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:
514
internal/web/admin_api.go
Normal file
514
internal/web/admin_api.go
Normal 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
|
||||
}
|
||||
337
internal/web/admin_api_test.go
Normal file
337
internal/web/admin_api_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type deviceView struct {
|
||||
Locked bool
|
||||
ManualLocked bool
|
||||
Scheduled bool
|
||||
Granted bool
|
||||
Until *time.Time
|
||||
UntilLocal string
|
||||
TimeLeft string
|
||||
@@ -81,9 +82,14 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState,
|
||||
v.ReasonLabel = "by schedule"
|
||||
case "manual":
|
||||
v.ReasonLabel = "manually"
|
||||
case "grant":
|
||||
v.Granted = true
|
||||
v.ReasonLabel = "time granted"
|
||||
}
|
||||
if eff.Locked {
|
||||
v.StateLabel = "Locked"
|
||||
} else if v.Granted {
|
||||
v.StateLabel = "Granted"
|
||||
} else {
|
||||
v.StateLabel = "Unlocked"
|
||||
}
|
||||
@@ -92,6 +98,12 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState,
|
||||
v.UntilLocal = local.Format("Mon 15:04")
|
||||
v.TimeLeft = timeLeft(*eff.Until)
|
||||
}
|
||||
if eff.GrantUntil != nil {
|
||||
v.Until = eff.GrantUntil
|
||||
local := eff.GrantUntil.In(loc)
|
||||
v.UntilLocal = local.Format("Mon 15:04")
|
||||
v.TimeLeft = timeLeft(*eff.GrantUntil)
|
||||
}
|
||||
if d.LastSeen != nil {
|
||||
v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04")
|
||||
v.HasLastSeen = true
|
||||
@@ -167,6 +179,18 @@ func auditLabel(action string) string {
|
||||
return "Unlocked"
|
||||
case "state.expired":
|
||||
return "Lock expired"
|
||||
case "override.granted":
|
||||
return "Time granted"
|
||||
case "override.keycode":
|
||||
return "Keycode override"
|
||||
case "override.keycode_rejected":
|
||||
return "Keycode rejected"
|
||||
case "override.keycode_throttled":
|
||||
return "Keycode throttled"
|
||||
case "keycode.updated":
|
||||
return "Keycode changed"
|
||||
case "admin.token_rejected":
|
||||
return "Admin API rejected"
|
||||
case "schedule.updated":
|
||||
return "Schedule saved"
|
||||
case "list.added":
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
</header>
|
||||
|
||||
<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>
|
||||
{{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">
|
||||
<form method="post" action="/devices/{{.ID}}/lock" class="inline-form">
|
||||
|
||||
Reference in New Issue
Block a user