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.service on telep-mainframe (active + enabled, survives reboot, runs as levander — NOT root). MainPID = python3 watch.py with one docker logs -f --since 0m frigate child. 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 serve writes the tailnet client IP into X-Forwarded-For — that's your viewer identity

A real human viewer through tailscale → XFF is a 100.x tailnet 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 the 100.x IP → device name via tailscale status (100.83.222.120personal-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 with findall(...)[-1] (the quoted field truly adjacent to request_time=), then validate against the 100.x shape.

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 status is 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 -f exits when the container restarts. The daemon detects EOF, logs a reconnect, backs off (2s → 30s cap), and respawns. Verified live: restarting Frigate → the service logged reconnecting, stayed active, no zombie docker logs accumulation. Reset the backoff only after a line is actually read — otherwise an immediate-EOF crashloop never escalates the delay.
  • --since 0m on docker logs -f so restarting the daemon does not replay old log lines and fire stale alerts.
  • Runs as levander, not root: levander is in the docker group, and tailscale status + the notify config are all reachable without root — least privilege. (Contrast with wifi-usage.service, which must be root because the mpv socket is root-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=5 as 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:
    1. Open https://telep-mainframe.taild4189d.ts.net from a tailnet device → expect one ping.
    2. Reload within 5 min → no dup.
    3. Reopen after 5 min → new ping.

frigate.service reads inactive in systemctl — that's expected

Frigate is a docker compose container, not a systemd unit; the frigate + frigate-notify containers are what run. A recurring point of confusion worth stating once.