Skip to content
RenaCodePublic

About

Self-hosted alerts about kids' iPad activity inferred from NextDNS query logs: sessions, apps, night use and daily/weekly reports via WhatsApp or ntfy, plus a 2FA web panel, NextDNS game-time controls and optional Android TV and UniFi sensors.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

kidwatch

Phone notifications about kids' iPad activity, inferred from DNS queries. In a cluster they go through the RenaCode notification gateway (WhatsApp, or e-mail when no number is linked); locally through ntfy or Home Assistant. On top of that: a web panel with history, a usage chart, an "all screens" view (iPads and TV on one daily timeline), trends and CSV export, NextDNS-based "game time" controls, and optional Android TV (ADB) and UniFi sensors.

Full cluster deployment guide (in Polish): docs/uruchomienie-od-zera.md.

At a glance

  • Data source: DNS query logs from NextDNS (live stream), or AdGuard Home's query log. No app on the iPad, no jailbreak. Android phones (Family Link) via Private DNS — see docs/telefon-android.md.
  • Own MDM server (optional, kidwatch-mdm): a separate container written from scratch — installed apps with alerts on new ones, device state, remote screen lock, OS update enforcement, and on supervised iPads: VPN and Private Relay blocked, DNS that can't be turned off, a profile that can't be removed. See Own MDM server.
  • Notifications: session start and end, new apps, night use, daily and weekly summaries, game time, TV viewing — via ntfy, Home Assistant or the WhatsApp/e-mail gateway.
  • Web panel: history, all-screens timeline, trends, CSV export, NextDNS game-time controls, TV monitoring pause, password + optional TOTP 2FA login.
  • Optional: Android/Google TV (ADB) and UniFi controller traffic.
  • Self-monitoring watchdog: alerts when kidwatch stops seeing DNS traffic.
  • Stack: Python 3.12 (asyncio, httpx, pydantic, SQLite), React 18 + Vite, Docker, Helm charts for k3s / Argo CD (kidwatch and kidwatch-mdm).

Code comments, notification texts and the panel UI are in Polish; strings quoted below are reproduced verbatim.

Message types:

when example
Session start activity confirmed (3 distinct moments of traffic spanning ≥1 min within 5 min, or ≥3 min of traffic; a burst of queries within 10 s is one moment) after ≥10 min of silence <device> aktywny · 15:12 — Roblox
App a new app during an ongoing session — each app once per session (switching back does not ping again) <child> · YouTube
Session end 10 min without activity <device> — koniec · 15:12–15:59, 47 min
Daily summary 20:30 sessions since 20:30 the previous day, time and top apps per child
TV (optional) viewing start and end TV salon: start — <title> (YouTube)
Night a session in the night window (starting or ongoing) + a reminder every 30 min 🌙 <child> uzywa iPada w nocy · 23:12, YouTube, w domu
Weekly report Sunday 19:00 since the previous Sunday 19:00: minutes vs previous week, sessions, active days, top 5, longest session, night use, TV
Game time (optional) change from the panel, bonus end, schedule 🎮 Czas gry dla <child>: +30 min (do 18:40)
TV pause (optional) monitoring paused / resumed Monitoring TV wstrzymany do … · Monitoring TV wznowiony
MDM (optional) MDM profile removed, supervision lost, iPad silent > 24 h, pushes rejected, APNs certificate < 30 days; new / removed app <iPad>: profil MDM zdjety · <iPad>: nowa aplikacja

Plus the self-monitoring watchdog — see Watchdog.


First: what these numbers are NOT

This is not a screen-time measurement. The minutes shown for an app are the number of distinct minutes in which the iPad sent a DNS query to that app's domains. iOS caches DNS answers, so a child can play for an hour while generating queries in only a few minutes. Every number is a lower bound, and every notification says so (czasy szacunkowe — liczba minut z ruchem DNS).

The full data, including offline use, is in Settings → Screen Time on your iPhone. Apple offers no public API for it and kidwatch does not try to hook into it. If you compare the two, kidwatch will always show less — that is the correct behaviour, not a bug.

What you will not see:

  • apps that don't query their own domains (offline games, photos, notes);
  • a 100% split between YouTube Kids and YouTube — YT Kids uses some of the same domains (googlevideo.com, youtubei.googleapis.com), so part of its traffic lands under "YouTube";
  • content — only domains. You don't know what the child watched.

Quick start — without erasing the iPad

You don't need to supervise the iPads or wipe anything. Checked against Apple's official schema (com.apple.dnsSettings.managed.yaml): the whole DNS payload has supervised: false and allowmanualinstall: true.

1. NextDNS profile

  1. Create an account at my.nextdns.io → new profile. Note the profile ID.
  2. Account → API → API key. This is a secret; it goes into an environment variable.
  3. In the profile enable logs (Settings → Logs) with retention ≥ 1 day.

2. DNS on each iPad — two options, pick one

Option A: the NextDNS app (simplest)

  1. App Store → NextDNS on the iPad.
  2. Enter the profile ID.
  3. Enable sending the device name — otherwise all iPads merge into one and kidwatch can't tell the children apart.
  4. Give the device a unique name, e.g. iPad-Child-1. That name goes into source_ids in config.yaml.

Option B: a .mobileconfig profile (no app)

uv run python tools/gen_profile.py \
  --name "iPad Child 1" \
  --doh-url "https://dns.nextdns.io/YOUR_ID/iPad-Child-1" \
  --out child1.mobileconfig

One file per iPad, each with a different URL suffix. Devices are told apart by the path in the DoH URL — a shared profile = one device in the logs.

Send the file to the iPad (AirDrop / mail), then Settings → Profile Downloaded → Install.

3. Make removal harder (without supervision it can't be locked)

Screen Time cannot lock the DNS settings — it does not restrict access to Settings → General, and VPN/DNS settings are not something parental controls cover. What you can do:

  • Screen Time → Content & Privacy Restrictions → App Store → Deleting Apps: Don't Allow (with option A this prevents deleting the NextDNS app);
  • a Screen Time passcode the kids don't know;
  • keep the watchdog enabled — it will report a removed profile.

4. ntfy on your iPhone

  1. App Store → ntfy.
  2. Subscribe to your topic.
  3. The topic is passed in the NTFY_TOPIC variable, not in the file (topic: "" in config.example.yaml; without the variable the config does not validate). Generate a long one: python3 -c "import secrets;print('kidwatch-'+secrets.token_hex(14))". On ntfy.sh the topic name IS the password — there is no access control; anyone who knows the name can read your notifications. A self-hosted ntfy server is better.

5. Run

cp config.example.yaml config.yaml    # set profile_id and device names
export NEXTDNS_API_KEY="..." NTFY_TOPIC="kidwatch-..."
uv sync
uv run python -m kidwatch test-notify  # test push
uv run python -m kidwatch run

6. Web panel (optional, locally)

The panel is disabled by default (panel.enabled: false, listens on 127.0.0.1:8080). It runs as a thread inside run and serves the built front end from panel.static_dir (default web/dist):

(cd web && npm ci && npm run build)
uv run python -m kidwatch user-add <login>   # prints a random password ONCE

Then set panel.enabled: true in config.yaml. Session cookies are Secure by default; set panel.cookie_secure: false only if you serve the panel over plain http:// on a host other than localhost. 2FA additionally requires PANEL_TOTP_KEY (e.g. openssl rand -base64 32).

Android phone (Google Family Link)

A child's Android phone works through the same DNS layer: set Private DNS to Phone--Name-<profile ID>.dns.nextdns.io (-- is a space in the device name) and add the device with kind: phone to devices. Sessions, night alerts, the silence watchdog, game time and the panel then treat it like an iPad, with phone wording. Family Link cannot lock the Private DNS setting (only a device owner can), so the watchdog is what catches it being turned off. Research, step-by-step setup and limits (in Polish): docs/telefon-android.md.


Watchdog — the most important part without supervision

On an unsupervised iPad the child can turn DNS off. Their iPad then disappears from the logs, which is indistinguishable from "the iPad is lying in a drawer". That is the worst possible silent failure: the service is quiet and looks healthy.

That is why system noise is valuable here. An iPad queries Apple domains non-stop, even while asleep. Silence including noise means one thing: we have stopped seeing it.

Each layer guards against a different failure:

layer detects reaction
stream_silence_minutes (20) zero events from all devices — dead token, network, account push kidwatch nie widzi ruchu DNS
device_silence_minutes (180) silence of one iPad while others report — profile removed push <device> nie zglasza sie do DNS
same threshold, device never seen a configured device that has not appeared in DNS once since start — typo in source_ids or a profile that never worked push <device> nie pojawil sie w DNS (at most once a day)
livenessProbe / HEALTHCHECK the process hung — the watchdog can't report its own death, since the alert is sent by the same process container restart (heartbeat file KIDWATCH_HEARTBEAT)
home supervisor (multi-home E3b) a loop of one home died or its tick stalled for > 3 periods (90 s) only that home is rebuilt after 1, 2, 4… min (max 30 min); instance alert + home watchdog push; other homes and the panel keep running. After 8 failures in a row (~2 h) of home 1, or of the last running home, the process exits with code 1 and Kubernetes restarts the pod; another home failing forever only keeps backing off. The heartbeat file is touched by the supervisor, not by a home's tick
UniFi sensor (optional) an iPad on home Wi-Fi transferred > alarm_mb (20) MB in window_minutes (15) while NextDNS has not a single query from it — DNS profile removed push <device> — profil DNS prawdopodobnie usunięty
tv.unreachable_alert_hours (24) TV without a single successful read for a day — tunnel, ADB, key push <TV> — odczyt z urzadzenia nie dziala
tv.traffic (true), tv.traffic_min_mb (10), tv.traffic_window_minutes (3) ADB not answering but UniFi sees the TV streaming — sessions continue from network traffic (no titles; service name from NextDNS if tv.nextdns_ids is set) push <TV> — ADB nie odpowiada, monitoring z ruchu sieci after tv.adb_alert_minutes (30)
tv.sony (true), TV_SONY_PSK Sony BRAVIA REST API on the TV host: power state without a key; with the pre-shared key also antenna (channel + EPG programme title) and HDMI input — sources ADB never saw sessions TVP1 HD: <programme> (Telewizja), HDMI 2
tv.aplikacja (true) Kidwatch TV app on the TV (tv-app/, port 8765): reads the TV's media sessions itself — titles without ADB. Installed and paired in Ustawienia → Telewizor (install goes once over ADB, then ADB is optional) primary TV source; falls back to ADB, Sony and traffic. Without a known foreground app only a playing session counts as watching — a paused video does not
outbox notifications no channel accepted for a day (see below) push kidwatch nie dostarczyl powiadomien

Repeated alerts go out at increasing intervals (20 → 40 → 80 min, capped at 8 h, repeat_backoff_max_minutes) so that one failure doesn't produce a dozen pushes a day. Recovery is notified too (DNS and UniFi watchdogs). Each failure is a separate episode — another one, even the same day, alerts again and gets its own "back" message. Exception: the TV watchdog reports a missing read at most once a day and sends no recovery message.

During quiet hours silence of a single iPad does not alert — it is allowed to be off at night (device_silence_ignore_quiet_hours).


How it works

NextDNS /logs/stream (SSE)  ─┐
                             ├─→ classifier ─→ engine ─→ gateway (WhatsApp / e-mail)
AdGuard /control/querylog   ─┘   (app_map)     (pure)    ntfy, Home Assistant
                                                  │
Google TV (ADB), UniFi ─────────────────────→  SQLite  ─→ web panel
                                     (sessions, cursor, dedup, outbox)
  • sources/ — a common interface for DNS sources (NextDNS, AdGuard). Alongside them, sensors: tv.py (Google TV over ADB), unifi.py (Wi-Fi presence) and device.py (reading the iPad over lockdown; disabled in the cluster). DNS sources reconnect on their own; a network error never aborts the loop.
  • classifier.py — domain → noise / shared / app / unknown. Longest match wins, so the order in app_map.yaml doesn't matter. The file is hot-reloaded on mtime change; broken YAML keeps the old map.
  • engine.py — all the rules. No network I/O, time comes from an injected clock — hence engine tests without a single sleep.
  • store.py — SQLite. What must survive a restart: open sessions, keys of sent notifications (idempotency), the stream cursor and the outbox.
  • scheduler.py — the tick loop: closing sessions, summaries, watchdog, cleanup, heartbeat.
  • panel.py / panel_auth.py — the web panel's HTTP server (a thread in the run process, read-only connection to the main DB) and its login (Argon2id passwords, optional TOTP, separate panel-auth.db).
  • gametime.py, tvpause.py, rollup.py — game time, TV pause and the daily aggregates.

Noise neither wakes nor extends a session

If it did, a session would never end — an iPad queries Apple constantly. A session ends at the last non-system activity.

A lying iPad woken up by something other than the child — plugging in a cable, an iCloud sync, an MDM refresh — produces minutes of real traffic. Apple services that sit on third-party CDNs under Apple-named hosts (*.apple.com.edgekey.net, apple-relay.cloudflare.com, Privacy Pass) and the own MDM server (mdm.renacode.com) are therefore noise; on 2026-10-09 they opened a phantom session and let a Netflix background refresh through as an "app" push. tests/test_szum_wybudzenia.py replays that real sequence.

A restart doesn't duplicate pushes

Every notification has a dedup_key claimed atomically in SQLite. Processing the same events again (e.g. after the stream resumes) sends nothing.

A restart doesn't lose pushes

The loops don't send directly — they append the notification to the outbox table in kidwatch.db, and a separate task sends them in order. A hanging channel therefore doesn't hold up the tick or the heartbeat (liveness).

An entry leaves the queue only once at least one channel has accepted it (the gateway answered 2xx). If none did — a gateway restart during a deploy, a 502 from the mail provider — the entry stays and is retried after 30 s, 1, 2, 5, 10 min, then every 15 min; it also survives a kidwatch restart. Later notifications wait behind it so that "session end" never arrives before "start". After a day (or 100 attempts) the entry is dropped: a WARNING in the log, "undelivered" in the panel history, and the watchdog alert kidwatch nie dostarczyl powiadomien (at most once a day), which goes out when the channel is back.

The price: if a channel accepts a push and the process dies before deleting the entry, the same push arrives a second time after startup. Lost is worse than duplicated.

YouTube ads inside games

Games show video ads through Google IMA, which pulls clips from YouTube's servers — to DNS that looks like YouTube was opened. YouTube traffic within ±60 s of a known ad network (Unity, AppLovin, ironSource, doubleclick, …) during a game is counted as the game, with no "YouTube" push; a stretch of YouTube longer than 2 min without the game is real viewing.


Configuration

Everything lives in config.yaml (template: config.example.yaml; --config or KIDWATCH_CONFIG selects the file). Secrets come only from environment variables:

variable purpose required
NEXTDNS_API_KEY NextDNS API key (logs and game time) with source.kind: nextdns
ADGUARD_PASSWORD AdGuard Home password with source.kind: adguard
NTFY_TOPIC ntfy topic (the topic name is a password, so not in the file) with ntfy enabled
NTFY_TOKEN ntfy access token no (public topics don't need it)
HA_WEBHOOK_ID Home Assistant webhook with HA enabled
BRAMKA_KLUCZ the sending key for the RenaCode notification gateway (notifiers.bramka) with the gateway enabled
BRAMKA_KLUCZ_ADMIN gateway admin key — panel: WhatsApp linking, recipients, test message no (without it the panel falls back to BRAMKA_KLUCZ, which only works with the old shared key)
PANEL_TOTP_KEY encrypts the panel's 2FA secrets no (without it 2FA can't be enabled)
UNIFI_API_KEY local UniFi controller API with unifi.enabled (without it the sensor doesn't start)
KIDWATCH_STORE_PATH overrides store.path no

A missing secret produces a readable message and exit code 2, not a stack trace.

Main thresholds

key default meaning
engine.idle_minutes 10 this much silence = session end
engine.app_cooldown_minutes 15 unrecognised traffic: further domains batched every N minutes; a named app is reported once per session anyway (and in a new session not sooner than after this time)
engine.confirm_minutes / confirm_moments 5 / 3 session confirmation window and number of distinct moments (see below)
engine.shared_extend_minutes 30 how long shared traffic may extend a session
engine.daily_summary_time 20:30 daily summary
engine.max_notifications_per_hour 12 per device; the excess is aggregated
engine.quiet_hours none (example config: 21:30–07:00) no app pushes; session start with priority session_start_priority (5)
engine.night = quiet_hours night-alert window (start/end), reminder_minutes: 30, enabled
engine.weekly_report Sunday 19:00 weekday (0=Mon … 6=Sun), time, enabled
store.retention_days 30 older events are deleted (0 = never)
store.notifications_retention_days 365 notification history in the panel (0 = no limit)
store.rollup_retention_days 0 daily aggregates (0 = no limit)

engine.session_start_merge_seconds and engine.confirm_burst_events are no longer used (session confirmation replaced them); they are still accepted so existing configs keep validating.

Night and quiet hours are one push, not two. A session starting in the night window gets the night form (🌙 <child> uzywa iPada w nocy — 23:12, YouTube, with w domu / poza domem when the UniFi sensor has a fresh reading) instead of the W CICHYCH GODZINACH suffix. A session started in the evening that continues into the night gets this push when activity shows up at night; then a reminder every reminder_minutes.

The weekly report (automatic, Sunday 19:00) counts sessions from the previous Sunday 19:00 to the current 19:00; the "exact" TV time (Android counters) stays in the calendar week Mon–Sun. Run manually it covers a whole ISO week (Mon–Sun, no 19:00 cut-off; without --week, on a Sunday the current week, on other days the last full one), without claiming the dedup key: python -m kidwatch weekly [--week 2026-W40] [--dry-run] (without --dry-run it also sends through the channels).

The hourly limit never throttles session start and end, the watchdog, night alerts, reports, game time or TV pause. Throttling "the iPad turned on at 2 a.m." would defeat the purpose; session starts are inherently limited by idle_minutes, so they can't cause an avalanche.

"Background" sessions don't exist

An iPad lying on a desk still queries DNS: a YouTube background refresh, a certificate OCSP check, a game notification. Each such query used to open a "0 min" session with start and end pushes. Now a session is confirmed only when, within the engine.confirm_minutes (5 min) window, activity (an app or an unknown domain) has:

  • at least engine.confirm_moments (3) distinct moments spanning one minute or more — a moment is all queries within 10 s of its first query, so a burst of a dozen queries in one second counts once;
  • or a span of at least 3 minutes (a game that rarely talks to its own backend).

The start push then carries the actual start time. An unconfirmed session is closed silently and kept in the database (sessions.confirmed=0) for diagnostics only — it does not count toward summaries, reports, charts, aggregates or the night alert.

Why moments rather than queries: a YouTube background refresh on an idle iPad (production, 10.2026) is youtubei.googleapis.com, redirector and a few rr*.googlevideo.com in the same second, sometimes one more rr* after 1–2 minutes — then silence. That's two moments, not three. Watching pulls segments every dozen or so seconds and confirms after ~1 minute; a game with traffic every 30–60 s after 1–2 minutes.

A 5-minute rather than 3-minute window: a game in play talks to its backend every ~3–3.5 min (an Asphalt recording in tests/fixtures/day.jsonl), and with 3 minutes a real half-hour session would never confirm.

Panel accounts

Panel accounts are managed from the CLI (in the cluster via kubectl exec deploy/kidwatch -c kidwatch -- python -m kidwatch …):

python -m kidwatch user-add <login> [--password-stdin] [--rola podglad] [--bez-domu]  # random password printed ONCE
python -m kidwatch user-rola <login> wlasciciel|podglad   # change role in Dom 1, clear overrides, log out
python -m kidwatch user-uprawnienia <login> [+perm] [-perm] [=perm]  # per-account overrides, log out
python -m kidwatch user-reset <login> [--password-stdin]  # new password, unlock, log out everywhere
python -m kidwatch user-reset <login> --totp              # remove 2FA (lost phone)
python -m kidwatch user-del <login>
python -m kidwatch user-login <login> <new> [--zapisz]  # rename in place (same id); dry run without --zapisz
python -m kidwatch user-list
python -m kidwatch konta-migruj [--przypisz N=login …] [--zapisz [--wlacz]]  # gateway recipients -> accounts
python -m kidwatch konta-tryb [trasy|konta]               # show / switch notification mode
python -m kidwatch user-admin <login> on|off [--tylko-sso]  # installation administrator (see below)
python -m kidwatch dom-dodaj <nazwa> [--strefa Europe/Warsaw]  # new household, empty event DB
python -m kidwatch dom-czlonek <dom> <login> wlasciciel-domu|zarzadca|podglad  # role in a household
python -m kidwatch dom-czlonek <dom> <login> --usun        # remove from a household

Households (docs/architektura-domy.md). Roles live per household (wlasciciel-domu, zarzadca, podglad); data routes are /api/domy/<id>/… and an account without membership gets 404 for that household. The pre-household routes (/api/day …) still work as an alias of the account's only household (409 when it sees more than one). The web UI shows a household switcher only when the account has more than one.

The installation administrator is a separate account (Authelia group admins), not the owner's private account: user-add <login> --bez-domu, log in with that password right away and enable panel 2FA on the "Brak domu" screen, then user-admin <login> on — it refuses an account without 2FA (--tylko-sso for an account that only ever logs in through Authelia: the mode is stored in the database, password login to that account is refused and a password session never carries the role, even with 2FA) and always removes the account from all households, so it gets no household notifications. It sees every household, but only when the flag is set in the panel database and the request comes through Authelia with admins in Remote-Groups (or, on the break-glass password login, with panel 2FA enabled). Every visit to a household it is not a member of is written to the household's audit log (Settings → Dziennik domu): one GET entry per session and household per 15 minutes, every POST. The WhatsApp bot is shared by the installation: only the active installation administrator may configure it (QR, sender, gateway recipients, test) — no household role grants it, and every change is logged as an installation event. Sending notifications through the gateway does not depend on it. The same applies to a household's integrations (NextDNS, AdGuard, UniFi, TV, MDM, ntfy, Home Assistant): only the active installation administrator sees Settings → Integracje, for households whose configuration lives in the database (docs/sejf-domow.md §10). Secrets are write-only (the screen shows masks), every change needs the password or a 2FA code and is logged without values, and the household switches to the new configuration within ~10 s without a pod restart (or the screen says the change takes effect after a restart). Without SSO the administrator account logs in with its panel password and must enable panel 2FA first (the "no household" screen offers it); this needs PANEL_TOTP_KEY in the kidwatch-secrets Secret.

WhatsApp commands (docs/whatsapp-komendy.md, off by default: whatsapp_komendy.enabled). A household guardian (permission whatsapp.komendy — wlasciciel-domu; zarzadca only by explicit grant) writes to the bot: stan, koniec, zablokuj ipad, +15, -15, odblokuj, cisza 2h, or free text recognised by an LLM (Gemini, KIDWATCH_LLM_GEMINI_KEY — for now shared with other apps, so a 429 is treated as temporary unavailability; Anthropic optional) strictly into that closed list. Every change needs a one-time 4-digit code (TAK 1234, 2 min); relaxing actions have a daily per-child limit and need another household member with a notification channel; every executed command is copied to all other members with a channel (viewers too), unknown numbers get no reply. Log and an emergency switch for the installation administrator: Settings → "Komendy z WhatsAppa".

Invitations (Settings → Zaproszenia, permission dom.zaproszenia — wlasciciel-domu and the active installation administrator; code: src/kidwatch/zaproszenia.py). Pick a role (never higher than your own role in that household), validity (default 72 h, at most 7 days) and, recommended with SSO, the invitee's expected SSO login. The panel shows the link https://<panel>/zaproszenie#<token> once — the token lives in the URL fragment (not in Traefik logs or Referer), the database keeps only its SHA-256, and it never appears in logs or the audit log (only #<id>). The invitee opens the link:

  • through Authelia (SSO on — the normal path): they log in with an Authelia account in group kidwatch-goscie (see "Adding a person from outside the family" below), read the privacy notice and accept. If Remote-User has no panel account yet, it is created at that moment — the only case of an SSO account created on the fly, and only with a valid token — with a random break-glass password nobody sees. With an expected login set, any other Remote-User gets 403;
  • without SSO: a "set password" screen (MIN_PASSWORD_LEN, Argon2id), then panel 2FA must be enabled before any household data opens (needs PANEL_TOTP_KEY; without it the account is not created);
  • already logged in (an account from another household): the membership is added to that account; a password session needs panel 2FA.

Accepting = confirming the privacy notice (the installation administrator sees all household data, every visit audited; the cluster operator has technical access to the database and key). Creating one needs a fresh password or 2FA confirmation. An invitation is single-use, can be revoked at any time, stops working as soon as the inviter loses the right to issue it (removed from the household, dom.zaproszenia revoked, role lowered below the invited role), dies after 5 failed unauthenticated attempts (wrong login on the no-SSO screen, used/expired/revoked token — a wrong account logged in through Authelia is refused without burning the link), and every address is limited to 10 failed attempts per 15 minutes (also for tokens that do not exist). It grants membership only in its household — never the inviter's other households — does not change the role of an existing member and cannot be accepted by the installation-administrator account. The inviter is notified through the gateway only when the gateway supports an explicit recipient (odbiorca); otherwise the acceptance is only in the household audit log. Expired and revoked invitations are deleted after 30 days. An invitee who joined through SSO has no known panel password: to confirm sensitive changes (e.g. their own notification channels) they enable panel 2FA in Settings and confirm with a code.

Members can also be managed in the panel (Settings → Konta, permission konta.zarzadzanie in that household): add (random password shown once), remove from the household (the account is deleted once it has no household left), role, per-account permissions, another member's preferences in that household and — only when the actor manages every household of that person — their channels. Every change there requires the acting account's password or 2FA code. You cannot remove yourself, and a household's last account with konta.zarzadzanie cannot be removed, demoted or stripped of it.

Panel behind SSO (Authelia)

With sso.enabled: true in the chart, Traefik puts Authelia (password + 2FA, renacode-infra) in front of the panel and the same switch sets SSO_NAGLOWKI=1 in the pod. Only then does the panel trust the Remote-User header from Authelia (contract: renacode-infra docs/sso-tozsamosc.md, code: src/kidwatch/panel_sso.py):

  • Remote-User must equal an existing panel login (user-add), lowercase [a-z0-9._-]. No accounts are created on the fly — an unknown login gets 403 with a readable message and an "SSO log out" button — with one exception: accepting a valid household invitation (see "Invitations" above). A new person needs either an Authelia account and user-add with the same login, or an Authelia account and an invitation. An existing account whose login differs from Authelia (e.g. before SSO is switched on): user-login <old> <new> renames it in place — password, 2FA, household memberships, roles, admin flag, notification channels and history stay (same account id); every place that stores the login as text (audit, request queues, outbox, per-account dedup, notification history) is rewritten in the same run, sessions are closed. Dry run by default. Procedure: (1) run without --zapisz and check the line dom N: outbox.odbiorca — notifications waiting for this account; best done when it is 0 (they are not lost either way: the running process reloads accounts before dropping a queue it no longer recognises); (2) run with --zapisz — no pod restart needed, the panel and the notification queue read accounts from the database on every request; (3) user-list: failed password attempts already recorded for the NEW login are merged into the account (the stricter lock wins) — if it shows as locked, user-reset; (4) running the same command again is safe and finishes anything left after an interrupted run (the old account no longer exists, the new one does AND the audit log has the konto.login "old -> new" entry newer than the new account, at most 24 h old, and the old login has left no audit/request traces after it → only the text traces are rewritten; --dokoncz forces this). Without that proof — e.g. the old account was deleted and the new login belongs to someone else — it refuses, even with --dokoncz, so a deleted person's traces never land on another account. With SSO the Authelia login is the target — change only Kidwatch, never the Authelia account, to match.
  • Remote-Groups must contain wlasciciel, rodzina, kidwatch-goscie or admins (chart sso.grupy → SSO_GRUPY; must match the Authelia rule for this host). kidwatch-goscie is for people from outside the family invited to a household: Authelia admits that group only to this host. Groups only gate entry; what an account may do comes from its household memberships in the panel database (see "Roles" below). admins is additionally required for the installation-administrator flag to take effect; an admins login without a panel account gets 403 like any other.
  • The session is marked sso and is created without the panel password or panel TOTP (Authelia already enforced 2FA). Account lockout from wrong panel passwords does not block SSO, and SSO does not lift it.
  • Sensitive actions still need the panel password or panel 2FA code exactly as before (password change, disabling 2FA, WhatsApp sender/recipients, bot QR) — an SSO session is not a fresh confirmation. MDM commands, game time and TV pause need a session + CSRF and the matching permission (see "Roles and permissions" below), as with a password session.
  • "Wyloguj (SSO)" deletes the panel session and goes to https://auth.renacode.com/logout?rd=https://<host>/, which ends the Authelia session for all RenaCode apps.
  • Break-glass: without the header (kubectl port-forward when Authelia is down) the normal password + TOTP login works unchanged. An sso session is valid only together with the matching Remote-User: a copied SSO cookie opens nothing over port-forward, and turning SSO off ends all SSO sessions.
  • Without SSO_NAGLOWKI=1 all Remote-* headers are ignored. Never set it by hand: without the Traefik middleware a client can send Remote-User itself. The chart always sets the variable explicitly ("0" without SSO), so a key in the Secret cannot switch it on.

Adding a family member (two steps, same lowercase login in both places):

  1. Authelia account in group rodzina (renacode-infra, authelia-uzytkownicy).
  2. Panel account with the view-only role, password from stdin:
    read -rs HASLO && printf '%s\n' "$HASLO" | kubectl -n default exec -i deploy/kidwatch \
      -c kidwatch -- python -m kidwatch user-add <login> --rola podglad --password-stdin; unset HASLO
    The password is the break-glass login and the confirmation for their own password/2FA changes. user-add warns when the login could never match Remote-User.

Adding a person from outside the family (another household, E4):

  1. The installation administrator creates an Authelia account in group kidwatch-goscie only (renacode-infra, authelia-uzytkownicy, procedure "Nowa osoba" in docs/zadania-wlasciciela.md) — it opens the Kidwatch panel and nothing else.
  2. The household owner creates an invitation with that login as the expected login (the screen warns when it is left empty under SSO) and sends the link. No user-add: the panel account appears when the invitation is accepted.
  3. Tell the invitee to log in at https://auth.renacode.com (and register 2FA) before opening the link — or simply open the link a second time: the first Authelia login redirect drops the #token part of the address, and the page then asks to open the link again.

Roles and permissions (from the panel database, never from Remote-Groups; code: src/kidwatch/uprawnienia.py). Every panel route is declared once with one permission (POST_TRASY / GET_TRASY in panel.py) and checked in one place; /api/auth/me returns the account's uprawnienia and the web UI hides buttons by permission, not by role name. A test walks the router tables: a new route without a permission decision fails CI, and every route the podglad role lacks is checked for 403.

Permission What it opens wlasciciel (default; all accounts that existed before roles) podglad
konto.wlasne own password and 2FA (cannot be revoked) yes yes
podglad.historia Pulpit, Ekrany, Użycie, Trendy, Dzień, Powiadomienia, device and TV-pause state, CSV export yes yes
tv.pauza TV monitoring pause / resume yes no (403, buttons hidden; the pause banner stays visible)
czas_gry.zmiana game time (block/allow/bonus) yes no
mdm.podglad MDM section: iPads, UDIDs, events yes no (section hidden)
mdm.komendy MDM refresh/lock/restart, enrollment link, forced iPadOS update yes no
tv.aplikacja Kidwatch TV app (state, install, pairing) yes no
whatsapp.konfiguracja WhatsApp/notification channels, recipients, bot QR only the active installation administrator (see Households) no

In a household the same table applies per membership: wlasciciel-domu has everything wlasciciel had, zarzadca everything except account management, integrations and invitations, podglad as above. The new dom.integracje permission is used by the integrations screen (together with the installation-administrator role), dom.zaproszenia by invitations; dom.urzadzenia is reserved for the household devices screen.

Logging out needs no permission. python -m kidwatch user-rola <login> wlasciciel|podglad changes a role, clears that account's overrides and logs it out. Single permissions can be granted above or revoked below the role:

python -m kidwatch user-uprawnienia <login>                         # show effective permissions
python -m kidwatch user-uprawnienia <login> +tv.pauza               # e.g. let a viewer pause TV
python -m kidwatch user-uprawnienia <login> -mdm.komendy            # owner without MDM commands
python -m kidwatch user-uprawnienia <login> =tv.pauza               # back to what the role gives

Overrides live in the user_uprawnienia table of panel-auth.db (created on start, existing accounts keep their role and get no overrides), every change logs the account out so the UI redraws its buttons, and user-list shows them next to the role.

Profile: password and WhatsApp

Avatar in the header → Profil: two-factor authentication, password change (old + new, min. 12 characters; logs out other sessions) and Powiadomienia / WhatsApp: gateway channel status, linking WhatsApp via QR (from WAHA, refreshed every 20 s until WORKING; this is where the bot number is connected), sending a test, disconnecting, and the list of WhatsApp recipients. The panel calls the gateway server-side with BRAMKA_KLUCZ_ADMIN (notifications are sent with the separate BRAMKA_KLUCZ); the browser knows neither.

Recipients (at most 5): number with country code, label (≤ 40 characters), a "Dostaje" (receives) column and an "active" switch. Changes are local until you save the list — saving the whole list requires the password or a 2FA code (the list is shared by all RenaCode apps, a session alone is not enough). A wrong confirmation counts toward account lockout; an invalid list (bad number, duplicate) is rejected earlier with 400, without checking the password. The test button sends a WhatsApp-only test to active recipients with "everything" routing and shows how many it reached (with the masked number of anyone it didn't reach).

"Dostaje" is gateway routing: "Wszystko" (everything) — every RenaCode app, kidwatch watchdog alerts and the test message; "Tylko kidwatch (dzieci)" — regular kidwatch notifications without watchdog alerts (kidwatch sends those with kategoria: czujka). A new row defaults to kidwatch-only. An alert no one receives on WhatsApp goes by e-mail to the gateway owner. With a gateway that predates routing the column shows "wszystko" and saving doesn't send the routing field.

The gateway sends to all active recipients of a route at once; a partial delivery is a success with no e-mail, an e-mail goes out only when nobody was reached. Numbers are visible only to the logged-in account owner in the profile; panel and gateway logs show only the last three digits. Endpoint: POST /api/profile/whatsapp/recipients {"recipients": [{"number", "label", "active", "sources"?: ["*"] | ["kidwatch"]}], "confirm": "<password or code>"} (the old single-number POST /api/profile/whatsapp/recipient remains). It requires a gateway with POST /v1/whatsapp/odbiorcy — with an older gateway the profile shows its single recipient, but saving the list fails.

Notifications per account

Each panel account is a notification recipient with its own profile, stored in panel-auth.db (never in the repo, values or logs — logs and the panel show only masked numbers/addresses):

  • channels: WhatsApp number and/or e-mail, and the route (auto = WhatsApp, e-mail when it fails; whatsapp; email). Changing a channel requires the password or a 2FA code — your own under Settings → Moje powiadomienia, someone else's only with konta.zarzadzanie;
  • categories: sessions, alarms (iPad at night, app installed/removed), reports, game time, MDM, watchdog/failures, other. Each category needs a permission: sessions/alarms/reports/game → podglad.historia, MDM → mdm.podglad, watchdog/other → powiadomienia.techniczne (owners only by default). Revoking a permission stops the category immediately, whatever the account has saved;
  • quiet hours (e.g. 22:00–07:00): session info is dropped, reports and technical alerts wait for the end of quiet, priority-5 alarms go through (unless the account turns that off);
  • children: notifications about another child's device are skipped; TV, combined reports and technical alerts concern the whole home.

The outbox splits every notification into one queue per account (plus one for home channels — ntfy and Home Assistant get everything once, as before). Each account gets an event at most once (<dedup>@<login> in sent), a failing number blocks only that account's queue, and the panel history keeps one row per event with per-account results (bramka:<login>).

What stays per home: the WhatsApp bot (sender), UniFi, MDM, TV, the NextDNS account, ntfy/Home Assistant — one device or service per home; splitting them per person would duplicate state with no gain.

Mode. Per-account routing (konta) is used only when (1) the owner switched it on (konta-tryb konta, konta-migruj --zapisz --wlacz or the panel), (2) notifiers.bramka.per_konto is not false, and (3) the gateway declares explicit recipients in GET /v1/stan ("funkcje": ["odbiorca"]). Otherwise kidwatch keeps the old route mode (trasy): one request per event, the gateway's recipient list decides. An older gateway would ignore the odbiorca field and deliver every per-account request to everyone, which is why (3) is checked (every 10 min) rather than assumed.

Migration from the gateway list: konta-migruj reads GET /v1/whatsapp, maps the single * recipient to the single owner, other positions need --przypisz N=login, and prints a before/after table of WhatsApp numbers for every notification kind. --zapisz stores channels and categories (granting e.g. powiadomienia.techniczne to a viewer who used to receive kidwatch:czujka); --wlacz switches the mode only when the table has no differences and no active recipient is left without an account. konta-tryb trasy switches back at any time.

Gateway request in konta mode: POST /v1/wyslij with "odbiorca": {"kanal": "auto"|"whatsapp"|"email", "whatsapp"?: "48…", "email"?: "…"} — delivered only to that recipient, bypassing the gateway's routes.

Notification content

The daily summary, weekly report and session end are sections per child and per TV, with bullet points; TV titles are shortened (without "| channel | tags", at most 5, "+N more"). In WhatsApp section headers are bold (*…*); in e-mail the gateway strips the asterisks. The panel receives the same data in structured form (notifications.data) and draws lists with mini bars; older entries are shown as text.

Panel: tabs, trends, archive

Tabs: Powiadomienia (notification history), Wszystkie ekrany, Użycie (daily usage chart), Trendy, Dzień (day view), with a per-child switcher.

  • Wszystkie ekrany (all screens) — iPads (minutes from DNS) and the TV (playback minutes, titles) on one daily timeline, with day and ISO-week totals. With a child selected, the TV is a separate greyed-out "TV (wspólny)" (shared) lane — it can be hidden and is not added to the child. Below, TV app time "exact, from the TV" (Android dumpsys usagestats, read every tv.usage_poll_minutes) next to the session estimate; the same time goes into the weekly report.
  • Trendy — week over week and month over month per child (total, daily average, top apps, night minutes) and a 12-week chart.
  • Archive — the daily_rollup table: one row per device per day (minutes, sessions, top apps, night minutes, TV minutes from usagestats). Recomputed every 15 min for today and yesterday, plus any day without an aggregate (the first start backfills all existing history). It outlives store.retention_days; its own retention is store.rollup_retention_days (0 = no limit, the default).
  • CSV export — GET /api/export.csv?from=YYYY-MM-DD&to=YYYY-MM-DD&child= (logged in only; last 30 days by default, at most 3660). Daily aggregates, UTF-8 with BOM (for Excel), cells starting with = + - @ prefixed with an apostrophe.

Game time (NextDNS)

The iPad card in the panel has Zablokuj gry / Odblokuj / +30 min (block games / unblock / +30 min) buttons. They toggle the services and categories from game_time in NextDNS parental controls (active: true/false). Blocks in NextDNS are a profile setting, so each child needs their own profile (devices[].nextdns_profile; without it — the main profile). kidwatch then reads the log streams of all profiles, each with its own cursor.

game_time:
  enabled: true
  services: [youtube, roblox, minecraft, fortnite, tiktok, twitch]
  categories: [gaming]            # video-streaming also blocks Netflix and Disney+
  default_bonus_minutes: 30
  block_schedule: {start: "20:00", end: "07:00"}   # optional
  • A click only queues the change (in panel-auth.db); it is executed by the service loop — the single writer of kidwatch.db — which also sends a push.
  • A bonus counts from the end of the current bonus, capped at max_bonus_minutes (180); afterwards the loop restores the block.
  • Every sync_minutes (5) the state is read from NextDNS. A change made by hand in my.nextdns.io is accepted; a failed kidwatch write is retried every minute.
  • The schedule blocks at the start of the window and in the morning lifts only its own block — a manual block from the panel stays.
  • A category id outside NextDNS's list (dating, gambling, gaming, piracy, porn, social-networks, video-streaming) is a config error. An unknown service id is only a log warning — NextDNS adds services, and an id it doesn't know will be rejected by the API itself (error in the panel and in the push).

TV monitoring pause (trips)

When the kids are away and others watch TV at home, Ustawienia → Telewizor (the account menu; formerly Profil) has a Wstrzymaj monitoring TV (pause TV monitoring) button — until a date and time (default: 7 days ahead) or until cancelled. While paused, the panel shows a banner Monitoring TV wstrzymany do … with a Wznów teraz (resume now) button. iPads keep being monitored as usual (NextDNS works away from home).

  • A paused TV is not polled at all (neither the player nor usagestats): no sessions, pushes or TV minutes in daily and weekly totals. The "TV not responding" watchdog stays quiet and counts silence from the end of the pause. The first usagestats read after a pause only sets a baseline so paused time doesn't leak into "exact, from the TV".

  • Viewing in progress when the pause starts ends silently at the last read.

  • A low-priority push when enabled, when the end time changes and when the pause ends (Monitoring TV wznowiony).

  • The daily summary and weekly report note "monitoring wstrzymany od … do …" in the TV section, and the all-screens timeline hatches that period.

  • Like game time: a click (session + CSRF) only queues the change in panel-auth.db with the login; the loop tick executes it (within ~30 s). State and audit (who enabled it and when, who or what ended it) live in the tv_pause table in kidwatch.db and survive restarts.

  • From the command line (queues the same job; the running run executes it):

    python -m kidwatch tv-pauza --do 2026-10-10T18:00   # pause until
    python -m kidwatch tv-pauza --do-odwolania          # pause until cancelled
    python -m kidwatch tv-pauza --wznow                 # resume now
    python -m kidwatch tv-pauza                         # show state

    A time without a zone is local time from timezone.

Domain map

app_map.yaml, hot-reloaded — edit it locally without a restart. In the cluster the file is mounted from a ConfigMap via subPath, which doesn't update: a change rolls out through the checksum/app-map annotation, i.e. a pod restart.

noise:                    # never counts as activity
  - apple.com
  - apple                 # Apple has its own .apple gTLD and actually uses it
shared:                   # extends an open session, never opens or names one
  - cloudfront.net
apps:
  "Roblox":
    - roblox.com          # the domain and all subdomains
    - "*.rbxcdn.com"      # same thing, written for readability
  "Something":
    - "=only.example.com" # EXACTLY this domain, no subdomains

Noise wins on an identical pattern, but the longer pattern wins — music.apple.com in apps beats apple.com in noise.

Non-system traffic with no match is recorded as Przegladarka / inne (browser / other) — in the database, sessions and the panel. By default there is no separate push for it (engine.notify_unknown: false): the first live day showed it is almost entirely app backends (Google, analytics, ads), not websites.

What you can see of browsing

DNS knows the domain, never the page URL or content. That is the ceiling of this method, and nothing raises it short of a proxy with its own CA (see below).

Within that ceiling kidwatch names things instead of lumping them into Przegladarka / inne:

[START]  <device> aktywny
         16:20 — super-gierka-online.com
[APKA]   <child>
         Przegladarka / inne: forum-o-grach.pl, wikipedia.org, jakas-gazeta.pl
[KONIEC] <device> — koniec
         15:12–15:59, 47 min
         Roblox ~26 min, Przegladarka / inne ~14 min
         strony: forum-o-grach.pl, wikipedia.org, jakas-gazeta.pl
         (czasy szacunkowe — liczba minut z ruchem DNS)

With notify_unknown: true a push goes out on the first unrecognised domain, and further ones wait for app_cooldown_minutes and are batched — otherwise every browser click would be a separate notification. Hosts of one site are collapsed to one name (a.shop.example, cdn1.shop.example → shop.example).

Domains report per child per day:

uv run python -m kidwatch web --days 7 [--device "<device>"] [--limit 25]
=== niedziela 27.09.2026 ===

<device> (<child>)
  aplikacje: YouTube ~37 min, Roblox ~36 min, Przegladarka / inne ~33 min
  strony (5 domen):
      10x  forum-o-grach.pl
      10x  super-gierka-online.com
       5x  wikipedia.org

Day names are hard-coded in Polish, not taken from locale — the container has C, the terminal something else, and the report should look the same everywhere.

What you won't see, and why it isn't worth trying

Page URLs, content, messages, in-app history. The only way is a proxy with its own CA installed on the iPad. Technically possible on the child's own device, but: certificate pinning breaks a large share of apps, you also intercept the children's passwords and private data, and the proxy becomes a single point whose compromise gives away everything. Fragile and disproportionate.

What DNS will never give you, however hard you try:

need only source
list of installed apps USB cable + ideviceinstaller, or Settings → iPad Storage
subscriptions and in-game purchases Apple receipts by e-mail, Settings → Subscriptions
preventing purchases "Ask to Buy" in Family Sharing — beats any after-the-fact monitoring
exact per-app time, including offline Screen Time (no public API)
games that work offline nothing — they are invisible to DNS

Three sections, three behaviours — and why games forced them

section opens a session extends an open one names it
noise no no no
shared no yes, within shared_extend_minutes no
apps yes yes yes

shared exists because of games. Asphalt talks to gameloft.com once every few minutes and to CloudFront constantly. If CDNs were noise, a gaming session would be artificially short or never happen; if they were an app, an ad fetched in the background would wake you up with a push at night.

Extension is bounded by shared_extend_minutes (default 30), counted from the last recognised event. Without it background app refresh would keep a session open forever and no duration would mean anything.

What shared deliberately does not contain: akamaiedge.net and akadns.net. Games use them, but on an iPad they are dominated by Apple background traffic — extending sessions with it would defeat the bound.

Games are the hardest case

Games are detected worse than streaming, for three reasons:

  1. They play offline. A game that doesn't hit the network is invisible to DNS. There's no way around it — only Screen Time shows it.
  2. They ride shared CDNs. Netify shows Gameloft using gameloft.com plus Akamai, CloudFront, AWS and Cloudflare. Only the first can be attributed.
  3. Publishers change backends. Any domain list written up front rots.

Hence a command that closes the gap from real data:

uv run python -m kidwatch domains --unknown-only [--days 7] [--limit 40]
domena                                 kategoria   zapytan  hostow  aplikacja
----------------------------------------------------------------------------
super-gierka-online.com                unknown          10       2
forum-o-grach.pl                       unknown          10       1

Add the top of this list to app_map.yaml — the file hot-reloads, no restart needed. It is the only reliable way to get good game coverage.

Other CLI commands

command what it does
run the service: listen to sources, send notifications, serve the panel
test-notify send a test push through the enabled channels
summary [--date YYYY-MM-DD] print the daily summary (doesn't claim the dedup key)
weekly [--week YYYY-Www] [--dry-run] weekly report (see above)
domains, web domain reports (see above)
replay <file.jsonl> [--dry-run] replay a recorded day against an in-memory DB
tv [--raw] one-off TV read over ADB (checks key and tunnel)
tv-pauza TV monitoring pause (see above)
unifi [--fingerprint] who is on home Wi-Fi per UniFi; --fingerprint prints the controller's SHA-256 certificate fingerprint for unifi.cert_sha256
device, device-watch one-off / looping read of iPad state over lockdown (device_read, requires pairing and the same LAN)
user-add, user-reset, user-del, user-login, user-list, user-rola, user-uprawnienia panel accounts, roles and permissions
domy [--sprawdz] [--baza <copy>] households in panel-auth.db and their members (runs the idempotent migration to "Dom 1"; see docs/architektura-domy.md); --sprawdz decrypts every integration with KIDWATCH_KEK and prints how many keys/integrations it verified — zero is a failure (backup restore test)
dom-importuj 1 --podglad|--zapisz move Dom 1 from config.yaml + env secrets into the encrypted database, after a field-by-field comparison (docs/sejf-domow.md)
dom-zrodlo <dom> yaml|baza where a household takes its configuration from (rollback: dom-zrodlo 1 yaml)
dom-sieci <dom> [--dodaj CIDR] [--usun CIDR] [--host H] networks/API hosts a household's integrations may connect to (SSRF guard, instance admin only)
klucze-rotuj, dom-klucz-rotuj <dom> re-wrap household keys after a KIDWATCH_KEK rotation; new data key for one household
klucze-potwierdz [--baza <copy>] proves the key from your password manager (stdin) opens every household key; refuses otherwise — a precondition for removing an old key
user-admin, dom-dodaj, dom-czlonek installation administrator, new household, household membership

Global options: --config <path> (default $KIDWATCH_CONFIG or config.yaml), -v/--verbose.


Own MDM server (kidwatch-mdm)

An optional, separate container (src/kidwatch_mdm/, Dockerfile.mdm, charts/kidwatch-mdm) — an MDM server written from scratch, managed from the Kidwatch panel. Full setup (APNs certificate via mdmcert.download, secrets, Argo CD, enrolling iPads), in Polish: docs/mdm.md.

unsupervised iPad supervised iPad
installed apps, alert on new / removed ✅ ✅
state (OS, battery, storage, last contact) ✅ ✅
remote screen lock with a message ✅ ✅
alert when the MDM profile is removed ✅ (the child can remove it) profile can't be removed
OS update enforcement with a deadline (DDM) ✅ ✅ + automatic installs
VPN / Private Relay blocked, no wiping, no own profiles ❌ ignored by iOS ✅
DNS that can't be turned off ❌ not sent (see above) ✅

How it works:

  • Identity per device. Each enrollment profile carries a fresh client certificate from the server's own CA; every message from the iPad is signed (Mdm-Signature) and checked: issuer, body signature, binding to the UDID.
  • Signed enrollment profile. Signed on the fly with the server's Let's Encrypt certificate, so iOS shows it as Verified.
  • Policy validated against Apple's schema at startup — an unknown key stops the server instead of silently not protecting. Keys that only work under supervision are sent only after the iPad reports IsSupervised.
  • Two ports. The MDM port is exposed only under /mdm/; the admin API (token) is reachable only from the Kidwatch pod (NetworkPolicy).
  • Kidwatch integration (mdm.enabled): an MDM tab in the panel (proxied, CSRF-protected; the API token never reaches the browser) and the alerts listed in the message table above.
  • Per home (multi-home stage E3b): every device belongs to a home. A home token (kidwatch-mdm token-domu <home>) sees and commands only that home's devices (a foreign UDID is a 404); the instance token keeps full scope. The Kidwatch panel checks independently that a UDID belongs to the home before calling the server. See docs/mdm.md, "Domy".

Deployment

k3s + Argo CD

Helm chart in charts/kidwatch. The author's Argo CD application lives in a private infrastructure repo. Deploying without that infrastructure (your own image, ntfy instead of the gateway, your own domain) is described in docs/uruchomienie-od-zera.md, section "Bez infrastruktury RenaCode". Argo CD watches charts/kidwatch/values.yaml, and CI (.github/workflows/docker-publish.yml) runs tests, ruff, the front-end tests/build and helm lint, then builds the image to GHCR and bumps its tag in that file on every push to main — together with the kidwatch-mdm image and charts/kidwatch-mdm/values.yaml. There is no SSH or kubectl apply.

The GHCR image is private: the chart expects an imagePullSecrets entry ghcr-pull (set imagePullSecrets: [] and your own image.repository when building your own image).

Secrets are created once, by hand — Argo CD doesn't sync them. Commands are in docs/uruchomienie-od-zera.md (step 7):

Secret contents required
kidwatch-secrets NEXTDNS_API_KEY, BRAMKA_KLUCZ, BRAMKA_KLUCZ_ADMIN, PANEL_TOTP_KEY, optionally UNIFI_API_KEY (or NTFY_TOPIC instead of the gateway keys) yes
kidwatch-config the real config.yaml (uploaded by tools/wgraj_konfiguracje.sh) yes
kidwatch-adb adbkey, adbkey.pub accepted by the TV with the TV sensor
kidwatch-pairing pairing records <UDID>.plist no (mounted only with deviceRead.enabled: true; disabled in the cluster)

A pairing record contains the host's private key — whoever has it is a trusted computer for the iPad. .gitignore blocks *.plist so it can't land in the repo.

Things worth knowing:

  • replicaCount: 1 + strategy: Recreate are a correctness requirement. SQLite has one writer; two pods would diverge session state and duplicate pushes. For the same reason the panel runs as a thread in the same process, not in a separate pod.
  • Disable pruning in the Argo CD application. The database PVC has helm.sh/resource-policy: keep; automatic deletion could remove the volume with its state, and then the service would send every push again.
  • Synced doesn't mean "the process reads the new config". The pod has a checksum/config annotation over files/config.yaml, but the real config is in the kidwatch-config Secret, which the checksum doesn't cover — after changing it, tools/wgraj_konfiguracje.sh validates the file locally, uploads it and restarts the pod.
  • charts/kidwatch/files/app_map.yaml is a copy of the root file (a chart can't reach outside its directory). After changing the map: cp app_map.yaml charts/kidwatch/files/app_map.yaml. tests/test_deploy.py catches drift.
  • NetworkPolicy (networkPolicy.enabled, on by default): ingress only from Traefik to the panel port; optional egress restriction to the home network (networkPolicy.egress.siecDomowa / wDomu, empty in the public repo and injected by the deployment).
  • Ingress uses Traefik with per-client rate limits (ingress.limity, requires the traefik.io/v1alpha1 CRDs) and optional alias hosts that 301-redirect to the main host (ingress.aliasy).

Tailscale — disabled

The chart has a Tailscale sidecar (tailscale.enabled, default false) left over from when iPad reads were meant to go over a tailnet. Verified 2026-10-02: iOS accepts lockdown (port 62078) only from the same local network, so neither Tailscale nor a WireGuard tunnel home works (ConnectionResetError). Tailscale is not deployed and not needed. The pod reaches the TV and UniFi through a VPS ↔ home WireGuard tunnel (docs/uruchomienie-od-zera.md, step 4).

docker compose

cp config.example.yaml config.yaml
printf 'NEXTDNS_API_KEY=...\nNTFY_TOPIC=...\n' > .env     # .env is in .gitignore
docker compose up -d --build

The databases (kidwatch.db, panel-auth.db) live on the kidwatch-data volume under /data — docker-compose.yml sets KIDWATCH_STORE_PATH, which overrides store.path from the config. app_map.yaml is mounted from the host, so the map can be edited without restarting the container. The image includes the built panel at /app/web; to use it set panel.enabled: true, panel.host: 0.0.0.0, panel.static_dir: /app/web and publish the port.


Profile: regular vs supervised

Verified against Apple's schema. Which networks a DNS profile applies to depends only on how it is installed:

method network scope
local install (manual, Apple Configurator) all ✅
supervised (MDM, supervised device) all ✅
device enrollment (MDM without supervision) managed networks only ❌

Source: network.dns-settings.yaml (lines 242–249) and the note in com.apple.dnsSettings.managed.yaml (257–259).

Conclusion: on an unsupervised iPad keep the manually installed DNS profile. MDM without supervision gives worse network coverage than a manual profile — so the own MDM server (kidwatch-mdm) sends DNS only to supervised iPads; on the rest the manual NextDNS profile stays untouched. Supervision (Apple Configurator with a cable and wiping the iPad, or Apple Business automated enrollment) is the only thing that adds real locks.

With --supervised the generator adds keys that work only on a supervised device — on a regular iPad iOS ignores them:

key since blocks
ProhibitDisablement iOS 14 turning DNS off in Settings
PayloadRemovalDisallowed iOS 6 removing the profile
allowCloudPrivateRelay: false iOS 15 Private Relay bypassing DNS
allowVPNCreation: false iOS 11 a free VPN from the App Store
allowUIConfigurationProfileInstallation: false iOS 6 the child's own profile

That is why the default mode doesn't add them. A key that does nothing would give an illusion of protection — and nothing is worse here than a false sense of visibility.

Other generator options: --org (organisation name in the profile).

Note for the future: the payload is deprecated

com.apple.dnsSettings.managed is marked deprecated as of OS 27. Its successor is the DDM declaration com.apple.configuration.network.dns-settings (introduced in 27.0), delivered by an MDM server. Deprecated doesn't mean removed — the old payload works. The --also-declaration flag writes a JSON file with the DDM declaration next to the profile, ready for when that changes.


Development

uv sync
uv run pytest              # the only tests that open a socket are in test_integration_live_http.py
uv run ruff check .
(cd web && npm ci && npm test && npm run build)

Replay a realistic day and compare with the snapshot:

uv run python -m kidwatch --config tests/fixtures/config.yaml \
  replay tests/fixtures/day.jsonl --dry-run

Validating the profile against Apple's schema

plutil -lint checks only plist syntax. A key with a wrong name, in the wrong place in the tree or of the wrong type passes lint without a blink, and iOS silently ignores it — the profile installs "fine", it just doesn't do what it promises. For a profile whose whole job is blocking DNS bypasses, that is the worst possible failure mode.

Proof: a profile with the typo allowVPNCreaton (instead of allowVPNCreation) passes plutil -lint as OK.

tests/test_profile_schema.py validates every emitted key against Apple's official schema — name, type, allowed values, nesting level and supervision requirement. The list of keys requiring supervision is derived from the schema, not written from memory. The index is generated from apple/device-management:

uv run python tools/fetch_apple_schema.py   # refreshes tools/apple_schema_index.json

This test caught a real bug: ProhibitDisablement was being placed inside DNSSettings, while according to the schema it is its sibling at the payload level. In the wrong place iOS ignores it, so --supervised mode promised a lock that wasn't there.

Can't be tested with a virtual iPad

It can't. Apple doesn't allow iPadOS virtualisation, so Parallels is irrelevant, and the Xcode iOS simulator has no mechanism for installing a configuration profile — xcrun simctl has no such subcommand (the --profiles flag concerns CoreSimulator device-type profiles, not .mobileconfig).

The profile can be checked on a real Apple system in a macOS VM — the com.apple.dnsSettings.managed payload supports macOS 11.0+ — but that verifies macOS, not iPadOS. Beyond that there is schema validation (above) and installing on a real iPad, which is reversible.

Integration tests on a real socket

The rest of the tests run on httpx.MockTransport, a fake transport — no socket is opened. That doesn't prove that after an actual TCP disconnect the source resumes the stream from the right cursor.

tests/test_integration_live_http.py starts two real HTTP/1.1 servers on ephemeral loopback ports and drives the full path: socket → SSE → parser → engine → HTTP POST → receiver. The NextDNS server disconnects mid-stream, and the test checks that the reconnect comes with ?id=evt-1.

replay never touches the real database (it uses :memory:) — otherwise a test run would claim dedup keys and block real notifications.

To change the day scenario: edit tools/gen_fixture_day.py, then regenerate the fixture and snapshot (commands in its docstring).

A decision that departs from the obvious

ntfy gets JSON, not HTTP headers. Headers don't carry UTF-8 — httpx encodes them with the ascii codec, so a title with a Polish character blows up the whole send with UnicodeEncodeError. Children's names are exactly the strings that go into the title. The test test_naglowki_http_naprawde_nie_przenosza_polskich_znakow pins down the reason.


Privacy

DNS logs show what a device connects to. This is data about children — keep it to yourself, on your own NextDNS profile and your own ntfy server, and don't give access to anyone who doesn't need it. Kids should know their iPads have a DNS filter; kidwatch is not a tool for hiding supervision.

License

MIT — see LICENSE.

About

Self-hosted alerts about kids' iPad activity inferred from NextDNS query logs: sessions, apps, night use and daily/weekly reports via WhatsApp or ntfy, plus a 2FA web panel, NextDNS game-time controls and optional Android TV and UniFi sensors.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages