test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite

Adds the MQTT driver integration-test project: an eclipse-mosquitto fixture running
auth AND TLS (allow_anonymous false on both listeners), a mosquitto_pub-based JSON
publisher emitting retained messages, a cert/password generator script whose output is
gitignored, and a live suite gated on MQTT_FIXTURE_ENDPOINT that skips clean offline.

The fixture is never anonymous by design: an anonymous broker would let the driver's
TLS/CA-pin and auth paths go untested, and those fail silently. The CA-pin leg carries
its own falsifiability control (a foreign CA generated in-process must be rejected).

Live gate on 10.100.0.35: 7/7 passed. It found a driver defect, reported not fixed
here (src/ is out of this task's scope): MqttConnection.ConnectAsync RETURNS NORMALLY
when Mosquitto rejects a CONNECT as not-authorized -- MQTTnet 5 does not throw on an
unsuccessful CONNACK, so a wrong broker password yields DriverState.Healthy from
InitializeAsync. The auth-negative test therefore asserts the invariant that holds
either way (no session is ever established) and documents the finding.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 17:49:14 -04:00
parent ff62b62a55
commit 033b3700c4
9 changed files with 1231 additions and 0 deletions
@@ -0,0 +1,14 @@
# Fixture credential + TLS material produced by gen-fixture-material.sh.
#
# NEVER commit anything in here: it holds the broker password hash, the fixture CA's
# PRIVATE key and the server's private key. The generator script is the artifact in
# git; its output is regenerated per machine.
secrets/
# Belt-and-braces: catch key/cert material written directly into this directory by a
# hand-run openssl command that forgot the secrets/ prefix.
*.key
*.crt
*.csr
*.srl
passwd
@@ -0,0 +1,77 @@
# MQTT integration-test fixture — Eclipse Mosquitto (auth + TLS) + a JSON publisher.
#
# PREREQUISITE — run this first, in this directory:
#
# MQTT_FIXTURE_PASSWORD='<pick-one>' ./gen-fixture-material.sh
#
# It writes ./secrets/ (password file + CA + server certificate/key), which is
# gitignored and which both services below mount. Without it the broker will not
# start: there is no anonymous fallback, by design.
#
# Bring up (on the shared Docker host — see infra/README.md §1):
#
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt \
# && MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<same> docker compose up -d'
#
# Endpoints: 10.100.0.35:8883 (TLS + auth) · 10.100.0.35:1883 (plaintext + auth, smoke)
#
# Unlike the Modbus / S7 fixtures there are no compose profiles: both services are
# always wanted together (a broker with nothing publishing into it tests nothing), and
# they bind different ports so nothing contends.
services:
mosquitto:
image: eclipse-mosquitto:2.0.22
container_name: otopcua-mosquitto
restart: unless-stopped
# Every lmxopcua fixture container carries this so the shared Docker host stays
# discoverable with `docker ps --filter label=project=lmxopcua`.
labels:
project: lmxopcua
ports:
- "1883:1883"
- "8883:8883"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- ./secrets:/mosquitto/secrets:ro
healthcheck:
# Authenticated round-trip against the broker's own $SYS tree ($$ escapes the
# compose variable syntax). Proves the password file loaded and the listener
# accepts a real session — a bare port check would go green on a broker that
# rejects every CONNECT.
test:
- CMD-SHELL
- >-
mosquitto_sub -h 127.0.0.1 -p 1883
-u "$$MQTT_FIXTURE_USERNAME" -P "$$MQTT_FIXTURE_PASSWORD"
-t '$$SYS/broker/uptime' -C 1 -W 3
interval: 5s
timeout: 8s
retries: 12
start_period: 5s
environment:
# Consumed only by the healthcheck above; the broker itself reads the hashed
# password file. Supplied by the operator's environment at `docker compose up` —
# never defaulted here, so a missing value fails loudly instead of silently
# provisioning a known-password broker.
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME (must match gen-fixture-material.sh)}
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD (must match gen-fixture-material.sh)}
publisher:
image: eclipse-mosquitto:2.0.22
container_name: otopcua-mqtt-publisher
restart: unless-stopped
labels:
project: lmxopcua
depends_on:
mosquitto:
condition: service_healthy
volumes:
- ./publisher/publish.sh:/publish.sh:ro
environment:
BROKER_HOST: mosquitto
BROKER_PORT: "1883"
TOPIC_PREFIX: ${MQTT_FIXTURE_TOPIC_PREFIX:-otopcua/fixture}
PUBLISH_INTERVAL: ${MQTT_FIXTURE_PUBLISH_INTERVAL:-2}
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME}
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD}
entrypoint: ["/bin/sh", "/publish.sh"]
@@ -0,0 +1,137 @@
#!/usr/bin/env sh
# ---------------------------------------------------------------------------
# Generates the MQTT fixture's password file + TLS material into ./secrets/.
#
# NOTHING this script writes is committed: ./secrets/ is excluded by the sibling
# .gitignore. The SCRIPT is the artifact in git, the OUTPUT never is. Re-run it on any
# machine that needs the fixture (or on the Docker host); the material is throwaway by
# construction — a self-issued CA, a 2048-bit server key, and whatever password the
# operator supplies in the environment.
#
# Usage:
# MQTT_FIXTURE_PASSWORD='<pick-one>' ./gen-fixture-material.sh
#
# Environment:
# MQTT_FIXTURE_PASSWORD (required) broker password. No default — a committed or
# defaulted password is exactly what the plan's
# "secrets from env, never committed" rule forbids.
# MQTT_FIXTURE_USERNAME broker username (default: otopcua)
# MQTT_FIXTURE_SAN_IPS comma-separated IP SANs for the server certificate
# (default: 10.100.0.35,127.0.0.1)
# MQTT_FIXTURE_SAN_DNS comma-separated DNS SANs
# (default: localhost,mosquitto)
# MQTT_FIXTURE_CERT_CN server certificate CN (default: first IP SAN)
# MQTT_FIXTURE_CERT_DAYS certificate lifetime in days (default: 825)
# MQTT_FIXTURE_SECRETS_DIR output directory (default: <script dir>/secrets)
# MOSQUITTO_IMAGE image used to run mosquitto_passwd
# (default: eclipse-mosquitto:2.0.22)
#
# The SANs matter: MqttConnection pins the TLS target host with
# `.WithTargetHost(options.Host)`, so hostname verification is enforced and the
# certificate MUST carry a SAN matching whatever host the tests dial — the shared
# Docker host's IP literal (10.100.0.35) in the normal case. A cert without the right
# SAN fails the handshake even with a correct CA pin, and the failure looks like a pin
# bug.
#
# Requires: openssl, docker (only for hashing the password — mosquitto 2.0 will not
# read a plaintext password file, and mosquitto_passwd is the only supported way to
# produce its PBKDF2-SHA512 format).
# ---------------------------------------------------------------------------
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT=${MQTT_FIXTURE_SECRETS_DIR:-"$SCRIPT_DIR/secrets"}
USERNAME=${MQTT_FIXTURE_USERNAME:-otopcua}
SAN_IPS=${MQTT_FIXTURE_SAN_IPS:-10.100.0.35,127.0.0.1}
SAN_DNS=${MQTT_FIXTURE_SAN_DNS:-localhost,mosquitto}
DAYS=${MQTT_FIXTURE_CERT_DAYS:-825}
MOSQUITTO_IMAGE=${MOSQUITTO_IMAGE:-eclipse-mosquitto:2.0.22}
if [ -z "${MQTT_FIXTURE_PASSWORD:-}" ]; then
echo "error: MQTT_FIXTURE_PASSWORD is not set." >&2
echo " Supply the broker password in the environment; this script never" >&2
echo " defaults or commits one. Example:" >&2
echo " MQTT_FIXTURE_PASSWORD='\$(openssl rand -base64 18)' $0" >&2
exit 2
fi
command -v openssl >/dev/null 2>&1 || { echo "error: openssl not found on PATH." >&2; exit 2; }
command -v docker >/dev/null 2>&1 || {
echo "error: docker not found on PATH — needed to run mosquitto_passwd." >&2
echo " Run this script on the Docker host (10.100.0.35) instead, or install docker." >&2
exit 2
}
CN=${MQTT_FIXTURE_CERT_CN:-$(printf '%s' "$SAN_IPS" | cut -d, -f1)}
mkdir -p "$OUT"
chmod 0755 "$OUT"
echo "==> generating fixture material into $OUT"
echo " username : $USERNAME"
echo " cert CN : $CN"
echo " IP SANs : $SAN_IPS"
echo " DNS SANs : $SAN_DNS"
# --- CA -------------------------------------------------------------------
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$OUT/ca.key" -out "$OUT/ca.crt" -days "$DAYS" \
-subj "/CN=OtOpcUa MQTT fixture CA/O=OtOpcUa test fixture" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
>/dev/null 2>&1
# --- server leaf ----------------------------------------------------------
# The SAN list is assembled into a temp extfile rather than a process substitution,
# which is a bashism this /bin/sh script cannot use.
EXT=$(mktemp)
trap 'rm -f "$EXT" "$OUT/server.csr" "$OUT/ca.srl"' EXIT
SAN=""
OLD_IFS=$IFS
IFS=,
for ip in $SAN_IPS; do [ -n "$ip" ] && SAN="$SAN,IP:$ip"; done
for dn in $SAN_DNS; do [ -n "$dn" ] && SAN="$SAN,DNS:$dn"; done
IFS=$OLD_IFS
SAN=${SAN#,}
cat >"$EXT" <<EOF
subjectAltName = $SAN
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
EOF
openssl req -newkey rsa:2048 -nodes \
-keyout "$OUT/server.key" -out "$OUT/server.csr" \
-subj "/CN=$CN/O=OtOpcUa test fixture" \
>/dev/null 2>&1
openssl x509 -req -in "$OUT/server.csr" \
-CA "$OUT/ca.crt" -CAkey "$OUT/ca.key" -CAcreateserial \
-out "$OUT/server.crt" -days "$DAYS" -sha256 -extfile "$EXT" \
>/dev/null 2>&1
# --- password file --------------------------------------------------------
# `--user` so the file lands owned by the invoking user rather than by root: the host
# user must be able to chmod it below, and (unlike a root-owned 0600 file) the broker
# container's `mosquitto` user must be able to READ it.
rm -f "$OUT/passwd"
docker run --rm --user "$(id -u):$(id -g)" -v "$OUT":/out "$MOSQUITTO_IMAGE" \
mosquitto_passwd -b -c /out/passwd "$USERNAME" "$MQTT_FIXTURE_PASSWORD" >/dev/null
# mosquitto runs as uid 1883 inside the container and reads these through a read-only
# bind mount, so they must be world-readable. This is a throwaway fixture key by
# construction — see the banner.
chmod 0644 "$OUT/ca.crt" "$OUT/server.crt" "$OUT/server.key" "$OUT/passwd"
chmod 0600 "$OUT/ca.key"
echo "==> done. Files written:"
ls -l "$OUT"
echo
echo "Next:"
echo " 1. Export the same creds for the test suite:"
echo " export MQTT_FIXTURE_USERNAME='$USERNAME'"
echo " export MQTT_FIXTURE_PASSWORD='<the same password>'"
echo " export MQTT_FIXTURE_CA_CERT='$OUT/ca.crt'"
echo " 2. Push this directory to the Docker host and bring the stack up."
@@ -0,0 +1,65 @@
# Eclipse Mosquitto configuration for the OtOpcUa MQTT driver integration fixture.
#
# SECURITY POSTURE — deliberate, and the whole reason this fixture exists.
# `allow_anonymous false` is set GLOBALLY (with per_listener_settings false), so it
# governs BOTH listeners. An anonymous fixture would let the driver's auth and TLS
# code paths — including the CA-pin logic in MqttConnection.ConfigureTls /
# ValidateAgainstPinnedCa — go completely untested, which is exactly backwards: those
# are the paths whose failure mode is silent (a pin that never rejects anything looks
# identical to a pin that works).
#
# The password file and the TLS material are NOT in git. Generate them first with
# `./gen-fixture-material.sh` (see that script's banner); it writes ./secrets/, which
# .gitignore excludes.
# One global security policy rather than per-listener ones: with
# per_listener_settings true, `allow_anonymous`/`password_file` would have to be
# repeated under EVERY listener block, and a listener that forgot them would silently
# fall back to allowing anonymous connections. Global + fail-closed is the safer shape.
per_listener_settings false
allow_anonymous false
password_file /mosquitto/secrets/passwd
# No persistence: the fixture is throwaway, and a persisted retained-message store
# surviving a `docker compose down` would make the retained-seed test's outcome depend
# on a previous run rather than on this one's publisher.
persistence false
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
connection_messages true
# ---------------------------------------------------------------------------
# Plain (non-TLS) listener — smoke only.
#
# Still authenticated (allow_anonymous false above applies here too); it is the
# TRANSPORT that is plaintext, not the access policy. It exists so the subscribe /
# retained-seed / unauthored-topic legs can be exercised without dragging the TLS
# handshake into every assertion, and so an operator can `mosquitto_sub` the fixture
# by hand. Never a model for a deployment.
# ---------------------------------------------------------------------------
listener 1883 0.0.0.0
protocol mqtt
# ---------------------------------------------------------------------------
# TLS listener — the real one.
#
# `require_certificate false`: the driver authenticates with username/password over
# TLS (the shape MqttDriverOptions models today); mutual TLS is not a driver feature
# yet, so demanding a client certificate here would make the fixture untestable rather
# than more secure.
#
# tls_version is deliberately NOT pinned: mosquitto's `tls_version` selects exactly
# one version, so pinning 1.2 would refuse a .NET client that negotiated 1.3. Leaving
# it unset lets OpenSSL negotiate the best mutually-supported version.
# ---------------------------------------------------------------------------
listener 8883 0.0.0.0
protocol mqtt
cafile /mosquitto/secrets/ca.crt
certfile /mosquitto/secrets/server.crt
keyfile /mosquitto/secrets/server.key
require_certificate false
@@ -0,0 +1,67 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# The fixture's JSON publisher. Runs inside a second eclipse-mosquitto container (the
# image already ships mosquitto_pub, so no bespoke image / paho dependency is needed)
# and feeds the broker over the PLAIN listener from inside the compose network —
# authenticated like every other client, since the broker is allow_anonymous false.
#
# Topics (all under $TOPIC_PREFIX, default otopcua/fixture):
#
# retained/seed published EXACTLY ONCE, retain=true, never republished.
# This is what makes the retained-seed test meaningful: a
# subscriber that connects minutes later and still receives a
# value on this topic can only have received the BROKER'S
# RETAINED COPY. On a topic that is also republished every few
# seconds, a retained seed is indistinguishable from a live
# publish that happened to arrive quickly.
# line1/temperature JSON object, retain=true, republished every $PUBLISH_INTERVAL
# line1/counter JSON object, retain=true, monotonically increasing
# line1/state bare scalar text (no JSON), retain=true
# noise/chatter JSON, retain=FALSE — deliberately never authored as a tag.
# A broker carrying traffic the deployment did not author is
# the normal case, and the driver must stay silent about it.
# ---------------------------------------------------------------------------
set -eu
HOST=${BROKER_HOST:-mosquitto}
PORT=${BROKER_PORT:-1883}
PREFIX=${TOPIC_PREFIX:-otopcua/fixture}
INTERVAL=${PUBLISH_INTERVAL:-2}
: "${MQTT_FIXTURE_USERNAME:?publisher needs MQTT_FIXTURE_USERNAME}"
: "${MQTT_FIXTURE_PASSWORD:?publisher needs MQTT_FIXTURE_PASSWORD}"
pub_retained() {
mosquitto_pub -h "$HOST" -p "$PORT" \
-u "$MQTT_FIXTURE_USERNAME" -P "$MQTT_FIXTURE_PASSWORD" \
-q 1 -r -t "$1" -m "$2"
}
pub_volatile() {
mosquitto_pub -h "$HOST" -p "$PORT" \
-u "$MQTT_FIXTURE_USERNAME" -P "$MQTT_FIXTURE_PASSWORD" \
-q 0 -t "$1" -m "$2"
}
echo "publisher: broker=$HOST:$PORT prefix=$PREFIX interval=${INTERVAL}s"
# The one-shot retained seed. Published before the loop starts and never again.
pub_retained "$PREFIX/retained/seed" \
'{"value":42.5,"unit":"C","note":"published once at fixture start; never republished"}'
echo "publisher: retained seed published on $PREFIX/retained/seed"
n=0
while true; do
n=$((n + 1))
temperature=$(awk "BEGIN { printf \"%.2f\", 20 + ($n % 50) / 10 }")
pub_retained "$PREFIX/line1/temperature" \
"{\"value\":$temperature,\"unit\":\"C\",\"seq\":$n}"
pub_retained "$PREFIX/line1/counter" "{\"value\":$n}"
pub_retained "$PREFIX/line1/state" "RUNNING"
# Unauthored chatter — the driver must never materialise a tag for this.
pub_volatile "$PREFIX/noise/chatter" "{\"value\":$n,\"source\":\"chatter\"}"
sleep "$INTERVAL"
done