move_detect/edge/config.py
유창욱 c3227c179c fix(security,edge,test): close the confirmed audit backlog; add the guards that prevent recurrence
감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는
그것이 조용히 지나가게 만든 구조를 고친다.

보안
- CSP 도입. 인라인 2블록에 per-request nonce, unsafe-inline 없음. 빌드가 없다는 제약이
  nonce를 막지는 않는다. 보안 헤더를 .htaccess 단독에서 PHP로 옮겨 개발/납품이 같아진다.
- /logout에 CSRF. SameSite=Lax가 막아주지만, 사고 대응 중 운영자를 콘솔에서 튕겨내는 건
  이 제품에선 실제 가용성 공격이라 예외로 둘 이유가 없다.
- 잠긴 계정 423 → 401. 아무나 이름을 추측해 5회 실패로 잠근 뒤 423을 계정 실재 확인으로
  쓸 수 있었다. 잠금 사실은 감사 로그에 남고, 익명 호출자는 못 읽는다.

엣지
- 큐 flush에 회당 상한. 정전 복구 직후 2000건을 비우는 동안 감지가 멈추던 구간을 없앤다.
- 타이머 env 전수 검증. COOLDOWN_S=nan은 dedup을 무력화해 매 틱 발보시켰다.
- T2 프레임 스킵(INFER_EVERY). 스킵 틱은 프레임을 읽어 버퍼만 비우고 링버퍼에 넣지 않는다 —
  추론이 없으면 얼굴 박스도 없어 그대로 넣으면 증거 스냅샷에 미블러 얼굴이 실린다.
  T3(모션 게이트)는 의도적으로 미구현: 게이트가 틀리면 '움직이지 않는 쓰러진 작업자'에게
  추론이 안 돈다. 실영상 없이 임계값을 정할 수 없다.
- systemd 워치독(sd_notify) + 메모리 상한. Restart=always는 죽은 프로세스만 잡고
  '살아 있는데 멈춘' 상태는 못 잡는다. UVC 해상도·버퍼 고정(링버퍼 RSS가 웹캠 기본값에 좌우).

테스트 — 감사의 최대 갭
- PDO 통합 테스트가 pdo_sqlite 부재로 통째로 스킵되고 CI도 없어 사실상 한 번도 안 돌았다.
  `-d extension=pdo_sqlite`로 되살리고 리포트·히트맵·익스포트 집계 SQL을 최초로 실행한다.
- 얼굴 블러 테스트가 out is not None만 봤다 — blur()가 무동작이어도 통과. PIPA 대응의
  유일한 실행 가능한 증거였는데 아무것도 증명하지 못했다. 디테일 파괴를 단언하도록 교체.
- index.php 라우트 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트,
  CI 신설(스킵이 하나라도 있으면 실패).
- ArraySessionStore.regenerate()가 데이터를 지워 실제 session_regenerate_id(true)와
  달랐다. 더블이 실구현과 어긋난 자리 하나를 실제로 잡았다.

프런트
- 이벤트 목록 시그니처 재렌더 스킵 + 포커스 복원. aria-live 영역을 5초마다 통째로 다시
  만들어 스크린리더가 200행을 재낭독하고 키보드 포커스가 body로 날아갔다. 같은 패턴이
  device-tree.js에는 이미 있었다.
- 폴링 중첩 가드(느린 공유호스팅에서 응답 역전), 콜드스타트 실패 표시.

납품
- VERSION 신설, 두 산출물에 version/git_commit/built_at 스탬프. 스테일 번들의 SHA256SUMS는
  자기정합적이라 통과한다 — provenance 없이는 하류에서 탐지할 방법이 없다.
- deploy/README·env.example을 Hailo 단독 번들 실제와 일치(템플릿이 없는 ONNX를 가리켰다).
- 이벤트 보존 정책은 기본 0(영구). 산업안전 기록을 시스템이 임의로 지우지 않는다.

검증: pytest 251 / PHPUnit 162, 스킵 0. CSP nonce 결합은 실제 렌더 경로로 확인.
브라우저 실렌더는 MySQL이 필요해 이 환경에서 미수행.
2026-08-07 23:31:53 +09:00

140 lines
6.4 KiB
Python

import math
from dataclasses import dataclass
from edge.detectors.fall import FallConfig
from edge.detectors.immobility import ImmobilityConfig
from edge.detectors.ghost import GhostConfig
from edge.detectors.zone import ZoneConfig
from edge.engine import EngineConfig
@dataclass
class Config:
device_id: str
camera_id: str
cafe24_url: str
secret: str
fall: FallConfig
immobility: ImmobilityConfig
ghost: GhostConfig
zone: ZoneConfig
engine: EngineConfig
# Tier-2 degradation knob: run pose on every Nth tick (1 = every tick, the T1 target).
# Raise this when a device cannot sustain full-rate inference across all its cameras —
# it trades reaction granularity for keeping every camera online, which beats going blind
# on one. See docs/hardware-bringup.md.
infer_every: int = 1
def _f(env: dict, key: str, default: float) -> float:
return float(env[key]) if key in env else default
def _positive(env: dict, key: str, default: float) -> float:
"""A timer/threshold knob that must be a finite positive number.
Every one of these feeds a `now - started >= limit` comparison. NaN makes that comparison
ALWAYS False and 0/negative makes it ALWAYS True, so a typo in edge.env does not crash —
it silently turns a detector off, or turns it into a permanent alarm. COOLDOWN_S=nan is the
worst case: dedup stops suppressing and every tick re-fires the same event. Reject at
startup, where the operator still sees the message, rather than in the field.
"""
val = _f(env, key, default)
if not math.isfinite(val) or val <= 0:
raise ValueError(f"{key} must be a finite positive number, got {val!r}")
return val
def frame_interval_from_env(env: dict) -> float:
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
systemd restart loop (Restart=always + StartLimitIntervalSec=0)."""
raw = env.get("TARGET_FPS", "12")
try:
fps = float(raw)
except (TypeError, ValueError):
raise ValueError(f"TARGET_FPS={raw!r} is not a number")
if fps <= 0:
raise ValueError(f"TARGET_FPS={raw!r} must be > 0")
return 1.0 / fps
def queue_db_path(env: dict) -> str:
"""Absolute sqlite path for the offline queue. A relative 'queue.db' resolves against the
unit's read-only WorkingDirectory under ProtectSystem=strict and cannot be created ->
crash loop. Default to the systemd StateDirectory, the one writable path."""
return env.get("QUEUE_DB") or "/var/lib/move-detect/queue.db"
# Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real
# provisioned value. A unit left at any of these uploads nothing while looking healthy.
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
# Per-device secrets issued by the server are HMAC-derived (long hex); anything shorter is a
# stub left in the template, not a real key.
_MIN_SECRET_LEN = 16
def require_upload_config(cfg: Config) -> None:
"""Fail LOUD at startup when the daemon cannot possibly deliver events.
load_config defaults cafe24_url and secret to '' so unit tests can build a Config from a
minimal env — but a real daemon with either blank, left at a ship-time placeholder, or on
plaintext http runs perfectly green while every upload fails (a bad/empty URL makes requests
raise, which PostClient catches and returns False; http is refused by allow_redirects=False),
and the offline queue grows until the SD card is full. The operator sees a healthy unit and
an empty dashboard, or worker snapshots leave the Pi in plaintext. Refuse to start instead.
"""
problems: list[str] = []
url = cfg.cafe24_url.strip()
secret = cfg.secret.strip()
if not url:
problems.append("CAFE24_URL not set")
elif not url.lower().startswith("https://"):
problems.append(f"CAFE24_URL must be https:// (got {url!r}) — plaintext would leak "
"worker snapshots and the .htaccess redirect is not followed")
elif any(m in url.lower() for m in _PLACEHOLDER_MARKERS):
problems.append(f"CAFE24_URL is still a template placeholder ({url!r})")
if not secret:
problems.append("DEVICE_SECRET not set")
elif any(m in secret.lower() for m in _PLACEHOLDER_MARKERS):
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
elif len(secret) < _MIN_SECRET_LEN:
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
"per-device secret issued by the server, not a stub")
if problems:
raise ValueError(
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
"but upload nothing. CAFE24_URL must include the app path, e.g. "
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the server."
)
def load_config(env: dict) -> Config:
camera_id = env.get("CAMERA_ID", "cam-01")
# Per-second stillness threshold (units/second), shared by fall no-motion and immobility.
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
motion_thr = _positive(env, "MOTION_THR", 0.25)
fall = FallConfig(
prone_confirm_s=_positive(env, "FALL_PRONE_S", 3.0),
no_motion_s=_positive(env, "FALL_NO_MOTION_S", 10.0),
motion_thr=motion_thr,
)
immobility = ImmobilityConfig(immobile_s=_positive(env, "IMMOBILE_S", 120.0),
motion_thr=motion_thr)
ghost = GhostConfig(reappear_s=_positive(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_positive(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_positive(env, "COOLDOWN_S", 30.0))
infer_every = int(_positive(env, "INFER_EVERY", 1.0))
return Config(
infer_every=infer_every,
device_id=env.get("DEVICE_ID", "pi-unknown"),
camera_id=camera_id,
cafe24_url=env.get("CAFE24_URL", ""),
secret=env.get("DEVICE_SECRET", ""),
fall=fall, immobility=immobility, ghost=ghost, zone=zone, engine=engine,
)