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

DecisionChoiceWhy
TriggerAny Frigate UI request from a tailnet sourceUser: “every UI session”
Identitytailnet IP → tailscale device nameThe useful part; unknown IP is itself a signal
Self-alertsAlert on everyone, no exclusionsUser choice; fully auditable
DedupPer-source session, 5-min quiet gap = new sessionAn open tab polls constantly; avoid spam
DeliveryExisting frigate-notify bot + chatZero new secrets
Runs aslevanderdocker, 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.x tailnet 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) with should_alert(ip, now) -> bool (edge-trigger + cooldown).
  • names.pyload_tailscale_names() -> dict[ip,name], resolve(ip, names) -> str (raw ip fallback). Cached, refreshed on a miss and periodically.
  • notify.pyload_telegram_creds(config_path) -> (token, chatid) parsing the frigate-notify YAML block; send(token, chatid, text) -> bool via urllib, never raises.
  • watch.py — the daemon: spawns/reads docker 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 iff ip unseen or now - last_seen[ip] > COOLDOWN. Always update last_seen[ip] = now afterwards (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.x address (an unrecognised tailnet device is a meaningful thing to see).

Telegram

  • Read token + chatid from ~/nvr/frigate-notify/config.yml’s telegram: 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 -f exits when Frigate restarts → detect EOF, sleep with backoff (2s→30s cap), respawn. Log the reconnect.
  • If docker logs cannot start at all (docker down) → retry with backoff, never exit.
  • Telegram send failure → logged, non-fatal.
  • systemd Restart=always, RestartSec=5 as the outer safety net.
  • The daemon only reads logs and sends Telegram; it can affect nothing on the box.

Testing / verification

  1. Parser unit tests — fixtures for: a /live/jsmpeg 101 line with a 100.x XFF; a plain /api/config line with 100.x; an internal line with - XFF (must be ignored); a go2rtc WRN line (must be ignored); a malformed line. Assert ip/path extraction and is_tailnet.
  2. Session gate unit tests — first hit alerts; second within cooldown silent; after cooldown alerts again; independent IPs tracked separately.
  3. Name resolution — known ip → name; unknown ip → raw ip; empty/failed tailscale status → all raw.
  4. Telegram creds parse — extract token+chatid from a fixture notify config.
  5. 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.)
  6. Restart resilience — restart the Frigate container, confirm the daemon reconnects and still alerts afterwards.

Risks

RiskMitigation
Alert spam from a live tab’s constant pollingPer-session cooldown; only session-start alerts
Missing a session because cooldown too long5 min is short enough for “someone opened it”; configurable
XFF format drift / auth changesParser ignores anything not matching the IP shape; auth 401s still count as a session
Frigate restart drops the log followEOF detection + respawn, plus systemd Restart=always
Leaking the bot tokenRead from the existing notify config at runtime; never copied into this project