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