// 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 ". 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 }