# 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//config` | service → device | **yes** | desired state | | `restrict//status` | device → service | yes | online, app, usage | | `restrict//telemetry` | device → service | no | periodic usage | | `restrict//cmd` | service → device | no | one-shot command | | `restrict//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. `` is `restrict/_`, stored in `devices.topic` when the device is created, and never changes. `` is also the device's MQTT username. ### Payloads, as implemented (Phase 3) `restrict//config`, retained, written by the Control Service only: ```json {"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//status`, retained, written by the device: ```json {"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//telemetry`, not retained, written by the device: ```json {"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?