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>
457 lines
15 KiB
Go
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)
|
|
}
|
|
}
|