033b3700c4
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
138 lines
6.0 KiB
Bash
Executable File
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."
|