a83bf1fa27
v2-ci / unit-tests (push) Has been cancelled
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
92 lines
7.9 KiB
Markdown
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.
|