# 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 (`\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 ``` `-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 `\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 `\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.