fix(install): worker isolation survives a folder swap: recorded settings, -Reapply, -Verify, swap script (#901)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user