감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는 그것이 조용히 지나가게 만든 구조를 고친다. 보안 - 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이 필요해 이 환경에서 미수행.
140 lines
6.4 KiB
Python
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,
|
|
)
|