Files
lmxopcua/scripts/install/Install-ScriptWorkerIsolation.ps1
T
Joseph Doherty 0c19f26c81
v2-ci / unit-tests (push) Has been cancelled
fix(install): cut the swap's stopped window to the swapped-in folder (#905)
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
2026-11-13 03:51:42 -05:00

322 lines
22 KiB
PowerShell

<#
.SYNOPSIS
Isolates the OtOpcUa ScriptWorker processes on a Windows node (lmxopcua#780, design §6, owner decisions §14 #5/#6):
the worker's virtual-account identity, the PeCacheDir ACL, an outbound firewall block on the worker executable and
a deny on the install folder and on every other sensitive folder (-DenyPaths: pki\, data\, logs\, ...). Idempotent; -WhatIf prints every change; -Uninstall rolls it back.
.DESCRIPTION
Only needed when the node runs Scripting:Runner = OutOfProcess or Shadow. It changes, in order:
1. AnchorService a DISABLED service '<WorkerServiceName>' whose account is NT SERVICE\<WorkerServiceName>,
so that virtual account exists and resolves by name. It is never started: the host launches
every worker itself, under a restricted token whose restricting SIDs are that account +
Everyone + BUILTIN\Users + RESTRICTED, with every privilege removed
(Scripting:Worker:Identity; RestrictedWorkerLauncher.cs). Each access check the worker
makes must pass for the host account AND that set.
2. PeCacheDir created; ACL replaced: protected, owner Administrators, SYSTEM + Administrators + the host
account full control. No Users, no worker access (the HMAC key and launch markers live here).
3. PeEntriesDir <PeCacheDir>\pe, the worker identity gets Modify (workers compile and write HMAC'd entries).
4. WorkerFolderAcl <InstallRoot>\script-worker, the worker identity gets read + execute.
5. InstallRootDeny <InstallRoot>, the worker identity is DENIED read and write (inherited): ReadData/List,
WriteData/AddFile, AppendData/AddSubdirectory, WriteAttributes/EA, Delete, DeleteChild,
ChangePermissions, TakeOwnership. Traverse/Execute, ReadAttributes and ReadPermissions stay,
so the loader still reaches script-worker\. The worker cannot read appsettings*.json, the logs
or the host binaries even where Users can.
SensitiveDirDeny (one per -DenyPaths folder, lmxopcua#890) the same inherited deny. The worker token's
restricting SIDs include Everyone and BUILTIN\Users, so ANY folder Users can read is readable
to the worker unless it is denied here. Name the deployment folder around InstallRoot (which
covers pki\, data\, logs\, backups and old app.bak.* swaps in one ACE) AND each folder that
holds keys or secrets explicitly (pki\, data\, the Secrets/LocalDb folder when it lives
elsewhere), so the deny also holds on a folder whose DACL does not inherit.
DenyPathRecord the -DenyPaths list, recorded under the anchor service's key
(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
worker (dotnet.exe) is NOT covered — the analyzer's System.Net ban is then the only control.
7. HostServiceEnv (-ConfigureHostService only) Scripting__Worker__Identity and Scripting__Worker__PeCacheDir
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).
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 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. 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\).
.PARAMETER HostServiceAccount
The OtOpcUaHost service account: DOMAIN\name, NT SERVICE\name, LocalSystem, or a SID. Gets full control of PeCacheDir.
.PARAMETER DenyPaths
More folders the worker identity is denied (read and write, inherited), lmxopcua#890. Absolute; must exist; not a
drive or share root; not script-worker\ or the PE cache. A comma- or semicolon-separated string also works (for
powershell -File). Example for the wonder layout E:\ApiInstall\OtOpcUa\{app,pki,data,logs,backup}:
-DenyPaths 'E:\ApiInstall\OtOpcUa','E:\ApiInstall\OtOpcUa\pki','E:\ApiInstall\OtOpcUa\data'
.PARAMETER PeCacheDir
The compiled-PE cache (Scripting:Worker:PeCacheDir). Absolute and outside InstallRoot (the host refuses otherwise).
Default: $env:ProgramData\OtOpcUa\script-pe-cache.
.PARAMETER WorkerServiceName
The worker identity's virtual account name. Default OtOpcUaScriptWorker (Scripting:Worker:Identity =
'NT SERVICE\OtOpcUaScriptWorker').
.PARAMETER ConfigureHostService
Also write the two Scripting__Worker__* entries into the host service's environment (or remove them with -Uninstall).
.PARAMETER Uninstall
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 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.
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -WhatIf
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService
.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 `
-DenyPaths E:\ApiInstall\OtOpcUa, E:\ApiInstall\OtOpcUa\pki, E:\ApiInstall\OtOpcUa\data
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
# 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',
[string[]]$DenyPaths = @(),
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache,
[switch]$Reapply,
[switch]$Verify,
[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
$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 ($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($_) })
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"
}
function Resolve-HostSid([string]$Account) {
if ($Account -match '^[Ss]-1-\d+(-\d+)+$') { return $Account.ToUpperInvariant() }
if ($PlanOnly) { throw "-PlanOnly needs -HostServiceAccount as a SID (no account lookup without the OS)" }
if ($Account -in 'LocalSystem', '.\LocalSystem', 'NT AUTHORITY\SYSTEM') { return 'S-1-5-18' }
if ($Account -match '^NT SERVICE\\(.+)$') { return (Get-OtOpcUaServiceSid -Name $Matches[1]) }
$name = $Account -replace '^\.\\', "$env:COMPUTERNAME\"
([System.Security.Principal.NTAccount]::new($name)).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
$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 -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 { $_ })
# 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}{6}" -f `
$(if ($Uninstall) { 'ROLLBACK' } elseif ($Verify) { 'VERIFY' } elseif ($Reapply) { 'REAPPLY' } else { 'install' }), $WorkerServiceName,
$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)
}
if ($PlanOnly) {
foreach ($step in $plan) { Write-Host (" [plan] {0,-22} {1}" -f $step.Id, $step.Change) }
return $plan
}
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 }
}
$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{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{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'))) {
throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it"
}
if (-not $Uninstall) {
# Checked up front so a typo stops the run before any step changes the machine.
$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('\')
if ($parent -and $parent -notmatch '^[A-Za-z]:\\?$') {
$siblings = @(Get-ChildItem -LiteralPath $parent -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^(pki|data|logs?|backups?|config|secrets?)$' } | ForEach-Object FullName)
foreach ($s in $siblings) {
Write-Warning "$s sits beside the install folder and is NOT denied to the worker (BUILTIN\Users is one of its restricting SIDs). Pass -DenyPaths '$parent' and the folders holding keys or secrets (lmxopcua#890)."
}
}
}
}
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'run elevated (services, firewall rules and folder ACLs need an administrator)'
}
$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) {
# The inherited deny stops at a protected DACL; list every such item that has no deny of its own (lmxopcua#890).
$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} 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) {
Write-Host ''
if ($Uninstall) {
Write-Host 'Rolled back. If the host still sets Scripting:Worker:Identity (appsettings or env), unset it and restart the host.'
}
else {
Write-Host 'Done. Host configuration for this node:'
Write-Host " Scripting:Worker:Identity = NT SERVICE\$WorkerServiceName"
Write-Host " Scripting:Worker:PeCacheDir = $PeCacheDir"
if (-not $ConfigureHostService) { Write-Host ' (not written: re-run with -ConfigureHostService, or set them in appsettings)' }
Write-Host ' Restart OtOpcUaHost for a configuration change to take effect.'
}
}
$results