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>
This commit is contained in:
2026-10-13 15:30:35 +11:00
parent 14b304e0b0
commit 980bcfa005
4 changed files with 132 additions and 44 deletions

View File

@@ -75,7 +75,7 @@ reads only the process environment.
| `CONTROL_MQTT_USERNAME` | — | Control Service broker account | | `CONTROL_MQTT_USERNAME` | — | Control Service broker account |
| `CONTROL_MQTT_PASSWORD` | — | its password | | `CONTROL_MQTT_PASSWORD` | — | its password |
| `CONTROL_MQTT_CA_FILE` | `/etc/webrestrict/tls/ca.crt` | PEM bundle that verifies the broker (TLS only) | | `CONTROL_MQTT_CA_FILE` | `/etc/webrestrict/tls/ca.crt` | PEM bundle that verifies the broker (TLS only) |
| `CONTROL_MQTT_INSECURE` | `false` | **Temporary.** Allow a plaintext, credential-less broker | | `CONTROL_MQTT_INSECURE` | `false` | Allow a plaintext, credential-less broker. Only for a broker with no TLS listener |
Generate a bcrypt hash without host Go: Generate a bcrypt hash without host Go:
@@ -86,11 +86,12 @@ docker run --rm --entrypoint /app/control webrestrict/control:0.3.0-phase3 \
`CONTROL_MQTT_USERNAME`/`CONTROL_MQTT_PASSWORD` are required unless `CONTROL_MQTT_USERNAME`/`CONTROL_MQTT_PASSWORD` are required unless
`CONTROL_MQTT_INSECURE=true`, and a non-TLS broker is refused unless `CONTROL_MQTT_INSECURE=true`, and a non-TLS broker is refused unless
`CONTROL_MQTT_INSECURE=true` (AGENTS.md §10 rule 6). The shared Mosquitto on `CONTROL_MQTT_INSECURE=true` (AGENTS.md §10 rule 6). The escape hatch exists for
`.13` has no TLS listener yet, so the running deployment sets the escape hatch a broker that has no TLS listener; the running deployment on `.13` does not use
and logs a warning. `deploy/mosquitto/README.md` holds the TLS listener, the it. Since 2026-10-13 the shared Mosquitto also serves a TLS listener on 8883
per-device credentials and the topic ACL, verified but not yet applied — see with per-device credentials and a topic ACL
that file for the apply and rollback steps. (`tls://host.docker.internal:8883`), so `CONTROL_MQTT_INSECURE=false`.
`deploy/mosquitto/README.md` is the design, the apply runbook and the rollback.
## MQTT (Phase 3) ## MQTT (Phase 3)
@@ -108,6 +109,10 @@ Broker: the shared **Mosquitto on `.13`**. A second broker is not added
- The service publishes each device's config **retained** on startup, after every - The service publishes each device's config **retained** on startup, after every
lock, unlock, expiry, create and edit, and again every 15 minutes so a broker lock, unlock, expiry, create and edit, and again every 15 minutes so a broker
that lost its retained store is repaired from SQLite. that lost its retained store is repaired from SQLite.
- Devices use the **8883 TLS listener** with one account each; the broker's ACL
lets a device read only its own config and write only its own status and
telemetry, and the Control Service is the only identity that may publish
config.
- A device registers the will `{"online": false}` on its status topic, so the - A device registers the will `{"online": false}` on its status topic, so the
broker reports the drop the moment the connection dies. That is presence broker reports the drop the moment the connection dies. That is presence
(FR-6); no polling. (FR-6); no polling.

View File

@@ -110,19 +110,22 @@ reconnects is locked again **immediately**, with no waiting.
own** status. Without this, a child can publish a fake "unlocked". own** status. Without this, a child can publish a fake "unlocked".
- Rotate device credentials when a device is removed. - Rotate device credentials when a device is removed.
**Status (Phase 3).** Written, verified, not yet applied: **Status (Phase 3). Applied on `.13` 2026-10-13.** The shared Mosquitto now runs
`deploy/mosquitto/conf.d/webrestrict.conf` is the TLS listener, `.acl` is the two planes: its original anonymous `1883` listener, unchanged, and a TLS
topic policy, and `deploy/mosquitto/README.md` is the apply/rollback runbook. listener on `8883` that requires an account
`make mqtt-verify` proves the policy on a throwaway broker. The Control Service (`deploy/mosquitto/conf.d/webrestrict.conf` + `.acl`). The Control Service
is configured for TLS plus credentials, and refuses a plaintext, 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. credential-less broker unless `CONTROL_MQTT_INSECURE=true` is set explicitly.
The shared Mosquitto on `.13` still has only its original anonymous `1883` `make mqtt-verify` proves the policy on a throwaway broker, so it can be
listener, so the running deployment uses that escape hatch and logs a warning. re-checked without touching the shared one. `deploy/mosquitto/README.md` holds
Applying the TLS listener means editing the shared broker's configuration and the design, the exact host changes, the notes from the live apply and the
compose file, adding port 8883 and restarting it — a change to a shared rollback.
household service, so it waits for Sam's approval instead of being applied
unilaterally. 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.
--- ---

View File

@@ -1,5 +1,12 @@
# Phase 3 MQTT security for the shared Mosquitto # Phase 3 MQTT security for the shared Mosquitto
> **APPLIED 2026-10-13 (AUD-80).** The shared Mosquitto on `.13` now runs the
> 8883 listener below next to its original anonymous `1883` listener, and the
> Control Service connects over TLS with credentials. Backups:
> `/home/sam/Docker/Containers/mosquitto/mosquitto.conf.bak.aud80-20261013-152648`
> and `docker-compose.yml.bak.aud80-20261013-152648`. See "What was changed on
> `.13`" at the end of this file.
This directory holds the **broker-side** half of Phase 3 (AGENTS.md §11, §10 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 rule 6; `TRANSPORT_DESIGN.md` "Security"). The Go half lives in
`internal/mqtt/`. `internal/mqtt/`.
@@ -78,19 +85,13 @@ 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 and telemetry, cannot write another device's topics, and **cannot overwrite its
own config to fake an unlock**. own config to fake an unlock**.
## Why this is not applied to the shared broker yet ## Applying it (approval required first)
Applying it changes files outside this project (`mosquitto.conf` and the Applying it changes files outside this project (`mosquitto.conf` and the
Mosquitto `docker-compose.yml`), adds port 8883 to a family-facing service, and 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 restarts that service — a change to a shared household service, so it needs
shared Mosquitto config.* So the change is prepared, generated and verified — Sam's approval first. Everything below is additive and a backup is taken before
**not applied**. Applying it is a one-command step after Sam approves it, and is anything is written.
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.
```bash ```bash
# 1. Credentials and certificates. # 1. Credentials and certificates.
@@ -107,14 +108,23 @@ 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.acl "$BROKER_DIR/conf.d/"
install -m 644 deploy/mosquitto/conf.d/webrestrict.passwd "$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/" install -m 644 deploy/mosquitto/tls/* "$BROKER_DIR/tls/"
# then add these three lines to mosquitto.conf: # then add to mosquitto.conf (leave `listener 1883 0.0.0.0` and
# `allow_anonymous true` exactly as they are):
# per_listener_settings true (before the first listener) # per_listener_settings true (before the first listener)
# include_dir /mosquitto/config/conf.d # 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. # 3. Mount the new directories, publish 8883, restart, bounded.
# add - "8883:8883" to the mosquitto service ports in # The shared compose mounts mosquitto.conf as a single file, so the new
# $BROKER_DIR/docker-compose.yml, then: # directories need their own lines or the broker will not start
# ("Error: Unable to open include_dir"). Add to the mosquitto service:
#
# ports:
# - "8883:8883"
# volumes:
# - /home/sam/Docker/Containers/mosquitto/conf.d:/mosquitto/config/conf.d:ro
# - /home/sam/Docker/Containers/mosquitto/tls:/mosquitto/config/tls:ro
#
# then:
cd "$BROKER_DIR" cd "$BROKER_DIR"
timeout 120 docker compose up -d timeout 120 docker compose up -d
timeout 30 docker logs --tail 20 mosquitto # expect both listeners to open timeout 30 docker logs --tail 20 mosquitto # expect both listeners to open
@@ -134,16 +144,83 @@ timeout 120 docker compose up -d --build
timeout 30 docker logs --tail 30 webrestrict-control # expect "mqtt connected" over tls timeout 30 docker logs --tail 30 webrestrict-control # expect "mqtt connected" over tls
``` ```
### Notes from the live apply (2026-10-13)
- The broker logs `The 'per_listener_settings' option is now deprecated and will
be removed in version 3.0` and the matching `allow_anonymous` notice. It still
works on the `eclipse-mosquitto:latest` image, which is 2.1.0.1 today.
`per_listener_settings true` is kept because it is the only way on 2.1.0 to
give the two listeners different `password_file`/`acl_file` values: the
per-listener `listener_*` prefix exists only for `allow_anonymous`. **Pin the
image tag before Mosquitto 3.0 arrives**, or rework the two planes.
- The bind mounts in step 3 are not optional. The shared compose mounts
`mosquitto.conf` as a single file, so `include_dir` fails without them and the
broker restart-loops.
- The password file and the TLS key are installed mode 0644 because the broker
runs as uid 1883 and that user does not exist on the host. See `tls/README.md`.
### Roll back ### Roll back
```bash ```bash
cd /home/sam/Docker/Containers/mosquitto cd /home/sam/Docker/Containers/mosquitto
cp mosquitto.conf.bak.<timestamp> mosquitto.conf cp mosquitto.conf.bak.aud80-<timestamp> mosquitto.conf
cp docker-compose.yml.bak.aud80-<timestamp> docker-compose.yml
rm -rf conf.d tls rm -rf conf.d tls
# remove the "8883:8883" port line from docker-compose.yml
timeout 120 docker compose up -d timeout 120 docker compose up -d
``` ```
Rolling back the broker needs no change in this project: set Rolling back the broker needs no change in this project: set
`CONTROL_MQTT_INSECURE=true` and `CONTROL_MQTT_BROKER=tcp://host.docker.internal:1883` `CONTROL_MQTT_INSECURE=true` and `CONTROL_MQTT_BROKER=tcp://host.docker.internal:1883`
in `environment.d/control-service.env` and restart the service. in `environment.d/control-service.env` and restart the service.
## What was changed on `.13` (2026-10-13)
Files outside this project were edited for the first time. The backup copies are
listed above; everything is additive.
`/home/sam/Docker/Containers/mosquitto/mosquitto.conf` — the two original lines
(`listener 1883 0.0.0.0`, `allow_anonymous true`) are untouched:
```diff
log_dest file /mosquitto/log/mosquitto.log
+
+# Internet Use Control (AUD-80). Two listeners, two auth planes. Mosquitto
+# requires per_listener_settings before the first listener, and it is what keeps
+# the 1883 listener below anonymous while 8883 requires an account.
+per_listener_settings true
+
listener 1883 0.0.0.0
allow_anonymous true # IMPORTANT: For production, configure authentication (username/password)
+
+# Internet Use Control device plane (AUD-80): TLS on 8883, account required,
+# topic ACL. Roll back by deleting this line and the conf.d directory.
+include_dir /mosquitto/config/conf.d
```
`/home/sam/Docker/Containers/mosquitto/docker-compose.yml`:
```diff
ports:
- "1883:1883" # Standard MQTT port
+ - "8883:8883" # Internet Use Control device plane (TLS, AUD-80)
- "9001:9001" # Websockets port (optional, good for web clients)
volumes:
- /home/sam/Docker/Containers/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
+ - /home/sam/Docker/Containers/mosquitto/conf.d:/mosquitto/config/conf.d:ro
+ - /home/sam/Docker/Containers/mosquitto/tls:/mosquitto/config/tls:ro
- /home/sam/Docker/Containers/mosquitto/data:/mosquitto/data
- /home/sam/Docker/Containers/mosquitto/log:/mosquitto/log
```
New on the host, copied from this directory:
`conf.d/webrestrict.{conf,acl,passwd}` and `tls/{ca.crt,server.crt,server.key}`.
`environment.d/control-service.env` in this project now points at
`tls://host.docker.internal:8883` with the `control-service` account and
`CONTROL_MQTT_INSECURE=false`.
Verified on the live broker 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's write to another device's
status and to its own config is dropped, and a client killed without DISCONNECT
drove the retained will into an offline transition in the Control Service.

View File

@@ -46,21 +46,24 @@ CONTROL_ADMIN_PASSWORD=change-me
CONTROL_MQTT_ENABLED=true CONTROL_MQTT_ENABLED=true
# The shared Mosquitto on `.13`, seen from inside this container. The compose # The shared Mosquitto on `.13`, seen from inside this container. The compose
# file maps host.docker.internal to the host gateway. # file maps host.docker.internal to the host gateway. The TLS listener on 8883
# When the TLS listener exists, change this to tls://host.docker.internal:8883. # has been live since 2026-10-13 (deploy/mosquitto/README.md).
CONTROL_MQTT_BROKER=tcp://host.docker.internal:1883 CONTROL_MQTT_BROKER=tls://host.docker.internal:8883
# The Control Service identity. The broker ACL gives it write access to every # The Control Service identity. The broker ACL gives it write access to every
# config topic and read access to every status and telemetry topic, nothing else. # config topic and read access to every status and telemetry topic, nothing else.
# Generate the account with deploy/mosquitto/provision-clients.sh, which writes
# the values into deploy/mosquitto/clients.env (gitignored).
CONTROL_MQTT_CLIENT_ID=control-service CONTROL_MQTT_CLIENT_ID=control-service
# CONTROL_MQTT_USERNAME=control-service CONTROL_MQTT_USERNAME=control-service
# CONTROL_MQTT_PASSWORD=change-me CONTROL_MQTT_PASSWORD=change-me
# PEM bundle used to verify the broker certificate. Required for a tls:// broker. # PEM bundle used to verify the broker certificate. Required for a tls:// broker.
# docker-compose.yml mounts deploy/mosquitto/tls/ here read-only.
CONTROL_MQTT_CA_FILE=/etc/webrestrict/tls/ca.crt CONTROL_MQTT_CA_FILE=/etc/webrestrict/tls/ca.crt
# TEMPORARY. The shared Mosquitto has no TLS listener yet, so a plaintext, # Escape hatch for a broker that has no TLS listener: it allows a plaintext,
# credential-less link is allowed. The service refuses to start without # credential-less link. The service refuses to start without credentials or TLS
# credentials or TLS unless this is true, and it logs a warning when it is. # unless this is true, and it logs a warning when it is. Leave it false against
# Set to false as soon as deploy/mosquitto/ is applied (AGENTS.md §10 rule 6). # the shared Mosquitto (AGENTS.md §10 rule 6).
CONTROL_MQTT_INSECURE=true CONTROL_MQTT_INSECURE=false