mcp-pacemaker 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -1
- package/CONTRIBUTING.md +49 -13
- package/README.md +361 -60
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +134 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1007 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-installation.mjs +217 -0
- package/bin/legacy-task.mjs +52 -0
- package/bin/legacy-upgrade.mjs +471 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +491 -0
- package/bin/mcp-bridge.mjs +132 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +33 -21
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +27 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +8 -0
- package/bin/upgrade-dependencies.mjs +122 -0
- package/bin/upgrade-package.mjs +203 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +783 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
- package/bin/windows-legacy-process.mjs +241 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +205 -0
- package/docs/execution-context.md +113 -0
- package/package.json +12 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +225 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +90 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Performs fixed controller task operations while preserving the registered account.
|
|
4
|
+
.DESCRIPTION
|
|
5
|
+
Reads a bounded controller request on stdin. This adapter changes only the exact
|
|
6
|
+
selected registration, preserving principal, logon type, run level and full security.
|
|
7
|
+
It never stops processes, edits configuration, enables privileges or changes ACLs.
|
|
8
|
+
.PARAMETER Action
|
|
9
|
+
Exact task operation chosen by the trusted controller.
|
|
10
|
+
.OUTPUTS
|
|
11
|
+
Bounded JSON task/profile/session evidence. A successful launch call is not readiness.
|
|
12
|
+
.EXAMPLE
|
|
13
|
+
.\account-task.ps1 -Action inspect
|
|
14
|
+
#>
|
|
15
|
+
[CmdletBinding()]
|
|
16
|
+
param(
|
|
17
|
+
[Parameter(Mandatory)]
|
|
18
|
+
[ValidateSet('inspect', 'session', 'bootstrap', 'hold', 'repoint', 'release', 'restore', 'launch')]
|
|
19
|
+
[string]$Action
|
|
20
|
+
)
|
|
21
|
+
$ErrorActionPreference = 'Stop'
|
|
22
|
+
. (Join-Path $PSScriptRoot 'legacy-task.ps1') -Operation inspect -Port 1 -FunctionsOnly
|
|
23
|
+
$text = [Console]::In.ReadToEnd()
|
|
24
|
+
if ($text.Length -gt 65536) { throw 'Controller request exceeds its bound.' }
|
|
25
|
+
$request = $text | ConvertFrom-Json
|
|
26
|
+
$targetSid = $request.targetSid
|
|
27
|
+
if ($targetSid -cnotmatch '^S-1-[0-9]+(?:-[0-9]+)+$' -or
|
|
28
|
+
([Security.Principal.SecurityIdentifier]::new($targetSid)).Value -cne $targetSid) {
|
|
29
|
+
throw 'A canonical target SID is required.'
|
|
30
|
+
}
|
|
31
|
+
$impersonated = [Security.Principal.WindowsIdentity]::GetCurrent($true)
|
|
32
|
+
if ($null -ne $impersonated) {
|
|
33
|
+
$impersonated.Dispose()
|
|
34
|
+
throw 'Impersonating task-adapter contexts are unsupported.'
|
|
35
|
+
}
|
|
36
|
+
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
37
|
+
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
|
38
|
+
if ($targetSid -cne $currentSid -and
|
|
39
|
+
-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
40
|
+
throw 'The current task-adapter token is not an enabled administrator.'
|
|
41
|
+
}
|
|
42
|
+
$path = $request.taskPath
|
|
43
|
+
if ([string]::IsNullOrEmpty($path) -or
|
|
44
|
+
$path.Length -gt 1024 -or
|
|
45
|
+
-not $path.StartsWith('\') -or
|
|
46
|
+
$path.Contains('/') -or
|
|
47
|
+
$path.Split('\') -contains '..') { throw 'An exact task path is required.' }
|
|
48
|
+
$separator = $path.LastIndexOf('\')
|
|
49
|
+
$folderPath = if ($separator -eq 0) { '\' } else { $path.Substring(0, $separator) }
|
|
50
|
+
$name = $path.Substring($separator + 1)
|
|
51
|
+
$service = New-Object -ComObject Schedule.Service
|
|
52
|
+
$service.Connect()
|
|
53
|
+
$folder = $service.GetFolder($folderPath)
|
|
54
|
+
$task = $folder.GetTask($name)
|
|
55
|
+
$record = Get-TaskRecord -Task $task
|
|
56
|
+
$record.runAsName = $task.Definition.Principal.UserId
|
|
57
|
+
if ($record.path -cne $path -or
|
|
58
|
+
$record.userSid -cne $targetSid -or
|
|
59
|
+
$record.logonType -ne 3 -or
|
|
60
|
+
$record.runLevel -ne 0) { throw 'Selected task does not preserve the requested interactive account.' }
|
|
61
|
+
Assert-TaskSecurity -Actual $record.security -Expected $record.security -RuntimeSid $targetSid
|
|
62
|
+
if ($Action -eq 'inspect') {
|
|
63
|
+
$profiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$targetSid'" -Property SID, LocalPath, Loaded -OperationTimeoutSec 10)
|
|
64
|
+
if ($profiles.Count -ne 1 -or
|
|
65
|
+
-not $profiles[0].Loaded) { throw 'The selected user profile is not loaded in an existing interactive session.' }
|
|
66
|
+
@{
|
|
67
|
+
record = $record
|
|
68
|
+
profilePath = [IO.Path]::GetFullPath($profiles[0].LocalPath)
|
|
69
|
+
allowDemandStart = $task.Definition.Settings.AllowDemandStart
|
|
70
|
+
} | ConvertTo-Json -Depth 12 -Compress
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if ($record.xmlSha256 -cne $request.expected.xmlSha256 -or
|
|
74
|
+
$record.userSid -cne $request.expected.userSid) { throw 'Task definition changed after the approved plan.' }
|
|
75
|
+
Assert-TaskSecurity -Actual $record.security -Expected $request.expected.security -RuntimeSid $targetSid
|
|
76
|
+
if ($Action -eq 'session') {
|
|
77
|
+
$port = [int]$request.port
|
|
78
|
+
if ($port -lt 1 -or $port -gt 65535) { throw 'Invalid selected port.' }
|
|
79
|
+
$listeners = @(Get-CimInstance -Namespace 'root/StandardCimv2' -ClassName MSFT_NetTCPConnection `
|
|
80
|
+
-Filter "LocalAddress='127.0.0.1' AND LocalPort=$port AND State=2" -OperationTimeoutSec 10)
|
|
81
|
+
if ($listeners.Count -ne 1) { throw 'Selected listener is absent or ambiguous.' }
|
|
82
|
+
$processId = [int]$listeners[0].OwningProcess
|
|
83
|
+
$process = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$processId" -Property ProcessId, ParentProcessId, SessionId, CreationDate, CommandLine, ExecutablePath -OperationTimeoutSec 10
|
|
84
|
+
$owner = Invoke-CimMethod -InputObject $process -MethodName GetOwnerSid -ErrorAction Stop
|
|
85
|
+
$scriptPath = [IO.Path]::GetFullPath((Join-Path $request.root 'bin\mcp-bridge.mjs'))
|
|
86
|
+
$commands = @(
|
|
87
|
+
"`"$($process.ExecutablePath)`" `"$scriptPath`" --port $port",
|
|
88
|
+
"$($process.ExecutablePath) `"$scriptPath`" --port $port"
|
|
89
|
+
)
|
|
90
|
+
if ($null -ne $request.managed) {
|
|
91
|
+
$parts = @((Join-Path $request.root 'supervisor\bridge-child.mjs'), '--port', "$port", '--config', $request.managed.config, '--cwd', $request.managed.cwd)
|
|
92
|
+
$arguments = ($parts | ForEach-Object {
|
|
93
|
+
if ($_ -match '\s') { "`"$_`"" } else { $_ }
|
|
94
|
+
}) -join ' '
|
|
95
|
+
$commands = @("`"$($process.ExecutablePath)`" $arguments", "$($process.ExecutablePath) $arguments")
|
|
96
|
+
}
|
|
97
|
+
if ($owner.ReturnValue -ne 0 -or
|
|
98
|
+
$owner.Sid -cne $targetSid -or
|
|
99
|
+
$process.SessionId -le 0 -or
|
|
100
|
+
$process.CommandLine -notin $commands) {
|
|
101
|
+
throw 'Selected listener account/session/absolute legacy command could not be verified.'
|
|
102
|
+
}
|
|
103
|
+
$parentId = [int]$process.ParentProcessId
|
|
104
|
+
$parent = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentId" -Property ProcessId, ExecutablePath, SessionId, CreationDate -OperationTimeoutSec 10
|
|
105
|
+
$parentOwner = Invoke-CimMethod -InputObject $parent -MethodName GetOwnerSid -ErrorAction Stop
|
|
106
|
+
if ($parentOwner.ReturnValue -ne 0 -or
|
|
107
|
+
$parentOwner.Sid -cne $targetSid -or
|
|
108
|
+
$parent.SessionId -ne $process.SessionId -or
|
|
109
|
+
$parent.CreationDate -gt $process.CreationDate) { throw 'Selected supervisor account/session/generation is ambiguous.' }
|
|
110
|
+
@{ ownerSid = $owner.Sid; sessionId = $process.SessionId; pid = $processId
|
|
111
|
+
createdUtc = $process.CreationDate.ToUniversalTime().ToString('o')
|
|
112
|
+
supervisorImage = @{ path = $parent.ExecutablePath; sha256 = (Get-FileHash -LiteralPath $parent.ExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant() }
|
|
113
|
+
} | ConvertTo-Json -Depth 4 -Compress
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if ($Action -eq 'launch') {
|
|
117
|
+
if (-not $task.Enabled -or
|
|
118
|
+
-not $task.Definition.Settings.AllowDemandStart -or
|
|
119
|
+
$request.sessionId -le 0) { throw 'The selected task cannot demand-start in the requested session.' }
|
|
120
|
+
$running = $task.RunEx($null, 12, [int]$request.sessionId, $targetSid)
|
|
121
|
+
@{ requested = $true; instanceGuid = $running.InstanceGuid; readinessProven = $false } | ConvertTo-Json -Compress
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
$definition = $task.Definition
|
|
125
|
+
switch ($Action) {
|
|
126
|
+
'bootstrap' {
|
|
127
|
+
if (-not $task.Enabled -or
|
|
128
|
+
-not $definition.Settings.AllowDemandStart) { throw 'Bootstrap requires an enabled demand-start task.' }
|
|
129
|
+
if ($definition.Actions.Count -ne 1) { throw 'Unsupported task actions.' }
|
|
130
|
+
$definition.Actions.Item(1).Path = $request.bootstrap.nodePath
|
|
131
|
+
$definition.Actions.Item(1).Arguments = $request.bootstrap.arguments
|
|
132
|
+
$definition.Actions.Item(1).WorkingDirectory = $request.bootstrap.cwd
|
|
133
|
+
foreach ($trigger in $definition.Triggers) { $trigger.Enabled = $false }
|
|
134
|
+
$definition.Settings.RestartCount = 0
|
|
135
|
+
}
|
|
136
|
+
'repoint' {
|
|
137
|
+
if ($definition.Actions.Count -ne 1) { throw 'Unsupported task actions.' }
|
|
138
|
+
$definition.Actions.Item(1).Path = 'wscript.exe'
|
|
139
|
+
$definition.Actions.Item(1).Arguments = "`"$($request.launcher)`""
|
|
140
|
+
$definition.Actions.Item(1).WorkingDirectory = ''
|
|
141
|
+
}
|
|
142
|
+
'hold' {
|
|
143
|
+
foreach ($trigger in $definition.Triggers) { $trigger.Enabled = $false }
|
|
144
|
+
$definition.Settings.RestartCount = 0
|
|
145
|
+
}
|
|
146
|
+
'release' {
|
|
147
|
+
$original = $service.NewTask(0)
|
|
148
|
+
$original.XmlText = $request.original.xml
|
|
149
|
+
if ($original.Triggers.Count -ne $definition.Triggers.Count) { throw 'Task trigger identity changed.' }
|
|
150
|
+
for ($index = 1; $index -le $definition.Triggers.Count; $index++) {
|
|
151
|
+
$definition.Triggers.Item($index).Enabled = $original.Triggers.Item($index).Enabled
|
|
152
|
+
}
|
|
153
|
+
$definition.Settings.RestartCount = $original.Settings.RestartCount
|
|
154
|
+
}
|
|
155
|
+
'restore' { $definition.XmlText = $request.original.xml }
|
|
156
|
+
}
|
|
157
|
+
Assert-TaskSecurity -Actual $record.security -Expected $request.original.security -RuntimeSid $targetSid
|
|
158
|
+
if ((Get-UserSid -User $definition.Principal.UserId) -cne $targetSid -or
|
|
159
|
+
$definition.Principal.LogonType -ne $record.logonType -or
|
|
160
|
+
$definition.Principal.RunLevel -ne $record.runLevel) { throw 'Task account semantics changed.' }
|
|
161
|
+
$expectedXml = $definition.XmlText
|
|
162
|
+
$folder.RegisterTaskDefinition($name, $definition, 52, $definition.Principal.UserId, $null, 3, $record.security.sddl) | Out-Null
|
|
163
|
+
$updated = Get-TaskRecord -Task ($folder.GetTask($name))
|
|
164
|
+
$updated.runAsName = $definition.Principal.UserId
|
|
165
|
+
Assert-TaskSecurity -Actual $updated.security -Expected $record.security -RuntimeSid $targetSid
|
|
166
|
+
if ($updated.xmlSha256 -cne (Get-XmlHash -Xml $expectedXml)) { throw 'Task update outcome differs from the exact requested definition.' }
|
|
167
|
+
$updated | ConvertTo-Json -Depth 12 -Compress
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Reads the selected per-user Pacemaker task and its legacy task identity.
|
|
4
|
+
.DESCRIPTION
|
|
5
|
+
Returns task actions and principal data without changing tasks or processes.
|
|
6
|
+
.PARAMETER Port
|
|
7
|
+
Selected service port.
|
|
8
|
+
.OUTPUTS
|
|
9
|
+
JSON array of existing task identities.
|
|
10
|
+
.EXAMPLE
|
|
11
|
+
.\inspect-task.ps1 -Port 8791
|
|
12
|
+
#>
|
|
13
|
+
[CmdletBinding()]
|
|
14
|
+
param([ValidateRange(1, 65535)][int]$Port)
|
|
15
|
+
|
|
16
|
+
$ErrorActionPreference = 'Stop'
|
|
17
|
+
$service = New-Object -ComObject Schedule.Service
|
|
18
|
+
$service.Connect()
|
|
19
|
+
$folder = $service.GetFolder('\')
|
|
20
|
+
$names = @("McpPacemaker-$Port", 'McpPacemaker')
|
|
21
|
+
$records = @()
|
|
22
|
+
foreach ($task in $folder.GetTasks(1)) {
|
|
23
|
+
if ($task.Name -notin $names) { continue }
|
|
24
|
+
$actions = @()
|
|
25
|
+
foreach ($action in $task.Definition.Actions) {
|
|
26
|
+
$actions += @{
|
|
27
|
+
type = $action.Type
|
|
28
|
+
path = $action.Path
|
|
29
|
+
arguments = $action.Arguments
|
|
30
|
+
workingDirectory = $action.WorkingDirectory
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
$records += @{
|
|
34
|
+
name = $task.Name
|
|
35
|
+
enabled = $task.Enabled
|
|
36
|
+
user = $task.Definition.Principal.UserId
|
|
37
|
+
logonType = $task.Definition.Principal.LogonType
|
|
38
|
+
runLevel = $task.Definition.Principal.RunLevel
|
|
39
|
+
actions = $actions
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
ConvertTo-Json -InputObject $records -Depth 5 -Compress
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Checks session facts for the original-user broker's bounded captured identities.
|
|
4
|
+
.DESCRIPTION
|
|
5
|
+
Reads a same-user request on stdin. Queries only its listed process identities,
|
|
6
|
+
or the selected managed loopback listener, and performs no process or security
|
|
7
|
+
mutation. Native held-generation revalidation remains the stop authority.
|
|
8
|
+
.OUTPUTS
|
|
9
|
+
A verification result without command lines or account identifiers.
|
|
10
|
+
.EXAMPLE
|
|
11
|
+
.\inspect-worker-process-sessions.ps1
|
|
12
|
+
#>
|
|
13
|
+
[CmdletBinding()]
|
|
14
|
+
param()
|
|
15
|
+
$ErrorActionPreference = 'Stop'
|
|
16
|
+
$watch = [Diagnostics.Stopwatch]::StartNew()
|
|
17
|
+
$stream = [Console]::OpenStandardInput()
|
|
18
|
+
$bytes = [byte[]]::new(131073)
|
|
19
|
+
$length = 0
|
|
20
|
+
while ($length -lt $bytes.Length) {
|
|
21
|
+
$read = $stream.Read($bytes, $length, $bytes.Length - $length)
|
|
22
|
+
if ($read -eq 0) { break }
|
|
23
|
+
$length += $read
|
|
24
|
+
}
|
|
25
|
+
if ($length -gt 131072) { throw 'Worker identity request exceeds its bound.' }
|
|
26
|
+
$text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes, 0, $length)
|
|
27
|
+
$request = $text | ConvertFrom-Json
|
|
28
|
+
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
29
|
+
$hasSessionType = $request.sessionId -is [long] -or $request.sessionId -is [int]
|
|
30
|
+
if ($request.ownerSid -isnot [string] -or
|
|
31
|
+
$request.ownerSid -cne $sid -or
|
|
32
|
+
$request.ownerSid.Length -gt 184 -or
|
|
33
|
+
-not $hasSessionType -or
|
|
34
|
+
$request.sessionId -ne [Diagnostics.Process]::GetCurrentProcess().SessionId) {
|
|
35
|
+
throw 'Worker process/session scope is not the actual current account.'
|
|
36
|
+
}
|
|
37
|
+
if ($null -ne $request.port) {
|
|
38
|
+
if ((($request.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'ownerSid,port,sessionId') {
|
|
39
|
+
throw 'Invalid managed listener request shape.'
|
|
40
|
+
}
|
|
41
|
+
$port = [int]$request.port
|
|
42
|
+
if ($port -lt 1 -or
|
|
43
|
+
$port -gt 65535 -or
|
|
44
|
+
$null -ne $request.identities) { throw 'Invalid managed listener scope.' }
|
|
45
|
+
$listeners = @(Get-CimInstance -Namespace 'root/StandardCimv2' -ClassName MSFT_NetTCPConnection `
|
|
46
|
+
-Filter "LocalAddress='127.0.0.1' AND LocalPort=$port AND State=2" -OperationTimeoutSec 10)
|
|
47
|
+
# A verified stopped/failed-start generation has no listener; its native proof is checked by the caller.
|
|
48
|
+
if ($listeners.Count -eq 0) {
|
|
49
|
+
'{"verified":true}'
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if ($listeners.Count -ne 1) { throw 'Managed listener is ambiguous.' }
|
|
53
|
+
$processId = [int]$listeners[0].OwningProcess
|
|
54
|
+
$row = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$processId" -OperationTimeoutSec 10
|
|
55
|
+
$owner = Invoke-CimMethod -InputObject $row -MethodName GetOwnerSid -ErrorAction Stop
|
|
56
|
+
if ($null -eq $row -or
|
|
57
|
+
$row.SessionId -ne $request.sessionId -or
|
|
58
|
+
$owner.ReturnValue -ne 0 -or
|
|
59
|
+
$owner.Sid -cne $sid) { throw 'Managed listener account/session changed; replan before stop.' }
|
|
60
|
+
'{"verified":true}'
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if ((($request.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'identities,ownerSid,sessionId' -or
|
|
64
|
+
$request.identities -isnot [Array]) { throw 'Invalid captured identity request shape.' }
|
|
65
|
+
$identities = @($request.identities)
|
|
66
|
+
if ($identities.Count -lt 2 -or
|
|
67
|
+
$identities.Count -gt 512) { throw 'Captured identity count is unsupported.' }
|
|
68
|
+
$seen = @{}
|
|
69
|
+
foreach ($identity in $identities) {
|
|
70
|
+
if ((($identity.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'creationTime,ownerSid,pid' -or
|
|
71
|
+
($identity.pid -isnot [long] -and $identity.pid -isnot [int]) -or
|
|
72
|
+
$identity.pid -le 0 -or
|
|
73
|
+
$identity.pid -gt [int]::MaxValue -or
|
|
74
|
+
$identity.ownerSid -isnot [string] -or
|
|
75
|
+
$identity.creationTime -isnot [string]) { throw 'Invalid captured identity shape.' }
|
|
76
|
+
$number = [int]$identity.pid
|
|
77
|
+
if ($seen.ContainsKey($number) -or
|
|
78
|
+
$identity.ownerSid -cne $sid -or
|
|
79
|
+
$identity.creationTime -cnotmatch '^[1-9][0-9]{0,18}$') {
|
|
80
|
+
throw 'Invalid original-user captured identity.'
|
|
81
|
+
}
|
|
82
|
+
$null = [DateTime]::FromFileTimeUtc([long]$identity.creationTime)
|
|
83
|
+
$seen[$number] = $true
|
|
84
|
+
}
|
|
85
|
+
$query = {
|
|
86
|
+
param([string]$Filter)
|
|
87
|
+
$ErrorActionPreference = 'Stop'
|
|
88
|
+
Import-Module CimCmdlets -ErrorAction Stop
|
|
89
|
+
$session = New-CimSession -ErrorAction Stop
|
|
90
|
+
try {
|
|
91
|
+
foreach ($row in Get-CimInstance -CimSession $session -ClassName Win32_Process -Filter $Filter `
|
|
92
|
+
-Property ProcessId, SessionId, CreationDate -OperationTimeoutSec 2) {
|
|
93
|
+
[pscustomobject]@{
|
|
94
|
+
pid = [int]$row.ProcessId
|
|
95
|
+
sessionId = [int]$row.SessionId
|
|
96
|
+
creationTicks = $row.CreationDate.ToUniversalTime().Ticks
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} finally { Remove-CimSession -CimSession $session }
|
|
100
|
+
}
|
|
101
|
+
$observed = @{}
|
|
102
|
+
$pool = [RunspaceFactory]::CreateRunspacePool(1, 4)
|
|
103
|
+
$pool.Open()
|
|
104
|
+
try {
|
|
105
|
+
for ($offset = 0; $offset -lt $identities.Count; $offset += 128) {
|
|
106
|
+
$pending = @()
|
|
107
|
+
try {
|
|
108
|
+
for ($index = $offset; $index -lt [Math]::Min($offset + 128, $identities.Count); $index += 32) {
|
|
109
|
+
if ($watch.ElapsedMilliseconds -ge 13000) { throw 'Worker session query deadline.' }
|
|
110
|
+
$batch = @($identities[$index..([Math]::Min($index + 31, $identities.Count - 1))])
|
|
111
|
+
$expected = @{}
|
|
112
|
+
$clauses = foreach ($identity in $batch) {
|
|
113
|
+
$expected[[int]$identity.pid] = $true
|
|
114
|
+
'ProcessId=' + ([int]$identity.pid).ToString([Globalization.CultureInfo]::InvariantCulture)
|
|
115
|
+
}
|
|
116
|
+
$pipeline = [PowerShell]::Create()
|
|
117
|
+
$pipeline.RunspacePool = $pool
|
|
118
|
+
$null = $pipeline.AddScript($query.ToString()).AddArgument(($clauses -join ' OR '))
|
|
119
|
+
$pending += @{ Pipeline = $pipeline; Expected = $expected; Result = $pipeline.BeginInvoke() }
|
|
120
|
+
}
|
|
121
|
+
foreach ($item in $pending) {
|
|
122
|
+
while (-not $item.Result.IsCompleted) {
|
|
123
|
+
if ($watch.ElapsedMilliseconds -ge 13000) { throw 'Worker session query deadline.' }
|
|
124
|
+
Start-Sleep -Milliseconds 10
|
|
125
|
+
}
|
|
126
|
+
$rows = @($item.Pipeline.EndInvoke($item.Result))
|
|
127
|
+
if ($item.Pipeline.HadErrors -or
|
|
128
|
+
$rows.Count -ne $item.Expected.Count) { throw 'Worker session query did not return its exact captured set.' }
|
|
129
|
+
foreach ($row in $rows) {
|
|
130
|
+
$number = [int]$row.pid
|
|
131
|
+
if (-not $item.Expected.ContainsKey($number) -or
|
|
132
|
+
$observed.ContainsKey($number)) { throw 'Worker session query returned an unexpected or duplicate identity.' }
|
|
133
|
+
$observed[$number] = $row
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
foreach ($item in $pending) {
|
|
138
|
+
if (-not $item.Result.IsCompleted) { $item.Pipeline.Stop() }
|
|
139
|
+
$item.Pipeline.Dispose()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} finally { $pool.Close(); $pool.Dispose() }
|
|
144
|
+
foreach ($identity in $identities) {
|
|
145
|
+
$matching = $observed[[int]$identity.pid]
|
|
146
|
+
if ($null -eq $matching -or
|
|
147
|
+
$matching.sessionId -ne $request.sessionId) { throw 'Captured generation/session changed; replan before stop.' }
|
|
148
|
+
$actual = $matching.creationTicks
|
|
149
|
+
$expected = [DateTime]::FromFileTimeUtc([long]$identity.creationTime).Ticks
|
|
150
|
+
# CIM creation dates have microsecond precision; this read never authorizes termination.
|
|
151
|
+
if ([decimal]::Truncate([decimal]$actual / 10) -ne [decimal]::Truncate([decimal]$expected / 10)) {
|
|
152
|
+
throw 'Captured process birth changed; replan before stop.'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
'{"verified":true}'
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Inspects or updates one identity-bound per-user Pacemaker registration.
|
|
4
|
+
.DESCRIPTION
|
|
5
|
+
Reads a bounded JSON request from standard input. Mutations require the exact
|
|
6
|
+
task XML and full owner/group/DACL/SACL/integrity descriptor. Does not enable
|
|
7
|
+
privileges, repair permissions or stop processes.
|
|
8
|
+
.PARAMETER Operation
|
|
9
|
+
The scoped task operation to perform.
|
|
10
|
+
.PARAMETER Port
|
|
11
|
+
The selected Pacemaker port.
|
|
12
|
+
.PARAMETER FunctionsOnly
|
|
13
|
+
Loads shared task inspection/security functions without connecting to Task Scheduler.
|
|
14
|
+
.OUTPUTS
|
|
15
|
+
JSON task identity including its XML for protected backup and scoped restoration.
|
|
16
|
+
.EXAMPLE
|
|
17
|
+
.\legacy-task.ps1 -Operation inspect -Port 8791
|
|
18
|
+
#>
|
|
19
|
+
[CmdletBinding()]
|
|
20
|
+
param(
|
|
21
|
+
[Parameter(Mandatory)]
|
|
22
|
+
[ValidateSet('inspect', 'hold', 'repoint', 'enable', 'restore', 'start', 'compare-restored')]
|
|
23
|
+
[string]$Operation,
|
|
24
|
+
[Parameter(Mandatory)]
|
|
25
|
+
[ValidateRange(1, 65535)]
|
|
26
|
+
[int]$Port,
|
|
27
|
+
[switch]$FunctionsOnly
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
$ErrorActionPreference = 'Stop'
|
|
31
|
+
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
32
|
+
$ownerPolicy = (Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot '..\..\bin\legacy-1.3.json') | ConvertFrom-Json).taskOwnerPolicy
|
|
33
|
+
if ($ownerPolicy.protocol -ne 1 -or
|
|
34
|
+
$ownerPolicy.allowRuntimeUser -ne $true -or
|
|
35
|
+
$null -eq $ownerPolicy.allowedOwnerSids) { throw 'Unsupported task owner policy.' }
|
|
36
|
+
|
|
37
|
+
function Get-UserSid {
|
|
38
|
+
param([string]$User)
|
|
39
|
+
if ($User.StartsWith('S-1-', [StringComparison]::Ordinal)) { return $User }
|
|
40
|
+
$account = [Security.Principal.NTAccount]::new($User)
|
|
41
|
+
return $account.Translate([Security.Principal.SecurityIdentifier]).Value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Get-XmlHash {
|
|
45
|
+
param([string]$Xml)
|
|
46
|
+
$algorithm = [Security.Cryptography.SHA256]::Create()
|
|
47
|
+
try {
|
|
48
|
+
return [Convert]::ToHexString($algorithm.ComputeHash([Text.Encoding]::UTF8.GetBytes($Xml))).ToLowerInvariant()
|
|
49
|
+
}
|
|
50
|
+
finally { $algorithm.Dispose() }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ConvertTo-TaskSecurity {
|
|
54
|
+
param([string]$Sddl)
|
|
55
|
+
try {
|
|
56
|
+
if ([string]::IsNullOrEmpty($Sddl) -or
|
|
57
|
+
$Sddl.Length -gt 65536) { throw 'Unsupported descriptor length.' }
|
|
58
|
+
$descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
|
|
59
|
+
if ($null -eq $descriptor.Owner -or
|
|
60
|
+
$null -eq $descriptor.Group) { throw 'Missing resource owner/group.' }
|
|
61
|
+
return @{
|
|
62
|
+
protocol = 1
|
|
63
|
+
information = 31
|
|
64
|
+
complete = $true
|
|
65
|
+
ownerSid = $descriptor.Owner.Value
|
|
66
|
+
groupSid = $descriptor.Group.Value
|
|
67
|
+
controlFlags = [int]$descriptor.ControlFlags
|
|
68
|
+
# AccessControlSections.All omits the LABEL flag when reserializing SDDL.
|
|
69
|
+
sddl = $Sddl
|
|
70
|
+
sha256 = Get-XmlHash -Xml $Sddl
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch { throw 'TASK_SECURITY_UNVERIFIED: Full task security descriptor could not be represented. No security fallback is allowed.' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Get-TaskSecurity {
|
|
77
|
+
param($Task)
|
|
78
|
+
try {
|
|
79
|
+
# OWNER | GROUP | DACL | SACL | LABEL; omitted audit data is not an empty SACL.
|
|
80
|
+
$sddl = $Task.GetSecurityDescriptor(31)
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw 'TASK_SECURITY_UNVERIFIED: Owner/group/DACL/SACL/integrity inspection was denied or unsupported. Further operations are refused; this adapter does not enable security privilege or elevate.'
|
|
84
|
+
}
|
|
85
|
+
return ConvertTo-TaskSecurity -Sddl $sddl
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Assert-TaskSecurity {
|
|
89
|
+
param($Actual, $Expected, [string]$RuntimeSid = $currentSid)
|
|
90
|
+
$recorded = ConvertTo-TaskSecurity -Sddl $Expected.sddl
|
|
91
|
+
if ($Expected.protocol -ne 1 -or
|
|
92
|
+
$Expected.information -ne 31 -or
|
|
93
|
+
$Expected.complete -ne $true -or
|
|
94
|
+
$Expected.sha256 -cne $recorded.sha256 -or
|
|
95
|
+
$Expected.ownerSid -cne $recorded.ownerSid -or
|
|
96
|
+
$Expected.groupSid -cne $recorded.groupSid -or
|
|
97
|
+
$Expected.controlFlags -ne $recorded.controlFlags -or
|
|
98
|
+
$Actual.sha256 -cne $recorded.sha256 -or
|
|
99
|
+
-not ($Actual.ownerSid -ceq $RuntimeSid -or
|
|
100
|
+
$Actual.ownerSid -cin $ownerPolicy.allowedOwnerSids)) {
|
|
101
|
+
throw 'TASK_SECURITY_CHANGED: Resource owner, permissions, audit or integrity no longer matches the approved task. Further operations are refused.'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Get-TaskRecord {
|
|
106
|
+
param($Task)
|
|
107
|
+
$definition = $Task.Definition
|
|
108
|
+
$actions = @($definition.Actions | ForEach-Object {
|
|
109
|
+
@{
|
|
110
|
+
type = $_.Type
|
|
111
|
+
path = $_.Path
|
|
112
|
+
arguments = $_.Arguments
|
|
113
|
+
workingDirectory = [string]$_.WorkingDirectory
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
$triggers = @($definition.Triggers | ForEach-Object {
|
|
117
|
+
@{
|
|
118
|
+
type = $_.Type
|
|
119
|
+
userSid = Get-UserSid -User $_.UserId
|
|
120
|
+
enabled = $_.Enabled
|
|
121
|
+
stateChange = if ($_.Type -eq 11) { $_.StateChange } else { 0 }
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
$xml = $Task.Xml
|
|
125
|
+
if ($xml.Length -gt 524288) { throw 'Selected task XML exceeds the supported bound.' }
|
|
126
|
+
return @{
|
|
127
|
+
name = $Task.Name
|
|
128
|
+
path = $Task.Path
|
|
129
|
+
enabled = $Task.Enabled
|
|
130
|
+
xml = $xml
|
|
131
|
+
xmlSha256 = Get-XmlHash -Xml $xml
|
|
132
|
+
security = Get-TaskSecurity -Task $Task
|
|
133
|
+
currentUserSid = $currentSid
|
|
134
|
+
userSid = Get-UserSid -User $definition.Principal.UserId
|
|
135
|
+
logonType = $definition.Principal.LogonType
|
|
136
|
+
runLevel = $definition.Principal.RunLevel
|
|
137
|
+
actions = $actions
|
|
138
|
+
triggers = $triggers
|
|
139
|
+
settings = @{
|
|
140
|
+
multipleInstances = $definition.Settings.MultipleInstances
|
|
141
|
+
restartCount = $definition.Settings.RestartCount
|
|
142
|
+
restartInterval = $definition.Settings.RestartInterval
|
|
143
|
+
executionTimeLimit = $definition.Settings.ExecutionTimeLimit
|
|
144
|
+
startWhenAvailable = $definition.Settings.StartWhenAvailable
|
|
145
|
+
disallowStartIfOnBatteries = $definition.Settings.DisallowStartIfOnBatteries
|
|
146
|
+
stopIfGoingOnBatteries = $definition.Settings.StopIfGoingOnBatteries
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Get-RestorationXml {
|
|
152
|
+
param([string]$Xml, $TaskService)
|
|
153
|
+
if ([string]::IsNullOrWhiteSpace($Xml) -or
|
|
154
|
+
$Xml.Length -gt 524288) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Invalid XML bounds.' }
|
|
155
|
+
$settings = [Xml.XmlReaderSettings]::new()
|
|
156
|
+
$settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit
|
|
157
|
+
$settings.XmlResolver = $null
|
|
158
|
+
$settings.MaxCharactersInDocument = 524288
|
|
159
|
+
$text = [IO.StringReader]::new($Xml)
|
|
160
|
+
$reader = [Xml.XmlReader]::Create($text, $settings)
|
|
161
|
+
$document = [Xml.XmlDocument]::new()
|
|
162
|
+
$document.XmlResolver = $null
|
|
163
|
+
try { $document.Load($reader) }
|
|
164
|
+
finally { $reader.Dispose(); $text.Dispose() }
|
|
165
|
+
$namespace = 'http://schemas.microsoft.com/windows/2004/02/mit/task'
|
|
166
|
+
$root = $document.DocumentElement
|
|
167
|
+
if ($root.LocalName -cne 'Task' -or
|
|
168
|
+
$root.NamespaceURI -cne $namespace) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected task root.' }
|
|
169
|
+
$registrations = @($document.SelectNodes("//*[local-name()='RegistrationInfo']"))
|
|
170
|
+
$descriptors = @($document.SelectNodes("//*[local-name()='SecurityDescriptor']"))
|
|
171
|
+
if ($registrations.Count -gt 1 -or
|
|
172
|
+
$descriptors.Count -gt 1) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Duplicate registration metadata.' }
|
|
173
|
+
foreach ($registration in $registrations) {
|
|
174
|
+
if ($registration.NamespaceURI -cne $namespace -or
|
|
175
|
+
$registration.ParentNode -ne $root) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected registration metadata.' }
|
|
176
|
+
}
|
|
177
|
+
foreach ($descriptor in $descriptors) {
|
|
178
|
+
if ($descriptor.NamespaceURI -cne $namespace -or
|
|
179
|
+
$descriptor.ParentNode.LocalName -cne 'RegistrationInfo' -or
|
|
180
|
+
$descriptor.ParentNode.NamespaceURI -cne $namespace -or
|
|
181
|
+
$descriptor.ParentNode.ParentNode -ne $root) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected embedded descriptor.' }
|
|
182
|
+
}
|
|
183
|
+
$definition = $TaskService.NewTask(0)
|
|
184
|
+
$definition.XmlText = $Xml
|
|
185
|
+
$definition.RegistrationInfo.SecurityDescriptor = ''
|
|
186
|
+
return $definition.XmlText
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Get-MatchingTasks {
|
|
190
|
+
param($TaskFolder, [int]$Depth)
|
|
191
|
+
if ($Depth -gt 8) { throw 'Task folder depth exceeds the inspection bound.' }
|
|
192
|
+
$script:folderCount++
|
|
193
|
+
if ($script:folderCount -gt 512) { throw 'Task folder count exceeds the inspection bound.' }
|
|
194
|
+
foreach ($candidate in $TaskFolder.GetTasks(1)) {
|
|
195
|
+
$matching = $candidate.Name -in @("McpPacemaker-$Port", 'McpPacemaker')
|
|
196
|
+
foreach ($action in $candidate.Definition.Actions) {
|
|
197
|
+
if ($action.Type -eq 0 -and
|
|
198
|
+
$null -ne $action.Arguments -and
|
|
199
|
+
$action.Arguments.EndsWith("\autostart\windows\launcher.vbs`" $Port", [StringComparison]::OrdinalIgnoreCase)) {
|
|
200
|
+
$matching = $true
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if ($matching) { $candidate }
|
|
204
|
+
}
|
|
205
|
+
foreach ($childFolder in $TaskFolder.GetFolders(0)) {
|
|
206
|
+
Get-MatchingTasks -TaskFolder $childFolder -Depth ($Depth + 1)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if ($FunctionsOnly) { return }
|
|
210
|
+
$service = New-Object -ComObject Schedule.Service
|
|
211
|
+
$service.Connect()
|
|
212
|
+
if ($Operation -eq 'compare-restored') {
|
|
213
|
+
$text = [Console]::In.ReadToEnd()
|
|
214
|
+
if ($text.Length -gt 1048576) { throw 'Task operation input exceeds the supported bound.' }
|
|
215
|
+
$request = $text | ConvertFrom-Json
|
|
216
|
+
Assert-TaskSecurity -Actual $request.actual.security -Expected $request.original.security -RuntimeSid $request.original.userSid
|
|
217
|
+
$actualXml = Get-RestorationXml -Xml $request.actual.xml -TaskService $service
|
|
218
|
+
$originalXml = Get-RestorationXml -Xml $request.original.xml -TaskService $service
|
|
219
|
+
if ((Get-XmlHash -Xml $actualXml) -cne (Get-XmlHash -Xml $originalXml)) {
|
|
220
|
+
throw 'Restored legacy registration XML differs beyond its embedded descriptor representation.'
|
|
221
|
+
}
|
|
222
|
+
@{ equivalent = $true } | ConvertTo-Json -Compress
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
$folder = $service.GetFolder('\')
|
|
226
|
+
$script:folderCount = 0
|
|
227
|
+
$tasks = @(Get-MatchingTasks -TaskFolder $folder -Depth 0)
|
|
228
|
+
if ($tasks.Count -gt 16) { throw 'Too many matching Pacemaker registrations.' }
|
|
229
|
+
if ($Operation -eq 'inspect') {
|
|
230
|
+
ConvertTo-Json -InputObject @($tasks | ForEach-Object { Get-TaskRecord -Task $_ }) -Depth 8 -Compress
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
$text = [Console]::In.ReadToEnd()
|
|
235
|
+
if ($text.Length -gt 1048576) { throw 'Task operation input exceeds the supported bound.' }
|
|
236
|
+
$request = $text | ConvertFrom-Json
|
|
237
|
+
if ($tasks.Count -ne 1) { throw 'Exactly one selected registration is required before mutation.' }
|
|
238
|
+
$task = $tasks[0]
|
|
239
|
+
$record = Get-TaskRecord -Task $task
|
|
240
|
+
Assert-TaskSecurity -Actual $record.security -Expected $request.expected.security
|
|
241
|
+
if ($record.name -ne $request.expected.name -or
|
|
242
|
+
$record.path -ne "\$($record.name)" -or
|
|
243
|
+
$record.xmlSha256 -cne $request.expected.xmlSha256 -or
|
|
244
|
+
$record.userSid -cne $currentSid -or
|
|
245
|
+
$record.logonType -ne 3 -or
|
|
246
|
+
$record.runLevel -ne 0) {
|
|
247
|
+
throw 'Selected task identity changed; no operation was performed.'
|
|
248
|
+
}
|
|
249
|
+
switch ($Operation) {
|
|
250
|
+
'hold' { $task.Enabled = $false }
|
|
251
|
+
'enable' { $task.Enabled = $true }
|
|
252
|
+
'repoint' {
|
|
253
|
+
if ($task.Enabled) { throw 'Registration must be held before replacing its launcher.' }
|
|
254
|
+
$launcher = [IO.Path]::GetFullPath($request.launcher)
|
|
255
|
+
if ($launcher.IndexOfAny([char[]]"`"`r`n") -ge 0 -or
|
|
256
|
+
-not [IO.File]::Exists($launcher)) { throw 'Invalid stable launcher path.' }
|
|
257
|
+
$definition = $task.Definition
|
|
258
|
+
if ($definition.Actions.Count -ne 1) { throw 'Unexpected task actions.' }
|
|
259
|
+
$definition.Actions.Item(1).Arguments = "`"$launcher`""
|
|
260
|
+
# Update only, do not alter principal ACEs, and suppress registration-trigger execution.
|
|
261
|
+
$definition.RegistrationInfo.SecurityDescriptor = ''
|
|
262
|
+
$folder.RegisterTaskDefinition($task.Name, $definition, 52, $definition.Principal.UserId, $null, 3, $null) | Out-Null
|
|
263
|
+
}
|
|
264
|
+
'restore' {
|
|
265
|
+
if ($task.Enabled) { throw 'Registration must be held before restoration.' }
|
|
266
|
+
Assert-TaskSecurity -Actual $record.security -Expected $request.original.security
|
|
267
|
+
$definition = $service.NewTask(0)
|
|
268
|
+
$definition.XmlText = $request.original.xml
|
|
269
|
+
if ((Get-XmlHash -Xml $request.original.xml) -cne $request.original.xmlSha256 -or
|
|
270
|
+
(Get-UserSid -User $definition.Principal.UserId) -cne $currentSid -or
|
|
271
|
+
$definition.Principal.LogonType -ne 3 -or
|
|
272
|
+
$definition.Principal.RunLevel -ne 0 -or
|
|
273
|
+
$request.original.name -ne $task.Name) { throw 'Invalid original task identity.' }
|
|
274
|
+
$definition.RegistrationInfo.SecurityDescriptor = ''
|
|
275
|
+
$folder.RegisterTaskDefinition($task.Name, $definition, 52, $definition.Principal.UserId, $null, 3, $null) | Out-Null
|
|
276
|
+
}
|
|
277
|
+
'start' { $task.Run($null) | Out-Null }
|
|
278
|
+
}
|
|
279
|
+
$updated = Get-TaskRecord -Task ($folder.GetTask($task.Name))
|
|
280
|
+
Assert-TaskSecurity -Actual $updated.security -Expected $record.security
|
|
281
|
+
$expectedDefinition = $service.NewTask(0)
|
|
282
|
+
$expectedDefinition.XmlText = $record.xml
|
|
283
|
+
switch ($Operation) {
|
|
284
|
+
'hold' { $expectedDefinition.Settings.Enabled = $false }
|
|
285
|
+
'enable' { $expectedDefinition.Settings.Enabled = $true }
|
|
286
|
+
'repoint' { $expectedDefinition.Actions.Item(1).Arguments = "`"$launcher`"" }
|
|
287
|
+
'restore' { $expectedDefinition.XmlText = $request.original.xml }
|
|
288
|
+
}
|
|
289
|
+
$updatedDefinition = $service.NewTask(0)
|
|
290
|
+
$updatedDefinition.XmlText = $updated.xml
|
|
291
|
+
if ($Operation -in @('repoint', 'restore')) {
|
|
292
|
+
$expectedDefinition.RegistrationInfo.SecurityDescriptor = ''
|
|
293
|
+
if (-not [string]::IsNullOrEmpty($updatedDefinition.RegistrationInfo.SecurityDescriptor)) {
|
|
294
|
+
throw 'Registration changed beyond the requested scoped edit; do not stop or activate a backend.'
|
|
295
|
+
}
|
|
296
|
+
$updatedDefinition.RegistrationInfo.SecurityDescriptor = ''
|
|
297
|
+
}
|
|
298
|
+
if ((Get-XmlHash -Xml $expectedDefinition.XmlText) -cne (Get-XmlHash -Xml $updatedDefinition.XmlText)) {
|
|
299
|
+
throw 'Registration changed beyond the requested scoped edit; do not stop or activate a backend.'
|
|
300
|
+
}
|
|
301
|
+
$updated | ConvertTo-Json -Depth 8 -Compress
|