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:
@@ -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
|
||||
+77
@@ -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"]
|
||||
Executable
+137
@@ -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
|
||||
+67
@@ -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
|
||||
Reference in New Issue
Block a user