Files
Sam Rolfe 3cf353d933 Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)
Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.

- hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a
  deterministic, read-only Answerer. It answers screen time today/yesterday,
  what is blocked now, and device presence, scoped to one child or all kids.
  Screen time is the foreground-app total per device-day; the domain total is
  used only when a device reports no apps, so a report carrying both is never
  double-counted.
- apprise: internal/apprise is a small Apprise API client (stored key or
  stateless URL mode, optional bearer token, typed notifications).
- alert: internal/alert evaluates device state on an interval and raises one
  Apprise alert per transition: a limit hit (a device becomes blocked) or a
  device offline longer than the threshold. A never-seen device is never
  alerted, and the first observation seeds state without replaying an old
  limit. The alert is recorded before delivery, with its delivery outcome.
- store: migration 005 adds `alerts` (delivery outcome, audited) and
  `alert_state` (per-device dedup). New usage reads from the telemetry detail
  table so the daily answer can be checked independently.
- web: GET /capabilities, POST /ask (read-only) and POST /notify behind the
  shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens.
  /notify accepts the contract's {from,level,message,person} and delivers it
  through Apprise (the future tamper-report path, FR-17).
- cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED,
  CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and
  `usage` subcommands for read-only inspection.
- deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done;
  version 0.7.0-phase9.

Measured on .13 (2026-10-13):
- GET /capabilities -> internet_use_control topics.
- POST /ask "how long have the kids been on screens today?" -> "The kids have
  had 1h 22m of screen time today." (total_seconds=4925); the independent
  count from the telemetry detail table is also 4925 (app=4925, domain=4800).
- A manual lock raised a delivered limit alert (id 4); an MQTT offline status
  raised a delivered offline alert (id 5); POST /notify delivered (id 6). All
  three report delivered=1 in `alerts`.

Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:41:07 +11:00

457 lines
15 KiB
Go

// Package web is the HTTP surface of the Control Service: /health, the session
// login, and the server-rendered HTMX dashboard (AGENTS.md §5, §9).
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
"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/hub"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
//go:embed templates/*.html templates/partials/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// Server holds the dependencies for the HTTP handlers.
type Server struct {
cfg *config.Config
store *store.Store
auth *auth.Manager
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
// The Hub contract surface (Phase 9): the read-only answerer and the
// Apprise client that delivers alerts and accepted notifications.
answerer *hub.Answerer
apprise *apprise.Client
}
// Publisher pushes device config to the broker and reports the link's health.
// Only the Control Service publishes config (AGENTS.md §10 rule 5). It is nil
// when MQTT is switched off, and every call site tolerates that.
type Publisher interface {
Broker() string
Connected() bool
Stats() (received, published int64, lastMessageAt time.Time, lastErr string)
PublishDeviceConfig(ctx context.Context, dev store.Device) error
PublishAll(ctx context.Context) (int, error)
}
// New builds a Server and parses the embedded templates. pub may be nil.
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger, pub Publisher) (*Server, error) {
funcs := template.FuncMap{
"year": func() int { return time.Now().Year() },
"now": func() time.Time { return time.Now() },
"clock": func() string {
return time.Now().Format("15:04:05 MST")
},
}
tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/*.html", "templates/partials/*.html")
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,
keycode: make(map[string]keycodeAttempts),
answerer: hub.NewAnswerer(st, cfg.ScheduleLocation),
apprise: apprise.New(cfg.AppriseURL, cfg.AppriseKey, cfg.AppriseURLs, cfg.AppriseToken, 0),
}, nil
}
// Apprise exposes the delivery client so the alert engine (cmd/control) can
// share the one configured destination with the /notify handler.
func (s *Server) Apprise() *apprise.Client { return s.apprise }
// publishConfig pushes one device's desired state to the broker, retained. A
// publish failure never fails the HTTP request: SQLite already holds the new
// state, and the link republishes everything when it reconnects, so the broker
// catches up on its own.
func (s *Server) publishConfig(ctx context.Context, dev store.Device) {
if s.pub == nil {
s.log.Debug("mqtt disabled; config kept in SQLite only", "device", dev.Name)
return
}
if err := s.pub.PublishDeviceConfig(ctx, dev); err != nil {
s.log.Error("mqtt publish config", "device", dev.Name, "topic", dev.Topic, "err", err)
return
}
s.log.Info("mqtt config published", "device", dev.Name, "topic", dev.Topic,
"manual_locked", dev.Locked)
}
// Handler returns the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/status", s.requireAuth(s.handleStatusPartial))
mux.HandleFunc("GET /devices/new", s.requireAuth(s.handleDeviceNew))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleDeviceCreate))
mux.HandleFunc("GET /devices/{id}/edit", s.requireAuth(s.handleDeviceEdit))
mux.HandleFunc("POST /devices/{id}", s.requireAuth(s.handleDeviceUpdate))
mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock))
mux.HandleFunc("POST /devices/{id}/unlock", s.requireAuth(s.handleDeviceUnlock))
mux.HandleFunc("GET /devices/{id}/schedule", s.requireAuth(s.handleScheduleEditor))
mux.HandleFunc("POST /devices/{id}/schedule", s.requireAuth(s.handleScheduleSave))
mux.HandleFunc("GET /devices/{id}/lists", s.requireAuth(s.handleListsEditor))
mux.HandleFunc("POST /devices/{id}/lists", s.requireAuth(s.handleListAdd))
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)
// The Hub contract (Phase 9, FR-12/FR-13). Bearer-token auth, separate
// from the dashboard session and the admin API token. /ask is read-only;
// /notify records and delivers a message through Apprise.
mux.HandleFunc("GET /capabilities", s.requireHub(s.handleCapabilities))
mux.HandleFunc("POST /ask", s.requireHub(s.handleAsk))
mux.HandleFunc("POST /notify", s.requireHub(s.handleNotify))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
return s.recoverer(s.securityHeaders(s.requestLogger(mux)))
}
// ---------- middleware ----------
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (s *Server) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"remote", r.RemoteAddr,
"duration", time.Since(start).String(),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.auth.Authenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// ---------- handlers ----------
type healthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Database string `json:"database"`
Time string `json:"time"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Service: "control-service",
Version: s.cfg.Version,
Database: "ok",
Time: time.Now().UTC().Format(time.RFC3339),
}
code := http.StatusOK
if err := s.store.Ping(r.Context()); err != nil {
resp.Status = "error"
resp.Database = "error"
code = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(resp)
}
type loginData struct {
CSRF string
Error string
Username string
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.auth.Authenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login.html", loginData{CSRF: s.auth.EnsureCSRF(w, r)})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if !s.auth.CheckCSRF(r) {
s.log.Warn("login rejected: bad csrf token", "remote", r.RemoteAddr)
_ = s.store.Audit(r.Context(), username, "login.csrf_rejected", nil, r.RemoteAddr)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Session expired. Please try again.",
Username: username,
})
return
}
if !s.auth.CheckCredentials(username, r.PostFormValue("password")) {
s.log.Warn("login failed", "user", username, "remote", r.RemoteAddr)
_ = s.store.Audit(r.Context(), username, "login.failed", nil, r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Incorrect username or password.",
Username: username,
})
return
}
s.auth.Issue(w)
_ = s.store.Audit(r.Context(), username, "login.success", nil, r.RemoteAddr)
s.log.Info("login success", "user", username, "remote", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if s.auth.Authenticated(r) && s.auth.CheckCSRF(r) {
_ = s.store.Audit(r.Context(), s.cfg.AdminUsername, "logout", nil, r.RemoteAddr)
}
s.auth.Clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type statusData struct {
DBHealthy bool
DBPath string
ServerTime string
ServerUp string
// MQTT link health (Phase 3).
MQTTEnabled bool
MQTTConnected bool
MQTTBroker string
MQTTReceived int64
MQTTPublished int64
MQTTLastMessage string
MQTTLastError string
}
type dashboardData struct {
statusData
Title string
Username string
CSRF string
Version string
AppEnv string
Devices []deviceView
Audit []auditView
Flash string
ErrMsg string
}
var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if n, err := s.store.ExpireLocks(ctx); err != nil {
s.log.Error("expire locks", "err", err)
} else if n > 0 {
s.log.Info("expired timed locks", "count", n)
// The lock lifted, so the retained config is stale. Republish.
if s.pub != nil {
if _, err := s.pub.PublishAll(ctx); err != nil {
s.log.Error("mqtt republish after expiry", "err", err)
}
}
}
devs, err := s.store.ListDevices(ctx)
if err != nil {
s.log.Error("list devices", "err", err)
}
audit, err := s.store.ListAudit(ctx, 20)
if err != nil {
s.log.Error("list audit", "err", err)
}
s.render(w, r, "dashboard.html", dashboardData{
statusData: s.buildStatusData(ctx),
Title: "Dashboard",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Devices: buildDeviceViews(devs, s.effectiveStates(ctx, devs), s.loc()),
Audit: buildAuditViews(audit, s.loc()),
Flash: strings.TrimSpace(r.URL.Query().Get("ok")),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
})
}
func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
s.renderFragment(w, r, "status.html", s.buildStatusData(ctx))
}
// effectiveStates computes the desired state for every device at the same
// instant, so one dashboard load is internally consistent.
func (s *Server) effectiveStates(ctx context.Context, devs []store.Device) map[int64]store.EffectiveState {
out := make(map[int64]store.EffectiveState, len(devs))
now := s.now()
for _, d := range devs {
eff, err := s.store.EffectiveState(ctx, d, now)
if err != nil {
s.log.Error("effective state", "device", d.Name, "err", err)
continue
}
out[d.ID] = eff
}
return out
}
// now is the current time in the schedule timezone, so weekly windows are
// evaluated wall-clock and not in the container's default UTC.
func (s *Server) now() time.Time {
return time.Now().In(s.loc())
}
// loc is the schedule timezone, defaulting to the process local zone.
func (s *Server) loc() *time.Location {
if s.cfg.ScheduleLocation != nil {
return s.cfg.ScheduleLocation
}
return time.Local
}
func (s *Server) buildStatusData(ctx context.Context) statusData {
data := statusData{
DBHealthy: s.store.Ping(ctx) == nil,
DBPath: s.cfg.DBPath,
ServerTime: time.Now().In(s.loc()).Format("Mon 02 Jan 2006 15:04:05"),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
}
if s.pub != nil {
data.MQTTEnabled = true
data.MQTTConnected = s.pub.Connected()
data.MQTTBroker = s.pub.Broker()
rx, tx, last, lastErr := s.pub.Stats()
data.MQTTReceived = rx
data.MQTTPublished = tx
data.MQTTLastError = lastErr
if !last.IsZero() {
data.MQTTLastMessage = last.Local().Format("15:04:05")
}
}
return data
}
func (s *Server) render(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render fragment", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}