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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user