Files
internet_use_control/internal/web/devices.go
Sam Rolfe 14b304e0b0 Phase 3: MQTT transport — retained config, status/telemetry, LWT presence, TLS/ACL design
- internal/mqtt: publish each device's desired state retained on
  restrict/<device>/config; subscribe to restrict/+/status and
  restrict/+/telemetry and write reports to SQLite; publish on every state
  change, republish on connect and every 15 minutes from SQLite, so the broker
  is repaired from the source of truth.
- store: presence (devices.online and last_seen, audited only on transitions),
  telemetry upsert per (device, day, app, domain) via migration 002, allow and
  block lists read into the config payload, device lookup by topic.
- config: MQTT settings. TLS plus per-device credentials are required; the
  pre-TLS shared broker needs an explicit CONTROL_MQTT_INSECURE escape hatch.
- web: republish config after lock, unlock, expiry, create and edit; broker card
  on the status partial.
- deploy/mosquitto: TLS listener, topic ACL, credential provisioning and a
  verifier that proves the policy on a throwaway broker. Not applied to the
  shared Mosquitto on .13 — that change needs Sam's approval.
- cmd/control: mqtt-clients prints the MQTT identity of every device.
- paho.mqtt.golang v1.5.1, which pulls golang.org/x/net and with it the
  golang.org/x/crypto bump to v0.42.0.

Verified on .13: retained config delivered to a fresh subscriber; a client
killed without DISCONNECT drove the broker's retained will and the service
flipped the device offline; telemetry landed in SQLite; make mqtt-verify passed
13/13; the service connected over TLS with credentials and a CA.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 14:37:43 +11:00

404 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Device registry and manual lock/unlock handlers (AGENTS.md §5 screens 2–3,
// §11 Phase 2). Only the Control Service writes device state; the dashboard is
// the admin surface.
package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// deviceView is the display model for one dashboard card.
type deviceView struct {
ID int64
Name string
Child string
Platform string
PlatformLabel string
Topic string
Online bool
Locked bool
Until *time.Time
UntilLocal string
TimeLeft string
StateLabel string
LastSeen string
HasLastSeen bool
}
// auditView is the display model for one audit row.
type auditView struct {
Action string
ActionLabel string
Actor string
DeviceName string
Detail string
CreatedAtLocal string
}
func buildDeviceViews(devs []store.Device) []deviceView {
out := make([]deviceView, 0, len(devs))
for _, d := range devs {
v := deviceView{
ID: d.ID,
Name: d.Name,
Child: d.Child,
Platform: d.Platform,
PlatformLabel: platformLabel(d.Platform),
Topic: d.Topic,
Online: d.Online,
Locked: d.Locked,
Until: d.Until,
}
if d.Locked {
v.StateLabel = "Locked"
} else {
v.StateLabel = "Unlocked"
}
if d.Until != nil {
local := d.Until.Local()
v.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*d.Until)
}
if d.LastSeen != nil {
v.LastSeen = d.LastSeen.Local().Format("Mon 02 Jan 15:04")
v.HasLastSeen = true
}
out = append(out, v)
}
return out
}
func buildAuditViews(entries []store.AuditEntry) []auditView {
out := make([]auditView, 0, len(entries))
for _, e := range entries {
v := auditView{
Action: e.Action,
ActionLabel: auditLabel(e.Action),
Actor: e.Actor,
DeviceName: e.DeviceName,
Detail: e.Detail,
}
if !e.CreatedAt.IsZero() {
v.CreatedAtLocal = e.CreatedAt.Local().Format("02 Jan 15:04:05")
}
out = append(out, v)
}
return out
}
func platformLabel(p string) string {
switch p {
case store.PlatformAndroid:
return "Android"
case store.PlatformWindows:
return "Windows"
default:
return p
}
}
// timeLeft renders how long remains on a timed lock.
func timeLeft(until time.Time) string {
d := time.Until(until)
if d <= 0 {
return "lifting now"
}
d = d.Round(time.Minute)
if d < time.Minute {
return "under a minute left"
}
h := int(d.Hours())
m := int(d.Minutes()) % 60
switch {
case h == 0:
return fmt.Sprintf("%d min left", m)
case m == 0:
return fmt.Sprintf("%dh left", h)
default:
return fmt.Sprintf("%dh %dm left", h, m)
}
}
func auditLabel(action string) string {
switch action {
case "device.created":
return "Device added"
case "device.updated":
return "Device edited"
case "state.locked":
return "Locked"
case "state.unlocked":
return "Unlocked"
case "state.expired":
return "Lock expired"
case "device.online":
return "Device online"
case "device.offline":
return "Device offline"
case "login.success":
return "Signed in"
case "login.failed":
return "Sign-in failed"
case "login.csrf_rejected":
return "Sign-in rejected"
case "logout":
return "Signed out"
default:
return action
}
}
// ---------- dashboard data ----------
type deviceFormData struct {
Title string
Heading string
Sub string
Username string
CSRF string
Version string
AppEnv string
FormAction string
Name string
Child string
Platform string
Error string
}
// ---------- handlers ----------
func (s *Server) handleDeviceNew(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "device-form.html", deviceFormData{
Title: "Add device",
Heading: "Add a device",
Sub: "Name the device, assign it to a child, and choose its platform.",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: "/devices",
Platform: store.PlatformAndroid,
})
}
func (s *Server) handleDeviceCreate(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PostFormValue("name")
child := r.PostFormValue("child")
platform := r.PostFormValue("platform")
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.CreateDevice(ctx, name, child, platform, s.cfg.AdminUsername)
if err != nil {
s.log.Warn("device create rejected", "err", err, "remote", r.RemoteAddr)
s.render(w, r, "device-form.html", deviceFormData{
Title: "Add device",
Heading: "Add a device",
Sub: "Name the device, assign it to a child, and choose its platform.",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: "/devices",
Name: strings.TrimSpace(name),
Child: strings.TrimSpace(child),
Platform: strings.ToLower(strings.TrimSpace(platform)),
Error: err.Error(),
})
return
}
s.log.Info("device created", "id", dev.ID, "name", dev.Name, "topic", dev.Topic)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, "/?ok="+url.QueryEscape("Added "+dev.Name), http.StatusSeeOther)
}
func (s *Server) handleDeviceEdit(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.render(w, r, "device-form.html", deviceFormData{
Title: "Edit " + dev.Name,
Heading: "Edit device",
Sub: fmt.Sprintf("%s · %s", platformLabel(dev.Platform), dev.Topic),
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: fmt.Sprintf("/devices/%d", dev.ID),
Name: dev.Name,
Child: dev.Child,
Platform: dev.Platform,
})
}
func (s *Server) handleDeviceUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PostFormValue("name")
child := r.PostFormValue("child")
platform := r.PostFormValue("platform")
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.UpdateDevice(ctx, id, name, child, platform, s.cfg.AdminUsername)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
s.deviceLookupError(w, r, err)
return
}
s.render(w, r, "device-form.html", deviceFormData{
Title: "Edit device",
Heading: "Edit device",
Sub: "Correct the details and save again.",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: fmt.Sprintf("/devices/%d", id),
Name: strings.TrimSpace(name),
Child: strings.TrimSpace(child),
Platform: strings.ToLower(strings.TrimSpace(platform)),
Error: err.Error(),
})
return
}
s.log.Info("device updated", "id", dev.ID, "name", dev.Name)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, "/?ok="+url.QueryEscape("Saved "+dev.Name), http.StatusSeeOther)
}
func (s *Server) handleDeviceLock(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
minutes, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("minutes")))
if minutes < 0 || minutes > 24*60 {
minutes = 0
}
var until *time.Time
if minutes > 0 {
t := time.Now().Add(time.Duration(minutes) * time.Minute).UTC()
until = &t
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.SetDeviceLock(ctx, id, true, until, s.cfg.AdminUsername, "state.locked")
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.log.Info("device locked", "id", dev.ID, "name", dev.Name, "minutes", minutes)
s.publishConfig(ctx, *dev)
msg := "Locked " + dev.Name
if minutes > 0 {
msg = fmt.Sprintf("Locked %s for %d min", dev.Name, minutes)
}
http.Redirect(w, r, "/?ok="+url.QueryEscape(msg), http.StatusSeeOther)
}
func (s *Server) handleDeviceUnlock(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.SetDeviceLock(ctx, id, false, nil, s.cfg.AdminUsername, "state.unlocked")
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.log.Info("device unlocked", "id", dev.ID, "name", dev.Name)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, "/?ok="+url.QueryEscape("Unlocked "+dev.Name), http.StatusSeeOther)
}
// ---------- helpers ----------
func (s *Server) deviceID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
func (s *Server) deviceLookupError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
s.log.Error("device handler", "err", err, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func (s *Server) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return false
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("rejected: bad csrf token", "path", r.URL.Path, "remote", r.RemoteAddr)
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return false
}
return true
}