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

92 lines
7.9 KiB
Markdown

# 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`](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
```powershell
$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.