POSA_LEAKSMS/docs/claude-config/iot-airgap-delivery.SKILL.md
cw2007 90b93f818c chore(deploy+docs): 노드 번들 생성기·하드닝 갱신 + 설계문서
- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉)
- tools/harden_pi.sh: 하드닝 스크립트 갱신
- docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서
- docs/claude-config: IoT 납품·검증 스킬/룰 참조본
- review/: 프로젝트 리뷰·UI v2 목업
2026-07-19 10:48:09 +09:00

90 lines
5.8 KiB
Markdown

---
name: iot-airgap-delivery
description: Use when working on IoT sensor systems with air-gapped deploy bundles (Pi nodes + shared-hosting PHP server) — covers derived-key auth, bundle regeneration workflow, 403/WAF debugging, display render verification, and pre-delivery audit. Trigger on deploy bundle work, HMAC key mismatch/403 errors, Pi display rendering, or "납품" (delivery) prep.
---
# IoT Air-gap Delivery Workflow
Battle-tested workflow for IoT systems shaped like: sensor nodes (Raspberry Pi / STM32)
→ shared-hosting PHP server (cafe24-style) → optional mini display node, deployed as
self-contained offline bundles generated by a bundle-generator script.
## 1. Key architecture (get this right first — most repeated confusion)
- **API_KEY** = master secret. Lives ONLY in: server `config.local.php` + build machine
`deploy/.secrets/api_key.txt`. Never on leaf devices.
- **Derived keys** = `HMAC-SHA256(message=<label>, key=API_KEY)`:
- `STATUS_KEY = hash_hmac('sha256', 'status', API_KEY)` → display node only
- `CRON_SECRET = hash_hmac('sha256', 'cron', API_KEY)` → cron HTTP access
- The server stores NO derived keys — it recomputes them per request and compares with
`hash_equals`. So "server-side status key" does not exist as a stored value.
- To print the expected derived key on the server:
`php -r 'require "config.php"; echo hash_hmac("sha256","status",API_KEY),"\n";'`
- NEVER put the same value in both API_KEY and a derived-key slot. They are different values.
- Regenerating `api_key.txt` breaks EVERY deployed node. The generator must reuse an
existing secret file; verify the printed key prefix is unchanged after regeneration.
## 2. Bundle workflow (source is truth)
1. Edit source files only (never hand-edit files inside `deploy/`).
2. Re-run the generator (e.g. `python tools/make_deploy_bundles.py`).
3. Confirm output reuses the existing API_KEY (prefix printed unchanged).
4. Deploy: server bundle → hosting upload; node bundle → copy to Pi →
`sudo systemctl restart <service>`.
5. `deploy/` must be gitignored (contains live secrets); remind to back up
`deploy/.secrets/` offline — losing it orphans every deployed node.
## 3. 403 / auth-failure debugging ladder (in order)
1. Distinguish WHO rejected: `curl -sS -i -H "X-<Key-Header>: <key>" <url>`
- JSON error body (`{"error":"forbidden"}`) → app rejected → key mismatch.
- HTML 403 → Apache/WAF rejected → NOT a key problem.
2. Key mismatch: print server-side derived key (see §1), diff against device config byte-for-byte
(watch for pasted whitespace/newlines).
3. curl works but device fails with SAME key →
a. Service not restarted after config edit (config imported once at process start).
b. Service loads a DIFFERENT config file (check systemd `WorkingDirectory`/`ExecStart`,
grep the actual file the service imports).
c. **WAF blocks default `python-requests/x.y` User-Agent** (very common on shared hosting;
curl's UA passes). Fix: always send a custom UA (`"<AppName>/2.0"`) in every
`requests` call. Reproduce with `curl -A "python-requests/2.31.0"`.
4. 503 `server_not_configured` → server API_KEY still default → upload real config.local.php.
5. Placeholder junk keys (e.g. `wnegfjk...`) in a config = never-configured file; find which
file the process actually loads before editing anything.
## 4. Display node changes (small TFT, Pillow rendering)
- Any render-code change: verify OFF-DEVICE first. Write a scratchpad script that stubs
`config` + driver modules via `sys.modules`, feeds synthetic data (including worst cases:
all-sections node, missing history, single data point, extreme values), saves PNGs at the
panel's exact resolution.
- Read the PNGs back (vision) to check: no overlapping text, no clipping at panel edges,
no dead zones; then send previews to the user via SendUserFile before deploying.
- Layout: slot-based adaptive layout (only sections the node type needs, evenly divided)
beats fixed y-coordinates. Guard: clamp page index when sensor count shrinks; fall back
to a smaller font when a formatted value exceeds panel width.
- SPI display init fail `No such file or directory` → `/dev/spidev*` missing → enable SPI
(`sudo raspi-config nonint do_spi 0`) + reboot; then check user in `spi,gpio` groups.
## 5. Server API endpoints (shared hosting)
- Poll-heavy endpoints (device polls every ~30s): every time-window query MUST ride an
index. Composite `(sensor_id, measured_at)` needs a `sensor_id` predicate — loop
per-sensor with a prepared statement instead of one global range scan.
- Cap unbounded result sets (`LIMIT`) — a flapping sensor must not inflate the JSON payload.
- Wrap the whole handler in try/catch → generic `{"error":"internal"}` + `error_log`;
set `ini_set('display_errors','0')` centrally (shared hosting often ships On).
- Rate-limit per IP (file-based fixed window, LOCK_EX, same pattern as login throttle);
fail-open on storage errors so monitoring devices never die from the throttle itself.
- Guard optional tables with a `table_exists` helper before querying.
- Additive JSON only: add keys, never rename/remove — old deployed clients keep working.
## 6. Pre-delivery audit (납품 전)
1. Launch TWO parallel review agents: security-reviewer + code-reviewer, scoped to the
changed surfaces. Verify each finding against actual code before acting.
2. Always check FIRST: real secret files (`config_local.py`, `config.local.php`) covered by
`.gitignore` AND never committed (`git log --all --full-history -- <path>`); rotate if leaked.
3. Fix by severity: CRITICAL (secrets/injection) → HIGH (perf/full scans) → MEDIUM
(error disclosure, unbounded payloads) → LOW (render edge cases).
4. `php -l` every touched PHP file; re-run render verification; regenerate bundles; then commit.