From 2ea0420de7e8e1a0a9148a40b52fee2ca6981a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 17 Jul 2026 19:24:42 +0900 Subject: [PATCH] fix(delivery): resolve codex + workflow review findings across edge/cloud/deploy Two independent adversarial reviews (codex CLI + a 6-dimension verification workflow) of the delivery-hardening branch surfaced ~40 findings; this closes the confirmed correctness/security/deploy blockers plus a codex re-review pass. edge: - geometry: track_displacement per-second threshold recalibrated (0.02->0.25, env-tunable via MOTION_THR, validated finite/positive); confidence-filter keypoints with a bbox-center fallback so hallucinated points don't fake motion - fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident (freeze + reset recovery timer so recovery needs a fresh confident streak); baseline height only seeded from trusted frames - queue: PostClient.send() returns a tri-state SendResult; only definite 4xx count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx) never deletes queued safety events; legacy rows persist their minted uuid - config: reject placeholder / http / short DEVICE_SECRET at startup - zones: warn+skip legacy pixel-space polygons that would silently match nothing cloud: - zone endpoints wired into P3-2b site-scope (cross-site read/write/delete closed) + role gate on list; catch narrowed so DB faults surface as 500 - snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan) - ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE (event_id, content_hash) makes recovery idempotent under retry/race - event list cursor carries full-precision occurred_at (no float rounding at the page boundary); string keyset end-to-end - SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap - runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit reap fails open deploy: - offline bundle hashes EVERY file (fatal on missing/failed/injected; models in SBOM; arch in tarball name; REQUIRE_SIGNATURE option) - installer verifies before any mutation, Hailo arch preflight before install, atomic src swap, backend reconcile preserves a working operator choice - systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert) Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4 skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings + fixes) with the second-pass findings folded in. --- cloud/deploy/README-UPLOAD.md | 13 ++ cloud/migrations/2026-07-17-p1-9-p3-2b.sql | 98 ++++++++++++ cloud/public/assets/app.js | 6 +- cloud/public/index.php | 2 +- cloud/schema.sql | 37 +++-- cloud/src/Dashboard/EventListController.php | 21 ++- cloud/src/Dashboard/ZoneConfigController.php | 51 +++++- cloud/src/Ingest/IngestController.php | 37 ++++- cloud/src/Repository/EventRepository.php | 2 +- .../src/Repository/Pdo/PdoEventRepository.php | 11 +- .../Pdo/PdoRateLimiterRepository.php | 12 +- .../Repository/Pdo/PdoSnapshotRepository.php | 13 +- .../src/Repository/Pdo/PdoZoneRepository.php | 16 +- .../src/Repository/ZoneOwnershipException.php | 12 ++ cloud/src/Repository/ZoneRepository.php | 2 + cloud/src/Security/SiteScope.php | 11 +- cloud/tests/DashboardTest.php | 14 ++ cloud/tests/IngestControllerTest.php | 18 +++ cloud/tests/SiteScopeTest.php | 31 ++++ cloud/tests/Support/InMemoryRepositories.php | 15 +- cloud/tests/ZoneConfigTest.php | 105 ++++++++++-- deploy/bundle/build_offline_bundle.sh | 52 ++++-- deploy/bundle/install_offline.sh | 150 ++++++++++++++---- deploy/systemd/move-detect-edge.service | 11 +- edge/alert.py | 30 +++- edge/config.py | 60 +++++-- edge/detectors/fall.py | 16 +- edge/detectors/immobility.py | 2 +- edge/geometry.py | 23 ++- edge/queue.py | 38 +++-- edge/zones.py | 21 +++ tests/test_alert.py | 27 +++- tests/test_config.py | 31 +++- tests/test_deploy_artifacts.py | 17 +- tests/test_multicam.py | 6 +- tests/test_queue.py | 61 ++++++- tests/test_zones.py | 28 +++- 37 files changed, 932 insertions(+), 168 deletions(-) create mode 100644 cloud/migrations/2026-07-17-p1-9-p3-2b.sql create mode 100644 cloud/src/Repository/ZoneOwnershipException.php create mode 100644 cloud/tests/SiteScopeTest.php diff --git a/cloud/deploy/README-UPLOAD.md b/cloud/deploy/README-UPLOAD.md index 2f10963..c248956 100644 --- a/cloud/deploy/README-UPLOAD.md +++ b/cloud/deploy/README-UPLOAD.md @@ -65,12 +65,25 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다. ## 3. DB 만들기 +### 처음 설치(빈 DB) + 1. 카페24 관리자 → **phpMyAdmin** 열기 2. 이 앱용 DB 선택 3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행 4. 표 10개가 생겼는지 확인 5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다 +### 이미 쓰던 DB 업그레이드(기존 데이터 있음) + +> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다. +> 대신 **마이그레이션**을 실행하세요. + +1. phpMyAdmin → 해당 DB → **SQL** 탭 +2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행 + (site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함) +3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인 +4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다 + --- ## 4. HTTPS 켜기 diff --git a/cloud/migrations/2026-07-17-p1-9-p3-2b.sql b/cloud/migrations/2026-07-17-p1-9-p3-2b.sql new file mode 100644 index 0000000..bc59222 --- /dev/null +++ b/cloud/migrations/2026-07-17-p1-9-p3-2b.sql @@ -0,0 +1,98 @@ +-- =========================================================================== +-- Migration: P1-9 (event dedup) + P3-2b (site-scoped access control) +-- + retention / rate-limit reap indexes +-- +-- Apply ONCE to an EXISTING move_detect database. A FRESH install already has +-- all of this via schema.sql — do NOT run this on a database created from the +-- current schema.sql. +-- +-- RUN IN THREE PARTS, IN ORDER. Part C (the events.site backfill) MUST run +-- AFTER Part B has set devices.site — otherwise it matches 0 rows and every +-- historical event stays site=NULL and DISAPPEARS from a scoped operator's +-- dashboard the moment you scope a user. Do not skip Part C. +-- +-- How to run: phpMyAdmin -> your DB -> "SQL" tab. MySQL (unlike MariaDB) has no +-- "ADD COLUMN IF NOT EXISTS": if a column/index already exists, that ONE +-- statement errors — skip it and run the rest. +-- =========================================================================== + +-- =========================================================================== +-- PART A — schema changes (safe to run as-is) +-- =========================================================================== + +-- A1) devices.site — the access-control site each device belongs to. +ALTER TABLE devices + ADD COLUMN site VARCHAR(64) NULL AFTER name, + ADD KEY ix_devices_site (site); + +-- A2) events.site — denormalized from devices.site at ingest, for the dashboard's +-- site-scoped event list. +ALTER TABLE events + ADD COLUMN site VARCHAR(64) NULL AFTER device_id, + ADD KEY ix_events_site_occurred_at (site, occurred_at); + +-- A3) events.event_uuid — edge-generated idempotency key (P1-9). The UNIQUE key +-- makes a re-sent event collide (dedup) instead of duplicating. MySQL allows +-- multiple NULLs in a UNIQUE index, so legacy rows without a uuid are fine. +ALTER TABLE events + ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id, + ADD UNIQUE KEY uq_events_uuid (event_uuid); + +-- A4) event_snapshots.created_at index — PdoSnapshotRepository::purgeOlderThan() +-- runs "SELECT ... WHERE created_at < ?" inside live ingest requests. Without +-- this index it full-scans the snapshot table on every triggered purge. +ALTER TABLE event_snapshots + ADD KEY ix_event_snapshots_created (created_at); + +-- A5) event_snapshots (event_id, content_hash) UNIQUE — lets the ingest dedup path +-- re-store an event's MISSING snapshots idempotently (a re-sent event carries +-- the same re-encoded bytes = same content_hash, so a duplicate/racing store +-- collides instead of writing a second copy). If this fails on "Duplicate +-- entry", the table already has duplicate (event_id, content_hash) rows — +-- remove the dups, then re-run this one statement. +ALTER TABLE event_snapshots + ADD UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash); + +-- A6) rate_limits.window_start index — the reap "DELETE ... WHERE window_start < ?" +-- cannot use the (rate_key, window_start) PRIMARY KEY (window_start is the +-- trailing column), so it full-scans and locks rows on the pre-auth hot path. +ALTER TABLE rate_limits + ADD KEY ix_rate_limits_window (window_start); + +-- =========================================================================== +-- PART B — provisioning (EDIT the values for your deployment, then run). +-- Assign every device to its site here; the backfill in Part C reads it. +-- =========================================================================== +-- UPDATE devices SET site = 'factory-a' WHERE device_id = 'pi-1'; +-- UPDATE devices SET site = 'factory-b' WHERE device_id = 'pi-2'; +-- -- ... one row per device ... + +-- =========================================================================== +-- PART C — backfill historical events from their device's site. +-- RUN THIS ONLY AFTER Part B. Re-run it whenever you add/relabel a +-- device's site so its past events reappear for scoped operators. +-- =========================================================================== +UPDATE events e + JOIN devices d ON d.device_id = e.device_id + SET e.site = d.site + WHERE e.site IS NULL AND d.site IS NOT NULL; + +-- =========================================================================== +-- PART D — scope dashboard users (run AFTER Part C, so scoped users immediately +-- see their backfilled history). NULL = global operator; '' is NOT +-- global (matches nothing — an accidental blank fails closed). +-- =========================================================================== +-- UPDATE users SET site_scope = 'factory-a' WHERE username = 'operator-a'; + +-- ---- Verification (run these; each must confirm the object/state) ----------- +-- SHOW COLUMNS FROM devices LIKE 'site'; +-- SHOW COLUMNS FROM events LIKE 'site'; +-- SHOW COLUMNS FROM events LIKE 'event_uuid'; +-- SHOW INDEX FROM events WHERE Key_name = 'uq_events_uuid'; +-- SHOW INDEX FROM event_snapshots WHERE Key_name = 'ix_event_snapshots_created'; +-- SHOW INDEX FROM event_snapshots WHERE Key_name = 'uq_event_snapshots_event_hash'; +-- SHOW INDEX FROM rate_limits WHERE Key_name = 'ix_rate_limits_window'; +-- Events still unscoped after Part C (expect 0, or only rows whose device has no +-- site assigned yet — go back and finish Part B for those devices): +-- SELECT COUNT(*) FROM events e JOIN devices d ON d.device_id = e.device_id +-- WHERE e.site IS NULL AND d.site IS NOT NULL; diff --git a/cloud/public/assets/app.js b/cloud/public/assets/app.js index a974a73..7aea0a5 100644 --- a/cloud/public/assets/app.js +++ b/cloud/public/assets/app.js @@ -190,7 +190,11 @@ } function loadEvents() { - return fetch(B + '/api/events', { headers: { Accept: 'application/json' } }) + // Request the full server page cap (MAX_LIMIT=200), not the default 50: the audible alarm + // and the "unacknowledged first" pinning only consider the rows fetched here. During an event + // burst, a default page of 50 could push an unacknowledged critical off the fetched set, and + // updateAlarm() would then clear the alarm while a critical fall is still unacknowledged. + return fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } }) .then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; diff --git a/cloud/public/index.php b/cloud/public/index.php index 88ac631..cb92251 100644 --- a/cloud/public/index.php +++ b/cloud/public/index.php @@ -92,7 +92,7 @@ $csrfCtl = new CsrfController(); $list = new EventListController($events, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess); -$zoneCtl = new ZoneConfigController($zones, $audit, $sess); +$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess); $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; diff --git a/cloud/schema.sql b/cloud/schema.sql index e24368e..31931f8 100644 --- a/cloud/schema.sql +++ b/cloud/schema.sql @@ -158,7 +158,12 @@ CREATE TABLE event_snapshots ( created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (snapshot_id), UNIQUE KEY uq_event_snapshots_uuid (file_uuid), + -- Idempotent snapshot recovery: a re-sent event carries the SAME re-encoded bytes (same + -- content_hash), so this makes a duplicate/racing store collide instead of writing a second + -- copy. Lets the dedup path safely re-store missing evidence without duplication. + UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash), KEY ix_event_snapshots_event (event_id), + KEY ix_event_snapshots_created (created_at), -- retention purge scans WHERE created_at < ? CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -206,7 +211,10 @@ CREATE TABLE rate_limits ( rate_key VARCHAR(190) NOT NULL, window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket count INT UNSIGNED NOT NULL DEFAULT 0, - PRIMARY KEY (rate_key, window_start) + PRIMARY KEY (rate_key, window_start), + -- The reap `DELETE ... WHERE window_start < ?` cannot use the PK (window_start is its trailing + -- column), so give it its own index to avoid a locking full-scan on the pre-auth hot path. + KEY ix_rate_limits_window (window_start) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- @@ -228,19 +236,18 @@ CREATE TABLE audit_log ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- =========================================================================== --- MIGRATIONS for an EXISTING database (fresh installs already have the columns --- above). Apply once via phpMyAdmin, then set each device's site with: +-- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above): +-- do NOT copy the statements below by hand — they are SQL comments and will not +-- run. Apply the RUNNABLE migration instead: +-- +-- cloud/migrations/2026-07-17-p1-9-p3-2b.sql +-- +-- It adds devices.site / events.site / events.event_uuid, the retention and +-- rate-limit reap indexes, AND backfills events.site from devices.site (without +-- that backfill, historical events vanish for a scoped operator). Run it once in +-- phpMyAdmin BEFORE deploying code that reads these columns, then provision: -- UPDATE devices SET site = 'factory-a' WHERE device_id = 'pi-1'; --- and scope a dashboard user with: --- UPDATE users SET site_scope = 'factory-a' WHERE username = '...'; --- A user with site_scope = NULL is a global operator (sees every site). --- --------------------------------------------------------------------------- --- P3-2b (site-scoped access control): --- ALTER TABLE devices ADD COLUMN site VARCHAR(64) NULL AFTER name, --- ADD KEY ix_devices_site (site); --- ALTER TABLE events ADD COLUMN site VARCHAR(64) NULL AFTER device_id, --- ADD KEY ix_events_site_occurred_at (site, occurred_at); --- P1-9 (ingest idempotency / dedup): --- ALTER TABLE events ADD COLUMN event_uuid CHAR(32) NULL AFTER event_id, --- ADD UNIQUE KEY uq_events_uuid (event_uuid); +-- UPDATE users SET site_scope = 'factory-a' WHERE username = 'operator-a'; +-- A user with site_scope = NULL is a global operator; '' is NOT global (matches +-- nothing — a blank scope fails closed). -- =========================================================================== diff --git a/cloud/src/Dashboard/EventListController.php b/cloud/src/Dashboard/EventListController.php index e67b622..7b720bc 100644 --- a/cloud/src/Dashboard/EventListController.php +++ b/cloud/src/Dashboard/EventListController.php @@ -21,8 +21,11 @@ final class EventListController [$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor')); // Site-scope access control (P3-2b): a scoped operator sees only their site's events; a // null/empty scope is a global operator (no site filter). Pushed into the SQL WHERE. + // Pass the scope through as-is: only a genuinely null scope is global (no filter). An + // empty-string scope is a misconfiguration that must match NOTHING (fail closed), not be + // silently promoted to global by collapsing it to null here. $scope = $this->sess->get('site_scope'); - $site = (is_string($scope) && $scope !== '') ? $scope : null; + $site = is_string($scope) ? $scope : null; $rows = $this->events->page($limit, $severity, $beforeTs, $beforeId, $site); // Advertise a next cursor only on a full page — a short page means we hit the end. @@ -30,7 +33,13 @@ final class EventListController $next = null; if (count($rows) === $limit && $rows !== []) { $last = $rows[count($rows) - 1]; - $next = $last['occurred_at'] . ':' . $last['event_id']; + // occurred_at arrives as a PHP float (native PDO fetch + mapRow cast). Plainly + // stringifying it uses the `precision` ini (14 sig figs), which ROUNDS a microsecond + // epoch and shifts the keyset boundary — the page-edge row then duplicates or is + // skipped. Format at full microsecond precision (the edge's own source resolution) so + // the value round-trips to the exact same DOUBLE when MySQL parses the bound string. + $ts = rtrim(rtrim(sprintf('%.6f', (float)$last['occurred_at']), '0'), '.'); + $next = $ts . ':' . $last['event_id']; } return Response::json(['events' => $rows, 'next_cursor' => $next], 200); } @@ -41,12 +50,16 @@ final class EventListController return max(1, min(self::MAX_LIMIT, (int)$raw)); } - /** @return array{0: ?float, 1: ?int} parsed (occurred_at, event_id), or null/null if absent/malformed. */ + /** @return array{0: ?string, 1: ?int} parsed (occurred_at, event_id), or null/null if absent/malformed. */ private static function parseCursor(string $raw): array { if ($raw === '' || !str_contains($raw, ':')) return [null, null]; [$ts, $id] = explode(':', $raw, 2); if (!is_numeric($ts) || $id === '' || !ctype_digit($id)) return [null, null]; - return [(float)$ts, (int)$id]; + // Keep occurred_at as its exact numeric STRING. Casting to float here would round it to + // PHP's ~14 significant digits (the `precision` ini) on the way back into the query, + // shifting the keyset boundary so the page-edge row is duplicated or skipped. MySQL parses + // the string to the same DOUBLE the column holds, so string end-to-end is lossless. + return [$ts, (int)$id]; } } diff --git a/cloud/src/Dashboard/ZoneConfigController.php b/cloud/src/Dashboard/ZoneConfigController.php index 54d34ab..1795d3f 100644 --- a/cloud/src/Dashboard/ZoneConfigController.php +++ b/cloud/src/Dashboard/ZoneConfigController.php @@ -4,16 +4,17 @@ namespace App\Dashboard; use App\Http\{Request, Response}; use App\Auth\{SessionStore, Rbac}; -use App\Security\Csrf; +use App\Security\{Csrf, SiteScope}; use App\Geometry\Polygon; -use App\Repository\{ZoneRepository, AuditRepository}; +use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, ZoneOwnershipException}; final class ZoneConfigController { private const LEVELS = ['warning','restricted','pinch']; public function __construct( - private ZoneRepository $zones, private AuditRepository $audit, private SessionStore $sess + private ZoneRepository $zones, private DeviceRepository $devices, + private AuditRepository $audit, private SessionStore $sess ) {} private function guard(string $perm): ?Response @@ -23,10 +24,25 @@ final class ZoneConfigController return null; } + // A scoped operator may only see/touch zones on their own site (P3-2b). A zone carries no site + // column; its site is its owning device's (devices.site via zones.device_id). A global + // (null-scope) caller passes everything. Mirrors EventDetailController::outOfScope — callers + // return 404 (never 403) so a cross-site id is indistinguishable from a non-existent one. + private function inScope(string $deviceId): bool + { + $scope = $this->sess->get('site_scope'); + return SiteScope::allows(is_string($scope) ? $scope : null, $this->devices->siteFor($deviceId)); + } + public function list(Request $req, array $params): Response { - if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); - return Response::json(['zones' => $this->zones->forCamera((string)($params['camera'] ?? ''))], 200); + // A role gate (not just "authenticated"): without it any logged-in account could enumerate + // another site's hazard-zone geometry. view_events is held by every dashboard role. + if ($g = $this->guard('view_events')) return $g; + $zones = $this->zones->forCamera((string)($params['camera'] ?? '')); + // Drop any zone whose owning device is outside the caller's site scope. + $zones = array_values(array_filter($zones, fn($z) => $this->inScope((string)($z['device_id'] ?? '')))); + return Response::json(['zones' => $zones], 200); } public function save(Request $req): Response @@ -41,17 +57,30 @@ final class ZoneConfigController if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) { return Response::json(['error' => 'invalid_zone'], 400); } + $deviceId = (string)($b['device_id'] ?? ''); + // Scope BEFORE the write: a scoped operator must not create or overwrite a zone on another + // site's device. For an update, the EXISTING zone's device must also be in scope, so a + // caller cannot re-home a cross-site zone under one of their own device ids. + if (!$this->inScope($deviceId)) return Response::json(['error' => 'not_found'], 404); + $zoneId = $b['zone_id'] ?? null; + if (is_string($zoneId) && $zoneId !== '') { + $existing = $this->zones->find($zoneId); + if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) { + return Response::json(['error' => 'not_found'], 404); + } + } try { $zoneId = $this->zones->save([ 'zone_id' => $b['zone_id'] ?? null, - 'device_id' => (string)($b['device_id'] ?? ''), + 'device_id' => $deviceId, 'camera_id' => (string)($b['camera_id'] ?? ''), 'level' => $level, 'polygon_json' => json_encode($polygon), ]); - } catch (\RuntimeException $e) { + } catch (ZoneOwnershipException $e) { // Editing a zone with a device_id/camera_id that does not own it (or a zone deleted - // concurrently) is a CLIENT error, not a server fault — return 400, not a 500. + // concurrently) is a CLIENT error — 400. A real \PDOException is NOT caught here (it no + // longer matches this narrowed type), so DB faults surface as 500 instead of hiding. return Response::json(['error' => 'invalid_zone'], 400); } $this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]); @@ -65,6 +94,12 @@ final class ZoneConfigController return Response::json(['error' => 'csrf'], 403); } $id = (string)($params['id'] ?? ''); + // Resolve the zone and scope-check its owning device BEFORE deleting, so a scoped operator + // cannot delete another site's zone by id. Unknown id and cross-site id both return 404. + $existing = $this->zones->find($id); + if ($existing === null || !$this->inScope((string)($existing['device_id'] ?? ''))) { + return Response::json(['error' => 'not_found'], 404); + } if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404); $this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []); return Response::json(['ok' => true], 200); diff --git a/cloud/src/Ingest/IngestController.php b/cloud/src/Ingest/IngestController.php index 518fe91..1320187 100644 --- a/cloud/src/Ingest/IngestController.php +++ b/cloud/src/Ingest/IngestController.php @@ -77,7 +77,7 @@ final class IngestController // instead of inserting a duplicate alert. $uuid = $clean['event_uuid'] ?? null; if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) { - return Response::json(['event_id' => $existing, 'dedup' => true], 200); + return $this->dedupResponse($existing, $payload); } try { $eventId = $this->events->insert($clean); @@ -85,7 +85,7 @@ final class IngestController // Lost the insert race to a concurrent retry with the same uuid (the UNIQUE key // fired). That is a successful dedup, not a 500 — return the winning row. if ($uuid !== null && ($existing = $this->events->idByUuid($uuid)) !== null) { - return Response::json(['event_id' => $existing, 'dedup' => true], 200); + return $this->dedupResponse($existing, $payload); } throw $e; } @@ -101,6 +101,29 @@ final class IngestController return Response::json(['event_id' => $eventId], 201); } + /** + * A dedup hit means the event row already exists. But the first attempt may have died AFTER + * the event insert and BEFORE its snapshots were stored (php-fpm timeout / OOM mid re-encode), + * leaving the event with zero evidence images. The edge re-sends the identical payload WITH + * the snapshot bytes on every retry, so when the stored event has no snapshots and this retry + * carries some, store them now before returning — otherwise the worker photo is lost forever + * (the edge stops retrying once it sees the 200). Idempotent: skipped once snapshots exist. + */ + private function dedupResponse(int $existing, array $payload): Response + { + $snaps = $payload['snapshots'] ?? []; + // Recover missing evidence: if the stored event has FEWER snapshots than this retry + // carries, the first attempt died mid-snapshot (all or some lost). Re-store — the UNIQUE + // (event_id, content_hash) index makes already-present snapshots collide harmlessly, so + // this fills only the gaps, never duplicates, and is safe if two retries race. + if (is_array($snaps) && count($snaps) > count($this->snapshots->forEvent($existing))) { + foreach ($snaps as $b64) { + $this->storeSnapshot($existing, (string)$b64); + } + } + return Response::json(['event_id' => $existing, 'dedup' => true], 200); + } + /** * Persist one snapshot: re-encode → write file → index in the DB, in that order so the * bytes exist on disk before a row can point at them. Failures are logged (never silently @@ -116,8 +139,14 @@ final class IngestController return; } $path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg'; - if (file_put_contents($path, $img['bytes']) === false) { - error_log("ingest: snapshot file write failed for event $eventId at $path"); + $bytes = (string)$img['bytes']; + $written = file_put_contents($path, $bytes); + // A short write (disk quota exhausted on shared hosting) returns fewer bytes than + // requested WITHOUT returning false. The truncated JPEG must never be indexed and served + // as evidence — remove it and bail. The event's 201 still stands. + if ($written === false || $written !== strlen($bytes)) { + if ($written !== false) @unlink($path); + error_log("ingest: snapshot file write failed/short for event $eventId at $path"); return; } try { diff --git a/cloud/src/Repository/EventRepository.php b/cloud/src/Repository/EventRepository.php index 86246d1..4cd8a7f 100644 --- a/cloud/src/Repository/EventRepository.php +++ b/cloud/src/Repository/EventRepository.php @@ -17,7 +17,7 @@ interface EventRepository * $site (when non-null) restricts the page to events whose denormalized site matches — * the SQL half of the site-scope access control (see App\Security\SiteScope). */ - public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId, ?string $site = null): array; + public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array; public function find(int $id): ?array; public function acknowledge(int $id, string $user): bool; } diff --git a/cloud/src/Repository/Pdo/PdoEventRepository.php b/cloud/src/Repository/Pdo/PdoEventRepository.php index a7ba436..8c39dd5 100644 --- a/cloud/src/Repository/Pdo/PdoEventRepository.php +++ b/cloud/src/Repository/Pdo/PdoEventRepository.php @@ -68,7 +68,7 @@ final class PdoEventRepository implements EventRepository return array_map(self::mapRow(...), $st->fetchAll()); } - public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId, ?string $site = null): array + public function page(int $limit, ?string $severity, ?string $beforeTs, ?int $beforeId, ?string $site = null): array { // Bind list as [value, PDO type] pairs, positional in SQL order. $where = []; @@ -83,12 +83,13 @@ final class PdoEventRepository implements EventRepository $binds[] = [$site, PDO::PARAM_STR]; } // Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id) - // on the (occurred_at DESC, event_id DESC) order. occurred_at is bound as a string so the - // DECIMAL comparison keeps full precision (no float round-trip in the driver). + // on the (occurred_at DESC, event_id DESC) order. occurred_at arrives as its exact numeric + // STRING (never round-tripped through a PHP float) and is bound as a string, so MySQL + // parses it to the same DOUBLE the column holds — the page-edge boundary stays exact. if ($beforeTs !== null && $beforeId !== null) { $where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))'; - $binds[] = [(string)$beforeTs, PDO::PARAM_STR]; - $binds[] = [(string)$beforeTs, PDO::PARAM_STR]; + $binds[] = [$beforeTs, PDO::PARAM_STR]; + $binds[] = [$beforeTs, PDO::PARAM_STR]; $binds[] = [$beforeId, PDO::PARAM_INT]; } $sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM events'; diff --git a/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php b/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php index 7a34ea2..b78db3f 100644 --- a/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php +++ b/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php @@ -31,9 +31,17 @@ final class PdoRateLimiterRepository implements RateLimiterRepository // Cron-less reap (Cafe24 has no scheduler): stale windows accumulate forever. Prune a // bounded batch of windows older than the current one (kept a couple back for safety). + // Fail OPEN: this reap fires on the pre-auth hot path (every ingest/config/heartbeat), so + // a lock-wait timeout or deadlock (SQLSTATE 40001) under load must NOT turn the anti-flood + // guard into a 500 — the throttle exists to protect availability, not to break it. The + // count is already computed above; swallow reap failures and let the next hit retry. if (mt_rand(1, 100) === 1) { - $del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000'); - $del->execute([$windowStart - 2]); + try { + $del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000'); + $del->execute([$windowStart - 2]); + } catch (\PDOException $e) { + error_log('rate_limits reap skipped: ' . $e->getMessage()); + } } return $count; } diff --git a/cloud/src/Repository/Pdo/PdoSnapshotRepository.php b/cloud/src/Repository/Pdo/PdoSnapshotRepository.php index 277d4fe..3998654 100644 --- a/cloud/src/Repository/Pdo/PdoSnapshotRepository.php +++ b/cloud/src/Repository/Pdo/PdoSnapshotRepository.php @@ -77,11 +77,20 @@ final class PdoSnapshotRepository implements SnapshotRepository $ids = []; foreach ($rows as $r) { $path = rtrim($this->snapshotDir, '/\\') . '/' . $r['file_uuid'] . '.jpg'; - if (is_file($path)) { - @unlink($path); + // Only delete the DB row once its file is actually gone. If unlink() fails (perms, a + // lock, or a hosting-support restore that reassigned the owner), KEEP the row so the + // next purge retries it. Deleting the row while the JPEG survives makes the file + // permanently untrackable — the row-driven purge can never find it again — leaving a + // worker image on disk past the PIPA retention window, the exact leak this closes. + if (is_file($path) && @unlink($path) === false) { + error_log("snapshot purge: unlink failed for $path; keeping row for next purge"); + continue; } $ids[] = (int)$r['snapshot_id']; } + if (!$ids) { + return 0; + } $in = implode(',', array_fill(0, count($ids), '?')); $del = $this->pdo->prepare("DELETE FROM event_snapshots WHERE snapshot_id IN ($in)"); $del->execute($ids); diff --git a/cloud/src/Repository/Pdo/PdoZoneRepository.php b/cloud/src/Repository/Pdo/PdoZoneRepository.php index ea18d53..9c5285c 100644 --- a/cloud/src/Repository/Pdo/PdoZoneRepository.php +++ b/cloud/src/Repository/Pdo/PdoZoneRepository.php @@ -3,13 +3,23 @@ declare(strict_types=1); namespace App\Repository\Pdo; use App\Repository\ZoneRepository; +use App\Repository\ZoneOwnershipException; use PDO; -use RuntimeException; final class PdoZoneRepository implements ZoneRepository { public function __construct(private PDO $pdo) {} + public function find(string $zoneId): ?array + { + $st = $this->pdo->prepare( + 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE zone_id = ? LIMIT 1' + ); + $st->execute([$zoneId]); + $row = $st->fetch(); + return $row === false ? null : $row; + } + public function forDevice(string $deviceId): array { $st = $this->pdo->prepare( @@ -83,7 +93,7 @@ final class PdoZoneRepository implements ZoneRepository ); $owns->execute([$zoneId, $deviceId, $cameraId]); if ($owns->fetchColumn() === false) { - throw new RuntimeException('zone not found or not owned by the given device/camera'); + throw new ZoneOwnershipException('zone not found or not owned by the given device/camera'); } $st = $this->pdo->prepare( @@ -101,7 +111,7 @@ final class PdoZoneRepository implements ZoneRepository $stillExists = $this->pdo->prepare('SELECT 1 FROM zones WHERE zone_id = ? LIMIT 1'); $stillExists->execute([$zoneId]); if ($stillExists->fetchColumn() === false) { - throw new RuntimeException('zone was deleted concurrently'); + throw new ZoneOwnershipException('zone was deleted concurrently'); } } return $zoneId; diff --git a/cloud/src/Repository/ZoneOwnershipException.php b/cloud/src/Repository/ZoneOwnershipException.php new file mode 100644 index 0000000..59f977f --- /dev/null +++ b/cloud/src/Repository/ZoneOwnershipException.php @@ -0,0 +1,12 @@ +assertSame(1, $p2['events'][0]['event_id']); $this->assertNull($p2['next_cursor']); } + public function testCursorPreservesMicrosecondPrecision(): void { + // Two events whose occurred_at differ only at the microsecond. The cursor must round-trip + // that value exactly — the old float stringification (precision=14) rounded it and made + // the page-edge row reappear on the next page (duplicate) or vanish (skip). + $repo = new InMemoryEventRepository(); + $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123456]); // id 1 + $repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 1700000000.123457]); // id 2 + $c = new EventListController($repo, $this->authed()); + $p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1'], []))->body(), true); + $this->assertSame(2, $p1['events'][0]['event_id']); // newest first + $p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '1', 'cursor' => $p1['next_cursor']], []))->body(), true); + $this->assertCount(1, $p2['events']); + $this->assertSame(1, $p2['events'][0]['event_id']); // id 2 not duplicated, id 1 not skipped + } public function testAcknowledgeRequiresPermissionAndSetsUser(): void { $repo = new InMemoryEventRepository(); $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); diff --git a/cloud/tests/IngestControllerTest.php b/cloud/tests/IngestControllerTest.php index 5b9c8e9..b962ca9 100644 --- a/cloud/tests/IngestControllerTest.php +++ b/cloud/tests/IngestControllerTest.php @@ -45,6 +45,24 @@ final class IngestControllerTest extends TestCase $id = json_decode($res->body(), true)['event_id']; $this->assertCount(1, $snaps->forEvent($id)); // snapshot re-encoded + stored } + public function testDedupPathRestoresMissingSnapshots(): void { + // Simulate a first attempt that stored the event but died before its snapshots (php-fpm + // timeout mid re-encode): the event exists with ZERO evidence images. The edge re-sends + // the identical payload WITH the bytes; the dedup path must store them, not discard them. + [$c, $events, $snaps] = $this->make(); + $uuid = str_repeat('a', 32); + $first = $this->payload(); $first['event_uuid'] = $uuid; $first['snapshots'] = []; + $res1 = $c->handle($this->signedReq('sekret', $first, nonce: 'n1'), now: 1000, clientIp: '1.2.3.4'); + $this->assertSame(201, $res1->status()); + $id = json_decode($res1->body(), true)['event_id']; + $this->assertCount(0, $snaps->forEvent($id)); // no evidence yet + + $retry = $this->payload(); $retry['event_uuid'] = $uuid; // same key, now WITH snapshots + $res2 = $c->handle($this->signedReq('sekret', $retry, nonce: 'n2'), now: 1001, clientIp: '1.2.3.4'); + $this->assertSame(200, $res2->status()); + $this->assertTrue(json_decode($res2->body(), true)['dedup']); + $this->assertCount(1, $snaps->forEvent($id)); // evidence recovered on the retry + } public function testRejectsBadSignature(): void { [$c] = $this->make(); $req = $this->signedReq('WRONG', $this->payload()); diff --git a/cloud/tests/SiteScopeTest.php b/cloud/tests/SiteScopeTest.php new file mode 100644 index 0000000..26a3d59 --- /dev/null +++ b/cloud/tests/SiteScopeTest.php @@ -0,0 +1,31 @@ +assertTrue(SiteScope::isGlobal(null)); + $this->assertTrue(SiteScope::allows(null, 'factory-a')); // a global operator sees every site + $this->assertTrue(SiteScope::allows(null, null)); + } + + public function testEmptyStringScopeIsNotGlobal(): void + { + // A blank scope (an accidental '') must fail CLOSED — never be silently promoted to + // global access the way it was before this fix. + $this->assertFalse(SiteScope::isGlobal('')); + $this->assertFalse(SiteScope::allows('', 'factory-a')); + $this->assertFalse(SiteScope::allows('', null)); + } + + public function testScopedCallerSeesOnlyItsExactSite(): void + { + $this->assertTrue(SiteScope::allows('factory-a', 'factory-a')); + $this->assertFalse(SiteScope::allows('factory-a', 'factory-b')); + $this->assertFalse(SiteScope::allows('factory-a', null)); // an unscoped row is hidden from a scoped caller + } +} diff --git a/cloud/tests/Support/InMemoryRepositories.php b/cloud/tests/Support/InMemoryRepositories.php index 2c219f7..40903a7 100644 --- a/cloud/tests/Support/InMemoryRepositories.php +++ b/cloud/tests/Support/InMemoryRepositories.php @@ -35,15 +35,16 @@ final class InMemoryEventRepository implements EventRepository { if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev); return array_slice(array_values($r), 0, $limit); } - public function page(int $limit, ?string $sev, ?float $beforeTs, ?int $beforeId, ?string $site = null): array { + public function page(int $limit, ?string $sev, ?string $beforeTs, ?int $beforeId, ?string $site = null): array { $r = array_values($this->rows); if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev); if ($site !== null) $r = array_filter($r, fn($x) => ($x['site'] ?? null) === $site); // (occurred_at DESC, event_id DESC); events inserted without occurred_at sort as 0. usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]); if ($beforeTs !== null && $beforeId !== null) { - $r = array_filter($r, fn($x) => ($x['occurred_at'] ?? 0) < $beforeTs - || (($x['occurred_at'] ?? 0) == $beforeTs && $x['event_id'] < $beforeId)); + $bt = (float)$beforeTs; // cursor carries occurred_at as a string; compare numerically + $r = array_filter($r, fn($x) => ($x['occurred_at'] ?? 0) < $bt + || (($x['occurred_at'] ?? 0) == $bt && $x['event_id'] < $beforeId)); } return array_slice(array_values($r), 0, $limit); } @@ -52,7 +53,12 @@ final class InMemoryEventRepository implements EventRepository { } final class InMemorySnapshotRepository implements SnapshotRepository { public array $rows = []; public array $paths = []; public array $created = []; - public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); } + public function add(int $e, string $uuid, string $hash): void { + // Mirror the DB's UNIQUE (event_id, content_hash): a re-sent event's identical bytes + // collide instead of duplicating, so the dedup recovery path is idempotent. + foreach ($this->rows[$e] ?? [] as $r) { if ($r['content_hash'] === $hash) return; } + $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); + } public function forEvent(int $e): array { return $this->rows[$e] ?? []; } public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; } public function eventIdByUuid(string $u): ?int { @@ -82,6 +88,7 @@ final class InMemoryZoneRepository implements ZoneRepository { public array $zones = []; private int $seq = 0; public function forDevice(string $d): array { return array_values(array_filter($this->zones, fn($z) => ($z['device_id'] ?? '') === $d)); } public function forCamera(string $c): array { return array_values(array_filter($this->zones, fn($z) => $z['camera_id'] === $c)); } + public function find(string $id): ?array { return $this->zones[$id] ?? null; } public function save(array $z): string { $id = $z['zone_id'] ?? ('z' . (++$this->seq)); $this->zones[$id] = $z + ['zone_id' => $id]; return $id; } public function delete(string $id): bool { if (!isset($this->zones[$id])) return false; unset($this->zones[$id]); return true; } } diff --git a/cloud/tests/ZoneConfigTest.php b/cloud/tests/ZoneConfigTest.php index 30959a0..31510d1 100644 --- a/cloud/tests/ZoneConfigTest.php +++ b/cloud/tests/ZoneConfigTest.php @@ -5,13 +5,19 @@ use App\Http\Request; use App\Auth\ArraySessionStore; use App\Geometry\Polygon; use App\Dashboard\ZoneConfigController; -use App\Tests\Support\{InMemoryZoneRepository, InMemoryAuditRepository}; +use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository}; use PHPUnit\Framework\TestCase; final class ZoneConfigTest extends TestCase { - private function authed(string $role = 'operator'): ArraySessionStore { - $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s; + private function authed(string $role = 'operator', ?string $site = null): ArraySessionStore { + $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); + if ($site !== null) $s->set('site_scope', $site); + return $s; + } + /** Device repo mapping the fixture devices to their sites (empty = every device unscoped). */ + private function devices(array $sites = []): InMemoryDeviceRepository { + return new InMemoryDeviceRepository([], [], $sites); } public function testPolygonValidation(): void { $this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]])); @@ -22,39 +28,56 @@ final class ZoneConfigTest extends TestCase return new Request('POST', '/api/zones', $headers, json_encode($body), [], []); } public function testSaveRequiresPermission(): void { - $c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('viewer')); + $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer')); $res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $this->assertSame(403, $res->status()); } public function testSaveValidZone(): void { $repo = new InMemoryZoneRepository(); - $c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $this->assertSame(201, $res->status()); $this->assertCount(1, $repo->forCamera('cam-1')); } public function testSaveOwnershipMismatchIs400NotError(): void { - // The repo throws RuntimeException when the zone isn't owned by the given device/camera. - // The controller must turn that client error into 400, not let it become a 500. + // The repo throws ZoneOwnershipException when the zone isn't owned by the given + // device/camera. The controller must turn that client error into 400, not a 500 — and, + // crucially, must NOT catch a real \PDOException (which also extends \RuntimeException). $throwing = new class implements \App\Repository\ZoneRepository { public function forDevice(string $d): array { return []; } public function forCamera(string $c): array { return []; } - public function save(array $z): string { throw new \RuntimeException('not owned'); } + public function find(string $id): ?array { return ['zone_id' => $id, 'device_id' => 'pi-2', 'camera_id' => 'cam-1']; } + public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); } public function delete(string $id): bool { return true; } }; - $c = new ZoneConfigController($throwing, new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); $res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); $this->assertSame(400, $res->status()); } + public function testSaveLetsDbFaultBecome500NotInvalidZone(): void { + // A genuine \PDOException must NOT be masked as 400 invalid_zone — it should propagate so + // the front controller returns 500 and the fault is not silently swallowed. + $dbFault = new class implements \App\Repository\ZoneRepository { + public function forDevice(string $d): array { return []; } + public function forCamera(string $c): array { return []; } + public function find(string $id): ?array { return ['zone_id' => $id, 'device_id' => 'pi-1', 'camera_id' => 'cam-1']; } + public function save(array $z): string { throw new \PDOException('server has gone away'); } + public function delete(string $id): bool { return true; } + }; + $c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); + $this->expectException(\PDOException::class); + $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', + 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); + } public function testSaveRejectsBadLevelOrPolygon(): void { - $c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status()); } public function testSaveRejectsMissingOrWrongCsrf(): void { $repo = new InMemoryZoneRepository(); - $c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); $body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]; // missing header $this->assertSame(403, $c->save($this->saveReq($body, []))->status()); @@ -66,7 +89,7 @@ final class ZoneConfigTest extends TestCase public function testDeleteRejectsMissingOrWrongCsrf(): void { $repo = new InMemoryZoneRepository(); $repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); - $c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); // missing header $missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []); $this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status()); @@ -80,4 +103,62 @@ final class ZoneConfigTest extends TestCase $this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status()); $this->assertCount(0, $repo->forCamera('cam-1')); } + + // ---- P3-2b: site-scoped access control on zone endpoints --------------------------------- + + private function seededRepo(): InMemoryZoneRepository { + $repo = new InMemoryZoneRepository(); + // z-a belongs to device pi-a (factory-a); z-b to pi-b (factory-b). + $repo->save(['zone_id' => 'z-a', 'device_id' => 'pi-a', 'camera_id' => 'cam-a', 'level' => 'restricted', 'polygon_json' => '[[0,0],[1,0],[1,1]]']); + $repo->save(['zone_id' => 'z-b', 'device_id' => 'pi-b', 'camera_id' => 'cam-b', 'level' => 'restricted', 'polygon_json' => '[[0,0],[1,0],[1,1]]']); + return $repo; + } + private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController { + return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']), + new InMemoryAuditRepository(), $this->authed('operator', $site)); + } + private function zonesOf($res): array { + return json_decode($res->body(), true)['zones']; + } + + public function testScopedOperatorCannotDeleteCrossSiteZone(): void { + $repo = $this->seededRepo(); + $c = $this->scopedController($repo, 'factory-b'); // scoped to factory-b + $ok = new Request('POST', '/api/zones/z-a/delete', ['X-Csrf-Token' => 'tok'], '', [], []); + $this->assertSame(404, $c->delete($ok, ['id' => 'z-a'])->status()); // z-a is factory-a + $this->assertCount(1, $repo->forCamera('cam-a')); // still present + } + public function testScopedOperatorCannotReadCrossSiteZones(): void { + $repo = $this->seededRepo(); + $c = $this->scopedController($repo, 'factory-b'); + $res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']); + $this->assertSame(200, $res->status()); + $this->assertSame([], $this->zonesOf($res)); // factory-a hidden + } + public function testScopedOperatorCannotCreateOnCrossSiteDevice(): void { + $repo = $this->seededRepo(); + $c = $this->scopedController($repo, 'factory-b'); + $res = $c->save($this->saveReq(['device_id' => 'pi-a', 'camera_id' => 'cam-a', 'level' => 'restricted', 'polygon' => [[0,0],[1,0],[1,1]]])); + $this->assertSame(404, $res->status()); + } + public function testScopedOperatorSeesAndManagesOwnSite(): void { + $repo = $this->seededRepo(); + $c = $this->scopedController($repo, 'factory-a'); + $res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']); + $this->assertCount(1, $this->zonesOf($res)); // own site visible + $ok = new Request('POST', '/api/zones/z-a/delete', ['X-Csrf-Token' => 'tok'], '', [], []); + $this->assertSame(200, $c->delete($ok, ['id' => 'z-a'])->status()); // own site deletable + } + public function testGlobalOperatorSeesEverySite(): void { + $repo = $this->seededRepo(); + $c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']), + new InMemoryAuditRepository(), $this->authed('operator', null)); // no site_scope = global + $res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']); + $this->assertCount(1, $this->zonesOf($res)); + } + public function testListRequiresAuthenticatedRole(): void { + $s = new ArraySessionStore(); // no user_id + $c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s); + $this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status()); + } } diff --git a/deploy/bundle/build_offline_bundle.sh b/deploy/bundle/build_offline_bundle.sh index a309730..06c5230 100755 --- a/deploy/bundle/build_offline_bundle.sh +++ b/deploy/bundle/build_offline_bundle.sh @@ -117,12 +117,12 @@ cp "$REPO_ROOT/docs/pilot-runbook.md" "$STAGE/docs/" 2>/de chmod +x "$STAGE/install_offline.sh" # Supply-chain integrity (air-gap: the target can't reach PyPI to re-resolve, so pin + hash here). -# SHA256SUMS — sha256 of every wheel + model; install_offline verifies before installing. # requirements.lock — exact name==version of every bundled wheel, for a reproducible rebuild. -# sbom.json — CycloneDX component list (name, version, pypi purl, sha256). -echo ">> generating integrity manifest (SHA256SUMS, requirements.lock, sbom.json)" -( cd "$STAGE" && sha256sum wheelhouse/*.whl models/* > SHA256SUMS ) - +# sbom.json — CycloneDX component list: wheels AND models (name, version, sha256). +# SHA256SUMS — sha256 of EVERY staged file (code, unit, installer, env template, models, +# wheels, lock, sbom, MANIFEST). Generated LAST so it covers everything +# install_offline.sh lays down and runs as root — not just the wheels/models. +echo ">> generating requirements.lock + sbom.json" : > "$STAGE/requirements.lock" { printf '{\n "bomFormat": "CycloneDX",\n "specVersion": "1.5",\n "version": 1,\n "components": [\n' @@ -138,22 +138,23 @@ echo ">> generating integrity manifest (SHA256SUMS, requirements.lock, sbom.json "$sep" "$name" "$ver" "$name" "$ver" "$sum" sep=$',\n' done + # ML weights are shipped artifacts too — record them so a customer-side SBOM audit is complete. + for mdl in "$STAGE"/models/*; do + [ -e "$mdl" ] || continue + mbase="$(basename "$mdl")" + msum="$(sha256sum "$mdl" | cut -d' ' -f1)" + printf '%s {"type":"file","name":"%s","hashes":[{"alg":"SHA-256","content":"%s"}]}' \ + "$sep" "$mbase" "$msum" + sep=$',\n' + done printf '\n ]\n}\n' } > "$STAGE/sbom.json" sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock" -# Optional detached GPG signature over the integrity file. The air-gap target verifies via -# SHA256SUMS regardless; a signature adds provenance when a signing key exists on the build box. -if [ -n "${SIGN_KEY:-}" ]; then - gpg --local-user "$SIGN_KEY" --armor --detach-sign --output "$STAGE/SHA256SUMS.asc" "$STAGE/SHA256SUMS" - echo ">> signed SHA256SUMS with key $SIGN_KEY -> SHA256SUMS.asc" -else - echo ">> NOTE: SHA256SUMS is unsigned (set SIGN_KEY= to add a detached signature)." -fi - # Which pose backends this bundle can actually run, so the operator knows before it crosses the # air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX. HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes +SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes { echo "bundle: move-detect-edge" echo "built_mode: $MODE" @@ -161,15 +162,34 @@ HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes echo "target_arch: $TARGET_ARCH" echo "hailo_arch: $HAILO_ARCH" echo "pose_backends: hailo (yolov8s_pose.hef)$([ "$HAS_ONNX" = yes ] && echo ', movenet (movenet_lightning.onnx)')" - echo "integrity: SHA256SUMS (verified on install), requirements.lock, sbom.json$([ -f "$STAGE/SHA256SUMS.asc" ] && echo ', SHA256SUMS.asc (signed)')" + echo "integrity: SHA256SUMS covers ALL bundle files (verified on install)$([ "$SIGNED" = yes ] && echo ', SHA256SUMS.asc (signed)')" echo "models:" ls -1 "$STAGE/models" echo "wheels:" ls -1 "$WHEELHOUSE" } > "$STAGE/MANIFEST.txt" +# SHA256SUMS LAST: hash every staged file EXCEPT the manifest itself and its detached signature, +# so the daemon source, systemd unit, installer, env template, models and wheels are ALL covered. +# A tampered edge/daemon.py can no longer pass `sha256sum -c` and then be installed and run. +( cd "$STAGE" && find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' -print0 \ + | sort -z | xargs -0 sha256sum > SHA256SUMS ) + +# Optional detached GPG signature over the integrity file. When present, install_offline.sh +# REQUIRES it to verify (a failed signature is fatal there); when absent the bundle offers +# corruption-detection only. Provenance-critical deliveries MUST set SIGN_KEY. +if [ -n "${SIGN_KEY:-}" ]; then + gpg --local-user "$SIGN_KEY" --armor --detach-sign --output "$STAGE/SHA256SUMS.asc" "$STAGE/SHA256SUMS" + echo ">> signed SHA256SUMS with key $SIGN_KEY -> SHA256SUMS.asc" +else + echo ">> NOTE: SHA256SUMS is unsigned (corruption-detection only). Set SIGN_KEY= for a" + echo ">> detached signature; install_offline.sh will then require it to verify." +fi + echo ">> [6/6] packaging tarball" -TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}.tar.gz" +# Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were +# otherwise identically named, so the wrong one could be shipped to a board and crash-loop. +TARBALL="$OUT_DIR/move-detect-edge-bundle-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz" tar -C "$OUT_DIR" -czf "$TARBALL" "move-detect-edge-bundle" echo ">> DONE: $TARBALL" echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi." diff --git a/deploy/bundle/install_offline.sh b/deploy/bundle/install_offline.sh index ab5ccbc..27cc14a 100755 --- a/deploy/bundle/install_offline.sh +++ b/deploy/bundle/install_offline.sh @@ -23,6 +23,74 @@ WHEELHOUSE="$HERE/wheelhouse" RUNTIME_PKGS=(numpy requests opencv-python-headless) OPTIONAL_PKGS=(onnxruntime) +echo ">> [0/6] verify bundle integrity BEFORE touching the system" +# Verify EVERY bundled file against the build-time SHA256SUMS before any mutation of $PREFIX. A +# truncated/tampered bundle must abort HERE — with the previous install still intact — never after +# rm -rf has already destroyed it and left the Restart=always unit to run half-copied code. The +# build now hashes the daemon source, unit and this installer too, so tampered code cannot pass. +ALLOW_UNVERIFIED="${ALLOW_UNVERIFIED:-0}" +if [ -f "$HERE/SHA256SUMS" ]; then + ( cd "$HERE" && sha256sum -c SHA256SUMS ) \ + || { echo "ERROR: bundle integrity check failed (SHA256SUMS mismatch) — refusing to install." >&2; exit 1; } + # sha256sum -c only checks LISTED files; an INJECTED extra wheel (which could win pip's version + # resolution) would pass unnoticed. Refuse any staged file not present in the manifest. + EXTRA="$( cd "$HERE" && comm -23 \ + <(find . -type f ! -name SHA256SUMS ! -name 'SHA256SUMS.asc' | sed 's|^\./||' | sort) \ + <(awk '{print $2}' SHA256SUMS | sed 's|^[*]||; s|^\./||' | sort) )" + if [ -n "$EXTRA" ]; then + echo "ERROR: bundle contains file(s) not listed in SHA256SUMS (possible injection):" >&2 + echo "$EXTRA" >&2 + exit 1 + fi + # A detached signature proves PROVENANCE — an unsigned SHA256SUMS only detects corruption, since + # an attacker who can modify the bundle can strip the .asc and regenerate the hashes. Set + # REQUIRE_SIGNATURE=1 for untrusted transport to make a missing/invalid signature fatal. + REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-0}" + if [ -f "$HERE/SHA256SUMS.asc" ]; then + gpg --verify "$HERE/SHA256SUMS.asc" "$HERE/SHA256SUMS" 2>/dev/null \ + || { echo "ERROR: SHA256SUMS.asc present but its signature did NOT verify — refusing to install." >&2; exit 1; } + echo " -> SHA256SUMS signature verified" + elif [ "$REQUIRE_SIGNATURE" = 1 ]; then + echo "ERROR: REQUIRE_SIGNATURE=1 but this bundle has no SHA256SUMS.asc. An unsigned bundle" >&2 + echo "detects corruption only, not tampering — refusing. Build with SIGN_KEY, or clear the flag." >&2 + exit 1 + else + echo " -> NOTE: unsigned bundle (corruption-detection only; set REQUIRE_SIGNATURE=1 to require a signature)." + fi + echo " -> bundle integrity OK" +elif [ "$ALLOW_UNVERIFIED" = 1 ]; then + echo " -> WARNING: no SHA256SUMS; ALLOW_UNVERIFIED=1 set — installing WITHOUT integrity check." >&2 +else + echo "ERROR: no SHA256SUMS in this bundle — refusing to install unverified code. Rebuild with a" >&2 + echo "current build_offline_bundle.sh, or (NOT recommended) re-run with ALLOW_UNVERIFIED=1." >&2 + exit 1 +fi + +echo ">> [0b/6] Hailo arch preflight (BEFORE any mutation)" +# A hailo8 HEF on a hailo8l board (or vice-versa) imports fine but the daemon dies with +# HAILO_INVALID_HEF(26) on every start, forever. Catch the WRONG bundle here — using MANIFEST.txt +# (staged) + hailortcli (a system CLI, no venv needed) — so a mismatch aborts with the previous +# install still intact, not after the HEF/source/venv have already been overwritten. +BUNDLE_HAILO_ARCH="$(awk -F': ' '/^hailo_arch:/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}' "$HERE/MANIFEST.txt" 2>/dev/null || true)" +if [ -n "$BUNDLE_HAILO_ARCH" ]; then + BOARD_HAILO_ARCH="" + if command -v hailortcli >/dev/null 2>&1; then + BOARD_HAILO_ARCH="$(hailortcli fw-control identify 2>/dev/null \ + | awk -F': ' '/[Aa]rchitecture/{gsub(/[[:space:]]/,"",$2); print tolower($2); exit}')" + fi + if [ -n "$BOARD_HAILO_ARCH" ] && [ "$BUNDLE_HAILO_ARCH" != "$BOARD_HAILO_ARCH" ]; then + echo "ERROR: Hailo arch mismatch — this bundle's HEF is '$BUNDLE_HAILO_ARCH' but the board is" >&2 + echo "'$BOARD_HAILO_ARCH'. It would crash-loop with HAILO_INVALID_HEF(26). Rebuild with" >&2 + echo "HAILO_ARCH=$BOARD_HAILO_ARCH (deploy/bundle/build_offline_bundle.sh)." >&2 + exit 1 + elif [ -z "$BOARD_HAILO_ARCH" ]; then + echo " -> NOTE: could not read the board's Hailo arch (hailortcli?) to verify the bundle's" >&2 + echo " '$BUNDLE_HAILO_ARCH'; if the daemon later fails HAILO_INVALID_HEF(26), the arch is wrong." >&2 + else + echo " -> Hailo arch OK ($BUNDLE_HAILO_ARCH)" + fi +fi + echo ">> [1/6] service account + groups" if ! id -u "$SVC_USER" >/dev/null 2>&1; then useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER" @@ -34,10 +102,14 @@ done echo ">> [2/6] lay down source, model, docs under $PREFIX" install -d -m 0755 "$PREFIX" "$PREFIX/src" "$PREFIX/models" "$PREFIX/docs" -# Fully replace the package tree on re-install/upgrade. Without the rm, `cp -r SRC DEST` -# nests SRC *inside* an existing DEST (edge/edge/...) and the OLD code keeps running. +# Fully replace the package tree on re-install/upgrade, atomically. Stage the new tree beside the +# old and rename over it: an interrupted copy leaves the previous WORKING tree untouched (the +# Restart=always unit keeps running old code) instead of a half-copied one. The rename also avoids +# `cp -r SRC DEST` nesting SRC *inside* an existing DEST (edge/edge/...) and running the OLD code. +rm -rf "$PREFIX/src/edge.new" +cp -r "$HERE/src/edge" "$PREFIX/src/edge.new" rm -rf "$PREFIX/src/edge" -cp -r "$HERE/src/edge" "$PREFIX/src/edge" +mv "$PREFIX/src/edge.new" "$PREFIX/src/edge" cp "$HERE/src/pyproject.toml" "$PREFIX/src/" 2>/dev/null || true # Copy whichever pose models the bundle carries. Each is optional on its own — a Hailo bundle # ships only the .hef, a CPU bundle only the .onnx — so a missing glob must not abort the install @@ -46,21 +118,8 @@ cp "$HERE/models/"*.hef "$PREFIX/models/" 2>/dev/null || true cp "$HERE/models/"*.onnx "$PREFIX/models/" 2>/dev/null || true cp "$HERE/docs/"*.md "$PREFIX/docs/" 2>/dev/null || true -echo ">> [3/6] verify bundle integrity + python venv + offline wheel install (--no-index)" -# Supply-chain: verify every bundled wheel + model against the build-time SHA256SUMS BEFORE -# installing anything. A truncated transfer or a tampered wheel must fail here, loudly, not -# install silently. Older bundles without the file are allowed through with a warning. -if [ -f "$HERE/SHA256SUMS" ]; then - ( cd "$HERE" && sha256sum -c SHA256SUMS ) \ - || { echo "ERROR: bundle integrity check failed (SHA256SUMS mismatch) — refusing to install." >&2; exit 1; } - if [ -f "$HERE/SHA256SUMS.asc" ]; then - gpg --verify "$HERE/SHA256SUMS.asc" "$HERE/SHA256SUMS" 2>/dev/null \ - && echo " -> SHA256SUMS signature verified" \ - || echo " -> WARNING: SHA256SUMS.asc present but signature did not verify (missing/again untrusted key?)." >&2 - fi -else - echo " -> WARNING: no SHA256SUMS in bundle; skipping integrity check (rebuild with a newer build_offline_bundle.sh)." >&2 -fi +echo ">> [3/6] python venv + offline wheel install (--no-index)" +# Integrity was already verified up-front in step [0/6], before any system mutation. # --system-site-packages so the venv can import the Pi SYSTEM packages that are NOT # pip-installable: hailo_platform (HailoRT, apt: hailo-all) for POSE_BACKEND=hailo, and # picamera2 (apt: python3-picamera2) for CAMERA_BACKEND=csi. The wheelhouse install @@ -100,13 +159,7 @@ state() { if "$@" >/dev/null 2>&1; then echo present; else echo MISSING; fi; } HAILO_OK=0; MOVENET_OK=0 if [ -f "$HEF_DEST" ] && pyhas hailo_platform; then HAILO_OK=1; fi if [ -f "$ONNX_DEST" ] && pyhas onnxruntime; then MOVENET_OK=1; fi -if [ "$HAILO_OK" = 1 ]; then - POSE_BACKEND=hailo - echo " -> Hailo HEF + hailo_platform present; POSE_BACKEND=hailo" -elif [ "$MOVENET_OK" = 1 ]; then - POSE_BACKEND=movenet - echo " -> CPU MoveNet usable; POSE_BACKEND=movenet" -else +if [ "$HAILO_OK" != 1 ] && [ "$MOVENET_OK" != 1 ]; then echo "" >&2 echo "ERROR: no usable pose backend in this bundle — refusing to install a daemon that" >&2 echo "cannot start." >&2 @@ -115,22 +168,57 @@ else echo " Rebuild the bundle for the target backend (deploy/bundle/build_offline_bundle.sh)." >&2 exit 1 fi +# Prefer Hailo (the accelerator) when usable; MoveNet is the CPU fallback. (Arch already +# preflighted in [0b/6].) +if [ "$HAILO_OK" = 1 ]; then DEFAULT_BACKEND=hailo; else DEFAULT_BACKEND=movenet; fi +backend_usable() { # 0 (true) iff the named backend can actually load in THIS bundle + case "$1" in + hailo) [ "$HAILO_OK" = 1 ] ;; + movenet) [ "$MOVENET_OK" = 1 ] ;; + *) return 1 ;; + esac +} install -d -m 0750 "$STATE" chown "$SVC_USER":"$SVC_USER" "$STATE" install -d -m 0750 "$ETC" if [ ! -f "$ETC/edge.env" ]; then + POSE_BACKEND="$DEFAULT_BACKEND" install -m 0640 "$HERE/deploy/systemd/move-detect-edge.env.example" "$ETC/edge.env" - # Reconcile the template's POSE_BACKEND to the one that actually works in this bundle. sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env" - if [ "$POSE_BACKEND" = hailo ]; then - grep -q '^HAILO_HEF=' "$ETC/edge.env" || printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env" - fi - chown root:"$SVC_USER" "$ETC/edge.env" echo " -> wrote $ETC/edge.env (POSE_BACKEND=$POSE_BACKEND; EDIT IT: DEVICE_ID, DEVICE_SECRET, CAFE24_URL)" else - echo " -> $ETC/edge.env already exists; left unchanged" + # Preserve the operator's DELIBERATE backend choice when it still works in this bundle; only + # switch when the current one can't load — the crash-loop this reconcile exists to prevent + # (e.g. a stale POSE_BACKEND=movenet on a Hailo-only bundle with no onnxruntime). + CURRENT_BACKEND="$(sed -n 's|^POSE_BACKEND=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)" + if backend_usable "$CURRENT_BACKEND"; then + POSE_BACKEND="$CURRENT_BACKEND" + echo " -> $ETC/edge.env POSE_BACKEND=$POSE_BACKEND is usable in this bundle; kept" + else + POSE_BACKEND="$DEFAULT_BACKEND" + if grep -q '^POSE_BACKEND=' "$ETC/edge.env"; then + sed -i "s|^POSE_BACKEND=.*|POSE_BACKEND=$POSE_BACKEND|" "$ETC/edge.env" + else + printf 'POSE_BACKEND=%s\n' "$POSE_BACKEND" >> "$ETC/edge.env" + fi + echo " -> reconciled $ETC/edge.env POSE_BACKEND: '${CURRENT_BACKEND:-unset}' (unusable) -> '$POSE_BACKEND'" + fi fi +# For the hailo backend, HAILO_HEF must point at a HEF that actually EXISTS — correct a missing +# key OR one left pointing at a stale/absent path, else the daemon crash-loops on a bad path. +if [ "$POSE_BACKEND" = hailo ]; then + CURRENT_HEF="$(sed -n 's|^HAILO_HEF=\(.*\)|\1|p' "$ETC/edge.env" | head -n1)" + if [ -z "$CURRENT_HEF" ] || [ ! -f "$CURRENT_HEF" ]; then + if grep -q '^HAILO_HEF=' "$ETC/edge.env"; then + sed -i "s|^HAILO_HEF=.*|HAILO_HEF=$HEF_DEST|" "$ETC/edge.env" + else + printf 'HAILO_HEF=%s\n' "$HEF_DEST" >> "$ETC/edge.env" + fi + echo " -> set HAILO_HEF=$HEF_DEST (was '${CURRENT_HEF:-unset}')" + fi +fi +chown root:"$SVC_USER" "$ETC/edge.env" echo ">> [6/6] systemd unit" install -m 0644 "$HERE/deploy/systemd/move-detect-edge.service" /etc/systemd/system/ diff --git a/deploy/systemd/move-detect-edge.service b/deploy/systemd/move-detect-edge.service index 0a15019..aeac345 100644 --- a/deploy/systemd/move-detect-edge.service +++ b/deploy/systemd/move-detect-edge.service @@ -46,13 +46,18 @@ StateDirectoryMode=0750 # unlisted device node, so the CSI camera needs its nodes explicitly: # char-video4linux USB UVC (/dev/video*) AND the CSI capture nodes # char-media picamera2/libcamera opens /dev/media* to configure the CSI pipeline (Pi 5) -# /dev/dma_heap/* libcamera allocates capture buffers from the dma-heap on Pi 5 -# Without char-media + dma_heap a Hailo Pi with a CSI camera is denied its own camera by this +# char-dma_heap libcamera allocates capture buffers from the dma-heap on Pi 5 +# Without char-media + char-dma_heap a Hailo Pi with a CSI camera is denied its own camera by this # unit's policy. (OFF-HW: verify on the Pi — `journalctl` shows a libcamera EPERM/open failure.) +# +# NOTE: DeviceAllow= takes a device-node path OR a `char-`/`block-` name — NOT a +# directory. `/dev/dma_heap/` (a directory, trailing slash) is rejected at cgroup-realize time and +# silently dropped, leaving dma-heap DENIED under this deny-by-default policy. Use the char group +# (dma_heap appears in /proc/devices as a char device) so the whole /dev/dma_heap/* set is allowed. DeviceAllow=/dev/hailo0 rw DeviceAllow=char-video4linux rw DeviceAllow=char-media rw -DeviceAllow=/dev/dma_heap/ rw +DeviceAllow=char-dma_heap rw # Conservative hardening (loosen if it blocks device access on your image). NoNewPrivileges=true diff --git a/edge/alert.py b/edge/alert.py index 8b8cdd1..dcf6919 100644 --- a/edge/alert.py +++ b/edge/alert.py @@ -2,6 +2,7 @@ import base64 import hashlib import hmac import json +from enum import Enum from edge.events import Event @@ -11,6 +12,19 @@ from edge.events import Event USER_AGENT = "move-detect-edge/1.0" +class SendResult(Enum): + """Outcome of one upload attempt. The offline queue must tell a transient outage (retry + forever — a real fall alert has to survive an overnight internet outage) apart from a + permanent rejection (poison — count toward the drop cap), which a bare bool cannot express. + DELIVERED is the only truthy value, so `if client.send(...)` keeps working unchanged.""" + DELIVERED = "delivered" + REJECTED = "rejected" # a definite 4xx (not 429): the server will never accept this event + TRANSIENT = "transient" # network error / timeout / 429 / 5xx — connectivity or server hiccup + + def __bool__(self) -> bool: + return self is SendResult.DELIVERED + + def sign(secret: str, nonce: str, ts: str, body: str | bytes) -> str: if isinstance(body, str): body = body.encode() @@ -32,7 +46,7 @@ class PostClient: http_post = requests.post self._post = http_post - def send(self, event: Event, snapshots: list[bytes]) -> bool: + def send(self, event: Event, snapshots: list[bytes]) -> SendResult: payload = event.to_payload(self.device_id) payload["snapshots"] = [base64.b64encode(s).decode() for s in snapshots] body = json.dumps(payload, sort_keys=True) @@ -51,5 +65,15 @@ class PostClient: resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout, allow_redirects=False) except Exception: - return False - return 200 <= resp.status_code < 300 + # Connection refused / DNS / timeout — the cloud is unreachable, not rejecting us. + return SendResult.TRANSIENT + code = resp.status_code + if 200 <= code < 300: + return SendResult.DELIVERED + # Transient — "back off and retry", NOT a permanent verdict on the event: all 5xx plus the + # 4xx codes that explicitly mean try-again (408 Request Timeout, 425 Too Early, 429 Too + # Many Requests). Counting these toward the poison drop cap would delete real events during + # a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection. + if code in (408, 425, 429) or code >= 500: + return SendResult.TRANSIENT + return SendResult.REJECTED diff --git a/edge/config.py b/edge/config.py index 9a4e044..659ffd7 100644 --- a/edge/config.py +++ b/edge/config.py @@ -1,3 +1,4 @@ +import math from dataclasses import dataclass from edge.detectors.fall import FallConfig @@ -45,33 +46,68 @@ def queue_db_path(env: dict) -> str: 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 cloud 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 runs perfectly green while every upload - fails (a bad/empty URL makes requests raise, which PostClient catches and returns False), - and the offline queue grows until the SD card is full. The operator sees a healthy unit - and an empty dashboard. Refuse to start instead. + 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. """ - missing = [name for name, val in (("CAFE24_URL", cfg.cafe24_url), - ("DEVICE_SECRET", cfg.secret)) if not val.strip()] - if missing: + 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 cloud, not a stub") + + if problems: raise ValueError( - f"FATAL: {', '.join(missing)} not set in /etc/move-detect/edge.env — the daemon " - f"would run but upload nothing. CAFE24_URL must include the app path, e.g. " - f"https:///move_detect ; DEVICE_SECRET is the per-device secret from the " - f"cloud." + "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:///move_detect ; DEVICE_SECRET is the per-device secret from the cloud." ) 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 = _f(env, "MOTION_THR", 0.25) + if not math.isfinite(motion_thr) or motion_thr <= 0: + raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}") fall = FallConfig( prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0), no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0), + motion_thr=motion_thr, ) - immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0)) + immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0), + motion_thr=motion_thr) ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0)) zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75)) engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0)) diff --git a/edge/detectors/fall.py b/edge/detectors/fall.py index 19e8844..d47df1b 100644 --- a/edge/detectors/fall.py +++ b/edge/detectors/fall.py @@ -15,7 +15,7 @@ class FallConfig: height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴) prone_confirm_s: float = 3.0 no_motion_s: float = 10.0 - motion_thr: float = 0.02 # 정규화 관절변위 임계 + motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env min_conf: float = 0.5 min_visible: int = 10 recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간 @@ -76,8 +76,20 @@ class FallDetector: if not prone: if st.name in ("FALLING", "FALLEN", "NO_MOTION"): + # Only a CONFIDENT non-prone frame may recover an active incident. A low-conf + # (kp_ok False) frame's bbox-only "not prone" reading is untrusted: occlusion of + # a still-fallen worker must FREEZE the incident, not run the recovery countdown + # on noise. Freezing also protects baseline_height below (only seeded here, when + # kp_ok holds, so a bad low-conf bbox can't corrupt the next fall's collapse cue). + if not kp_ok: + # Occlusion PAUSES recovery — it must not count toward the grace window. Reset + # so recovery requires a FRESH, continuous run of confident non-prone frames; + # otherwise a confident frame at t0 plus a lone confident frame after a long + # low-conf gap would recover instantly on wall-clock elapsed alone. + st.not_prone_since = None + return None, {} # 단일 튐(noisy) 프레임만으로 CRITICAL 사고를 즉시 취소하지 않도록 - # recover_grace_s만큼 지속된 비-prone을 요구한다. + # recover_grace_s만큼 지속된 (신뢰) 비-prone을 요구한다. if st.not_prone_since is None: st.not_prone_since = now return None, {} diff --git a/edge/detectors/immobility.py b/edge/detectors/immobility.py index 5ca8721..ae87b97 100644 --- a/edge/detectors/immobility.py +++ b/edge/detectors/immobility.py @@ -6,7 +6,7 @@ from edge.geometry import track_displacement @dataclass class ImmobilityConfig: - motion_thr: float = 0.02 + motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env immobile_s: float = 120.0 zone_immobile_s: float = 30.0 diff --git a/edge/geometry.py b/edge/geometry.py index ce8397c..7e4ba56 100644 --- a/edge/geometry.py +++ b/edge/geometry.py @@ -61,6 +61,13 @@ def normalize(value: float, bbox_height: float) -> float: return value / bbox_height if bbox_height else 0.0 +# Keypoints below this confidence are treated as noise (commonly hallucinated at a frame +# corner). Averaging them into the motion estimate makes a still track read as moving, which +# suppresses no_motion/immobility exactly when they matter (a motionless fallen worker). +MOTION_MIN_CONF = 0.2 +MOTION_MIN_KP = 3 + + def track_displacement(track) -> float: """Mean per-keypoint SPEED between the last two frames: displacement normalized by bbox height AND by elapsed time (per second). Without the time term the value scales with the @@ -68,8 +75,12 @@ def track_displacement(track) -> float: high fps — flipping immobility/no_motion with the daemon's actual (load-dependent) fps. Returns 1.0 (moving/unknown) with <2 frames or a non-positive dt. - NOTE: motion_thr in FallConfig/ImmobilityConfig is now a per-SECOND threshold; its default - predates this change and should be re-tuned on device.""" + Only keypoints the model is confident about in BOTH frames are averaged; below + MOTION_MIN_KP trustworthy points it falls back to the bbox centre (a stable proxy that + doesn't ride on one noisy joint). + + NOTE: motion_thr in FallConfig/ImmobilityConfig is a per-SECOND threshold (units/second), + tunable via MOTION_THR in the environment; recalibrate on device against real jitter.""" if len(track.frames) < 2: return 1.0 a, b = track.frames[-2], track.frames[-1] @@ -77,6 +88,10 @@ def track_displacement(track) -> float: if dt <= 0: return 1.0 h = b.bbox.height or 1.0 - d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 - for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints) + pairs = [(p, q) for p, q in zip(a.keypoints, b.keypoints) + if p.conf >= MOTION_MIN_CONF and q.conf >= MOTION_MIN_CONF] + if len(pairs) >= MOTION_MIN_KP: + d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 for p, q in pairs) / len(pairs) + else: + d = ((a.bbox.cx - b.bbox.cx) ** 2 + (a.bbox.cy - b.bbox.cy) ** 2) ** 0.5 return normalize(d, h) / dt diff --git a/edge/queue.py b/edge/queue.py index 41e40de..c7aaf4b 100644 --- a/edge/queue.py +++ b/edge/queue.py @@ -2,6 +2,7 @@ import base64 import json import sqlite3 +from edge.alert import SendResult from edge.events import Event, EventType, Severity @@ -66,23 +67,36 @@ class OfflineQueue: payload = json.loads(payload_json) snaps = [base64.b64decode(s) for s in json.loads(snaps_json)] event, snapshots = self._rehydrate(payload, snaps) - if self.client.send(event, snapshots): + # A row queued before event_uuid existed has none stored; _rehydrate minted a fresh + # one just now. Persist it so every retry of this row reuses the SAME key — otherwise + # each flush sends a different uuid and a timeout-retried delivery duplicates on the + # cloud (the P1-9 bug, still open for transitional rows). + if not payload.get("event_uuid"): + payload["event_uuid"] = event.event_uuid + self.db.execute("UPDATE queue SET payload = ? WHERE id = ?", + (json.dumps(payload), rid)) + self.db.commit() + + result = self.client.send(event, snapshots) + if result: # SendResult.DELIVERED (only truthy result) self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) self.db.commit() sent += 1 continue - # Failed. A bool send() can't tell a transient outage from a permanently-rejected - # (poison) event, so count attempts: below the cap, stop and preserve order (an - # outage recovers and the whole queue drains next flush); at the cap, DROP this - # row and continue so a poison head can never wedge the queue forever. - attempts += 1 - if attempts >= self.max_send_attempts: - self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) + # Not delivered. Only a DEFINITE rejection (4xx) counts toward the poison drop cap — + # a transient outage (network error / 429 / 5xx) must retry forever so a real fall + # alert survives an overnight outage; max_rows already bounds the disk. This is the + # fix for the old bool send(), which counted every offline failure toward the cap and + # silently deleted the oldest queued safety events after ~20 flushes of downtime. + if result is SendResult.REJECTED: + attempts += 1 + if attempts >= self.max_send_attempts: + self.db.execute("DELETE FROM queue WHERE id = ?", (rid,)) + self.db.commit() + continue # poison dropped — let the events behind it drain + self.db.execute("UPDATE queue SET attempts = ? WHERE id = ?", (attempts, rid)) self.db.commit() - continue - self.db.execute("UPDATE queue SET attempts = ? WHERE id = ?", (attempts, rid)) - self.db.commit() - break # transient — preserve order, retry the whole queue next time + break # preserve order; retry the whole queue next flush return sent def pending(self) -> int: diff --git a/edge/zones.py b/edge/zones.py index 8c49342..d075910 100644 --- a/edge/zones.py +++ b/edge/zones.py @@ -1,7 +1,21 @@ import json +import logging from edge.detectors.zone import Zone +log = logging.getLogger(__name__) + +# Zones are stored NORMALIZED (0..1 of the image) by the current dashboard. Polygons drawn by the +# pre-normalization dashboard were saved in canvas PIXELS (values in the hundreds); after the edge +# normalizes the foot point, a pixel polygon can never match — silently killing that zone's +# intrusion detection with no error. Flag such rows loudly so the operator redraws them, instead +# of failing dark. A normalized coordinate may round slightly past 1.0, so allow a small margin. +_NORMALIZED_MAX = 1.5 + + +def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool: + return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon) + def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: """Parse cloud config zone rows into Zone objects. When camera_id is given, only @@ -19,6 +33,13 @@ def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: polygon = [(float(p[0]), float(p[1])) for p in pts] if len(polygon) < 3: continue + if _looks_like_pixels(polygon): + log.warning( + "zone %s on camera %s has un-normalized (pixel-space) coordinates %r — it " + "would match nothing after foot normalization. Skipping; redraw this zone in " + "the dashboard so it is stored normalized (0..1).", + row.get("zone_id"), row.get("camera_id"), polygon[:2]) + continue out.append(Zone(str(row["zone_id"]), str(row["level"]), polygon)) except (KeyError, ValueError, TypeError, IndexError, AttributeError, json.JSONDecodeError): continue diff --git a/tests/test_alert.py b/tests/test_alert.py index eec2411..f6cb12b 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -1,6 +1,6 @@ import json from edge.events import Event, EventType, Severity -from edge.alert import sign, PostClient +from edge.alert import sign, PostClient, SendResult def _event(): return Event(EventType.FALL, Severity.HIGH, "cam-01", 7, None, 1000.0, 3.0, @@ -27,7 +27,7 @@ def test_send_posts_signed_payload(): nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_post=fake_post) ok = client.send(_event(), [b"\xff\xd8jpeg"]) - assert ok is True + assert ok is SendResult.DELIVERED assert captured["headers"]["X-Nonce"] == "n1" assert captured["headers"]["X-Timestamp"] == "1000.0" payload = json.loads(captured["body"]) @@ -43,7 +43,26 @@ def test_send_posts_signed_payload(): # is a misconfiguration to surface, not to silently downgrade the POST. assert captured["kw"].get("allow_redirects") is False -def test_send_returns_false_on_error_code(): +def test_send_classifies_5xx_as_transient(): + # 5xx / 429 are "retry", not a permanent verdict — the offline queue must keep retrying, not + # count these toward the poison drop cap. (A bare bool could not express this distinction.) client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, http_post=lambda *a, **k: _FakeResp(500)) - assert client.send(_event(), []) is False + assert client.send(_event(), []) is SendResult.TRANSIENT + for code in (408, 425, 429): # try-again 4xx codes are transient, not permanent rejections + c = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, + http_post=lambda *a, _c=code, **k: _FakeResp(_c)) + assert c.send(_event(), []) is SendResult.TRANSIENT + +def test_send_classifies_4xx_as_rejected(): + # A definite 4xx (not 429) means the cloud will never accept this event — the queue counts it + # toward the poison drop cap so it can eventually be discarded and unblock the queue. + client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, + http_post=lambda *a, **k: _FakeResp(400)) + assert client.send(_event(), []) is SendResult.REJECTED + +def test_send_treats_transport_error_as_transient(): + def boom(*a, **k): + raise ConnectionError("network down") + client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0, http_post=boom) + assert client.send(_event(), []) is SendResult.TRANSIENT diff --git a/tests/test_config.py b/tests/test_config.py index 26e87c0..ac9b366 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,10 +49,39 @@ def test_require_upload_config_rejects_blank_url_and_secret(): def test_require_upload_config_passes_when_both_present(): - cfg = load_config({"CAFE24_URL": "https://x/move_detect", "DEVICE_SECRET": "s"}) + # A real per-device secret is a long HMAC-derived hex string, on an https URL. + cfg = load_config({"CAFE24_URL": "https://x/move_detect", + "DEVICE_SECRET": "0123456789abcdef0123"}) require_upload_config(cfg) # must not raise +def test_require_upload_config_rejects_placeholder_and_http(): + # Non-empty ship-time placeholders pass a blank check but are never a real value; a unit left + # at one uploads nothing while looking healthy. http would leak worker snapshots in plaintext. + with pytest.raises(ValueError, match="DEVICE_SECRET"): + require_upload_config(load_config({"CAFE24_URL": "https://x/move_detect", + "DEVICE_SECRET": "CHANGEME"})) + with pytest.raises(ValueError, match="CAFE24_URL"): + require_upload_config(load_config({"CAFE24_URL": "https://your-domain.example/move_detect", + "DEVICE_SECRET": "0123456789abcdef0123"})) + with pytest.raises(ValueError, match="https"): + require_upload_config(load_config({"CAFE24_URL": "http://x/move_detect", + "DEVICE_SECRET": "0123456789abcdef0123"})) + with pytest.raises(ValueError, match="too short"): + require_upload_config(load_config({"CAFE24_URL": "https://x/move_detect", + "DEVICE_SECRET": "short"})) + + +def test_load_config_rejects_non_finite_or_nonpositive_motion_thr(): + # A 0 / NaN / inf MOTION_THR would make every track read as "still" -> constant no_motion. + for bad in ("0", "-1", "nan", "inf"): + with pytest.raises(ValueError, match="MOTION_THR"): + load_config({"MOTION_THR": bad}) + # A sane value flows through to both detectors. + cfg = load_config({"MOTION_THR": "0.3"}) + assert cfg.fall.motion_thr == 0.3 and cfg.immobility.motion_thr == 0.3 + + def test_frame_interval_defaults_and_validates(): assert frame_interval_from_env({}) == pytest.approx(1 / 12) assert frame_interval_from_env({"TARGET_FPS": "24"}) == pytest.approx(1 / 24) diff --git a/tests/test_deploy_artifacts.py b/tests/test_deploy_artifacts.py index 59f2796..67a8ca4 100644 --- a/tests/test_deploy_artifacts.py +++ b/tests/test_deploy_artifacts.py @@ -249,17 +249,28 @@ def test_offline_bundle_emits_pinned_integrity_manifest(): with open(BUILD_OFFLINE, encoding="utf-8") as f: sh = f.read() # Air-gap supply-chain: the target can't re-resolve from PyPI, so the build must pin exact - # versions, hash every artifact, and ship an SBOM. GPG signing is optional (SIGN_KEY). - assert "sha256sum wheelhouse/*.whl models/* > SHA256SUMS" in sh + # versions, hash EVERY artifact (not just wheels/models — the daemon source, unit and installer + # too), and ship an SBOM. GPG signing is optional (SIGN_KEY). + assert "find . -type f" in sh and "sha256sum > SHA256SUMS" in sh # hashes every staged file + assert "! -name SHA256SUMS" in sh # except the manifest itself assert "requirements.lock" in sh # exact name==version pins for a reproducible rebuild assert "sbom.json" in sh assert "CycloneDX" in sh + assert 'for mdl in "$STAGE"/models/*' in sh # SBOM includes the ML model weights, not just wheels assert 'if [ -n "${SIGN_KEY:-}" ]; then' in sh # optional detached signature def test_offline_installer_verifies_integrity_before_install(): with open(INSTALL_OFFLINE, encoding="utf-8") as f: sh = f.read() - # A truncated/tampered wheel must fail loudly BEFORE any pip install, not install silently. + # A truncated/tampered bundle must fail loudly BEFORE any system mutation — not after rm -rf + # has destroyed the previous install and the pip step has already run. assert "sha256sum -c SHA256SUMS" in sh assert "integrity check failed" in sh + # Ordering (the substance of the fix): the verify must come BEFORE the first $PREFIX mutation + # and before the offline wheel install. + verify_at = sh.index("sha256sum -c SHA256SUMS") + assert verify_at < sh.index('rm -rf "$PREFIX/src/edge') # before destroying the old tree + assert verify_at < sh.index("install --no-index") # before installing wheels + # A missing SHA256SUMS must be FATAL (refuse), not a warn-and-proceed that installs blind. + assert "refusing to install unverified code" in sh diff --git a/tests/test_multicam.py b/tests/test_multicam.py index 613cd4a..d415c2e 100644 --- a/tests/test_multicam.py +++ b/tests/test_multicam.py @@ -7,11 +7,13 @@ from edge.daemon import run_loop, parse_cameras, build_runtimes def _cfg_two_cams(): + # Polygons are stored NORMALIZED (0..1 of the image); zones_from_config now rejects + # un-normalized (pixel-space) coordinates as legacy data. return {"zones": [ {"zone_id": "z1", "camera_id": "cam-01", "level": "restricted", - "polygon_json": "[[0,0],[9,0],[9,9]]"}, + "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]"}, {"zone_id": "z2", "camera_id": "cam-02", "level": "warning", - "polygon_json": "[[0,0],[5,0],[5,5]]"}, + "polygon_json": "[[0,0],[0.5,0],[0.5,0.5]]"}, ], "thresholds": {}} diff --git a/tests/test_queue.py b/tests/test_queue.py index 93a361a..8de4568 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,5 +1,9 @@ +import json +import sqlite3 + from edge.events import Event, EventType, Severity from edge.queue import OfflineQueue +from edge.alert import SendResult def _event(n=1): return Event(EventType.FALL, Severity.HIGH, "cam-01", n, None, float(n), 3.0, {}, None) @@ -37,15 +41,15 @@ def test_flush_resends_when_recovered(tmp_path): class _PoisonClient: - """Rejects one specific event forever (e.g. a 400 the cloud will never accept).""" + """Rejects one specific event forever with a definite 4xx the cloud will never accept.""" def __init__(self, poison_id): self.poison = poison_id self.sent = [] def send(self, event, snapshots): if event.track_id == self.poison: - return False + return SendResult.REJECTED self.sent.append(event.track_id) - return True + return SendResult.DELIVERED def test_queue_is_bounded_and_keeps_newest(tmp_path): @@ -91,3 +95,54 @@ def test_poison_head_is_dropped_and_unblocks_the_queue(tmp_path): q.flush() assert client.sent == [2] # good event delivered assert q.pending() == 0 # poison dropped, queue drained + + +def test_sustained_outage_never_drops_events(tmp_path): + # A transient outage (network down / 5xx) must NEVER poison-drop queued events, no matter how + # long it lasts — a real fall alert has to survive an overnight outage. Only a definite 4xx + # rejection counts toward the drop cap (contrast test_poison_head_is_dropped above). + class _Outage: + up = False + sent = [] + def send(self, event, snapshots): + if not _Outage.up: + return SendResult.TRANSIENT + _Outage.sent.append(event.track_id) + return SendResult.DELIVERED + client = _Outage() + q = OfflineQueue(str(tmp_path / "q.db"), client, max_send_attempts=3) + q.enqueue(_event(1), [b"x"]) + for _ in range(10): # far beyond max_send_attempts + q.flush() + assert q.pending() == 1 # still queued — a transient failure is never poison-dropped + _Outage.up = True + q.flush() + assert q.pending() == 0 and _Outage.sent == [1] # drains once connectivity returns + + +def test_legacy_row_without_uuid_reuses_minted_uuid_across_retries(tmp_path): + # A row queued before event_uuid existed has none stored. On first flush a fresh uuid is + # minted; it MUST be persisted so every retry reuses the SAME key — else each failed-then- + # retried delivery sends a different uuid and the cloud stores duplicates (the P1-9 bug). + db = str(tmp_path / "q.db") + con = sqlite3.connect(db) + con.execute("CREATE TABLE queue (id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, " + "snaps TEXT, attempts INTEGER DEFAULT 0)") + legacy = {"event_type": "fall", "severity": "high", "camera_id": "cam-01", "track_id": 1, + "zone_id": None, "occurred_at": 100.0, "duration_s": 3.0, "metrics": {}, "spatial": None} + con.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)", + (json.dumps(legacy), json.dumps([]))) + con.commit(); con.close() + + class _Capture: + up = False + uuids = [] + def send(self, event, snapshots): + _Capture.uuids.append(event.event_uuid) + return SendResult.DELIVERED if _Capture.up else SendResult.TRANSIENT + q = OfflineQueue(db, _Capture()) + q.flush() # transient -> row stays; uuid minted and persisted + _Capture.up = True + q.flush() # delivered + assert len(_Capture.uuids) == 2 + assert _Capture.uuids[0] == _Capture.uuids[1] # same key reused across retries diff --git a/tests/test_zones.py b/tests/test_zones.py index 1fcb342..a8f936c 100644 --- a/tests/test_zones.py +++ b/tests/test_zones.py @@ -1,15 +1,19 @@ +import logging + from edge.zones import zones_from_config, thresholds_from_config +# Zones ship NORMALIZED (0..1 of the image); fixtures use that range. + def test_parses_zone_rows_json_string_polygon(): cfg = {"zones": [ {"zone_id": "z1", "camera_id": "cam-1", "level": "restricted", - "polygon_json": "[[0,0],[9,0],[9,9]]"}, + "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]"}, ], "thresholds": {"fall_prone_s": 3.0}} zones = zones_from_config(cfg) assert len(zones) == 1 assert zones[0].zone_id == "z1" assert zones[0].level == "restricted" - assert zones[0].polygon == [(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)] + assert zones[0].polygon == [(0.0, 0.0), (0.9, 0.0), (0.9, 0.9)] assert thresholds_from_config(cfg)["fall_prone_s"] == 3.0 def test_skips_malformed_row(): @@ -22,8 +26,8 @@ def test_empty_config(): def test_skips_short_point_without_crashing(): cfg = {"zones": [ - {"zone_id": "bad", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9],[9,9]]"}, - {"zone_id": "ok", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9,0],[9,9]]"}, + {"zone_id": "bad", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[9],[0.9,0.9]]"}, + {"zone_id": "ok", "camera_id": "c", "level": "restricted", "polygon_json": "[[0,0],[0.9,0],[0.9,0.9]]"}, ]} zones = zones_from_config(cfg) # bad row skipped, good row survives -- function did not crash @@ -31,3 +35,19 @@ def test_skips_short_point_without_crashing(): def test_non_iterable_zones_returns_empty(): assert zones_from_config({"zones": 5}) == [] + +def test_rejects_legacy_pixel_space_polygon_and_warns(caplog): + # A polygon saved by the pre-normalization dashboard is in canvas PIXELS (values in the + # hundreds). After the edge normalizes the foot to 0..1 it can never match, so such a zone + # would silently detect nothing. zones_from_config must DROP it and warn loudly (so the + # operator redraws it) rather than fail dark. + cfg = {"zones": [ + {"zone_id": "legacy", "camera_id": "cam-1", "level": "restricted", + "polygon_json": "[[320,180],[600,180],[600,400]]"}, + {"zone_id": "modern", "camera_id": "cam-1", "level": "restricted", + "polygon_json": "[[0.25,0.25],[0.75,0.25],[0.75,0.75]]"}, + ]} + with caplog.at_level(logging.WARNING): + zones = zones_from_config(cfg) + assert [z.zone_id for z in zones] == ["modern"] # legacy dropped, modern kept + assert any("legacy" in r.message and "pixel" in r.message for r in caplog.records)