Files
lmxopcua/scripts/validation/opcua-windows-service/README.md
T
Joseph Doherty a83bf1fa27
v2-ci / unit-tests (push) Has been cancelled
fix(opcua): a configured OpcUa:EnabledSecurityProfiles list replaces the default, so None can be dropped (#879)
ConfigurationBinder appends configured collection items to an existing list, so
the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op:
every deployment that listed only the secure profiles still advertised an
unsigned/unencrypted None endpoint.

EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles()
applies the unchanged three-profile default only when nothing is configured.
A set-but-empty list fails startup validation and BuildSecurityPolicies no
longer falls back to a None endpoint on empty input (it throws). Sweep of all
options classes found no other non-empty pre-filled collection default.

Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a
booted-server wire test (no None endpoint, None connection refused with
BadSecurityPolicyRejected, secure connection works; default case is the control).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:38:42 -05:00

7.9 KiB

Windows service gate (lmxopcua#800)

This harness checks the SDK 2 upgrade on a real Windows node with the real OtOpcUaHost service. It installs a known-good SDK 1.5 build with scripts/install/Install-Services.ps1, then upgrades that install in place to the SDK 2 candidate. Config, LocalDb and PKI carry over with their ACLs. The service is then started and stopped twice, the ScriptWorker isolation installer is exercised, the known-good build is restored, and the node is cleaned up. The results of the first run are in evidence/2026-11-12-windev/RESULTS.md.

Where it may run. Use an approved Windows node that has no OtOpcUaHost service, from an elevated local or SSH session in PowerShell 7. The Wonder-box hold still applies: this is not a deploy tool for vd03/vq03/vq04.

Parts

Part What it is
Invoke-ServiceGate.ps1 One phase per invocation (see its header). Everything lives under -Root (default C:\OtOpcUa800).
Seeder/ Writes a deployment artifact into the LocalDb that the service created, through the production IDeploymentArtifactCache. The artifact contains: one raw folder; one UNS area/line/equipment; a timer-driven virtual tag whose script is return 800;; three API keys (a 65-char enabled key, a 54-char enabled key, a disabled key); and read grants. It uses only APIs that exist at both SDK lines, so it is built against the baseline tree (copy the folder to the same path in a baseline checkout).
Probe/ An SDK 2.0.1 client with peer auto-accept off. It can prepare a client certificate, list the advertised endpoints, or open a None/Sign/SignAndEncrypt Basic256Sha256 session and check for an expected result: allow, key-deny, peer-deny or anon-deny. On allow it reads ServerStatus.State, ServiceLevel and the virtual tag. The key comes from GATE_PROBE_KEY, never from an argument.
BindRepro/ Reproduces lmxopcua#879 (listing only the secure profiles does not remove None).

Inputs (<Root>\upload)

baseline-host, baseline-cli, candidate-host, candidate-cli are self-contained win-x64 publishes, made with -p:PublishSingleFile=false -p:PublishTrimmed=false. probe is the framework-dependent build of Probe/, and seeder-baseline is the framework-dependent publish of Seeder/ from the baseline tree. install-baseline and install-candidate are scripts/install/*.ps1,*.psm1 from each tree. Keep a manifest (sha256 relative/path, sorted) of every directory and compare it on the node before you start.

Order

$g = 'C:\OtOpcUa800\upload\Invoke-ServiceGate.ps1'
& $g -Phase Prepare                       # Install-Services.ps1 -Roles driver as NT SERVICE\OtOpcUaHost, node env, ACLs
& $g -Phase Boot  -Label b0 -AllowNotReady; & $g -Phase Stop -Label b0   # service creates PKI + LocalDb
& $g -Phase Seed; & $g -Phase Trust
& $g -Phase Boot -Label b1; & $g -Phase Checks -Label b1 -BaselineServer; & $g -Phase Stop -Label b1
& $g -Phase Upgrade                       # backup, stage separately, carry appsettings/pki/data/logs with /COPYALL, swap
& $g -Phase Boot -Label c1; & $g -Phase Checks -Label c1; & $g -Phase Stop -Label c1
& $g -Phase Boot -Label c2; & $g -Phase Checks -Label c2; & $g -Phase Stop -Label c2
& $g -Phase IsolationInstall -Label s1 -ApplyHost pwsh      # apply with 7, re-apply with 5.1 and 7
& $g -Phase Boot -Label c3; & $g -Phase Checks -Label c3; & $g -Phase IsolationVerify -Label c3; & $g -Phase Stop -Label c3
& $g -Phase IsolationUninstall -Label s1 -ApplyHost pwsh
# Optional second pass with the hosts swapped (apply with 5.1): IsolationInstall/Verify/Uninstall -Label s2 -ApplyHost powershell.exe
& $g -Phase Rollback
& $g -Phase Boot -Label r1; & $g -Phase Checks -Label r1 -BaselineServer; & $g -Phase Stop -Label r1
& $g -Phase Compare -Label b1,c1,c2,c3,r1
& $g -Phase Cleanup                       # then copy evidence off and remove <Root>

-Phase Observe -Label L records a start the gate did not make (an SCM auto-start after a host reboot). Every phase stops at the first error. A Checks failure lists the failing cases by name. Secrets live in <Root>\private\secrets.json, which only SYSTEM and Administrators can read. Every output and log is checked against every task secret, and the evidence keeps only the redacted copies and the occurrence counts.

Re-running only the isolation step. -Phase Prepare -PrepareFrom candidate installs the candidate directly (no baseline, upgrade or rollback), so baseline-host and install-baseline are not needed: Prepare, Boot b0 -AllowNotReady, Stop b0, Seed, Trust, Boot/Checks/Stop c1, then the isolation phases, then Cleanup.

What the checks mean

  • Strict channel. These are gating cases. The probe's peer auto-accept is off. A trusted client gets a Basic256Sha256 Sign session and a SignAndEncrypt session. An untrusted client (both modes) must be refused with BadSecurityChecksFailed, and a server the client does not trust must be refused with BadCertificateUntrusted.
  • Credentials. These are gating cases. The 65-character production-format key and the 54-character key must both be admitted. A wrong secret, an unknown key id, a disabled key and anonymous must all be refused.
  • Against the SDK 1.5 server (-BaselineServer), the probe's 65-character key is expected to be refused. The probe uses the SDK's own UserName handler, and SDK 2.0.1 sends any password over 64 bytes as RsaEncryptedSecret, which 1.5.378 rejects with BadIdentityTokenInvalid (lmxopcua#880). That makes the probe the SDK-default negative control. The production SDK 2 CLI forces the legacy format since #880, so cli-sdk2-encrypt-key-65 gates against both servers. A CLI built before the fix fails it against the baseline; the 2026-11-12 evidence predates the fix. The 54-character key and the SDK 1.5 CLI with the 65-character key remain the positive controls.
  • CLIs. The CLI that matches each SDK line reads ServerStatus over SignAndEncrypt with an API key. The CLIs auto-accept the server certificate by design, so these reads do not count as strict-peer coverage.
  • ScriptWorker isolation. IsolationInstall/IsolationUninstall run Install-ScriptWorkerIsolation.ps1 from <Root>\upload\install-candidate. The script runs under PowerShell 7 and Windows PowerShell 5.1, so both must work: -ApplyHost picks the host that applies (and rolls back first), the other host re-applies (every step must report unchanged) and does the second, idempotent uninstall. The dry run happens first, on both hosts. The first run of this gate (2026-11-12, evidence/2026-11-12-windev/) found the installer failing on both hosts (lmxopcua#883); it was exercised there with isolation-installer-883.diff, which is now merged. scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1 is the standalone Windows-executed apply / re-apply / uninstall test (scratch names, no OtOpcUaHost needed). IsolationVerify needs the service running. It checks:
    • worker processes are children of the host and run under a restricted token;
    • the elevated administrator is refused on the worker pipe;
    • script-runner health and the PE cache.
  • Findings, not gating. The script records which endpoints the server advertises, and the result of a session over the None endpoint (lmxopcua#879). The #879 fix landed after the 2026-11-12 windev run: a build with it must advertise no None endpoint when only the secure profiles are configured, and the None session must be refused.

Cleanup caution. Cleanup deletes OtOpcUaHost with sc.exe delete. It deliberately does not run scripts/install/Uninstall-Services.ps1, because that script also stops and deletes OtOpcUaWonderwareHistorian, which other work may still run (windev does). A virtual-account profile (C:\Windows\ServiceProfiles\OtOpcUaHost) stays loaded until the next reboot. Remove it afterwards.