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
@@ -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.