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

219 lines
8.0 KiB
Markdown

# 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:
```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/<device>/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/<device>/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). 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?