Files
internet_use_control/deploy/mosquitto/verify-isolation.sh
Sam Rolfe 14b304e0b0 Phase 3: MQTT transport — retained config, status/telemetry, LWT presence, TLS/ACL design
- internal/mqtt: publish each device's desired state retained on
  restrict/<device>/config; subscribe to restrict/+/status and
  restrict/+/telemetry and write reports to SQLite; publish on every state
  change, republish on connect and every 15 minutes from SQLite, so the broker
  is repaired from the source of truth.
- store: presence (devices.online and last_seen, audited only on transitions),
  telemetry upsert per (device, day, app, domain) via migration 002, allow and
  block lists read into the config payload, device lookup by topic.
- config: MQTT settings. TLS plus per-device credentials are required; the
  pre-TLS shared broker needs an explicit CONTROL_MQTT_INSECURE escape hatch.
- web: republish config after lock, unlock, expiry, create and edit; broker card
  on the status partial.
- deploy/mosquitto: TLS listener, topic ACL, credential provisioning and a
  verifier that proves the policy on a throwaway broker. Not applied to the
  shared Mosquitto on .13 — that change needs Sam's approval.
- cmd/control: mqtt-clients prints the MQTT identity of every device.
- paho.mqtt.golang v1.5.1, which pulls golang.org/x/net and with it the
  golang.org/x/crypto bump to v0.42.0.

Verified on .13: retained config delivered to a fresh subscriber; a client
killed without DISCONNECT drove the broker's retained will and the service
flipped the device offline; telemetry landed in SQLite; make mqtt-verify passed
13/13; the service connected over TLS with credentials and a CA.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 14:37:43 +11:00

257 lines
9.9 KiB
Bash
Executable File

#!/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