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

5.8 KiB

name description
iot-airgap-delivery 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.