- 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>
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/configthe account isharry_phone_2. %uexpands 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.