fix(install): cut the swap's stopped window to the swapped-in folder (#905)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
Root cause: the protected-DACL coverage scan, not the ACL writes. Both -Reapply and -Verify walked every denied folder (all of E:\ApiInstall on vd03, nested roots again) with Get-ChildItem -Recurse + Get-Acl per item + a pipeline per item for -Skip, about 3.4 ms an item on PS 5.1, all while the service was stopped. An unchanged deny is read, not rewritten; the re-applied install-folder deny propagates over ~1,400 files in 0.5 s. - Find-OtOpcUaDenyCoverageGap: .NET enumeration, -Skip folders pruned, DACL-only GetAccessControl(Access) (about 0.15 ms an item); reparse points not followed. - Get-OtOpcUaCoverageWalkRoots: each denied folder walked once (outermost only). - Install-ScriptWorkerIsolation.ps1 -Scope All|InstallRoot|Outside (with -Reapply, -Verify, -PlanOnly). InstallRoot = the folder ACEs a swap drops; Outside = the rest, including the firewall rule (its query costs 3-5 s; its exe path does not change). The two partition the plan. Prints the scan time. - Invoke-OtOpcUaInstallSwap.ps1: -Reapply/-Verify -Scope Outside before the stop (a failure stops before anything moves), -Scope InstallRoot inside the window; preflight checks the candidate carries the worker exe; prints the stopped window and its parts. -NoStart, -NoCarry and the pre-#901 refusal unchanged. windev, vd03-like tree (17,851 items): stopped window 153.1 s -> 6.0 s (PS 5.1), 101.6 s -> 6.2 s (PS 7); the Outside part takes ~23 s with the old build serving. Dry-run harness 101 -> 123 passed; Windows test 72 passed on PS 5.1 and PS 7. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -50,12 +50,15 @@
|
||||
.\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.
|
||||
Invoke-OtOpcUaInstallSwap.ps1 does both around its swap and does not start the service when -Verify fails: -Scope
|
||||
Outside while the old build still serves, -Scope InstallRoot after the swap (lmxopcua#905). 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
|
||||
folder around InstallRoot has a pki, data, logs, backup, config or secrets sibling.
|
||||
folder around InstallRoot has a pki, data, logs, backup, config or secrets sibling. The walk covers each denied
|
||||
folder once (a folder inside another denied folder is walked with it) and prints its time; about 0.15 ms an item
|
||||
(lmxopcua#905: before, Get-Acl per item made it about 3.4 ms, 59 s for 17.5k items).
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
The host install folder (holds OtOpcUa.Host.exe and script-worker\).
|
||||
@@ -94,6 +97,18 @@
|
||||
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 Scope
|
||||
lmxopcua#905, with -Reapply, -Verify or -PlanOnly. Which install steps run:
|
||||
All every step (default).
|
||||
InstallRoot only what a folder swap can undo: the folder ACEs on InstallRoot and below (WorkerFolderAcl,
|
||||
InstallRootDeny, a -DenyPaths folder inside InstallRoot such as vd03's pki\). The coverage scan walks
|
||||
InstallRoot only.
|
||||
Outside every other step: the anchor service, the records, the PE cache, the denies outside InstallRoot, the
|
||||
firewall rule (its path to the worker exe does not change with a swap; the query costs seconds), the
|
||||
host service env. The coverage scan walks the denied folders and skips InstallRoot.
|
||||
InstallRoot and Outside together are exactly All. Invoke-OtOpcUaInstallSwap.ps1 runs Outside before it stops the
|
||||
service and InstallRoot inside the stopped window.
|
||||
|
||||
.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.
|
||||
@@ -131,7 +146,9 @@ param(
|
||||
[switch]$RemovePeCache,
|
||||
[switch]$Reapply,
|
||||
[switch]$Verify,
|
||||
[switch]$PlanOnly
|
||||
[switch]$PlanOnly,
|
||||
# lmxopcua#905: with -Reapply / -Verify / -PlanOnly only. Which steps run (see .PARAMETER Scope).
|
||||
[ValidateSet('All', 'InstallRoot', 'Outside')] [string]$Scope = 'All'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -140,6 +157,7 @@ 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 ($Scope -ne 'All' -and -not ($Reapply -or $Verify -or $PlanOnly)) { throw '-Scope works with -Reapply, -Verify or -PlanOnly only (an install or -Uninstall always covers every step)' }
|
||||
if ($Reapply) {
|
||||
$given = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'DenyPaths', 'ConfigureHostService', 'RemovePeCache' |
|
||||
Where-Object { $PSBoundParameters.ContainsKey($_) })
|
||||
@@ -188,11 +206,25 @@ $plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSi
|
||||
-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 { $_ })
|
||||
# lmxopcua#905: a swap runs -Scope Outside while the old build still serves and -Scope InstallRoot after the swap, so only
|
||||
# the steps the swap can undo run while the service is stopped. The two scopes partition the plan.
|
||||
$plan = @(Select-ScriptWorkerIsolationPlanScope -Plan $plan -InstallRoot $InstallRoot -Scope $Scope)
|
||||
$deniedInScope = @($denied | Where-Object { $Scope -eq 'All' -or ((Test-OtOpcUaPathWithin -Path $_ -Ancestor $InstallRoot) -eq ($Scope -eq 'InstallRoot')) })
|
||||
function Invoke-CoverageScan {
|
||||
# The protected-DACL coverage scan (lmxopcua#890), over the outermost folders of this scope only (lmxopcua#905).
|
||||
$walk = Get-OtOpcUaCoverageWalkRoots -InstallRoot $InstallRoot -DeniedPaths $denied -Scope $Scope
|
||||
$skip = @((Join-Path $InstallRoot.TrimEnd('\') 'script-worker'), $PeCacheDir.TrimEnd('\')) + @($walk.Skip)
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$found = @(foreach ($p in $walk.Roots) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip $skip })
|
||||
$sw.Stop()
|
||||
[pscustomobject]@{ Gaps = @($found | Sort-Object -Unique); Roots = @($walk.Roots); Seconds = $sw.Elapsed.TotalSeconds }
|
||||
}
|
||||
|
||||
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
|
||||
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4}){5}" -f `
|
||||
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4}){5}{6}" -f `
|
||||
$(if ($Uninstall) { 'ROLLBACK' } elseif ($Verify) { 'VERIFY' } elseif ($Reapply) { 'REAPPLY' } else { 'install' }), $WorkerServiceName,
|
||||
$workerSid, $HostServiceAccount, $hostSid, $(if ($fromRecord) { ', settings from the record' } else { '' }))
|
||||
$workerSid, $HostServiceAccount, $hostSid, $(if ($fromRecord) { ', settings from the record' } else { '' }),
|
||||
$(if ($Scope -ne 'All') { ", scope $Scope ($($plan.Count) step(s))" } 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)
|
||||
@@ -212,17 +244,19 @@ if ($Verify) {
|
||||
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." }
|
||||
$scan = Invoke-CoverageScan
|
||||
foreach ($g in $scan.Gaps) { 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." }
|
||||
Write-Host (" coverage scan: {0} folder(s) walked ({1}), {2} protected item(s) the deny does not reach, {3:N1} s" -f `
|
||||
$scan.Roots.Count, $(if ($scan.Roots.Count) { $scan.Roots -join '; ' } else { 'none' }), $scan.Gaps.Count, $scan.Seconds)
|
||||
$scopeNote = if ($Scope -ne 'All') { " (scope $Scope)" } else { '' }
|
||||
$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 '; '))
|
||||
Write-Host ("VERIFY FAILED{3}: {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 '; '), $scopeNote)
|
||||
exit 1
|
||||
}
|
||||
Write-Host ("VERIFY OK: {0}/{0} step(s) in place." -f @($checks).Count)
|
||||
Write-Host ("VERIFY OK{1}: {0}/{0} step(s) in place." -f @($checks).Count, $scopeNote)
|
||||
exit 0
|
||||
}
|
||||
if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) {
|
||||
@@ -230,7 +264,7 @@ if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 's
|
||||
}
|
||||
if (-not $Uninstall) {
|
||||
# Checked up front so a typo stops the run before any step changes the machine.
|
||||
$missing = @($denied | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Container) })
|
||||
$missing = @($deniedInScope | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Container) })
|
||||
if ($missing.Count) { throw "DenyPaths folder(s) not found: $($missing -join '; ')" }
|
||||
if ($denied.Count -eq 0) {
|
||||
$parent = Split-Path -Parent $InstallRoot.TrimEnd('\')
|
||||
@@ -262,13 +296,13 @@ if (-not $WhatIfPreference) {
|
||||
|
||||
if (-not $WhatIfPreference -and -not $Uninstall) {
|
||||
# The inherited deny stops at a protected DACL; list every such item that has no deny of its own (lmxopcua#890).
|
||||
$workerFolder = Join-Path $InstallRoot.TrimEnd('\') 'script-worker'
|
||||
$gaps = @(foreach ($p in @($InstallRoot.TrimEnd('\')) + $denied) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip @($workerFolder, $PeCacheDir.TrimEnd('\')) })
|
||||
$gaps = @($gaps | Sort-Object -Unique)
|
||||
$scan = Invoke-CoverageScan
|
||||
$gaps = $scan.Gaps
|
||||
foreach ($g in $gaps) {
|
||||
Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own). Check its ACL grants nothing to Everyone, BUILTIN\Users or RESTRICTED, or add it to -DenyPaths."
|
||||
}
|
||||
Write-Host (" coverage: {0} denied folder(s); {1} protected item(s) the deny does not reach" -f (1 + $denied.Count), $gaps.Count)
|
||||
Write-Host (" coverage: {0} denied folder(s); {1} folder(s) walked; {2} protected item(s) the deny does not reach; {3:N1} s" -f `
|
||||
(1 + $denied.Count), $scan.Roots.Count, $gaps.Count, $scan.Seconds)
|
||||
}
|
||||
|
||||
if (-not $WhatIfPreference) {
|
||||
|
||||
Reference in New Issue
Block a user