feat(frigate): Frigate NVR Foundation — podman-in-LXC on amd64 + /api/v1/frigate shim (#821) (#822)
Some checks are pending
License Headers / check (push) Waiting to run

* 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 <gandalf@Gk2.net>
This commit is contained in:
CyberMind 2026-07-06 11:53:20 +02:00 committed by GitHub
parent fecd233d88
commit e79dfc8368
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1952 additions and 0 deletions

View File

@ -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 <devel@cybermind.fr>
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 <devel@cybermind.fr> 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: `<h1>Frigate — dashboard coming in sub-project 2. API at /api/v1/frigate/</h1>` (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; <podman step>; 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 <devel@cybermind.fr>
"""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 <verb> 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.

View File

@ -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:<pin>
│ ├── 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://<lxc-ip>: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.

View File

@ -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).

View File

@ -0,0 +1,4 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# secubox-frigate API

View File

@ -0,0 +1,154 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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)

View File

@ -0,0 +1,67 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# 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]

View File

@ -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;
}

View File

@ -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 <devel@cybermind.fr> Mon, 06 Jul 2026 00:00:00 +0000

View File

@ -0,0 +1,23 @@
Source: secubox-frigate
Section: net
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
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.

View File

@ -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

View File

@ -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

View File

@ -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/

View File

@ -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

View File

@ -0,0 +1,9 @@
[Unit]
Description=Run SecuBox Frigate disk-pressure guard periodically
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 <<EOF
[NetDev]
Name=$LXC_BRIDGE
Kind=bridge
EOF
cat > /etc/systemd/network/10-secubox-lxc-bridge.network <<EOF
[Match]
Name=$LXC_BRIDGE
[Network]
Address=${LXC_GW}/24
ConfigureWithoutCarrier=yes
IPMasquerade=ipv4
EOF
systemctl reload systemd-networkd 2>/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" <<EOF
# SecuBox-managed — see secubox-frigate / install-lxc.sh
lxc.include = /usr/share/lxc/config/debian.common.conf
lxc.arch = linux64
lxc.idmap = u 0 $LXC_ROOT_UID 65536
lxc.idmap = g 0 $LXC_ROOT_UID 65536
lxc.rootfs.path = dir:$LXC_PATH/$LXC_NAME/rootfs
lxc.uts.name = $LXC_NAME
lxc.net.0.type = veth
lxc.net.0.link = $LXC_BRIDGE
lxc.net.0.flags = up
lxc.net.0.ipv4.address = $LXC_IP/24
lxc.net.0.ipv4.gateway = $LXC_GW
lxc.net.0.name = eth0
# Config (host /etc/secubox/frigate) + media (host /data/frigate) bind mounts.
lxc.mount.entry = $CONFIG_DIR config none bind,create=dir 0 0
lxc.mount.entry = $DATA_DIR media/frigate none bind,create=dir 0 0
lxc.cgroup2.memory.high = 1500M
lxc.cgroup2.memory.max = 2G
lxc.start.auto = 1
lxc.start.delay = 5
EOF
}
ensure_resolv() {
log "Seeding /etc/resolv.conf in LXC ..."
la sh -c 'rm -f /etc/resolv.conf; printf "nameserver 1.1.1.1\nnameserver 9.9.9.9\n" > /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 "$@"

View File

@ -0,0 +1,9 @@
{
"id": "frigate",
"name": "Frigate",
"path": "/frigate/",
"icon": "📹",
"category": "mesh",
"order": 618,
"description": "AI-powered NVR — cameras, events, detection"
}

View File

@ -0,0 +1,2 @@
[pytest]
testpaths = tests

View File

@ -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 <<EOF
frigatectl $VERSION — SecuBox Frigate NVR (podman-in-LXC) controller
Usage: frigatectl <verb> [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 "$@"

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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)"

View File

@ -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

View File

@ -0,0 +1,3 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<!-- Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr> -->
<h1>Frigate — dashboard coming in sub-project 2. API at /api/v1/frigate/</h1>