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>
This commit is contained in:
2026-10-13 14:37:43 +11:00
parent 7c88d3f52b
commit 14b304e0b0
31 changed files with 2624 additions and 47 deletions

149
deploy/mosquitto/README.md Normal file
View File

@@ -0,0 +1,149 @@
# 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
```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:
```bash
docker exec webrestrict-control /app/control mqtt-clients
```
## Prove it without touching anything shared
```bash
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.
```bash
# 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
```bash
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.

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# Build the scratch broker configuration used by `make mqtt-verify`.
#
# It mirrors the proposed change to the shared Mosquitto on `.13` exactly:
# the existing plain listener is left as it is, and the webrestrict listener is
# added beside it. The verification runs this config on a separate, throwaway
# container, so nothing shared is touched.
#
# build/mosquitto.conf the scratch main configuration (generated)
# conf.d/ the real listener, ACL and password files
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
out="$here/build"
mkdir -p "$out"
cat >"$out/mosquitto.conf" <<'EOF'
# GENERATED by deploy/mosquitto/build-config.sh — do not edit.
#
# The scratch broker for `make mqtt-verify`. It is the proposed shape of the
# shared Mosquitto on `.13`:
#
# * the plain 1883 listener is unchanged, still anonymous, so n8n and the rest
# of the lab keep working;
# * the 8883 listener (conf.d/webrestrict.conf) is TLS-only and needs an
# account;
# * `per_listener_settings true` is what keeps those two planes apart. It
# belongs in the main configuration, before the first listener, and is the
# only change to the existing lines of mosquitto.conf.
persistence false
log_dest stdout
connection_messages true
per_listener_settings true
listener 1883 0.0.0.0
allow_anonymous true
include_dir /mosquitto/config/conf.d
EOF
echo "build-config: wrote $out/mosquitto.conf"

View File

@@ -0,0 +1,28 @@
# Topic ACL for the Internet Use Control MQTT plane (AGENTS.md §10 rule 6).
#
# Included by conf.d/webrestrict.conf, which is reached only over TLS on 8883.
# A device may read only its own config and write only its own status and
# telemetry. Without this, a child can publish a fake "unlocked" (TRANSPORT_
# DESIGN.md "Security").
#
# The MQTT username of a device IS its topic token: the middle level of its
# config topic. For restrict/harry_phone_2/config the account is
# `harry_phone_2`. List the tokens with:
#
# docker exec webrestrict-control /app/control mqtt-clients
#
# and create the accounts with deploy/mosquitto/provision-clients.sh.
# The Control Service. It publishes every device's config and reads every
# device's reports. It cannot read configs and cannot write status or telemetry.
user control-service
topic write restrict/+/config
topic read restrict/+/status
topic read restrict/+/telemetry
# One rule set for every device. %u expands to the authenticated username, so a
# device is confined to its own topics by construction — no per-device edit is
# needed when a device is added.
pattern read restrict/%u/config
pattern write restrict/%u/status
pattern write restrict/%u/telemetry

View File

@@ -0,0 +1,35 @@
# Per-device MQTT security for the Internet Use Control Control Service.
#
# APPLY WITH deploy/mosquitto/README.md. This fragment is included by the shared
# Mosquitto on `.13`; it does not replace its configuration (AGENTS.md §11
# Phase 3, §10 rule 6).
#
# Two listeners, two planes:
# * 1883 stays exactly as it is — plain, anonymous. n8n and the rest of the
# lab keep working (N8N_DEFAULT_MQTT_BROKER_URL=mqtt://192.168.20.13:1883).
# * 8883 is new and is the ONLY listener the kids' devices use: TLS, a
# required account, and a topic ACL.
#
# `per_listener_settings true` belongs in the main mosquitto.conf, before the
# first listener, not here. It is what keeps the 1883 plane anonymous while the
# 8883 plane requires credentials.
listener 8883 0.0.0.0
# No anonymous devices. A device reads only its own config and writes only its
# own status and telemetry (webrestrict.acl).
allow_anonymous false
password_file /mosquitto/config/conf.d/webrestrict.passwd
acl_file /mosquitto/config/conf.d/webrestrict.acl
# TLS. The lab CA is generated by generate-tls.sh; every device and the Control
# Service trust the same CA, so a rogue broker on the LAN cannot hand a device
# its rules.
tls_version tlsv1.2
cafile /mosquitto/config/tls/ca.crt
certfile /mosquitto/config/tls/server.crt
keyfile /mosquitto/config/tls/server.key
# A device proves who it is with a password; it does not need its own client
# certificate.
require_certificate false

113
deploy/mosquitto/generate-tls.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
#
# Generate the lab CA and the broker certificate for the Internet Use Control
# MQTT plane (AGENTS.md §11 Phase 3, §10 rule 6).
#
# The CA is what makes "TLS" mean something on a home LAN: every device and the
# Control Service trust this CA, and the broker presents a certificate it signed.
# A rogue machine cannot impersonate the broker and hand a child new rules.
#
# Output (all gitignored, mode 0644 so the Mosquitto container user can read it):
#
# tls/ca.crt the lab CA certificate — ship this to every device
# tls/ca.key the CA private key — keep offline, never ship
# tls/server.crt the broker certificate
# tls/server.key the broker private key
#
# Re-run with --force to replace an existing set. Existing files are kept
# otherwise, so this is safe to call from `make mqtt-config`.
#
# Hostname override:
# MQTT_TLS_HOSTNAME=broker.example.lan MQTT_TLS_SANS=DNS:broker.example.lan,IP:10.0.0.5 \
# deploy/mosquitto/generate-tls.sh --force
#
# The certificates are NOT for the public internet. They exist to authenticate
# the broker to the kids' devices on the LAN.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
tls_dir="$here/tls"
hostname="${MQTT_TLS_HOSTNAME:-mosquitto.lab.audasmedia.com.au}"
sans="${MQTT_TLS_SANS:-DNS:${hostname},DNS:localhost,DNS:host.docker.internal,IP:127.0.0.1,IP:192.168.20.13}"
days="${MQTT_TLS_DAYS:-3650}"
force=0
case "${1:-}" in
--force | -f) force=1 ;;
--help | -h)
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
"") ;;
*)
echo "usage: $(basename "$0") [--force]" >&2
exit 2
;;
esac
mkdir -p "$tls_dir"
work_dir="$PWD"
cd "$tls_dir"
if [[ -s "$tls_dir/ca.crt" && -s "$tls_dir/server.crt" && $force -eq 0 ]]; then
echo "generate-tls: $tls_dir already holds a CA and a server certificate; leaving it alone."
echo "generate-tls: re-run with --force to replace it."
exit 0
fi
# Prefer a host openssl. `.13` has no openssl, so fall back to a bounded
# container run (never an unbounded `docker run`, in line with the project
# guardrails). The container needs root to install the package, so it hands the
# generated files back to the calling user before it exits.
openssl_run() {
if command -v openssl >/dev/null 2>&1; then
openssl "$@"
return
fi
timeout 300 docker run --rm --network host \
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
-v "$tls_dir":/tls -w /tls alpine:3.21 sh -c '
apk add --no-cache openssl >/dev/null 2>&1 || exit 1
openssl "$@" || exit 1
chown -R "$HOST_UID:$HOST_GID" /tls' -- "$@"
}
echo "generate-tls: CA + broker certificate for $hostname"
echo "generate-tls: SANs $sans"
# 1. The lab CA.
openssl_run req -x509 -newkey rsa:4096 -sha256 -days "$days" -nodes \
-keyout ca.key -out ca.crt \
-subj "/O=Family Home Lab/CN=Internet Use Control Lab CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# 2. The broker certificate, signed by the CA. The extension file lives in the
# certificate directory so the same relative path works for a host openssl and
# for the fallback container, which is started with that directory as its cwd.
ext=.ext.cnf
cat >"$ext" <<EOF
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=$sans
EOF
trap 'rm -f "$tls_dir/$ext"' EXIT
openssl_run req -newkey rsa:2048 -sha256 -nodes \
-keyout server.key -out server.csr \
-subj "/O=Family Home Lab/CN=$hostname"
openssl_run x509 -req -in server.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days "$days" -sha256 -extfile "$ext"
rm -f server.csr ca.srl "$ext"
chmod 644 ca.crt ca.key server.crt server.key
cd "$work_dir"
echo "generate-tls: wrote $tls_dir/{ca.crt,ca.key,server.crt,server.key}"
echo "generate-tls: ship ca.crt to every device; keep ca.key and server.key off-device."

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# Create one broker account per registered device, plus the Control Service's
# own account (AGENTS.md §11 Phase 3, §10 rule 6).
#
# The MQTT username of a device is its topic token — the middle level of its
# config topic. The device list comes from the Control Service itself:
#
# docker exec webrestrict-control /app/control mqtt-clients
#
# Because conf.d/webrestrict.acl is written with `%u`, no per-device ACL edit is
# needed: adding the account is the whole onboarding step.
#
# Output (both gitignored, mode 0600 for the secrets):
#
# conf.d/webrestrict.passwd hashed accounts, read by the broker
# clients.env the plaintext values, for the devices and .env
#
# Nothing secret is printed to the terminal. Read clients.env to collect them.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
passwd_file="$here/conf.d/webrestrict.passwd"
secrets_file="$here/clients.env"
container="${CONTROL_CONTAINER:-webrestrict-control}"
broker_image="${MQTT_IMAGE:-eclipse-mosquitto:latest}"
service_user="${CONTROL_MQTT_USERNAME:-control-service}"
service_pass="${CONTROL_MQTT_PASSWORD:-}"
die() {
echo "provision-clients: $*" >&2
exit 1
}
command -v docker >/dev/null 2>&1 || die "docker is not available"
# head reads a fixed number of bytes from the device and cut consumes its whole
# input, so no stage of this pipeline is signalled with SIGPIPE.
rand_pass() {
head -c 32 /dev/urandom | base64 | tr -d '/+=' | cut -c1-32
}
[[ -n "$service_pass" ]] || service_pass="$(rand_pass)"
# The device list comes from the service, so the broker accounts can never
# drift from the devices the Control Service knows about.
clients="$(timeout 30 docker exec "$container" /app/control mqtt-clients)" ||
die "could not read the device list from container '$container' (is it running?)"
tmp_dir="$(mktemp -d)"
chmod 700 "$tmp_dir"
trap 'rm -rf "$tmp_dir"' EXIT
add_user() { # user password (creates the file on the first call)
local user="$1" pass="$2" args=(-b)
[[ -s "$tmp_dir/webrestrict.passwd" ]] || args+=(-c)
timeout 60 docker run --rm --user "$(id -u):$(id -g)" \
-v "$tmp_dir":/cfg --entrypoint mosquitto_passwd "$broker_image" \
"${args[@]}" /cfg/webrestrict.passwd "$user" "$pass" >/dev/null ||
die "mosquitto_passwd failed for $user"
}
add_user "$service_user" "$service_pass"
: >"$tmp_dir/clients.env"
{
echo "# GENERATED by deploy/mosquitto/provision-clients.sh — secrets, do not commit."
echo "#"
echo "# Control Service account. Put these two lines in"
echo "# environment.d/control-service.env, then set CONTROL_MQTT_INSECURE=false"
echo "# and CONTROL_MQTT_BROKER=tls://host.docker.internal:8883."
echo "CONTROL_MQTT_USERNAME=$service_user"
echo "CONTROL_MQTT_PASSWORD=$service_pass"
echo
echo "# One account per device. The name is the device's topic token; the"
echo "# password goes into the device's own MQTT settings."
echo "# token<TAB>password<TAB>config topic"
} >>"$tmp_dir/clients.env"
count=0
while IFS=$'\t' read -r token topic platform child name; do
# mqtt-clients prints real tabs; names contain spaces, so the split matters.
token="$(printf '%s' "${token:-}" | tr -d '[:space:]')"
[[ -n "$token" ]] || continue
pass="$(rand_pass)"
add_user "$token" "$pass"
printf '%s\t%s\t%s\n' "$token" "$pass" "$topic" >>"$tmp_dir/clients.env"
count=$((count + 1))
echo "provision-clients: account '$token' ($name · $platform) -> $topic"
done <<<"$clients"
[[ $count -gt 0 ]] || echo "provision-clients: no devices registered yet; only the Control Service account was created."
install -m 600 "$tmp_dir/webrestrict.passwd" "$passwd_file"
install -m 600 "$tmp_dir/clients.env" "$secrets_file"
echo "provision-clients: wrote $passwd_file ($((count + 1)) accounts)"
echo "provision-clients: wrote $secrets_file — read it, do not commit it."

View File

@@ -0,0 +1,25 @@
# The lab CA and broker certificate
`generate-tls.sh` writes the certificate material here:
| File | Purpose | Who gets it |
|---|---|---|
| `ca.crt` | the lab CA certificate | every device and the Control Service |
| `ca.key` | the CA private key | **not** a device; keep it off-device |
| `server.crt` | the broker certificate | the broker |
| `server.key` | the broker private key | the broker |
Everything in this directory except this README is gitignored: certificates and
keys are generated per host and never committed.
```bash
make mqtt-tls # writes the four files (skips them if they exist)
make mqtt-tls-force # use generate-tls.sh --force to replace them
```
The broker runs as user `mosquitto` (uid 1883) inside its container, and this
directory is bind-mounted read-only, so the files are created mode **0644**. That
makes the private keys readable on `.13` by any local user. It is the pragmatic
choice for a LAN-only broker whose key must be readable by a container user that
does not exist on the host. Replace it with a group-mapped setup if `.13` ever
gets untrusted local users.

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
#
# Prove the Phase 3 MQTT security design on a throwaway broker.
#
# This script NEVER touches the shared Mosquitto on `.13`. It builds a scratch
# configuration with the same shape as the proposed shared change, runs it in its
# own container on a spare port, and asserts the policy in
# conf.d/webrestrict.acl:
#
# * the plain listener is still anonymous, so n8n and the lab keep working;
# * the TLS listener refuses anonymous clients and a wrong password;
# * a device can read only its own config;
# * a device can write only its own status and telemetry;
# * a device cannot fake an unlock by publishing to its own config topic.
#
# Usage: make mqtt-verify
# MQTT_VERIFY_PORT host port for the TLS listener (default 18883)
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
broker_image="${MQTT_IMAGE:-eclipse-mosquitto:latest}"
tls_port="${MQTT_VERIFY_PORT:-18883}"
plain_port=$((tls_port + 1))
name="webrestrict-mqtt-verify"
service_user="control-service"
# A throwaway password. head reads a fixed number of bytes from the device and
# cut consumes its whole input, so no stage of this pipeline is signalled with
# SIGPIPE (which `set -o pipefail` would turn into a failure).
rand_pass() { head -c 32 /dev/urandom | base64 | tr -d '/+=' | cut -c1-32; }
service_pass="$(rand_pass)"
dev_a_pass="$(rand_pass)"
dev_b_pass="$(rand_pass)"
work="$(mktemp -d)"
chmod 755 "$work"
cleanup() {
timeout 30 docker rm -f "$name" >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT
pass=0
fail=0
ok() {
echo " PASS $1"
pass=$((pass + 1))
}
no() {
echo " FAIL $1"
fail=$((fail + 1))
}
die() {
echo "mqtt-verify: $*" >&2
exit 1
}
command -v docker >/dev/null 2>&1 || die "docker is not available"
[[ -s "$here/conf.d/webrestrict.conf" ]] || die "run build-config.sh first (make mqtt-config)"
[[ -s "$here/tls/ca.crt" ]] || die "no CA; run generate-tls.sh first (make mqtt-tls)"
# --- stage a throwaway config root -----------------------------------------
echo "mqtt-verify: staging a scratch config in $work"
mkdir -p "$work/build" "$work/conf.d" "$work/tls"
cp "$here/conf.d/webrestrict.conf" "$work/conf.d/"
cp "$here/conf.d/webrestrict.acl" "$work/conf.d/"
cp "$here/tls/ca.crt" "$here/tls/server.crt" "$here/tls/server.key" "$work/tls/"
if [[ -s "$here/build/mosquitto.conf" ]]; then
cp "$here/build/mosquitto.conf" "$work/build/"
else
"$here/build-config.sh" >/dev/null
cp "$here/build/mosquitto.conf" "$work/build/"
fi
# Throwaway accounts, generated with the broker's own tool. mosquitto_passwd
# refuses to overwrite an existing file, so only the first account creates it.
rm -f "$work/conf.d/webrestrict.passwd"
for pair in "$service_user:$service_pass" "testdev_a:$dev_a_pass" "testdev_b:$dev_b_pass"; do
user="${pair%%:*}"
secret="${pair#*:}"
args=(-b)
[[ -s "$work/conf.d/webrestrict.passwd" ]] || args+=(-c)
timeout 60 docker run --rm --user "$(id -u):$(id -g)" \
-v "$work/conf.d":/cfg --entrypoint mosquitto_passwd "$broker_image" \
"${args[@]}" /cfg/webrestrict.passwd "$user" "$secret" >/dev/null ||
die "mosquitto_passwd failed"
done
chmod -R a+rX "$work"
# --- run the scratch broker -------------------------------------------------
timeout 30 docker rm -f "$name" >/dev/null 2>&1 || true
echo "mqtt-verify: starting $name (tls :$tls_port -> 8883, plain :$plain_port -> 1883)"
timeout 60 docker run -d --name "$name" \
-v "$work":/mosquitto/config:ro \
-p "127.0.0.1:$tls_port:8883" -p "127.0.0.1:$plain_port:1883" \
"$broker_image" /usr/sbin/mosquitto -c /mosquitto/config/build/mosquitto.conf >/dev/null
for _ in $(seq 1 40); do
if timeout 10 docker logs "$name" 2>&1 | grep -c 'mosquitto version .* running' >/dev/null; then break; fi
sleep 0.5
done
sleep 1
if [[ "$(timeout 20 docker inspect -f '{{.State.Running}}' "$name")" != "true" ]]; then
timeout 20 docker logs "$name" 2>&1 | tail -20
die "the scratch broker did not stay up (see its log above)"
fi
# --- helpers ----------------------------------------------------------------
# mosquitto_pub/sub run in a client container that shares the host network.
pub() { # user pass topic payload [retain]
local retain=""
[[ -n "${5:-}" ]] && retain="-r"
timeout 40 docker run --rm --network host -v "$work":/work:ro \
--entrypoint mosquitto_pub "$broker_image" \
-h 127.0.0.1 -p "$tls_port" --cafile /work/tls/ca.crt \
-u "$1" -P "$2" -t "$3" -m "$4" $retain 2>&1
}
# read retains everything published to the topic; prints what it got, or "" on
# timeout.
read_topic() { # user pass topic wait_secs
timeout 40 docker run --rm --network host -v "$work":/work:ro \
--entrypoint mosquitto_sub "$broker_image" \
-h 127.0.0.1 -p "$tls_port" --cafile /work/tls/ca.crt \
-u "$1" -P "$2" -t "$3" -C 1 -W "$4" 2>/dev/null || true
}
# roundtrip publishes as one account and reads as another, so a write permission
# and a read permission are both exercised. It prints what the reader received,
# or nothing.
roundtrip() { # pub_user pub_pass sub_user sub_pass topic payload
timeout 60 docker run --rm --network host -e PORT="$tls_port" -v "$work":/work:ro \
--entrypoint sh "$broker_image" -c '
mosquitto_sub -h 127.0.0.1 -p "$PORT" --cafile /work/tls/ca.crt \
-u "$1" -P "$2" -t "$3" -C 1 -W 10 >/tmp/out 2>/dev/null &
s=$!; sleep 1
mosquitto_pub -h 127.0.0.1 -p "$PORT" --cafile /work/tls/ca.crt \
-u "$4" -P "$5" -t "$3" -m "$6" >/dev/null 2>&1 || true
wait $s; cat /tmp/out' \
sh "$3" "$4" "$5" "$1" "$2" "$6" 2>/dev/null || true
}
echo
echo "== 1. the plain listener is unchanged (the lab keeps working) =="
plain="$(timeout 60 docker run --rm --network host --entrypoint sh "$broker_image" -c '
mosquitto_sub -h 127.0.0.1 -p '"$plain_port"' -t lab/verify -C 1 -W 10 >/tmp/out 2>&1 &
s=$!; sleep 1
mosquitto_pub -h 127.0.0.1 -p '"$plain_port"' -t lab/verify -m plain-plane-ok >/dev/null 2>&1 || true
wait $s; cat /tmp/out' 2>/dev/null || true)"
if [[ "$plain" == "plain-plane-ok" ]]; then
ok "anonymous client on the plain listener can publish and subscribe"
else
no "anonymous client on the plain listener was blocked (got '$plain')"
fi
echo
echo "== 2. the TLS listener rejects what it should =="
if timeout 40 docker run --rm --network host --entrypoint mosquitto_pub "$broker_image" \
-h 127.0.0.1 -p "$tls_port" -t restrict/testdev_a/config -m nope >/dev/null 2>&1; then
no "a plain TCP client was accepted on the TLS port"
else
ok "a plain TCP client is rejected on the TLS port (TLS really is on)"
fi
if timeout 40 docker run --rm --network host -v "$work":/work:ro --entrypoint mosquitto_pub "$broker_image" \
-h 127.0.0.1 -p "$tls_port" --cafile /work/tls/ca.crt \
-t restrict/testdev_a/config -m nope >/dev/null 2>&1; then
no "an anonymous client was accepted over TLS"
else
ok "an anonymous client is refused over TLS"
fi
if timeout 40 docker run --rm --network host -v "$work":/work:ro --entrypoint mosquitto_pub "$broker_image" \
-h 127.0.0.1 -p "$tls_port" --cafile /work/tls/ca.crt \
-u testdev_a -P wrong-password -t restrict/testdev_a/status -m nope >/dev/null 2>&1; then
no "a wrong password was accepted"
else
ok "a wrong password is refused"
fi
echo
echo "== 3. one device is confined to its own topics =="
payload='{"v":1,"locked":true,"src":"control"}'
if pub "$service_user" "$service_pass" restrict/testdev_a/config "$payload" retain >/dev/null; then
ok "the Control Service can publish retained config"
else
no "the Control Service could not publish config"
fi
got="$(read_topic testdev_a "$dev_a_pass" restrict/testdev_a/config 5)"
if [[ "$got" == "$payload" ]]; then
ok "a device reads its own retained config"
else
no "a device did not read its own config (got '$got')"
fi
got="$(read_topic testdev_b "$dev_b_pass" restrict/testdev_a/config 4)"
if [[ -z "$got" ]]; then
ok "a device cannot read another device's config"
else
no "a device read another device's config (got '$got')"
fi
got="$(roundtrip testdev_a "$dev_a_pass" "$service_user" "$service_pass" restrict/testdev_a/status '{"online":true}')"
if [[ "$got" == '{"online":true}' ]]; then
ok "a device writes its own status and the Control Service reads it"
else
no "a device's own status did not arrive (got '$got')"
fi
got="$(roundtrip testdev_a "$dev_a_pass" "$service_user" "$service_pass" restrict/testdev_a/telemetry '{"app":"com.spotify.music","seconds":60}')"
if [[ "$got" == *com.spotify.music* ]]; then
ok "a device writes its own telemetry and the Control Service reads it"
else
no "a device's own telemetry did not arrive (got '$got')"
fi
got="$(roundtrip testdev_a "$dev_a_pass" "$service_user" "$service_pass" restrict/testdev_b/status '{"online":true}')"
if [[ -z "$got" ]]; then
ok "a device cannot write another device's status"
else
no "a device wrote another device's status (got '$got')"
fi
got="$(roundtrip testdev_a "$dev_a_pass" "$service_user" "$service_pass" restrict/testdev_b/telemetry '{"app":"x","seconds":1}')"
if [[ -z "$got" ]]; then
ok "a device cannot write another device's telemetry"
else
no "a device wrote another device's telemetry (got '$got')"
fi
echo
echo "== 4. a device cannot fake an unlock =="
pub testdev_a "$dev_a_pass" restrict/testdev_a/config '{"v":1,"locked":false,"src":"device"}' retain >/dev/null || true
got="$(read_topic testdev_a "$dev_a_pass" restrict/testdev_a/config 5)"
if [[ "$got" == "$payload" ]]; then
ok "a device's publish to its own config topic was ignored (still $payload)"
else
no "a device overwrote its own config (got '$got')"
fi
echo
echo "== 5. the Control Service cannot be spoofed either =="
echo
got="$(roundtrip "$service_user" "$service_pass" "$service_user" "$service_pass" restrict/testdev_a/status '{"online":false}')"
if [[ -z "$got" ]]; then
ok "the Control Service identity cannot publish status (read-only there)"
else
no "the Control Service identity published status (got '$got')"
fi
echo
echo "mqtt-verify: $pass passed, $fail failed"
[[ $fail -eq 0 ]] || exit 1