From 4f233a801297e6020a8409b5cd6f545ad9c5dc5d Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Tue, 13 Oct 2026 16:27:46 +1100 Subject: [PATCH] Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 12 +- README.md | 144 ++++- cmd/control/main.go | 86 +++ deploy/verify-phase8.sh | 143 +++++ docker-compose.yml | 4 +- environment.d/control-service.env.example | 18 +- internal/config/config.go | 11 + internal/mqtt/link.go | 1 + internal/mqtt/payload.go | 12 +- internal/store/devices.go | 45 +- .../store/migrations/004_overrides_grant.sql | 20 + internal/store/overrides.go | 233 ++++++++ internal/store/overrides_test.go | 213 ++++++++ internal/store/schedules.go | 49 +- internal/web/admin_api.go | 514 ++++++++++++++++++ internal/web/admin_api_test.go | 337 ++++++++++++ internal/web/devices.go | 24 + internal/web/server.go | 30 +- .../web/templates/partials/device-cards.html | 4 +- 19 files changed, 1833 insertions(+), 67 deletions(-) create mode 100755 deploy/verify-phase8.sh create mode 100644 internal/store/migrations/004_overrides_grant.sql create mode 100644 internal/store/overrides.go create mode 100644 internal/store/overrides_test.go create mode 100644 internal/web/admin_api.go create mode 100644 internal/web/admin_api_test.go diff --git a/Makefile b/Makefile index 9c0225a..bdb7d5b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= webrestrict/control:0.5.0-phase7 -BUILD_VERSION ?= 0.5.0-phase7 +IMAGE ?= webrestrict/control:0.6.0-phase8 +BUILD_VERSION ?= 0.6.0-phase8 # Bounded docker helper for the Go toolchain. The container runs as the current # user, so the repository and the module cache stay owned by you. @@ -13,7 +13,7 @@ MQTT_VERIFY_PORT ?= 18883 MQTT_IMAGE ?= eclipse-mosquitto:latest .PHONY: help test vet build up down restart logs ps hash-password deps fmt \ - mqtt-tls mqtt-verify mqtt-config + mqtt-tls mqtt-verify mqtt-config verify-phase8 help: @echo "make test run go test ./... in golang:1.25-alpine" @@ -28,6 +28,7 @@ help: @echo "make mqtt-tls generate the lab CA and broker certificate" @echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/" @echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker" + @echo "make verify-phase8 prove an API lock reaches a device in under a second" test: $(GO_RUN) go test ./... @@ -76,3 +77,8 @@ mqtt-config: mqtt-tls mqtt-verify: mqtt-config MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \ deploy/mosquitto/verify-isolation.sh + +# Phase 8 definition of done: lock through the admin API, measured until a real +# broker subscriber receives the retained config. Never touches the 1883 plane. +verify-phase8: + deploy/verify-phase8.sh diff --git a/README.md b/README.md index e9e88db..04e451a 100644 --- a/README.md +++ b/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 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//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). diff --git a/cmd/control/main.go b/cmd/control/main.go index d14fbf3..8136b6a 100644 --- a/cmd/control/main.go +++ b/cmd/control/main.go @@ -7,10 +7,12 @@ package main import ( "context" + "crypto/rand" "errors" "fmt" "io" "log/slog" + "math/big" "net/http" "os" "os/signal" @@ -58,6 +60,9 @@ func main() { case "mqtt-clients": mqttClients(os.Args[2:]) return + case "keycode": + keycode(os.Args[2:]) + return case "rollup": rollup(os.Args[2:]) return @@ -107,6 +112,16 @@ func run() error { log.Info("audit log capped", "removed", n, "max", auditMaxRows) } + // Phase 8: seed the shared device keycode from environment.d. The stored + // value is the runtime copy that `control keycode` can change; the + // environment is the deploy-time source. A malformed hash stops start-up + // rather than silently disabling the keycode path. + if seeded, err := st.SeedKeycodeHash(context.Background(), cfg.DeviceKeycodeHash); err != nil { + return err + } else if seeded { + log.Info("device keycode seeded from CONTROL_DEVICE_KEYCODE_HASH") + } + am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure, cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword) if err != nil { @@ -365,6 +380,77 @@ func env(key, fallback string) string { return fallback } +// keycode sets the shared device keycode (AGENTS.md §10 rule 3). It reads the +// code from a positional argument or stdin, or generates a random six-digit +// code with --generate, and stores only its bcrypt hash in SQLite. The code +// itself is printed once, and only when it was generated here, so it can be set +// on each device; it is never logged by the running service. +func keycode(args []string) { + dbPath := env("CONTROL_DB_PATH", "/data/control.db") + generate := false + code := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--db", "-db": + if i+1 < len(args) { + dbPath = args[i+1] + i++ + } + case "--generate", "-generate": + generate = true + default: + if code == "" { + code = args[i] + } + } + } + + if generate { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + code = fmt.Sprintf("%06d", n.Int64()) + } else if code == "" { + b, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + code = strings.TrimSpace(string(b)) + } + if len(code) < 4 || len(code) > 64 { + fmt.Fprintln(os.Stderr, "control: keycode must be 4 to 64 characters") + os.Exit(2) + } + + st, err := store.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + defer st.Close() + if err := st.Migrate(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + if err := st.SetKeycodeHash(context.Background(), string(hash), "admin"); err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + if generate { + fmt.Printf("device keycode set: %s\n", code) + return + } + fmt.Println("device keycode updated") +} + // hashPassword prints a bcrypt hash for the password given as an argument or // read from stdin. It never prints the password itself. func hashPassword(args []string) { diff --git a/deploy/verify-phase8.sh b/deploy/verify-phase8.sh new file mode 100755 index 0000000..12601ac --- /dev/null +++ b/deploy/verify-phase8.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# Phase 8 definition of done: prove that a lock issued through the admin API +# reaches a device in under one second. +# +# It issues POST /api/v1/admin/devices//lock and measures the time from the +# request to the moment a real broker subscriber receives the retained +# restrict//config message over the 8883 TLS plane. Nothing shared is +# restarted or reconfigured: it only needs the Control Service (and its broker +# link) already running, the admin API token, and a device account from +# deploy/mosquitto/clients.env. +# +# Usage: +# make verify-phase8 +# DEVICE_ID=2 deploy/verify-phase8.sh +# +# The device is unlocked again at the end, so the proof leaves no lock behind. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +SERVICE_URL="${CONTROL_BASE_URL:-http://127.0.0.1:8791}" +CA_FILE="${MQTT_CA_FILE:-deploy/mosquitto/tls/ca.crt}" +CLIENTS_FILE="${MQTT_CLIENTS_FILE:-deploy/mosquitto/clients.env}" +ENV_FILE="${CONTROL_ENV_FILE:-environment.d/control-service.env}" +MQTT_IMAGE="${MQTT_IMAGE:-eclipse-mosquitto:latest}" +DEVICE_ID="${DEVICE_ID:-}" + +die() { echo "verify-phase8: $*" >&2; exit 1; } + +# The token may be exported or live in the service env file. Never echo it. +TOKEN="${CONTROL_ADMIN_API_TOKEN:-}" +if [[ -z "$TOKEN" && -f "$ENV_FILE" ]]; then + TOKEN="$(grep -E '^CONTROL_ADMIN_API_TOKEN=' "$ENV_FILE" | head -1 | cut -d= -f2- || true)" +fi +[[ -n "$TOKEN" ]] || die "set CONTROL_ADMIN_API_TOKEN or add it to $ENV_FILE" + +[[ -f "$CA_FILE" ]] || die "missing CA file $CA_FILE" +[[ -f "$CLIENTS_FILE" ]] || die "missing device credentials $CLIENTS_FILE" + +api() { + # api [json-body] + local method="$1" path="$2" body="${3:-}" + if [[ -n "$body" ]]; then + curl -sS -X "$method" -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d "$body" "$SERVICE_URL$path" + else + curl -sS -X "$method" -H "Authorization: Bearer $TOKEN" "$SERVICE_URL$path" + fi +} + +# Pick a device. +if [[ -n "$DEVICE_ID" ]]; then + devices_json="$(api GET "/api/v1/admin/devices/$DEVICE_ID")" + echo "$devices_json" | jq -e '.ok == true' >/dev/null || die "device $DEVICE_ID not found: $devices_json" +else + devices_json="$(api GET /api/v1/admin/devices)" + echo "$devices_json" | jq -e '.ok == true and (.devices | length > 0)' >/dev/null \ + || die "no devices: $devices_json" + DEVICE_ID="$(echo "$devices_json" | jq -r '.devices[0].id')" +fi + +device_name="$(echo "$devices_json" | jq -r --argjson id "$DEVICE_ID" \ + '(.device // (.devices[] | select(.id==$id))).name')" +topic="$(echo "$devices_json" | jq -r --argjson id "$DEVICE_ID" \ + '(.device // (.devices[] | select(.id==$id))).topic')" +[[ "$topic" == restrict/*/config ]] || die "unexpected device topic: $topic" + +# The device token is the middle topic level; its password is in clients.env. +token="${topic#restrict/}"; token="${token%/config}" +password="$(awk -F'\t' -v t="$topic" '$3==t {print $2}' "$CLIENTS_FILE" | head -1)" +[[ -n "$password" ]] || die "no broker account for $topic in $CLIENTS_FILE" + +echo "device: $device_name (id $DEVICE_ID)" +echo "topic: $topic" +echo "service: $SERVICE_URL" + +WORK="$(mktemp -d)" +SUB_NAME="verify-phase8-sub-$$" +cleanup() { + timeout 20 docker rm -f "$SUB_NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" + # Leave the device as we found it: no lock from this proof. + timeout 20 curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/unlock" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# A real subscriber on the device's own TLS credentials. %U is a Unix timestamp +# with sub-second precision, so the receipt can be measured on the same clock. +: > "$WORK/sub.log" +timeout 60 docker run --rm -d --name "$SUB_NAME" --network host \ + -v "$PWD/$CA_FILE:/tls/ca.crt:ro" -v "$WORK:/out" \ + --entrypoint sh "$MQTT_IMAGE" -c \ + "exec mosquitto_sub -h 127.0.0.1 -p 8883 --cafile /tls/ca.crt \ + -u '$token' -P '$password' -t '$topic' -F '%U|%t|%p' > /out/sub.log 2>/out/sub.err" \ + >/dev/null + +# The retained config arrives at once, so its arrival proves the subscriber is +# connected and will see the lock. +for _ in $(seq 1 100); do + [[ -s "$WORK/sub.log" ]] && break + sleep 0.1 +done +[[ -s "$WORK/sub.log" ]] || die "subscriber never received the retained config: $(cat "$WORK/sub.err" 2>/dev/null)" +before_lines="$(wc -l < "$WORK/sub.log")" +echo "retained before lock:" +tail -1 "$WORK/sub.log" | cut -c1-160 + +# Issue the lock and time it. +t0="$(date +%s.%N)" +http="$(curl -sS -o "$WORK/resp.json" -w '%{http_code} %{time_total}' \ + -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"minutes":30}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/lock")" +t_after_curl="$(date +%s.%N)" + +# Wait for the new retained message to reach the subscriber. +for _ in $(seq 1 100); do + [[ "$(wc -l < "$WORK/sub.log")" -gt "$before_lines" ]] && break + sleep 0.01 +done +t_after_recv="$(date +%s.%N)" +received="$(tail -1 "$WORK/sub.log")" +t_recv="$(echo "$received" | cut -d'|' -f1)" + +lat="$(awk -v a="$t0" -v b="$t_recv" 'BEGIN { printf "%.0f", (b-a)*1000 }')" +wall="$(awk -v a="$t0" -v b="$t_after_recv" 'BEGIN { printf "%.0f", (b-a)*1000 }')" + +echo +echo "request: POST $SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/lock {\"minutes\":30}" +echo "response: HTTP $(echo "$http" | cut -d' ' -f1) after $(echo "$http" | cut -d' ' -f2) s" +echo " $(cat "$WORK/resp.json")" +echo +echo "delivered: $received" +echo +echo "LATENCY (request -> subscriber receipt): ${lat} ms" +echo "LATENCY (upper bound, poll included): ${wall} ms" + +if [[ "$lat" -ge 1000 ]]; then + die "lock took ${lat} ms, which is not under one second" +fi +echo "PASS: lock reached the device in ${lat} ms" diff --git a/docker-compose.yml b/docker-compose.yml index b6c1a9f..d7cd123 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: build: context: . args: - VERSION: "0.5.0-phase7" - image: webrestrict/control:0.5.0-phase7 + VERSION: "0.6.0-phase8" + image: webrestrict/control:0.6.0-phase8 container_name: webrestrict-control restart: unless-stopped env_file: diff --git a/environment.d/control-service.env.example b/environment.d/control-service.env.example index b919db3..a3b8291 100644 --- a/environment.d/control-service.env.example +++ b/environment.d/control-service.env.example @@ -38,9 +38,25 @@ CONTROL_ADMIN_USERNAME=sam CONTROL_ADMIN_PASSWORD=change-me # Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty. -# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.5.0-phase7 hash-password 'your-password' +# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 hash-password 'your-password' # CONTROL_ADMIN_PASSWORD_HASH=$2a$10$... +# --- Admin API (Phase 8) --------------------------------------------------- +# The versioned HTTP API the separate Kotlin admin app (kotlin_phone_app_admin) +# calls directly. Its bearer token is SEPARATE from the dashboard session login. +# Generate a long random value: openssl rand -hex 32 +# When empty the API answers 503 "admin API is not configured" and nothing else +# changes, so an older deployment keeps working. +CONTROL_ADMIN_API_TOKEN=change-me-to-a-long-random-token + +# The shared device keycode (one code, every device — AGENTS.md §10 rule 3). +# Give a bcrypt hash, never the code itself. Generate one with: +# docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 \ +# hash-password 'the-code' +# The hash is seeded into SQLite at start-up and can be changed later with +# `docker exec webrestrict-control /app/control keycode`. +# CONTROL_DEVICE_KEYCODE_HASH=$2a$10$... + # --- MQTT (Phase 3) -------------------------------------------------------- # The broker is transport only. SQLite stays the source of truth, and only the # Control Service publishes config (AGENTS.md §10 rules 5-6). diff --git a/internal/config/config.go b/internal/config/config.go index 0310789..800129f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,15 @@ type Config struct { AdminPassword string // AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set. AdminPasswordHash string + // AdminAPIToken authenticates the versioned admin API (Phase 8). It is + // separate from the session login and comes from environment.d. When it is + // empty the admin API answers 503 and nothing else changes. + AdminAPIToken string + // DeviceKeycodeHash is a bcrypt hash of the shared device keycode (Phase 8). + // It is optional: the code is seeded into SQLite at start-up and can be + // changed at runtime with `control keycode`. It is never stored in plaintext + // and never logged. + DeviceKeycodeHash string // CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS. CookieSecure bool // SessionTTL is how long a session cookie stays valid. @@ -74,6 +83,8 @@ func Load() (*Config, error) { AdminUsername: env("CONTROL_ADMIN_USERNAME", "sam"), AdminPassword: os.Getenv("CONTROL_ADMIN_PASSWORD"), AdminPasswordHash: os.Getenv("CONTROL_ADMIN_PASSWORD_HASH"), + AdminAPIToken: os.Getenv("CONTROL_ADMIN_API_TOKEN"), + DeviceKeycodeHash: os.Getenv("CONTROL_DEVICE_KEYCODE_HASH"), CookieSecure: envBool("CONTROL_COOKIE_SECURE", true), AppEnv: env("CONTROL_APP_ENV", "production"), MQTTEnabled: envBool("CONTROL_MQTT_ENABLED", true), diff --git a/internal/mqtt/link.go b/internal/mqtt/link.go index 82b1e30..c13f50d 100644 --- a/internal/mqtt/link.go +++ b/internal/mqtt/link.go @@ -356,6 +356,7 @@ func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error Allow: eff.Allow, Block: eff.Block, Reason: eff.Reason, + GrantUntil: eff.GrantUntil, GeneratedAt: time.Now().UTC(), }.Marshal() } diff --git a/internal/mqtt/payload.go b/internal/mqtt/payload.go index b4e862a..11deb5e 100644 --- a/internal/mqtt/payload.go +++ b/internal/mqtt/payload.go @@ -86,9 +86,15 @@ type Config struct { Allow []string `json:"allow"` Block []string `json:"block"` // Reason is why the device is locked: "schedule" for an active weekly - // window, "manual" for a lock from the dashboard. It is omitted when the - // device is not locked. It is additive (Phase 6), so v stays 1. - Reason string `json:"reason,omitempty"` + // window, "manual" for a lock from the dashboard or the admin API. It is + // omitted when the device is not locked. It is additive (Phase 6), so v + // stays 1. + Reason string `json:"reason,omitempty"` + // GrantUntil is set when a time-limited exemption is active (Phase 8): the + // device is unlocked and must not re-lock before this instant. It is omitted + // when there is no grant. Additive, so v stays 1. + GrantUntil *time.Time `json:"grant_until,omitempty"` + GeneratedAt time.Time `json:"generated_at"` } diff --git a/internal/store/devices.go b/internal/store/devices.go index e9f4e9a..2d7ef49 100644 --- a/internal/store/devices.go +++ b/internal/store/devices.go @@ -41,9 +41,13 @@ type Device struct { UpdatedAt time.Time // Desired state, from the state table. - Locked bool - Until *time.Time - StateAt time.Time + Locked bool + Until *time.Time + // GrantUntil is a time-limited exemption (Phase 8): while it is in the + // future the device is unlocked even during a schedule window. It is nil + // when no grant is active or one has been cleared. + GrantUntil *time.Time + StateAt time.Time } // AuditEntry is one row of the append-only audit log (FR-15). @@ -61,7 +65,7 @@ type AuditEntry struct { const selectDevice = ` SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen, d.created_at, d.updated_at, - COALESCE(s.locked, 0), s.until, s.updated_at + COALESCE(s.locked, 0), s.until, s.grant_until, s.updated_at FROM devices d LEFT JOIN state s ON s.device_id = d.id` @@ -69,21 +73,22 @@ func scanDevice(row interface { Scan(dest ...any) error }) (*Device, error) { var ( - dev Device - online int - lastSeen, until, stateAt sql.NullString - createdAt, updatedAt sql.NullString + dev Device + online int + lastSeen, until, grantUntil sql.NullString + createdAt, updatedAt, stateAt sql.NullString ) if err := row.Scan( &dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online, &lastSeen, &createdAt, &updatedAt, - &dev.Locked, &until, &stateAt, + &dev.Locked, &until, &grantUntil, &stateAt, ); err != nil { return nil, err } dev.Online = online != 0 dev.LastSeen = parseTimePtr(lastSeen) dev.Until = parseTimePtr(until) + dev.GrantUntil = parseTimePtr(grantUntil) if t := parseTime(createdAt.String); !t.IsZero() { dev.CreatedAt = t } @@ -268,11 +273,12 @@ func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until } if _, err := tx.ExecContext(ctx, ` - INSERT INTO state (device_id, locked, until, updated_at) - VALUES (?, ?, ?, datetime('now')) + INSERT INTO state (device_id, locked, until, grant_until, updated_at) + VALUES (?, ?, ?, NULL, datetime('now')) ON CONFLICT(device_id) DO UPDATE SET locked = excluded.locked, until = excluded.until, + grant_until = NULL, updated_at = datetime('now')`, id, boolToInt(locked), untilArg); err != nil { return nil, fmt.Errorf("set state: %w", err) @@ -280,15 +286,18 @@ func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil { return nil, err } + if err := insertOverride(ctx, tx, id, actor, action, until); err != nil { + return nil, err + } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit state: %w", err) } return s.GetDevice(ctx, id) } -// ExpireLocks unlocks every device whose timed lock has passed and appends one -// audit entry per device. It returns how many locks it cleared. It is safe to -// call often. +// ExpireLocks unlocks every device whose timed lock has passed and clears every +// expired grant, appending one audit entry per device whose lock lifted. It +// returns how many locks it cleared. It is safe to call often. func (s *Store) ExpireLocks(ctx context.Context) (int, error) { now := time.Now().UTC().Format(time.RFC3339) rows, err := s.db.QueryContext(ctx, @@ -316,6 +325,14 @@ func (s *Store) ExpireLocks(ctx context.Context) (int, error) { return 0, err } } + // A grant is silent: it changes no audit row and needs no device callback, + // because EffectiveState already ignores a past grant. Clearing it keeps the + // stored state tidy and the API response honest. + if _, err := s.db.ExecContext(ctx, ` + UPDATE state SET grant_until = NULL, updated_at = datetime('now') + WHERE grant_until IS NOT NULL AND grant_until <= ?`, now); err != nil { + return len(ids), fmt.Errorf("expire grants: %w", err) + } return len(ids), nil } diff --git a/internal/store/migrations/004_overrides_grant.sql b/internal/store/migrations/004_overrides_grant.sql new file mode 100644 index 0000000..eb117fb --- /dev/null +++ b/internal/store/migrations/004_overrides_grant.sql @@ -0,0 +1,20 @@ +-- Migration 004 — Phase 8. Overrides (AGENTS.md §7, §11 Phase 8). +-- +-- The shared device keycode lives in `settings` as a bcrypt hash under the key +-- 'device_keycode_hash'. It is never stored or logged in plaintext. The +-- key/value table also gives later phases a place for small settings without a +-- new migration. +-- +-- grant_until is a time-limited exemption: while it is in the future the device +-- is unlocked even when a weekly window would otherwise block it. It is a +-- separate column from the manual lock's `until` so "grant 30 minutes" and +-- "lock until I unlock" cannot fight over the same value. A manual lock clears +-- a grant; a grant clears a manual lock. + +ALTER TABLE state ADD COLUMN grant_until TEXT; + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/internal/store/overrides.go b/internal/store/overrides.go new file mode 100644 index 0000000..b8b70d0 --- /dev/null +++ b/internal/store/overrides.go @@ -0,0 +1,233 @@ +// Overrides and the shared device keycode (AGENTS.md §4 FR-10, §7 overrides, +// §10 rule 3, §11 Phase 8). +// +// An override is a deliberate change to a device's desired state: a lock, an +// unlock, or a grant of time. Every override is written to the `overrides` +// table and, through the caller, to the append-only `audit` log (FR-15). The +// state table holds only the current value; `overrides` is the history. +// +// The device keycode is a single code shared by every device (§10 rule 3). It +// is stored only as a bcrypt hash in `settings`, is seeded from the environment +// at start-up, and can be changed with `control keycode` (or, later, from the +// dashboard). +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// keycodeSettingKey is the settings row that holds the shared device keycode. +const keycodeSettingKey = "device_keycode_hash" + +// ErrNoKeycode is returned when no device keycode has been set. +var ErrNoKeycode = errors.New("device keycode is not set") + +// Override is one row of the override history. +type Override struct { + ID int64 + DeviceID int64 + Actor string + Action string + Until *time.Time + CreatedAt time.Time +} + +// insertOverride appends one override row using the given executor, so it can +// be written atomically with the state change it records. +func insertOverride(ctx context.Context, ex interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +}, deviceID int64, actor, action string, until *time.Time) error { + var untilArg any + if until != nil { + untilArg = until.UTC().Format(time.RFC3339) + } + if _, err := ex.ExecContext(ctx, + `INSERT INTO overrides (device_id, actor, action, until) VALUES (?, ?, ?, ?)`, + deviceID, actor, action, untilArg); err != nil { + return fmt.Errorf("override insert: %w", err) + } + return nil +} + +// ListOverrides returns a device's override history, newest first. +func (s *Store) ListOverrides(ctx context.Context, deviceID int64, limit int) ([]Override, error) { + if limit <= 0 || limit > 500 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id, device_id, actor, action, until, created_at + FROM overrides WHERE device_id = ? + ORDER BY id DESC LIMIT ?`, deviceID, limit) + if err != nil { + return nil, fmt.Errorf("list overrides: %w", err) + } + defer rows.Close() + + out := make([]Override, 0, limit) + for rows.Next() { + var ( + o Override + until sql.NullString + created sql.NullString + ) + if err := rows.Scan(&o.ID, &o.DeviceID, &o.Actor, &o.Action, &until, &created); err != nil { + return nil, fmt.Errorf("scan override: %w", err) + } + o.Until = parseTimePtr(until) + o.CreatedAt = parseTime(created.String) + out = append(out, o) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list overrides: %w", err) + } + return out, nil +} + +// SetDeviceGrant gives a device a time-limited exemption: it is unlocked until +// `until`, even if a weekly window is active. It clears any manual lock, writes +// one override row and one audit row in the same transaction, and returns the +// fresh device. `until` must be in the future. +func (s *Store) SetDeviceGrant(ctx context.Context, id int64, until time.Time, actor, action string) (*Device, error) { + if until.IsZero() { + return nil, errors.New("grant needs an end time") + } + until = until.UTC() + if !until.After(time.Now()) { + return nil, errors.New("grant end time must be in the future") + } + if _, err := s.GetDevice(ctx, id); err != nil { + return nil, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("set grant: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO state (device_id, locked, until, grant_until, updated_at) + VALUES (?, 0, NULL, ?, datetime('now')) + ON CONFLICT(device_id) DO UPDATE SET + locked = 0, + until = NULL, + grant_until = excluded.grant_until, + updated_at = datetime('now')`, + id, until.Format(time.RFC3339)); err != nil { + return nil, fmt.Errorf("set grant: %w", err) + } + if err := insertAudit(ctx, tx, actor, action, &id, + "granted time until "+until.Format(time.RFC3339)); err != nil { + return nil, err + } + if err := insertOverride(ctx, tx, id, actor, action, &until); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit grant: %w", err) + } + return s.GetDevice(ctx, id) +} + +// ---------- device keycode ---------- + +// SetKeycodeHash stores the bcrypt hash of the shared device keycode and +// appends one audit row. The hash is validated first, so a corrupt value never +// replaces a working code. +func (s *Store) SetKeycodeHash(ctx context.Context, hash, actor string) error { + if _, err := bcrypt.Cost([]byte(hash)); err != nil { + return fmt.Errorf("device keycode hash is not a valid bcrypt hash: %w", err) + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("set keycode: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + keycodeSettingKey, hash); err != nil { + return fmt.Errorf("set keycode: %w", err) + } + if err := insertAudit(ctx, tx, actor, "keycode.updated", nil, "shared device keycode changed"); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit keycode: %w", err) + } + return nil +} + +// KeycodeHash returns the stored bcrypt hash, or ErrNoKeycode. +func (s *Store) KeycodeHash(ctx context.Context) (string, error) { + var value string + err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, keycodeSettingKey).Scan(&value) + if errors.Is(err, sql.ErrNoRows) || value == "" { + return "", ErrNoKeycode + } + if err != nil { + return "", fmt.Errorf("keycode hash: %w", err) + } + return value, nil +} + +// VerifyKeycode reports whether code matches the stored shared keycode. A +// missing or invalid stored hash never matches, so a broken settings row cannot +// unlock a device by accident. +func (s *Store) VerifyKeycode(ctx context.Context, code string) bool { + code = strings.TrimSpace(code) + if code == "" { + return false + } + hash, err := s.KeycodeHash(ctx) + if err != nil { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) == nil +} + +// SeedKeycodeHash stores hash only when no code is stored yet. The Control +// Service calls it at start-up so CONTROL_DEVICE_KEYCODE_HASH in environment.d +// is the first-run source of the code. Once a code exists, the settings row is +// authoritative: a restart never overwrites a code changed with `control +// keycode`, so a runtime change is durable. It returns whether it stored a code. +func (s *Store) SeedKeycodeHash(ctx context.Context, hash string) (bool, error) { + hash = strings.TrimSpace(hash) + if hash == "" { + return false, nil + } + if _, err := bcrypt.Cost([]byte(hash)); err != nil { + return false, fmt.Errorf("CONTROL_DEVICE_KEYCODE_HASH is not a valid bcrypt hash: %w", err) + } + if _, err := s.KeycodeHash(ctx); err == nil { + return false, nil // already set; never overwrite at start-up + } else if !errors.Is(err, ErrNoKeycode) { + return false, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, fmt.Errorf("seed keycode: %w", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, ` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + keycodeSettingKey, hash); err != nil { + return false, fmt.Errorf("seed keycode: %w", err) + } + if err := insertAudit(ctx, tx, "system", "keycode.updated", nil, "seeded from CONTROL_DEVICE_KEYCODE_HASH"); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("commit seeded keycode: %w", err) + } + return true, nil +} diff --git a/internal/store/overrides_test.go b/internal/store/overrides_test.go new file mode 100644 index 0000000..0327a6b --- /dev/null +++ b/internal/store/overrides_test.go @@ -0,0 +1,213 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// TestGrantOverridesScheduleAndReLocks is the Phase 8 grant contract: a grant +// unlocks a device inside an active schedule window, and the window re-applies +// the moment the grant ends. +func TestGrantOverridesScheduleAndReLocks(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + + now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) // Tuesday + if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{ + {Weekday: weekdayMon0(now), Start: "19:00", End: "21:00"}, + }, "sam"); err != nil { + t.Fatalf("ReplaceSchedules: %v", err) + } + + // Before the grant the window blocks the device. + eff, err := st.EffectiveState(ctx, *dev, now) + if err != nil { + t.Fatalf("EffectiveState: %v", err) + } + if !eff.Locked || eff.Reason != "schedule" { + t.Fatalf("before grant: %+v, want locked by schedule", eff) + } + + // Grant time: the device is unlocked even though the window is active. + grantUntil := now.Add(15 * time.Minute) + granted, err := st.SetDeviceGrant(ctx, dev.ID, grantUntil, "admin-api", "override.granted") + if err != nil { + t.Fatalf("SetDeviceGrant: %v", err) + } + eff, err = st.EffectiveState(ctx, *granted, now) + if err != nil { + t.Fatalf("EffectiveState: %v", err) + } + if eff.Locked || eff.Reason != "grant" { + t.Fatalf("during grant: %+v, want unlocked by grant", eff) + } + if eff.GrantUntil == nil || !eff.GrantUntil.Equal(grantUntil) { + t.Errorf("grant until = %v, want %v", eff.GrantUntil, grantUntil) + } + + // The grant end is a republish boundary, so the device re-locks on time. + next, err := st.NextScheduleBoundary(ctx, now) + if err != nil { + t.Fatalf("NextScheduleBoundary: %v", err) + } + if next == nil || !next.Equal(grantUntil) { + t.Errorf("next boundary = %v, want the grant end %v", next, grantUntil) + } + + // After the grant ends the schedule applies again. + eff, err = st.EffectiveState(ctx, *granted, grantUntil.Add(time.Minute)) + if err != nil { + t.Fatalf("EffectiveState: %v", err) + } + if !eff.Locked || eff.Reason != "schedule" { + t.Fatalf("after grant: %+v, want locked by schedule again", eff) + } + + // The override history holds the grant. + overrides, err := st.ListOverrides(ctx, dev.ID, 10) + if err != nil { + t.Fatalf("ListOverrides: %v", err) + } + if len(overrides) != 1 || overrides[0].Action != "override.granted" || overrides[0].Actor != "admin-api" { + t.Fatalf("overrides = %+v, want one grant", overrides) + } + if overrides[0].Until == nil || !overrides[0].Until.Equal(grantUntil) { + t.Errorf("override until = %v, want %v", overrides[0].Until, grantUntil) + } +} + +func TestLockClearsGrant(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Finn PC", "Finn", "windows") + + until := time.Now().Add(time.Hour).UTC() + dev, err := st.SetDeviceGrant(ctx, dev.ID, until, "admin-api", "override.granted") + if err != nil { + t.Fatalf("SetDeviceGrant: %v", err) + } + if dev.GrantUntil == nil { + t.Fatal("grant was not stored") + } + + dev, err = st.SetDeviceLock(ctx, dev.ID, true, nil, "admin-api", "state.locked") + if err != nil { + t.Fatalf("SetDeviceLock: %v", err) + } + if !dev.Locked || dev.GrantUntil != nil { + t.Fatalf("after lock: locked=%v grant=%v, want locked with no grant", dev.Locked, dev.GrantUntil) + } + + // Both actions are in the override history. + overrides, err := st.ListOverrides(ctx, dev.ID, 10) + if err != nil { + t.Fatalf("ListOverrides: %v", err) + } + if len(overrides) != 2 { + t.Fatalf("overrides = %d, want 2", len(overrides)) + } +} + +func TestExpireClearsGrant(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + + // Set an already-expired grant directly: SetDeviceGrant refuses the past, + // which is the right guard for callers, but the cleanup path must still work. + past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + if _, err := st.DB().ExecContext(ctx, + `UPDATE state SET grant_until = ? WHERE device_id = ?`, past, dev.ID); err != nil { + t.Fatalf("seed past grant: %v", err) + } + + if _, err := st.ExpireLocks(ctx); err != nil { + t.Fatalf("ExpireLocks: %v", err) + } + got, err := st.GetDevice(ctx, dev.ID) + if err != nil { + t.Fatalf("GetDevice: %v", err) + } + if got.GrantUntil != nil { + t.Errorf("grant_until = %v after expiry, want nil", got.GrantUntil) + } +} + +func TestSetDeviceGrantRejectsPastAndMissing(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + + if _, err := st.SetDeviceGrant(ctx, dev.ID, time.Now().Add(-time.Minute), "admin-api", "override.granted"); err == nil { + t.Error("expected a past grant to be refused") + } + if _, err := st.SetDeviceGrant(ctx, 999, time.Now().Add(time.Hour), "admin-api", "override.granted"); !errors.Is(err, ErrNotFound) { + t.Errorf("missing device: err = %v, want ErrNotFound", err) + } +} + +func TestDeviceKeycode(t *testing.T) { + ctx := context.Background() + st := testStore(t) + + if _, err := st.KeycodeHash(ctx); !errors.Is(err, ErrNoKeycode) { + t.Fatalf("KeycodeHash with none: err = %v, want ErrNoKeycode", err) + } + if st.VerifyKeycode(ctx, "1234") { + t.Error("an unset keycode must never verify") + } + + hash, err := bcrypt.GenerateFromPassword([]byte("2468"), bcrypt.MinCost) + if err != nil { + t.Fatalf("bcrypt: %v", err) + } + if err := st.SetKeycodeHash(ctx, string(hash), "admin"); err != nil { + t.Fatalf("SetKeycodeHash: %v", err) + } + if !st.VerifyKeycode(ctx, "2468") { + t.Error("correct keycode did not verify") + } + if st.VerifyKeycode(ctx, "8642") { + t.Error("wrong keycode verified") + } + if st.VerifyKeycode(ctx, "") { + t.Error("empty keycode verified") + } + + // Invalid hashes are refused and do not replace the working one. + if err := st.SetKeycodeHash(ctx, "not-a-hash", "admin"); err == nil { + t.Error("expected an invalid hash to be refused") + } + if !st.VerifyKeycode(ctx, "2468") { + t.Error("the working keycode was replaced by an invalid one") + } + + // Seeding never overwrites an existing code: a runtime change made with + // `control keycode` survives a restart even if the environment still holds + // the old value. + other, _ := bcrypt.GenerateFromPassword([]byte("1357"), bcrypt.MinCost) + if seeded, err := st.SeedKeycodeHash(ctx, string(other)); err != nil || seeded { + t.Fatalf("SeedKeycodeHash over existing = (%v, %v), want (false, nil)", seeded, err) + } + if !st.VerifyKeycode(ctx, "2468") || st.VerifyKeycode(ctx, "1357") { + t.Error("seeding must not overwrite a configured keycode") + } + + // A fresh store takes the environment value once. + fresh := testStore(t) + seeded, err := fresh.SeedKeycodeHash(ctx, string(other)) + if err != nil || !seeded { + t.Fatalf("SeedKeycodeHash on empty store = (%v, %v), want (true, nil)", seeded, err) + } + if !fresh.VerifyKeycode(ctx, "1357") { + t.Error("seeded keycode did not verify") + } + if seeded, err := fresh.SeedKeycodeHash(ctx, string(other)); err != nil || seeded { + t.Errorf("SeedKeycodeHash repeat = (%v, %v), want (false, nil)", seeded, err) + } +} diff --git a/internal/store/schedules.go b/internal/store/schedules.go index cbbf1e0..e717bce 100644 --- a/internal/store/schedules.go +++ b/internal/store/schedules.go @@ -53,9 +53,13 @@ type EffectiveState struct { DeviceID int64 Locked bool Until *time.Time - // Reason is "manual" (a lock from the dashboard), "schedule" (an active - // weekly window) or "" (not blocked). + // Reason is "manual" (a lock from the dashboard or the admin API), + // "schedule" (an active weekly window), "grant" (a time-limited exemption) + // or "" (not blocked). Reason string + // GrantUntil is set when a grant is active: the device is unlocked, and a + // schedule window must not block it before this instant (Phase 8). + GrantUntil *time.Time // Allow and Block are the configured list values, always non-nil. Allow []string Block []string @@ -279,8 +283,13 @@ func atClock(now time.Time, minutes int) time.Time { } // EffectiveState computes the desired state for one device at now: a manual -// lock, an active schedule window, or neither. While blocked, the configured -// allowlist is honoured: the device blocks Block except Allow (FR-4). +// lock, an active schedule window, a time-limited grant, or none of them. +// While blocked, the configured allowlist is honoured: the device blocks Block +// except Allow (FR-4). +// +// A grant wins over a schedule window but not over a manual lock; the write +// paths keep the two mutually exclusive (a lock clears a grant, a grant clears +// a lock), so the order below only matters for hand-edited data. // // A manual lock with no end time ("until I unlock") never lifts, so it wins over // a schedule end. Otherwise Until is the latest of the manual end and the @@ -314,6 +323,10 @@ func (s *Store) EffectiveState(ctx context.Context, dev Device, now time.Time) ( if dev.Until != nil && schedUntil != nil && schedUntil.After(*dev.Until) { eff.Until = schedUntil } + case dev.GrantUntil != nil && dev.GrantUntil.After(now): + eff.Locked = false + eff.Reason = "grant" + eff.GrantUntil = dev.GrantUntil case scheduled: eff.Locked = true eff.Reason = "schedule" @@ -346,26 +359,30 @@ func (s *Store) NextScheduleBoundary(ctx context.Context, now time.Time) (*time. } best := NextWindowBoundary(schedules, now) - // A manual lock that expires is a boundary too, so the loop lifts it from - // the device promptly instead of waiting for the next interval. + // A manual lock that expires and a grant that ends are boundaries too, so + // the loop lifts or re-locks from the device promptly instead of waiting for + // the next interval. A grant end matters because the device may be unlocked + // only because of the grant: when it passes, an active window must re-lock. lockRows, err := s.db.QueryContext(ctx, - `SELECT until FROM state WHERE locked = 1 AND until IS NOT NULL`) + `SELECT until, grant_until FROM state WHERE (locked = 1 AND until IS NOT NULL) OR grant_until IS NOT NULL`) if err != nil { return nil, fmt.Errorf("next lock boundary: %w", err) } defer lockRows.Close() for lockRows.Next() { - var until sql.NullString - if err := lockRows.Scan(&until); err != nil { + var until, grantUntil sql.NullString + if err := lockRows.Scan(&until, &grantUntil); err != nil { return nil, fmt.Errorf("scan lock until: %w", err) } - t := parseTime(until.String) - if t.IsZero() || !t.After(now) { - continue - } - if best == nil || t.Before(*best) { - c := t - best = &c + for _, ns := range []sql.NullString{until, grantUntil} { + t := parseTime(ns.String) + if t.IsZero() || !t.After(now) { + continue + } + if best == nil || t.Before(*best) { + c := t + best = &c + } } } if err := lockRows.Err(); err != nil { diff --git a/internal/web/admin_api.go b/internal/web/admin_api.go new file mode 100644 index 0000000..c8a8cd2 --- /dev/null +++ b/internal/web/admin_api.go @@ -0,0 +1,514 @@ +// The versioned admin API (AGENTS.md §4 FR-11, §11 Phase 8). +// +// Another project, the Kotlin admin app (kotlin_phone_app_admin), consumes this +// surface. It is deliberately small and stable: bearer-token auth, JSON in and +// out, and one clear shape for lock, unlock, grant and a state query. Control +// never goes through the Hub (AGENTS.md §8): the app calls this service +// directly. +// +// Every state change is persisted to SQLite, recorded in `overrides` and the +// append-only `audit` log (FR-15), and only then published retained on the +// device's MQTT config topic. The publish is synchronous, so a 200 response +// means the broker has accepted the retained message and every subscriber +// receives it at once (FR-5, FR-11). +package web + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "net" + "net/http" + "strings" + "time" + + "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" +) + +// apiVersion is the version of this surface, carried in the path. +const apiVersion = "v1" + +// Actor names written to the audit log and the override history. +const ( + apiActor = "admin-api" + keycodeActor = "device-keycode" + apiMaxBody = 8 << 10 // 8 KiB is plenty for these requests + apiMaxGrant = 24 * 60 // minutes; a day at a time + apiMaxLockMin = 7 * 24 * 60 +) + +// apiDevice is the wire shape of one device. It is versioned by its path, and +// clients must ignore unknown fields so it can grow without a v2. +type apiDevice struct { + ID int64 `json:"id"` + Name string `json:"name"` + Child string `json:"child"` + Platform string `json:"platform"` + Topic string `json:"topic"` + Online bool `json:"online"` + LastSeen *time.Time `json:"last_seen"` + Locked bool `json:"locked"` + Until *time.Time `json:"until"` + GrantUntil *time.Time `json:"grant_until"` + Reason string `json:"reason"` + UpdatedAt time.Time `json:"updated_at"` +} + +// apiResponse is the one response envelope for every endpoint. Exactly one of +// Device or Devices is set on success; Error is set on failure. +type apiResponse struct { + OK bool `json:"ok"` + Action string `json:"action,omitempty"` + Device *apiDevice `json:"device,omitempty"` + Devices []apiDevice `json:"devices,omitempty"` + Error string `json:"error,omitempty"` +} + +// overrideRequest is the body of lock, grant and keycode requests. +type overrideRequest struct { + // Minutes grants time relative to now. Mutually exclusive with Until. + Minutes int `json:"minutes"` + // Until is an absolute RFC3339 instant. + Until string `json:"until"` + // DeviceID and Keycode are used only by the device keycode endpoint. + DeviceID int64 `json:"device_id"` + Keycode string `json:"keycode"` +} + +// handleAPIListDevices returns every device with its effective state. +func (s *Server) handleAPIListDevices(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + devs, err := s.store.ListDevices(ctx) + if err != nil { + s.log.Error("api list devices", "err", err) + writeAPIError(w, http.StatusInternalServerError, "could not read devices") + return + } + out := make([]apiDevice, 0, len(devs)) + for _, dev := range devs { + item, err := s.apiDevice(ctx, dev) + if err != nil { + s.log.Error("api effective state", "device", dev.Name, "err", err) + writeAPIError(w, http.StatusInternalServerError, "could not read device state") + return + } + out = append(out, item) + } + writeAPIJSON(w, http.StatusOK, apiResponse{OK: true, Devices: out}) +} + +// handleAPIState returns one device with its effective state. +func (s *Server) handleAPIState(w http.ResponseWriter, r *http.Request) { + id, ok := s.deviceID(w, r) + if !ok { + writeAPIError(w, http.StatusNotFound, "device not found") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + dev, err := s.store.GetDevice(ctx, id) + if err != nil { + s.apiDeviceError(w, err) + return + } + item, err := s.apiDevice(ctx, *dev) + if err != nil { + s.log.Error("api effective state", "device", dev.Name, "err", err) + writeAPIError(w, http.StatusInternalServerError, "could not read device state") + return + } + writeAPIJSON(w, http.StatusOK, apiResponse{OK: true, Device: &item}) +} + +// handleAPILock locks a device now, for a duration or indefinitely. +func (s *Server) handleAPILock(w http.ResponseWriter, r *http.Request) { + id, ok := s.deviceID(w, r) + if !ok { + writeAPIError(w, http.StatusNotFound, "device not found") + return + } + req, ok := decodeOverride(w, r, false) + if !ok { + return + } + until, err := resolveUntil(req, apiMaxLockMin) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + started := time.Now() + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + dev, err := s.store.SetDeviceLock(ctx, id, true, until, apiActor, "state.locked") + if err != nil { + s.apiDeviceError(w, err) + return + } + s.publishConfig(ctx, *dev) + s.apiRespond(w, ctx, "lock", dev, started) +} + +// handleAPIUnlock clears a manual lock and any grant, returning the device to +// its schedule. It is the opposite of lock. +func (s *Server) handleAPIUnlock(w http.ResponseWriter, r *http.Request) { + id, ok := s.deviceID(w, r) + if !ok { + writeAPIError(w, http.StatusNotFound, "device not found") + return + } + // An empty body is fine; reject a malformed one. + if !decodeEmpty(w, r) { + return + } + + started := time.Now() + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + dev, err := s.store.SetDeviceLock(ctx, id, false, nil, apiActor, "state.unlocked") + if err != nil { + s.apiDeviceError(w, err) + return + } + s.publishConfig(ctx, *dev) + s.apiRespond(w, ctx, "unlock", dev, started) +} + +// handleAPIGrant gives a device time: it is unlocked until the grant ends, even +// if a weekly window is active, and re-locks automatically when the grant ends +// (the republish loop treats the grant end as a boundary). +func (s *Server) handleAPIGrant(w http.ResponseWriter, r *http.Request) { + id, ok := s.deviceID(w, r) + if !ok { + writeAPIError(w, http.StatusNotFound, "device not found") + return + } + req, ok := decodeOverride(w, r, true) + if !ok { + return + } + until, err := resolveUntil(req, apiMaxGrant) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if until == nil { + writeAPIError(w, http.StatusBadRequest, "grant requires minutes or until") + return + } + + started := time.Now() + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + dev, err := s.store.SetDeviceGrant(ctx, id, *until, apiActor, "override.granted") + if err != nil { + s.apiDeviceError(w, err) + return + } + s.publishConfig(ctx, *dev) + s.apiRespond(w, ctx, "grant", dev, started) +} + +// handleDeviceOverride is the on-device keycode path (FR-10). A kid's client +// posts the shared code with its own device id; a correct code grants a short, +// bounded exemption. It intentionally does not use the admin bearer token: the +// device never holds that secret. Failures are throttled per client address and +// audited, because the keycode is the enforcement that the tamper alert +// protects (AGENTS.md §13). +func (s *Server) handleDeviceOverride(w http.ResponseWriter, r *http.Request) { + if !s.keycodeConfigured(r.Context()) { + writeAPIError(w, http.StatusServiceUnavailable, "device keycode is not configured") + return + } + req, ok := decodeOverride(w, r, true) + if !ok { + return + } + if req.DeviceID <= 0 { + writeAPIError(w, http.StatusBadRequest, "device_id is required") + return + } + req.Keycode = strings.TrimSpace(req.Keycode) + if req.Keycode == "" { + writeAPIError(w, http.StatusBadRequest, "keycode is required") + return + } + + remote := clientIP(r) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if s.keycodeThrottled(remote) { + _ = s.store.Audit(ctx, keycodeActor, "override.keycode_throttled", &req.DeviceID, remote) + writeAPIError(w, http.StatusTooManyRequests, "too many attempts; try again later") + return + } + if !s.store.VerifyKeycode(ctx, req.Keycode) { + s.noteKeycodeFailure(remote) + _ = s.store.Audit(ctx, keycodeActor, "override.keycode_rejected", &req.DeviceID, remote) + s.log.Warn("device keycode rejected", "device_id", req.DeviceID, "remote", remote) + writeAPIError(w, http.StatusUnauthorized, "invalid keycode") + return + } + s.noteKeycodeSuccess(remote) + + until, err := resolveUntil(req, apiMaxGrant) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if until == nil { + // A keycode run without a duration gives a short, fixed exemption. + t := time.Now().Add(15 * time.Minute).UTC() + until = &t + } + + started := time.Now() + dev, err := s.store.SetDeviceGrant(ctx, req.DeviceID, *until, keycodeActor, "override.keycode") + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeAPIError(w, http.StatusNotFound, "device not found") + return + } + s.log.Error("device keycode grant", "device_id", req.DeviceID, "err", err) + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + s.publishConfig(ctx, *dev) + s.apiRespond(w, ctx, "keycode", dev, started) +} + +// ---------- helpers ---------- + +// apiDevice combines the stored device with its effective state, so a client +// sees the same `locked`/`until`/`reason` a device is given. +func (s *Server) apiDevice(ctx context.Context, dev store.Device) (apiDevice, error) { + eff, err := s.store.EffectiveState(ctx, dev, s.now()) + if err != nil { + return apiDevice{}, err + } + return apiDevice{ + ID: dev.ID, + Name: dev.Name, + Child: dev.Child, + Platform: dev.Platform, + Topic: dev.Topic, + Online: dev.Online, + LastSeen: dev.LastSeen, + Locked: eff.Locked, + Until: eff.Until, + GrantUntil: eff.GrantUntil, + Reason: eff.Reason, + UpdatedAt: dev.StateAt, + }, nil +} + +// apiRespond writes the fresh device after a state change, logging the time +// from request receipt to the retained publish being accepted. That duration is +// the number the Phase 8 definition of done measures. +func (s *Server) apiRespond(w http.ResponseWriter, ctx context.Context, action string, dev *store.Device, started time.Time) { + item, err := s.apiDevice(ctx, *dev) + if err != nil { + s.log.Error("api effective state", "device", dev.Name, "err", err) + writeAPIError(w, http.StatusInternalServerError, "state saved but could not be read back") + return + } + s.log.Info("admin api "+action, "device", dev.Name, "device_id", dev.ID, + "locked", item.Locked, "reason", item.Reason, + "duration_ms", time.Since(started).Milliseconds()) + writeAPIJSON(w, http.StatusOK, apiResponse{OK: true, Action: action, Device: &item}) +} + +// requireAPIToken guards the admin endpoints. It is deliberately a separate +// credential from the session login (AGENTS.md §11 Phase 8). +func (s *Server) requireAPIToken(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if strings.TrimSpace(s.cfg.AdminAPIToken) == "" { + writeAPIError(w, http.StatusServiceUnavailable, "admin API is not configured") + return + } + token, ok := bearerToken(r) + if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.AdminAPIToken)) != 1 { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + _ = s.store.Audit(ctx, "anonymous", "admin.token_rejected", nil, r.URL.Path+" from "+clientIP(r)) + cancel() + s.log.Warn("admin api rejected", "path", r.URL.Path, "remote", r.RemoteAddr) + w.Header().Set("WWW-Authenticate", `Bearer realm="internet-use-control"`) + writeAPIError(w, http.StatusUnauthorized, "unauthorized") + return + } + next(w, r) + } +} + +// bearerToken reads "Authorization: Bearer ". +func bearerToken(r *http.Request) (string, bool) { + header := r.Header.Get("Authorization") + kind, token, found := strings.Cut(header, " ") + if !found || !strings.EqualFold(kind, "Bearer") { + return "", false + } + token = strings.TrimSpace(token) + return token, token != "" +} + +// decodeOverride reads and bounds the JSON body. When requireBody is false an +// empty body is the same as {}. +func decodeOverride(w http.ResponseWriter, r *http.Request, requireBody bool) (overrideRequest, bool) { + var req overrideRequest + if r.Body == nil { + if requireBody { + writeAPIError(w, http.StatusBadRequest, "a JSON body is required") + return req, false + } + return req, true + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, apiMaxBody)) + err := dec.Decode(&req) + if err != nil { + if strings.Contains(err.Error(), "EOF") && !requireBody { + return req, true + } + writeAPIError(w, http.StatusBadRequest, "invalid JSON body") + return req, false + } + // A second JSON value in the body is a mistake. + if dec.Decode(&struct{}{}) == nil { + writeAPIError(w, http.StatusBadRequest, "body must contain one JSON object") + return req, false + } + return req, true +} + +// decodeEmpty accepts an empty or absent body and rejects anything malformed. +func decodeEmpty(w http.ResponseWriter, r *http.Request) bool { + if r.Body == nil || r.ContentLength == 0 { + return true + } + _, ok := decodeOverride(w, r, false) + return ok +} + +// resolveUntil turns a request into an absolute end time. Exactly one of +// minutes or until may be set. A nil result with no error means "no end time" +// (only meaningful for a lock). +func resolveUntil(req overrideRequest, maxMinutes int) (*time.Time, error) { + if req.Minutes < 0 { + return nil, errors.New("minutes must not be negative") + } + if req.Until != "" && req.Minutes > 0 { + return nil, errors.New("set either minutes or until, not both") + } + if req.Until != "" { + t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.Until)) + if err != nil { + return nil, errors.New("until must be an RFC3339 instant, for example 2026-10-13T21:00:00+11:00") + } + t = t.UTC() + if !t.After(time.Now()) { + return nil, errors.New("until must be in the future") + } + return &t, nil + } + if req.Minutes > maxMinutes { + return nil, errors.New("minutes is too large") + } + if req.Minutes > 0 { + t := time.Now().Add(time.Duration(req.Minutes) * time.Minute).UTC() + return &t, nil + } + return nil, nil +} + +// apiDeviceError maps a store error to an HTTP status. +func (s *Server) apiDeviceError(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrNotFound) { + writeAPIError(w, http.StatusNotFound, "device not found") + return + } + s.log.Error("admin api device", "err", err) + writeAPIError(w, http.StatusBadRequest, err.Error()) +} + +func writeAPIJSON(w http.ResponseWriter, code int, body apiResponse) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +func writeAPIError(w http.ResponseWriter, code int, message string) { + writeAPIJSON(w, code, apiResponse{OK: false, Error: message}) +} + +// clientIP is the direct peer address. No proxy header is trusted: the service +// is reached through Caddy, but X-Forwarded-For is caller-controlled. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// ---------- keycode throttle ---------- + +// keycodeThrottle bounds brute force on the shared code: five failures from one +// address freeze that address for five minutes. It is in memory on purpose — +// the Control Service is a single process, and a restart clearing it is an +// acceptable trade for keeping the store schema small. +const ( + keycodeMaxFailures = 5 + keycodeFreeze = 5 * time.Minute +) + +type keycodeAttempts struct { + failures int + blocked time.Time +} + +func (s *Server) keycodeThrottled(remote string) bool { + s.keycodeMu.Lock() + defer s.keycodeMu.Unlock() + a, ok := s.keycode[remote] + if !ok { + return false + } + if time.Now().Before(a.blocked) { + return true + } + if !a.blocked.IsZero() { + delete(s.keycode, remote) // freeze over; start fresh + } + return false +} + +func (s *Server) noteKeycodeFailure(remote string) { + s.keycodeMu.Lock() + defer s.keycodeMu.Unlock() + a := s.keycode[remote] + a.failures++ + if a.failures >= keycodeMaxFailures { + a.blocked = time.Now().Add(keycodeFreeze) + a.failures = 0 + } + s.keycode[remote] = a +} + +func (s *Server) noteKeycodeSuccess(remote string) { + s.keycodeMu.Lock() + defer s.keycodeMu.Unlock() + delete(s.keycode, remote) +} + +// keycodeConfigured reports whether any keycode can be verified. +func (s *Server) keycodeConfigured(ctx context.Context) bool { + _, err := s.store.KeycodeHash(ctx) + return err == nil +} diff --git a/internal/web/admin_api_test.go b/internal/web/admin_api_test.go new file mode 100644 index 0000000..bf443af --- /dev/null +++ b/internal/web/admin_api_test.go @@ -0,0 +1,337 @@ +package web + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth" + "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config" + "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" +) + +// fakePublisher records the retained config publishes an API call makes, so a +// test can prove a state change reaches the broker before the response. +type fakePublisher struct { + mu sync.Mutex + topics []string +} + +func (f *fakePublisher) Broker() string { return "fake" } +func (f *fakePublisher) Connected() bool { return true } +func (f *fakePublisher) Stats() (int64, int64, time.Time, string) { + return 0, 0, time.Time{}, "" +} +func (f *fakePublisher) PublishDeviceConfig(_ context.Context, dev store.Device) error { + f.mu.Lock() + defer f.mu.Unlock() + f.topics = append(f.topics, dev.Topic) + return nil +} +func (f *fakePublisher) PublishAll(context.Context) (int, error) { return 0, nil } + +func (f *fakePublisher) published() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.topics...) +} + +type apiTestApp struct { + handler http.Handler + store *store.Store + pub *fakePublisher + token string +} + +func newAPITestApp(t *testing.T, token, keycode string) *apiTestApp { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "control.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(context.Background()); err != nil { + t.Fatalf("Migrate: %v", err) + } + + am, err := auth.New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2") + if err != nil { + t.Fatalf("auth.New: %v", err) + } + pub := &fakePublisher{} + cfg := &config.Config{ + AdminUsername: "sam", + Version: "test", + AppEnv: "test", + DBPath: ":memory:", + AdminAPIToken: token, + ScheduleLocation: time.UTC, + } + srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), pub) + if err != nil { + t.Fatalf("web.New: %v", err) + } + if keycode != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(keycode), bcrypt.MinCost) + if err != nil { + t.Fatalf("bcrypt: %v", err) + } + if err := st.SetKeycodeHash(context.Background(), string(hash), "test"); err != nil { + t.Fatalf("SetKeycodeHash: %v", err) + } + } + return &apiTestApp{handler: srv.Handler(), store: st, pub: pub, token: token} +} + +func (a *apiTestApp) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, req) + return rec +} + +func decodeAPIResponse(t *testing.T, rec *httptest.ResponseRecorder) apiResponse { + t.Helper() + var resp apiResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response %q: %v", rec.Body.String(), err) + } + return resp +} + +func mustCreateDevice(t *testing.T, st *store.Store, name string) store.Device { + t.Helper() + dev, err := st.CreateDevice(context.Background(), name, "Harry", "android", "test") + if err != nil { + t.Fatalf("CreateDevice: %v", err) + } + return *dev +} + +func TestAdminAPIAuth(t *testing.T) { + app := newAPITestApp(t, "s3cret", "") + dev := mustCreateDevice(t, app.store, "Harry Phone") + + if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", ""); rec.Code != http.StatusUnauthorized { + t.Errorf("no token = %d, want 401", rec.Code) + } + if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "wrong"); rec.Code != http.StatusUnauthorized { + t.Errorf("wrong token = %d, want 401", rec.Code) + } + if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret"); rec.Code != http.StatusOK { + t.Errorf("good token = %d, want 200", rec.Code) + } + + // The rejection is audited. + audit, err := app.store.ListAudit(context.Background(), 10) + if err != nil { + t.Fatalf("ListAudit: %v", err) + } + var rejected int + for _, e := range audit { + if e.Action == "admin.token_rejected" { + rejected++ + } + } + if rejected != 2 { + t.Errorf("admin.token_rejected = %d, want 2", rejected) + } + + // The token is separate from the session login: a session cookie does not + // authorise the API and the API token does not open the dashboard. + if rec := app.do(t, http.MethodGet, "/", "", ""); rec.Code != http.StatusSeeOther { + t.Errorf("dashboard without session = %d, want 303", rec.Code) + } + _ = dev +} + +func TestAdminAPIUnconfigured(t *testing.T) { + app := newAPITestApp(t, "", "") + if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "anything"); rec.Code != http.StatusServiceUnavailable { + t.Errorf("unconfigured admin API = %d, want 503", rec.Code) + } +} + +func TestAdminAPILockUnlockGrant(t *testing.T) { + app := newAPITestApp(t, "s3cret", "") + dev := mustCreateDevice(t, app.store, "Harry Phone") + + // Lock for 30 minutes. + rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/lock", `{"minutes":30}`, "s3cret") + if rec.Code != http.StatusOK { + t.Fatalf("lock = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + resp := decodeAPIResponse(t, rec) + if !resp.OK || resp.Action != "lock" || resp.Device == nil { + t.Fatalf("lock response = %+v", resp) + } + if !resp.Device.Locked || resp.Device.Reason != "manual" || resp.Device.Until == nil { + t.Errorf("locked device = %+v", resp.Device) + } + if got := app.pub.published(); len(got) != 1 || got[0] != dev.Topic { + t.Errorf("published = %v, want the device config topic", got) + } + + // Grant time (unlocked, with an end). + rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", `{"minutes":15}`, "s3cret") + if rec.Code != http.StatusOK { + t.Fatalf("grant = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + resp = decodeAPIResponse(t, rec) + if resp.Device == nil || resp.Device.Locked || resp.Device.Reason != "grant" || resp.Device.GrantUntil == nil { + t.Fatalf("granted device = %+v", resp.Device) + } + + // Unlock clears the grant. + rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/unlock", "", "s3cret") + if rec.Code != http.StatusOK { + t.Fatalf("unlock = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + resp = decodeAPIResponse(t, rec) + if resp.Device == nil || resp.Device.Locked || resp.Device.GrantUntil != nil { + t.Fatalf("unlocked device = %+v", resp.Device) + } + + // Query one device and the list. + if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices/1", "", "s3cret"); rec.Code != http.StatusOK { + t.Errorf("state = %d, want 200", rec.Code) + } + rec = app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret") + if rec.Code != http.StatusOK { + t.Fatalf("list = %d, want 200", rec.Code) + } + if resp = decodeAPIResponse(t, rec); len(resp.Devices) != 1 || resp.Devices[0].Name != "Harry Phone" { + t.Errorf("device list = %+v", resp.Devices) + } + + // Every mutation is in the override history and the audit log. + overrides, err := app.store.ListOverrides(context.Background(), 1, 10) + if err != nil { + t.Fatalf("ListOverrides: %v", err) + } + if len(overrides) != 3 { + t.Errorf("overrides = %d, want 3 (lock, grant, unlock)", len(overrides)) + } + audit, _ := app.store.ListAudit(context.Background(), 20) + var stateChanges int + for _, e := range audit { + switch e.Action { + case "state.locked", "override.granted", "state.unlocked": + stateChanges++ + } + } + if stateChanges != 3 { + t.Errorf("audited state changes = %d, want 3", stateChanges) + } + + // A publish happens for every mutation. + if got := app.pub.published(); len(got) != 3 { + t.Errorf("publishes = %v, want 3", got) + } +} + +func TestAdminAPIBadRequests(t *testing.T) { + app := newAPITestApp(t, "s3cret", "") + mustCreateDevice(t, app.store, "Harry Phone") + + cases := []struct { + name string + body string + code int + }{ + {"unknown field JSON is rejected", `not json`, http.StatusBadRequest}, + {"negative minutes", `{"minutes":-5}`, http.StatusBadRequest}, + {"past until", `{"until":"2000-01-01T00:00:00Z"}`, http.StatusBadRequest}, + {"both minutes and until", `{"minutes":5,"until":"2999-01-01T00:00:00Z"}`, http.StatusBadRequest}, + {"grant with no time", `{}`, http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", tc.body, "s3cret") + if rec.Code != tc.code { + t.Errorf("grant %q = %d, want %d; body=%s", tc.body, rec.Code, tc.code, rec.Body.String()) + } + }) + } + + if rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/999/lock", `{}`, "s3cret"); rec.Code != http.StatusNotFound { + t.Errorf("lock unknown device = %d, want 404", rec.Code) + } +} + +func TestDeviceKeycodeOverride(t *testing.T) { + app := newAPITestApp(t, "s3cret", "246810") + mustCreateDevice(t, app.store, "Harry Phone") + + // A wrong code is refused and audited. + rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"000000","minutes":10}`, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong keycode = %d, want 401; body=%s", rec.Code, rec.Body.String()) + } + + // The right code grants time without the admin token. + rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":10}`, "") + if rec.Code != http.StatusOK { + t.Fatalf("keycode = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + resp := decodeAPIResponse(t, rec) + if !resp.OK || resp.Action != "keycode" || resp.Device == nil || resp.Device.Reason != "grant" { + t.Fatalf("keycode response = %+v", resp) + } + + // A keycode with no duration still grants a short, fixed exemption. + rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810"}`, "") + if rec.Code != http.StatusOK { + t.Fatalf("keycode without duration = %d, want 200", rec.Code) + } + if resp = decodeAPIResponse(t, rec); resp.Device == nil || resp.Device.GrantUntil == nil { + t.Fatalf("default grant = %+v", resp.Device) + } +} + +func TestDeviceKeycodeThrottle(t *testing.T) { + app := newAPITestApp(t, "s3cret", "246810") + mustCreateDevice(t, app.store, "Harry Phone") + + for i := 0; i < 5; i++ { + rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"wrong"}`, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d = %d, want 401", i+1, rec.Code) + } + } + // The sixth attempt is throttled even with the right code. + rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":5}`, "") + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("after five failures = %d, want 429; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDeviceKeycodeUnconfigured(t *testing.T) { + app := newAPITestApp(t, "s3cret", "") + mustCreateDevice(t, app.store, "Harry Phone") + rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"1234"}`, "") + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured keycode = %d, want 503", rec.Code) + } +} diff --git a/internal/web/devices.go b/internal/web/devices.go index 1d441c0..d311716 100644 --- a/internal/web/devices.go +++ b/internal/web/devices.go @@ -29,6 +29,7 @@ type deviceView struct { Locked bool ManualLocked bool Scheduled bool + Granted bool Until *time.Time UntilLocal string TimeLeft string @@ -81,9 +82,14 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState, v.ReasonLabel = "by schedule" case "manual": v.ReasonLabel = "manually" + case "grant": + v.Granted = true + v.ReasonLabel = "time granted" } if eff.Locked { v.StateLabel = "Locked" + } else if v.Granted { + v.StateLabel = "Granted" } else { v.StateLabel = "Unlocked" } @@ -92,6 +98,12 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState, v.UntilLocal = local.Format("Mon 15:04") v.TimeLeft = timeLeft(*eff.Until) } + if eff.GrantUntil != nil { + v.Until = eff.GrantUntil + local := eff.GrantUntil.In(loc) + v.UntilLocal = local.Format("Mon 15:04") + v.TimeLeft = timeLeft(*eff.GrantUntil) + } if d.LastSeen != nil { v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04") v.HasLastSeen = true @@ -167,6 +179,18 @@ func auditLabel(action string) string { return "Unlocked" case "state.expired": return "Lock expired" + case "override.granted": + return "Time granted" + case "override.keycode": + return "Keycode override" + case "override.keycode_rejected": + return "Keycode rejected" + case "override.keycode_throttled": + return "Keycode throttled" + case "keycode.updated": + return "Keycode changed" + case "admin.token_rejected": + return "Admin API rejected" case "schedule.updated": return "Schedule saved" case "list.added": diff --git a/internal/web/server.go b/internal/web/server.go index ea3ba09..9c1b651 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -12,6 +12,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth" @@ -33,6 +34,12 @@ type Server struct { tmpl *template.Template log *slog.Logger pub Publisher + + // keycodeMu guards the in-memory throttle for the device keycode endpoint + // (admin_api.go). Failures are remembered per client address, so a wrong + // code cannot be tried without limit. + keycodeMu sync.Mutex + keycode map[string]keycodeAttempts } // Publisher pushes device config to the broker and reports the link's health. @@ -60,7 +67,15 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } - return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, pub: pub}, nil + return &Server{ + cfg: cfg, + store: st, + auth: am, + tmpl: tmpl, + log: log, + pub: pub, + keycode: make(map[string]keycodeAttempts), + }, nil } // publishConfig pushes one device's desired state to the broker, retained. A @@ -103,6 +118,19 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete)) mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage)) + // Versioned admin API (Phase 8). Bearer-token auth, separate from the + // session login. The Kotlin admin app calls these directly (AGENTS.md §8, + // §11 Phase 8). + api := "/api/" + apiVersion + mux.HandleFunc("GET "+api+"/admin/devices", s.requireAPIToken(s.handleAPIListDevices)) + mux.HandleFunc("GET "+api+"/admin/devices/{id}", s.requireAPIToken(s.handleAPIState)) + mux.HandleFunc("POST "+api+"/admin/devices/{id}/lock", s.requireAPIToken(s.handleAPILock)) + mux.HandleFunc("POST "+api+"/admin/devices/{id}/unlock", s.requireAPIToken(s.handleAPIUnlock)) + mux.HandleFunc("POST "+api+"/admin/devices/{id}/grant", s.requireAPIToken(s.handleAPIGrant)) + // The on-device keycode path is not behind the admin token: a device does + // not hold that secret. It is throttled and audited instead (admin_api.go). + mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride) + staticSub, err := fs.Sub(staticFS, "static") if err != nil { panic("web: embedded static dir missing: " + err.Error()) diff --git a/internal/web/templates/partials/device-cards.html b/internal/web/templates/partials/device-cards.html index 15b549d..cfeb220 100644 --- a/internal/web/templates/partials/device-cards.html +++ b/internal/web/templates/partials/device-cards.html @@ -12,9 +12,9 @@

- {{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}} + {{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else if .Granted}}Time granted{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}

- {{if .Until}}

Lifts {{.UntilLocal}}

{{end}} + {{if .Until}}

{{if .Granted}}Grant ends{{else}}Lifts{{end}} {{.UntilLocal}}

{{end}}