obacht

obacht is in early development and not ready for families yet. Follow along on GitHub

Running obacht at home

On this page

One obacht server serves the API and the parent app. It needs Postgres and a hostname with HTTPS. The parent app can only be installed, and can only send push notifications, over HTTPS, and release builds of the Android agent only talk HTTPS.

Two ways to run it: the NixOS module, or the Helm chart on Kubernetes.

NixOS

Add the flake as an input and enable the module. It creates a local Postgres database obacht and connects over the Unix socket, so no password is needed.

# flake.nix of the host
inputs.obacht.url = "git+ssh://git@github.com/make-it-so-dev/obacht";

# configuration.nix (with `obacht` passed in from the flake inputs)
imports = [ obacht.nixosModules.default ];

services.obacht = {
  enable = true;
  pushContact = "mailto:you@example.org";
  # listen = "127.0.0.1:8080";      # default
  # usageRetentionDays = 90;        # default
};

Put a TLS reverse proxy in front. Live updates are server-sent events, so turn off response buffering. With Caddy:

services.caddy = {
  enable = true;
  virtualHosts."obacht.example.org".extraConfig = ''
    reverse_proxy 127.0.0.1:8080 {
      flush_interval -1
    }
  '';
};

With nginx:

services.nginx.virtualHosts."obacht.example.org" = {
  enableACME = true;
  forceSSL = true;
  locations."/" = {
    proxyPass = "http://127.0.0.1:8080";
    extraConfig = ''
      proxy_buffering off;
      proxy_read_timeout 1h;
    '';
  };
};

To use an existing Postgres instead, set database.createLocally = false and put DATABASE_URL=postgres://... in a file outside the Nix store, referenced by services.obacht.environmentFile.

Optional and experimental: to import Steam playtime, get a Steam Web API key at https://steamcommunity.com/dev/apikey and put OBACHT_STEAM_API_KEY=... in the environmentFile (Helm: steam.existingSecret).

Optional: parents can log in through an OpenID Connect provider such as Authelia, Keycloak or Authentik instead. It is then the only parent login: password login, setup, adding parents by hand and password changes are off, so no password set once and never rotated can get around the provider. Parents are the members of one provider group, checked at every login.

Register obacht at the provider as a client with the redirect URL https://obacht.example.org/api/v1/auth/oidc/callback and the scopes openid profile groups, create a group for the parents, then:

services.obacht.oidc = {
  issuer = "https://auth.example.org";
  clientId = "obacht";
  clientSecretFile = "/run/secrets/obacht-oidc"; # outside the Nix store
  redirectUrl = "https://obacht.example.org/api/v1/auth/oidc/callback";
  parentGroup = "obacht-parents";
  name = "Authelia"; # on the login button
};

(Helm: oidc.* values.)

  • Group members get an account on their first login. A parent account from before with the same username (the provider's preferred_username) is taken over instead, and its password is deleted.
  • Groups are read from the groups claim of the ID token, or from the userinfo endpoint if the ID token has none (Authelia's default). Other names: oidc.groupsClaim, oidc.scopes.
  • Removing someone from the group refuses their next login and ends their sessions then. Provider login sessions last 7 days (password ones 30), so a removed parent is logged out within a week either way; remove the parent on the Account page to end it at once.
  • When the server starts with a provider, all login sessions from passwords end. API tokens keep working.
  • The way back: remove the provider settings. Parents that came from the provider have no password; if no parent has one, POST /api/v1/setup (the "first start" form in the app) works again to create one.

The module is tested in CI by booting a VM (nix build .#checks.x86_64-linux.module -L).

Kubernetes (Helm)

See deploy/helm/obacht/README.md. While the repository is private, the cluster needs a pull secret for ghcr.io/make-it-so-dev/obacht, for example from a GitHub token with read:packages:

kubectl create secret docker-registry ghcr \
  --docker-server=ghcr.io --docker-username=<github user> --docker-password=<token>
helm install obacht deploy/helm/obacht \
  --set database.existingSecret=obacht-db \
  --set 'imagePullSecrets[0].name=ghcr' \
  --set pushContact=mailto:you@example.org \
  --set ingress.enabled=true --set ingress.host=obacht.example.org

First start

  1. Open https://obacht.example.org and create the parent account.
  2. Add a child and set limits and bedtimes.
  3. Optional (experimental, only with a Steam key): under the child's Devices, link their Steam account so time played on any PC counts toward the same limits (the profile and its game details must be public).
  4. On your phone, open the page and add it to the home screen (on iPhone this is required for notifications), then turn on notifications on the home screen.

Android phones

The APKs come from CI. CI only builds them when Android code changes, so under Actions open the CI workflow, click Run workflow on main, and download the android-debug-apks artifact from that run. It holds two builds:

  • play: works on any phone. Blocks apps through the accessibility service; the child could turn obacht off in the settings (see Platform limitations).
  • owner: obacht becomes device owner and pauses blocked apps; it cannot be uninstalled while paired. Device owner can only be set on a phone without accounts, so use a freshly reset phone (or skip adding accounts during setup), then add accounts afterwards.

Install and set up:

adb install app-play-debug.apk      # or app-owner-debug.apk
# owner flavor only, before adding any account to the phone:
adb shell dpm set-device-owner io.obacht.agent.owner/.AdminReceiver

Owner flavor by QR code

Instead of adb, a freshly reset phone can set up the owner flavor itself. The phone downloads the APK during setup, so put app-owner-debug.apk somewhere it can reach over Wi-Fi, for example python3 -m http.server 8000 in the download folder on a computer in the same network. Then print the QR code (inside nix develop .#android):

WIFI_SSID=Home WIFI_PASSWORD=... \
  scripts/owner-qr.sh app-owner-debug.apk http://192.168.1.10:8000/app-owner-debug.apk https://obacht.example.org

On the phone's welcome screen, tap six times on the same spot, connect to Wi-Fi if the code doesn't carry it, and scan the code. Android downloads obacht, makes it device owner and finishes setup; all system apps stay enabled. The last argument is optional and prefills the server address on the pairing screen. The code is tied to the APK's signing key, so print a new one when the APK comes from a different build machine.

To test this on a spare phone and record what Play Protect does for the evidence log, follow the QR provisioning test.

Then pair:

  1. In the parent app, open the child, then Show pairing code.
  2. In obacht on the phone, enter the server address (https://obacht.example.org), the code and a name for the phone.
  3. Grant usage access and notifications. With play, also turn on obacht in Accessibility and the removal protection; both screens explain what they do.

Debug builds also accept a plain http:// address (for example http://192.168.1.10:8080) for testing without TLS.

To remove obacht from a phone, remove the device in the parent app first: that lifts every restriction. With owner, the pairing screen then offers Remove obacht as device owner, after which the app can be uninstalled normally.

DNS filter (early)

For devices obacht has no agent for, the server can act as a filtering DNS resolver. Turn it on with the resolvers that allowed lookups go to: services.obacht.dnsUpstream = "tls://dns.quad9.net"; on NixOS, dnsUpstream: tls://dns.quad9.net in the Helm values, or OBACHT_DNS_UPSTREAM directly. A comma-separated list, tried in order of how well they answer:

  • 192.168.1.2 or 9.9.9.9:53: plain DNS (UDP, TCP for large answers). Fine for a resolver on your own network or host (Unbound, AdGuard Home, your router).
  • tls://dns.quad9.net (port 853) or tls://9.9.9.9: DNS over TLS.
  • https://dns.quad9.net/dns-query: DNS over HTTPS.

Host names are looked up once at start with the system's resolver; the certificate must be valid for the name or IP address given. For a resolver at home with its own certificate, point OBACHT_DNS_UPSTREAM_CA_FILE at it (PEM). obacht keeps answers in a cache for as long as their TTL allows, and gives each device its remaining TTL (never more than five minutes, see below).

Then add a DNS device for a child in the parent app (the child's devices, "Consoles and TVs") or with POST /api/v1/children/{id}/dns-devices ({"name": "Living room TV"}). The answer holds a secret DNS over HTTPS address, https://obacht.example.org/api/v1/dns-query/obdns_..., shown only once. Whatever resolves through it follows the child's rules:

  • Websites are apps named web: plus their domain. Add one under the child's apps ("Websites"), then block, limit or categorise web:tiktok.com like any app (PUT /api/v1/children/{id}/apps/web:tiktok.com); the rule covers subdomains such as m.tiktok.com.
  • Domains on website lists count as the list's category or are blocked (see below).
  • Every other domain is only blocked by schedules for all apps, such as bedtime, or a pause.
  • Blocked domains answer "no such domain". Allowed answers are cached at most until the next change, and never longer than five minutes.

Which domains a device looks up is never logged or stored. For the same reason, use a resolver on your own network or an encrypted one (tls://, https://) as upstream: with plain DNS to a public resolver, the network on the way sees every lookup.

Website lists

Instead of adding sites one by one, parents can subscribe to lists of domains that others maintain, e.g. of AI chatbots or adult sites: in the parent app under Account ("Website lists"), or with POST /api/v1/domain-lists ({"name": "AI chatbots", "url": "https://...", "counts_as": {"kind": "category", "category": "ai"}} or "counts_as": {"kind": "blocked"}). obacht downloads the list right away and again once a day; if a download fails, the domains from before stay in use. It reads plain lists (one domain per line), hosts files and simple ad-block rules (||example.com^), up to 500,000 domains and 32 MB per list.

Lists apply to every child: a category list only to children who have that category, with its limits and schedules, a blocked list to all. A listed domain covers its subdomains like a rule; the most specific entry wins, and a parent's own rule for the same domain wins over every list. Nothing is subscribed by default; check a list's license and contents before adding it.

This needs a client that speaks DNS over HTTPS (Firefox, Chrome, Windows 11, or a router or forwarder such as dnsproxy), or DNS over TLS (below). Consoles and most TVs only speak plain DNS: use AdGuard Home for those, or a router that forwards their DNS over TLS.

DNS over TLS

DNS over TLS (port 853) is what Android and Android TV call "Private DNS", and what many routers (a FRITZ!Box, OPNsense, dnsproxy) can forward to. Each DNS device gets its own host name, <secret>.dns.example.org, so obacht knows whose rules apply. You need:

  • A domain for it, e.g. dns.example.org, with a wildcard DNS record *.dns.example.org pointing at the server (often a CNAME to the server's name).
  • A certificate for *.dns.example.org. Wildcards need the ACME DNS challenge, e.g. security.acme.certs."dns.example.org" = { domain = "*.dns.example.org"; dnsProvider = ...; group = "obacht"; }; on NixOS or a cert-manager Certificate with a DNS01 solver on Kubernetes. obacht reads the files again within an hour of a renewal.

On NixOS

services.obacht.dot = {
  domain = "dns.example.org";
  certFile = "/var/lib/acme/dns.example.org/fullchain.pem";
  keyFile = "/var/lib/acme/dns.example.org/key.pem";
  openFirewall = true;
};

in the Helm values dot.domain and dot.tlsSecret (a kubernetes.io/tls Secret; the chart adds a LoadBalancer Service for port 853), or OBACHT_DOT_DOMAIN, OBACHT_DOT_CERT_FILE, OBACHT_DOT_KEY_FILE and OBACHT_DOT_LISTEN (default 0.0.0.0:853). It needs OBACHT_DNS_UPSTREAM too.

Each DNS device then has its own host name, e.g. d3f9...e1.dns.example.org, listed with the device in the parent app (dot_host in GET /api/v1/devices). Unlike the DNS over HTTPS address it is not a secret: devices send it unencrypted when they connect (TLS server name) and look it up through ordinary DNS, so anyone on the way can see it. Someone who copies it gets filtered DNS from your server, nothing else (not the child's queries); "New address" gives the device a new name and a new DNS over HTTPS address. Queries for names obacht doesn't know are refused.

Limits

Against misuse of an address or name, each DNS device may ask 20 times a second on average (with bursts of 300, a TV starting an app); beyond that its queries are refused until it slows down. Addresses and names obacht doesn't know are only looked up in the database about 10 times a second across everyone, so made-up ones can't flood it, and DNS over TLS takes at most 16 open connections per IP address. A device's rules and the child's usage are kept in memory and refreshed the moment they change.

If a device gets refused in normal use (a TV starting an app asks many names at once), raise the limits: OBACHT_DNS_QUERIES_PER_SEC (default 20), OBACHT_DNS_QUERY_BURST (default 300) and OBACHT_DNS_LOOKUPS_PER_SEC (default 10); in the Helm values under dnsLimits, on NixOS through environmentFile.

Through AdGuard Home

If you run AdGuard Home, obacht can let it do the DNS and only decide what's blocked. Point obacht at it: on NixOS

services.obacht.adguard = {
  url = "http://127.0.0.1:3000";
  username = "admin";
  passwordFile = "/run/secrets/adguard-password";
};

in the Helm values adguard.url, adguard.username and adguard.existingSecret, or OBACHT_ADGUARD_URL, OBACHT_ADGUARD_USERNAME and OBACHT_ADGUARD_PASSWORD(_FILE).

Each DNS device then becomes an AdGuard Home client. Add it with its addresses on the home network, so plain DNS from it is recognised: {"name": "Switch", "addresses": ["192.168.1.50"]} (an IP or MAC address; you can also add them to the client in AdGuard Home later, and a fixed IP from your router helps). The answer holds adguard_client_id, which also works in AdGuard's own encrypted addresses: https://<adguard>/dns-query/<id> and <id>.<adguard> for DNS over TLS.

obacht keeps a block of user rules in AdGuard Home, marked ! obacht: ..., with what each of its clients may not resolve right now, and rewrites it on every change and at least once a minute. Your own rules outside that block stay as they are. The same rules apply as for the built-in filter, with one difference: during bedtime or a pause, an allowed site also lets its subdomains through even if they have their own block rule. Domains of website lists become rules for each DNS device, so very large lists make for a long rule block; for a list that should hold for every device on your network anyway (e.g. ads), add it in AdGuard Home itself.

obacht's clients are created with AdGuard's query log and statistics turned off. Don't turn them on: parents only see visited domains if the child opted in.

Network devices through UniFi (early)

With a UniFi console, obacht can take a child's console or TV offline at the router: at bedtime, during a pause, and when its category's time is used up on the child's other devices. It needs no DNS settings on the device and works for anything on the network.

Create a local user on the console for obacht (UniFi OS: Admins & Users, "Restrict to local access only", with a role limited to the Network application), then point obacht at it: on NixOS

services.obacht.unifi = {
  url = "https://192.168.1.1";
  username = "obacht";
  passwordFile = "/run/secrets/unifi-password";
  caFile = ./unifi-console.pem;   # or acceptInvalidCerts = true; for its self-signed certificate
};

in the Helm values unifi.url, unifi.username and unifi.existingSecret, or OBACHT_UNIFI_URL, OBACHT_UNIFI_USERNAME and OBACHT_UNIFI_PASSWORD(_FILE) (plus OBACHT_UNIFI_SITE, OBACHT_UNIFI_CA_FILE and OBACHT_UNIFI_ACCEPT_INVALID_CERTS). An API key (OBACHT_UNIFI_API_KEY(_FILE), unifi.apiKeyFile) can replace the user where the console accepts one for the Network application's classic API; check that it can block a client before relying on it. For a self-hosted Network application without UniFi OS, set OBACHT_UNIFI_OS=false (unifi.unifiOs = false).

Then link devices in the parent app (the child's devices, "Consoles and TVs (UniFi)"): "Find on the network" lists what UniFi knows, or type the MAC address. With POST /api/v1/children/{id}/network-devices ({"name": "Switch", "mac": "aa:bb:cc:dd:ee:ff", "category": "games"}):

  • With a category, the device is offline like an app of that category: when its time or the total time for today is used up (on the phone, the PC, Steam), during schedules for it or for all apps, and during a pause. The device's own time can't be measured.
  • Without a category, only schedules for all apps (bedtime) and pauses take it offline.

obacht blocks and unblocks the device as a UniFi client (block-sta/unblock-sta), on every change and at least once a minute; a blocked client can't join any of the site's networks. obacht only touches linked devices, and decides for them: a block set by hand in UniFi is lifted when the rules allow the device online. Unlinking a device (or removing the child) lets it back online. Devices that use a private (random) Wi-Fi address need it turned off for the home network, so they keep their MAC address.

This page is part of obacht's documentation, licensed under CC BY 4.0. Source on GitHub · CC BY 4.0 · Version: main (976f125)