- 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>
257 lines
9.9 KiB
Bash
Executable File
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
|