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

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
}