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
6.6 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 -PatchedIsolation # see lmxopcua#883; drop the switch once the repo installer is fixed
& $g -Phase Boot -Label c3; & $g -Phase Checks -Label c3; & $g -Phase IsolationVerify -Label c3; & $g -Phase Stop -Label c3
& $g -Phase IsolationUninstall -PatchedIsolation
& $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.
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 withBadCertificateUntrusted. - 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 SDK 2 client's 65-character key is expected to be refused (lmxopcua#880). An SDK 2 client sends any password over 64 bytes asRsaEncryptedSecret, which 1.5.378 rejects withBadIdentityTokenInvalid. The 54-character key and the SDK 1.5 CLI with the 65-character key are the positive controls. - CLIs. The CLI that matches each SDK line reads
ServerStatusover 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/IsolationUninstallrunInstall-ScriptWorkerIsolation.ps1. The script runs once under PowerShell 7 and once under Windows PowerShell 5.1, so both must work. The script's dry run happens first. Without-PatchedIsolationthe repository copy is used, and that copy fails on both hosts (lmxopcua#883). With-PatchedIsolationit uses<Root>\upload\install-candidate-patched, which is the repository copy plusevidence/2026-11-12-windev/isolation-installer-883.diff.IsolationVerifyneeds 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-runnerhealth and the PE cache.
- Findings, not gating. The script records which endpoints the server advertises, and the result of a session over the
Noneendpoint (lmxopcua#879).
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.