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

@@ -7,10 +7,12 @@ package main
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"os"
"os/signal"
@@ -58,6 +60,9 @@ func main() {
case "mqtt-clients":
mqttClients(os.Args[2:])
return
case "keycode":
keycode(os.Args[2:])
return
case "rollup":
rollup(os.Args[2:])
return
@@ -107,6 +112,16 @@ func run() error {
log.Info("audit log capped", "removed", n, "max", auditMaxRows)
}
// Phase 8: seed the shared device keycode from environment.d. The stored
// value is the runtime copy that `control keycode` can change; the
// environment is the deploy-time source. A malformed hash stops start-up
// rather than silently disabling the keycode path.
if seeded, err := st.SeedKeycodeHash(context.Background(), cfg.DeviceKeycodeHash); err != nil {
return err
} else if seeded {
log.Info("device keycode seeded from CONTROL_DEVICE_KEYCODE_HASH")
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)
if err != nil {
@@ -365,6 +380,77 @@ func env(key, fallback string) string {
return fallback
}
// keycode sets the shared device keycode (AGENTS.md §10 rule 3). It reads the
// code from a positional argument or stdin, or generates a random six-digit
// code with --generate, and stores only its bcrypt hash in SQLite. The code
// itself is printed once, and only when it was generated here, so it can be set
// on each device; it is never logged by the running service.
func keycode(args []string) {
dbPath := env("CONTROL_DB_PATH", "/data/control.db")
generate := false
code := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--db", "-db":
if i+1 < len(args) {
dbPath = args[i+1]
i++
}
case "--generate", "-generate":
generate = true
default:
if code == "" {
code = args[i]
}
}
}
if generate {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
code = fmt.Sprintf("%06d", n.Int64())
} else if code == "" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
code = strings.TrimSpace(string(b))
}
if len(code) < 4 || len(code) > 64 {
fmt.Fprintln(os.Stderr, "control: keycode must be 4 to 64 characters")
os.Exit(2)
}
st, err := store.Open(dbPath)
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
if err := st.SetKeycodeHash(context.Background(), string(hash), "admin"); err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
if generate {
fmt.Printf("device keycode set: %s\n", code)
return
}
fmt.Println("device keycode updated")
}
// hashPassword prints a bcrypt hash for the password given as an argument or
// read from stdin. It never prints the password itself.
func hashPassword(args []string) {