Files
lmxopcua/scripts/ci/integration-fixtures.sh
T
Joseph Doherty ae91e6b7f7 feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)
#816 finding: the SDK checks that the server certificate names the host only
when a session is created with checkDomain:true. The driver, the AdminUI
browser and Client.Shared all called the 8-argument
DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no
host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine
over an IP in #813). When enabled the SDK (Session.OpenAsync ->
ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost
— the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector
rewrote the advertised one — against the cert's DNS/IP SANs; secured
sessions only, and the auto-accept handler accepts a mismatch.

Policy: certificateValidation.validateServerHostName, tri-state. Unset =
OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the
pre-#816 behaviour, so nothing changes on upgrade; true = validate the
configured host; false = explicit NAT/port-publish opt-out. Threaded into the
driver, the AdminUI browser (same JSON) and the AdminUI form (a select that
keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName
and the CLI --validate-host-name. A driver refusal names the opt-out.

#818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified:
same thumbprint across --force-recreate), its PUBLIC cert is committed as
Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run
every secure browse on a throwaway PKI (internal browser seam): refusal with
nothing trusted, positive with the cert trusted (unset/false host check), and
a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is
OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch);
OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT
container's public cert. ServerHostNameValidationTests pin the driver against
the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in
v2-integration (440 -> 446), documented in scripts/ci/README.md.
2026-11-11 01:33:00 -05:00

116 lines
5.9 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# integration-fixtures.sh — throwaway, self-contained fixtures for the v2-integration workflow (#757).
#
# up start SQL Server, PostgreSQL (seeded), QuestDB, opc-plc and a port-NAT opc-plc as containers named
# otopcua-ci-*, wait until each is usable, and print the env exports the
# *.IntegrationTests fixtures read (also appended to $GITHUB_ENV when set).
# down remove those containers (idempotent; touches nothing else).
#
# Deliberately NOT the shared 10.100.0.35 host: CI must not depend on that box, and these
# containers are created and destroyed inside the job. Host ports are non-default so a
# developer's own fixtures are never clobbered when the script is run locally.
#
# Reaching the containers: on the host (or a host-network job) that is 127.0.0.1; when the job
# itself runs inside a container (/.dockerenv) the published ports live on the docker host,
# i.e. the container's default gateway. Override with FIXTURE_HOST.
set -euo pipefail
SQL_PORT="${CI_SQL_PORT:-14340}"
PG_PORT="${CI_PG_PORT:-15440}"
QDB_PORT="${CI_QDB_PORT:-18812}"
QDB_HTTP_PORT="${CI_QDB_HTTP_PORT:-19000}"
OPC_PORT="${CI_OPC_PORT:-50100}"
# #813 NAT opc-plc: published on OPC_NAT_PORT, listening on 50000 inside, advertising a private `.invalid`
# host — so its GetEndpoints URLs are unreachable on BOTH host and port (NatPublishedEndpointBrowseTests).
OPC_NAT_PORT="${CI_OPC_NAT_PORT:-50110}"
# Throwaway-container-only credentials (never used against anything else).
SA_PASSWORD="${CI_SA_PASSWORD:-Ci_throwaway_Pw1}"
PG_PASSWORD="${CI_PG_PASSWORD:-Ci_throwaway_pg1}"
NAMES=(otopcua-ci-mssql otopcua-ci-pg otopcua-ci-questdb otopcua-ci-opcplc otopcua-ci-opcplc-nat)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fixture_host() {
if [ -n "${FIXTURE_HOST:-}" ]; then echo "$FIXTURE_HOST"; return; fi
if [ -f /.dockerenv ] && command -v ip >/dev/null 2>&1; then
ip route | awk '/^default/{print $3; exit}'; return
fi
echo 127.0.0.1
}
down() { docker rm -f "${NAMES[@]}" >/dev/null 2>&1 || true; }
wait_for() { # <description> <timeout-seconds> <command...>
local what="$1" t="$2"; shift 2
local end=$((SECONDS + t))
until "$@" >/dev/null 2>&1; do
if [ "$SECONDS" -ge "$end" ]; then echo "TIMEOUT waiting for $what" >&2; return 1; fi
sleep 3
done
echo "ready: $what"
}
up() {
command -v docker >/dev/null || { echo "docker not available" >&2; exit 1; }
down
docker run -d --name otopcua-ci-mssql -e ACCEPT_EULA=Y -e MSSQL_PID=Developer \
-e "MSSQL_SA_PASSWORD=$SA_PASSWORD" -p "$SQL_PORT:1433" \
mcr.microsoft.com/mssql/server:2022-latest >/dev/null
docker run -d --name otopcua-ci-pg -e "POSTGRES_PASSWORD=$PG_PASSWORD" -e POSTGRES_DB=sqlpollfixture \
-p "$PG_PORT:5432" postgres:17-alpine >/dev/null
docker run -d --name otopcua-ci-questdb -p "$QDB_PORT:8812" -p "$QDB_HTTP_PORT:9000" \
questdb/questdb:10.0.1 >/dev/null
docker run -d --name otopcua-ci-opcplc -p "$OPC_PORT:$OPC_PORT" \
mcr.microsoft.com/iotedge/opc-plc:2.14.10 --pn="$OPC_PORT" --ut --aa --alm >/dev/null
# Mirrors tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/docker-compose.yml.
docker run -d --name otopcua-ci-opcplc-nat --hostname otopcua-ci-opcplc-nat-private -p "$OPC_NAT_PORT:50000" \
mcr.microsoft.com/iotedge/opc-plc:2.14.10 --pn=50000 --ph=otopcua-ci-opcplc-nat-private.invalid --ut --aa >/dev/null
wait_for "postgres" 120 docker exec otopcua-ci-pg pg_isready -U postgres -d sqlpollfixture
# pg_isready can answer during initdb's temp server; require a real query on the final server.
wait_for "postgres query" 60 docker exec -e "PGPASSWORD=$PG_PASSWORD" otopcua-ci-pg \
psql -h 127.0.0.1 -U postgres -d sqlpollfixture -c 'select 1'
docker exec -i -e "PGPASSWORD=$PG_PASSWORD" otopcua-ci-pg \
psql -v ON_ERROR_STOP=1 -v "app_password=$PG_PASSWORD" -h 127.0.0.1 -U postgres -d sqlpollfixture \
< "$ROOT/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql" >/dev/null
echo "seeded: postgres"
local sqlcmd=/opt/mssql-tools18/bin/sqlcmd
wait_for "sql server" 300 docker exec otopcua-ci-mssql "$sqlcmd" -C -S localhost -U sa -P "$SA_PASSWORD" -Q 'SELECT 1'
wait_for "questdb" 120 curl -fsS "http://$(fixture_host):$QDB_HTTP_PORT/exec?query=select%201"
wait_for "opc-plc" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_PORT"
wait_for "opc-plc (NAT)" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_NAT_PORT"
# #818: hand the NAT server's PUBLIC certificate to NatPublishedEndpointBrowseTests (OPCUA_NAT_SERVER_CERT) so the
# positive secure / host-name cases run here too. Copied out of the container — the trust channel is docker itself,
# not the wire. The throwaway container makes a fresh certificate per run, so the committed shared-host copy cannot
# be used; the private key never leaves the container.
local nat_cert="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/otopcua-ci-fixtures/opc-plc-nat-server.der"
mkdir -p "$(dirname "$nat_cert")"
wait_for "opc-plc (NAT) certificate" 60 bash -c \
"docker exec otopcua-ci-opcplc-nat bash -c 'cat /app/pki/own/certs/*.der' > '$nat_cert' && [ -s '$nat_cert' ]"
local h; h="$(fixture_host)"
local -a exports=(
"SQL_TEST_ENDPOINT=$h,$SQL_PORT"
"SQL_TEST_USER=sa"
"SQL_TEST_PASSWORD=$SA_PASSWORD"
"SQL_PG_ENDPOINT=$h:$PG_PORT"
"SQL_PG_PASSWORD=$PG_PASSWORD"
"HISTORIAN_SQL_TEST_CONNECTION=Server=$h,$SQL_PORT;User Id=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;Encrypt=False"
"HISTORIAN_QUESTDB_TEST_CONNECTION=Host=$h;Port=$QDB_PORT;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading"
"OPCUA_SIM_ENDPOINT=opc.tcp://$h:$OPC_PORT"
"OPCUA_NAT_ENDPOINT=opc.tcp://$h:$OPC_NAT_PORT"
"OPCUA_NAT_SERVER_CERT=$nat_cert"
)
for e in "${exports[@]}"; do
echo "export \"$e\""
[ -n "${GITHUB_ENV:-}" ] && echo "$e" >> "$GITHUB_ENV"
done
}
case "${1:-}" in
up) up ;;
down) down ;;
*) echo "usage: $0 up|down" >&2; exit 2 ;;
esac