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>
This commit is contained in:
@@ -37,6 +37,25 @@ type Config struct {
|
||||
AppEnv string
|
||||
// Version is injected at build time.
|
||||
Version string
|
||||
|
||||
// MQTTEnabled turns the broker link on. When false the service runs
|
||||
// HTTP-only and never touches the broker.
|
||||
MQTTEnabled bool
|
||||
// MQTTBroker is the broker URL, for example "tls://mosquitto:8883" or
|
||||
// "tcp://127.0.0.1:1883".
|
||||
MQTTBroker string
|
||||
// MQTTClientID is the MQTT client id of the Control Service.
|
||||
MQTTClientID string
|
||||
// MQTTUsername and MQTTPassword are the Control Service credentials
|
||||
// (AGENTS.md §10 rule 6: per-device credentials).
|
||||
MQTTUsername string
|
||||
MQTTPassword string
|
||||
// MQTTCAFile is the PEM bundle used to verify the broker certificate.
|
||||
MQTTCAFile string
|
||||
// MQTTInsecure allows a plaintext, credential-less broker link. It exists
|
||||
// only until the shared Mosquitto gets the TLS listener; never set it in
|
||||
// production.
|
||||
MQTTInsecure bool
|
||||
}
|
||||
|
||||
// Load reads and validates the configuration.
|
||||
@@ -49,6 +68,13 @@ func Load() (*Config, error) {
|
||||
AdminPasswordHash: os.Getenv("CONTROL_ADMIN_PASSWORD_HASH"),
|
||||
CookieSecure: envBool("CONTROL_COOKIE_SECURE", true),
|
||||
AppEnv: env("CONTROL_APP_ENV", "production"),
|
||||
MQTTEnabled: envBool("CONTROL_MQTT_ENABLED", true),
|
||||
MQTTBroker: env("CONTROL_MQTT_BROKER", DefaultMQTTBroker),
|
||||
MQTTClientID: env("CONTROL_MQTT_CLIENT_ID", "control-service"),
|
||||
MQTTUsername: os.Getenv("CONTROL_MQTT_USERNAME"),
|
||||
MQTTPassword: os.Getenv("CONTROL_MQTT_PASSWORD"),
|
||||
MQTTCAFile: env("CONTROL_MQTT_CA_FILE", "/etc/webrestrict/tls/ca.crt"),
|
||||
MQTTInsecure: envBool("CONTROL_MQTT_INSECURE", false),
|
||||
}
|
||||
|
||||
secret := os.Getenv("CONTROL_SESSION_SECRET")
|
||||
@@ -76,9 +102,45 @@ func Load() (*Config, error) {
|
||||
}
|
||||
cfg.LogLevel = lvl
|
||||
|
||||
if err := cfg.validateMQTT(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// DefaultMQTTBroker is where the shared Mosquitto on `.13` lives, as seen from
|
||||
// inside this project's container. The compose file maps host.docker.internal
|
||||
// to the host gateway.
|
||||
const DefaultMQTTBroker = "tcp://host.docker.internal:1883"
|
||||
|
||||
// MQTTTLS reports whether the broker URL asks for TLS.
|
||||
func (c *Config) MQTTTLS() bool {
|
||||
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")
|
||||
}
|
||||
|
||||
// validateMQTT refuses a link that carries neither TLS nor credentials, unless
|
||||
// CONTROL_MQTT_INSECURE explicitly allows it (AGENTS.md §10 rule 6).
|
||||
func (c *Config) validateMQTT() error {
|
||||
if !c.MQTTEnabled {
|
||||
return nil
|
||||
}
|
||||
if c.MQTTBroker == "" {
|
||||
return errors.New("CONTROL_MQTT_BROKER must not be empty when MQTT is enabled")
|
||||
}
|
||||
if c.MQTTUsername == "" || c.MQTTPassword == "" {
|
||||
if !c.MQTTInsecure {
|
||||
return errors.New("CONTROL_MQTT_USERNAME and CONTROL_MQTT_PASSWORD are required; " +
|
||||
"set CONTROL_MQTT_INSECURE=true only for the pre-TLS lab broker")
|
||||
}
|
||||
}
|
||||
if !c.MQTTTLS() && !c.MQTTInsecure {
|
||||
return errors.New("CONTROL_MQTT_BROKER must use tls:// (AGENTS.md §10 rule 6); " +
|
||||
"set CONTROL_MQTT_INSECURE=true only for the pre-TLS lab broker")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
|
||||
@@ -11,6 +11,9 @@ func TestLoadDefaultsAndValidation(t *testing.T) {
|
||||
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
|
||||
t.Setenv("CONTROL_ADDR", "")
|
||||
t.Setenv("CONTROL_DB_PATH", "")
|
||||
// The lab broker has no TLS listener yet, so the pre-TLS escape hatch is
|
||||
// the current deployment default (see CONTROL_MQTT_INSECURE).
|
||||
t.Setenv("CONTROL_MQTT_INSECURE", "true")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
@@ -50,3 +53,56 @@ func TestLoadRequiresSecretAndPassword(t *testing.T) {
|
||||
t.Error("expected error when no admin password is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTDefaultsAndGuards(t *testing.T) {
|
||||
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
|
||||
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
|
||||
|
||||
// Enabled by default, pointed at the shared Mosquitto on `.13`.
|
||||
t.Setenv("CONTROL_MQTT_INSECURE", "true")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.MQTTEnabled {
|
||||
t.Error("MQTTEnabled = false, want true by default")
|
||||
}
|
||||
if cfg.MQTTBroker != DefaultMQTTBroker {
|
||||
t.Errorf("MQTTBroker = %q, want %q", cfg.MQTTBroker, DefaultMQTTBroker)
|
||||
}
|
||||
if cfg.MQTTTLS() {
|
||||
t.Error("MQTTTLS = true for the default plaintext broker, want false")
|
||||
}
|
||||
|
||||
// Without the escape hatch, a plaintext credential-less broker is refused
|
||||
// (AGENTS.md §10 rule 6).
|
||||
t.Setenv("CONTROL_MQTT_INSECURE", "false")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Error("expected an error for a plaintext broker without CONTROL_MQTT_INSECURE")
|
||||
}
|
||||
|
||||
// TLS plus credentials is the supported shape.
|
||||
t.Setenv("CONTROL_MQTT_BROKER", "tls://mosquitto.lab.audasmedia.com.au:8883")
|
||||
t.Setenv("CONTROL_MQTT_USERNAME", "control-service")
|
||||
t.Setenv("CONTROL_MQTT_PASSWORD", "secret")
|
||||
cfg, err = Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load with TLS: %v", err)
|
||||
}
|
||||
if !cfg.MQTTTLS() {
|
||||
t.Error("MQTTTLS = false for a tls:// broker, want true")
|
||||
}
|
||||
|
||||
// MQTT can be switched off entirely.
|
||||
t.Setenv("CONTROL_MQTT_ENABLED", "false")
|
||||
t.Setenv("CONTROL_MQTT_BROKER", "")
|
||||
t.Setenv("CONTROL_MQTT_USERNAME", "")
|
||||
t.Setenv("CONTROL_MQTT_PASSWORD", "")
|
||||
cfg, err = Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load with MQTT disabled: %v", err)
|
||||
}
|
||||
if cfg.MQTTEnabled {
|
||||
t.Error("MQTTEnabled = true, want false when CONTROL_MQTT_ENABLED=false")
|
||||
}
|
||||
}
|
||||
|
||||
414
internal/mqtt/link.go
Normal file
414
internal/mqtt/link.go
Normal file
@@ -0,0 +1,414 @@
|
||||
// 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.
|
||||
type ConfigSource interface {
|
||||
ListDevices(ctx context.Context) ([]store.Device, error)
|
||||
DeviceLists(ctx context.Context, id int64) (allow, block []string, err 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
|
||||
// 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
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
allow, block, err := l.opts.Source.DeviceLists(ctx, dev.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := Config{
|
||||
V: ConfigVersion,
|
||||
DeviceID: dev.ID,
|
||||
Name: dev.Name,
|
||||
Platform: dev.Platform,
|
||||
Locked: dev.Locked,
|
||||
Until: dev.Until,
|
||||
Allow: allow,
|
||||
Block: block,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return l.Publish(dev.Topic, payload)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
223
internal/mqtt/payload.go
Normal file
223
internal/mqtt/payload.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Package mqtt is the Control Service's link to the broker (AGENTS.md §10
|
||||
// rules 5–6, §11 Phase 3; TRANSPORT_DESIGN.md).
|
||||
//
|
||||
// The broker is transport only. This package publishes each device's desired
|
||||
// state, retained, on restrict/<device>/config, and consumes the reports that
|
||||
// devices write back. SQLite stays the source of truth (SYSTEM_MAP.md): nothing
|
||||
// here is authoritative, and a report is handed straight to the store.
|
||||
//
|
||||
// Topics (TRANSPORT_DESIGN.md "Topic design"):
|
||||
//
|
||||
// restrict/<device>/config service → device, retained
|
||||
// restrict/<device>/status device → service, retained (presence)
|
||||
// restrict/<device>/telemetry device → service, not retained
|
||||
//
|
||||
// "config" keeps the exact topic the device table already stores, so a device's
|
||||
// identity never changes when it is renamed.
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||
)
|
||||
|
||||
// TopicPrefix is the first topic level for every message this service touches.
|
||||
const TopicPrefix = "restrict"
|
||||
|
||||
// Topic suffixes, appended to "restrict/<device>".
|
||||
const (
|
||||
SuffixConfig = "/config"
|
||||
SuffixStatus = "/status"
|
||||
SuffixTelemetry = "/telemetry"
|
||||
)
|
||||
|
||||
// StatusSubscription and TelemetrySubscription are the wildcard topics the
|
||||
// Control Service reads. A device may write only its own status and telemetry
|
||||
// (AGENTS.md §10 rule 6).
|
||||
const (
|
||||
StatusSubscription = TopicPrefix + "/+/status"
|
||||
TelemetrySubscription = TopicPrefix + "/+/telemetry"
|
||||
)
|
||||
|
||||
// StatusTopic returns the status topic for a device's config topic.
|
||||
func StatusTopic(configTopic string) string {
|
||||
return withSuffix(configTopic, SuffixStatus)
|
||||
}
|
||||
|
||||
// TelemetryTopic returns the telemetry topic for a device's config topic.
|
||||
func TelemetryTopic(configTopic string) string {
|
||||
return withSuffix(configTopic, SuffixTelemetry)
|
||||
}
|
||||
|
||||
func withSuffix(configTopic, suffix string) string {
|
||||
return strings.TrimSuffix(configTopic, SuffixConfig) + suffix
|
||||
}
|
||||
|
||||
// deviceFromTopic turns "restrict/harry_phone_2/status" back into the config
|
||||
// topic "restrict/harry_phone_2/config" so the store can find the device.
|
||||
func deviceFromTopic(topic, suffix string) (string, bool) {
|
||||
if !strings.HasPrefix(topic, TopicPrefix+"/") || !strings.HasSuffix(topic, suffix) {
|
||||
return "", false
|
||||
}
|
||||
base := strings.TrimSuffix(topic, suffix)
|
||||
if strings.Count(base, "/") != 1 || len(base) <= len(TopicPrefix)+1 {
|
||||
return "", false
|
||||
}
|
||||
return base + SuffixConfig, true
|
||||
}
|
||||
|
||||
// Config is the desired state for one device. It is published retained on
|
||||
// restrict/<device>/config: a device that reconnects is configured at once,
|
||||
// with no poll and no waiting (TRANSPORT_DESIGN.md).
|
||||
//
|
||||
// The shape is versioned with "v" and is consumed by the kids' clients in
|
||||
// Phases 4 and 5. Add fields; do not repurpose them.
|
||||
type Config struct {
|
||||
V int `json:"v"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Locked bool `json:"locked"`
|
||||
Until *time.Time `json:"until"`
|
||||
Allow []string `json:"allow"`
|
||||
Block []string `json:"block"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// ConfigVersion is the current schema version of Config.
|
||||
const ConfigVersion = 1
|
||||
|
||||
// Marshal renders the payload. Lists are always present, never null, so a
|
||||
// client can parse without null checks.
|
||||
func (c Config) Marshal() ([]byte, error) {
|
||||
if c.Allow == nil {
|
||||
c.Allow = []string{}
|
||||
}
|
||||
if c.Block == nil {
|
||||
c.Block = []string{}
|
||||
}
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Status is a presence report from a device.
|
||||
//
|
||||
// The device publishes a retained {"online": true} when it comes up and
|
||||
// registers the will {"online": false} on the same topic, so the broker
|
||||
// publishes "offline" the moment the connection drops without a clean
|
||||
// goodbye. That is the whole of presence detection (FR-6).
|
||||
type Status struct {
|
||||
Online *bool `json:"online"`
|
||||
App string `json:"app"`
|
||||
Locked *bool `json:"locked"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// ParseStatus reads a status payload. A report with no "online" field means the
|
||||
// device is talking, so it counts as online.
|
||||
func ParseStatus(payload []byte) (Status, error) {
|
||||
var st Status
|
||||
if err := json.Unmarshal(payload, &st); err != nil {
|
||||
return Status{}, fmt.Errorf("parse status: %w", err)
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return Status{}, fmt.Errorf("parse status: empty payload")
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// IsOnline reports the presence the payload asserts.
|
||||
func (s Status) IsOnline() bool { return s.Online == nil || *s.Online }
|
||||
|
||||
// At parses the report timestamp, falling back to the current time.
|
||||
func (s Status) Timestamp(now time.Time) time.Time {
|
||||
if s.At == "" {
|
||||
return now
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
|
||||
if t, err := time.Parse(layout, s.At); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
// UsageEntry is one app or domain total.
|
||||
type UsageEntry struct {
|
||||
Name string `json:"name"`
|
||||
Seconds int `json:"seconds"`
|
||||
}
|
||||
|
||||
// Telemetry is a rolling daily usage report from a device. Devices report
|
||||
// totals for the day, not deltas, so a repeated report replaces the row
|
||||
// (migration 002).
|
||||
//
|
||||
// Two shapes are accepted: the list form
|
||||
//
|
||||
// {"day":"2026-10-13","apps":[{"name":"com.spotify.music","seconds":600}],
|
||||
// "domains":[{"name":"youtube.com","seconds":120}]}
|
||||
//
|
||||
// and the flat single-row form
|
||||
//
|
||||
// {"app":"com.spotify.music","seconds":600}
|
||||
type Telemetry struct {
|
||||
Day string `json:"day"`
|
||||
Apps []UsageEntry `json:"apps"`
|
||||
Domains []UsageEntry `json:"domains"`
|
||||
App string `json:"app"`
|
||||
Domain string `json:"domain"`
|
||||
Seconds int `json:"seconds"`
|
||||
}
|
||||
|
||||
// ParseTelemetry reads a telemetry payload.
|
||||
func ParseTelemetry(payload []byte) (Telemetry, error) {
|
||||
var t Telemetry
|
||||
if len(payload) == 0 {
|
||||
return Telemetry{}, fmt.Errorf("parse telemetry: empty payload")
|
||||
}
|
||||
if err := json.Unmarshal(payload, &t); err != nil {
|
||||
return Telemetry{}, fmt.Errorf("parse telemetry: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DayOrDefault returns the day the report belongs to, in the given location. A
|
||||
// device may send its own local day; anything that is not YYYY-MM-DD is
|
||||
// ignored and today's date is used instead.
|
||||
func (t Telemetry) DayOrDefault(now time.Time) string {
|
||||
if t.Day != "" {
|
||||
if d, err := time.Parse("2006-01-02", t.Day); err == nil {
|
||||
return d.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return now.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Rows flattens the payload into telemetry rows. The app and the domain are
|
||||
// kept in separate columns; a row with neither is dropped.
|
||||
func (t Telemetry) Rows() []store.TelemetryEntry {
|
||||
out := make([]store.TelemetryEntry, 0, len(t.Apps)+len(t.Domains)+1)
|
||||
for _, e := range t.Apps {
|
||||
if e.Name == "" || e.Seconds < 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, store.TelemetryEntry{App: e.Name, Seconds: e.Seconds})
|
||||
}
|
||||
for _, e := range t.Domains {
|
||||
if e.Name == "" || e.Seconds < 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, store.TelemetryEntry{Domain: e.Name, Seconds: e.Seconds})
|
||||
}
|
||||
if (t.App != "" || t.Domain != "") && t.Seconds >= 0 {
|
||||
out = append(out, store.TelemetryEntry{App: t.App, Domain: t.Domain, Seconds: t.Seconds})
|
||||
}
|
||||
return out
|
||||
}
|
||||
211
internal/mqtt/payload_test.go
Normal file
211
internal/mqtt/payload_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||
)
|
||||
|
||||
func TestTopicHelpers(t *testing.T) {
|
||||
const config = "restrict/harry_phone_2/config"
|
||||
if got := StatusTopic(config); got != "restrict/harry_phone_2/status" {
|
||||
t.Errorf("StatusTopic = %q", got)
|
||||
}
|
||||
if got := TelemetryTopic(config); got != "restrict/harry_phone_2/telemetry" {
|
||||
t.Errorf("TelemetryTopic = %q", got)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
topic string
|
||||
suffix string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"restrict/harry_phone_2/status", SuffixStatus, config, true},
|
||||
{"restrict/harry_phone_2/telemetry", SuffixTelemetry, config, true},
|
||||
{"restrict/harry_phone_2/config", SuffixStatus, "", false},
|
||||
{"restrict/status", SuffixStatus, "", false},
|
||||
{"restrict/a/b/status", SuffixStatus, "", false},
|
||||
{"other/harry_phone_2/status", SuffixStatus, "", false},
|
||||
} {
|
||||
got, ok := deviceFromTopic(tc.topic, tc.suffix)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Errorf("deviceFromTopic(%q, %q) = (%q, %v), want (%q, %v)",
|
||||
tc.topic, tc.suffix, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMarshal(t *testing.T) {
|
||||
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
|
||||
b, err := Config{
|
||||
V: ConfigVersion,
|
||||
DeviceID: 2,
|
||||
Name: "harry_phone",
|
||||
Platform: store.PlatformAndroid,
|
||||
Locked: true,
|
||||
Until: &until,
|
||||
GeneratedAt: time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC),
|
||||
}.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
|
||||
// The lists must be present as arrays, never null, so a client can parse
|
||||
// this without null checks.
|
||||
if !strings.Contains(string(b), `"allow":[]`) || !strings.Contains(string(b), `"block":[]`) {
|
||||
t.Errorf("payload has null lists: %s", b)
|
||||
}
|
||||
|
||||
var back Config
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if !back.Locked || back.Until == nil || !back.Until.Equal(until) {
|
||||
t.Errorf("round trip lost the lock: %+v", back)
|
||||
}
|
||||
if back.V != ConfigVersion || back.DeviceID != 2 {
|
||||
t.Errorf("round trip lost the identity: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatus(t *testing.T) {
|
||||
if _, err := ParseStatus(nil); err == nil {
|
||||
t.Error("expected an error for an empty payload")
|
||||
}
|
||||
if _, err := ParseStatus([]byte("{oops")); err == nil {
|
||||
t.Error("expected an error for malformed JSON")
|
||||
}
|
||||
|
||||
// An explicit offline (the will payload the broker publishes on a dropped
|
||||
// connection) must read as offline.
|
||||
off, err := ParseStatus([]byte(`{"online":false}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStatus: %v", err)
|
||||
}
|
||||
if off.IsOnline() {
|
||||
t.Error(`{"online":false} read as online`)
|
||||
}
|
||||
|
||||
// A report with no "online" field means the device is talking.
|
||||
on, err := ParseStatus([]byte(`{"app":"com.spotify.music"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStatus: %v", err)
|
||||
}
|
||||
if !on.IsOnline() {
|
||||
t.Error("a report with no online field read as offline")
|
||||
}
|
||||
if on.App != "com.spotify.music" {
|
||||
t.Errorf("App = %q", on.App)
|
||||
}
|
||||
|
||||
at := on.Timestamp(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
if !at.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("Timestamp with no at field = %v, want the fallback", at)
|
||||
}
|
||||
at = Status{At: "2026-10-13T07:30:00Z"}.Timestamp(time.Now())
|
||||
if at.UTC().Format(time.RFC3339) != "2026-10-13T07:30:00Z" {
|
||||
t.Errorf("Timestamp = %v", at)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelemetryRows(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"day": "2026-10-13",
|
||||
"apps": [{"name": "com.spotify.music", "seconds": 600}, {"name": "", "seconds": 5}],
|
||||
"domains": [{"name": "youtube.com", "seconds": 120}]
|
||||
}`)
|
||||
report, err := ParseTelemetry(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTelemetry: %v", err)
|
||||
}
|
||||
rows := report.Rows()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("Rows = %+v, want 2 rows", rows)
|
||||
}
|
||||
// Apps first, then domains.
|
||||
if rows[0].App != "com.spotify.music" || rows[0].Seconds != 600 {
|
||||
t.Errorf("rows[0] = %+v", rows[0])
|
||||
}
|
||||
if rows[1].Domain != "youtube.com" || rows[1].Seconds != 120 {
|
||||
t.Errorf("rows[1] = %+v", rows[1])
|
||||
}
|
||||
if got := report.DayOrDefault(time.Now()); got != "2026-10-13" {
|
||||
t.Errorf("DayOrDefault = %q, want the reported day", got)
|
||||
}
|
||||
|
||||
// The flat single-row form, and a bad day falling back to today.
|
||||
flat, err := ParseTelemetry([]byte(`{"app":"roblox","seconds":60,"day":"yesterday"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTelemetry flat: %v", err)
|
||||
}
|
||||
flatRows := flat.Rows()
|
||||
if len(flatRows) != 1 || flatRows[0].App != "roblox" {
|
||||
t.Fatalf("flat Rows = %+v", flatRows)
|
||||
}
|
||||
now := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||
if got := flat.DayOrDefault(now); got != "2026-03-04" {
|
||||
t.Errorf("DayOrDefault with a bad day = %q, want 2026-03-04", got)
|
||||
}
|
||||
|
||||
// Negative totals are dropped, not stored.
|
||||
bad, err := ParseTelemetry([]byte(`{"apps":[{"name":"x","seconds":-1}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTelemetry: %v", err)
|
||||
}
|
||||
if len(bad.Rows()) != 0 {
|
||||
t.Errorf("negative usage stored: %+v", bad.Rows())
|
||||
}
|
||||
|
||||
if _, err := ParseTelemetry([]byte("not json")); err == nil {
|
||||
t.Error("expected an error for malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsIncompleteOptions(t *testing.T) {
|
||||
if _, err := New(Options{Broker: "tcp://127.0.0.1:1883"}); err == nil {
|
||||
t.Error("expected an error without a device lookup, source and sink")
|
||||
}
|
||||
_, err := New(Options{
|
||||
Broker: "tls://broker:8883",
|
||||
CAFile: "/nonexistent/ca.crt",
|
||||
Devices: stubLookup{},
|
||||
Source: stubSource{},
|
||||
Reports: stubSink{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a missing CA bundle")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ca.crt") {
|
||||
t.Errorf("error = %v, want it to name the CA file", err)
|
||||
}
|
||||
}
|
||||
|
||||
type stubLookup struct{}
|
||||
|
||||
func (stubLookup) DeviceByConfigTopic(ctx context.Context, topic string) (*store.Device, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
type stubSource struct{}
|
||||
|
||||
func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
|
||||
|
||||
func (stubSource) DeviceLists(ctx context.Context, id int64) ([]string, []string, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
type stubSink struct{}
|
||||
|
||||
func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
74
internal/store/lists.go
Normal file
74
internal/store/lists.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Per-device allow and block lists, and device lookup by MQTT topic
|
||||
// (AGENTS.md §7 blocklist/allowlist, §11 Phase 3).
|
||||
//
|
||||
// The editors for these lists arrive in Phase 6. Phase 3 already publishes them
|
||||
// in the device config, so a device that is onboarded later gets the real lists
|
||||
// without another protocol change. Migration 001 created both tables.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeviceLists returns the site/app values that stay allowed and the values that
|
||||
// are blocked for one device. A row with a NULL device_id applies to every
|
||||
// device (§7). Rows are returned in insertion order, de-duplicated.
|
||||
func (s *Store) DeviceLists(ctx context.Context, id int64) (allow, block []string, err error) {
|
||||
if allow, err = s.listValues(ctx, "allowlist", id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if block, err = s.listValues(ctx, "blocklist", id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return allow, block, nil
|
||||
}
|
||||
|
||||
func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) {
|
||||
// The table name is a package constant, never caller input.
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT value FROM `+table+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list %s: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{}, 4)
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("scan %s: %w", table, err)
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[v]; dup {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list %s: %w", table, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeviceByConfigTopic finds a device from a topic the device wrote to, for
|
||||
// example "restrict/harry_phone_2/status". Only the Control Service reads the
|
||||
// topic table, so a topic the service does not know is ignored.
|
||||
func (s *Store) DeviceByConfigTopic(ctx context.Context, topic string) (*Device, error) {
|
||||
dev, err := scanDevice(s.db.QueryRowContext(ctx, selectDevice+` WHERE d.topic = ?`, topic))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device by topic: %w", err)
|
||||
}
|
||||
return dev, nil
|
||||
}
|
||||
13
internal/store/migrations/002_telemetry_unique.sql
Normal file
13
internal/store/migrations/002_telemetry_unique.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Migration 002 — Phase 3. Telemetry reports arrive over MQTT and repeat.
|
||||
--
|
||||
-- A device reports a rolling daily total ("Spotify: 600 s today"), not a delta,
|
||||
-- so one row per (device, day, app, domain) is the correct shape: a repeat
|
||||
-- report replaces the previous total instead of adding to it. Devices may omit
|
||||
-- the app or the domain, so both default to '' — SQLite treats NULLs in a
|
||||
-- UNIQUE index as distinct, which would defeat the upsert.
|
||||
|
||||
UPDATE telemetry SET app = '' WHERE app IS NULL;
|
||||
UPDATE telemetry SET domain = '' WHERE domain IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_telemetry_daily
|
||||
ON telemetry(device_id, day, app, domain);
|
||||
131
internal/store/reports.go
Normal file
131
internal/store/reports.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Reports that arrive from devices over MQTT: presence and telemetry
|
||||
// (AGENTS.md §7, §11 Phase 3).
|
||||
//
|
||||
// The broker is transport only. Every report is written to SQLite here, because
|
||||
// SQLite is the source of truth (SYSTEM_MAP.md). A malformed or hostile report
|
||||
// must never take the service down: callers validate first, and a failed insert
|
||||
// is logged and dropped.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetPresence records that a device was seen and whether it is online now. It
|
||||
// returns whether the online flag changed.
|
||||
//
|
||||
// Presence comes from the broker's Last Will and Testament: a device publishes
|
||||
// a retained "online" status, and the broker publishes its retained will
|
||||
// ("online": false) when the connection drops without a clean disconnect.
|
||||
//
|
||||
// last_seen is refreshed on every report. The audit log only gets an entry when
|
||||
// the online flag changes, so a chatty device does not flood the log (FR-15).
|
||||
func (s *Store) SetPresence(ctx context.Context, id int64, online bool, at time.Time, actor string) (bool, error) {
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
at = at.UTC()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set presence: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var current int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT online FROM devices WHERE id = ?`, id).Scan(¤t); err != nil {
|
||||
return false, fmt.Errorf("set presence: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE devices SET online = ?, last_seen = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
boolToInt(online), at.Format(time.RFC3339), id); err != nil {
|
||||
return false, fmt.Errorf("set presence: %w", err)
|
||||
}
|
||||
|
||||
changed := (current != 0) != online
|
||||
if changed {
|
||||
action, detail := "device.offline", "last will received"
|
||||
if online {
|
||||
action, detail = "device.online", "status report"
|
||||
}
|
||||
if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, fmt.Errorf("commit presence: %w", err)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// TelemetryEntry is one app or domain row of a usage report.
|
||||
type TelemetryEntry struct {
|
||||
App string
|
||||
Domain string
|
||||
Seconds int
|
||||
}
|
||||
|
||||
// InsertTelemetry upserts a rolling daily usage total per (device, day, app,
|
||||
// domain). Reports are repeats, not deltas: a later report for the same key
|
||||
// replaces the earlier total (migration 002). It returns the number of rows
|
||||
// written.
|
||||
func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entries []TelemetryEntry) (int, error) {
|
||||
if len(entries) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert telemetry: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if e.Seconds < 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO telemetry (device_id, app, domain, seconds, day)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_id, day, app, domain) DO UPDATE SET
|
||||
seconds = excluded.seconds,
|
||||
created_at = datetime('now')`,
|
||||
id, e.App, e.Domain, e.Seconds, day); err != nil {
|
||||
return n, fmt.Errorf("insert telemetry: %w", err)
|
||||
}
|
||||
n++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return n, fmt.Errorf("commit telemetry: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// TelemetryForDay returns one device's usage rows for a day, biggest first.
|
||||
func (s *Store) TelemetryForDay(ctx context.Context, id int64, day string) ([]TelemetryEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT app, domain, seconds FROM telemetry
|
||||
WHERE device_id = ? AND day = ?
|
||||
ORDER BY seconds DESC`, id, day)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telemetry for day: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]TelemetryEntry, 0, 8)
|
||||
for rows.Next() {
|
||||
var e TelemetryEntry
|
||||
if err := rows.Scan(&e.App, &e.Domain, &e.Seconds); err != nil {
|
||||
return nil, fmt.Errorf("scan telemetry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("telemetry for day: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
187
internal/store/reports_test.go
Normal file
187
internal/store/reports_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestDevice(t *testing.T) (*Store, *Device) {
|
||||
t.Helper()
|
||||
st := testStore(t)
|
||||
dev, err := st.CreateDevice(t.Context(), "Harry's phone", "Harry", PlatformAndroid, "sam")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDevice: %v", err)
|
||||
}
|
||||
return st, dev
|
||||
}
|
||||
|
||||
func TestSetPresenceAuditsOnlyTransitions(t *testing.T) {
|
||||
st, dev := newTestDevice(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// The first "online" flips the flag, so it is audited.
|
||||
changed, err := st.SetPresence(ctx, dev.ID, true, time.Now(), "device")
|
||||
if err != nil {
|
||||
t.Fatalf("SetPresence: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Error("changed = false on the first online report, want true")
|
||||
}
|
||||
|
||||
// A repeat report only refreshes last_seen.
|
||||
changed, err = st.SetPresence(ctx, dev.ID, true, time.Now().Add(time.Minute), "device")
|
||||
if err != nil {
|
||||
t.Fatalf("SetPresence: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Error("changed = true on a repeat report, want false")
|
||||
}
|
||||
|
||||
// The will payload flips it back.
|
||||
changed, err = st.SetPresence(ctx, dev.ID, false, time.Now(), "device")
|
||||
if err != nil {
|
||||
t.Fatalf("SetPresence: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Error("changed = false when the device went offline, want true")
|
||||
}
|
||||
|
||||
got, err := st.GetDevice(ctx, dev.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDevice: %v", err)
|
||||
}
|
||||
if got.Online {
|
||||
t.Error("Online = true after the will, want false")
|
||||
}
|
||||
if got.LastSeen == nil {
|
||||
t.Fatal("LastSeen is nil, want the report time")
|
||||
}
|
||||
|
||||
audit, err := st.ListAudit(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAudit: %v", err)
|
||||
}
|
||||
var online, offline int
|
||||
for _, e := range audit {
|
||||
switch e.Action {
|
||||
case "device.online":
|
||||
online++
|
||||
case "device.offline":
|
||||
offline++
|
||||
}
|
||||
}
|
||||
if online != 1 || offline != 1 {
|
||||
t.Errorf("audit has %d online and %d offline entries, want 1 and 1", online, offline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertTelemetryReplacesTheDailyTotal(t *testing.T) {
|
||||
st, dev := newTestDevice(t)
|
||||
ctx := context.Background()
|
||||
const day = "2026-10-13"
|
||||
|
||||
n, err := st.InsertTelemetry(ctx, dev.ID, day, []TelemetryEntry{
|
||||
{App: "com.spotify.music", Seconds: 600},
|
||||
{Domain: "youtube.com", Seconds: 120},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertTelemetry: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("rows = %d, want 2", n)
|
||||
}
|
||||
|
||||
// Devices report rolling totals, so a repeat report replaces the row.
|
||||
if _, err := st.InsertTelemetry(ctx, dev.ID, day, []TelemetryEntry{
|
||||
{App: "com.spotify.music", Seconds: 900},
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertTelemetry repeat: %v", err)
|
||||
}
|
||||
|
||||
rows, err := st.TelemetryForDay(ctx, dev.ID, day)
|
||||
if err != nil {
|
||||
t.Fatalf("TelemetryForDay: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %+v, want 2 (a replace, not an insert)", rows)
|
||||
}
|
||||
if rows[0].App != "com.spotify.music" || rows[0].Seconds != 900 {
|
||||
t.Errorf("rows[0] = %+v, want the replaced total 900", rows[0])
|
||||
}
|
||||
if rows[1].Seconds != 120 {
|
||||
t.Errorf("rows[1] = %+v", rows[1])
|
||||
}
|
||||
|
||||
// Another day is a different row.
|
||||
if _, err := st.InsertTelemetry(ctx, dev.ID, "2026-10-14", []TelemetryEntry{
|
||||
{App: "com.spotify.music", Seconds: 60},
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertTelemetry next day: %v", err)
|
||||
}
|
||||
rows, err = st.TelemetryForDay(ctx, dev.ID, day)
|
||||
if err != nil {
|
||||
t.Fatalf("TelemetryForDay: %v", err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].Seconds != 900 {
|
||||
t.Errorf("rows for %s = %+v, want the day isolated", day, rows)
|
||||
}
|
||||
|
||||
// A negative total is dropped, and an empty report is a no-op.
|
||||
if n, err := st.InsertTelemetry(ctx, dev.ID, day, []TelemetryEntry{{App: "x", Seconds: -5}}); err != nil || n != 0 {
|
||||
t.Errorf("negative telemetry = (%d, %v), want (0, nil)", n, err)
|
||||
}
|
||||
if n, err := st.InsertTelemetry(ctx, dev.ID, day, nil); err != nil || n != 0 {
|
||||
t.Errorf("empty telemetry = (%d, %v), want (0, nil)", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceListsAndTopicLookup(t *testing.T) {
|
||||
st, dev := newTestDevice(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// The Phase 6 editors do not exist yet, so seed the tables directly.
|
||||
seed := []struct {
|
||||
table string
|
||||
kind string
|
||||
value string
|
||||
}{
|
||||
{"allowlist", "app", "com.spotify.music"},
|
||||
{"allowlist", "site", "school.example.edu"},
|
||||
{"blocklist", "app", "com.roblox.client"},
|
||||
}
|
||||
for _, s := range seed {
|
||||
if _, err := st.DB().ExecContext(ctx,
|
||||
`INSERT INTO `+s.table+` (device_id, kind, value) VALUES (?, ?, ?)`,
|
||||
dev.ID, s.kind, s.value); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.table, err)
|
||||
}
|
||||
}
|
||||
// A NULL device_id applies to every device, and duplicates collapse.
|
||||
if _, err := st.DB().ExecContext(ctx,
|
||||
`INSERT INTO allowlist (device_id, kind, value) VALUES (NULL, 'app', 'com.spotify.music')`); err != nil {
|
||||
t.Fatalf("seed global allow: %v", err)
|
||||
}
|
||||
|
||||
allow, block, err := st.DeviceLists(ctx, dev.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceLists: %v", err)
|
||||
}
|
||||
if len(allow) != 2 || allow[0] != "com.spotify.music" || allow[1] != "school.example.edu" {
|
||||
t.Errorf("allow = %v", allow)
|
||||
}
|
||||
if len(block) != 1 || block[0] != "com.roblox.client" {
|
||||
t.Errorf("block = %v", block)
|
||||
}
|
||||
|
||||
found, err := st.DeviceByConfigTopic(ctx, dev.Topic)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceByConfigTopic: %v", err)
|
||||
}
|
||||
if found.ID != dev.ID {
|
||||
t.Errorf("DeviceByConfigTopic = %d, want %d", found.ID, dev.ID)
|
||||
}
|
||||
if _, err := st.DeviceByConfigTopic(ctx, "restrict/nope_99/config"); err != ErrNotFound {
|
||||
t.Errorf("unknown topic error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,10 @@ func auditLabel(action string) string {
|
||||
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":
|
||||
@@ -221,6 +225,7 @@ func (s *Server) handleDeviceCreate(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -294,6 +299,7 @@ func (s *Server) handleDeviceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -329,6 +335,7 @@ func (s *Server) handleDeviceLock(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -358,6 +365,7 @@ func (s *Server) handleDeviceUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func newTestApp(t *testing.T) *testApp {
|
||||
AppEnv: "test",
|
||||
DBPath: ":memory:",
|
||||
}
|
||||
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("web.New: %v", err)
|
||||
}
|
||||
|
||||
@@ -32,10 +32,22 @@ type Server struct {
|
||||
auth *auth.Manager
|
||||
tmpl *template.Template
|
||||
log *slog.Logger
|
||||
pub Publisher
|
||||
}
|
||||
|
||||
// New builds a Server and parses the embedded templates.
|
||||
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger) (*Server, error) {
|
||||
// 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() },
|
||||
@@ -48,7 +60,24 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse templates: %w", err)
|
||||
}
|
||||
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil
|
||||
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, pub: pub}, nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
"locked", dev.Locked)
|
||||
}
|
||||
|
||||
// Handler returns the fully wrapped HTTP handler.
|
||||
@@ -234,6 +263,15 @@ type statusData struct {
|
||||
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 {
|
||||
@@ -259,6 +297,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -290,12 +334,25 @@ func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) buildStatusData(ctx context.Context) statusData {
|
||||
return statusData{
|
||||
data := statusData{
|
||||
DBHealthy: s.store.Ping(ctx) == nil,
|
||||
DBPath: s.cfg.DBPath,
|
||||
ServerTime: time.Now().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) {
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
<span class="status-pill status-pill--muted">{{if .DBHealthy}}ok{{else}}error{{end}}</span>
|
||||
</article>
|
||||
|
||||
<article class="tool-card">
|
||||
<div class="tool-card__icon tool-card__icon--purple" aria-hidden="true">⇄</div>
|
||||
<h3 class="tool-card__title">Broker</h3>
|
||||
<p class="tool-card__desc">
|
||||
{{if not .MQTTEnabled}}MQTT switched off{{else if .MQTTConnected}}{{.MQTTBroker}}{{else}}{{.MQTTBroker}} · reconnecting{{end}}
|
||||
</p>
|
||||
<span class="status-pill {{if not .MQTTEnabled}}status-pill--muted{{else if .MQTTConnected}}status-pill--on{{else}}status-pill--off{{end}}">
|
||||
<span class="dot {{if .MQTTConnected}}dot--on{{else}}dot--off{{end}}"></span>
|
||||
{{if not .MQTTEnabled}}disabled{{else if .MQTTConnected}}connected{{else}}offline{{end}}
|
||||
</span>
|
||||
{{if .MQTTEnabled}}
|
||||
<p class="tool-card__desc">{{.MQTTPublished}} published · {{.MQTTReceived}} received{{if .MQTTLastMessage}} · last report {{.MQTTLastMessage}}{{end}}</p>
|
||||
{{if .MQTTLastError}}<p class="tool-card__desc">last error: {{.MQTTLastError}}</p>{{end}}
|
||||
{{end}}
|
||||
</article>
|
||||
|
||||
<article class="tool-card">
|
||||
<div class="tool-card__icon tool-card__icon--purple" aria-hidden="true">⏱</div>
|
||||
<h3 class="tool-card__title">Uptime</h3>
|
||||
|
||||
Reference in New Issue
Block a user