From e79dfc83686c4d49c83161faaa9e6ba44b2dadf1 Mon Sep 17 00:00:00 2001 From: CyberMind Date: Mon, 6 Jul 2026 11:53:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(frigate):=20Frigate=20NVR=20Foundation=20?= =?UTF-8?q?=E2=80=94=20podman-in-LXC=20on=20amd64=20+=20/api/v1/frigate=20?= =?UTF-8?q?shim=20(#821)=20(#822)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(frigate): Foundation design — LXC podman on amd64 + /api/v1/frigate shim + WAF-fronted (ref #821) * docs(frigate): Foundation implementation plan — 7 tasks (ref #821) * feat(frigate): package scaffold — control/rules/changelog + placeholder (ref #821) * feat(frigate): idempotent LXC + podman provisioning (mirror photoprism) (ref #821) * feat(frigate): config example + reconcile example path/db-path/image-override (ref #821) Task 3: conf/frigate.config.yml.example — OpenVINO CPU detector, go2rtc demo source (boots with no real camera), record/snapshots retention, mqtt.enabled: false, commented real-camera block. Task 2 review reconciliations: - debian/rules now ships the example to the canonical path /usr/share/secubox/frigate/frigate.config.yml.example, matching what install-lxc.sh's install_config() actually reads (was wrongly shipped to /etc/secubox/frigate.config.yml.example). - config example adds database.path: /media/frigate/db/frigate.db so Frigate's SQLite DB lands on /data/frigate (bind-mounted), not the Frigate default /config/frigate.db. - install-lxc.sh templates the installed frigate.service unit's image tag from $IMAGE via sed after install -D (idempotent, fail-safe via grep -qF check), so SECUBOX_FRIGATE_IMAGE overrides actually take effect instead of silently running the hardcoded :0.14.1. Also drops the unused `openssl` from require_cmds (dead dep from photoprism). * feat(frigate): /api/v1/frigate shim — status/cameras/events/storage/stats, JWT, double-cached, fail-safe (ref #821) * fix(frigate): shim robustness — ImportError-only JWT fallback, cache /storage, bound /events, single /api/stats fetch, + fail-safe tests (ref #821) * feat(frigate): host shim service + disk-pressure guard + frigatectl (ref #821) * fix(frigate): diskguard survives missing/unmounted /data (was silent exit 1) + regression test (ref #821) * feat(frigate): nginx shim route + menu.d + documented WAF exposure & sidebar stats (ref #821) * feat(frigate): postinst/prerm (provision LXC, data-preserving) + build verification (ref #821) --------- Co-authored-by: CyberMind-FR --- .../plans/2026-07-06-frigate-foundation.md | 766 ++++++++++++++++++ .../2026-07-06-frigate-foundation-design.md | 141 ++++ packages/secubox-frigate/README.md | 66 ++ packages/secubox-frigate/api/__init__.py | 4 + packages/secubox-frigate/api/main.py | 154 ++++ .../conf/frigate.config.yml.example | 67 ++ .../secubox-frigate/conf/frigate.nginx.conf | 22 + packages/secubox-frigate/debian/changelog | 8 + packages/secubox-frigate/debian/control | 23 + packages/secubox-frigate/debian/postinst | 17 + packages/secubox-frigate/debian/prerm | 11 + packages/secubox-frigate/debian/rules | 40 + .../debian/secubox-frigate-diskguard.service | 9 + .../debian/secubox-frigate-diskguard.timer | 9 + .../debian/secubox-frigate.service | 22 + packages/secubox-frigate/debian/secubox.yaml | 17 + .../lib/frigate/frigate.container | 25 + .../lib/frigate/install-lxc.sh | 235 ++++++ .../secubox-frigate/menu.d/618-frigate.json | 9 + packages/secubox-frigate/pytest.ini | 2 + packages/secubox-frigate/sbin/frigatectl | 97 +++ .../sbin/secubox-frigate-diskguard | 25 + packages/secubox-frigate/tests/__init__.py | 0 .../secubox-frigate/tests/test_diskguard.py | 26 + .../secubox-frigate/tests/test_frigate_get.py | 85 ++ packages/secubox-frigate/tests/test_shim.py | 53 ++ .../tests/test_stats_contract.py | 16 + .../secubox-frigate/www/frigate/index.html | 3 + 28 files changed, 1952 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-frigate-foundation.md create mode 100644 docs/superpowers/specs/2026-07-06-frigate-foundation-design.md create mode 100644 packages/secubox-frigate/README.md create mode 100644 packages/secubox-frigate/api/__init__.py create mode 100644 packages/secubox-frigate/api/main.py create mode 100644 packages/secubox-frigate/conf/frigate.config.yml.example create mode 100644 packages/secubox-frigate/conf/frigate.nginx.conf create mode 100644 packages/secubox-frigate/debian/changelog create mode 100644 packages/secubox-frigate/debian/control create mode 100755 packages/secubox-frigate/debian/postinst create mode 100755 packages/secubox-frigate/debian/prerm create mode 100755 packages/secubox-frigate/debian/rules create mode 100644 packages/secubox-frigate/debian/secubox-frigate-diskguard.service create mode 100644 packages/secubox-frigate/debian/secubox-frigate-diskguard.timer create mode 100644 packages/secubox-frigate/debian/secubox-frigate.service create mode 100644 packages/secubox-frigate/debian/secubox.yaml create mode 100644 packages/secubox-frigate/lib/frigate/frigate.container create mode 100644 packages/secubox-frigate/lib/frigate/install-lxc.sh create mode 100644 packages/secubox-frigate/menu.d/618-frigate.json create mode 100644 packages/secubox-frigate/pytest.ini create mode 100755 packages/secubox-frigate/sbin/frigatectl create mode 100755 packages/secubox-frigate/sbin/secubox-frigate-diskguard create mode 100644 packages/secubox-frigate/tests/__init__.py create mode 100644 packages/secubox-frigate/tests/test_diskguard.py create mode 100644 packages/secubox-frigate/tests/test_frigate_get.py create mode 100644 packages/secubox-frigate/tests/test_shim.py create mode 100644 packages/secubox-frigate/tests/test_stats_contract.py create mode 100644 packages/secubox-frigate/www/frigate/index.html diff --git a/docs/superpowers/plans/2026-07-06-frigate-foundation.md b/docs/superpowers/plans/2026-07-06-frigate-foundation.md new file mode 100644 index 00000000..d82b08fc --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-frigate-foundation.md @@ -0,0 +1,766 @@ +# Frigate NVR Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up Frigate NVR in a dedicated LXC on the amd64 node (official image via podman), with a JWT'd `/api/v1/frigate/*` shim, cross-node WAF exposure from gk2, storage + disk-pressure guard, and sidebar/menu integration — validated with a go2rtc demo source, no real camera. + +**Architecture:** A new `secubox-frigate` Debian package (Architecture: all) that mirrors `secubox-photoprism` **exactly** in shape (podman container inside a dedicated Debian LXC, a host-side FastAPI shim daemon on a Unix socket, an nginx vhost, a `menu.d` entry, an `frigatectl` lifecycle tool). It installs on **amd64**; gk2's HAProxy→mitmproxy→mesh fronts the Frigate UI (no `waf_bypass`), and gk2's Hub polls the shim over the mesh. + +**Tech Stack:** LXC (`lxc-create` download template), podman (`--network=host` inside the LXC), Frigate official OCI image (OpenVINO CPU detector + bundled go2rtc/ffmpeg), FastAPI/uvicorn (host shim), pytest, HAProxy + mitmproxy, nginx. + +## Global Constraints + +- **Reference module = `packages/secubox-photoprism`** — copy its idioms verbatim (control, rules, `debian/*.service`, `lib/photoprism/install-lxc.sh`, `sbin/photoprismctl`, `postinst`, `nginx/*.conf`, `menu.d/*.json`). Frigate deltas are spelled out per task; everything unspecified follows photoprism. +- **Host node = amd64** (`secubox-live`, mesh `10.10.0.3`, LAN `192.168.1.9`). The package installs there. +- **Podman runs INSIDE the LXC** via a systemd unit in the LXC — **never** driven by the `.deb`/postinst. The `.deb` only provisions the LXC + config; the LXC's own systemd runs Frigate. (`--network=host`, mirror photoprism.) +- **Detector = OpenVINO on CPU**; Frigate image pinned (no `:latest`) — `ghcr.io/blakeblackshear/frigate:0.14.1` (override via `SECUBOX_FRIGATE_IMAGE`). +- **NO `waf_bypass`** — the Frigate UI is fronted by gk2 HAProxy → `mitmproxy_inspector` → mesh → amd64. Update **both** `/srv/mitmproxy/haproxy-routes.json` and `/srv/mitmproxy-in/haproxy-routes.json`, then restart mitmproxy. (This is the one place Frigate differs from photoprism, which bypasses the WAF — do NOT copy photoprism's bypass here.) +- **All shim handlers plain `def`** (aggregator SPOF discipline, even though it runs standalone on amd64). Stats-heavy endpoints **double-cached** (background refresh + JSON cache file). Fail-safe: Frigate down → `status` reports it, others serve empty/last-cache, **never a 5xx storm**. +- **`/api/v1/frigate/stats` returns TOP-LEVEL keys** `cameras`, `events`, `fps` (the sidebar reads them directly — same contract as nac `/stats`). +- **Shim service** sets `RuntimeDirectoryPreserve=yes` (avoid the `/run/secubox` socket-wipe on the satellites). +- **Secrets** (camera RTSP creds) in `/etc/secubox/secrets/` chmod 600 owner `secubox` — never in the repo/config-in-git. +- **Perms:** do not touch `/run` (1777), `/etc/secubox` (0755), `/var/log/secubox` (0755) parents. Media on `/data/frigate`. +- **License header** `LicenseRef-CMSD-1.0` on every new file (Python + bash), via the codebase convention. +- Tests run: `cd packages/secubox-frigate && PYTHONPATH=../../common:. python3 -m pytest tests -q`. + +--- + +## File Structure + +``` +packages/secubox-frigate/ +├── api/ +│ ├── __init__.py +│ └── main.py # FastAPI shim: status/cameras/events/storage/stats (JWT, plain def, double-cache) +├── conf/ +│ ├── frigate.config.yml.example # OpenVINO detector + go2rtc demo source + retention + commented camera +│ └── frigate.nginx.conf # /api/v1/frigate/ → shim socket ; /frigate/ static (placeholder) +├── lib/frigate/ +│ ├── install-lxc.sh # idempotent: create LXC, install podman, drop in-LXC frigate.container unit, bind mounts +│ └── frigate.container # systemd unit (in the LXC) that `podman run`s the pinned Frigate image +├── sbin/ +│ ├── frigatectl # install/status/start/stop/restart lifecycle (mirror photoprismctl) +│ └── secubox-frigate-diskguard # /data disk-pressure check (called by a timer) +├── menu.d/ +│ └── 618-frigate.json # navbar entry +├── nginx/ +│ └── frigate.conf # gk2-side route entry (proxy /api/v1/frigate over mesh to amd64) — Task 6 +├── tests/ +│ ├── test_shim.py # endpoint shapes, JWT gate, plain-def, fail-safe +│ ├── test_stats_contract.py # /stats top-level {cameras,events,fps} +│ └── test_diskguard.py # disk-pressure guard fires on low df (mocked) +├── www/frigate/index.html # minimal placeholder (dashboard = sub-project 2) +├── debian/ +│ ├── changelog control rules postinst prerm +│ ├── secubox-frigate.service # host shim daemon (uvicorn on /run/secubox/frigate.sock) +│ ├── secubox-frigate-diskguard.service + .timer +│ └── secubox.yaml +└── README.md +``` + +--- + +## Task 1: Package scaffold + +**Files:** +- Create: `packages/secubox-frigate/debian/control`, `debian/rules`, `debian/changelog`, `debian/compat` (via compat-13), `debian/secubox.yaml`, `README.md`, `api/__init__.py`, `www/frigate/index.html` +- Reference: `packages/secubox-photoprism/debian/{control,rules,changelog,secubox.yaml}` + +**Interfaces:** +- Produces: an installable (empty-behaviour) `secubox-frigate` `.deb` at version `0.1.0-1~bookworm1`; package name `secubox-frigate`, Architecture: all. + +- [ ] **Step 1: Copy photoprism's debian scaffold, rename to frigate.** Read `packages/secubox-photoprism/debian/control` and create `packages/secubox-frigate/debian/control` with: +``` +Source: secubox-frigate +Section: net +Priority: optional +Maintainer: Gerald KERMA +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 + +Package: secubox-frigate +Architecture: all +Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn | python3-pip, + lxc, lxc-templates, podman, nftables, openssl +Description: Frigate NVR for SecuBox (native LXC, podman) + SecuBox module for the Frigate NVR (frigate.video) running as the official + podman container inside a dedicated Debian LXC on the amd64 node. `frigatectl + install` provisions the LXC + container on demand; the host shim daemon stays + lightweight and always reachable on /run/secubox/frigate.sock. + . + Foundation scope: OpenVINO CPU detector, go2rtc demo source (no camera yet), + storage + retention on /data/frigate, /api/v1/frigate/* shim, and cross-node + exposure through gk2's WAF (mitmproxy — no bypass). The C3BOX dashboard, MQTT, + and 4R config double-buffer are deferred (sub-project 2 / follow-ups). + . + Provides FastAPI backend on /api/v1/frigate/ via a Unix socket. +``` +- [ ] **Step 2: rules + changelog + secubox.yaml.** Copy `packages/secubox-photoprism/debian/rules` verbatim to `packages/secubox-frigate/debian/rules` (dh sequence; it installs `lib/`, `sbin/`, `conf/`, `www/`, `menu.d/`, `api/` — verify the install stanzas reference the frigate paths, adjust any `photoprism`→`frigate` literal). Create `debian/changelog`: +``` +secubox-frigate (0.1.0-1~bookworm1) bookworm; urgency=medium + + * Foundation (#821): Frigate NVR in a podman-in-LXC on amd64; /api/v1/frigate + shim (status/cameras/events/storage/stats); go2rtc demo source; OpenVINO CPU + detector; storage + disk-pressure guard; cross-node WAF exposure (no bypass); + menu.d + sidebar /stats. Dashboard/MQTT/4R deferred. + + -- Gerald KERMA Mon, 06 Jul 2026 00:00:00 +0000 +``` +Copy `debian/secubox.yaml` from photoprism, changing `name`/`socket`/paths to `frigate`. +- [ ] **Step 3: minimal api + placeholder www.** Create `api/__init__.py` (SPDX header + empty). Create `www/frigate/index.html` — a minimal placeholder carrying the SPDX header and one line: `

Frigate — dashboard coming in sub-project 2. API at /api/v1/frigate/

` (the full dashboard is a separate spec; do NOT build it here). +- [ ] **Step 4: build the .deb (arch:all).** +Run: `cd packages/secubox-frigate && dpkg-buildpackage -us -uc -b 2>&1 | tail -3` +Expected: `dpkg-deb: building package 'secubox-frigate' in '../secubox-frigate_0.1.0-1~bookworm1_all.deb'`. +- [ ] **Step 5: verify contents + parseable changelog.** +Run: `dpkg-deb -c ../secubox-frigate_0.1.0-1~bookworm1_all.deb | grep -E "www/frigate|api/__init__"` and `dpkg-parsechangelog -l debian/changelog -S Version` +Expected: files present; Version `0.1.0-1~bookworm1`. +- [ ] **Step 6: Commit.** +```bash +git add packages/secubox-frigate/debian packages/secubox-frigate/api/__init__.py packages/secubox-frigate/www packages/secubox-frigate/README.md +git commit -m "feat(frigate): package scaffold — control/rules/changelog + placeholder (ref #821)" +``` + +--- + +## Task 2: LXC + podman provisioning (`install-lxc.sh` + in-LXC unit) + +**Files:** +- Create: `packages/secubox-frigate/lib/frigate/install-lxc.sh`, `packages/secubox-frigate/lib/frigate/frigate.container` +- Reference: `packages/secubox-photoprism/lib/photoprism/install-lxc.sh` (copy structure verbatim; change the deltas below) + +**Interfaces:** +- Produces: `install-lxc.sh` idempotently creates an LXC named `frigate` (override `SECUBOX_LXC_NAME`) on `br-lxc` at `10.100.0.140` (override `SECUBOX_LXC_IP`), installs podman inside it, drops the `frigate.container`/`podman run` unit, bind-mounts `/etc/secubox/frigate`→`/config` and `/data/frigate`→`/media/frigate`, and starts Frigate. Sentinel `/var/lib/secubox/frigate/.lxc-provisioned`. + +- [ ] **Step 1: copy photoprism's install-lxc.sh, retarget the readonly vars.** Change the header block to frigate; set: +```bash +readonly LXC_NAME="${SECUBOX_LXC_NAME:-frigate}" +readonly LXC_IP="${SECUBOX_LXC_IP:-10.100.0.140}" +readonly DATA_DIR="${SECUBOX_DATA_DIR:-/data/frigate}" +readonly STATE_DIR="${SECUBOX_STATE_DIR:-/var/lib/secubox/frigate}" +readonly CONFIG_DIR="${SECUBOX_FRIGATE_CONFIG:-/etc/secubox/frigate}" +readonly IMAGE="${SECUBOX_FRIGATE_IMAGE:-ghcr.io/blakeblackshear/frigate:0.14.1}" +readonly HTTP_PORT="${SECUBOX_FRIGATE_PORT:-5000}" +readonly SENTINEL="$STATE_DIR/.lxc-provisioned" +``` +Keep photoprism's `require_cmds`, `ensure_bridge`, `ensure_masquerade`, `lxc_state`, `create_lxc`, `write_lxc_config`, `la()` helpers verbatim (they are generic). Add `podman` to `require_cmds` list check inside the LXC step. +- [ ] **Step 2: ensure_dirs for frigate.** Replace photoprism's `ensure_dirs` body with: +```bash +ensure_dirs() { + install -d -m 0755 -o root -g root "$LXC_PATH" + install -d -m 0755 "$STATE_DIR" 2>/dev/null || true + install -d -m 0755 "$CONFIG_DIR" + install -d -m 0750 "$DATA_DIR/db" "$DATA_DIR/recordings" "$DATA_DIR/clips" "$DATA_DIR/exports" + chown -R "$LXC_ROOT_UID:$LXC_ROOT_UID" "$DATA_DIR" + chown "$LXC_ROOT_UID:$LXC_ROOT_UID" "$CONFIG_DIR" +} +``` +- [ ] **Step 3: lay down the config into CONFIG_DIR if absent.** After `ensure_dirs`, add a step that copies the `.example` to the live path only when missing (never clobber operator edits): +```bash +install_config() { + if [ ! -f "$CONFIG_DIR/config.yml" ]; then + log "Seeding $CONFIG_DIR/config.yml from example" + install -m 0640 /usr/share/secubox/frigate/frigate.config.yml.example "$CONFIG_DIR/config.yml" + chown "$LXC_ROOT_UID:$LXC_ROOT_UID" "$CONFIG_DIR/config.yml" + fi +} +``` +- [ ] **Step 4: the in-LXC podman unit `frigate.container`.** Frigate runs `--network=host` (mirror photoprism's reason: unprivileged LXC podman CNI). Create `lib/frigate/frigate.container` as a plain systemd unit (podman run, not quadlet — match photoprism's approach of a unit that execs podman): +```ini +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Installed INTO the frigate LXC by install-lxc.sh; runs the official Frigate +# image. --network=host (unprivileged LXC podman has no CNI bridge). Config + +# media are bind-mounted from the host through the LXC. +[Unit] +Description=Frigate NVR (podman) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +ExecStartPre=-/usr/bin/podman rm -f frigate +ExecStart=/usr/bin/podman run --rm --name frigate --network=host \ + --shm-size=128m \ + -e FRIGATE_RTSP_PASSWORD_FILE=/run/secrets/frigate-rtsp \ + -v /config:/config \ + -v /media/frigate:/media/frigate \ + --tmpfs /tmp/cache:size=256000000 \ + ghcr.io/blakeblackshear/frigate:0.14.1 +ExecStop=/usr/bin/podman stop -t 10 frigate + +[Install] +WantedBy=multi-user.target +``` +In `install-lxc.sh`, add a step that (a) `la() apt-get install -y podman` inside the LXC, (b) copies `frigate.container` to `$LXC_PATH/$LXC_NAME/rootfs/etc/systemd/system/frigate.service`, (c) bind-mounts (via the LXC config `lxc.mount.entry`) the host `$CONFIG_DIR`→`/config` and `$DATA_DIR`→`/media/frigate`, (d) `la() systemctl enable --now frigate`. +- [ ] **Step 5: idempotency sentinel + main().** Mirror photoprism's `main()` order: `require_cmds; ensure_dirs; install_config; ensure_bridge; ensure_masquerade; create_lxc; write_lxc_config; ; touch "$SENTINEL"`. Re-run short-circuits the debootstrap (`create_lxc` already guards on the rootfs dir) and `podman pull` only runs on image change. +- [ ] **Step 6: syntax check.** +Run: `bash -n packages/secubox-frigate/lib/frigate/install-lxc.sh && echo OK` +Expected: `OK`. +- [ ] **Step 7: Commit.** +```bash +git add packages/secubox-frigate/lib +git commit -m "feat(frigate): idempotent LXC + podman provisioning (mirror photoprism) (ref #821)" +``` + +--- + +## Task 3: Frigate config example (OpenVINO + go2rtc demo source) + +**Files:** +- Create: `packages/secubox-frigate/conf/frigate.config.yml.example` + +**Interfaces:** +- Produces: a valid Frigate `config.yml` that boots with the OpenVINO CPU detector and a **go2rtc demo source** (no real camera), so the pipeline runs end-to-end for validation. + +- [ ] **Step 1: write the example config.** Frigate config schema (docs.frigate.video). Create `conf/frigate.config.yml.example`: +```yaml +# SecuBox Frigate config (Foundation). Copied to /etc/secubox/frigate/config.yml +# on first install (never clobbered). OpenVINO CPU detector; a go2rtc demo source +# validates the pipeline with no real camera. Add real cameras under `cameras:`. +mqtt: + enabled: false # deferred (sub-project follow-up) + +detectors: + ov: + type: openvino + device: CPU + +model: + path: /openvino-model/ssdlite_mobilenet_v2.xml + input_tensor: nhwc + input_pixel_format: bgr + width: 300 + height: 300 + +# go2rtc restream — a bundled demo/test source so Frigate runs with no camera. +go2rtc: + streams: + demo: + # Frigate/go2rtc ships an ffmpeg test-pattern generator; replace with rtsp://... for a real camera. + - "ffmpeg:device?video=testsrc#video=h264" + +record: + enabled: true + retain: + days: 3 + mode: motion + +snapshots: + enabled: true + retain: + default: 5 + +cameras: + demo: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/demo + input_args: preset-rtsp-restream + roles: [detect, record] + detect: + width: 1280 + height: 720 + fps: 5 + +# ── Add real cameras later, e.g.: +# cameras: +# frontdoor: +# ffmpeg: +# inputs: +# - path: rtsp://USER:PASS@192.168.1.50:554/stream +# roles: [detect, record] +``` +- [ ] **Step 2: lint it parses as YAML.** +Run: `python3 -c "import yaml,sys; yaml.safe_load(open('packages/secubox-frigate/conf/frigate.config.yml.example')); print('yaml ok')"` +Expected: `yaml ok`. +- [ ] **Step 3: assert the required Foundation keys are present (guards against a broken example).** +Run: +```bash +python3 -c " +import yaml +c=yaml.safe_load(open('packages/secubox-frigate/conf/frigate.config.yml.example')) +assert c['detectors']['ov']['type']=='openvino', 'detector must be openvino' +assert 'demo' in c['go2rtc']['streams'], 'demo go2rtc source required' +assert c['record']['enabled'] is True, 'record must be enabled' +assert c['mqtt']['enabled'] is False, 'mqtt deferred → disabled' +print('config contract ok')" +``` +Expected: `config contract ok`. +- [ ] **Step 4: Commit.** +```bash +git add packages/secubox-frigate/conf/frigate.config.yml.example +git commit -m "feat(frigate): config example — OpenVINO detector + go2rtc demo source (ref #821)" +``` + +--- + +## Task 4: API shim (`api/main.py`) — status/cameras/events/storage/stats + +**Files:** +- Create: `packages/secubox-frigate/api/main.py`, `packages/secubox-frigate/tests/test_shim.py`, `packages/secubox-frigate/tests/test_stats_contract.py`, `packages/secubox-frigate/tests/__init__.py`, `packages/secubox-frigate/pytest.ini` +- Reference: `packages/secubox-photoprism/api/main.py` (FastAPI app + JWT dep + socket idioms) + +**Interfaces:** +- Consumes: `secubox_core.auth.require_jwt` (JWT dependency, from `common/secubox_core`), Frigate HTTP API at `FRIGATE_URL` (default `http://10.100.0.140:5000`). +- Produces: a FastAPI `app` with a `router` prefixed `/api/v1/frigate`, 5 GET handlers (all plain `def`), a module-level `_cache` dict + `refresh_cache()` background task started on `@app.on_event("startup")`, and a `_frigate_get(path)` helper that returns `(json_or_None, ok_bool)` fail-safe. + +- [ ] **Step 1: write failing tests for the endpoint shapes + fail-safe.** Create `tests/test_shim.py`: +```python +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +import importlib, inspect +from fastapi.testclient import TestClient + +def _app(monkeypatch, frigate_stats=None, frigate_events=None, up=True): + import api.main as m + importlib.reload(m) + # bypass JWT + m.app.dependency_overrides[m.require_jwt] = lambda: {"sub": "test"} + def fake_get(path): + if not up: + return None, False + if path == "/api/stats": + return frigate_stats or {"cameras": {"demo": {"camera_fps": 5, "detection_fps": 4.9, "process_fps": 5}}, + "detectors": {"ov": {"inference_speed": 12.3}}, + "service": {"version": "0.14.1", "uptime": 3600}}, True + if path == "/api/events": + return frigate_events or [{"id": "1", "label": "person", "camera": "demo", "start_time": 1, "zones": []}], True + return {}, True + monkeypatch.setattr(m, "_frigate_get", fake_get) + return TestClient(m.app), m + +def test_status_up(monkeypatch): + c, _ = _app(monkeypatch) + r = c.get("/api/v1/frigate/status") + assert r.status_code == 200 + b = r.json() + assert b["up"] is True and b["version"] == "0.14.1" + +def test_status_down_is_failsafe(monkeypatch): + c, _ = _app(monkeypatch, up=False) + r = c.get("/api/v1/frigate/status") + assert r.status_code == 200 # never 5xx + assert r.json()["up"] is False + +def test_cameras_shape(monkeypatch): + c, _ = _app(monkeypatch) + r = c.get("/api/v1/frigate/cameras") + assert r.status_code == 200 + cams = r.json()["cameras"] + assert cams[0]["name"] == "demo" and cams[0]["online"] is True + +def test_events_bounded(monkeypatch): + c, _ = _app(monkeypatch) + r = c.get("/api/v1/frigate/events") + assert r.status_code == 200 + assert r.json()["events"][0]["label"] == "person" + +def test_all_handlers_plain_def(monkeypatch): + _, m = _app(monkeypatch) + for name in ("status", "cameras", "events", "storage", "stats"): + fn = getattr(m, name) + assert not inspect.iscoroutinefunction(fn), f"{name} must be plain def (aggregator SPOF rule)" +``` +- [ ] **Step 2: run — fails (no module).** +Run: `cd packages/secubox-frigate && PYTHONPATH=../../common:. python3 -m pytest tests/test_shim.py -q` +Expected: FAIL (`ModuleNotFoundError: api.main` / import error). +- [ ] **Step 3: implement `api/main.py`.** +```python +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +"""SecuBox-Deb :: secubox-frigate :: /api/v1/frigate/* shim (Foundation, #821).""" +import json, os, shutil, threading, time, urllib.request +from pathlib import Path +from fastapi import APIRouter, Depends, FastAPI + +try: + from secubox_core.auth import require_jwt +except Exception: # test/offline fallback + def require_jwt(): # noqa: D401 + return {"sub": "anon"} + +FRIGATE_URL = os.environ.get("SECUBOX_FRIGATE_URL", "http://10.100.0.140:5000") +DATA_DIR = os.environ.get("SECUBOX_FRIGATE_DATA", "/data/frigate") +CACHE_FILE = Path("/var/cache/secubox/frigate/stats.json") +EVENTS_LIMIT = 50 + +app = FastAPI(title="secubox-frigate") +router = APIRouter(prefix="/api/v1/frigate") +_cache: dict = {} +_lock = threading.Lock() + + +def _frigate_get(path: str): + """GET Frigate's HTTP API. Returns (json, True) or (None, False). Never raises.""" + try: + with urllib.request.urlopen(FRIGATE_URL + path, timeout=3) as r: + return json.loads(r.read().decode("utf-8")), True + except Exception: + return None, False + + +def _compute_status() -> dict: + data, ok = _frigate_get("/api/stats") + if not ok or not data: + return {"up": False, "version": None, "uptime": None, "detector_fps": None} + svc = data.get("service", {}) + det = next(iter(data.get("detectors", {}).values()), {}) + return {"up": True, "version": svc.get("version"), "uptime": svc.get("uptime"), + "detector_fps": det.get("inference_speed")} + + +def _compute_cameras() -> list: + data, ok = _frigate_get("/api/stats") + if not ok or not data: + return [] + out = [] + for name, c in (data.get("cameras") or {}).items(): + out.append({"name": name, "online": (c.get("camera_fps", 0) or 0) > 0, + "camera_fps": c.get("camera_fps"), "detection_fps": c.get("detection_fps"), + "process_fps": c.get("process_fps")}) + return out + + +def _compute_events() -> list: + data, ok = _frigate_get("/api/events") + if not ok or not data: + return [] + out = [] + for e in data[:EVENTS_LIMIT]: + eid = e.get("id") + out.append({"id": eid, "label": e.get("label"), "camera": e.get("camera"), + "start_time": e.get("start_time"), "zones": e.get("zones", []), + "snapshot": f"/api/v1/frigate/media/events/{eid}/snapshot.jpg" if eid else None}) + return out + + +def _compute_storage() -> dict: + try: + du = shutil.disk_usage(DATA_DIR) + rec = Path(DATA_DIR) / "recordings" + oldest = None + if rec.is_dir(): + files = sorted(rec.rglob("*.mp4")) + if files: + oldest = int(files[0].stat().st_mtime) + return {"path": DATA_DIR, "total": du.total, "used": du.used, "free": du.free, + "pct_used": round(du.used / du.total * 100, 1) if du.total else 0, "oldest_recording": oldest} + except Exception: + return {"path": DATA_DIR, "total": None, "used": None, "free": None, "pct_used": None, "oldest_recording": None} + + +def _compute_stats() -> dict: + cams = _compute_cameras() + evs = _compute_events() + det = _compute_status().get("detector_fps") + # TOP-LEVEL keys the sidebar reads directly (nac /stats contract). + return {"cameras": len(cams), "events": len(evs), "fps": det, + "by_camera": {c["name"]: c.get("detection_fps") for c in cams}} + + +def refresh_cache(): + while True: + try: + data = _compute_stats() + CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + CACHE_FILE.write_text(json.dumps(data)) + with _lock: + _cache.update(data) + except Exception: + pass + time.sleep(60) + + +@app.on_event("startup") +def _startup(): + threading.Thread(target=refresh_cache, daemon=True).start() + + +@router.get("/status") +def status(user=Depends(require_jwt)): + return _compute_status() + + +@router.get("/cameras") +def cameras(user=Depends(require_jwt)): + return {"cameras": _compute_cameras()} + + +@router.get("/events") +def events(user=Depends(require_jwt)): + return {"events": _compute_events()} + + +@router.get("/storage") +def storage(user=Depends(require_jwt)): + return _compute_storage() + + +@router.get("/stats") +def stats(user=Depends(require_jwt)): + with _lock: + if _cache: + return dict(_cache) + if CACHE_FILE.exists(): + try: + return json.loads(CACHE_FILE.read_text()) + except Exception: + pass + return _compute_stats() + + +app.include_router(router) +``` +NOTE for the implementer: keep `status/cameras/events/storage/stats` as **module-level** `def`s (decorated with `@router.get`). Do not nest them inside another function — the tests read `m.status` etc. directly and assert `not iscoroutinefunction`. +- [ ] **Step 4: create `tests/__init__.py` (empty) and `pytest.ini`** with `[pytest]\ntestpaths = tests`. +- [ ] **Step 5: run — passes.** +Run: `cd packages/secubox-frigate && PYTHONPATH=../../common:. python3 -m pytest tests/test_shim.py -q` +Expected: PASS (5 tests). +- [ ] **Step 6: /stats contract test.** Create `tests/test_stats_contract.py`: +```python +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +import importlib +from fastapi.testclient import TestClient + +def test_stats_top_level_keys(monkeypatch): + import api.main as m + importlib.reload(m) + m.app.dependency_overrides[m.require_jwt] = lambda: {"sub": "t"} + monkeypatch.setattr(m, "_frigate_get", lambda p: ( + ({"cameras": {"demo": {"camera_fps": 5, "detection_fps": 4.5, "process_fps": 5}}, + "detectors": {"ov": {"inference_speed": 10.0}}, "service": {"version": "0.14.1"}}, True) + if p == "/api/stats" else ([{"id": "1", "label": "car", "camera": "demo"}], True))) + m._cache.clear() + b = TestClient(m.app).get("/api/v1/frigate/stats").json() + assert set(["cameras", "events", "fps"]).issubset(b), "sidebar reads top-level cameras/events/fps" + assert b["cameras"] == 1 and b["events"] == 1 and b["fps"] == 10.0 +``` +Run: `PYTHONPATH=../../common:. python3 -m pytest tests/test_stats_contract.py -q` +Expected: PASS. +- [ ] **Step 7: Commit.** +```bash +git add packages/secubox-frigate/api/main.py packages/secubox-frigate/tests packages/secubox-frigate/pytest.ini +git commit -m "feat(frigate): /api/v1/frigate shim — status/cameras/events/storage/stats, JWT, double-cached, fail-safe (ref #821)" +``` + +--- + +## Task 5: Host shim service + disk-pressure guard + `frigatectl` + +**Files:** +- Create: `packages/secubox-frigate/debian/secubox-frigate.service`, `debian/secubox-frigate-diskguard.service`, `debian/secubox-frigate-diskguard.timer`, `sbin/secubox-frigate-diskguard`, `sbin/frigatectl`, `tests/test_diskguard.py` +- Reference: `packages/secubox-photoprism/debian/secubox-photoprism.service`, `packages/secubox-photoprism/sbin/photoprismctl` + +**Interfaces:** +- Consumes: `api/main.py` (`app`). +- Produces: `secubox-frigate.service` (uvicorn on `/run/secubox/frigate.sock`, `User=secubox`, `RuntimeDirectoryPreserve=yes`), a `secubox-frigate-diskguard` oneshot+timer, `frigatectl {install,status,start,stop,restart}` delegating to `install-lxc.sh` + `lxc-attach`. + +- [ ] **Step 1: shim service (copy photoprism's, retarget paths).** Create `debian/secubox-frigate.service`: +```ini +[Unit] +Description=SecuBox Frigate API shim +After=network.target secubox-core.service +Requires=secubox-core.service + +[Service] +Type=simple +User=secubox +Group=secubox +WorkingDirectory=/usr/lib/secubox/frigate +ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/frigate.sock --log-level warning +Restart=on-failure +RestartSec=5 +UMask=0000 +NoNewPrivileges=true +RuntimeDirectory=secubox +RuntimeDirectoryPreserve=yes +RuntimeDirectoryMode=0775 +ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /var/log/secubox /var/cache/secubox /data/frigate + +[Install] +WantedBy=multi-user.target +``` +- [ ] **Step 2: failing test for the disk guard.** Create `tests/test_diskguard.py`: +```python +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +import subprocess, sys, os +GUARD = os.path.join(os.path.dirname(__file__), "..", "sbin", "secubox-frigate-diskguard") + +def test_guard_fires_below_threshold(tmp_path): + # DF override: guard reads SECUBOX_FRIGATE_DF_PCT for testability + env = {**os.environ, "SECUBOX_FRIGATE_DF_PCT": "95", "SECUBOX_FRIGATE_DISK_LIMIT": "90"} + r = subprocess.run(["bash", GUARD], capture_output=True, text=True, env=env) + assert r.returncode == 2, "guard must exit 2 when over the limit" + assert "disk pressure" in (r.stdout + r.stderr).lower() + +def test_guard_ok_below_limit(): + env = {**os.environ, "SECUBOX_FRIGATE_DF_PCT": "40", "SECUBOX_FRIGATE_DISK_LIMIT": "90"} + r = subprocess.run(["bash", GUARD], capture_output=True, text=True, env=env) + assert r.returncode == 0 +``` +- [ ] **Step 3: run — fails (no guard script).** +Run: `cd packages/secubox-frigate && python3 -m pytest tests/test_diskguard.py -q` +Expected: FAIL. +- [ ] **Step 4: implement `sbin/secubox-frigate-diskguard`.** +```bash +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: secubox-frigate :: /data disk-pressure guard. +set -euo pipefail +readonly DATA_DIR="${SECUBOX_FRIGATE_DATA:-/data/frigate}" +readonly LIMIT="${SECUBOX_FRIGATE_DISK_LIMIT:-90}" # percent +# Testable: SECUBOX_FRIGATE_DF_PCT overrides the measured value. +if [ -n "${SECUBOX_FRIGATE_DF_PCT:-}" ]; then + pct="$SECUBOX_FRIGATE_DF_PCT" +else + pct="$(df --output=pcent "$DATA_DIR" 2>/dev/null | tail -1 | tr -dc '0-9')" + pct="${pct:-0}" +fi +if [ "$pct" -ge "$LIMIT" ]; then + logger -t secubox-frigate "disk pressure: ${pct}% >= ${LIMIT}% on ${DATA_DIR} — reduce retention" + echo "disk pressure: ${pct}% >= ${LIMIT}%" >&2 + exit 2 +fi +echo "disk ok: ${pct}% < ${LIMIT}%" +exit 0 +``` +- [ ] **Step 5: run — passes.** +Run: `python3 -m pytest tests/test_diskguard.py -q` +Expected: PASS (2). +- [ ] **Step 6: guard unit + timer.** Create `debian/secubox-frigate-diskguard.service` (`Type=oneshot`, `ExecStart=/usr/sbin/secubox-frigate-diskguard`, `User=secubox`) and `debian/secubox-frigate-diskguard.timer` (`OnCalendar=*:0/15`, `Persistent=true`, `WantedBy=timers.target`). +- [ ] **Step 7: `frigatectl` (copy photoprismctl, retarget).** Create `sbin/frigatectl` mirroring `sbin/photoprismctl`: subcommands `install` (runs `/usr/share/secubox/lib/frigate/install-lxc.sh`), `status`/`start`/`stop`/`restart` (`lxc-info`/`lxc-attach -n frigate ... systemctl frigate`). `bash -n` clean. +Run: `bash -n packages/secubox-frigate/sbin/frigatectl && bash -n packages/secubox-frigate/sbin/secubox-frigate-diskguard && echo OK` +Expected: `OK`. +- [ ] **Step 8: Commit.** +```bash +git add packages/secubox-frigate/debian/secubox-frigate.service packages/secubox-frigate/debian/secubox-frigate-diskguard.* packages/secubox-frigate/sbin packages/secubox-frigate/tests/test_diskguard.py +git commit -m "feat(frigate): host shim service + disk-pressure guard + frigatectl (ref #821)" +``` + +--- + +## Task 6: Cross-node exposure (WAF) + nginx route + menu.d + sidebar stats + +**Files:** +- Create: `packages/secubox-frigate/conf/frigate.nginx.conf`, `packages/secubox-frigate/menu.d/618-frigate.json` +- Reference: `packages/secubox-photoprism/conf/photoprism.nginx.conf`, `packages/secubox-photoprism/menu.d/617-photoprism.json`, and the sidebar contract at `packages/secubox-hub/www/shared/sidebar.js:203-218` + +**Interfaces:** +- Produces: an nginx location routing `/api/v1/frigate/` → the shim socket; a `menu.d` entry; documentation of the gk2 HAProxy + both mitmproxy route-file edits (applied at deploy, not in the package). + +- [ ] **Step 1: nginx route.** Create `conf/frigate.nginx.conf` mirroring photoprism's, but the API location proxies to the shim socket and the UI location routes through the WAF (documented), NOT a bypass: +```nginx +# secubox-frigate — API shim (local socket) + placeholder UI. +# NOTE: the Frigate UI itself is fronted by gk2 HAProxy→mitmproxy→mesh→amd64 +# (no waf_bypass). This file only serves the shim API + the placeholder page. +location /api/v1/frigate/ { + proxy_pass http://unix:/run/secubox/frigate.sock:/api/v1/frigate/; + include /etc/nginx/snippets/secubox-proxy.conf; + proxy_intercept_errors on; +} +location /frigate/ { + alias /usr/share/secubox/www/frigate/; + index index.html; + try_files $uri $uri/ /frigate/index.html; +} +``` +- [ ] **Step 2: menu.d entry.** Create `menu.d/618-frigate.json` mirroring `617-photoprism.json` shape — title "Frigate", path `/frigate/`, an appropriate icon, category security/media. Verify JSON parses: +Run: `python3 -c "import json; json.load(open('packages/secubox-frigate/menu.d/618-frigate.json')); print('ok')"` +Expected: `ok`. +- [ ] **Step 3: document the sidebar stats wiring.** In `README.md`, record the exact line to add to `packages/secubox-hub/www/shared/sidebar.js` PAGE_METRICS map (the shared navbar; a hub change, applied separately): +``` +'/frigate/': { metrics: ['cameras','events','fps'], api: '/api/v1/frigate/stats' }, +``` +(Do NOT edit sidebar.js in this package — it belongs to secubox-hub; note it for the deploy step. The `/stats` endpoint already returns those top-level keys — Task 4.) +- [ ] **Step 4: document the gk2 WAF exposure (deploy-time, both route files).** In `README.md`, record the exact deploy steps (run on gk2, not in the package): +``` +# gk2: front the amd64 Frigate UI through the WAF (NO bypass) +haproxyctl vhost add frigate.gk2.secubox.in # backend defaults to mitmproxy_inspector +# add to BOTH /srv/mitmproxy/haproxy-routes.json AND /srv/mitmproxy-in/haproxy-routes.json: +# "frigate.gk2.secubox.in": ["10.100.0.140", 5000] # amd64 frigate LXC over the mesh +systemctl restart mitmproxy +``` +- [ ] **Step 5: Commit.** +```bash +git add packages/secubox-frigate/conf/frigate.nginx.conf packages/secubox-frigate/menu.d packages/secubox-frigate/README.md +git commit -m "feat(frigate): nginx shim route + menu.d + documented WAF exposure & sidebar stats (ref #821)" +``` + +--- + +## Task 7: postinst / prerm + build & install verification + +**Files:** +- Create/modify: `packages/secubox-frigate/debian/postinst`, `debian/prerm`, `debian/rules` (install stanzas) +- Reference: `packages/secubox-photoprism/debian/{postinst,prerm,rules}` + +**Interfaces:** +- Produces: postinst that creates the `secubox` interaction dirs, enables the shim + diskguard timer (NOT the in-LXC frigate unit), and runs `frigatectl install` (provision the LXC) — guarded so a build host without LXC doesn't fail; prerm stops the shim + timer, leaves `/data/frigate` + `/etc/secubox/frigate` intact. + +- [ ] **Step 1: postinst (mirror photoprism, adapt).** Create `debian/postinst`: +```bash +#!/bin/bash +set -e +case "$1" in + configure) + install -d -m 0755 -o secubox -g secubox /var/cache/secubox/frigate 2>/dev/null || true + systemctl daemon-reload || true + deb-systemd-helper enable secubox-frigate.service >/dev/null 2>&1 || true + deb-systemd-invoke restart secubox-frigate.service >/dev/null 2>&1 || true + systemctl enable --now secubox-frigate-diskguard.timer >/dev/null 2>&1 || true + # Provision the LXC only where LXC is available (the amd64 host). Never fail install. + if command -v lxc-create >/dev/null 2>&1; then + /usr/sbin/frigatectl install || echo "frigatectl install deferred — run manually on the frigate host" >&2 + fi + ;; +esac +#DEBHELPER# +exit 0 +``` +- [ ] **Step 2: prerm.** Create `debian/prerm`: +```bash +#!/bin/bash +set -e +case "$1" in + remove|deconfigure) + systemctl stop secubox-frigate-diskguard.timer >/dev/null 2>&1 || true + deb-systemd-invoke stop secubox-frigate.service >/dev/null 2>&1 || true + # Leave the frigate LXC, /data/frigate, and /etc/secubox/frigate intact (data-preserving). + ;; +esac +#DEBHELPER# +exit 0 +``` +- [ ] **Step 3: rules install stanzas + no dh_installsystemd auto-manage of the in-LXC unit.** In `debian/rules`, ensure `lib/frigate/frigate.container` is installed to `/usr/share/secubox/lib/frigate/` (NOT to `/lib/systemd`), the `.example` to `/usr/share/secubox/frigate/`, `sbin/*` to `/usr/sbin/`, `www/frigate/` to `/usr/share/secubox/www/frigate/`, `conf/frigate.nginx.conf` to the module nginx dir, `menu.d/*` to `/usr/share/secubox/menu.d/`, `api/` to `/usr/lib/secubox/frigate/`. Mirror photoprism's `rules` exactly for placement. +- [ ] **Step 4: build the full package.** +Run: `cd packages/secubox-frigate && dpkg-buildpackage -us -uc -b 2>&1 | tail -3` +Expected: builds `secubox-frigate_0.1.0-1~bookworm1_all.deb`. +- [ ] **Step 5: verify the .deb ships everything + scripts parse.** +Run: +```bash +dpkg-deb -c ../secubox-frigate_0.1.0-1~bookworm1_all.deb | grep -E "install-lxc.sh|frigate.container|frigate.config.yml.example|usr/sbin/frigatectl|secubox-frigate-diskguard|api/main.py|www/frigate|618-frigate.json" +dpkg-deb -e ../secubox-frigate_0.1.0-1~bookworm1_all.deb /tmp/frig-ctl && bash -n /tmp/frig-ctl/postinst && bash -n /tmp/frig-ctl/prerm && echo "maintainer scripts OK" +``` +Expected: all paths present; `maintainer scripts OK`. +- [ ] **Step 6: full test suite green.** +Run: `PYTHONPATH=../../common:. python3 -m pytest tests -q` +Expected: all pass (shim + stats-contract + diskguard). +- [ ] **Step 7: Commit.** +```bash +git add packages/secubox-frigate/debian/postinst packages/secubox-frigate/debian/prerm packages/secubox-frigate/debian/rules +git commit -m "feat(frigate): postinst/prerm (provision LXC, data-preserving) + build verification (ref #821)" +``` + +--- + +## Deployment (post-merge, human-run — NOT part of the plan's tasks) + +Documented in `README.md` (Task 6). On amd64: install the `.deb` → postinst runs `frigatectl install` → LXC boots Frigate with the demo source. On gk2: `haproxyctl vhost add frigate.gk2.secubox.in`, add the route to **both** mitmproxy files, `systemctl restart mitmproxy`, and add the sidebar PAGE_METRICS line to secubox-hub. Verify `/api/v1/frigate/status` `up:true`, the sidebar badge populates, and Frigate's UI loads through the WAF chain. + +--- + +## Self-Review Notes + +- **Spec coverage:** §4.2 LXC/podman → Task 2; §4.3 config → Task 3; §4.4 shim (5 endpoints, plain def, double-cache, fail-safe) → Task 4; §4.5 cross-node WAF (both route files, no bypass) → Task 6; §4.6 security (JWT, secrets, RuntimeDirectoryPreserve) → Tasks 4/5/6; storage + disk guard → Task 5; menu.d + /stats → Task 6; packaging/postinst → Tasks 1/7. All covered. +- **Deferred (per spec §2):** C3BOX dashboard (sub-project 2), MQTT (config `enabled:false`), 4R double-buffer — none appear as tasks. Correct. diff --git a/docs/superpowers/specs/2026-07-06-frigate-foundation-design.md b/docs/superpowers/specs/2026-07-06-frigate-foundation-design.md new file mode 100644 index 00000000..626810c2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-frigate-foundation-design.md @@ -0,0 +1,141 @@ +# Design — Frigate NVR Foundation (Sub-project 1 of #821) + +- **Issue**: [#821](https://github.com/CyberMind-FR/secubox-deb/issues/821) +- **Date**: 2026-07-06 +- **Licence**: LicenseRef-CMSD-1.0 +- **Module**: new `packages/secubox-frigate/` +- **Scope**: Foundation only. The full custom C3BOX dashboard is **Sub-project 2** (separate spec). + +## 1. Problème + +SecuBox has no NVR / camera-analytics capability. Frigate (frigate.video) is a local-only NVR with +real-time AI object detection (person/car/…), event clips, and a mature API — a natural fit beside +DPI/WAF/SOC. It must land as a first-class SecuBox module, **LXC-native** (own systemd inside a dedicated +LXC, not driven by the `.deb`), reachable through the existing WAF/Hub conventions — without a working +camera yet (framework-first). + +## 2. Objectif (Foundation) + +Frigate running in a dedicated LXC on the **amd64** node, validated against a **go2rtc demo source** (no real +camera), with a `secubox-frigate` `.deb` that provisions the LXC + config + storage, a JWT'd `/api/v1/frigate/*` +API shim (status/cameras/events/storage/stats), cross-node exposure through gk2's WAF, and the sidebar +`/stats` + `menu.d` integration. **Non-goals (deferred):** the custom C3BOX dashboard (Sub-project 2), +MQTT/Home-Assistant wiring, and full 4R double-buffer on the Frigate config. + +## 3. Décisions (brainstorm 2026-07-06) + +| Axe | Décision | +|-----|----------| +| Host | **amd64** (x86-64, `secubox-live`, mesh 10.10.0.3 / LAN 192.168.1.9) — most CPU/RAM headroom; detection-bound workload | +| Detector | **OpenVINO on CPU** (x86) — no extra hardware; model bundled in the Frigate image | +| Install | **Official Frigate OCI image via podman**, run by a **systemd quadlet INSIDE the LXC** (not by the `.deb`). Upstream is Docker-only; bare-metal is unsupported. The guideline's intent (the *package* doesn't drive containers) holds — the LXC's own systemd does | +| Cameras | **Framework-first** — a go2rtc demo/test source validates the pipeline; real cameras added to config later | +| WebUI | **Full custom C3BOX dashboard = Sub-project 2**; Foundation ships the API + a minimal placeholder page only | +| Shim placement | **amd64**, standalone `secubox-frigate.service` (mirrors how nac is served on amd64); gk2's Hub aggregates over the mesh | +| Exposure | gk2 **HAProxy → `mitmproxy_inspector` → (mesh) → amd64** — **no `waf_bypass`**; both mitmproxy route files updated | +| Storage/DB | Recordings + Frigate **SQLite** DB on amd64 `/data/frigate` (bind host→LXC→container); retention via Frigate config + a disk-pressure guard | +| Config | `/etc/secubox/frigate/config.yml` (+ `.example`); camera creds in `/etc/secubox/secrets/` chmod 600 | + +## 4. Architecture + +### 4.1 Runtime topology +``` +amd64 host (192.168.1.9 / mesh 10.10.0.3) + ├── /etc/secubox/frigate/config.yml (operator config; bind-mounted in) + ├── /data/frigate/{db,recordings,clips} (media + SQLite; bind-mounted in) + ├── LXC "frigate" (dedicated, idmap per fleet pattern) + │ └── systemd quadlet → podman: ghcr.io/blakeblackshear/frigate: + │ ├── frigate app (:5000 HTTP API + UI) + │ ├── go2rtc (:1984 / RTSP restream → WebRTC/MSE) + │ └── OpenVINO detector (CPU) + ffmpeg + └── secubox-frigate.service → /api/v1/frigate/* (FastAPI shim, JWT, plain def) + └── queries http://:5000 (Frigate API) + go2rtc + +gk2 (WAF/Hub front) + └── HAProxy(frigate vhost) → mitmproxy_inspector → nginx/route → (mesh) → amd64 frigate UI/API + └── Hub polls amd64 /api/v1/frigate/stats over the mesh for cross-node aggregation +``` + +### 4.2 LXC provisioning — `lib/frigate/install-lxc.sh` (idempotent) +- Create the `frigate` LXC on amd64 if absent (Debian rootfs, idmap consistent with the fleet's shared + pattern — mirror `secubox-photoprism`/`secubox-peertube` `install-lxc.sh`). +- Install podman in the LXC; drop the **quadlet** unit (`frigate.container`) that runs the official image + with the bind mounts (`/etc/secubox/frigate` → container `/config`, `/data/frigate` → `/media/frigate`) + and the OpenVINO device access needed on x86 CPU. +- `systemctl --machine` / `lxc-attach` enable+start the quadlet inside the LXC. +- Re-run = no-op (guarded creation, `podman pull` only past an image-tag change). +- The image tag is **pinned** (reproducible), overridable via config. + +### 4.3 Frigate config (`/etc/secubox/frigate/config.yml`) +- Ships a validated **`.example`** with: the OpenVINO detector + bundled model, the go2rtc **demo source** + (a test-pattern / sample RTSP so the pipeline runs with zero cameras), `record` + `snapshots` retention + defaults, and a commented camera block operators fill in later. +- Frigate validates config on start and via its config API; a bad edit fails Frigate startup loudly (surfaced + by the shim's `status`). Full 4R double-buffer is deferred. + +### 4.4 API shim — `api/main.py` (`secubox-frigate`, FastAPI, JWT, **plain `def`**) +All handlers plain `def` (FastAPI threadpools them); stats-heavy ones **double-cached** (background refresh +task + JSON cache file, per the perf pattern) so a request never blocks on Frigate/go2rtc. +- `GET /api/v1/frigate/status` — Frigate reachable? version, uptime, detector inference speed/fps, config-valid. +- `GET /api/v1/frigate/cameras` — cameras + per-camera state (online, detect/record fps, last-seen) from Frigate `/api/stats`. +- `GET /api/v1/frigate/events` — recent detections (label, camera, ts, zone) + snapshot/clip URLs, from Frigate `/api/events` (bounded). +- `GET /api/v1/frigate/storage` — `/data/frigate` usage, retention days, oldest recording, free space. +- `GET /api/v1/frigate/stats` — **top-level** `{cameras, events, fps}` for the sidebar badge (matches the nac `/stats` contract the sidebar polls). +- Fail-safe: Frigate down → `status` reports it, other endpoints return empty/last-cache, never 5xx-storm. + +### 4.5 Cross-node exposure (gk2 fronts amd64) +- Add a frigate vhost on gk2's HAProxy routed to `mitmproxy_inspector` (no `waf_bypass`); mitmproxy forwards + over the mesh to amd64's Frigate UI/API. Update **both** `/srv/mitmproxy/haproxy-routes.json` and + `/srv/mitmproxy-in/haproxy-routes.json` + restart mitmproxy (CLAUDE.md rule). +- nginx route `/api/v1/frigate/` on the serving node → the amd64 shim socket (local on amd64; over-mesh proxy + entry on gk2 for the Hub). +- Frigate's UI is **never** exposed directly to the LAN/WAN — only via the WAF chain. + +### 4.6 Security +- Frigate's built-in auth (0.14+) enabled; SecuBox **JWT** required on every shim endpoint (`Depends(require_jwt)`). +- Camera RTSP creds in `/etc/secubox/secrets/frigate-*` chmod 600 owner `secubox`; referenced from config, never in the repo. +- nftables on amd64: allow the frigate LXC → camera RTSP (LAN) only; no inbound to Frigate except via the WAF chain. +- `/run`/`/etc/secubox`/`/var/log/secubox`/`/data` parent perms untouched; media dir owned appropriately for the LXC idmap. + +## 5. Data flow +``` +go2rtc demo source ─▶ Frigate (detect via OpenVINO) ─▶ SQLite events + /data recordings + │ + shim (amd64) ◀── Frigate /api/stats,/api/events ──┘ + │ + /api/v1/frigate/* (JWT, cached) ──▶ gk2 Hub (mesh) ──▶ sidebar badge / (later) C3BOX dashboard + Frigate UI ──▶ HAProxy(gk2) ─ mitmproxy ─ mesh ─▶ amd64 (no waf_bypass) +``` + +## 6. Error handling / constraints +- **Fail-safe shim**: Frigate/LXC down → `status` says so; cached/empty elsewhere; never a 5xx storm (WAF would amplify). +- **Aggregator SPOF rule** applies IF the shim is ever aggregator-mounted: all handlers plain `def`, no blocking on the loop. On amd64 it's a standalone service, but keep the discipline for portability. +- **Idempotent provisioning**: `install-lxc.sh` re-run = no-op; a partial LXC create is repaired, never bricks the host. +- **Disk-pressure guard**: a small timer checks `/data` free space; below threshold → log + (config) reduce retention / alert. Recording never fills the disk unbounded. +- **Socket hygiene**: the shim service uses `RuntimeDirectoryPreserve=yes` (avoid the `/run/secubox` wipe that bit nac on the satellites). +- **Image pin**: reproducible Frigate version; upgrades are a deliberate tag bump, not silent `:latest`. + +## 7. Tests +- `install-lxc.sh`: idempotent create (mock/dry-run), quadlet laid down, bind mounts correct, re-run no-op. +- Config `.example`: validates against Frigate's schema (or a lint) — demo source present, detector = openvino. +- Shim: each endpoint with a mocked Frigate `/api/stats`,`/api/events` fixture → correct shape; `/stats` top-level `{cameras,events,fps}`; JWT-gated (401 without token); plain `def`; Frigate-down → `status` down + others fail-safe (no 5xx). +- Storage guard: fixture `/data` low → guard fires (mocked df). +- Cross-node: route entries present in both mitmproxy files; HAProxy backend = `mitmproxy_inspector` (no bypass) — structural check. +- Manual: on amd64, LXC up, Frigate UI reachable through the WAF chain, demo source produces an event. + +## 8. Séquencement (pour le plan) +1. Package scaffold (`secubox-frigate`: debian/, control, structure mirroring an LXC module). +2. `lib/frigate/install-lxc.sh` + the quadlet unit (create LXC, podman, bind mounts, OpenVINO) — idempotent. +3. Frigate `config.yml.example` (OpenVINO detector + go2rtc demo source + retention + commented camera). +4. API shim `api/main.py` (status/cameras/events/storage/stats, JWT, plain def, double-cache) + tests. +5. `secubox-frigate.service` (amd64 standalone) + storage/disk-pressure guard timer. +6. Cross-node exposure (HAProxy vhost + both mitmproxy route files + nginx `/api/v1/frigate/` route) + menu.d + sidebar `/stats` wiring. +7. postinst/prerm (provision LXC on install, stop cleanly; keep `/data` + config on remove) + changelog. + +## 9. Risques +- **Frigate is Docker-first** — podman-in-LXC is the pragmatic path; watch cgroup/nesting quirks for podman inside LXC (may need `security.nesting=true` on the LXC). +- **OpenVINO on CPU** — modest fps; fine for framework validation + a few cameras. Coral/iGPU is a later upgrade, config-only. +- **Cross-node latency** — the shim runs local on amd64 (fast); only the UI proxy + Hub poll cross the mesh. Keep shim cached so the mesh hop never blocks a handler. +- **amd64 lacks the `RuntimeDirectoryPreserve` fix** — the shim ships its own drop-in; note the fleet-wide backport (separate). +- **Storage growth** — retention + disk guard mandatory before any real camera; unbounded recording would fill `/data`. +- **Scope creep toward the dashboard** — live view / event UI / config editing are explicitly Sub-project 2. diff --git a/packages/secubox-frigate/README.md b/packages/secubox-frigate/README.md new file mode 100644 index 00000000..2d0b3476 --- /dev/null +++ b/packages/secubox-frigate/README.md @@ -0,0 +1,66 @@ +# 📹 Frigate NVR + +Frigate NVR (frigate.video) running as the official podman container inside a +dedicated Debian LXC on the amd64 node. + +**Category:** Security / Media + +## Status + +Foundation scaffold (#821). This is the package skeleton only — the LXC/podman +provisioning, API shim, host service, and WAF exposure land in follow-up tasks +of the same plan. The full C3BOX dashboard is a separate sub-project. + +## Features (Foundation scope) + +- OpenVINO CPU detector +- go2rtc demo source (no real camera yet) +- Storage + retention on `/data/frigate` +- `/api/v1/frigate/*` shim +- Cross-node exposure through gk2's WAF (mitmproxy — no bypass) + +## Installation + +```bash +# Add SecuBox repository +curl -fsSL https://apt.secubox.in/install.sh | sudo bash + +# Install package +sudo apt install secubox-frigate +``` + +## Configuration + +Configuration file: `/etc/secubox/frigate/config.yml` (seeded from +`frigate.config.yml.example` on first install, never clobbered). + +## API Endpoints + +- `GET /api/v1/frigate/status` - Module status +- `GET /api/v1/frigate/cameras` - Camera list +- `GET /api/v1/frigate/events` - Recent events +- `GET /api/v1/frigate/storage` - Storage usage +- `GET /api/v1/frigate/stats` - Sidebar stats (`cameras`, `events`, `fps`) + +## Sidebar wiring (applied separately, in secubox-hub) + +Add to `packages/secubox-hub/www/shared/sidebar.js` PAGE_METRICS map: + +``` +'/frigate/': { metrics: ['cameras','events','fps'], api: '/api/v1/frigate/stats' }, +``` + +## Cross-node WAF exposure (deploy-time, run on gk2 — NOT part of the package) + +```bash +# gk2: front the amd64 Frigate UI through the WAF (NO bypass) +haproxyctl vhost add frigate.gk2.secubox.in # backend defaults to mitmproxy_inspector +# add to BOTH /srv/mitmproxy/haproxy-routes.json AND /srv/mitmproxy-in/haproxy-routes.json: +# "frigate.gk2.secubox.in": ["10.100.0.140", 5000] # amd64 frigate LXC over the mesh +systemctl restart mitmproxy +``` + +## License + +LicenseRef-CMSD-1.0 (Source-Disclosed License) — CyberMind © 2024-2026. +See [LICENCE-CMSD-1.0.md](../../LICENCE-CMSD-1.0.md). diff --git a/packages/secubox-frigate/api/__init__.py b/packages/secubox-frigate/api/__init__.py new file mode 100644 index 00000000..50c6b00b --- /dev/null +++ b/packages/secubox-frigate/api/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma + +# secubox-frigate API diff --git a/packages/secubox-frigate/api/main.py b/packages/secubox-frigate/api/main.py new file mode 100644 index 00000000..0d928b5d --- /dev/null +++ b/packages/secubox-frigate/api/main.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +"""SecuBox-Deb :: secubox-frigate :: /api/v1/frigate/* shim (Foundation, #821).""" +import json, os, shutil, threading, time, urllib.request +from pathlib import Path +from fastapi import APIRouter, Depends, FastAPI + +try: + from secubox_core.auth import require_jwt +except ImportError: # test/offline fallback — only when the lib is absent + def require_jwt(): # noqa: D401 + return {"sub": "anon"} + +FRIGATE_URL = os.environ.get("SECUBOX_FRIGATE_URL", "http://10.100.0.140:5000") +DATA_DIR = os.environ.get("SECUBOX_FRIGATE_DATA", "/data/frigate") +CACHE_FILE = Path("/var/cache/secubox/frigate/stats.json") +EVENTS_LIMIT = 50 + +app = FastAPI(title="secubox-frigate") +router = APIRouter(prefix="/api/v1/frigate") +_cache: dict = {} +_storage_cache: dict = {} +_lock = threading.Lock() + + +def _frigate_get(path: str): + """GET Frigate's HTTP API. Returns (json, True) or (None, False). Never raises.""" + try: + with urllib.request.urlopen(FRIGATE_URL + path, timeout=3) as r: + return json.loads(r.read().decode("utf-8")), True + except Exception: + return None, False + + +def _compute_status(fetched=None) -> dict: + """fetched: optional pre-fetched (data, ok) tuple from `_frigate_get("/api/stats")`, + to avoid a redundant round-trip when the caller already has it (see _compute_stats).""" + data, ok = fetched if fetched is not None else _frigate_get("/api/stats") + if not ok or not data: + return {"up": False, "version": None, "uptime": None, "detector_fps": None} + svc = data.get("service", {}) + det = next(iter(data.get("detectors", {}).values()), {}) + return {"up": True, "version": svc.get("version"), "uptime": svc.get("uptime"), + "detector_fps": det.get("inference_speed")} + + +def _compute_cameras(fetched=None) -> list: + """fetched: optional pre-fetched (data, ok) tuple, see _compute_status.""" + data, ok = fetched if fetched is not None else _frigate_get("/api/stats") + if not ok or not data: + return [] + out = [] + for name, c in (data.get("cameras") or {}).items(): + out.append({"name": name, "online": (c.get("camera_fps", 0) or 0) > 0, + "camera_fps": c.get("camera_fps"), "detection_fps": c.get("detection_fps"), + "process_fps": c.get("process_fps")}) + return out + + +def _compute_events() -> list: + data, ok = _frigate_get(f"/api/events?limit={EVENTS_LIMIT}") + if not ok or not data: + return [] + out = [] + for e in data[:EVENTS_LIMIT]: + eid = e.get("id") + out.append({"id": eid, "label": e.get("label"), "camera": e.get("camera"), + "start_time": e.get("start_time"), "zones": e.get("zones", []), + "snapshot": f"/api/v1/frigate/media/events/{eid}/snapshot.jpg" if eid else None}) + return out + + +def _compute_storage() -> dict: + try: + du = shutil.disk_usage(DATA_DIR) + rec = Path(DATA_DIR) / "recordings" + oldest = None + if rec.is_dir(): + files = sorted(rec.rglob("*.mp4")) + if files: + oldest = int(files[0].stat().st_mtime) + return {"path": DATA_DIR, "total": du.total, "used": du.used, "free": du.free, + "pct_used": round(du.used / du.total * 100, 1) if du.total else 0, "oldest_recording": oldest} + except Exception: + return {"path": DATA_DIR, "total": None, "used": None, "free": None, "pct_used": None, "oldest_recording": None} + + +def _compute_stats() -> dict: + fetched = _frigate_get("/api/stats") # single round-trip, shared by cameras + status + cams = _compute_cameras(fetched=fetched) + evs = _compute_events() + det = _compute_status(fetched=fetched).get("detector_fps") + # TOP-LEVEL keys the sidebar reads directly (nac /stats contract). + return {"cameras": len(cams), "events": len(evs), "fps": det, + "by_camera": {c["name"]: c.get("detection_fps") for c in cams}} + + +def refresh_cache(): + while True: + try: + data = _compute_stats() + CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + CACHE_FILE.write_text(json.dumps(data)) + storage_data = _compute_storage() + with _lock: + _cache.update(data) + _storage_cache.update(storage_data) + except Exception: + pass + time.sleep(60) + + +@app.on_event("startup") +def _startup(): + threading.Thread(target=refresh_cache, daemon=True).start() + + +@router.get("/status") +def status(user=Depends(require_jwt)): + return _compute_status() + + +@router.get("/cameras") +def cameras(user=Depends(require_jwt)): + return {"cameras": _compute_cameras()} + + +@router.get("/events") +def events(user=Depends(require_jwt)): + return {"events": _compute_events()} + + +@router.get("/storage") +def storage(user=Depends(require_jwt)): + with _lock: + if _storage_cache: + return dict(_storage_cache) + return _compute_storage() + + +@router.get("/stats") +def stats(user=Depends(require_jwt)): + with _lock: + if _cache: + return dict(_cache) + if CACHE_FILE.exists(): + try: + return json.loads(CACHE_FILE.read_text()) + except Exception: + pass + return _compute_stats() + + +app.include_router(router) diff --git a/packages/secubox-frigate/conf/frigate.config.yml.example b/packages/secubox-frigate/conf/frigate.config.yml.example new file mode 100644 index 00000000..242a3e14 --- /dev/null +++ b/packages/secubox-frigate/conf/frigate.config.yml.example @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +# +# SecuBox Frigate config (Foundation). Copied to /etc/secubox/frigate/config.yml +# on first install (never clobbered). OpenVINO CPU detector; a go2rtc demo source +# validates the pipeline with no real camera. Add real cameras under `cameras:`. +mqtt: + enabled: false # deferred (sub-project follow-up) + +detectors: + ov: + type: openvino + device: CPU + +model: + path: /openvino-model/ssdlite_mobilenet_v2.xml + input_tensor: nhwc + input_pixel_format: bgr + width: 300 + height: 300 + +# SQLite DB on /data/frigate (bind-mounted to /media/frigate) — NOT the +# Frigate default /config/frigate.db, so the DB lives with recordings/clips +# on bulk storage rather than the (smaller) config bind mount. +database: + path: /media/frigate/db/frigate.db + +# go2rtc restream — a bundled demo/test source so Frigate runs with no camera. +go2rtc: + streams: + demo: + # Frigate/go2rtc ships an ffmpeg test-pattern generator; replace with rtsp://... for a real camera. + - "ffmpeg:device?video=testsrc#video=h264" + +record: + enabled: true + retain: + days: 3 + mode: motion + +snapshots: + enabled: true + retain: + default: 5 + +cameras: + demo: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/demo + input_args: preset-rtsp-restream + roles: [detect, record] + detect: + width: 1280 + height: 720 + fps: 5 + +# ── Add real cameras later, e.g.: +# cameras: +# frontdoor: +# ffmpeg: +# inputs: +# - path: rtsp://USER:PASS@192.168.1.50:554/stream +# roles: [detect, record] diff --git a/packages/secubox-frigate/conf/frigate.nginx.conf b/packages/secubox-frigate/conf/frigate.nginx.conf new file mode 100644 index 00000000..14e42df7 --- /dev/null +++ b/packages/secubox-frigate/conf/frigate.nginx.conf @@ -0,0 +1,22 @@ +# packages/secubox-frigate/conf/frigate.nginx.conf +# Installed into the aggregator/webui nginx config (see secubox-hub sites-enabled) +# alongside the other module location snippets. +# +# secubox-frigate — API shim (local socket) + placeholder UI. +# NOTE: the Frigate UI itself is fronted by gk2 HAProxy → mitmproxy_inspector +# → mesh → amd64 (NO waf_bypass — the whole point is that the Frigate UI is +# WAF-inspected like every other public vhost). This file only serves the +# local shim API + the placeholder page; it does NOT define the public vhost +# or bypass the WAF. See README.md for the gk2-side exposure runbook. + +location /api/v1/frigate/ { + proxy_pass http://unix:/run/secubox/frigate.sock:/api/v1/frigate/; + include /etc/nginx/snippets/secubox-proxy.conf; + proxy_intercept_errors on; +} + +location /frigate/ { + alias /usr/share/secubox/www/frigate/; + index index.html; + try_files $uri $uri/ /frigate/index.html; +} diff --git a/packages/secubox-frigate/debian/changelog b/packages/secubox-frigate/debian/changelog new file mode 100644 index 00000000..fd4eb394 --- /dev/null +++ b/packages/secubox-frigate/debian/changelog @@ -0,0 +1,8 @@ +secubox-frigate (0.1.0-1~bookworm1) bookworm; urgency=medium + + * Foundation (#821): Frigate NVR in a podman-in-LXC on amd64; /api/v1/frigate + shim (status/cameras/events/storage/stats); go2rtc demo source; OpenVINO CPU + detector; storage + disk-pressure guard; cross-node WAF exposure (no bypass); + menu.d + sidebar /stats. Dashboard/MQTT/4R deferred. + + -- Gerald KERMA Mon, 06 Jul 2026 00:00:00 +0000 diff --git a/packages/secubox-frigate/debian/control b/packages/secubox-frigate/debian/control new file mode 100644 index 00000000..c56b2387 --- /dev/null +++ b/packages/secubox-frigate/debian/control @@ -0,0 +1,23 @@ +Source: secubox-frigate +Section: net +Priority: optional +Maintainer: Gerald KERMA +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 + +Package: secubox-frigate +Architecture: all +Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn | python3-pip, + lxc, lxc-templates, podman, nftables, openssl +Description: Frigate NVR for SecuBox (native LXC, podman) + SecuBox module for the Frigate NVR (frigate.video) running as the official + podman container inside a dedicated Debian LXC on the amd64 node. `frigatectl + install` provisions the LXC + container on demand; the host shim daemon stays + lightweight and always reachable on /run/secubox/frigate.sock. + . + Foundation scope: OpenVINO CPU detector, go2rtc demo source (no camera yet), + storage + retention on /data/frigate, /api/v1/frigate/* shim, and cross-node + exposure through gk2's WAF (mitmproxy — no bypass). The C3BOX dashboard, MQTT, + and 4R config double-buffer are deferred (sub-project 2 / follow-ups). + . + Provides FastAPI backend on /api/v1/frigate/ via a Unix socket. diff --git a/packages/secubox-frigate/debian/postinst b/packages/secubox-frigate/debian/postinst new file mode 100755 index 00000000..4bc1f887 --- /dev/null +++ b/packages/secubox-frigate/debian/postinst @@ -0,0 +1,17 @@ +#!/bin/bash +set -e +case "$1" in + configure) + install -d -m 0755 -o secubox -g secubox /var/cache/secubox/frigate 2>/dev/null || true + systemctl daemon-reload || true + deb-systemd-helper enable secubox-frigate.service >/dev/null 2>&1 || true + deb-systemd-invoke restart secubox-frigate.service >/dev/null 2>&1 || true + systemctl enable --now secubox-frigate-diskguard.timer >/dev/null 2>&1 || true + # Provision the LXC only where LXC is available (the amd64 host). Never fail install. + if command -v lxc-create >/dev/null 2>&1; then + /usr/sbin/frigatectl install || echo "frigatectl install deferred — run manually on the frigate host" >&2 + fi + ;; +esac +#DEBHELPER# +exit 0 diff --git a/packages/secubox-frigate/debian/prerm b/packages/secubox-frigate/debian/prerm new file mode 100755 index 00000000..f11b0819 --- /dev/null +++ b/packages/secubox-frigate/debian/prerm @@ -0,0 +1,11 @@ +#!/bin/bash +set -e +case "$1" in + remove|deconfigure) + systemctl stop secubox-frigate-diskguard.timer >/dev/null 2>&1 || true + deb-systemd-invoke stop secubox-frigate.service >/dev/null 2>&1 || true + # Leave the frigate LXC, /data/frigate, and /etc/secubox/frigate intact (data-preserving). + ;; +esac +#DEBHELPER# +exit 0 diff --git a/packages/secubox-frigate/debian/rules b/packages/secubox-frigate/debian/rules new file mode 100755 index 00000000..0d2c7eba --- /dev/null +++ b/packages/secubox-frigate/debian/rules @@ -0,0 +1,40 @@ +#!/usr/bin/make -f +%: + dh $@ + +override_dh_auto_install: + # API files (host shim daemon) + install -d debian/secubox-frigate/usr/lib/secubox/frigate/ + cp -r api debian/secubox-frigate/usr/lib/secubox/frigate/ + # Host control plane CLI + install -d debian/secubox-frigate/usr/sbin + [ -f sbin/frigatectl ] && install -m 755 sbin/frigatectl debian/secubox-frigate/usr/sbin/frigatectl || true + [ -f sbin/secubox-frigate-diskguard ] && install -m 755 sbin/secubox-frigate-diskguard debian/secubox-frigate/usr/sbin/secubox-frigate-diskguard || true + # Native-LXC bootstrap (run on demand by `frigatectl install`) + install -d debian/secubox-frigate/usr/share/secubox/lib/frigate + [ -d lib/frigate ] && cp -r lib/frigate/. debian/secubox-frigate/usr/share/secubox/lib/frigate/ || true + # Static www files + install -d debian/secubox-frigate/usr/share/secubox/www + [ -d www ] && cp -r www/. debian/secubox-frigate/usr/share/secubox/www/ || true + # Menu definitions + install -d debian/secubox-frigate/usr/share/secubox/menu.d + [ -d menu.d ] && cp -r menu.d/. debian/secubox-frigate/usr/share/secubox/menu.d/ || true + # Dashboard-API route. The ACTIVE include is secubox-routes.d/ (webui.conf); + # secubox.d/ is legacy/back-compat. Ship to BOTH (mirror secubox-grafana) so + # /api/v1/frigate/ is routed — installing only to secubox.d/ silently + # drops the route (regression that broke the dashboard tile). + install -d debian/secubox-frigate/etc/nginx/secubox.d + install -d debian/secubox-frigate/etc/nginx/secubox-routes.d + [ -f conf/frigate.nginx.conf ] && cp conf/frigate.nginx.conf debian/secubox-frigate/etc/nginx/secubox.d/ || true + [ -f conf/frigate.nginx.conf ] && cp conf/frigate.nginx.conf debian/secubox-frigate/etc/nginx/secubox-routes.d/ || true + # Config example — canonical path read by lib/frigate/install-lxc.sh + # (install_config() seeds the live LXC config from here on first install). + install -d debian/secubox-frigate/usr/share/secubox/frigate + [ -f conf/frigate.config.yml.example ] && cp conf/frigate.config.yml.example debian/secubox-frigate/usr/share/secubox/frigate/frigate.config.yml.example || true + # Disk-pressure guard unit + timer. dh_installsystemd only auto-detects the + # package-named unit (secubox-frigate.service); extra units need to be + # placed in the install tree explicitly (mirror secubox-mitmproxy's + # waf-watchdog pattern) so dh_installsystemd picks them up for enable/start. + install -d debian/secubox-frigate/usr/lib/systemd/system + install -m 644 debian/secubox-frigate-diskguard.service debian/secubox-frigate/usr/lib/systemd/system/ + install -m 644 debian/secubox-frigate-diskguard.timer debian/secubox-frigate/usr/lib/systemd/system/ diff --git a/packages/secubox-frigate/debian/secubox-frigate-diskguard.service b/packages/secubox-frigate/debian/secubox-frigate-diskguard.service new file mode 100644 index 00000000..035da9ff --- /dev/null +++ b/packages/secubox-frigate/debian/secubox-frigate-diskguard.service @@ -0,0 +1,9 @@ +[Unit] +Description=SecuBox Frigate /data disk-pressure guard +After=network.target + +[Service] +Type=oneshot +User=secubox +Group=secubox +ExecStart=/usr/sbin/secubox-frigate-diskguard diff --git a/packages/secubox-frigate/debian/secubox-frigate-diskguard.timer b/packages/secubox-frigate/debian/secubox-frigate-diskguard.timer new file mode 100644 index 00000000..e8e5598b --- /dev/null +++ b/packages/secubox-frigate/debian/secubox-frigate-diskguard.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run SecuBox Frigate disk-pressure guard periodically + +[Timer] +OnCalendar=*:0/15 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/packages/secubox-frigate/debian/secubox-frigate.service b/packages/secubox-frigate/debian/secubox-frigate.service new file mode 100644 index 00000000..32780cc4 --- /dev/null +++ b/packages/secubox-frigate/debian/secubox-frigate.service @@ -0,0 +1,22 @@ +[Unit] +Description=SecuBox Frigate API shim +After=network.target secubox-core.service +Requires=secubox-core.service + +[Service] +Type=simple +User=secubox +Group=secubox +WorkingDirectory=/usr/lib/secubox/frigate +ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/frigate.sock --log-level warning +Restart=on-failure +RestartSec=5 +UMask=0000 +NoNewPrivileges=true +RuntimeDirectory=secubox +RuntimeDirectoryPreserve=yes +RuntimeDirectoryMode=0775 +ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /var/log/secubox /var/cache/secubox /data/frigate + +[Install] +WantedBy=multi-user.target diff --git a/packages/secubox-frigate/debian/secubox.yaml b/packages/secubox-frigate/debian/secubox.yaml new file mode 100644 index 00000000..68bca08d --- /dev/null +++ b/packages/secubox-frigate/debian/secubox.yaml @@ -0,0 +1,17 @@ +# debian/secubox.yaml +# Auto-generated from debian/control + +name: secubox-frigate +category: misc +tier: lite +description: "Frigate NVR for SecuBox" + +depends: + - secubox-core + +api: + socket: /run/secubox/frigate.sock + health: /api/v1/frigate/status + +ui: + path: /srv/secubox/www/frigate diff --git a/packages/secubox-frigate/lib/frigate/frigate.container b/packages/secubox-frigate/lib/frigate/frigate.container new file mode 100644 index 00000000..4a04e56c --- /dev/null +++ b/packages/secubox-frigate/lib/frigate/frigate.container @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Installed INTO the frigate LXC by install-lxc.sh; runs the official Frigate +# image. --network=host (unprivileged LXC podman has no CNI bridge). Config + +# media are bind-mounted from the host through the LXC. +[Unit] +Description=Frigate NVR (podman) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +ExecStartPre=-/usr/bin/podman rm -f frigate +ExecStart=/usr/bin/podman run --rm --name frigate --network=host \ + --shm-size=128m \ + -e FRIGATE_RTSP_PASSWORD_FILE=/run/secrets/frigate-rtsp \ + -v /config:/config \ + -v /media/frigate:/media/frigate \ + --tmpfs /tmp/cache:size=256000000 \ + ghcr.io/blakeblackshear/frigate:0.14.1 +ExecStop=/usr/bin/podman stop -t 10 frigate + +[Install] +WantedBy=multi-user.target diff --git a/packages/secubox-frigate/lib/frigate/install-lxc.sh b/packages/secubox-frigate/lib/frigate/install-lxc.sh new file mode 100644 index 00000000..4cb5c063 --- /dev/null +++ b/packages/secubox-frigate/lib/frigate/install-lxc.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: secubox-frigate :: install-lxc.sh +# CyberMind — https://cybermind.fr +# +# Idempotent native-LXC bootstrap for the Frigate NVR module. Safe to re-run. +# Follows docs/MODULE-GUIDELINES.md §3 (mirror of photoprism / peertube). +# +# Frigate runs as a podman container INSIDE a dedicated Debian LXC, with +# `--network=host` (an unprivileged LXC can't bring up a podman CNI bridge). +# Podman is never driven by the .deb/postinst — only the LXC's own systemd +# (via the frigate.service unit dropped in by this script) starts/stops it. +# Config lives on the host at /etc/secubox/frigate (bind-mounted to /config) +# and recordings/clips/exports/db under /data/frigate (bind-mounted to +# /media/frigate). + +set -euo pipefail + +readonly LXC_NAME="${SECUBOX_LXC_NAME:-frigate}" +readonly LXC_IP="${SECUBOX_LXC_IP:-10.100.0.140}" +readonly LXC_PATH="${SECUBOX_LXC_PATH:-/data/lxc}" +readonly LXC_BRIDGE="${SECUBOX_LXC_BRIDGE:-br-lxc}" +readonly LXC_GW="${SECUBOX_LXC_GW:-10.100.0.1}" +readonly DEBIAN_SUITE="${SECUBOX_DEBIAN_SUITE:-bookworm}" +readonly DATA_DIR="${SECUBOX_DATA_DIR:-/data/frigate}" +readonly STATE_DIR="${SECUBOX_STATE_DIR:-/var/lib/secubox/frigate}" +readonly CONFIG_DIR="${SECUBOX_FRIGATE_CONFIG:-/etc/secubox/frigate}" +readonly IMAGE="${SECUBOX_FRIGATE_IMAGE:-ghcr.io/blakeblackshear/frigate:0.14.1}" +readonly HTTP_PORT="${SECUBOX_FRIGATE_PORT:-5000}" +readonly SENTINEL="$STATE_DIR/.lxc-provisioned" +readonly LXC_ROOT_UID="${SECUBOX_LXC_ROOT_UID:-100000}" + +# Directory this script lives in — used to locate the sibling frigate.container +# unit regardless of whether we're running from the source tree +# (packages/secubox-frigate/lib/frigate/) or the installed path +# (/usr/share/secubox/lib/frigate/). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR + +log() { printf '[frigate-install] %s\n' "$*"; } +fail() { printf '[frigate-install] ERROR: %s\n' "$*" >&2; exit 1; } +la() { lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- "$@"; } + +# ── Preflight ──────────────────────────────────────────────────────────────── +require_cmds() { + for c in lxc-create lxc-info lxc-start lxc-attach; do + command -v "$c" >/dev/null 2>&1 || fail "$c not installed" + done +} + +ensure_dirs() { + install -d -m 0755 -o root -g root "$LXC_PATH" + install -d -m 0755 "$STATE_DIR" 2>/dev/null || true + install -d -m 0755 "$CONFIG_DIR" + install -d -m 0750 "$DATA_DIR/db" "$DATA_DIR/recordings" "$DATA_DIR/clips" "$DATA_DIR/exports" + chown -R "$LXC_ROOT_UID:$LXC_ROOT_UID" "$DATA_DIR" + chown "$LXC_ROOT_UID:$LXC_ROOT_UID" "$CONFIG_DIR" +} + +install_config() { + if [ ! -f "$CONFIG_DIR/config.yml" ]; then + log "Seeding $CONFIG_DIR/config.yml from example" + install -m 0640 /usr/share/secubox/frigate/frigate.config.yml.example "$CONFIG_DIR/config.yml" + chown "$LXC_ROOT_UID:$LXC_ROOT_UID" "$CONFIG_DIR/config.yml" + fi +} + +ensure_bridge() { + if ! ip link show "$LXC_BRIDGE" >/dev/null 2>&1; then + log "Creating bridge $LXC_BRIDGE @ ${LXC_GW}/24 ..." + ip link add name "$LXC_BRIDGE" type bridge + ip addr add "${LXC_GW}/24" dev "$LXC_BRIDGE" + ip link set "$LXC_BRIDGE" up + cat > /etc/systemd/network/10-secubox-lxc-bridge.netdev < /etc/systemd/network/10-secubox-lxc-bridge.network </dev/null || true + fi +} + +ensure_masquerade() { + if ! nft list table ip lxc 2>/dev/null | grep -q 'saddr 10.100.0.0/24'; then + log "Adding nftables MASQUERADE for 10.100.0.0/24 ..." + nft 'add table ip lxc' 2>/dev/null || true + nft 'add chain ip lxc postrouting { type nat hook postrouting priority srcnat ; policy accept ; }' 2>/dev/null || true + nft 'add rule ip lxc postrouting ip saddr 10.100.0.0/24 ip daddr != 10.100.0.0/24 counter masquerade' 2>/dev/null || true + fi +} + +# ── LXC lifecycle ──────────────────────────────────────────────────────────── +lxc_state() { + lxc-info -n "$LXC_NAME" -P "$LXC_PATH" 2>/dev/null \ + | awk -F: '/^State:/ { gsub(/ /,"",$2); print tolower($2) }' +} + +create_lxc() { + if [ -d "$LXC_PATH/$LXC_NAME/rootfs" ]; then + log "LXC '$LXC_NAME' already exists — skipping debootstrap" + return + fi + log "Creating LXC '$LXC_NAME' (debian $DEBIAN_SUITE) ..." + lxc-create -n "$LXC_NAME" -t download -P "$LXC_PATH" -- \ + --dist debian --release "$DEBIAN_SUITE" --arch "$(dpkg --print-architecture)" +} + +write_lxc_config() { + log "Pinning LXC network: $LXC_IP/24 on $LXC_BRIDGE; bind mounts" + cat > "$LXC_PATH/$LXC_NAME/config" < /etc/resolv.conf' +} + +start_lxc() { + [ "$(lxc_state)" = "running" ] && { log "LXC already running"; return; } + log "Starting LXC '$LXC_NAME' ..." + lxc-start -n "$LXC_NAME" -P "$LXC_PATH" +} + +wait_for_network() { + log "Waiting for LXC network ..." + for _ in $(seq 1 30); do + la ping -c1 -W1 "$LXC_GW" >/dev/null 2>&1 && return 0 + sleep 1 + done + fail "LXC did not reach $LXC_GW within 30s" +} + +# ── Frigate (podman) install inside LXC ────────────────────────────────────── +install_frigate_in_lxc() { + log "Dropping frigate.service unit into '$LXC_NAME' rootfs ..." + install -D -m 0644 "$SCRIPT_DIR/frigate.container" \ + "$LXC_PATH/$LXC_NAME/rootfs/etc/systemd/system/frigate.service" + + # Template the image tag from $IMAGE — the static unit ships the pinned + # default (:0.14.1); honor SECUBOX_FRIGATE_IMAGE overrides here so the + # unit that actually runs matches what we `podman pull` below (idempotent: + # re-running with the same $IMAGE is a no-op sed). + log "Templating Frigate image tag ($IMAGE) into installed unit ..." + sed -i "s|ghcr.io/blakeblackshear/frigate:[^[:space:]]*|$IMAGE|" \ + "$LXC_PATH/$LXC_NAME/rootfs/etc/systemd/system/frigate.service" + grep -qF "$IMAGE" "$LXC_PATH/$LXC_NAME/rootfs/etc/systemd/system/frigate.service" \ + || fail "failed to template Frigate image into frigate.service" + + log "Installing podman + Frigate in '$LXC_NAME' ..." + la env \ + DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C \ + IMAGE="$IMAGE" \ + bash -e <<'INNER' +set -euo pipefail +echo '[1/3] podman' +apt-get update -q +apt-get install -y -q --no-install-recommends podman ca-certificates curl +command -v podman >/dev/null 2>&1 || { echo 'podman not installed' >&2; exit 1; } + +echo '[2/3] pull image' +podman pull "$IMAGE" + +echo '[3/3] enable frigate.service' +systemctl daemon-reload +systemctl enable --now frigate.service +echo '=== Frigate install complete ===' +INNER +} + +verify() { + log "Verifying Frigate on $LXC_IP:$HTTP_PORT ..." + for _ in $(seq 1 30); do + curl -fsS -o /dev/null --max-time 3 "http://$LXC_IP:$HTTP_PORT/" && { log "OK — responding."; return 0; } + sleep 2 + done + log "WARN: not responding yet (first boot can take a while; check 'journalctl' inside the LXC)." +} + +mark_provisioned() { date -Iseconds > "$SENTINEL"; } + +main() { + require_cmds + ensure_dirs + install_config + ensure_bridge + ensure_masquerade + create_lxc + write_lxc_config + start_lxc + wait_for_network + ensure_resolv + install_frigate_in_lxc + verify + mark_provisioned + log "Done — LXC '$LXC_NAME' at $LXC_IP, Frigate running." + log "Config: $CONFIG_DIR/config.yml Media: $DATA_DIR" +} + +main "$@" diff --git a/packages/secubox-frigate/menu.d/618-frigate.json b/packages/secubox-frigate/menu.d/618-frigate.json new file mode 100644 index 00000000..b06ff488 --- /dev/null +++ b/packages/secubox-frigate/menu.d/618-frigate.json @@ -0,0 +1,9 @@ +{ + "id": "frigate", + "name": "Frigate", + "path": "/frigate/", + "icon": "📹", + "category": "mesh", + "order": 618, + "description": "AI-powered NVR — cameras, events, detection" +} diff --git a/packages/secubox-frigate/pytest.ini b/packages/secubox-frigate/pytest.ini new file mode 100644 index 00000000..5ee64771 --- /dev/null +++ b/packages/secubox-frigate/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/packages/secubox-frigate/sbin/frigatectl b/packages/secubox-frigate/sbin/frigatectl new file mode 100755 index 00000000..08fc594f --- /dev/null +++ b/packages/secubox-frigate/sbin/frigatectl @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: frigatectl +# CyberMind — https://cybermind.fr +# +# Frigate host-side controller. Frigate runs as a podman container inside a +# dedicated Debian LXC (default 10.100.0.140 on br-lxc, --network=host). +# Recordings/clips/exports live on /data/frigate (bind-mounted into the LXC). +# +# Conventions: docs/MODULE-GUIDELINES.md §7 (mirror of photoprismctl). + +set -u + +readonly VERSION="1.0.0" +readonly CONFIG_FILE="${SECUBOX_FRIGATE_CTL_CONFIG:-/etc/secubox/frigate.toml}" +readonly INSTALL_LIB="${SECUBOX_INSTALL_LIB:-/usr/share/secubox/lib/frigate/install-lxc.sh}" + +readonly RED='\033[0;31m'; readonly GREEN='\033[0;32m'; readonly YELLOW='\033[1;33m'; readonly NC='\033[0m' +log() { printf '%b[frigate]%b %s\n' "$GREEN" "$NC" "$*"; } +warn() { printf '%b[warn]%b %s\n' "$YELLOW" "$NC" "$*" >&2; } +err() { printf '%b[error]%b %s\n' "$RED" "$NC" "$*" >&2; } + +config_get() { + local key="$1" default="${2:-}" raw="" + [ -f "$CONFIG_FILE" ] && raw=$(grep -E "^[[:space:]]*${key}[[:space:]]*=" "$CONFIG_FILE" 2>/dev/null | head -1) + [ -z "$raw" ] && { echo "$default"; return; } + local val + val=$(echo "$raw" | cut -d= -f2- | sed 's/[[:space:]]*#.*$//' \ + | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' | tr -d '"' | tr -d "'") + [ -z "$val" ] && echo "$default" || echo "$val" +} + +LXC_NAME=$(config_get "name" "frigate") +LXC_IP=$(config_get "ip" "10.100.0.140") +LXC_PATH=$(config_get "path" "/data/lxc") +HTTP_PORT=$(config_get "http_port" "5000") +PUBLIC_HOSTNAME=$(config_get "public_hostname" "frigate.gk2.secubox.in") + +lxc_state() { + lxc-info -n "$LXC_NAME" -P "$LXC_PATH" 2>/dev/null \ + | awk -F: '/^State:/ { gsub(/ /,"",$2); print tolower($2) }' +} +lxc_running() { [ "$(lxc_state)" = "running" ]; } +svc() { lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl "$@" frigate; } + +cmd_install() { + [ -f "$INSTALL_LIB" ] || { err "install script not found at $INSTALL_LIB"; exit 1; } + log "Running LXC bootstrap from $INSTALL_LIB ..." + SECUBOX_LXC_NAME="$LXC_NAME" SECUBOX_LXC_IP="$LXC_IP" SECUBOX_LXC_PATH="$LXC_PATH" \ + bash "$INSTALL_LIB" + log "Done. Try 'frigatectl status'." +} + +cmd_status() { + printf 'LXC %s : %s\n' "$LXC_NAME" "$(lxc_state || echo absent)" + if lxc_running; then + printf 'frigate.service : %s\n' "$(svc is-active 2>/dev/null || echo unknown)" + if curl -fsS -o /dev/null --max-time 3 "http://$LXC_IP:$HTTP_PORT/" 2>/dev/null; then + printf 'HTTP %s:%s : responding\n' "$LXC_IP" "$HTTP_PORT" + else + printf 'HTTP %s:%s : NOT responding\n' "$LXC_IP" "$HTTP_PORT" + fi + fi + printf 'public URL : https://%s/\n' "$PUBLIC_HOSTNAME" +} + +cmd_start() { lxc_running || lxc-start -n "$LXC_NAME" -P "$LXC_PATH"; svc start; log "started"; } +cmd_stop() { lxc_running && svc stop || true; log "stopped"; } +cmd_restart() { lxc_running || lxc-start -n "$LXC_NAME" -P "$LXC_PATH"; svc restart; log "restarted"; } +cmd_logs() { lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- journalctl -u frigate -n "${1:-50}" --no-pager; } + +cmd_help() { + cat < [args] + + install Provision the LXC + podman + Frigate (idempotent) + status LXC + service + HTTP reachability + start|stop|restart Control frigate.service inside the LXC + logs [N] Tail N lines of frigate journal (default 50) + help This message + +Config: $CONFIG_FILE — LXC $LXC_NAME @ $LXC_IP, public https://$PUBLIC_HOSTNAME/ +EOF +} + +main() { + local noun="${1:-help}"; shift || true + case "$noun" in + install|status|start|stop|restart|logs) "cmd_${noun}" "$@" ;; + help|-h|--help) cmd_help ;; + *) err "unknown verb: $noun"; cmd_help; exit 1 ;; + esac +} + +main "$@" diff --git a/packages/secubox-frigate/sbin/secubox-frigate-diskguard b/packages/secubox-frigate/sbin/secubox-frigate-diskguard new file mode 100755 index 00000000..4cd68364 --- /dev/null +++ b/packages/secubox-frigate/sbin/secubox-frigate-diskguard @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: secubox-frigate :: /data disk-pressure guard. +set -euo pipefail +readonly DATA_DIR="${SECUBOX_FRIGATE_DATA:-/data/frigate}" +readonly LIMIT="${SECUBOX_FRIGATE_DISK_LIMIT:-90}" # percent +# Testable: SECUBOX_FRIGATE_DF_PCT overrides the measured value. +if [ -n "${SECUBOX_FRIGATE_DF_PCT:-}" ]; then + pct="$SECUBOX_FRIGATE_DF_PCT" +else + # df fails if DATA_DIR is missing/unmounted (normal before provisioning) — + # never let that abort the guard under set -e/pipefail. + pct="$(df --output=pcent "$DATA_DIR" 2>/dev/null | tail -1 | tr -dc '0-9' || true)" + if [ -z "$pct" ]; then + echo "disk guard: ${DATA_DIR} unavailable — nothing to guard yet" + exit 0 + fi +fi +if [ "$pct" -ge "$LIMIT" ]; then + logger -t secubox-frigate "disk pressure: ${pct}% >= ${LIMIT}% on ${DATA_DIR} — reduce retention" + echo "disk pressure: ${pct}% >= ${LIMIT}%" >&2 + exit 2 +fi +echo "disk ok: ${pct}% < ${LIMIT}%" +exit 0 diff --git a/packages/secubox-frigate/tests/__init__.py b/packages/secubox-frigate/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/secubox-frigate/tests/test_diskguard.py b/packages/secubox-frigate/tests/test_diskguard.py new file mode 100644 index 00000000..7236ceb3 --- /dev/null +++ b/packages/secubox-frigate/tests/test_diskguard.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +import subprocess, sys, os +GUARD = os.path.join(os.path.dirname(__file__), "..", "sbin", "secubox-frigate-diskguard") + +def test_guard_fires_over_threshold(tmp_path): + # DF override: guard reads SECUBOX_FRIGATE_DF_PCT for testability + env = {**os.environ, "SECUBOX_FRIGATE_DF_PCT": "95", "SECUBOX_FRIGATE_DISK_LIMIT": "90"} + r = subprocess.run(["bash", GUARD], capture_output=True, text=True, env=env) + assert r.returncode == 2, "guard must exit 2 when over the limit" + assert "disk pressure" in (r.stdout + r.stderr).lower() + +def test_guard_ok_below_limit(): + env = {**os.environ, "SECUBOX_FRIGATE_DF_PCT": "40", "SECUBOX_FRIGATE_DISK_LIMIT": "90"} + r = subprocess.run(["bash", GUARD], capture_output=True, text=True, env=env) + assert r.returncode == 0 + +def test_guard_survives_missing_data_dir(tmp_path): + # Regression test: DATA_DIR missing/unmounted (normal pre-provisioning + # state right after package install) must NOT abort the guard under + # set -euo pipefail — it must report "nothing to guard yet" and exit 0. + missing_dir = str(tmp_path / "frigate-nonexistent-821") + env = {**os.environ, "SECUBOX_FRIGATE_DATA": missing_dir} + env.pop("SECUBOX_FRIGATE_DF_PCT", None) + r = subprocess.run(["bash", GUARD], capture_output=True, text=True, env=env) + assert r.returncode == 0, "guard must not fail when the data dir is absent" + assert "unavailable" in (r.stdout + r.stderr).lower() diff --git a/packages/secubox-frigate/tests/test_frigate_get.py b/packages/secubox-frigate/tests/test_frigate_get.py new file mode 100644 index 00000000..fe98a0e9 --- /dev/null +++ b/packages/secubox-frigate/tests/test_frigate_get.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +"""Fail-safe coverage for `_frigate_get` itself (no mock of the function), +plus the /events truncation contract (#821 review fix).""" +import importlib +import socket +import urllib.error +import urllib.request + +from fastapi.testclient import TestClient + + +def _reload(): + import api.main as m + importlib.reload(m) + return m + + +def test_frigate_get_network_error_is_failsafe(monkeypatch): + m = _reload() + + def raise_urlerror(*a, **kw): + raise urllib.error.URLError("timeout") + + monkeypatch.setattr(urllib.request, "urlopen", raise_urlerror) + assert m._frigate_get("/api/stats") == (None, False) + + +def test_frigate_get_socket_timeout_is_failsafe(monkeypatch): + m = _reload() + + def raise_timeout(*a, **kw): + raise socket.timeout("timed out") + + monkeypatch.setattr(urllib.request, "urlopen", raise_timeout) + assert m._frigate_get("/api/stats") == (None, False) + + +def test_frigate_get_non_2xx_is_failsafe(monkeypatch): + m = _reload() + + def raise_http_error(*a, **kw): + raise urllib.error.HTTPError( + "http://frigate/api/stats", 503, "Service Unavailable", hdrs=None, fp=None + ) + + monkeypatch.setattr(urllib.request, "urlopen", raise_http_error) + assert m._frigate_get("/api/stats") == (None, False) + + +def test_frigate_get_invalid_json_is_failsafe(monkeypatch): + m = _reload() + + class FakeResponse: + def read(self): + return b"not-json{{{" + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **kw: FakeResponse()) + assert m._frigate_get("/api/stats") == (None, False) + + +def test_events_truncated_to_limit(monkeypatch): + m = _reload() + m.app.dependency_overrides[m.require_jwt] = lambda: {"sub": "test"} + + many_events = [ + {"id": str(i), "label": "person", "camera": "demo", "start_time": i, "zones": []} + for i in range(m.EVENTS_LIMIT + 25) + ] + + def fake_get(path): + if path.startswith("/api/events"): + return many_events, True + return {}, True + + monkeypatch.setattr(m, "_frigate_get", fake_get) + c = TestClient(m.app) + r = c.get("/api/v1/frigate/events") + assert r.status_code == 200 + assert len(r.json()["events"]) == m.EVENTS_LIMIT diff --git a/packages/secubox-frigate/tests/test_shim.py b/packages/secubox-frigate/tests/test_shim.py new file mode 100644 index 00000000..d3f8b6b3 --- /dev/null +++ b/packages/secubox-frigate/tests/test_shim.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +import importlib, inspect +from fastapi.testclient import TestClient + +def _app(monkeypatch, frigate_stats=None, frigate_events=None, up=True): + import api.main as m + importlib.reload(m) + # bypass JWT + m.app.dependency_overrides[m.require_jwt] = lambda: {"sub": "test"} + def fake_get(path): + if not up: + return None, False + if path == "/api/stats": + return frigate_stats or {"cameras": {"demo": {"camera_fps": 5, "detection_fps": 4.9, "process_fps": 5}}, + "detectors": {"ov": {"inference_speed": 12.3}}, + "service": {"version": "0.14.1", "uptime": 3600}}, True + if path.startswith("/api/events"): + return frigate_events or [{"id": "1", "label": "person", "camera": "demo", "start_time": 1, "zones": []}], True + return {}, True + monkeypatch.setattr(m, "_frigate_get", fake_get) + return TestClient(m.app), m + +def test_status_up(monkeypatch): + c, _ = _app(monkeypatch) + r = c.get("/api/v1/frigate/status") + assert r.status_code == 200 + b = r.json() + assert b["up"] is True and b["version"] == "0.14.1" + +def test_status_down_is_failsafe(monkeypatch): + c, _ = _app(monkeypatch, up=False) + r = c.get("/api/v1/frigate/status") + assert r.status_code == 200 # never 5xx + assert r.json()["up"] is False + +def test_cameras_shape(monkeypatch): + c, _ = _app(monkeypatch) + r = c.get("/api/v1/frigate/cameras") + assert r.status_code == 200 + cams = r.json()["cameras"] + assert cams[0]["name"] == "demo" and cams[0]["online"] is True + +def test_events_bounded(monkeypatch): + c, _ = _app(monkeypatch) + r = c.get("/api/v1/frigate/events") + assert r.status_code == 200 + assert r.json()["events"][0]["label"] == "person" + +def test_all_handlers_plain_def(monkeypatch): + _, m = _app(monkeypatch) + for name in ("status", "cameras", "events", "storage", "stats"): + fn = getattr(m, name) + assert not inspect.iscoroutinefunction(fn), f"{name} must be plain def (aggregator SPOF rule)" diff --git a/packages/secubox-frigate/tests/test_stats_contract.py b/packages/secubox-frigate/tests/test_stats_contract.py new file mode 100644 index 00000000..d0afc56b --- /dev/null +++ b/packages/secubox-frigate/tests/test_stats_contract.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +import importlib +from fastapi.testclient import TestClient + +def test_stats_top_level_keys(monkeypatch): + import api.main as m + importlib.reload(m) + m.app.dependency_overrides[m.require_jwt] = lambda: {"sub": "t"} + monkeypatch.setattr(m, "_frigate_get", lambda p: ( + ({"cameras": {"demo": {"camera_fps": 5, "detection_fps": 4.5, "process_fps": 5}}, + "detectors": {"ov": {"inference_speed": 10.0}}, "service": {"version": "0.14.1"}}, True) + if p == "/api/stats" else ([{"id": "1", "label": "car", "camera": "demo"}], True))) + m._cache.clear() + b = TestClient(m.app).get("/api/v1/frigate/stats").json() + assert set(["cameras", "events", "fps"]).issubset(b), "sidebar reads top-level cameras/events/fps" + assert b["cameras"] == 1 and b["events"] == 1 and b["fps"] == 10.0 diff --git a/packages/secubox-frigate/www/frigate/index.html b/packages/secubox-frigate/www/frigate/index.html new file mode 100644 index 00000000..a1e418b3 --- /dev/null +++ b/packages/secubox-frigate/www/frigate/index.html @@ -0,0 +1,3 @@ + + +

Frigate — dashboard coming in sub-project 2. API at /api/v1/frigate/