Frigate viewer alert — design
Telegram alert when someone opens the Frigate camera UI.
Related: SESSION-HANDOVER, telep-mainframe, 2026-07-17-intruder-alarm
Goal
Send a Telegram message whenever a person opens the Frigate web UI (any session), identifying who by tailnet device name. “Someone is looking at my cameras.”
Non-goals
- Blocking or auth. This is notification only.
- Distinguishing live-view vs recordings (user chose “every UI session” — the widest net).
- Per-camera view tracking (the triggering path is included as a bonus when it is a
/live/request, but sessions are not tracked per camera). - A new bot or chat. Reuse the existing frigate-notify Telegram bot + chat.
Key decisions
| Decision | Choice | Why |
|---|---|---|
| Trigger | Any Frigate UI request from a tailnet source | User: “every UI session” |
| Identity | tailnet IP → tailscale device name | The useful part; unknown IP is itself a signal |
| Self-alerts | Alert on everyone, no exclusions | User choice; fully auditable |
| Dedup | Per-source session, 5-min quiet gap = new session | An open tab polls constantly; avoid spam |
| Delivery | Existing frigate-notify bot + chat | Zero new secrets |
| Runs as | levander | docker, tailscale, notify config all reachable without root |
Signal source
Frigate’s nginx access log is emitted to the container’s stdout, captured by docker logs. Each request line carries the real client IP in the X-Forwarded-For field (set by tailscale serve), which sits immediately before request_time=:
172.18.0.1 - - [22/Jul/2026:13:06:55 +0200] "GET /api/config HTTP/1.1" 401 581 "…referer…" "…UA…" "100.83.222.120" request_time="0.003" upstream_response_time="-"
- A real human viewer through tailscale → XFF is a
100.x.x.xtailnet address. - Internal automation (frigate-notify, self-polls) → XFF is
-. Excluded by construction. - Both live (
GET /live/jsmpeg/telep_cam1, HTTP 101) and plain UI (GET /api/config,/login) requests carry the tailnet XFF, so every session is visible.
The detector follows the log with docker logs -f --since 0m frigate (from “now”, so startup does not replay history and alert on stale sessions).
Architecture
Single daemon on telep-mainframe; no new packages; stdlib only.
frigate-viewer-alert.service (levander, python3 stdlib)
Popen: docker logs -f --since 0m frigate (stdout, line-buffered)
for each line:
parse -> (ip, method, path, status) [ignore non-access lines]
if ip is tailnet (100.x):
if first-seen OR quiet > COOLDOWN: -> session start
name = resolve(ip)
camera = extract if path is /live/.../<cam>
telegram_send("👁 <name> (<ip>) opened Frigate — HH:MM[ · live: <cam>]")
last_seen[ip] = now
on EOF (frigate restarted): respawn docker logs with backoff
Components / files
/home/levander/frigate-viewer-alert/ on telep-mainframe:
logparse.py— pure:parse_access_line(line) -> dict|None(ip, method, path, status),is_tailnet(ip) -> bool,extract_camera(path) -> str|None.sessions.py— pure:SessionGate(cooldown)withshould_alert(ip, now) -> bool(edge-trigger + cooldown).names.py—load_tailscale_names() -> dict[ip,name],resolve(ip, names) -> str(raw ip fallback). Cached, refreshed on a miss and periodically.notify.py—load_telegram_creds(config_path) -> (token, chatid)parsing the frigate-notify YAML block;send(token, chatid, text) -> boolvia urllib, never raises.watch.py— the daemon: spawns/readsdocker logs -f, wires the above, respawns on EOF.frigate-viewer-alert.service— systemd unit,Restart=always.
Parsing detail
The XFF is the quoted field immediately before request_time=. Robust extract:
match: "([^"]*)" request_time=
then test the captured value against ^100\.\d{1,3}\.\d{1,3}\.\d{1,3}$. Lines without that shape (go2rtc INF/WRN logs, frigate app logs, internal - XFF) yield None and are ignored. The request line’s method/path come from the "METHOD path HTTP/x" field.
Session / dedup logic
COOLDOWN_SECONDS = 300.- Per source IP, keep
last_seen(monotonic). should_alert(ip, now)returns True iffipunseen ornow - last_seen[ip] > COOLDOWN. Always updatelast_seen[ip] = nowafterwards (done by the caller).- Consequence: an open, actively-polling tab keeps its timer fresh → no repeat alert. Closed and reopened after 5 min → new alert. Matches “every UI session” without a message per click.
- Time uses
time.monotonic()for the gate; wall-clock (time.localtime) only for the message text.
Identity resolution
tailscale status→ parse^100\.\S+ <hostname>lines into{ip: name}.- Cache; refresh when an IP is missing from the cache (a new device) and at most every 60s; also refresh lazily every ~5 min.
- Unknown IP → show the raw
100.xaddress (an unrecognised tailnet device is a meaningful thing to see).
Telegram
- Read
token+chatidfrom~/nvr/frigate-notify/config.yml’stelegram:block at startup. Do not hardcode or copy secrets. - Send via
https://api.telegram.org/bot<token>/sendMessage(urllib, POST, 10s timeout). - Failure (WAN flaky) → log to stderr, return False, never crash. No retry (a missed viewer ping is not worth blocking on; matches the rig’s “no retry on flaky WAN” pattern).
Failure handling
docker logs -fexits when Frigate restarts → detect EOF, sleep with backoff (2s→30s cap), respawn. Log the reconnect.- If
docker logscannot start at all (docker down) → retry with backoff, never exit. - Telegram send failure → logged, non-fatal.
- systemd
Restart=always,RestartSec=5as the outer safety net. - The daemon only reads logs and sends Telegram; it can affect nothing on the box.
Testing / verification
- Parser unit tests — fixtures for: a
/live/jsmpeg101 line with a 100.x XFF; a plain/api/configline with 100.x; an internal line with-XFF (must be ignored); a go2rtcWRNline (must be ignored); a malformed line. Assert ip/path extraction andis_tailnet. - Session gate unit tests — first hit alerts; second within cooldown silent; after cooldown alerts again; independent IPs tracked separately.
- Name resolution — known ip → name; unknown ip → raw ip; empty/failed
tailscale status→ all raw. - Telegram creds parse — extract token+chatid from a fixture notify config.
- End-to-end — run the daemon, open Frigate from a tailnet device, confirm one Telegram message with the right device name and time; reopen within 5 min → no second message; reopen after cooldown → new message. (This posts to the real camera-alert chat — that is the intended channel.)
- Restart resilience — restart the Frigate container, confirm the daemon reconnects and still alerts afterwards.
Risks
| Risk | Mitigation |
|---|---|
| Alert spam from a live tab’s constant polling | Per-session cooldown; only session-start alerts |
| Missing a session because cooldown too long | 5 min is short enough for “someone opened it”; configurable |
| XFF format drift / auth changes | Parser ignores anything not matching the IP shape; auth 401s still count as a session |
| Frigate restart drops the log follow | EOF detection + respawn, plus systemd Restart=always |
| Leaking the bot token | Read from the existing notify config at runtime; never copied into this project |