ProjectsFRITZ!Box history
FRITZ!Box history
A FRITZ!Box keeps only a few hundred log lines, and its device list knows only the state “right now”. A collector written in Go fetches both on a schedule and stores every connect and disconnect for good. The interface then shows, over months, when each device was on the network.
Private, reachable only through Leon’s own VPN. The source code is not public.

Key facts
As of
- Operations
- running on a self-managed server since 19 October 2026, the box is attached over WireGuard
- Tests
- 108 Go tests, 246 with subtests, plus 212 pgTAP assertions on the database, 92 Vitest cases and 10 Playwright tests with axe
- Access
- one owner, sign-in by invitation only, no sign-up
The device is the unit, not the line
Log lines and the device list become events, events become sessions with start, end and duration. A device is recognised by its MAC address. Phones often switch to a new private address, so addresses can be merged into one device by hand and split again.
For selected devices a mail goes out when they connect or disconnect, at least 15 minutes apart. Unknown devices never trigger a mail.

A gap is not “offline”
When the collector cannot reach the box for a while, nobody knows what happened in that time. The interface shows it as a gap, not as a time when every device was gone.
After a gap the collector reconciles the log with the device list, so events between two polls are not lost. Raw events are only ever appended, never changed, and sessions can be rebuilt from them at any time.
In a load test with 4.6 million events, the first poll after an outage originally took 333 seconds. After reworking the gap run and the indexes it takes 0.18 seconds, up to 10 with a cold cache.

From the log into the database
The box writes its lines in German and without a time zone. On the night the clocks go back, the hour from 2 to 3 a.m. happens twice. The parser resolves it from the time of the poll: newer lines come first, so no older line can be later than the one above it.
Every line gets a key made of message number, text and time. When the collector fetches the same line again, the database drops it. Only the registered owner may read anything. Postgres enforces that itself through row level security.
Excerpts from the real code, shortened.
Real code from the FRITZ!Box history
collector/internal/logparse/logparse.go
func Resolve(lines []model.LogLine, loc *time.Location, observedAt time.Time) { ceiling := observedAt.Add(jitter) for i := range lines { if lines[i].At.IsZero() { if wall, ok := wallTime(lines[i].Raw); ok { lines[i].At = localTime(wall, loc, ceiling) } } if at := lines[i].At; !at.IsZero() && at.Add(jitter).Before(ceiling) { ceiling = at.Add(jitter) } }}
collector/internal/logparse/logparse.go
func dedupeKey(id int, text string, at time.Time) string { sum := sha256.Sum256(fmt.Appendf(nil, "%d|%s|%s", id, text, at.UTC().Truncate(time.Second).Format(time.RFC3339))) return hex.EncodeToString(sum[:])}
supabase/migrations/20261002000300_collector.sql
… format('hostlist:%s:%s:%s', hosts.mac, case when hosts.active then 'connected' else 'disconnected' end, extract(epoch from p_observed_at)) from hosts where hosts.active <> hosts.was_open on conflict (dedupe_key) do nothing
supabase/migrations/20261002000500_access.sql
create policy "owner reads events" on public.events for select to authenticated using ((select public.is_owner()));create policy "owner reads sessions" on public.sessions for select to authenticated using ((select public.is_owner()));
Operations
Five containers on a self-managed server: database, sign-in, REST interface, web interface and collector. Database and interfaces sit in internal networks without outbound access, only the collector may reach the box and the mail server.
- The collector speaks TR-064 with digest authentication, using only the Go standard library
- Box attached over WireGuard, interface reachable from the VPN only
- All containers except the database with a read-only file system, all with memory and process limits and only the Linux capabilities they need
- Collector image based on distroless, Caddy built from source
- Daily database backup encrypted with age, plus a script that checks the restore
- Chain test from the simulated box through collector and database to the interface and the mail
Technology
- Collector
- Go 1.27, pgx as the only direct dependency
- Data
- PostgreSQL 17 with GoTrue and PostgREST from the Supabase stack, logic as SQL functions, tested with pgTAP
- Interface
- React 19, Vite, TanStack Query and Router, React Aria
- Operations
- Docker Compose behind Caddy, WireGuard to the box, mail over SMTP
- CI
- go test -race, golangci-lint, govulncheck, pgTAP, Vitest, Playwright, Trivy and a secret scan
Access
- Address inside a private VPN only
- Source private
The tool runs on a private home network and shows real devices, so there is no demo. The screenshots above come from development with made-up devices.