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>
This commit is contained in:
2026-10-13 16:27:46 +11:00
parent b58993d994
commit 4f233a8012
19 changed files with 1833 additions and 67 deletions

144
README.md
View File

@@ -5,8 +5,8 @@ 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 7 — Telemetry and usage**
(AGENTS.md §11), on top of Phases 1–6:
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
@@ -39,9 +39,14 @@ This repository currently contains **Phase 7 — Telemetry and usage**
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), the admin API and the Hub arrive in
later phases. Nothing here touches the router, Pi-hole, or DNS.
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`)
@@ -57,7 +62,7 @@ docker compose up -d --build
# 3. Prove it.
curl -s http://127.0.0.1:8791/health
# {"status":"ok","service":"control-service","version":"0.5.0-phase7",...}
# {"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
@@ -80,6 +85,8 @@ reads only the process environment.
| `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 |
@@ -94,7 +101,7 @@ reads only the process environment.
Generate a bcrypt hash without host Go:
```bash
docker run --rm --entrypoint /app/control webrestrict/control:0.5.0-phase7 \
docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 \
hash-password 'your-password'
```
@@ -145,10 +152,11 @@ Broker: the shared **Mosquitto on `.13`**. A second broker is not added
`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. 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.
`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)
@@ -199,6 +207,35 @@ docker exec webrestrict-control /app/control mqtt-clients
| `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:
```json
{"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).
@@ -257,6 +294,65 @@ 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:
```bash
# 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:
```bash
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.
@@ -268,6 +364,7 @@ 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
@@ -278,20 +375,22 @@ broker on `127.0.0.1:18883` and removes it afterwards.
## Layout
```
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup)
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 expiry
schedules.go weekly windows and the effective 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
migrations/001_init.sql, 002_telemetry_unique.sql, 003_usage_rollup.sql
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
@@ -309,16 +408,11 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
`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 7)
## Non-goals (Phase 8)
- No group/global list editing from the UI. The data model supports a NULL
`device_id` that applies to every device, and the effective state merges it,
but the editor manages per-device rows only.
- No HTTP telemetry ingest. Devices report over MQTT; an HTTP path arrives if a
client needs it.
- No usage charts or export beyond the week grid and the top lists shown.
- No admin API (Phase 8).
- No kids' clients (Phases 4–5). The topic and payload contract above is what
they implement. The clients apply the allowlist exception; the Control Service
only publishes it.
- 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).