mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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
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:
parent
fecd233d88
commit
e79dfc8368
766
docs/superpowers/plans/2026-07-06-frigate-foundation.md
Normal file
766
docs/superpowers/plans/2026-07-06-frigate-foundation.md
Normal 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.
|
||||
141
docs/superpowers/specs/2026-07-06-frigate-foundation-design.md
Normal file
141
docs/superpowers/specs/2026-07-06-frigate-foundation-design.md
Normal 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.
|
||||
66
packages/secubox-frigate/README.md
Normal file
66
packages/secubox-frigate/README.md
Normal 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).
|
||||
4
packages/secubox-frigate/api/__init__.py
Normal file
4
packages/secubox-frigate/api/__init__.py
Normal 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
|
||||
154
packages/secubox-frigate/api/main.py
Normal file
154
packages/secubox-frigate/api/main.py
Normal 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)
|
||||
67
packages/secubox-frigate/conf/frigate.config.yml.example
Normal file
67
packages/secubox-frigate/conf/frigate.config.yml.example
Normal 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]
|
||||
22
packages/secubox-frigate/conf/frigate.nginx.conf
Normal file
22
packages/secubox-frigate/conf/frigate.nginx.conf
Normal 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;
|
||||
}
|
||||
8
packages/secubox-frigate/debian/changelog
Normal file
8
packages/secubox-frigate/debian/changelog
Normal 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
|
||||
23
packages/secubox-frigate/debian/control
Normal file
23
packages/secubox-frigate/debian/control
Normal 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.
|
||||
17
packages/secubox-frigate/debian/postinst
Executable file
17
packages/secubox-frigate/debian/postinst
Executable 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
|
||||
11
packages/secubox-frigate/debian/prerm
Executable file
11
packages/secubox-frigate/debian/prerm
Executable 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
|
||||
40
packages/secubox-frigate/debian/rules
Executable file
40
packages/secubox-frigate/debian/rules
Executable 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/
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=Run SecuBox Frigate disk-pressure guard periodically
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/15
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
22
packages/secubox-frigate/debian/secubox-frigate.service
Normal file
22
packages/secubox-frigate/debian/secubox-frigate.service
Normal 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
|
||||
17
packages/secubox-frigate/debian/secubox.yaml
Normal file
17
packages/secubox-frigate/debian/secubox.yaml
Normal 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
|
||||
25
packages/secubox-frigate/lib/frigate/frigate.container
Normal file
25
packages/secubox-frigate/lib/frigate/frigate.container
Normal 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
|
||||
235
packages/secubox-frigate/lib/frigate/install-lxc.sh
Normal file
235
packages/secubox-frigate/lib/frigate/install-lxc.sh
Normal 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 "$@"
|
||||
9
packages/secubox-frigate/menu.d/618-frigate.json
Normal file
9
packages/secubox-frigate/menu.d/618-frigate.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "frigate",
|
||||
"name": "Frigate",
|
||||
"path": "/frigate/",
|
||||
"icon": "📹",
|
||||
"category": "mesh",
|
||||
"order": 618,
|
||||
"description": "AI-powered NVR — cameras, events, detection"
|
||||
}
|
||||
2
packages/secubox-frigate/pytest.ini
Normal file
2
packages/secubox-frigate/pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
97
packages/secubox-frigate/sbin/frigatectl
Executable file
97
packages/secubox-frigate/sbin/frigatectl
Executable 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 "$@"
|
||||
25
packages/secubox-frigate/sbin/secubox-frigate-diskguard
Executable file
25
packages/secubox-frigate/sbin/secubox-frigate-diskguard
Executable 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
|
||||
0
packages/secubox-frigate/tests/__init__.py
Normal file
0
packages/secubox-frigate/tests/__init__.py
Normal file
26
packages/secubox-frigate/tests/test_diskguard.py
Normal file
26
packages/secubox-frigate/tests/test_diskguard.py
Normal 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()
|
||||
85
packages/secubox-frigate/tests/test_frigate_get.py
Normal file
85
packages/secubox-frigate/tests/test_frigate_get.py
Normal 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
|
||||
53
packages/secubox-frigate/tests/test_shim.py
Normal file
53
packages/secubox-frigate/tests/test_shim.py
Normal 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)"
|
||||
16
packages/secubox-frigate/tests/test_stats_contract.py
Normal file
16
packages/secubox-frigate/tests/test_stats_contract.py
Normal 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
|
||||
3
packages/secubox-frigate/www/frigate/index.html
Normal file
3
packages/secubox-frigate/www/frigate/index.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user