# 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//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`) ```bash 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 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` | Allow a plaintext, credential-less broker. Only for a broker with no TLS listener | Generate a bcrypt hash without host Go: ```bash 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 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//config` | service → device | yes | desired state | | `restrict//status` | device → service | yes | presence (`online`) | | `restrict//telemetry` | device → service | no | usage totals for a day | - `` is `restrict/_` from `devices.topic`, which is stored when the device is created and never changes. `` 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) ```json {"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) ```json {"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) ```json {"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): ```bash 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. ```bash 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).