diff --git a/docs/ServiceHosting.md b/docs/ServiceHosting.md index 5b1e9adb7..73de846db 100644 --- a/docs/ServiceHosting.md +++ b/docs/ServiceHosting.md @@ -122,13 +122,41 @@ The plan is pure data asserted by `scripts/install/tests/Test-ScriptWorkerIsolat `Test-ScriptWorkerIsolation.Windows.ps1` applies it for real in a scratch folder (elevated), and `InstallerLayoutIsolationTests` (ScriptWorker.Tests, `OTOPCUA_ISOLATION_INSTALLER_E2E=1`, elevated) proves under the real restricted token that a worker cannot read `pki\`/`data\`/`logs\`/`backup\` of a scratch wonder layout and still runs. -Re-run the installer after a deploy that replaces the install folder: a rename/swap drops the folder ACEs, but files -copied into the existing folder inherit them. Rollback: `-Uninstall -ConfigureHostService`, then restart the host. +Rollback: `-Uninstall -ConfigureHostService`, then restart the host (`-Uninstall` alone also works from the record). + +#### After an upgrade that swaps the install folder (lmxopcua#901) + +A rename/swap drops the explicit ACEs on `script-worker\`, on the install folder and on any denied folder that is +recreated or copied in (vd03's `pki\`): a moved-in folder carries its own DACL. Files copied into an existing folder +inherit the ACEs. The installer therefore records its effective settings (`InstallRoot`, `HostServiceAccount`, +`PeCacheDir`, `HostServiceName`, `ConfigureHostService`, beside `DenyPaths`) under +`HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters`, and sets that key's DACL protected, SYSTEM + +Administrators only (no secrets are recorded; Users, a restricting SID of the worker token, can no longer read the list). +Two argument-free modes use the record: + +| Command | Does | +|---|---| +| `Install-ScriptWorkerIsolation.ps1 -Reapply` | Re-applies with the recorded settings; one `[changed]`/`[unchanged]` line per step and a `summary:` line. After a swap: the three folder ACEs `changed`, the rest `unchanged`; a second run all `unchanged`. | +| `Install-ScriptWorkerIsolation.ps1 -Verify` | Check only. `[ok]`/`[MISSING]` per step (anchor service, record + key ACL, PE cache ACL, `pe\`, `script-worker\`, install-folder deny, every `-DenyPaths` deny, firewall rule and the worker exe it names, the host env entries when recorded). **Exit 1** with `VERIFY FAILED: …` when anything is missing, exit 0 with `VERIFY OK`. Protected-DACL coverage gaps are WARNed, not failed. | + +Both refuse to run without a full record. A node isolated by an installer older than #901 recorded only `DenyPaths`: +run the install once with its full arguments (the per-box deploy record lists them) to write the record. + +`scripts/install/Invoke-OtOpcUaInstallSwap.ps1` is the swap with the gate built in: preflight (candidate carries +`script-worker\`, `PreviousInstall` is fresh and on the same volume, isolation record present when isolation is +installed, record `InstallRoot` equals `-InstallRoot`) → stop → move live → previous, candidate → live → copy `-Carry` +items back (default `appsettings*.json`, `pki`, `logs`; QA: `'appsettings*.json','logs'`; rollback: `-NoCarry`) → +`-Reapply` → `-Verify` → start (`-NoStart` for a pair window). A failed `-Verify` leaves the service **stopped**. Backups, +`data\` restore, migrations and health checks stay in the deploy's own steps. Every deploy pack that swaps the folder runs +it (or `-Reapply` + `-Verify` right after its own swap), and `-Verify` is a post-deploy gate. +`Test-ScriptWorkerIsolation.Windows.ps1` exercises the whole sequence (swap → `-Verify` fails → `-Reapply` → `-Verify` +passes → `-Reapply` unchanged → the swap script) on a scratch tree. ## Install / Uninstall - `scripts/install/Install-Services.ps1 -Roles admin,driver` — installs `OtOpcUaHost`. - `scripts/install/Install-ScriptWorkerIsolation.ps1` — the ScriptWorker identity, ACLs and firewall rule (above); only for `Scripting:Runner` ≠ `InProcess`. +- `scripts/install/Invoke-OtOpcUaInstallSwap.ps1` — upgrade/rollback folder swap with the isolation `-Reapply` + `-Verify` gate built in (lmxopcua#901, above). - `scripts/install/Uninstall-Services.ps1` — stops + removes the host service. (The historian backend is the external HistorianGateway — not installed/removed by these scripts.) ## Logging diff --git a/docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md b/docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md index dfae113ef..9d03b8846 100644 --- a/docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md +++ b/docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md @@ -56,6 +56,10 @@ Staged in `E:\ApiInstall\_stage-otopcua-6c4d2990\` (`prepare.ps1`, `swap.ps1`, ` (`E:\ApiInstall\OtOpcUa`) and `SensitiveDirDeny` (`…\OtOpcUa\pki`) applied as **changed**; the immediate re-apply reported **every step unchanged**. The runner stays `InProcess`; no restart needed. The same 6 pre-existing "protected DACL" warnings under `E:\psrem-stage` as before. + **Follow-up: lmxopcua#901.** The re-apply was a manual step with recorded arguments. From #901 the installer records its + settings and has `-Reapply` / `-Verify`, and `scripts/install/Invoke-OtOpcUaInstallSwap.ps1` runs both after the swap + (service left stopped if `-Verify` fails). Next vd03 deploy: run the full-argument install once to write the record, then + swap with the script and gate on `-Verify` ([`vd03-upgrade-b8b4c18f-to-master.md`](vd03-upgrade-b8b4c18f-to-master.md) §e/§f). ## Verification @@ -81,7 +85,8 @@ Details: [evidence, second run](../plans/evidence/2026-11-13-historian-700-vd03/ `E:\ApiInstall\_stage-otopcua-6c4d2990\rollback.ps1`: stop; move the live folder to `E:\ApiInstall\OtOpcUa.failed-6c4d2990`; restore `E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990` (`fea7de0a`); restore `OtOpcUa-data` from the pre-deploy backup; -start; re-apply the worker isolation. No schema undo (no migration in range). The ConfigDb `.bak` is there if the +start; re-apply the worker isolation. (From #901 the re-apply comes before the start, as `-Reapply` then `-Verify`: +`Invoke-OtOpcUaInstallSwap.ps1 -Candidate -PreviousInstall -NoCarry` does it.) No schema undo (no migration in range). The ConfigDb `.bak` is there if the configuration itself must go back. ## Observed (not deploy blockers) diff --git a/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md b/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md index 2549fd15c..87f0d387b 100644 --- a/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md +++ b/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md @@ -5,6 +5,10 @@ > ScriptWorker isolation installed. Tracking: #801 (SDK 2 live gate), #793 (ConfigDb preflight), #794 (worker > isolation install), #780 (P3 runner flip). Template: [`vd03-upgrade-b8b4c18f-to-master.md`](vd03-upgrade-b8b4c18f-to-master.md). > +> **lmxopcua#901 (2026-11-13):** a folder swap drops the worker-isolation folder ACEs. The installer now records its +> settings and has `-Reapply` / `-Verify`; `scripts/install/Invoke-OtOpcUaInstallSwap.ps1` runs both after the swap. QA's +> ACLs were checked intact over SMB; see "Worker isolation across the next swap" at the end for the next QA deploy. +> > **DEV vd03 moved on 2026-11-13:** `fea7de0a` → `6c4d2990` (#899 read-only historian bindings; #700 checks). Record: > [`2026-11-13-vd03-fea7de0a-to-6c4d2990.md`](2026-11-13-vd03-fea7de0a-to-6c4d2990.md). QA is unchanged. @@ -207,6 +211,8 @@ Rollback stays `… -ConfigureHostService -Uninstall`. It now also removes the d 3. imports `backup\OtOpcUaHost-pre-fea7de0a.reg`, which restores the pre-deploy environment, without the Scripting entries. The isolation ACEs, firewall rule and anchor service are harmless to `b8b4c18f`. No schema undo is needed. + **The rollback swaps `app\` back, which drops the folder ACEs (lmxopcua#901):** re-apply with `-Reapply` and gate with + `-Verify` before starting (see "Worker isolation across the next swap" below). `OtOpcUaConfig_pre-fea7de0a_20261112.bak` is the DB fallback. - **DEV:** `E:\ApiInstall\_stage-otopcua-fea7de0a\rollback.ps1` (→ `5fb4b4db`). - SDK 1.5 tooling caveat after a rollback: see the rollout runbook §Rollback item 4 (#880). @@ -351,4 +357,52 @@ None of these grants anything to `Everyone`, `BUILTIN\Users` or `RESTRICTED`. A **Deploy procedure from now on.** Write registry exports and appsettings backups into a backup folder that you create with a protected ACL (SYSTEM + Administrators only), and run the installer with the box-wide `-DenyPaths` list above. Any new app folder under `E:\ApiInstall` must be added to the list when it is installed. +## Worker isolation across the next swap (lmxopcua#901) + +**Was the 2026-11-12 QA swap before or after the isolation?** Before. The window swapped `app\` at 16:48:13–16:48:20 and +installed the #794 isolation on the new `app\` afterwards (QA window step 3, services stopped); the #890 and #891 re-applies +ran later the same evening, with no further swap. So nothing on QA was lost to a swap. **Checked 2026-11-13 from the Mac, +read-only over SMB** (a DACL query per folder, no remote exec), both vq03 and vq04 identical: + +| Folder | Worker identity ACEs | +|---|---| +| `E:\ApiInstall\OtOpcUa` | explicit Deny `0xD0157` (OI)(CI) | +| `…\OtOpcUa\app` | explicit Deny `0xD0157` + the inherited one | +| `…\app\script-worker` | explicit Allow `0x1200A9` (RX) (OI)(CI) ahead of the inherited deny | +| `…\OtOpcUa\pki`, `…\OtOpcUa\data` | explicit Deny `0xD0157` + inherited | +| `C:\ProgramData\ZB\kek` (protected) | explicit Deny `0xD0157` | +| `C:\ProgramData\OtOpcUa\script-pe-cache\pe` | explicit Allow `0x1301BF` (Modify) | +| `E:\ApiInstall\ScadaBridge`, `E:\psrem-stage` (#891 box-wide) | explicit Deny `0xD0157` | + +Controls: `C:\Windows` and the PE-cache root show no worker ACE (as designed). The anchor service, the firewall rule and +the host env entries are not visible over SMB; `-Verify` checks them at the next deploy. + +**Next QA deploy (both nodes, stop-both / start-both):** + +1. **One time, before the first swap with the #901 scripts:** QA was isolated by the older installer, which recorded only + `DenyPaths`. Run the full-argument install once from the staged `scripts\install` to write the settings record (and + lock its key to SYSTEM + Administrators). The `-DenyPaths` list is the #891 box-wide one: + ```powershell + $deny = 'E:\ApiInstall\OtOpcUa','E:\ApiInstall\OtOpcUa\pki','E:\ApiInstall\OtOpcUa\data','C:\ProgramData\ZB\kek', + 'E:\ApiInstall\ScadaBridge','E:\ApiInstall\HistorianGateway','E:\ApiInstall\MxAccessGateway','E:\ApiInstall\Overview', + 'E:\ApiInstall\btdbmanager','E:\psrem-stage','E:\psrem' + .\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService -DenyPaths $deny + .\Install-ScriptWorkerIsolation.ps1 -Verify # VERIFY OK + ``` + Check the list against `reg query HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters /v DenyPaths` + first and use exactly what is recorded. Expect `SettingsRecord` changed, every other step unchanged. +2. **Swap each node with the gate built in**, services stopped, then start both: + ```powershell + .\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -Candidate E:\ApiInstall\OtOpcUa\app.new- ` + -PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.-pre- -Carry 'appsettings*.json','logs' -NoStart + ``` + It stops the service, swaps, carries the box files, runs `-Reapply` and `-Verify`, and throws (service still stopped) if + `-Verify` fails. On QA `pki\` and `data\` sit beside `app\`, so only `app\` and its `script-worker\` lose their explicit + ACEs; expect the re-apply to report `WorkerFolderAcl` and `InstallRootDeny` changed. If the swap is done by hand instead, + run `-Reapply` then `-Verify` right after it, **before** starting the service. +3. **Post-deploy gate:** `Install-ScriptWorkerIsolation.ps1 -Verify` on each node → `VERIFY OK`, exit 0. Record the output. +4. **Rollback** swaps the old `app\` back, so it loses the ACEs the same way: `Invoke-OtOpcUaInstallSwap.ps1 -Candidate + …\app.bak.<…> -PreviousInstall …\app.failed- -NoCarry -NoStart`, then restore `data\`/the registry as before, then + start both. Without the script: `-Reapply` + `-Verify` after the folder moves. + **Rollback:** re-run the installer with the previous four-path list to remove the new denies, or use `-Uninstall`. To restore the `.reg` ACLs, run `icacls /reset`. diff --git a/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md b/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md index e03f408e3..6ef809e82 100644 --- a/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md +++ b/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md @@ -207,7 +207,21 @@ The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Roll - `Move-Item` the live install to a fresh sibling `$PreviousInstall`, then `Move-Item` the candidate into `E:\ApiInstall\OtOpcUa`. - Copy back the box's `appsettings*.json`, `pki\` and `data\`. + - **Re-apply and verify the worker isolation before the start (lmxopcua#901).** The swap drops the explicit + isolation ACEs on `script-worker\`, `E:\ApiInstall\OtOpcUa` and the copied-back `pki\`. Run + `Install-ScriptWorkerIsolation.ps1 -Reapply`, then `-Verify` (exit 1 = do not start; fix and re-run). - `Start-Service` and wait for Running. + + **From #901 on, use `scripts/install/Invoke-OtOpcUaInstallSwap.ps1`** for the stop / move / carry / re-apply / verify / + start sequence (it leaves the service stopped when `-Verify` fails): + `.\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.new- -PreviousInstall E:\ApiInstall\OtOpcUa.bak.-pre-` + (default `-Carry` = `appsettings*.json`, `pki`, `logs`; vd03's `data\` lives outside the install in `E:\ApiInstall\OtOpcUa-data`). + **One-time step at the first deploy with the #901 installer:** vd03 was isolated by the older installer, which recorded only + `DenyPaths`, so the swap script refuses until the record is written. Before the swap, run the full-argument install once + (§"Worker isolation and box-wide denies" below; it writes the settings record and locks the key): + `.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -HostServiceAccount LocalSystem -DenyPaths E:\ApiInstall,E:\ApiInstall\OtOpcUa\pki,E:\ApiInstall\OtOpcUa-data,C:\ProgramData\ZB\kek,E:\psrem-stage` + (add `-ConfigureHostService` only if the vd03 runner flip is part of that deploy). Expect `DenyPathRecord` unchanged and + `SettingsRecord` changed; then `-Verify` must print `VERIFY OK`. 5. **Never run `Install-Services.ps1`, `Uninstall-Services.ps1` or `Refresh-Services.ps1` here.** - `sc delete` wipes the 23 environment variables. - `Install-Services.ps1` rewrites the `Environment` value with only 2–3 entries. @@ -216,7 +230,9 @@ The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Roll 6. **Rollback.** - Stop the service and move the candidate to a quarantine path. - Restore `$PreviousInstall` (or the robocopy backup), including its `appsettings*.json`, `pki\` and `data\`. - - Check the certificate thumbprint, then start the service. + - Check the certificate thumbprint, re-apply and verify the worker isolation (`-Reapply`, `-Verify`; or run the + rollback as `Invoke-OtOpcUaInstallSwap.ps1 -Candidate -PreviousInstall -NoCarry`), + then start the service. - Clients reconnect. Do not replay writes or acks. No schema undo is needed (§a). Remove the pepper only if it got in the way; `b8b4c18f` ignores it. The @@ -228,6 +244,9 @@ The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Roll ## (f) Post-deploy checks +- **Worker isolation gate (lmxopcua#901), first.** `Install-ScriptWorkerIsolation.ps1 -Verify` from the staged + `scripts\install` (elevated) must print `VERIFY OK` and exit 0. Exit 1 lists each `[MISSING]` step: run `-Reapply`, + then `-Verify` again. Do this even when the runner is `InProcess` (vd03 today): the denies also cover the box-wide folders. - **Boot log** `logs\otopcua-YYYYMMDD.log`. Read its contents; the listed size shows 0 while the service holds it open. - Expect `OtOpcUa.Host starting with roles=[admin,driver]`, with no roles-disagreement warning. @@ -407,3 +426,8 @@ None of them grants anything to Everyone, Users or RESTRICTED. **After:** `/health/ready` returns 200 for OtOpcUa `:9000`, Overview `:5320`, ScadaBridge `:8085`, mxgw `:5130` and HG `:5220`. **Rollback:** `-Uninstall`, which removes the recorded denies, the anchor service, the firewall rule and the PE cache ACLs. To undo the lockdown on a backup file, run `icacls /reset`. + +**Every later swap (lmxopcua#901).** The 2026-11-13 upgrade (`6c4d2990`) showed that the folder swap drops `WorkerFolderAcl`, +`InstallRootDeny` and the `pki\` `SensitiveDirDeny`; re-running the line above restored them. From the #901 installer on, the +settings are recorded, so a swap is followed by `-Reapply` + `-Verify` with no arguments, or the whole swap runs through +`Invoke-OtOpcUaInstallSwap.ps1` (§e step 4). The first #901 run on vd03 must be the full-argument line above (one time). diff --git a/scripts/install/Install-ScriptWorkerIsolation.ps1 b/scripts/install/Install-ScriptWorkerIsolation.ps1 index 3e755fd41..382d6ae52 100644 --- a/scripts/install/Install-ScriptWorkerIsolation.ps1 +++ b/scripts/install/Install-ScriptWorkerIsolation.ps1 @@ -32,6 +32,9 @@ (HKLM\...\Services\\Parameters, value DenyPaths). A re-run removes the deny from folders dropped from the list; -Uninstall removes it from every recorded folder without the operator repeating -DenyPaths. + SettingsRecord (lmxopcua#901) the other effective arguments (InstallRoot, HostServiceAccount, PeCacheDir, + HostServiceName, ConfigureHostService) under the same key, whose DACL is set protected, + SYSTEM + Administrators only. No secrets. -Reapply and -Verify read them back. 6. FirewallBlockOutbound a Windows Firewall rule blocking all outbound traffic from \script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (every profile). The self-contained Windows publish starts the worker through that apphost; a framework-dependent @@ -40,8 +43,15 @@ in the OtOpcUaHost service environment. Scripting:Runner itself is NOT set here: the runner flip is a separate, per-tier decision (design §12, P2/P3). - Re-run it after any deploy that REPLACES the install folder (a rename/swap drops the folder ACEs; files copied into - an existing folder inherit them). The host must be restarted for -ConfigureHostService to take effect. + A deploy that REPLACES the install folder (a rename/swap) drops the explicit ACEs on script-worker\, on the install + folder and on any denied folder that is recreated or copied in (pki\ on vd03); files copied into an existing folder + inherit them (lmxopcua#901). After every swap run, with no other arguments: + + .\Install-ScriptWorkerIsolation.ps1 -Reapply # the recorded settings; prints changed/unchanged per step + .\Install-ScriptWorkerIsolation.ps1 -Verify # check only; exit 1 and a [MISSING] line per gap + + Invoke-OtOpcUaInstallSwap.ps1 does both after its swap and does not start the service when -Verify fails. The host + must be restarted for -ConfigureHostService to take effect. After an apply it walks every denied folder and WARNs about any file or folder whose DACL is protected (does not inherit) and has no deny of its own; the inherited deny does not reach those. Without -DenyPaths it WARNs when the @@ -74,6 +84,16 @@ Roll everything back: the firewall rule, every explicit ACE for the worker identity, the anchor service, and (with -ConfigureHostService) the env entries. The PE cache is kept unless -RemovePeCache. +.PARAMETER Reapply + lmxopcua#901. Re-apply with the settings the last install recorded (no other argument except -WorkerServiceName, + -WhatIf). Fails when nothing is recorded, or when the box was installed by an installer older than #901 (that one + recorded only DenyPaths): run the full-argument install once, which writes the record. + +.PARAMETER Verify + lmxopcua#901. Check only: compares every install step with the machine, prints [ok] or [MISSING] per step and exits 1 + when anything is missing (0 when all is in place). Uses the recorded settings, or -InstallRoot/-HostServiceAccount/... + when given. Protected-DACL coverage gaps are WARNed, as after an apply, and do not fail the check. + .PARAMETER PlanOnly Print the plan and return it as objects without touching (or even reading) the machine. Runs on any OS; used by the dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1. -HostServiceAccount must then be a SID. @@ -87,6 +107,11 @@ .EXAMPLE .\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall +.EXAMPLE + # After an upgrade swapped the install folder (lmxopcua#901): re-apply from the record, then gate on the check. + .\Install-ScriptWorkerIsolation.ps1 -Reapply + .\Install-ScriptWorkerIsolation.ps1 -Verify; if ($LASTEXITCODE) { throw 'worker isolation incomplete' } + .EXAMPLE # The wonder layout: binaries in app\, keys in pki\, secrets.db + LocalDb in data\ (lmxopcua#890). .\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService ` @@ -94,8 +119,9 @@ #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( - [Parameter(Mandatory)] [string]$InstallRoot, - [Parameter(Mandatory)] [string]$HostServiceAccount, + # Required unless -Reapply / -Verify / -Uninstall take them from the record (lmxopcua#901). + [string]$InstallRoot, + [string]$HostServiceAccount, [string]$PeCacheDir, [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$HostServiceName = 'OtOpcUaHost', @@ -103,6 +129,8 @@ param( [switch]$ConfigureHostService, [switch]$Uninstall, [switch]$RemovePeCache, + [switch]$Reapply, + [switch]$Verify, [switch]$PlanOnly ) @@ -110,6 +138,34 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force +# lmxopcua#901: -Reapply / -Verify / -Uninstall without arguments run from what the last install recorded. +if (@($Reapply, $Verify, $Uninstall, $PlanOnly | Where-Object { $_ }).Count -gt 1) { throw '-Reapply, -Verify, -Uninstall and -PlanOnly are exclusive' } +if ($Reapply) { + $given = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'DenyPaths', 'ConfigureHostService', 'RemovePeCache' | + Where-Object { $PSBoundParameters.ContainsKey($_) }) + if ($given.Count) { throw "-Reapply takes its settings from the record; do not pass -$($given -join ', -') (run a normal install to change them)" } +} +$fromRecord = $false +if (($Reapply -or $Verify -or $Uninstall) -and -not $InstallRoot -and -not $HostServiceAccount) { + # The record key is SYSTEM + Administrators only, so a non-elevated read would look like "nothing recorded". + $me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run elevated (the recorded settings are readable by SYSTEM and Administrators only)' } + $rec = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName + if (-not $rec) { + throw ("no recorded ScriptWorker isolation settings under HKLM\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters. " + + 'Either isolation is not installed, or it was installed by an installer older than lmxopcua#901 (which recorded only DenyPaths): ' + + 'run the install once with its full arguments (-InstallRoot, -HostServiceAccount, -DenyPaths, [-ConfigureHostService]); that writes the record.') + } + $InstallRoot = $rec.InstallRoot + $HostServiceAccount = $rec.HostServiceAccount + if (-not $PSBoundParameters.ContainsKey('PeCacheDir') -and $rec.PeCacheDir) { $PeCacheDir = $rec.PeCacheDir } + if (-not $PSBoundParameters.ContainsKey('HostServiceName')) { $HostServiceName = $rec.HostServiceName } + if (-not $PSBoundParameters.ContainsKey('DenyPaths')) { $DenyPaths = @($rec.DenyPaths) } + if (-not $PSBoundParameters.ContainsKey('ConfigureHostService')) { $ConfigureHostService = [switch]$rec.ConfigureHostService } + $fromRecord = $true +} +if (-not $InstallRoot -or -not $HostServiceAccount) { throw '-InstallRoot and -HostServiceAccount are required (or -Reapply / -Verify / -Uninstall with a recorded install)' } + if (-not $PeCacheDir) { $programData = if ($env:ProgramData) { $env:ProgramData } else { 'C:\ProgramData' } $PeCacheDir = "$($programData.TrimEnd('\'))\OtOpcUa\script-pe-cache" @@ -128,14 +184,19 @@ $hostSid = Resolve-HostSid $HostServiceAccount # lmxopcua#890: what an earlier run denied, so a re-run drops stale denies and -Uninstall removes all of them. -PlanOnly # reads nothing from the machine, so there it is empty. $recorded = if ($PlanOnly) { @() } else { @(Get-OtOpcUaRecordedDenyPaths -WorkerServiceName $WorkerServiceName) } -$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir ` +$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir -HostServiceAccount $HostServiceAccount ` -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -DenyPaths $DenyPaths -RecordedDenyPaths $recorded ` -ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache $denied = @($plan | Where-Object { $_.Id -eq 'DenyPathRecord' -and $_.Data.ContainsKey('Paths') } | ForEach-Object { $_.Data.Paths } | Where-Object { $_ }) $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName -Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f ` - $(if ($Uninstall) { 'ROLLBACK' } else { 'install' }), $WorkerServiceName, $workerSid, $HostServiceAccount, $hostSid) +Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4}){5}" -f ` + $(if ($Uninstall) { 'ROLLBACK' } elseif ($Verify) { 'VERIFY' } elseif ($Reapply) { 'REAPPLY' } else { 'install' }), $WorkerServiceName, + $workerSid, $HostServiceAccount, $hostSid, $(if ($fromRecord) { ', settings from the record' } else { '' })) +if ($fromRecord) { + Write-Host (" InstallRoot {0}; PeCacheDir {1}; DenyPaths {2}; ConfigureHostService {3}" -f $InstallRoot, $PeCacheDir, + $(if (@($DenyPaths).Count) { $DenyPaths -join ', ' } else { 'none' }), [bool]$ConfigureHostService) +} if ($PlanOnly) { foreach ($step in $plan) { Write-Host (" [plan] {0,-22} {1}" -f $step.Id, $step.Change) } @@ -143,6 +204,27 @@ if ($PlanOnly) { } if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-ScriptWorkerIsolation.ps1 applies changes on Windows only (use -PlanOnly elsewhere)' } + +if ($Verify) { + # lmxopcua#901: check only, nothing changes. Exit 1 when any step is not in place. + $checks = foreach ($step in $plan) { + $r = try { Test-ScriptWorkerIsolationStep -Step $step } catch { [pscustomobject]@{ Ok = $false; Detail = "check failed: $($_.Exception.Message)" } } + Write-Host (" [{0,-7}] {1,-22} {2} - {3}" -f $(if ($r.Ok) { 'ok' } else { 'MISSING' }), $step.Id, $step.Target, $r.Detail) + [pscustomobject]@{ Id = $step.Id; Target = $step.Target; Ok = [bool]$r.Ok; Detail = $r.Detail } + } + $workerFolder = Join-Path $InstallRoot.TrimEnd('\') 'script-worker' + $gaps = @(foreach ($p in @($InstallRoot.TrimEnd('\')) + $denied) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip @($workerFolder, $PeCacheDir.TrimEnd('\')) }) + foreach ($g in @($gaps | Sort-Object -Unique)) { Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own); check it grants nothing to Everyone, BUILTIN\Users or RESTRICTED." } + $missing = @($checks | Where-Object { -not $_.Ok }) + Write-Host '' + if ($missing.Count) { + Write-Host ("VERIFY FAILED: {0} of {1} step(s) not in place: {2}. Run -Reapply (as administrator) and verify again." -f ` + $missing.Count, @($checks).Count, (($missing | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '; ')) + exit 1 + } + Write-Host ("VERIFY OK: {0}/{0} step(s) in place." -f @($checks).Count) + exit 0 +} if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) { throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it" } @@ -166,12 +248,16 @@ if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.Win throw 'run elevated (services, firewall rules and folder ACLs need an administrator)' } -$results = foreach ($step in $plan) { +$results = @(foreach ($step in $plan) { if ($PSCmdlet.ShouldProcess($step.Target, "$($step.Id): $($step.Change)")) { $outcome = Invoke-ScriptWorkerIsolationStep -Step $step Write-Host (" [{0,-9}] {1,-22} {2}" -f $outcome, $step.Id, $step.Target) [pscustomobject]@{ Id = $step.Id; Target = $step.Target; Outcome = $outcome } } +}) +if (-not $WhatIfPreference) { + $nChanged = @($results | Where-Object Outcome -eq 'changed').Count + Write-Host (" summary: {0} step(s), {1} changed, {2} unchanged" -f $results.Count, $nChanged, ($results.Count - $nChanged)) } if (-not $WhatIfPreference -and -not $Uninstall) { diff --git a/scripts/install/Invoke-OtOpcUaInstallSwap.ps1 b/scripts/install/Invoke-OtOpcUaInstallSwap.ps1 new file mode 100644 index 000000000..eef866a13 --- /dev/null +++ b/scripts/install/Invoke-OtOpcUaInstallSwap.ps1 @@ -0,0 +1,189 @@ +<# +.SYNOPSIS + Swaps an OtOpcUa install folder for a candidate build and restores the ScriptWorker isolation before the host starts + again (lmxopcua#901). Also the rollback: swap the previous install back in the same way. + +.DESCRIPTION + A folder swap drops the explicit worker-isolation ACEs on script-worker\, on the install folder and on any denied + folder that is recreated or copied in (pki\ on vd03), because a moved-in folder carries its own DACL. This script makes + the re-apply part of the swap, in this order: + + 0. Preflight (nothing changed yet): the candidate exists and carries script-worker\; PreviousInstall does not exist + and is on the same volume; when isolation is installed, its settings are recorded (an install by an installer + older than #901 recorded only DenyPaths; then run the full-argument install once first, or pass -SkipIsolation). + 1. Stop the service (unless -NoServiceControl). + 2. Move InstallRoot -> PreviousInstall, then Candidate -> InstallRoot. + 3. Copy each -Carry item (relative to InstallRoot; wildcards allowed) from PreviousInstall into the new InstallRoot. + Folders are copied with robocopy /E /COPY:DAT (data, attributes, timestamps; not ACLs: step 4 sets those). + 4. Isolation, when recorded: Install-ScriptWorkerIsolation.ps1 -Reapply, then -Verify. If -Verify fails, the script + stops here with the service STOPPED (fail closed): fix it, re-run -Reapply/-Verify, then start the service. + 5. Start the service (unless -NoStart, e.g. a Primary/Secondary pair window where both nodes start together). + + Not covered (layout-specific; keep them in the deploy's own scripts): backups of data\ / the registry / the ConfigDb, + restoring data\ on a rollback, migrations, health checks. + +.PARAMETER InstallRoot + The live install folder (OtOpcUa.Host.exe + script-worker\). vd03: E:\ApiInstall\OtOpcUa. QA: E:\ApiInstall\OtOpcUa\app. + +.PARAMETER Candidate + The expanded new build (for a rollback: the previous install). + +.PARAMETER PreviousInstall + Where the live folder is moved to. Must not exist. E.g. E:\ApiInstall\OtOpcUa.bak.20261113-pre-; for a rollback + E:\ApiInstall\OtOpcUa.failed-. + +.PARAMETER Carry + Items copied from the previous install into the new one, relative to InstallRoot. Default: appsettings*.json, pki, logs + (the vd03 layout, where pki\ and logs\ live inside the install folder). QA keeps pki\ and data\ beside app\: pass + -Carry 'appsettings*.json','logs'. A rollback swaps back a complete folder: pass -NoCarry. + +.PARAMETER NoCarry + Copy nothing from the previous install (a rollback). + +.PARAMETER ServiceName + The host service (default OtOpcUaHost). + +.PARAMETER NoServiceControl + Neither stop nor start the service (the caller owns it; used by the scratch test). + +.PARAMETER NoStart + Leave the service stopped after a successful swap and verify. + +.PARAMETER SkipIsolation + Do not re-apply/verify the isolation even when it is installed. WARNs. Only for a box where it is being removed. + +.PARAMETER WorkerServiceName + Passed to the isolation installer (default OtOpcUaScriptWorker). + +.EXAMPLE + # vd03 (pki\ and logs\ inside the install folder) + .\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.new-6c4d2990 ` + -PreviousInstall E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990 + +.EXAMPLE + # QA, one node of the stop-both/start-both window (start both afterwards) + .\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -Candidate E:\ApiInstall\OtOpcUa\app.new- ` + -PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.-pre- -Carry 'appsettings*.json','logs' -NoStart + +.EXAMPLE + # Rollback on vd03: the failed build out, the previous one back (restore data\ separately) + .\Invoke-OtOpcUaInstallSwap.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -Candidate E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990 ` + -PreviousInstall E:\ApiInstall\OtOpcUa.failed-6c4d2990 -NoCarry +#> +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] +param( + [Parameter(Mandatory)] [string]$InstallRoot, + [Parameter(Mandatory)] [string]$Candidate, + [Parameter(Mandatory)] [string]$PreviousInstall, + [string[]]$Carry = @('appsettings*.json', 'pki', 'logs'), + [switch]$NoCarry, + [string]$ServiceName = 'OtOpcUaHost', + [switch]$NoServiceControl, + [switch]$NoStart, + [switch]$SkipIsolation, + [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker', + [int]$StopTimeoutSeconds = 120 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force +$installer = Join-Path $PSScriptRoot 'Install-ScriptWorkerIsolation.ps1' + +function Step([string]$Message) { Write-Host "==> $Message" } +function Invoke-Isolation([string]$Mode) { + # Same PowerShell host as this script; the installer's exit code is the gate. + $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } + & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName | ForEach-Object { Write-Host " $_" } + $LASTEXITCODE +} + +$InstallRoot = $InstallRoot.TrimEnd('\') +$Candidate = $Candidate.TrimEnd('\') +$PreviousInstall = $PreviousInstall.TrimEnd('\') +if ($NoCarry) { $Carry = @() } +$Carry = @($Carry | ForEach-Object { $_ -split '[,;]' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + +# ---- 0. Preflight: nothing changes until all of it passes ----------------------------------------------------------- +$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run elevated' } +if (-not (Test-Path -LiteralPath $InstallRoot -PathType Container)) { throw "InstallRoot $InstallRoot not found" } +if (-not (Test-Path -LiteralPath (Join-Path $Candidate 'script-worker') -PathType Container)) { throw "candidate $Candidate has no script-worker\ (not a Host publish?)" } +if (Test-Path -LiteralPath $PreviousInstall) { throw "PreviousInstall $PreviousInstall already exists; pick a fresh name" } +if ([IO.Path]::GetPathRoot($InstallRoot) -ne [IO.Path]::GetPathRoot($PreviousInstall) -or [IO.Path]::GetPathRoot($InstallRoot) -ne [IO.Path]::GetPathRoot($Candidate)) { + throw 'InstallRoot, Candidate and PreviousInstall must be on the same volume (Move-Item renames)' +} +$isolation = $null +if (-not $SkipIsolation) { + $isolation = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName + $legacy = @(Get-OtOpcUaRecordedDenyPaths -WorkerServiceName $WorkerServiceName) + $anchor = Get-Service -Name $WorkerServiceName -ErrorAction SilentlyContinue + if (-not $isolation -and ($anchor -or $legacy.Count)) { + throw ("ScriptWorker isolation is installed but its settings are not recorded (installed by an installer older than lmxopcua#901). " + + 'Before swapping, run Install-ScriptWorkerIsolation.ps1 once with its full arguments (the deploy pack lists them); that writes the record. ' + + 'Then re-run this swap.') + } + if ($isolation -and $isolation.InstallRoot.TrimEnd('\') -ine $InstallRoot) { + throw "the isolation record is for InstallRoot '$($isolation.InstallRoot)', not '$InstallRoot'; fix -InstallRoot or re-run the isolation install" + } + if ($isolation) { Write-Host "Isolation installed (record: InstallRoot $($isolation.InstallRoot)); it is re-applied and verified after the swap." } + else { Write-Host 'Isolation not installed on this node; nothing to re-apply.' } +} +else { Write-Warning '-SkipIsolation: the worker-isolation ACLs are NOT re-applied; a swapped folder has lost them (lmxopcua#901).' } + +if (-not $PSCmdlet.ShouldProcess($InstallRoot, "swap in $Candidate (previous install -> $PreviousInstall)")) { return } + +# ---- 1. Stop --------------------------------------------------------------------------------------------------------- +if (-not $NoServiceControl) { + Step "stop $ServiceName" + $svc = Get-Service -Name $ServiceName + if ($svc.Status -ne 'Stopped') { + Stop-Service -Name $ServiceName -Force + $svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds($StopTimeoutSeconds)) + } +} + +# ---- 2. Swap --------------------------------------------------------------------------------------------------------- +Step "move $InstallRoot -> $PreviousInstall" +Move-Item -LiteralPath $InstallRoot -Destination $PreviousInstall +Step "move $Candidate -> $InstallRoot" +try { Move-Item -LiteralPath $Candidate -Destination $InstallRoot } +catch { + Write-Warning "candidate move failed; putting the previous install back: $($_.Exception.Message)" + Move-Item -LiteralPath $PreviousInstall -Destination $InstallRoot + throw +} + +# ---- 3. Carry -------------------------------------------------------------------------------------------------------- +foreach ($item in $Carry) { + # Get-Item, not Get-ChildItem: a folder name must give the folder itself, not its children (wildcards still expand). + $sources = @(Get-Item -Path (Join-Path $PreviousInstall $item) -Force -ErrorAction SilentlyContinue) + if ($sources.Count -eq 0) { Write-Host " (carry: nothing matches $item)"; continue } + foreach ($src in $sources) { + $dst = Join-Path $InstallRoot $src.Name + if ($src.PSIsContainer) { + & robocopy $src.FullName $dst /E /COPY:DAT /DCOPY:DAT /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { throw "robocopy $($src.FullName) -> $dst failed ($LASTEXITCODE)" } + } + else { Copy-Item -LiteralPath $src.FullName -Destination $dst -Force } + Write-Host " carried $($src.Name)" + } +} + +# ---- 4. Isolation re-apply + verify (fail closed) -------------------------------------------------------------------- +if ($isolation) { + Step 'worker isolation: -Reapply' + $code = Invoke-Isolation 'Reapply' + if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Reapply failed ($code); $ServiceName left stopped" } + Step 'worker isolation: -Verify (post-swap gate)' + $code = Invoke-Isolation 'Verify' + if ($code -ne 0) { throw "Install-ScriptWorkerIsolation.ps1 -Verify failed ($code); $ServiceName left stopped. Fix the [MISSING] steps, re-run -Reapply/-Verify, then start it." } +} + +# ---- 5. Start -------------------------------------------------------------------------------------------------------- +if (-not $NoServiceControl -and -not $NoStart) { + Step "start $ServiceName" + Start-Service -Name $ServiceName + (Get-Service -Name $ServiceName).WaitForStatus('Running', [TimeSpan]::FromSeconds(60)) +} +Write-Host "Swap complete: $InstallRoot is the candidate; previous install at $PreviousInstall." diff --git a/scripts/install/ScriptWorkerIsolation.psm1 b/scripts/install/ScriptWorkerIsolation.psm1 index 7c48e7ea3..e6d7320cd 100644 --- a/scripts/install/ScriptWorkerIsolation.psm1 +++ b/scripts/install/ScriptWorkerIsolation.psm1 @@ -33,6 +33,13 @@ $script:DenyMask = 0x0D0157 # anchor service's key and goes with it. $script:DenyRecordValue = 'DenyPaths' +# lmxopcua#901: the effective install arguments, recorded beside DenyPaths so `-Reapply` (after an upgrade swapped the +# install folder) and `-Verify` (post-deploy gate) need no arguments. Paths, an account name and two flags; no secrets. +# The key's DACL is set protected, SYSTEM + Administrators only (the services hive is otherwise readable by Users, one of +# the worker token's restricting SIDs, and the record lists exactly which folders are denied). +$script:SettingsValueNames = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'ConfigureHostService') +$script:RecordKeySddl = 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)' + $script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound' $script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe' $script:EnvIdentity = 'Scripting__Worker__Identity' @@ -162,6 +169,8 @@ function Get-ScriptWorkerIsolationPlan { # The -DenyPaths a previous run recorded (read by the entry script from the anchor service's key; empty under # -PlanOnly). Install removes the deny from recorded paths no longer listed; -Uninstall removes it from all. [string[]]$RecordedDenyPaths = @(), + # lmxopcua#901: the -HostServiceAccount as the operator gave it (recorded for -Reapply). Defaults to the SID. + [string]$HostServiceAccount, [switch]$ConfigureHostService, [switch]$Uninstall, [switch]$RemovePeCache @@ -200,6 +209,13 @@ function Get-ScriptWorkerIsolationPlan { $steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey ` "record the denied folders ($(if ($deny.Count) { $deny -join '; ' } else { 'none beyond the install folder' })) so a re-run or -Uninstall removes exactly these" ` @{ Key = $recordKey; Name = $script:DenyRecordValue; Paths = $deny })) + $settings = [ordered]@{ + InstallRoot = $root; HostServiceAccount = $(if ($HostServiceAccount) { $HostServiceAccount } else { $HostSid }) + PeCacheDir = $cache; HostServiceName = $HostServiceName; ConfigureHostService = [int][bool]$ConfigureHostService + } + $steps.Add((New-OtOpcUaPlanStep 'SettingsRecord' $recordKey ` + "record the effective settings (InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService) and lock the key to SYSTEM + Administrators, so -Reapply and -Verify need no arguments (lmxopcua#901)" ` + @{ Key = $recordKey; Settings = $settings; Sddl = $script:RecordKeySddl })) $steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache ` "create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" ` @{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) })) @@ -265,13 +281,9 @@ function Get-ScriptWorkerIsolationPlan { # returns 'changed' or 'unchanged'. Callers gate every call with ShouldProcess, so -WhatIf runs none of them. # --------------------------------------------------------------------------------------------------------------------- -function Set-OtOpcUaExplicitAce { - # Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove). +function Get-OtOpcUaExplicitAceState { + # The explicit ACEs for $Sid on $Path and whether they are exactly the wanted one (or none when $Remove). param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove) - if (-not (Test-Path -LiteralPath $Path)) { - if ($Remove) { return 'unchanged' } - throw "$Path does not exist" - } $acl = Get-Acl -LiteralPath $Path # #800: DirectoryInfo.GetAccessControl does not exist on PowerShell 7 (.NET) $identity = [System.Security.Principal.SecurityIdentifier]::new($Sid) $explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | @@ -287,9 +299,22 @@ function Set-OtOpcUaExplicitAce { $wanted.Count -eq 0 -or ( [int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and $explicit[0].InheritanceFlags -eq $inherit -and $explicit[0].PropagationFlags -eq 'None')) - if ($same) { return 'unchanged' } - $acl.PurgeAccessRules($identity) - foreach ($rule in $wanted) { $acl.AddAccessRule($rule) } + $found = @($explicit | ForEach-Object { '{0} 0x{1:x} {2}' -f $_.AccessControlType, [int]$_.FileSystemRights, $_.InheritanceFlags }) -join '; ' + [pscustomobject]@{ Acl = $acl; Identity = $identity; Wanted = $wanted; Same = $same; Found = $(if ($found) { $found } else { 'none' }) } +} + +function Set-OtOpcUaExplicitAce { + # Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove). + param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove) + if (-not (Test-Path -LiteralPath $Path)) { + if ($Remove) { return 'unchanged' } + throw "$Path does not exist" + } + $state = Get-OtOpcUaExplicitAceState -Path $Path -Sid $Sid -Mask $Mask -Type $Type -Remove:$Remove + if ($state.Same) { return 'unchanged' } + $acl = $state.Acl + $acl.PurgeAccessRules($state.Identity) + foreach ($rule in $state.Wanted) { $acl.AddAccessRule($rule) } Set-Acl -LiteralPath $Path -AclObject $acl 'changed' } @@ -388,6 +413,27 @@ function Invoke-ScriptWorkerIsolationStep { else { Set-ItemProperty -LiteralPath $d.Key -Name $d.Name -Type MultiString -Value ([string[]]$d.Paths) } return 'changed' } + 'SettingsRecord' { + # Install only: -Uninstall deletes the whole key in its DenyPathRecord step. + $changed = $false + if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null; $changed = $true } + $current = Get-ItemProperty -LiteralPath $d.Key -ErrorAction SilentlyContinue + foreach ($name in @($d.Settings.Keys)) { + $want = $d.Settings[$name] + $have = if ($current -and $current.PSObject.Properties[$name]) { $current.$name } else { $null } + if ($null -ne $have -and "$have" -ceq "$want") { continue } + $kind = if ($want -is [int]) { 'DWord' } else { 'String' } + Set-ItemProperty -LiteralPath $d.Key -Name $name -Type $kind -Value $want + $changed = $true + } + if (-not (Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl)) { + $acl = Get-Acl -Path $d.Key + $acl.SetSecurityDescriptorSddlForm($d.Sddl, 'Access') + Set-Acl -Path $d.Key -AclObject $acl + $changed = $true + } + return $(if ($changed) { 'changed' } else { 'unchanged' }) + } 'FirewallBlockOutbound' { $rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue if ($d.ContainsKey('Remove')) { @@ -433,6 +479,109 @@ function Get-OtOpcUaRecordedDenyPaths { if ($v) { @($v.$Name | Where-Object { $_ }) } } +function Test-OtOpcUaRecordKeyAcl { + # True when the record key's DACL is exactly $Sddl (protected; SYSTEM + Administrators), compared in canonical form. + param([string]$Key, [string]$Sddl = $script:RecordKeySddl) + $want = [System.Security.AccessControl.RegistrySecurity]::new() + $want.SetSecurityDescriptorSddlForm($Sddl, 'Access') + # -Path, not -LiteralPath: Windows PowerShell 5.1's Get-Acl -LiteralPath does not find registry keys (windev, #901). + (Get-Acl -Path $Key).GetSecurityDescriptorSddlForm('Access') -ceq $want.GetSecurityDescriptorSddlForm('Access') +} + +function Get-OtOpcUaIsolationSettings { + <# + .SYNOPSIS + The settings an earlier install recorded (lmxopcua#901) as a hashtable: InstallRoot, HostServiceAccount, PeCacheDir, + HostServiceName, ConfigureHostService ([bool]) and DenyPaths. $null when the key or InstallRoot/HostServiceAccount + is missing (nothing installed, or installed by an installer older than #901, which recorded only DenyPaths). + #> + param([string]$WorkerServiceName = 'OtOpcUaScriptWorker') + $key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" + if (-not (Test-Path -LiteralPath $key)) { return $null } + $v = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue + if (-not $v) { return $null } + $get = { param($n) if ($v.PSObject.Properties[$n]) { $v.$n } else { $null } } + $root = & $get 'InstallRoot'; $account = & $get 'HostServiceAccount' + if (-not $root -or -not $account) { return $null } + @{ + InstallRoot = [string]$root + HostServiceAccount = [string]$account + PeCacheDir = [string](& $get 'PeCacheDir') + HostServiceName = $(if (& $get 'HostServiceName') { [string](& $get 'HostServiceName') } else { 'OtOpcUaHost' }) + ConfigureHostService = [bool][int](& $get 'ConfigureHostService') + DenyPaths = @(& $get $script:DenyRecordValue | Where-Object { $_ }) + } +} + +function Test-ScriptWorkerIsolationStep { + <# + .SYNOPSIS + Checks one INSTALL plan step against the machine without changing anything (lmxopcua#901, -Verify). Returns + [pscustomobject]@{ Ok; Detail }. Windows only. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Step) + $d = $Step.Data + $result = { param([bool]$Ok, [string]$Detail) [pscustomobject]@{ Ok = $Ok; Detail = $Detail } } + switch ($Step.Id) { + 'AnchorService' { + $cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue + if (-not $cfg) { return (& $result $false "service '$($d.Name)' not found") } + $ok = $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`"" + return (& $result $ok "StartMode=$($cfg.StartMode) StartName=$($cfg.StartName) PathName=$($cfg.PathName)") + } + 'DenyPathRecord' { + $current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name) + $ok = (@($current) -join "`n") -ceq (@($d.Paths) -join "`n") + return (& $result $ok "recorded: $(if ($current.Count) { $current -join '; ' } else { 'none' })") + } + 'SettingsRecord' { + if (-not (Test-Path -LiteralPath $d.Key)) { return (& $result $false 'record key missing') } + $v = Get-ItemProperty -LiteralPath $d.Key + $bad = @(foreach ($n in @($d.Settings.Keys)) { + if (-not $v.PSObject.Properties[$n] -or "$($v.$n)" -cne "$($d.Settings[$n])") { $n } + }) + $aclOk = Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl + if (-not $aclOk) { $bad += 'key ACL (want SYSTEM + Administrators only, protected)' } + return (& $result ($bad.Count -eq 0) $(if ($bad.Count) { "differs: $($bad -join ', ')" } else { 'settings recorded, key locked' })) + } + 'PeCacheDir' { + if (-not (Test-Path -LiteralPath $d.Path)) { return (& $result $false 'missing') } + $desired = [System.Security.AccessControl.DirectorySecurity]::new() + $desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner') + $current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner') + return (& $result ($current -eq $desired.GetSecurityDescriptorSddlForm('Access, Owner')) "SDDL $current") + } + { $_ -in 'PeEntriesDir', 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } { + if (-not (Test-Path -LiteralPath $d.Path)) { + if ($d.ContainsKey('Remove')) { return (& $result $true 'absent') } + return (& $result $false 'folder missing') + } + $state = if ($d.ContainsKey('Remove')) { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Remove } + else { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Mask $d.Mask -Type $d.Type } + $want = if ($d.ContainsKey('Remove')) { 'none' } else { '{0} 0x{1:x} ContainerInherit, ObjectInherit' -f $d.Type, $d.Mask } + return (& $result $state.Same "explicit worker ACE: $($state.Found) (want $want)") + } + 'FirewallBlockOutbound' { + $rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue + if (-not $rule) { return (& $result $false "rule '$($d.Name)' missing") } + $app = ($rule | Get-NetFirewallApplicationFilter).Program + $ok = "$($rule.Direction)" -eq 'Outbound' -and "$($rule.Action)" -eq 'Block' -and "$($rule.Enabled)" -eq 'True' -and + "$($rule.Profile)" -eq 'Any' -and $app -ieq $d.Program + if ($ok -and -not (Test-Path -LiteralPath $d.Program)) { return (& $result $false "rule ok but $($d.Program) is missing") } + return (& $result $ok "$($rule.Direction)/$($rule.Action)/$($rule.Profile)/enabled=$($rule.Enabled) program=$app") + } + 'HostServiceEnv' { + $key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)" + if (-not (Test-Path $key)) { return (& $result $false "service '$($d.Service)' not installed") } + $current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ }) + $missing = @(foreach ($k in $d.Set.Keys) { if ($current -notcontains "$k=$($d.Set[$k])") { $k } }) + return (& $result ($missing.Count -eq 0) $(if ($missing.Count) { "missing or different: $($missing -join ', ')" } else { 'both entries present' })) + } + default { throw "unknown plan step '$($Step.Id)'" } + } +} + function Find-OtOpcUaDenyCoverageGap { <# .SYNOPSIS @@ -456,4 +605,5 @@ function Find-OtOpcUaDenyCoverageGap { Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce, Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan, - Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap + Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap, Get-OtOpcUaIsolationSettings, + Test-ScriptWorkerIsolationStep, Test-OtOpcUaRecordKeyAcl diff --git a/scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1 b/scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1 index e84cbc70d..8376447fb 100644 --- a/scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1 +++ b/scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1 @@ -1,7 +1,9 @@ <# .SYNOPSIS - Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890): apply -> re-apply -> verify -> shorter - -DenyPaths -> uninstall (from the record, without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that + Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890, #901): apply -> re-apply -> verify -> shorter + -DenyPaths -> -Verify -> folder swap -> -Verify fails -> -Reapply -> -Verify passes -> -Reapply unchanged -> + Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> uninstall (from the record, + without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1). The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1. @@ -12,13 +14,15 @@ .DESCRIPTION Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a - scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM'. + scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM', + or with -LocalHostAccount a scratch local user (random password, never shown) that is deleted at the end. It does NOT touch OtOpcUaHost. The firewall rule name is fixed by the installer, so the test refuses to run (exit 2) when that rule already exists, i.e. on a node that has the real isolation installed. Everything it creates is removed in `finally`, including on failure. #> [CmdletBinding()] -param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost') +param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost', [switch]$LocalHostAccount, + [string]$LocalHostAccountName = 'otopcuaIsoTestAcct') Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -34,6 +38,8 @@ if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-H foreach ($n in $WorkerServiceName, $HostServiceName) { if (Get-Service -Name $n -ErrorAction SilentlyContinue) { Write-Host "refusing: service $n already exists"; exit 2 } } +if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Write-Host "refusing: local user $LocalHostAccountName already exists"; exit 2 } +$script:hostAccount = 'NT AUTHORITY\SYSTEM' $script:failures = 0; $script:passes = 0 function Assert([bool]$Condition, [string]$Message) { @@ -43,11 +49,27 @@ function Invoke-Installer([string[]]$Extra) { # $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits. # Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'. $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } - $out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount 'NT AUTHORITY\SYSTEM' ` + $out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount $script:hostAccount ` -PeCacheDir $cache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService @Extra 2>&1 | Out-String $steps = @([regex]::Matches($out, '\[(?[a-z]+)\s*\]\s+(?[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } }) [pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps } } +function Invoke-Mode([string]$Mode) { + # lmxopcua#901: -Reapply / -Verify with NO settings argument, only the scratch worker name (the record selector). + $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } + # Continue: an expected failure writes to stderr, which Stop would turn into a terminating error here. Whitespace is + # collapsed (and pwsh's '|' margins dropped) because both hosts wrap the error text at the console width. + $out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } + $steps = @([regex]::Matches($out, '\[(?[a-z]+)\s*\]\s+(?[A-Za-z]+)\s+(?\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } }) + $code = $LASTEXITCODE + $missing = @([regex]::Matches($out, '\[MISSING\]\s+(?[A-Za-z]+)\s+(?\S+)') | ForEach-Object { "$($_.Groups['id'].Value) $($_.Groups['t'].Value)" }) + [pscustomobject]@{ Exit = $code; Output = $out; Flat = ($out -replace '[\s|]+', ' '); Steps = $steps; Missing = $missing } +} +function Show([string]$Label, $R) { + # The transcript the issue asks for: one line per run. + $what = if ($R.Missing.Count) { "MISSING: $($R.Missing -join ' | ')" } else { ($R.Steps | Group-Object Outcome | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ' } + Write-Host (" {0,-34} exit {1}; {2}" -f $Label, $R.Exit, $what) +} function Get-Explicit([string]$Path, [string]$Sid) { $id = [System.Security.Principal.SecurityIdentifier]::new($Sid) @((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $id }) @@ -56,7 +78,8 @@ function Get-Explicit([string]$Path, [string]$Sid) { $scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $root = Join-Path $scratch 'install' $cache = Join-Path $scratch 'pe-cache' -$deny = @($scratch, (Join-Path $scratch 'pki'), (Join-Path $scratch 'data')) # lmxopcua#890: the deployment folder + keys + secrets +# lmxopcua#890: the deployment folder + keys + secrets. pki\ inside the install root, as on vd03 (lmxopcua#901: the swap re-creates it). +$deny = @($scratch, (Join-Path $root 'pki'), (Join-Path $scratch 'data')) $recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" $workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe' $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName @@ -65,10 +88,17 @@ try { New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null Set-Content -LiteralPath $workerExe -Value 'stub' foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null } + if ($LocalHostAccount) { + $pw = [Security.SecureString]::new() + foreach ($ch in ([Convert]::ToBase64String((1..24 | ForEach-Object { [byte](Get-Random -Maximum 256) })) + 'aA1!').ToCharArray()) { $pw.AppendChar($ch) } + New-LocalUser -Name $LocalHostAccountName -Password $pw -AccountNeverExpires -Description 'lmxopcua#901 isolation test (scratch)' | Out-Null + $script:hostAccount = ".\$LocalHostAccountName" + } + Set-Content -LiteralPath (Join-Path $root 'appsettings.json') -Value '{}' & sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" } Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1')) - Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch" + Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch; host account $script:hostAccount" Write-Host 'Dry run' $dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ',')) @@ -79,7 +109,7 @@ try { Write-Host 'Apply' $a = Invoke-Installer @('-DenyPaths', ($deny -join ',')) Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))" - Assert ($a.Steps.Count -eq 11 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 11 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" + Assert ($a.Steps.Count -eq 12 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 12 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled' Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid' Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)' @@ -107,7 +137,7 @@ try { Write-Host 'Re-apply (idempotent)' $b = Invoke-Installer @('-DenyPaths', ($deny -join ',')) Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))" - Assert ($b.Steps.Count -eq 11 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 11 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" + Assert ($b.Steps.Count -eq 12 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 12 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay' $c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ',')) @@ -118,6 +148,81 @@ try { $c2 = Invoke-Installer @('-DenyPaths', ($deny -join ',')) Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only' + Write-Host 'Settings record (lmxopcua#901)' + $recVals = Get-ItemProperty -Path $recordKey + Assert ($recVals.InstallRoot -eq $root -and $recVals.HostServiceAccount -eq $script:hostAccount -and $recVals.PeCacheDir -eq $cache -and + $recVals.HostServiceName -eq $HostServiceName -and $recVals.ConfigureHostService -eq 1) 'install root, host account, PE cache, host service and the flag are recorded' + Assert (Test-OtOpcUaRecordKeyAcl -Key $recordKey) "record key DACL is protected SYSTEM + Administrators ($((Get-Acl -Path $recordKey).GetSecurityDescriptorSddlForm('Access')))" + $settings = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName + Assert ($null -ne $settings -and (@($settings.DenyPaths) -join '|') -eq ($deny -join '|') -and $settings.ConfigureHostService) 'Get-OtOpcUaIsolationSettings reads it back' + + Write-Host 'Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901)' + $v0 = Invoke-Mode 'Verify'; Show 'Verify (installed)' $v0 + Assert ($v0.Exit -eq 0 -and $v0.Output -match 'VERIFY OK') "verify passes on a fresh install ($($v0.Output))" + # The swap as the deploy packs did it: build the candidate beside the live folder, move live aside, move candidate in, + # copy pki\ and appsettings back (robocopy without ACLs, as a new folder). + $cand = Join-Path $scratch 'install.new' + New-Item -ItemType Directory -Path (Join-Path $cand 'script-worker') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $cand 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v2' + $prev1 = Join-Path $scratch 'install.bak.1' + Move-Item -LiteralPath $root -Destination $prev1 + Move-Item -LiteralPath $cand -Destination $root + & robocopy (Join-Path $prev1 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null + Copy-Item -LiteralPath (Join-Path $prev1 'appsettings.json') -Destination $root + $v1 = Invoke-Mode 'Verify'; Show 'Verify (after the swap)' $v1 + Assert ($v1.Exit -eq 1 -and $v1.Output -match 'VERIFY FAILED') "verify exits 1 after the swap ($($v1.Exit))" + $expectMissing = @("WorkerFolderAcl $root\script-worker", "InstallRootDeny $root", "SensitiveDirDeny $root\pki") + Assert ((@($v1.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "exactly the three folder ACEs the swap drops are MISSING ($($v1.Missing -join ' | '))" + $r1 = Invoke-Mode 'Reapply'; Show 'Reapply (no arguments)' $r1 + Assert ($r1.Exit -eq 0 -and $r1.Output -match 'settings from the record') "reapply exits 0 and says it used the record ($($r1.Output))" + $r1Changed = @($r1.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" }) + Assert ($r1.Steps.Count -eq 12 -and (@($r1Changed | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "reapply changes exactly the missing three ($($r1Changed -join ' | '))" + $v2 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v2 + Assert ($v2.Exit -eq 0 -and $v2.Output -match 'VERIFY OK') "verify passes after the reapply ($($v2.Exit))" + $r2 = Invoke-Mode 'Reapply'; Show 'Reapply again' $r2 + Assert ($r2.Exit -eq 0 -and $r2.Steps.Count -eq 12 -and @($r2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second reapply: 12 steps, all unchanged ($(($r2.Steps | ForEach-Object Outcome) -join ','))" + Assert ($r2.Output -match 'summary: 12 step\(s\), 0 changed, 12 unchanged') 'the summary line says 0 changed' + + Write-Host 'Negative control: Verify catches each kind of loss' + & icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # Users on the PE cache root + Remove-NetFirewallRule -Name $ruleName + $v3 = Invoke-Mode 'Verify'; Show 'Verify (cache ACL + rule broken)' $v3 + Assert ($v3.Exit -eq 1 -and ($v3.Missing -join '|') -match 'PeCacheDir' -and ($v3.Missing -join '|') -match 'FirewallBlockOutbound') "verify flags the PE cache ACL and the missing rule ($($v3.Missing -join ' | '))" + $r3 = Invoke-Mode 'Reapply'; $v4 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v4 + Assert ($r3.Exit -eq 0 -and $v4.Exit -eq 0) 'reapply repairs them' + + Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: swap + Reapply + Verify in one step (no service control in the scratch test)' + $swapScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'Invoke-OtOpcUaInstallSwap.ps1' + $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } + $cand2 = Join-Path $scratch 'install.new2' + New-Item -ItemType Directory -Path (Join-Path $cand2 'script-worker') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $cand2 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v3' + $prev2 = Join-Path $scratch 'install.bak.2' + $sw = & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand2 -PreviousInstall $prev2 ` + -Carry 'appsettings*.json,pki' -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String + $swExit = $LASTEXITCODE + Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap', $swExit, $(if ($sw -match 'VERIFY OK') { 'VERIFY OK after its Reapply' } else { 'no VERIFY OK' })) + Assert ($swExit -eq 0 -and $sw -match 'VERIFY OK' -and $sw -match 'Swap complete') "swap script exits 0 with the gate passed ($sw)" + Assert ((Get-Content -LiteralPath (Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe')) -eq 'stub v3' -and (Test-Path (Join-Path $root 'pki')) -and + (Test-Path (Join-Path $root 'appsettings.json'))) 'the candidate is live and pki\ + appsettings were carried' + Assert (@(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1 -and + @(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'the three folder ACEs are back on the swapped-in folder' + + Write-Host 'Swap refuses a pre-#901 record (DenyPaths only) before changing anything' + $saved = (Get-ItemProperty -Path $recordKey).InstallRoot + Remove-ItemProperty -Path $recordKey -Name InstallRoot + $cand3 = Join-Path $scratch 'install.new3' + New-Item -ItemType Directory -Path (Join-Path $cand3 'script-worker') -Force | Out-Null + $sw2 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand3 ` + -PreviousInstall (Join-Path $scratch 'install.bak.3') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } + $sw2Exit = $LASTEXITCODE + Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap (pre-#901)', $sw2Exit, $(if (($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901') { 'refused: settings not recorded' } else { 'NOT refused' })) + Assert ($sw2Exit -ne 0 -and ($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901' -and (Test-Path $cand3) -and -not (Test-Path (Join-Path $scratch 'install.bak.3'))) "refused, nothing moved ($sw2)" + $r4 = Invoke-Mode 'Reapply' + Show 'Reapply (pre-#901 record)' $r4 + Assert ($r4.Exit -ne 0 -and $r4.Flat -match 'full arguments') "-Reapply without a full record refuses and says how to fix it ($($r4.Exit))" + Set-ItemProperty -Path $recordKey -Name InstallRoot -Value $saved + Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice' $u1 = Invoke-Installer @('-Uninstall') Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))" @@ -142,6 +247,7 @@ finally { foreach ($n in $WorkerServiceName, $HostServiceName) { if (Get-Service -Name $n -ErrorAction SilentlyContinue) { & sc.exe delete $n | Out-Null } } + if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Remove-LocalUser -Name $LocalHostAccountName } if (Test-Path -LiteralPath $scratch) { & icacls $scratch /reset /T /C /Q 2>&1 | Out-Null Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 b/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 index cecfbd01e..20323b19a 100644 --- a/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 +++ b/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 @@ -39,7 +39,7 @@ Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a spac Write-Host 'Install plan' $plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService) $ids = $plan.Id -join ',' -Assert ($ids -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)" +Assert ($ids -eq 'AnchorService,DenyPathRecord,SettingsRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)" $byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s } $anchor = $byId['AnchorService'].Data @@ -79,6 +79,19 @@ $rec = $byId['DenyPathRecord'].Data Assert ($rec.Key -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters' -and $rec.Name -eq 'DenyPaths' -and @($rec.Paths).Count -eq 0) ` "without -DenyPaths the record is empty, under the anchor service's key ($($rec.Key))" +# lmxopcua#901: the effective arguments are recorded beside DenyPaths, in a key locked to SYSTEM + Administrators. +$set = $byId['SettingsRecord'].Data +Assert ($set.Key -eq $rec.Key) "settings live under the same key as DenyPaths ($($set.Key))" +Assert ($set.Settings.InstallRoot -eq $root -and $set.Settings.PeCacheDir -eq $cache -and $set.Settings.HostServiceName -eq 'OtOpcUaHost' -and + $set.Settings.ConfigureHostService -eq 1 -and $set.Settings.HostServiceAccount -eq $hostSid) "recorded settings ($(($set.Settings.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; '))" +Assert ((@($set.Settings.Keys) -join ',') -eq 'InstallRoot,HostServiceAccount,PeCacheDir,HostServiceName,ConfigureHostService') 'exactly these five values (no secrets)' +Assert ($set.Sddl -eq 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)') "record key DACL is protected, SYSTEM + Administrators only ($($set.Sddl))" +foreach ($forbidden in 'BU', 'WD', 'AU', 'RC', $workerSid) { Assert (-not $set.Sddl.Contains(";;;$forbidden)")) "record key grants nothing to $forbidden" } +$named = @(Get-ScriptWorkerIsolationPlan -InstallRoot "$root\" -HostSid 'S-1-5-18' -HostServiceAccount 'LocalSystem' -PeCacheDir $cache) +$namedSet = ($named | Where-Object Id -eq 'SettingsRecord').Data.Settings +Assert ($namedSet.HostServiceAccount -eq 'LocalSystem' -and $namedSet.ConfigureHostService -eq 0 -and $namedSet.InstallRoot -eq $root) ` + 'the account is recorded as given, the flag as 0, the root without a trailing backslash' + $fw = $byId['FirewallBlockOutbound'].Data Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))" Assert ($byId['FirewallBlockOutbound'].Change -like '*blocking ALL outbound*') 'firewall rule blocks outbound' @@ -96,7 +109,7 @@ Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostS Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused' Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused' $sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache') -Assert ($sibling.Count -eq 7) 'a sibling folder sharing the prefix is NOT inside the root (positive control)' +Assert ($sibling.Count -eq 8) 'a sibling folder sharing the prefix is NOT inside the root (positive control)' Write-Host 'Sensitive siblings: -DenyPaths (lmxopcua#890, the wonder layout)' $qaRoot = 'E:\ApiInstall\OtOpcUa' @@ -104,7 +117,7 @@ $qaApp = "$qaRoot\app" $qaDeny = @($qaRoot, "$qaRoot\pki", "$qaRoot\data") $qa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -ConfigureHostService) $qaIds = $qa.Id -join ',' -Assert ($qaIds -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') ` +Assert ($qaIds -eq 'AnchorService,DenyPathRecord,SettingsRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') ` "QA plan order ($qaIds)" $sens = @($qa | Where-Object Id -eq 'SensitiveDirDeny') Assert ((Join-Targets $sens) -eq ($qaDeny -join '|')) "one deny per folder, in the given order ($(Join-Targets $sens))" @@ -151,8 +164,8 @@ $un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCa $unIds = $un.Id -join ',' Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') "rollback order ($unIds)" Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes' -Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) ` - 'rollback reverses every install step but the cache (kept by default)' +Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -notin 'PeCacheDir', 'SettingsRecord' } | Sort-Object) -join ',')) ` + 'rollback reverses every install step but the cache (kept by default); the settings go with the record key (DenyPathRecord)' Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scripting__Worker__Identity') 'rollback removes the identity env entry' foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) { Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs" @@ -181,6 +194,13 @@ Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*O $qaEntry = @(& $entry -InstallRoot $qaApp -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ',') -ConfigureHostService -PlanOnly 6>$null) Assert ((@($qaEntry.Id) -join ',') -eq $qaIds) "-PlanOnly -DenyPaths 'a,b,c' (one string, as powershell -File passes it) plans the same steps" +Write-Host 'Entry script -Reapply / -Verify argument rules (lmxopcua#901; refused before any machine access)' +Assert-Throws { & $entry -Reapply -InstallRoot $root 6>$null } '*-Reapply takes its settings from the record*' '-Reapply refuses explicit settings' +Assert-Throws { & $entry -Reapply -DenyPaths 'C:\x' 6>$null } '*-Reapply takes its settings from the record*' '-Reapply refuses -DenyPaths' +Assert-Throws { & $entry -Reapply -Verify 6>$null } '*exclusive*' '-Reapply and -Verify are exclusive' +Assert-Throws { & $entry -Verify -Uninstall 6>$null } '*exclusive*' '-Verify and -Uninstall are exclusive' +Assert-Throws { & $entry -InstallRoot $root 6>$null } '*required*' 'a plain install still needs -HostServiceAccount' + if ($IsWindows) { Write-Host 'SDDL parses on Windows' $ds = [System.Security.AccessControl.DirectorySecurity]::new() diff --git a/scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify/RESULTS.md b/scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify/RESULTS.md new file mode 100644 index 000000000..d90c779aa --- /dev/null +++ b/scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify/RESULTS.md @@ -0,0 +1,50 @@ +# lmxopcua#901: re-apply and verify the ScriptWorker isolation after a folder swap (windev, 2026-11-13) + +**Result: PASS on both PowerShell hosts.** `scripts/install/tests/Test-ScriptWorkerIsolation.Windows.ps1` (elevated, scratch +folder tree under `%TEMP%`, scratch worker/host service names, never touches `OtOpcUaHost`; windev has no OtOpcUa service and +no real isolation installed): + +| Host | Host account | Assertions | +|---|---|---| +| Windows PowerShell 5.1.19041.6456 | scratch local user `.\otopcuaIsoTestAcct` (`-LocalHostAccount`, random password, deleted) | 52 passed, 0 failed | +| PowerShell 7 | scratch local user (`-LocalHostAccount`) | 52 passed, 0 failed | +| Windows PowerShell 5.1 | `NT AUTHORITY\SYSTEM` | 52 passed, 0 failed | + +The scratch layout mirrors vd03: `pki\` inside the install folder and in `-DenyPaths`, the deployment folder around it denied +too. The "swap" is the deploy packs' sequence: build the candidate beside the live folder, move live aside, move the +candidate in, robocopy `pki\` (no ACLs) and copy `appsettings.json` back. + +Transcript of the #901 part (Windows PowerShell 5.1, SYSTEM; the scratch path is shown as ``): + +``` +Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901) + Verify (installed) exit 0; 12 ok + Verify (after the swap) exit 1; MISSING: WorkerFolderAcl \install\script-worker | InstallRootDeny \install | SensitiveDirDeny \install\pki + Reapply (no arguments) exit 0; 9 unchanged, 3 changed + Verify (after Reapply) exit 0; 12 ok + Reapply again exit 0; 12 unchanged +Negative control: Verify catches each kind of loss + Verify (cache ACL + rule broken) exit 1; MISSING: PeCacheDir \pe-cache | FirewallBlockOutbound OtOpcUa-ScriptWorker-BlockOutbound + Verify (after Reapply) exit 0; 12 ok +Invoke-OtOpcUaInstallSwap.ps1: swap + Reapply + Verify in one step (no service control in the scratch test) + Invoke-OtOpcUaInstallSwap exit 0; VERIFY OK after its Reapply +Swap refuses a pre-#901 record (DenyPaths only) before changing anything + Invoke-OtOpcUaInstallSwap (pre-#901) exit 1; refused: settings not recorded + Reapply (pre-#901 record) exit 1; +Uninstall WITHOUT -DenyPaths (the record drives it), twice +``` + +- After the swap `-Verify` exits **1** and lists exactly the three ACEs the 2026-11-13 vd03 swap lost: `WorkerFolderAcl` + (`script-worker\`), `InstallRootDeny` (install folder), `SensitiveDirDeny` (`pki\`). +- `-Reapply` with no settings argument changes exactly those three (9 unchanged); `-Verify` then exits 0; a second `-Reapply` + reports 12/12 unchanged (`summary: 12 step(s), 0 changed, 12 unchanged`). +- Negative controls: a Users grant on the PE-cache root and a deleted firewall rule each show as `[MISSING]` (exit 1). +- `Invoke-OtOpcUaInstallSwap.ps1` (`-NoServiceControl`) swaps, carries `appsettings*.json` + `pki`, re-applies and verifies in + one run (exit 0, `VERIFY OK`); with a pre-#901 record (no `InstallRoot` value) it refuses before moving anything (exit 1, + candidate still in place, no previous-install folder), and `-Reapply` refuses with the "run the full-argument install once" message. +- The record key's DACL is `D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)`; the five settings read back. +- Cleanup verified afterwards: no firewall rule, no scratch services, no scratch local user, no scratch folder, no scratch + registry key; the staged scripts were removed from windev. + +The dry-run harness (`pwsh -File scripts/install/tests/Test-ScriptWorkerIsolation.ps1`, macOS) passes 101/101; a mutation of the +record-key SDDL (unprotected + a Users read ACE) turns 2 assertions red.