Files
internet_use_control/TRANSPORT_DESIGN.md
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

7.9 KiB

Internet Control — transport design (MQTT vs polling)

Date: 2026-10-10 Question from Sam: can the internet controls use a broadcast mechanism (Mosquitto / MQTT) as the source of truth? Devices check in, apply state, and block on device. Then Harry's phone can be off while his desktop stays on.


Short answer

Yes — and it is better than polling. But do not make the broker the database.

Use both, each for its own job:

Layer Job
MQTT (Mosquitto) Push state, presence, and telemetry. Instant.
HTTP API Setup and queries: schedules, blocklists, the portal, the Hub.
The service (Go) + SQLite The source of truth. MQTT is transport, not storage.

Mosquitto is already installed. Home Assistant already speaks MQTT. This is proven ground.


Why MQTT beats the 30-second poll

Property Poll every 30 s MQTT
Enforcement delay up to 30 s instant
Presence (online/offline) must be inferred free, via Last Will and Testament
Reconnect state next poll retained message delivers it at once
Per-device state possible natural (one topic per device)
Device discovery service must know addresses device announces itself

Topic design

Topic Direction Retained Payload
restrict/<device>/config service → device yes desired state
restrict/<device>/status device → service yes online, app, usage
restrict/<device>/telemetry device → service no periodic usage
restrict/<device>/cmd service → device no one-shot command
restrict/<device>/ack device → service no command result

config, status and telemetry are implemented (Phase 3, internal/mqtt/). cmd and ack are still design only: no phase uses them yet.

<device> is restrict/<slug>_<id>, stored in devices.topic when the device is created, and never changes. <id> is also the device's MQTT username.

Payloads, as implemented (Phase 3)

restrict/<device>/config, retained, written by the Control Service only:

{"v":1,"device_id":1,"name":"Harry's phone","platform":"android",
 "locked":true,"until":"2026-10-13T21:00:00Z","allow":[],"block":[],
 "generated_at":"2026-10-13T03:30:27Z"}

allow and block are always arrays. until is null when the lock has no end time. Clients must ignore unknown fields; v is the schema version.

restrict/<device>/status, retained, written by the device:

{"online":true,"app":"com.spotify.music","at":"2026-10-13T03:30:27Z"}

The device publishes {"online": true} when it comes up and registers the will {"online": false} on the same topic in its CONNECT packet. The broker then publishes that will the moment the connection drops without DISCONNECT, which is the whole of presence detection (FR-6). A status payload with no online field counts as online, because the device just talked to us.

restrict/<device>/telemetry, not retained, written by the device:

{"day":"2026-10-13",
 "apps":[{"name":"com.spotify.music","seconds":600}],
 "domains":[{"name":"youtube.com","seconds":120}]}

Seconds are totals for the day, not deltas. A repeated report replaces the row for (device, day, app, domain), so a device may resend freely (migration 002_telemetry_unique.sql). A flat single-row form ({"app":"…","seconds":600}) is also accepted.

Example — the "phone off, desktop on" case

restrict/harry_phone/config    {"locked": true,  "until": "2026-10-10T21:00Z", "allow": ["spotify"]}
restrict/harry_desktop/config  {"locked": false}
restrict/finn_phone/config     {"locked": true,  "until": "2026-10-10T21:00Z"}

Each device subscribes only to its own config. Retained means a phone that reconnects is locked again immediately, with no waiting.


Security (do not skip this)

  • TLS, plus a per-device username/password or a client certificate.
  • Topic ACLs. A device may read only its own config and write only its own status. Without this, a child can publish a fake "unlocked".
  • Rotate device credentials when a device is removed.

Status (Phase 3). Written, verified, not yet applied: deploy/mosquitto/conf.d/webrestrict.conf is the TLS listener, .acl is the topic policy, and deploy/mosquitto/README.md is the apply/rollback runbook. make mqtt-verify proves the policy on a throwaway broker. The Control Service is configured for TLS plus credentials, and refuses a plaintext, credential-less broker unless CONTROL_MQTT_INSECURE=true is set explicitly.

The shared Mosquitto on .13 still has only its original anonymous 1883 listener, so the running deployment uses that escape hatch and logs a warning. Applying the TLS listener means editing the shared broker's configuration and compose file, adding port 8883 and restarting it — a change to a shared household service, so it waits for Sam's approval instead of being applied unilaterally.


Keep the HTTP API

  • Admin portal: schedules, blocklists, usage history.
  • The Hub: /ask ("how long have the kids been on screens?"), /capabilities.
  • Approval callbacks.

Setup and questions are request/response. That is what HTTP is for. Do not force them through MQTT.


Enforcement: device only, kids only (corrected)

Rules apply to the kids' devices only. Nothing house-wide. No router change. No Pi-hole. So filtering happens on the device:

Target How (device-local)
An app (YouTube, a game) Block the app. Android Device Owner setPackagesSuspended. Windows process kill.
A website by name A device-local filter. Android: a local VpnService. Windows: hosts file or a firewall rule.
Everything except Spotify Block all, allow a short list — on the device.

MQTT carries the commands. The device applies them to itself.


Anti-bypass (honest)

The client runs on the device, so a determined child can interfere. Reasonable steps, not a fortress:

  • Android Device Owner — the app cannot be uninstalled or force-stopped.
  • Windows service with auto-restart and a watchdog.
  • A child can buy another SIM. That is accepted. Aim for reasonable friction, not a lockdown.

Fail safe (when a device cannot reach the broker)

The device follows instructions. If it cannot hear the broker, what should it do?

  • Fail-open: assume "not locked". A child turns off Wi-Fi and plays games.
  • Fail-closed: assume "locked". A server reboot locks everyone out.
  • Keep last state: keep following the last instruction. An outage changes nothing.

Recommended: keep last state. Use a retained MQTT message, so a reconnecting device gets the current instruction at once. Add an admin override for real exceptions (travel, holidays).

The open question: after a very long outage, does the device still keep the last state, or does it lock?


The broker

Use the existing Mosquitto on .13. Do not add a second broker. Isolation is done with per-device credentials and topic ACLs. A second broker is optional hardening only.


Who publishes config

See SYSTEM_MAP.md. Short answer: the Control Service, and only the Control Service. Everything else writes to it over HTTP.


Recommendation

  1. SQLite in the service stays the source of truth.
  2. The service publishes desired state to MQTT (retained).
  3. Devices subscribe, apply instantly, and block locally.
  4. Telemetry returns over MQTT; the service writes it to SQLite.
  5. HTTP stays for setup and Hub queries.
  6. Do not make the broker the database.
  7. Use a per-device topic so state is per device, not per family.

Open questions

  1. Fail safe: when the broker is unreachable, lock or keep the last state?
  2. One broker only, or one broker per VLAN? (Mosquitto is single today.)
  3. Should device state also appear in Home Assistant (MQTT discovery)?
  4. Who publishes config — only the Go service, or also the admin app directly?