DMN/Software

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.

Detail page of the made-up device Beispiel-Handy: online for 2 hours, connected over Wi-Fi to the hallway repeater. Below it a bar of the last 7 days with connected periods and one marked gap without data, a table of sessions with start, end and duration, and on the right the settings for mails on connect and disconnect and the device's two MAC addresses
One device with its history, sessions and the mails it triggers. The interface is German. All devices, addresses and times are made up.

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.

Device list with made-up devices such as Beispiel-Handy, Beispiel-Laptop, Beispiel-Fernseher and Beispiel-Drucker, each with online or offline since, MAC address and connection over Wi-Fi or LAN. One device is marked as guest, one has no name
Part of the device list, made-up devices.

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.

Collector page: host list fetched 20 seconds ago, log 50 seconds ago, no error. Below it the gaps of the last 90 days as a table with source, start, end and duration
The collector shows when it last reached the box and where data is missing.

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.

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

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.