First real run of the #780 installer (windev, #800 gate) failed on both hosts and its rollback failed too:
- PowerShell 7: DirectoryInfo.GetAccessControl/SetAccessControl do not exist on .NET. Use Get-Acl / Set-Acl.
- Windows PowerShell 5.1: an if-expression unrolled the wanted ACE to a scalar ($null on -Remove), so .Count threw under
StrictMode. Wrap it in @(...).
- Found on the re-run: an apply from 5.1 was not idempotent. 5.1 passes the literal quotes in sc.exe binPath= with legacy
quoting, so the anchor path was stored unquoted and AnchorService reported 'changed' on every run. sc create/config now
runs through Invoke-OtOpcUaSc with an explicit command line, identical on both hosts.
Adds scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1: a Windows-executed dry run -> apply -> re-apply ->
uninstall -> uninstall test (scratch names, stand-in host service) for either host. The plan-only harness cannot see these.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The baseline is 83ed880e (SDK 1.5.378.182). The candidate is the #880 change (on da948e5f), run twice.
Fixed: every SDK 2 client (Shared, OpcUaClient driver, CLI) logs in to the SDK 1.5
server with a 65-char key. A wrong 65-char key gets BadIdentityTokenRejected. In the
control, the SDK default handler is refused by 1.5 with BadIdentityTokenInvalid and
admitted by SDK 2.
Mutation (the factory reverted to the SDK default handler): the SDK 2 Shared connect,
the driver connect and all three CLI calls fail against 1.5 with
BadIdentityTokenInvalid, and the wrong-key case gets Invalid instead of Rejected. This
is #880 reproduced.
In both runs the UNS cases fail against the master server for every client SDK. That
is a fixture gap since #808 and is tracked as #888.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The cross-version matrix (#782) used a 55-char 'otua_matrix_' key, which stayed under
SDK 2's 64-byte RsaEncryptedSecret threshold and hid #880. It now uses the
ApiKeySecret.Generate shape (65 chars, random per run, never recorded). Every Shared
leg adds production-length-API-key-wrong-secret-reaches-key-check, which must get
BadIdentityTokenRejected rather than a format failure. SDK 2 clients also add
sdk2-default-RsaEncryptedSecret-control, the negative control: the SDK's default
handler must be refused with BadIdentityTokenInvalid by a 1.x server and admitted
by an SDK 2 server. The runner passes the server SDK version from ready.json.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
SDK 2.0.1's UserNameIdentityTokenHandler encrypts a password longer than 64 bytes
(a private RsaEncryptedSecretPasswordThreshold, no setting) as an RsaEncryptedSecret
with EncryptionAlgorithm=null. SDK 1.5.378 (.106 and .182) decrypts only the legacy
format under RSA policies; its SecurityPolicies.Decrypt answers a null algorithm with
BadIdentityTokenInvalid ("Unexpected encryption algorithm") before any key check.
Every production API key is 65 chars, so no SDK 2 OtOpcUa client could use one
against QA (b8b4c18f), a rollback target or an older third-party server.
LegacyRsaUserNameIdentityTokenHandler (Driver.OpcUaClient.Session, plugged in through
the public UserIdentity(IUserIdentityTokenHandler) ctor) always uses the legacy
format, RSA-OAEP(len|password|serverNonce) under the server certificate, under an RSA
policy. That is what SDK 2 sends for passwords of 64 bytes or less and what SDK 1.5
clients always send. The legacy format has no size limit (the plaintext is
length-framed over as many RSA blocks as it needs), and an SDK 2 server reads both
formats. Policy None, the ECC policies and a missing certificate go to the SDK
handler unchanged.
OpcUaUserIdentityFactory.UserName is now the one UserName identity the driver, the
AdminUI browse and Client.Shared (CLI/UI) present. Client.Shared references .Session
for it.
Tests: handler unit tests (all three RSA policies, the 1.5-required algorithm URI,
SDK 2 server-side decrypt, the session Copy() keeps the type, None unchanged, and an
SDK control pinning the 64/65-byte switch); a Client.Shared identity test; and an
SDK2<->SDK2 integration test where a 65-char key is admitted in both formats and a
wrong 65-char key reaches the key check (BadIdentityTokenRejected). The #800 Windows
gate's SDK 2 CLI 65-char case now gates against the 1.5 server too.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Adds scripts/validation/opcua-windows-service/: a phased PowerShell gate (Invoke-ServiceGate.ps1)
that installs the known-good build as the real OtOpcUaHost service via Install-Services.ps1, seeds
LocalDb through the production cache API (Seeder, built against the baseline tree), upgrades in
place with config/PKI/LocalDb carried with their ACLs, starts/stops through the SCM, runs a strict
SDK 2 probe matrix (Sign + SignAndEncrypt, valid/wrong/unknown/disabled/anonymous keys, untrusted
client and server), exercises the ScriptWorker isolation, rolls back and cleans up.
windev 2026-11-12 (efb9197a -> 438484fd -> efb9197a): app cert CEF9D91F... reused byte-for-byte
with identical PKI SDDL on every start, all gating checks pass, 0 secrets in logs. Findings filed:
#879 (None endpoint cannot be dropped), #880 (SDK 2 client 65-char key vs SDK 1.5 server:
rollback/QA risk), #883 (Install-ScriptWorkerIsolation.ps1 fails on pwsh 7 and 5.1; runtime
isolation proven with the patch in the evidence directory).
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Console helper that mints and revokes OPC UA API keys through the same IApiKeyAdminService the AdminUI
/apikeys page calls, edits NodeAcl grants in the rig ConfigDb, and holds one OPC UA session open per node
(Client.Shared) with a subscription plus a read/write every 3 s, so a revocation, expiry or grant removal
can be observed landing on an ALREADY-OPEN session on both pair nodes next to a non-revoked control key.
Credentials go to a 0600 file and are never printed. LOCAL docker-dev only.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
AvevaGatewayHistoryLiveGateTests boots the server in-process, wires the production
Gateway backend (HistorianBackendRegistry -> GatewayHistorianDataSource) to a real
HistorianGateway and drives HistoryRead over a real SDK session: raw (all, paged,
reverse, bounds), modified, the ten served aggregates and seven refused ones, AtTime,
events (all, 1-event pages, reverse), a missing tag, HistoryServerCapabilities and
node metadata, and a HistoryUpdate that must be refused (sandbox-bound node only).
Every wire answer is compared with the backend's direct answer; the run is dumped as
JSON for cross-SDK and SQL comparison. Skips unless HISTGW_GATEWAY_ENDPOINT and
HISTGW_GATEWAY_APIKEY are set; read-only by construction.
scripts/validation/aveva-history-801: run/compare instructions, the SDK 1.5.378
baseline port patch (API spelling only), and the diff / SQL / aggregate / histgw-cli
comparison scripts.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Probe 'failover <primary> <backup> <mode> <dir>' drives ScadaBridge's own DataConnectionActor
(production primary/backup machine, real DataConnectionFactory, default DataConnectionOptions,
FailoverRetryCount 3) against two servers; run.py loses the primary (SIGKILL or SIGSTOP) once the
probe reports a steady primary, and the probe requires Bad quality on loss, "Connected to backup",
the backup's own value, a fresh ConditionRefresh replaying the native condition, and a write via the
backup. rig-failover.sh runs the same probe against the local docker-dev central pair.
Matrix legs gain a UNS subscription case and a Server-object alarm subscription on the reconnected
session whose ConditionRefresh must replay the active condition (11 cases). run.py gains --versions,
--suites, --failover-modes and --failover-loss.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Probe 'prepare' provisions the consumer identity via OpcUaApplicationIdentity.EnsureApplicationCertificateAsync
and exports the public DER; runner installs it in the server trusted store before start. Adds Sign
(OPCUA_MATRIX_SERVER_SECURITY=sign), untrusted-client / untrusted-server fail-closed controls per secure
mode, and OPCUA_MATRIX_ACL=off. Autoaccept stays false everywhere.
Hosted v2-integration run 1224 got past the old fixture-host timeout and
failed one step earlier: "Bind for :::19000 failed: port is already
allocated" — the runner's docker daemon is shared. Fixtures now retry on the
next host port when one is taken (opc-plc keeps host == listen port, as its
advertised endpoint requires) and export the ports actually used; container
names carry the run id so a concurrent run's `down` can't remove them, and a
new `logs` subcommand replaces the workflow's hard-coded name list.
Verified locally: with 19000 squatted, QuestDB moved to 18813/19001 and every
fixture reached ready; logs/down found the suffixed containers.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Read-only ConfigDb preflight (scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql)
covering every deploy gate added since the deployed build: VirtualTag/Calculation effective
triggers (#783), VT->VT reads and VirtualTagDependencyCycle (#791), newly forbidden script
APIs (#792, E2), credential-reference namespace / other-instance / reserved-name rules
(#815#835#836#848#849#850), MTConnectMultipleDevices, Sql query/write gates (#781),
Heidenhain, UNS effective-name collisions and the FOCAS renames/value changes. Verified on a
throwaway b8b4c18f-schema database (every check fires on seeded rows) and clean against the
docker-dev config.
Upgrade notes (docs/deploy/vd03-upgrade-b8b4c18f-to-master.md): the EnableSnapshotIsolation
migration and how to apply it by hand, the env-var audit (Security:ApiKeys:Pepper is now
required and absent on the box; JWT key validated at start), SDK 2.0.1 / CA KeyUsage, the
ScadaBridge-visible changes, swap/rollback and post-deploy checks.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
catthehacker/ubuntu:act-latest ships no iproute2, so fixture_host() fell back to 127.0.0.1 inside the
job container and every published-port fixture timed out (QuestDB is the first one probed over the
network). Read the default gateway from /proc/net/route in plain bash, warn when none is found, and log
the chosen host. Verified in that image on a user-defined network: gateway parsed, published port 200.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The persistent NAT PKI (ae91e6b7) re-verified 2026-11-11: same thumbprint
1FBF8791... across --force-recreate on 10.100.0.35, matching the committed
opc-plc-nat-server.der; NAT class 7/7 against the shared stack and against a
CI-style throwaway container + docker-exec cert hand-off. MIN_EXECUTED stays
at 440: no hosted v2-integration run has reached the test step (#860).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Decision: CI stays hermetic (no dependency on the 10.100.0.35 box or its
state), but must run the same engine. integration-fixtures.sh now takes the
questdb/questdb:<tag> line from the #822 fixture compose file (10.0.1 today,
same as the docker-dev overlay) and fails 'up' if it is missing;
CI_QDB_IMAGE overrides for a trial. Documented in scripts/ci/README.md.
Co-Authored-By: Claude <noreply@anthropic.com>
#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.
Docker/nat/docker-compose.yml (stack /opt/otopcua-opcuaclient-nat on 10.100.0.35,
project: lmxopcua label): opc-plc 2.14.10 advertising --ph=otopcua-opc-plc-nat-private.invalid
on internal 50000, published on 50010, so every advertised URL is unreachable on host AND
port. Deployed; NatPublishedEndpointBrowseTests 3/3 against opc.tcp://10.100.0.35:50010
(and the secure case positive once, with the fixture cert temporarily trusted).
CI: integration-fixtures.sh starts a throwaway otopcua-ci-opcplc-nat (50110->50000) and
exports OPCUA_NAT_ENDPOINT; v2-e2e.yml points it at the shared stack. infra/README.md,
scripts/ci/README.md and the fixture README document it.
Scripted condition NodeIds are opaque ScriptedAlarmIds, so splitting them as a
topology path could never match an Equipment grant: a narrow equipment grant was
denied while a Cluster grant passed.
- OtOpcUaNodeManager binds each LIVE scripted condition instance to the
EquipmentId it was materialised under (only when it hangs under that folder);
dropped on remove / drop-recreate / equipment-subtree removal / rebuild; a
same-id re-materialise under another equipment re-parents and re-binds.
- DataPlaneAuthoritySnapshot carries EquipmentScopePaths (EquipmentId ->
[uns, Area, Line, Equipment]) decoded from the artifact by the same
AclScopePathResolver call that plants Equipment grants.
- DataPlaneAuthorizer.AuthorizeScriptedCondition re-reads key, grant and
generation per call (uncached) and evaluates at the owner path; unbound,
replaced or unplaceable owners deny under every grant. No id-split or
realm-root fallback.
- Native conditions (RawPath) and the ACL-off role gate are unchanged.
NodeIds and AlarmCommand.AlarmId are unchanged.
Sdk2OpaqueScriptedScopeBaselineTests deliberately flipped: it pinned the denial
and now pins owner-grant allow / sibling deny / cluster allow. Follow-up #808
(folder ids + Server-object event visibility for narrow UNS grants).