Files
internet_use_control/README.md
Sam Rolfe 4f233a8012 Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API)
Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11:
the shared device keycode and the versioned admin HTTP API the separate Kotlin
admin app will call.

- store: migration 004 adds state.grant_until and a settings table. New
  overrides.go records every override in `overrides` and verifies the shared
  keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also
  clears an expired grant, and NextScheduleBoundary includes the grant end so a
  device re-locks on time.
- effective state: a grant unlocks a device inside a schedule window; a manual
  lock clears a grant and a grant clears a manual lock. The retained config
  payload gains grant_until (additive, v stays 1).
- web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by
  CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device
  keycode endpoint POST /api/v1/device/override with per-address throttling.
  A state change is published retained before the API responds.
- cmd/control: `keycode` subcommand and start-up seeding from
  CONTROL_DEVICE_KEYCODE_HASH.
- deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done:
  an API lock reaches a real broker subscriber in well under a second.
- Version 0.6.0-phase8 (compose, Makefile, docs).

Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler
1 ms, request to retained publish accepted).

Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:27:46 +11:00

20 KiB
Raw Blame History

Internet Use Control — Control Service

The Control Service is the brain and the single source of truth for the Internet Use Control project (SYSTEM_MAP.md). It holds schedules, blocklists, per-device state and usage, and serves the web dashboard. Only it publishes device config.

This repository currently contains Phase 8 — Overrides (AGENTS.md §11), on top of Phases 1–7:

  • Go service with its own image (Dockerfile) and compose file.
  • SQLite schema for the whole data model (§7), applied through versioned migrations.
  • Configuration from the process environment (environment.d); no secrets in the repo.
  • A separate session login for Sam.
  • GET /health.
  • HTMX + server-rendered dashboard styled with the Family Home Lab design system (tokens.css copied in).
  • Device registry: add and edit a device, assign it to a child, choose its platform. Persisted in devices.
  • Dashboard cards: name, child, platform, status, current state, time left.
  • Manual lock/unlock from the dashboard, persisted in state and written to the append-only audit log (FR-15).
  • MQTT: each device's desired state is published retained on restrict/<device>/config; device status and telemetry are subscribed and written to SQLite; presence comes from the broker's Last Will and Testament. The broker is transport only — SQLite stays the source of truth.
  • Weekly schedules: a per-device grid of blocking windows, Monday to Sunday, including windows that cross midnight. The Control Service computes the effective state — manual lock or active window — and republishes it at each window boundary, so a schedule turns on and off with nobody touching it.
  • Blocklist / allowlist: per-device app (Android package or Windows process) and site (hostname) entries. While a device is blocked, the device blocks every blocklist value except the allowlist values (Spotify at night). That allow decision is in the published config.
  • Usage: telemetry reports are stored per device, day, app and domain and rolled up into a daily summary. The dashboard shows a week at a time — the daily total plus the top apps and top sites — with dark mode and phone-sized touch targets.
  • Every edit is written to the append-only audit log (FR-15).
  • Overrides: a versioned admin HTTP API for the separate Kotlin admin app (lock, unlock, grant, query state) guarded by a bearer token, plus the shared device keycode (one code, every device) that a kid's client can redeem for a short exemption. Both are recorded in overrides and audit, and a state change is published retained before the API responds.

Enforcement itself (the kids' clients) and the Hub arrive in later phases. Nothing here touches the router, Pi-hole, or DNS.

Quick start (on .13)

cd /home/sam/paseo/projects/internet_use_control

# 1. Create the env file from the template and set real values.
cp environment.d/control-service.env.example environment.d/control-service.env
$EDITOR environment.d/control-service.env

# 2. Build and start. Own image, own container, port 8791.
docker compose up -d --build

# 3. Prove it.
curl -s http://127.0.0.1:8791/health
# {"status":"ok","service":"control-service","version":"0.6.0-phase8",...}

Open http://127.0.0.1:8791/ in a browser. It redirects to /login. Sign in with CONTROL_ADMIN_USERNAME and CONTROL_ADMIN_PASSWORD.

Configuration

All configuration is environment variables. Docker Compose loads them from environment.d/control-service.env (gitignored). The variables can also be exported from ~/.config/environment.d/10-secrets.conf instead; the service reads only the process environment.

Variable Default Purpose
CONTROL_ADDR :8791 Listen address
CONTROL_DB_PATH /data/control.db SQLite file
CONTROL_SESSION_SECRET — (required) Signs the session cookie, ≥ 16 chars
CONTROL_SESSION_TTL 12h Session lifetime
CONTROL_COOKIE_SECURE true Secure cookie flag; keep true behind HTTPS
CONTROL_ADMIN_USERNAME sam Dashboard login
CONTROL_ADMIN_PASSWORD — Plaintext password (option A)
CONTROL_ADMIN_PASSWORD_HASH — bcrypt hash (option B, preferred)
CONTROL_ADMIN_API_TOKEN — Bearer token for the versioned admin API (Phase 8); empty disables it with 503
CONTROL_DEVICE_KEYCODE_HASH — bcrypt hash of the shared device keycode, seeded into SQLite at start-up
CONTROL_APP_ENV production Label shown in the footer
CONTROL_LOG_LEVEL info debug/info/warn/error
CONTROL_TIMEZONE Australia/Sydney IANA zone weekly windows are evaluated in
CONTROL_MQTT_ENABLED true Turn the broker link on or off
CONTROL_MQTT_BROKER tcp://host.docker.internal:1883 Broker URL; tls://…:8883 once the TLS listener exists
CONTROL_MQTT_CLIENT_ID control-service MQTT client id
CONTROL_MQTT_USERNAME — Control Service broker account
CONTROL_MQTT_PASSWORD — its password
CONTROL_MQTT_CA_FILE /etc/webrestrict/tls/ca.crt PEM bundle that verifies the broker (TLS only)
CONTROL_MQTT_INSECURE false Allow a plaintext, credential-less broker. Only for a broker with no TLS listener

Generate a bcrypt hash without host Go:

docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 \
  hash-password 'your-password'

CONTROL_MQTT_USERNAME/CONTROL_MQTT_PASSWORD are required unless CONTROL_MQTT_INSECURE=true, and a non-TLS broker is refused unless CONTROL_MQTT_INSECURE=true (AGENTS.md §10 rule 6). The escape hatch exists for a broker that has no TLS listener; the running deployment on .13 does not use it. Since 2026-10-13 the shared Mosquitto also serves a TLS listener on 8883 with per-device credentials and a topic ACL (tls://host.docker.internal:8883), so CONTROL_MQTT_INSECURE=false. deploy/mosquitto/README.md is the design, the apply runbook and the rollback.

MQTT (Phase 3)

Broker: the shared Mosquitto on .13. A second broker is not added (§10 rule 6).

Topic Direction Retained Payload
restrict/<device>/config service → device yes desired state
restrict/<device>/status device → service yes presence (online)
restrict/<device>/telemetry device → service no usage totals for a day
  • <device> is restrict/<slug>_<id> from devices.topic, which is stored when the device is created and never changes. <id> is its MQTT username.
  • The service publishes each device's config retained on startup, after every lock, unlock, expiry, create and edit, and again every 15 minutes so a broker that lost its retained store is repaired from SQLite.
  • Devices use the 8883 TLS listener with one account each; the broker's ACL lets a device read only its own config and write only its own status and telemetry, and the Control Service is the only identity that may publish config.
  • A device registers the will {"online": false} on its status topic, so the broker reports the drop the moment the connection dies. That is presence (FR-6); no polling.
  • Reports are written to SQLite. Telemetry is a rolling daily total per (device, day, app, domain), so a repeated report replaces the row rather than adding to it (migration 002).

Config payload (v1)

{"v":1,"device_id":1,"name":"Harry's phone","platform":"android",
 "locked":true,"until":"2026-10-13T21:00:00Z",
 "allow":["com.spotify.music"],"block":["com.youtube.android","roblox.exe"],
 "reason":"schedule","generated_at":"2026-10-13T03:30:27Z"}

allow and block are always arrays, never null. locked and until are the effective state: a reason of schedule means a weekly window is active, manual means a lock was set from the dashboard or the admin API, and grant means a time-limited exemption is active, in which case grant_until (RFC3339) is set and locked is false. While locked is true the device blocks every block value except the allow values (FR-4), so the allowlist is the exception list. A client must ignore unknown fields, so this shape can grow.

Status payload (device → service)

{"online":true,"app":"com.spotify.music","at":"2026-10-13T03:30:27Z"}

The will the device registers on the same topic is {"online":false}. A report with no online field counts as online, because the device just talked to us.

Telemetry payload (device → service)

{"day":"2026-10-13",
 "apps":[{"name":"com.spotify.music","seconds":600}],
 "domains":[{"name":"youtube.com","seconds":120}]}

A flat single-row form ({"app":"…","seconds":600}) is also accepted. Seconds are totals for day, in YYYY-MM-DD; a missing or malformed day uses today.

List the MQTT identity of every device (tokens, no secrets):

docker exec webrestrict-control /app/control mqtt-clients

Endpoints (Phase 2–7)

Method Path Notes
GET /health JSON. status: ok, database check. No auth.
GET /login Session login form.
POST /login Verifies credentials, sets a signed session cookie.
POST /logout Clears the session.
GET / Dashboard: service status, device cards, recent activity.
GET /partials/status HTMX fragment, polled every 10 s.
GET /devices/new Add-device form.
POST /devices Creates a device.
GET /devices/{id}/edit Edit-device form.
POST /devices/{id} Updates name, child, platform.
POST /devices/{id}/lock Locks now; optional minutes (0 = until unlocked).
POST /devices/{id}/unlock Unlocks now.
GET /devices/{id}/schedule Weekly schedule editor (grid of windows).
POST /devices/{id}/schedule Replaces the device's windows; clear=1 clears them.
GET /devices/{id}/lists Blocklist / allowlist editor.
POST /devices/{id}/lists Adds one entry (`table=block
POST /devices/{id}/lists/{entryID}/delete Removes one entry.
GET /devices/{id}/usage Usage for one device: a week of daily totals, top apps and top sites. ?week=YYYY-MM-DD selects the week (any day in it; default today).

Admin API (Phase 8)

All admin endpoints are under /api/v1, use JSON, and require Authorization: Bearer $CONTROL_ADMIN_API_TOKEN. This token is separate from the dashboard session login. Responses use one envelope:

{"ok":true,"action":"lock","device":{"id":1,"name":"Harry's phone",
  "child":"Harry","platform":"android","topic":"restrict/harry_s_phone_1/config",
  "online":true,"last_seen":null,"locked":true,"until":"2026-10-13T21:00:00Z",
  "grant_until":null,"reason":"manual","updated_at":"2026-10-13T10:29:59Z"}}
Method Path Body Notes
GET /api/v1/admin/devices — Every device with its effective state.
GET /api/v1/admin/devices/{id} — One device. 404 if unknown.
POST /api/v1/admin/devices/{id}/lock {"minutes":30} or {"until":"2026-10-13T21:00:00+11:00"} or {} Lock now. {} means until unlocked; the two time fields are mutually exclusive.
POST /api/v1/admin/devices/{id}/unlock {} or empty Clear the manual lock and any grant.
POST /api/v1/admin/devices/{id}/grant {"minutes":30} or {"until":"…"} Grant time: unlocked until then, even during a schedule window, then the schedule re-applies. A time is required.
POST /api/v1/device/override {"device_id":1,"keycode":"…","minutes":15} The on-device keycode path. No bearer token. Wrong codes are throttled and audited.

minutes is capped (grants at 24 h, locks at 7 days). Errors are {"ok":false,"error":"…"} with 400 (bad body), 401 (bad token or keycode), 404 (unknown device), 429 (keycode throttled) or 503 (admin API or keycode not configured). A 200 means the state was stored and the retained config was accepted by the broker, so the device changes without a poll.

Every form POST needs the csrf field from the session cookie. State changes append to the audit table and republish the device's retained config. Devices are never hard-deleted (§10 rule 10).

Telemetry and usage (Phase 7)

Usage arrives on restrict/<device>/telemetry and is stored in telemetry: one row per (device, day, app, domain), where a repeat report for the same key replaces the earlier total (a rolling daily total, not a delta).

telemetry_daily is the daily rollup — one row per (device, day) — written together with the detail row and rebuildable from it with control rollup. Apps and domains are summed into separate columns on purpose: a report may carry both for the same session, so a single total would double-count the time. The rollup is derived data, never a second source of truth.

GET /devices/{id}/usage shows one Monday-to-Sunday week: the app total and the site total per day, then the top 10 apps and top 10 sites for the week. The screen uses the Family Home Lab tokens, follows the OS light/dark preference (there is a toggle in the nav bar), and works on a phone browser.

Retention:

  • The daily rollup is kept long; the detail table is small and kept too. Nothing is hard-deleted (AGENTS.md §10 rule 10).
  • The append-only audit log is size-capped at 20 000 rows by the service's maintenance loop, oldest first (§7). It is never edited in place.

Rebuild the rollup from the detail table at any time (idempotent, safe while the service is up; the service also does it at start-up and every 15 minutes):

# inside the container
docker exec webrestrict-control /app/control rollup
# or against a database file on the host
/app/control rollup --db /data/control.db

Schedules, blocklists and allowlists (Phase 6)

A schedule is a set of weekly blocking windows per device: a weekday (Monday = 0 … Sunday = 6), a local HH:MM start and end, and an end earlier than the start means the window crosses midnight (Monday 20:00–07:00 ends Tuesday 07:00). A window means restrictions are ON during that time.

The effective state is a manual lock, an active window, or neither. The Control Service computes it and publishes it retained. A manual lock with no end time (“until I unlock”) never lifts; otherwise the later of the manual end and the covering window's end is used. A background loop republishes at the next window boundary, so a window starts and ends on time without a poll.

A blocklist entry is blocked while a window is active or the device is locked. An allowlist entry (each entry is app or site) is the exception list: the device blocks every blocklist value except the allowlist. For example, with com.spotify.music allowlisted and com.youtube.android blocked, Spotify stays available and YouTube does not. Hostnames are lower-cased; app values keep their case.

Overrides (Phase 8)

An override is a deliberate change to a device's desired state. There are two paths (AGENTS.md §4 FR-10):

  1. The admin app, over the versioned HTTP API above. It is the remote lock/unlock/grant surface.
  2. The device keycode, entered on the kid's device. One code is shared by every device (§10 rule 3). A correct code grants a short exemption through POST /api/v1/device/override; the clients in Phases 4–5 will call it.

Semantics:

  • lock sets the manual lock (state.locked). With no end it never lifts; with an end it lifts at that time.
  • unlock clears the manual lock and any grant, returning the device to its schedule.
  • grant unlocks the device until its end, even inside a schedule window, and records grant_until. When the grant ends the weekly schedule applies again — the Control Service treats the grant end as a republish boundary, so the device re-locks on time.

Every override is written to the overrides table and to the append-only audit log (FR-15), with the actor: admin-api, device-keycode, or sam from the dashboard. A lock clears a grant and a grant clears a lock, so the two cannot contradict each other.

The shared device keycode

The code is never stored or logged in plaintext. It is a bcrypt hash in SQLite. CONTROL_DEVICE_KEYCODE_HASH seeds the first run; after that the stored code is authoritative, so a change made with control keycode survives a restart:

# Set a code you choose (read from stdin, so it never reaches the process list).
docker exec -i webrestrict-control /app/control keycode <<< '246810'

# Or generate a random six-digit code and print it once to put on each device.
docker exec webrestrict-control /app/control keycode --generate

The keycode endpoint is throttled: five wrong codes from one address freeze that address for five minutes, and every attempt is audited. It returns 503 when no keycode is configured.

Prove a lock reaches a device in under a second

The Phase 8 definition of done. make verify-phase8 issues a lock through the API and measures the time until a broker subscriber receives the retained restrict/<device>/config message:

make verify-phase8

It needs the control-service broker credentials (deploy/mosquitto/clients.env) and a device that exists. It never restarts or reconfigures anything shared.

Development

There is no host Go toolchain on .13; use the cached builder image.

make test          # go test ./... in golang:1.25-alpine
make vet           # go vet ./...
make build         # build the container image
make up            # docker compose up -d --build
make logs          # follow container logs
make mqtt-verify   # prove TLS + credentials + ACLs on a throwaway broker
make verify-phase8 # prove an API lock reaches a device in under a second

make test and make vet run the container as your own user and keep the module cache in .gocache/ (gitignored), so nothing in the repository ends up owned by root. make mqtt-verify never touches the shared Mosquitto: it stages a scratch broker on 127.0.0.1:18883 and removes it afterwards.

Layout

cmd/control/            service entry point (+ hash-password, mqtt-clients, rollup, keycode)
internal/config/        environment configuration
internal/store/         SQLite open, migrations, audit, devices + state
  devices.go            device registry, desired state, lock + grant expiry
  schedules.go          weekly windows and the effective state (incl. grants)
  reports.go            presence, telemetry, the daily rollup, top lists, audit cap
  lists.go              allow/block lists and device lookup by topic
  overrides.go          override history + the shared device keycode
  migrations/001_init.sql … 004_overrides_grant.sql
internal/auth/          signed-cookie session login + CSRF
internal/mqtt/          the broker link: retained config out, reports in
  payload.go            topic and payload contract for the kids' clients
  link.go               paho client, subscriptions, publish, TLS verification
internal/web/           HTTP handlers, templates, static assets
  devices.go            device registry + manual lock/unlock handlers
  admin_api.go          versioned admin API + the device keycode endpoint
  schedules.go          weekly schedule and block/allow editors
  usage.go              usage screen: week grid, top apps, top sites
  templates/            login, dashboard, device form, editors, usage, partials
  static/               tokens.css (Family Home Lab), app.css, theme.js, htmx, fonts
Dockerfile              own image (multi-stage, CGO-free)
docker-compose.yml      own container, port 8791
environment.d/          control-service.env.example (real file gitignored)
deploy/caddy/           Caddy vhost for webrestrict.lab.audasmedia.com.au
deploy/mosquitto/       TLS listener, credentials, topic ACL, verifier

Caddy

deploy/caddy/webrestrict.caddy holds the vhost for webrestrict.lab.audasmedia.com.au. The terminating Caddy runs on .35 (sam-Ubuntu1); see that file for the two proxy patterns and the apply commands.

Non-goals (Phase 8)

  • No grant button in the dashboard. Grant is on the admin API; the dashboard has lock and unlock. Adding the button is a later UI change.
  • No per-child or per-device keycode. One code, every device (§10 rule 3).
  • No HTTP telemetry ingest and no kids' clients (Phases 4–5). The topic and payload contract above is what they implement.
  • No Hub contact (Phase 9).