Sam Rolfe 14b304e0b0 Phase 3: MQTT transport — retained config, status/telemetry, LWT presence, TLS/ACL design
- internal/mqtt: publish each device's desired state retained on
  restrict/<device>/config; subscribe to restrict/+/status and
  restrict/+/telemetry and write reports to SQLite; publish on every state
  change, republish on connect and every 15 minutes from SQLite, so the broker
  is repaired from the source of truth.
- store: presence (devices.online and last_seen, audited only on transitions),
  telemetry upsert per (device, day, app, domain) via migration 002, allow and
  block lists read into the config payload, device lookup by topic.
- config: MQTT settings. TLS plus per-device credentials are required; the
  pre-TLS shared broker needs an explicit CONTROL_MQTT_INSECURE escape hatch.
- web: republish config after lock, unlock, expiry, create and edit; broker card
  on the status partial.
- deploy/mosquitto: TLS listener, topic ACL, credential provisioning and a
  verifier that proves the policy on a throwaway broker. Not applied to the
  shared Mosquitto on .13 — that change needs Sam's approval.
- cmd/control: mqtt-clients prints the MQTT identity of every device.
- paho.mqtt.golang v1.5.1, which pulls golang.org/x/net and with it the
  golang.org/x/crypto bump to v0.42.0.

Verified on .13: retained config delivered to a fresh subscriber; a client
killed without DISCONNECT drove the broker's retained will and the service
flipped the device offline; telemetry landed in SQLite; make mqtt-verify passed
13/13; the service connected over TLS with credentials and a CA.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 14:37:43 +11:00

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 3 — MQTT (AGENTS.md §11), on top of Phases 1 and 2:

  • 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.

Enforcement, schedules, blocklists, the admin API and the kids' clients 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.3.0-phase3",...}

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_APP_ENV production Label shown in the footer
CONTROL_LOG_LEVEL info debug/info/warn/error
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 Temporary. Allow a plaintext, credential-less broker

Generate a bcrypt hash without host Go:

docker run --rm --entrypoint /app/control webrestrict/control:0.3.0-phase3 \
  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 shared Mosquitto on .13 has no TLS listener yet, so the running deployment sets the escape hatch and logs a warning. deploy/mosquitto/README.md holds the TLS listener, the per-device credentials and the topic ACL, verified but not yet applied — see that file for the apply and rollback steps.

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.
  • 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":[],"block":[],
 "generated_at":"2026-10-13T03:30:27Z"}

allow and block are always arrays, never null. They are read from the allowlist and blocklist tables, which get their editors in Phase 6; until then they are empty. 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–3)

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.

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).

Phase 3 adds no HTTP endpoints.

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 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)
internal/config/        environment configuration
internal/store/         SQLite open, migrations, audit, devices + state
  devices.go            device registry, desired state, lock expiry
  reports.go            presence and telemetry written from broker reports
  lists.go              allow/block lists and device lookup by topic
  migrations/001_init.sql, 002_telemetry_unique.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
  templates/            login, dashboard, device form, HTMX partials
  static/               tokens.css (Family Home Lab), app.css, htmx, Inter 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 3)

  • No schedules, blocklists or allowlists (Phase 6). The config payload reads those tables, but nothing edits them yet.
  • No usage screens (Phase 7). Telemetry is stored, not yet shown.
  • No admin API (Phase 8).
  • No kids' clients (Phases 4–5). The topic and payload contract above is what they implement.
  • No Hub contact (Phase 9).
Description
Per-device screen-time limits for the kids.
Readme 1 MiB
Languages
Go 55.3%
Kotlin 14.7%
Python 13.8%
Shell 6.2%
HTML 4.7%
Other 5.3%