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:
@@ -12,6 +12,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
|
||||
@@ -33,6 +34,12 @@ type Server struct {
|
||||
tmpl *template.Template
|
||||
log *slog.Logger
|
||||
pub Publisher
|
||||
|
||||
// keycodeMu guards the in-memory throttle for the device keycode endpoint
|
||||
// (admin_api.go). Failures are remembered per client address, so a wrong
|
||||
// code cannot be tried without limit.
|
||||
keycodeMu sync.Mutex
|
||||
keycode map[string]keycodeAttempts
|
||||
}
|
||||
|
||||
// Publisher pushes device config to the broker and reports the link's health.
|
||||
@@ -60,7 +67,15 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse templates: %w", err)
|
||||
}
|
||||
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, pub: pub}, nil
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
auth: am,
|
||||
tmpl: tmpl,
|
||||
log: log,
|
||||
pub: pub,
|
||||
keycode: make(map[string]keycodeAttempts),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// publishConfig pushes one device's desired state to the broker, retained. A
|
||||
@@ -103,6 +118,19 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete))
|
||||
mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage))
|
||||
|
||||
// Versioned admin API (Phase 8). Bearer-token auth, separate from the
|
||||
// session login. The Kotlin admin app calls these directly (AGENTS.md §8,
|
||||
// §11 Phase 8).
|
||||
api := "/api/" + apiVersion
|
||||
mux.HandleFunc("GET "+api+"/admin/devices", s.requireAPIToken(s.handleAPIListDevices))
|
||||
mux.HandleFunc("GET "+api+"/admin/devices/{id}", s.requireAPIToken(s.handleAPIState))
|
||||
mux.HandleFunc("POST "+api+"/admin/devices/{id}/lock", s.requireAPIToken(s.handleAPILock))
|
||||
mux.HandleFunc("POST "+api+"/admin/devices/{id}/unlock", s.requireAPIToken(s.handleAPIUnlock))
|
||||
mux.HandleFunc("POST "+api+"/admin/devices/{id}/grant", s.requireAPIToken(s.handleAPIGrant))
|
||||
// The on-device keycode path is not behind the admin token: a device does
|
||||
// not hold that secret. It is throttled and audited instead (admin_api.go).
|
||||
mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride)
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic("web: embedded static dir missing: " + err.Error())
|
||||
|
||||
Reference in New Issue
Block a user