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:
144
README.md
144
README.md
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user