Files
internet_use_control/TRANSPORT_DESIGN.md
Sam Rolfe 980bcfa005 Record the applied MQTT TLS listener on the shared Mosquitto
Follow-up to the Phase 3 commit (14b304e), after Sam accepted the confirmation
on AUD-80. The operational change itself lives outside this repository; this
commit updates the documentation so it matches reality.

Applied on .13 (backups: mosquitto.conf.bak.aud80-20261013-152648 and
docker-compose.yml.bak.aud80-20261013-152648):
- mosquitto.conf gains per_listener_settings true and
  include_dir /mosquitto/config/conf.d; its original 1883 listener and
  allow_anonymous true are untouched.
- docker-compose.yml publishes 8883 and mounts the new conf.d and tls
  directories (the compose mounts mosquitto.conf as a single file, so the
  include_dir fails without them).
- conf.d/webrestrict.{conf,acl,passwd} and tls/{ca.crt,server.crt,server.key}
  are installed from deploy/mosquitto/.
- The Control Service now uses tls://host.docker.internal:8883 with the
  control-service account and CONTROL_MQTT_INSECURE=false.

Verified live after the restart: the 1883 plane still carries anonymous traffic,
8883 refuses anonymous clients and a wrong password, each device reads only its
own retained config, a device cannot write another device's status or its own
config, and a client killed without DISCONNECT drove the retained will into an
offline transition recorded in SQLite.

Docs: deploy/mosquitto/README.md records the exact host diff, the runbook gap
that caused one restart-loop (the missing bind mounts), the per_listener_settings
deprecation caveat and the rollback. README.md and TRANSPORT_DESIGN.md now
describe the applied state.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 15:30:35 +11:00

8.0 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). Applied on .13 2026-10-13. The shared Mosquitto now runs two planes: its original anonymous 1883 listener, unchanged, and a TLS listener on 8883 that requires an account (deploy/mosquitto/conf.d/webrestrict.conf + .acl). The Control Service connects to 8883 as control-service with the lab CA, and refuses a plaintext, credential-less broker unless CONTROL_MQTT_INSECURE=true is set explicitly.

make mqtt-verify proves the policy on a throwaway broker, so it can be re-checked without touching the shared one. deploy/mosquitto/README.md holds the design, the exact host changes, the notes from the live apply and the rollback.

One caveat to carry forward: per_listener_settings true is deprecated in Mosquitto 2.1 and is removed in 3.0. It is kept because it is the only way on 2.1 to give the two listeners different password_file/acl_file values. Pin the broker image tag before upgrading, or rework the two planes.


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?