Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/gen-fixture-material.sh
T
Joseph Doherty 033b3700c4 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
2026-07-24 17:49:14 -04:00

138 lines
6.0 KiB
Bash
Executable File

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