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:
149
deploy/mosquitto/README.md
Normal file
149
deploy/mosquitto/README.md
Normal 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.
|
||||
45
deploy/mosquitto/build-config.sh
Executable file
45
deploy/mosquitto/build-config.sh
Executable 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"
|
||||
28
deploy/mosquitto/conf.d/webrestrict.acl
Normal file
28
deploy/mosquitto/conf.d/webrestrict.acl
Normal 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
|
||||
35
deploy/mosquitto/conf.d/webrestrict.conf
Normal file
35
deploy/mosquitto/conf.d/webrestrict.conf
Normal 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
113
deploy/mosquitto/generate-tls.sh
Executable 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."
|
||||
100
deploy/mosquitto/provision-clients.sh
Executable file
100
deploy/mosquitto/provision-clients.sh
Executable 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."
|
||||
25
deploy/mosquitto/tls/README.md
Normal file
25
deploy/mosquitto/tls/README.md
Normal 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.
|
||||
256
deploy/mosquitto/verify-isolation.sh
Executable file
256
deploy/mosquitto/verify-isolation.sh
Executable 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
|
||||
Reference in New Issue
Block a user