Files
internet_use_control/deploy/mosquitto
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
..

Phase 3 MQTT security for the shared Mosquitto

This directory holds the broker-side half of Phase 3 (AGENTS.md §11, §10 rule 6; TRANSPORT_DESIGN.md "Security"). The Go half lives in internal/mqtt/.

The transport itself — retained config per device, status and telemetry subscriptions, LWT presence — is implemented and running against the shared Mosquitto on .13:1883. What follows is the credential and TLS layer that onboards the kids' devices.

What is here

File What it is
conf.d/webrestrict.conf the TLS listener on 8883 with authentication
conf.d/webrestrict.acl the topic ACL: one device, one topic set
conf.d/webrestrict.passwd generated hashed accounts (gitignored)
generate-tls.sh builds the lab CA and the broker certificate
build-config.sh writes the scratch broker config used by the verifier
provision-clients.sh creates one broker account per registered device
verify-isolation.sh proves the ACL on a throwaway broker
clients.env generated device credentials (gitignored)

The design

Two listeners on the one broker. No second broker (§10 rule 6), no router change, no DNS change.

Listener Auth Who uses it
1883 (existing, unchanged) anonymous n8n and the rest of the lab
8883 (new, TLS) required account the kids' devices and the Control Service

per_listener_settings true is what keeps the two planes apart: the plain listener stays anonymous, the TLS listener requires credentials. It must appear in the main mosquitto.conf before the first listener line.

Topic ACL

user control-service
topic write restrict/+/config
topic read  restrict/+/status
topic read  restrict/+/telemetry

pattern read  restrict/%u/config
pattern write restrict/%u/status
pattern write restrict/%u/telemetry
  • Only the Control Service publishes config (§10 rule 5). It cannot read config and cannot write status or telemetry.
  • A device's MQTT username is its topic token — the middle level of its config topic. For restrict/harry_phone_2/config the account is harry_phone_2.
  • %u expands to that username, so a device can only ever touch its own topics. Adding a device is therefore one account, no ACL edit.
  • A device publishing to its own config topic is dropped by the broker, so a child cannot publish a fake "unlocked".

List the tokens the Control Service expects:

docker exec webrestrict-control /app/control mqtt-clients

Prove it without touching anything shared

make mqtt-verify          # TLS + CA + scratch broker on 127.0.0.1:18883

verify-isolation.sh stages a throwaway copy of the config, runs its own Mosquitto container on a spare port, and asserts the whole policy: the plain listener is still anonymous, the TLS listener rejects anonymous clients and a wrong password, a device reads only its own config, writes only its own status and telemetry, cannot write another device's topics, and cannot overwrite its own config to fake an unlock.

Why this is not applied to the shared broker yet

Applying it changes files outside this project (mosquitto.conf and the Mosquitto docker-compose.yml), adds port 8883 to a family-facing service, and restarts that service. The Phase 3 guardrail is explicit: do not change the shared Mosquitto config. So the change is prepared, generated and verified — not applied. Applying it is a one-command step after Sam approves it, and is tracked as the follow-up on AUD-80.

Applying it (after approval)

Everything below is additive. Nothing existing is rewritten except the three lines in step 2, and a backup is taken first.

# 1. Credentials and certificates.
docker exec webrestrict-control /app/control mqtt-clients     # tokens, no secrets
deploy/mosquitto/provision-clients.sh                         # writes conf.d/webrestrict.passwd + clients.env
make mqtt-tls                                                 # writes tls/{ca,server}.{crt,key}

# 2. Copy the material into the shared broker's config directory.
BROKER_DIR=/home/sam/Docker/Containers/mosquitto
sudo -u sam cp "$BROKER_DIR/mosquitto.conf" \
     "$BROKER_DIR/mosquitto.conf.bak.$(date +%Y%m%d-%H%M%S)"
install -d -m 755 "$BROKER_DIR/conf.d" "$BROKER_DIR/tls"
install -m 644 deploy/mosquitto/conf.d/webrestrict.conf  "$BROKER_DIR/conf.d/"
install -m 644 deploy/mosquitto/conf.d/webrestrict.acl   "$BROKER_DIR/conf.d/"
install -m 644 deploy/mosquitto/conf.d/webrestrict.passwd "$BROKER_DIR/conf.d/"
install -m 644 deploy/mosquitto/tls/* "$BROKER_DIR/tls/"
# then add these three lines to mosquitto.conf:
#   per_listener_settings true          (before the first listener)
#   include_dir /mosquitto/config/conf.d
#   (leave `listener 1883 0.0.0.0` and `allow_anonymous true` exactly as they are)

# 3. Publish 8883 and restart, bounded.
#    add  - "8883:8883"  to the mosquitto service ports in
#    $BROKER_DIR/docker-compose.yml, then:
cd "$BROKER_DIR"
timeout 120 docker compose up -d
timeout 30 docker logs --tail 20 mosquitto     # expect both listeners to open

# 4. Prove the plain plane still works (n8n depends on it).
timeout 40 docker run --rm --network host --entrypoint sh eclipse-mosquitto:latest -c \
  'mosquitto_sub -h 127.0.0.1 -p 1883 -t lab/after -C 1 -W 10 & sleep 1; \
   mosquitto_pub -h 127.0.0.1 -p 1883 -t lab/after -m ok; wait'

# 5. Point the Control Service at TLS.
#    environment.d/control-service.env:
#      CONTROL_MQTT_BROKER=tls://host.docker.internal:8883
#      CONTROL_MQTT_INSECURE=false
#      CONTROL_MQTT_USERNAME=control-service
#      CONTROL_MQTT_PASSWORD=<from clients.env>
timeout 120 docker compose up -d --build
timeout 30 docker logs --tail 30 webrestrict-control   # expect "mqtt connected" over tls

Roll back

cd /home/sam/Docker/Containers/mosquitto
cp mosquitto.conf.bak.<timestamp> mosquitto.conf
rm -rf conf.d tls
# remove the "8883:8883" port line from docker-compose.yml
timeout 120 docker compose up -d

Rolling back the broker needs no change in this project: set CONTROL_MQTT_INSECURE=true and CONTROL_MQTT_BROKER=tcp://host.docker.internal:1883 in environment.d/control-service.env and restart the service.