Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API)

Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11:
the shared device keycode and the versioned admin HTTP API the separate Kotlin
admin app will call.

- store: migration 004 adds state.grant_until and a settings table. New
  overrides.go records every override in `overrides` and verifies the shared
  keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also
  clears an expired grant, and NextScheduleBoundary includes the grant end so a
  device re-locks on time.
- effective state: a grant unlocks a device inside a schedule window; a manual
  lock clears a grant and a grant clears a manual lock. The retained config
  payload gains grant_until (additive, v stays 1).
- web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by
  CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device
  keycode endpoint POST /api/v1/device/override with per-address throttling.
  A state change is published retained before the API responds.
- cmd/control: `keycode` subcommand and start-up seeding from
  CONTROL_DEVICE_KEYCODE_HASH.
- deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done:
  an API lock reaches a real broker subscriber in well under a second.
- Version 0.6.0-phase8 (compose, Makefile, docs).

Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler
1 ms, request to retained publish accepted).

Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 16:27:46 +11:00
parent b58993d994
commit 4f233a8012
19 changed files with 1833 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
IMAGE ?= webrestrict/control:0.5.0-phase7 IMAGE ?= webrestrict/control:0.6.0-phase8
BUILD_VERSION ?= 0.5.0-phase7 BUILD_VERSION ?= 0.6.0-phase8
# Bounded docker helper for the Go toolchain. The container runs as the current # 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. # 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 MQTT_IMAGE ?= eclipse-mosquitto:latest
.PHONY: help test vet build up down restart logs ps hash-password deps fmt \ .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: help:
@echo "make test run go test ./... in golang:1.25-alpine" @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-tls generate the lab CA and broker certificate"
@echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/" @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 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: test:
$(GO_RUN) go test ./... $(GO_RUN) go test ./...
@@ -76,3 +77,8 @@ mqtt-config: mqtt-tls
mqtt-verify: mqtt-config mqtt-verify: mqtt-config
MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \ MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \
deploy/mosquitto/verify-isolation.sh 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

144
README.md
View File

@@ -5,8 +5,8 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists,
per-device state and usage, and serves the web dashboard. Only it publishes per-device state and usage, and serves the web dashboard. Only it publishes
device config. device config.
This repository currently contains **Phase 7 — Telemetry and usage** This repository currently contains **Phase 8 — Overrides** (AGENTS.md §11), on
(AGENTS.md §11), on top of Phases 1–6: top of Phases 1–7:
- Go service with its own image (`Dockerfile`) and compose file. - Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned - 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 daily total plus the top apps and top sites — with dark mode and phone-sized
touch targets. touch targets.
- Every edit is written to the append-only audit log (FR-15). - 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 Enforcement itself (the kids' clients) and the Hub arrive in later phases.
later phases. Nothing here touches the router, Pi-hole, or DNS. Nothing here touches the router, Pi-hole, or DNS.
## Quick start (on `.13`) ## Quick start (on `.13`)
@@ -57,7 +62,7 @@ docker compose up -d --build
# 3. Prove it. # 3. Prove it.
curl -s http://127.0.0.1:8791/health 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 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_USERNAME` | `sam` | Dashboard login |
| `CONTROL_ADMIN_PASSWORD` | — | Plaintext password (option A) | | `CONTROL_ADMIN_PASSWORD` | — | Plaintext password (option A) |
| `CONTROL_ADMIN_PASSWORD_HASH` | — | bcrypt hash (option B, preferred) | | `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_APP_ENV` | `production` | Label shown in the footer |
| `CONTROL_LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error` | | `CONTROL_LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error` |
| `CONTROL_TIMEZONE` | `Australia/Sydney` | IANA zone weekly windows are evaluated in | | `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: Generate a bcrypt hash without host Go:
```bash ```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' 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 `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, **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 `manual` means a lock was set from the dashboard or the admin API, and `grant`
device blocks every `block` value **except** the `allow` values (FR-4), so the means a time-limited exemption is active, in which case `grant_until` (RFC3339)
allowlist is the exception list. A client must ignore unknown fields, so this is set and `locked` is false. While `locked` is true the device blocks every
shape can grow. `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) ### 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. | | `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). | | `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 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 append to the `audit` table and republish the device's retained config. Devices
are never hard-deleted (§10 rule 10). 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 Spotify stays available and YouTube does not. Hostnames are lower-cased; app
values keep their case. 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 ## Development
There is no host Go toolchain on `.13`; use the cached builder image. 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 up # docker compose up -d --build
make logs # follow container logs make logs # follow container logs
make mqtt-verify # prove TLS + credentials + ACLs on a throwaway broker 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 `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 ## 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/config/ environment configuration
internal/store/ SQLite open, migrations, audit, devices + state internal/store/ SQLite open, migrations, audit, devices + state
devices.go device registry, desired state, lock expiry devices.go device registry, desired state, lock + grant expiry
schedules.go weekly windows and the effective state schedules.go weekly windows and the effective state (incl. grants)
reports.go presence, telemetry, the daily rollup, top lists, audit cap reports.go presence, telemetry, the daily rollup, top lists, audit cap
lists.go allow/block lists and device lookup by topic 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/auth/ signed-cookie session login + CSRF
internal/mqtt/ the broker link: retained config out, reports in internal/mqtt/ the broker link: retained config out, reports in
payload.go topic and payload contract for the kids' clients payload.go topic and payload contract for the kids' clients
link.go paho client, subscriptions, publish, TLS verification link.go paho client, subscriptions, publish, TLS verification
internal/web/ HTTP handlers, templates, static assets internal/web/ HTTP handlers, templates, static assets
devices.go device registry + manual lock/unlock handlers 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 schedules.go weekly schedule and block/allow editors
usage.go usage screen: week grid, top apps, top sites usage.go usage screen: week grid, top apps, top sites
templates/ login, dashboard, device form, editors, usage, partials 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` `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. (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 - No grant button in the dashboard. Grant is on the admin API; the dashboard has
`device_id` that applies to every device, and the effective state merges it, lock and unlock. Adding the button is a later UI change.
but the editor manages per-device rows only. - No per-child or per-device keycode. One code, every device (§10 rule 3).
- No HTTP telemetry ingest. Devices report over MQTT; an HTTP path arrives if a - No HTTP telemetry ingest and no kids' clients (Phases 4–5). The topic and
client needs it. payload contract above is what they implement.
- 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 Hub contact (Phase 9). - No Hub contact (Phase 9).

View File

@@ -7,10 +7,12 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"math/big"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -58,6 +60,9 @@ func main() {
case "mqtt-clients": case "mqtt-clients":
mqttClients(os.Args[2:]) mqttClients(os.Args[2:])
return return
case "keycode":
keycode(os.Args[2:])
return
case "rollup": case "rollup":
rollup(os.Args[2:]) rollup(os.Args[2:])
return return
@@ -107,6 +112,16 @@ func run() error {
log.Info("audit log capped", "removed", n, "max", auditMaxRows) 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, am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword) cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)
if err != nil { if err != nil {
@@ -365,6 +380,77 @@ func env(key, fallback string) string {
return fallback 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 // hashPassword prints a bcrypt hash for the password given as an argument or
// read from stdin. It never prints the password itself. // read from stdin. It never prints the password itself.
func hashPassword(args []string) { func hashPassword(args []string) {

143
deploy/verify-phase8.sh Executable file
View File

@@ -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/<id>/lock and measures the time from the
# request to the moment a real broker subscriber receives the retained
# restrict/<device>/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 <method> <path> [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"

View File

@@ -12,8 +12,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.5.0-phase7" VERSION: "0.6.0-phase8"
image: webrestrict/control:0.5.0-phase7 image: webrestrict/control:0.6.0-phase8
container_name: webrestrict-control container_name: webrestrict-control
restart: unless-stopped restart: unless-stopped
env_file: env_file:

View File

@@ -38,9 +38,25 @@ CONTROL_ADMIN_USERNAME=sam
CONTROL_ADMIN_PASSWORD=change-me CONTROL_ADMIN_PASSWORD=change-me
# Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty. # 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$... # 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) -------------------------------------------------------- # --- MQTT (Phase 3) --------------------------------------------------------
# The broker is transport only. SQLite stays the source of truth, and only the # The broker is transport only. SQLite stays the source of truth, and only the
# Control Service publishes config (AGENTS.md §10 rules 5-6). # Control Service publishes config (AGENTS.md §10 rules 5-6).

View File

@@ -30,6 +30,15 @@ type Config struct {
AdminPassword string AdminPassword string
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set. // AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
AdminPasswordHash string 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 sets the Secure attribute on cookies. Keep true behind HTTPS.
CookieSecure bool CookieSecure bool
// SessionTTL is how long a session cookie stays valid. // SessionTTL is how long a session cookie stays valid.
@@ -74,6 +83,8 @@ func Load() (*Config, error) {
AdminUsername: env("CONTROL_ADMIN_USERNAME", "sam"), AdminUsername: env("CONTROL_ADMIN_USERNAME", "sam"),
AdminPassword: os.Getenv("CONTROL_ADMIN_PASSWORD"), AdminPassword: os.Getenv("CONTROL_ADMIN_PASSWORD"),
AdminPasswordHash: os.Getenv("CONTROL_ADMIN_PASSWORD_HASH"), 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), CookieSecure: envBool("CONTROL_COOKIE_SECURE", true),
AppEnv: env("CONTROL_APP_ENV", "production"), AppEnv: env("CONTROL_APP_ENV", "production"),
MQTTEnabled: envBool("CONTROL_MQTT_ENABLED", true), MQTTEnabled: envBool("CONTROL_MQTT_ENABLED", true),

View File

@@ -356,6 +356,7 @@ func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error
Allow: eff.Allow, Allow: eff.Allow,
Block: eff.Block, Block: eff.Block,
Reason: eff.Reason, Reason: eff.Reason,
GrantUntil: eff.GrantUntil,
GeneratedAt: time.Now().UTC(), GeneratedAt: time.Now().UTC(),
}.Marshal() }.Marshal()
} }

View File

@@ -86,9 +86,15 @@ type Config struct {
Allow []string `json:"allow"` Allow []string `json:"allow"`
Block []string `json:"block"` Block []string `json:"block"`
// Reason is why the device is locked: "schedule" for an active weekly // 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 // window, "manual" for a lock from the dashboard or the admin API. It is
// device is not locked. It is additive (Phase 6), so v stays 1. // omitted when the device is not locked. It is additive (Phase 6), so v
Reason string `json:"reason,omitempty"` // 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"` GeneratedAt time.Time `json:"generated_at"`
} }

View File

@@ -41,9 +41,13 @@ type Device struct {
UpdatedAt time.Time UpdatedAt time.Time
// Desired state, from the state table. // Desired state, from the state table.
Locked bool Locked bool
Until *time.Time Until *time.Time
StateAt 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). // AuditEntry is one row of the append-only audit log (FR-15).
@@ -61,7 +65,7 @@ type AuditEntry struct {
const selectDevice = ` const selectDevice = `
SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen, SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen,
d.created_at, d.updated_at, 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 FROM devices d
LEFT JOIN state s ON s.device_id = d.id` LEFT JOIN state s ON s.device_id = d.id`
@@ -69,21 +73,22 @@ func scanDevice(row interface {
Scan(dest ...any) error Scan(dest ...any) error
}) (*Device, error) { }) (*Device, error) {
var ( var (
dev Device dev Device
online int online int
lastSeen, until, stateAt sql.NullString lastSeen, until, grantUntil sql.NullString
createdAt, updatedAt sql.NullString createdAt, updatedAt, stateAt sql.NullString
) )
if err := row.Scan( if err := row.Scan(
&dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online, &dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online,
&lastSeen, &createdAt, &updatedAt, &lastSeen, &createdAt, &updatedAt,
&dev.Locked, &until, &stateAt, &dev.Locked, &until, &grantUntil, &stateAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
dev.Online = online != 0 dev.Online = online != 0
dev.LastSeen = parseTimePtr(lastSeen) dev.LastSeen = parseTimePtr(lastSeen)
dev.Until = parseTimePtr(until) dev.Until = parseTimePtr(until)
dev.GrantUntil = parseTimePtr(grantUntil)
if t := parseTime(createdAt.String); !t.IsZero() { if t := parseTime(createdAt.String); !t.IsZero() {
dev.CreatedAt = t dev.CreatedAt = t
} }
@@ -268,11 +273,12 @@ func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO state (device_id, locked, until, updated_at) INSERT INTO state (device_id, locked, until, grant_until, updated_at)
VALUES (?, ?, ?, datetime('now')) VALUES (?, ?, ?, NULL, datetime('now'))
ON CONFLICT(device_id) DO UPDATE SET ON CONFLICT(device_id) DO UPDATE SET
locked = excluded.locked, locked = excluded.locked,
until = excluded.until, until = excluded.until,
grant_until = NULL,
updated_at = datetime('now')`, updated_at = datetime('now')`,
id, boolToInt(locked), untilArg); err != nil { id, boolToInt(locked), untilArg); err != nil {
return nil, fmt.Errorf("set state: %w", err) 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 { if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil {
return nil, err return nil, err
} }
if err := insertOverride(ctx, tx, id, actor, action, until); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit state: %w", err) return nil, fmt.Errorf("commit state: %w", err)
} }
return s.GetDevice(ctx, id) return s.GetDevice(ctx, id)
} }
// ExpireLocks unlocks every device whose timed lock has passed and appends one // ExpireLocks unlocks every device whose timed lock has passed and clears every
// audit entry per device. It returns how many locks it cleared. It is safe to // expired grant, appending one audit entry per device whose lock lifted. It
// call often. // returns how many locks it cleared. It is safe to call often.
func (s *Store) ExpireLocks(ctx context.Context) (int, error) { func (s *Store) ExpireLocks(ctx context.Context) (int, error) {
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
@@ -316,6 +325,14 @@ func (s *Store) ExpireLocks(ctx context.Context) (int, error) {
return 0, err 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 return len(ids), nil
} }

View File

@@ -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'))
);

233
internal/store/overrides.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -53,9 +53,13 @@ type EffectiveState struct {
DeviceID int64 DeviceID int64
Locked bool Locked bool
Until *time.Time Until *time.Time
// Reason is "manual" (a lock from the dashboard), "schedule" (an active // Reason is "manual" (a lock from the dashboard or the admin API),
// weekly window) or "" (not blocked). // "schedule" (an active weekly window), "grant" (a time-limited exemption)
// or "" (not blocked).
Reason string 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 and Block are the configured list values, always non-nil.
Allow []string Allow []string
Block []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 // EffectiveState computes the desired state for one device at now: a manual
// lock, an active schedule window, or neither. While blocked, the configured // lock, an active schedule window, a time-limited grant, or none of them.
// allowlist is honoured: the device blocks Block except Allow (FR-4). // 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 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 // 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) { if dev.Until != nil && schedUntil != nil && schedUntil.After(*dev.Until) {
eff.Until = schedUntil eff.Until = schedUntil
} }
case dev.GrantUntil != nil && dev.GrantUntil.After(now):
eff.Locked = false
eff.Reason = "grant"
eff.GrantUntil = dev.GrantUntil
case scheduled: case scheduled:
eff.Locked = true eff.Locked = true
eff.Reason = "schedule" eff.Reason = "schedule"
@@ -346,26 +359,30 @@ func (s *Store) NextScheduleBoundary(ctx context.Context, now time.Time) (*time.
} }
best := NextWindowBoundary(schedules, now) best := NextWindowBoundary(schedules, now)
// A manual lock that expires is a boundary too, so the loop lifts it from // A manual lock that expires and a grant that ends are boundaries too, so
// the device promptly instead of waiting for the next interval. // 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, 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 { if err != nil {
return nil, fmt.Errorf("next lock boundary: %w", err) return nil, fmt.Errorf("next lock boundary: %w", err)
} }
defer lockRows.Close() defer lockRows.Close()
for lockRows.Next() { for lockRows.Next() {
var until sql.NullString var until, grantUntil sql.NullString
if err := lockRows.Scan(&until); err != nil { if err := lockRows.Scan(&until, &grantUntil); err != nil {
return nil, fmt.Errorf("scan lock until: %w", err) return nil, fmt.Errorf("scan lock until: %w", err)
} }
t := parseTime(until.String) for _, ns := range []sql.NullString{until, grantUntil} {
if t.IsZero() || !t.After(now) { t := parseTime(ns.String)
continue if t.IsZero() || !t.After(now) {
} continue
if best == nil || t.Before(*best) { }
c := t if best == nil || t.Before(*best) {
best = &c c := t
best = &c
}
} }
} }
if err := lockRows.Err(); err != nil { if err := lockRows.Err(); err != nil {

514
internal/web/admin_api.go Normal file
View File

@@ -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 <token>".
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
}

View File

@@ -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)
}
}

View File

@@ -29,6 +29,7 @@ type deviceView struct {
Locked bool Locked bool
ManualLocked bool ManualLocked bool
Scheduled bool Scheduled bool
Granted bool
Until *time.Time Until *time.Time
UntilLocal string UntilLocal string
TimeLeft string TimeLeft string
@@ -81,9 +82,14 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState,
v.ReasonLabel = "by schedule" v.ReasonLabel = "by schedule"
case "manual": case "manual":
v.ReasonLabel = "manually" v.ReasonLabel = "manually"
case "grant":
v.Granted = true
v.ReasonLabel = "time granted"
} }
if eff.Locked { if eff.Locked {
v.StateLabel = "Locked" v.StateLabel = "Locked"
} else if v.Granted {
v.StateLabel = "Granted"
} else { } else {
v.StateLabel = "Unlocked" 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.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*eff.Until) 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 { if d.LastSeen != nil {
v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04") v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04")
v.HasLastSeen = true v.HasLastSeen = true
@@ -167,6 +179,18 @@ func auditLabel(action string) string {
return "Unlocked" return "Unlocked"
case "state.expired": case "state.expired":
return "Lock 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": case "schedule.updated":
return "Schedule saved" return "Schedule saved"
case "list.added": case "list.added":

View File

@@ -12,6 +12,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
@@ -33,6 +34,12 @@ type Server struct {
tmpl *template.Template tmpl *template.Template
log *slog.Logger log *slog.Logger
pub Publisher 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. // 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 { if err != nil {
return nil, fmt.Errorf("parse templates: %w", err) 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 // 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("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete))
mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage)) 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") staticSub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
panic("web: embedded static dir missing: " + err.Error()) panic("web: embedded static dir missing: " + err.Error())

View File

@@ -12,9 +12,9 @@
</header> </header>
<p class="device-card__state"> <p class="device-card__state">
{{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}}
</p> </p>
{{if .Until}}<p class="device-card__until">Lifts {{.UntilLocal}}</p>{{end}} {{if .Until}}<p class="device-card__until">{{if .Granted}}Grant ends{{else}}Lifts{{end}} {{.UntilLocal}}</p>{{end}}
<div class="device-card__actions"> <div class="device-card__actions">
<form method="post" action="/devices/{{.ID}}/lock" class="inline-form"> <form method="post" action="/devices/{{.ID}}/lock" class="inline-form">