0c19f26c81
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
322 lines
22 KiB
PowerShell
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
|