fix(install): worker isolation survives a folder swap: recorded settings, -Reapply, -Verify, swap script (#901)
v2-ci / unit-tests (push) Has been cancelled

An upgrade that swaps the install folder dropped the explicit ScriptWorker isolation ACEs on script-worker\, the
install folder and a copied-back pki\ (vd03 2026-11-13). Re-applying needed the operator to repeat the arguments.

- Install-ScriptWorkerIsolation.ps1 records its effective settings (InstallRoot, HostServiceAccount, PeCacheDir,
  HostServiceName, ConfigureHostService) beside DenyPaths under ...\Services\OtOpcUaScriptWorker\Parameters (new plan
  step SettingsRecord) and locks that key to SYSTEM + Administrators (protected DACL; no secrets recorded).
- -Reapply: re-apply from the record, no arguments; prints a summary line (changed/unchanged).
- -Verify: check only, [ok]/[MISSING] per step, exit 1 when anything is missing. -Uninstall also works from the record.
  A node isolated by a pre-#901 installer (DenyPaths only) must run the full-argument install once; both modes say so.
- Invoke-OtOpcUaInstallSwap.ps1: preflight -> stop -> move live/candidate -> carry appsettings/pki/logs ->
  -Reapply -> -Verify -> start; a failed -Verify leaves the service stopped. Also the rollback (-NoCarry).
- Tests: dry-run harness 101/101 (record + argument rules; record-key SDDL mutation turns 2 red). The Windows test adds
  swap -> Verify fails (exactly the 3 lost ACEs) -> Reapply (3 changed) -> Verify ok -> Reapply 12/12 unchanged, PE-cache
  and firewall negative controls, the swap script, and the pre-#901 refusal; 52/52 on windev under 5.1 and 7, with a
  scratch local host account and with SYSTEM. Evidence: scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify.
- Docs: ServiceHosting isolation section; vd03 pack (swap step, rollback, post-deploy gate, one-time full-argument run),
  the 2026-11-13 vd03 record, and the QA pack (QA swap preceded the isolation install; ACLs checked intact over SMB on
  vq03/vq04 2026-11-13; next-deploy procedure).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 02:06:10 -05:00
parent 2470f82eea
commit 2cb8a2c317
10 changed files with 748 additions and 36 deletions
+30 -2
View File
@@ -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
@@ -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 <bak> -PreviousInstall <failed> -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)
@@ -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-<sha> `
-PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.<yyyymmdd>-pre-<sha> -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-<sha> -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 <file> /reset`.
+25 -1
View File
@@ -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-<sha> -PreviousInstall E:\ApiInstall\OtOpcUa.bak.<yyyymmdd>-pre-<sha>`
(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 <previous install> -PreviousInstall <quarantine path> -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 <file> /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).
@@ -32,6 +32,9 @@
(HKLM\...\Services\<WorkerServiceName>\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
<InstallRoot>\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) {
@@ -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-<sha>; for a rollback
E:\ApiInstall\OtOpcUa.failed-<sha>.
.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-<sha> `
-PreviousInstall E:\ApiInstall\OtOpcUa\app.bak.<date>-pre-<sha> -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."
+160 -10
View File
@@ -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
@@ -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, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[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, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } })
$code = $LASTEXITCODE
$missing = @([regex]::Matches($out, '\[MISSING\]\s+(?<id>[A-Za-z]+)\s+(?<t>\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
@@ -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()
@@ -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 `<scratch>`):
```
Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901)
Verify (installed) exit 0; 12 ok
Verify (after the swap) exit 1; MISSING: WorkerFolderAcl <scratch>\install\script-worker | InstallRootDeny <scratch>\install | SensitiveDirDeny <scratch>\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 <scratch>\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.