Files
Sam Rolfe e018c3bfa8 Phase 5.1: kids' Android client (Accessibility block overlay + tamper alert)
Add android/, a normal Gradle/Kotlin project with its own wrapper. It
blocks by interruption: an Accessibility Service watches the foreground
package and a full-screen overlay covers any listed app while the device
is locked (FR-16). No Device Owner, no factory reset, no managed profile.

The client subscribes to the retained restrict/<device>/config topic on
the 8883 TLS plane, keeps the last config on disk so a broker-less device
keeps its rules (FR-14), and reports tampering on its own status topic
(FR-17): a disabled service, a revoked overlay permission, or a
force-stop seen on the next start. The Control Service now parses the
tamper field and raises an Apprise alert, so it reaches Sam.

The block path and the tamper alert are proven on the headless pixel35
emulator on .27; see android/README.md and android/tools/emu-proof.sh.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 20:17:22 +11:00

451 lines
14 KiB
Go

// The broker link: connect, subscribe to device reports, publish retained
// config. Everything that reaches SQLite is handled here (AGENTS.md §11
// Phase 3).
package mqtt
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// DeviceLookup finds a device from a topic it wrote to.
type DeviceLookup interface {
DeviceByConfigTopic(ctx context.Context, topic string) (*store.Device, error)
}
// ReportSink stores what a device reported.
type ReportSink interface {
SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error)
InsertTelemetry(ctx context.Context, id int64, day string, entries []store.TelemetryEntry) (int, error)
}
// ConfigSource reads the desired state that is published to devices.
// EffectiveState combines the manual lock, the weekly schedule and the
// allow/block lists (Phase 6).
type ConfigSource interface {
ListDevices(ctx context.Context) ([]store.Device, error)
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
}
// Options configures the link.
type Options struct {
// Broker is a URL such as "tls://mosquitto.lab:8883" or, until the shared
// broker gets its TLS listener, "tcp://host.docker.internal:1883".
Broker string
// ClientID identifies the Control Service on the broker.
ClientID string
// Username and Password are the Control Service credentials. The broker's
// ACL gives this identity write access to every config topic and read
// access to every status and telemetry topic — and nothing else.
Username string
Password string
// CAFile is a PEM bundle used to verify the broker certificate.
CAFile string
// KeepAlive is the MQTT keepalive.
KeepAlive time.Duration
// ConnectTimeout bounds one connection attempt.
ConnectTimeout time.Duration
// Actor is the audit-log actor for reports that arrive over the broker.
Actor string
// Location is the schedule timezone. Weekly windows are wall-clock, so the
// service must evaluate them in the family's zone, not the container's
// (usually UTC). Nil means time.Local.
Location *time.Location
// Log receives structured logs.
Log *slog.Logger
// Devices and Reports are required.
Devices DeviceLookup
// Source is required to publish config.
Source ConfigSource
// Reports is required to store device reports.
Reports ReportSink
// OnTamper, when set, is called for every status payload that carries a
// tamper report (FR-17). It runs on the broker's callback goroutine, so it
// must return quickly; the service uses it to raise one Apprise alert.
OnTamper func(ctx context.Context, dev store.Device, tamper Tamper)
// OnConnected runs after every successful (re)connect. The service uses it
// to republish every device's retained config.
OnConnected func()
}
// Link is the live connection to the broker.
type Link struct {
opts Options
log *slog.Logger
client paho.Client
connected atomic.Bool
received atomic.Int64
published atomic.Int64
lastMsg atomic.Int64 // unix nanos
lastErrMu sync.Mutex
lastErrMsg string
}
// New builds the link. It does not connect; call Start.
func New(opts Options) (*Link, error) {
if opts.Devices == nil || opts.Source == nil || opts.Reports == nil {
return nil, errors.New("mqtt: Devices, Source and Reports are required")
}
if strings.TrimSpace(opts.Broker) == "" {
return nil, errors.New("mqtt: Broker is required")
}
if opts.ClientID == "" {
opts.ClientID = "control-service"
}
if opts.KeepAlive <= 0 {
opts.KeepAlive = 30 * time.Second
}
if opts.ConnectTimeout <= 0 {
opts.ConnectTimeout = 10 * time.Second
}
if opts.Actor == "" {
opts.Actor = "device"
}
if opts.Log == nil {
opts.Log = slog.Default()
}
l := &Link{opts: opts, log: opts.Log}
clientOpts := paho.NewClientOptions()
clientOpts.AddBroker(opts.Broker)
clientOpts.SetClientID(opts.ClientID)
clientOpts.SetUsername(opts.Username)
clientOpts.SetPassword(opts.Password)
clientOpts.SetKeepAlive(opts.KeepAlive)
clientOpts.SetPingTimeout(min(10*time.Second, opts.KeepAlive))
clientOpts.SetConnectTimeout(opts.ConnectTimeout)
clientOpts.SetWriteTimeout(opts.ConnectTimeout)
// The service is a publisher of retained truth and a listener for reports:
// reconnect forever, and re-subscribe on every connect.
clientOpts.SetAutoReconnect(true)
clientOpts.SetConnectRetry(true)
clientOpts.SetConnectRetryInterval(5 * time.Second)
clientOpts.SetMaxReconnectInterval(60 * time.Second)
// Clean session: the broker keeps the retained config, so the service does
// not need a queue of stale reports.
clientOpts.SetCleanSession(true)
clientOpts.SetOrderMatters(false)
if isTLSBroker(opts.Broker) {
tlsCfg, err := tlsConfig(opts.Broker, opts.CAFile)
if err != nil {
return nil, err
}
clientOpts.SetTLSConfig(tlsCfg)
}
clientOpts.SetOnConnectHandler(l.handleConnect)
clientOpts.SetConnectionLostHandler(func(_ paho.Client, err error) {
l.connected.Store(false)
l.setErr(err)
l.log.Warn("mqtt connection lost", "broker", opts.Broker, "err", err)
})
l.client = paho.NewClient(clientOpts)
return l, nil
}
// Start connects in the background. A broker outage must never stop the HTTP
// service: the dashboard keeps working and the link reconnects on its own.
func (l *Link) Start(ctx context.Context) {
l.log.Info("mqtt connecting", "broker", l.opts.Broker, "client_id", l.opts.ClientID,
"tls", isTLSBroker(l.opts.Broker), "username", l.opts.Username)
go func() {
if token := l.client.Connect(); token.Wait() && token.Error() != nil {
l.setErr(token.Error())
l.log.Error("mqtt connect", "err", token.Error())
}
}()
}
// Stop disconnects cleanly. The broker then publishes nothing on our behalf;
// the service's own will is not registered, because only the service publishes
// config and a stale config must survive a service restart.
func (l *Link) Stop() {
if l.client == nil {
return
}
if l.client.IsConnected() {
l.client.Unsubscribe(StatusSubscription, TelemetrySubscription)
}
l.client.Disconnect(250)
l.connected.Store(false)
}
// Broker returns the configured broker URL.
func (l *Link) Broker() string { return l.opts.Broker }
// Connected reports the current connection state.
func (l *Link) Connected() bool { return l.connected.Load() }
// Stats returns counters for the dashboard.
func (l *Link) Stats() (received, published int64, lastMessageAt time.Time, lastErr string) {
ns := l.lastMsg.Load()
if ns > 0 {
lastMessageAt = time.Unix(0, ns)
}
l.lastErrMu.Lock()
lastErr = l.lastErrMsg
l.lastErrMu.Unlock()
return l.received.Load(), l.published.Load(), lastMessageAt, lastErr
}
func (l *Link) setErr(err error) {
if err == nil {
return
}
l.lastErrMu.Lock()
l.lastErrMsg = err.Error()
l.lastErrMu.Unlock()
}
// handleConnect runs on every (re)connect, including the first one.
func (l *Link) handleConnect(c paho.Client) {
l.connected.Store(true)
l.setErr(nil)
l.log.Info("mqtt connected", "broker", l.opts.Broker)
for _, sub := range []struct {
topic string
handler paho.MessageHandler
}{
{StatusSubscription, l.handleStatusMessage},
{TelemetrySubscription, l.handleTelemetryMessage},
} {
token := c.Subscribe(sub.topic, 1, sub.handler)
if !token.WaitTimeout(l.opts.ConnectTimeout) {
l.log.Warn("mqtt subscribe timed out", "topic", sub.topic)
continue
}
if err := token.Error(); err != nil {
l.setErr(err)
l.log.Error("mqtt subscribe", "topic", sub.topic, "err", err)
continue
}
l.log.Info("mqtt subscribed", "topic", sub.topic)
}
if l.opts.OnConnected != nil {
l.opts.OnConnected()
}
}
// handleStatusMessage turns a device presence report into a store update.
func (l *Link) handleStatusMessage(_ paho.Client, msg paho.Message) {
l.noteMessage()
configTopic, ok := deviceFromTopic(msg.Topic(), SuffixStatus)
if !ok {
l.log.Warn("mqtt status on an unexpected topic", "topic", msg.Topic())
return
}
status, err := ParseStatus(msg.Payload())
if err != nil {
l.log.Warn("mqtt status rejected", "topic", msg.Topic(), "err", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dev, err := l.opts.Devices.DeviceByConfigTopic(ctx, configTopic)
if err != nil {
l.log.Warn("mqtt status for an unknown device", "topic", msg.Topic(), "err", err)
return
}
online := status.IsOnline()
changed, err := l.opts.Reports.SetPresence(ctx, dev.ID, online, status.Timestamp(time.Now()), l.opts.Actor)
if err != nil {
l.log.Error("mqtt presence", "device", dev.Name, "err", err)
return
}
if changed {
l.log.Info("presence changed", "device", dev.Name, "topic", msg.Topic(),
"online", online, "retained", msg.Retained())
}
if status.HasTamper() {
t := *status.Tamper
l.log.Warn("device tamper reported", "device", dev.Name, "topic", msg.Topic(),
"kind", t.Kind, "detail", t.Detail)
if l.opts.OnTamper != nil {
l.opts.OnTamper(ctx, *dev, t)
}
}
}
// handleTelemetryMessage stores a device usage report.
func (l *Link) handleTelemetryMessage(_ paho.Client, msg paho.Message) {
l.noteMessage()
configTopic, ok := deviceFromTopic(msg.Topic(), SuffixTelemetry)
if !ok {
l.log.Warn("mqtt telemetry on an unexpected topic", "topic", msg.Topic())
return
}
report, err := ParseTelemetry(msg.Payload())
if err != nil {
l.log.Warn("mqtt telemetry rejected", "topic", msg.Topic(), "err", err)
return
}
rows := report.Rows()
if len(rows) == 0 {
l.log.Warn("mqtt telemetry had no usable rows", "topic", msg.Topic())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dev, err := l.opts.Devices.DeviceByConfigTopic(ctx, configTopic)
if err != nil {
l.log.Warn("mqtt telemetry for an unknown device", "topic", msg.Topic(), "err", err)
return
}
day := report.DayOrDefault(time.Now())
n, err := l.opts.Reports.InsertTelemetry(ctx, dev.ID, day, rows)
if err != nil {
l.log.Error("mqtt telemetry", "device", dev.Name, "err", err)
return
}
l.log.Info("telemetry stored", "device", dev.Name, "day", day, "rows", n)
}
func (l *Link) noteMessage() {
l.received.Add(1)
l.lastMsg.Store(time.Now().UnixNano())
}
// PublishDeviceConfig publishes one device's desired state, retained, on the
// topic stored with the device. It is the single writer path for config
// (AGENTS.md §10 rule 5): only the Control Service publishes.
func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error {
payload, err := l.buildConfig(ctx, dev)
if err != nil {
return err
}
return l.Publish(dev.Topic, payload)
}
// buildConfig computes the effective desired state for one device at the current
// time and renders the retained payload. It is separated from the publish so it
// can be tested without a broker.
func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error) {
eff, err := l.opts.Source.EffectiveState(ctx, dev, time.Now().In(l.location()))
if err != nil {
return nil, err
}
return Config{
V: ConfigVersion,
DeviceID: dev.ID,
Name: dev.Name,
Platform: dev.Platform,
Locked: eff.Locked,
Until: eff.Until,
Allow: eff.Allow,
Block: eff.Block,
Reason: eff.Reason,
GrantUntil: eff.GrantUntil,
GeneratedAt: time.Now().UTC(),
}.Marshal()
}
// location returns the schedule timezone, defaulting to the process local zone.
func (l *Link) location() *time.Location {
if l.opts.Location != nil {
return l.opts.Location
}
return time.Local
}
// Publish sends one retained message and waits for the broker to accept it.
func (l *Link) Publish(topic string, payload []byte) error {
if !l.client.IsConnected() {
return errors.New("mqtt: not connected")
}
token := l.client.Publish(topic, 1, true, payload)
if !token.WaitTimeout(l.opts.ConnectTimeout) {
return fmt.Errorf("mqtt: publish to %s timed out", topic)
}
if err := token.Error(); err != nil {
return fmt.Errorf("mqtt: publish to %s: %w", topic, err)
}
l.published.Add(1)
return nil
}
// PublishAll republishes every device's retained config. It runs after every
// connect, so a broker that lost its retained store is repaired from SQLite —
// the database stays the source of truth.
func (l *Link) PublishAll(ctx context.Context) (int, error) {
devices, err := l.opts.Source.ListDevices(ctx)
if err != nil {
return 0, err
}
published := 0
var firstErr error
for _, dev := range devices {
if err := l.PublishDeviceConfig(ctx, dev); err != nil {
l.log.Error("mqtt publish config", "device", dev.Name, "err", err)
if firstErr == nil {
firstErr = err
}
continue
}
published++
}
return published, firstErr
}
// isTLSBroker reports whether the broker URL asks for TLS.
func isTLSBroker(broker string) bool {
return strings.HasPrefix(broker, "tls://") || strings.HasPrefix(broker, "ssl://")
}
// tlsConfig verifies the broker against the lab CA. Every device and the
// Control Service use the same CA, so a rogue broker on the LAN cannot take
// over a device's rules.
func tlsConfig(broker, caFile string) (*tls.Config, error) {
if caFile == "" {
return nil, errors.New("mqtt: a TLS broker needs CONTROL_MQTT_CA_FILE")
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("mqtt: read CA %s: %w", caFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("mqtt: no certificates in %s", caFile)
}
cfg := &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
if u, err := url.Parse(broker); err == nil && u.Hostname() != "" {
cfg.ServerName = u.Hostname()
}
return cfg, nil
}