As-built Telegram alert that fires when someone opens the Frigate camera UI on telep-mainframe.
A python3-stdlib daemon follows docker logs -f --since 0m frigate, parses Frigate’s nginx access
log, reads the real client IP out of the X-Forwarded-For field (set by tailscale serve), maps it
to a tailscale device name, and posts to the same frigate-notify
Telegram bot+chat. “Someone is looking at my cameras — and it’s personal-mac.”
Design in 2026-07-23-frigate-viewer-alert-design, task-by-task plan in 2026-07-23-frigate-viewer-alert-plan — this note is the completion record and the hard-won knowledge, not a restatement of either.
For Agents
Service:
frigate-viewer-alert.serviceon telep-mainframe (active + enabled, survives reboot, runs as levander — NOT root). MainPID =python3 watch.pywith onedocker logs -f --since 0m frigatechild. Code:/home/levander/frigate-viewer-alert/—logparse.py,sessions.py,names.py,notify.py,watch.py+ tests (34/34 pass, python3 stdlib only, no pip). Credentials: token + chatid read at runtime from~/nvr/frigate-notify/config.yml— never copied into this project. Same bot as the person/car alerts, so viewer pings land in the same chat. Message:👁 <device-name> (100.x) opened Frigate — HH:MM[ · live: <camera>]. Dedup: per-source-IP 5-min session cooldown.
How detection works (the reusable part)
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),
sitting immediately before request_time=:
172.18.0.1 - - [22/Jul/2026:13:06:55 +0200] "GET /api/config HTTP/1.1" 401 581 "…ref…" "…UA…" "100.83.222.120" request_time="0.003" upstream_response_time="-"
tailscale servewrites the tailnet client IP into X-Forwarded-For — that's your viewer identityA real human viewer through tailscale → XFF is a
100.xtailnet address. Internal automation (frigate-notify’s API polling, self-polls) → XFF is-, excluded by construction — no allowlist needed. Both live (GET /live/jsmpeg/telep_cam1, HTTP 101) and plain UI (/api/config,/login) requests carry the tailnet XFF, so every session is visible off a single log stream. Map the100.xIP → device name viatailscale status(100.83.222.120→personal-mac); an unknown tailnet IP shows the raw address — itself a signal.
Every UI session without spam. An open Frigate tab polls constantly, so it edge-triggers: a
per-source-IP cooldown (COOLDOWN_SECONDS = 300) means the first request after a quiet gap is a new
session → alert; every later request just refreshes the timer. Reload within 5 min = no dup; reopen
after 5 min = new alert. The gate uses time.monotonic(); wall-clock only formats the message.
The two that cost thought
1. X-Forwarded-For is the LAST quoted field before request_time=
Leftmost-match on a quoted nginx field is spoofable — take the field adjacent to request_time=
A security review found that
re.search(r'"([^"]*)" request_time=', line)(leftmost match) is forgeable: a client that injects a literal"into its User-Agent or Referer creates an earlier"…" request_time=occurrence, letting it plant an arbitrary string where the viewer IP is read. Default nginx escapes quotes in logged headers, so it’s safe in practice — but the parser must not depend on the log-escape setting. Fix: capture withfindall(...)[-1](the quoted field truly adjacent torequest_time=), then validate against the100.xshape.Reusable lesson for any nginx-access-log parsing that trusts a quoted field: anchor on the field’s real position, not the first match; the captured value is attacker-influenceable input.
2. Never spawn tailscale status per log line
A per-line name-cache refresh is a self-DoS under an active viewer
An early version refreshed the name cache on every unknown-IP line. If
tailscale statusis momentarily failing (returns nothing) or the IP genuinely isn’t listed, that becomes one blocking ~10 s subprocess per log line — and an open viewer emits a stream of them. Rate-limit miss-triggered refreshes (here: at most once per 60 s) in addition to the periodic ~5-min refresh.
Failure handling & least privilege
- Reconnect on Frigate restart:
docker logs -fexits when the container restarts. The daemon detects EOF, logs a reconnect, backs off (2s → 30s cap), and respawns. Verified live: restarting Frigate → the service loggedreconnecting, stayedactive, no zombiedocker logsaccumulation. Reset the backoff only after a line is actually read — otherwise an immediate-EOF crashloop never escalates the delay. --since 0mondocker logs -fso restarting the daemon does not replay old log lines and fire stale alerts.- Runs as
levander, not root: levander is in thedockergroup, andtailscale status+ the notify config are all reachable without root — least privilege. (Contrast with wifi-usage.service, which must be root because the mpv socket isroot-owned.) - Telegram send never crashes the daemon: failure (flaky WAN) is logged to stderr, returns False, no retry — a missed viewer ping is not worth blocking on. Matches the rig’s “no retry on flaky WAN” pattern.
- systemd
Restart=always,RestartSec=5as the outer safety net. The daemon only reads logs and sends Telegram — it can affect nothing on the box.
Design choices (recorded honestly)
- Scope = “every UI session” (the widest net) — user’s choice, not live-view-only. The triggering
camera is included as a bonus when the path is
/live/…, but sessions aren’t tracked per camera. - No self-exclusion — alerts on everyone, including the user’s own devices. User’s choice, fully auditable.
- Reuses the frigate-notify bot+chat — zero new secrets; viewer pings land in the same Telegram chat as the person/car detection alerts.
Current state / verification
- Service
active+enabled(survives reboot). All 34 unit tests pass. - A one-off self-test Telegram message was sent during the build to prove the token+chat+network path
(delivered
True). - The only unverified leg is a real browser-triggered session — it cannot be faked into the live
docker stream. Left for the user as a 3-step manual test:
- Open
https://telep-mainframe.taild4189d.ts.netfrom a tailnet device → expect one ping. - Reload within 5 min → no dup.
- Reopen after 5 min → new ping.
- Open
frigate.servicereadsinactivein systemctl — that's expectedFrigate is a
docker composecontainer, not a systemd unit; thefrigate+frigate-notifycontainers are what run. A recurring point of confusion worth stating once.
Related
- 2026-07-23-frigate-viewer-alert-design — the design this implements
- 2026-07-23-frigate-viewer-alert-plan — the task-by-task implementation plan
- telep-mainframe — the host; Frigate NVR,
tailscale serveTLS front door - 2026-07-17-intruder-alarm — shares the same “Dezsi az őr” bot + the token in
~/nvr/frigate-notify/config.yml - 2026-07-22-wifi-usage-strip — sibling stdlib daemon; contrast on the root-vs-levander privilege call
- SESSION-HANDOVER
- homelab
- LOG
- TOPICS