Sumber yang diterbitkan
Muat turun ZIP sumber
CH-J Server Manager
Layari direktori dan fail untuk keluaran aplikasi tertentu.
CH-J / Server Manager
377 fail
core-20261019T111616Z-160a00bfcddc
Ditambah: · Dibina:
1
# CH-J Server Manager - fixed remote helper for chj.users on Windows (Windows PowerShell 5.1+ / PowerShell 7).2
# Core starts it through a short -EncodedCommand bootstrap that reads this script and one JSON argument object3
# from stdin, so it works with cmd.exe, Windows PowerShell or PowerShell 7 as the OpenSSH default shell.4
# Secrets (account passwords) and file contents only arrive inside the stdin JSON, never in a command line.5
# Output: one line "CHJ_RESULT:<base64 UTF-8 JSON>" or "CHJ_ERR:<CODE>:<base64 UTF-8 message>".6
param([string]$ArgsJson)7
Set-StrictMode -Version 28
$ErrorActionPreference = 'Stop'9
$ProgressPreference = 'SilentlyContinue'10
$WarningPreference = 'SilentlyContinue'12
$AdminsSid = 'S-1-5-32-544'13
$UsersSid = 'S-1-5-32-545'14
$SystemSid = 'S-1-5-18'15
$SshDir = Join-Path $env:ProgramData 'ssh'16
$SshConfig = Join-Path $SshDir 'sshd_config'17
$AdminKeys = Join-Path $SshDir 'administrators_authorized_keys'18
$StateDir = Join-Path $env:ProgramData 'CH-J\chj-users'19
$TaskPrefix = 'CH-J chj.users sshd apply '20
$MaxKeysBytes = 26214422
function Fail([string]$Code, [string]$Message) { throw [System.Exception]::new("CHJ:${Code}:$Message") }23
function B64([string]$Text) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Text)) }24
# The leading comma keeps PowerShell from unrolling the byte array (an empty array would become $null).25
function FromB64([string]$Text) { ,[Convert]::FromBase64String($Text) }26
function Iso($Value) { if ($null -eq $Value) { return $null }; return ([datetime]$Value).ToUniversalTime().ToString('o') }27
function Sha256Hex([byte[]]$Bytes) {28
$sha = [Security.Cryptography.SHA256]::Create()29
try { return (($sha.ComputeHash($Bytes) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $sha.Dispose() }30
}31
function FileSha([string]$Path) { if (Test-Path -LiteralPath $Path -PathType Leaf) { return Sha256Hex ([IO.File]::ReadAllBytes($Path)) }; return 'absent' }32
function Prop($Object, [string]$Name) { if ($null -ne $Object -and $Object.PSObject.Properties[$Name]) { return $Object.$Name }; return $null }34
function Get-TokenInfo {35
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()36
$principal = New-Object Security.Principal.WindowsPrincipal($identity)37
$elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)38
$groupSids = @($identity.Groups | ForEach-Object { $_.Value })39
$denyOnly = @($identity.Claims | Where-Object { $_.Type -eq 'http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlysid' } | ForEach-Object { $_.Value })40
$integrity = 'unknown'41
foreach ($line in @(& "$env:WINDIR\System32\whoami.exe" /groups /fo csv 2>$null)) {42
if ($line -match '"(S-1-16-\d+)"') { $integrity = @{ 'S-1-16-4096' = 'low'; 'S-1-16-8192' = 'medium'; 'S-1-16-8448' = 'medium-plus'; 'S-1-16-12288' = 'high'; 'S-1-16-16384' = 'system' }[$Matches[1]]; if (-not $integrity) { $integrity = $Matches[1] } }43
}44
# Group names as sshd compares them (lower-case, without domain), plus the unlocalized aliases of well-known SIDs.45
$groupNames = @()46
foreach ($group in @($identity.Groups)) {47
try { $groupNames += (($group.Translate([Security.Principal.NTAccount]).Value -split '\\')[-1]).ToLowerInvariant() } catch {}48
$alias = @{ 'S-1-5-32-544' = 'administrators'; 'S-1-5-32-545' = 'users'; 'S-1-5-32-585' = 'openssh users' }[$group.Value]49
if ($alias) { $groupNames += $alias }50
}51
[ordered]@{52
name = $identity.Name53
groupNames = @($groupNames | Select-Object -Unique)54
sid = $identity.User.Value55
elevated = $elevated56
adminMember = ($elevated -or ($groupSids -contains $AdminsSid) -or ($denyOnly -contains $AdminsSid))57
adminDenyOnly = ($denyOnly -contains $AdminsSid)58
integrity = $integrity59
isSystem = ($identity.User.Value -eq $SystemSid)60
}61
}62
function Require-Elevation {63
$token = Get-TokenInfo64
if ($token.elevated) { return }65
if ($token.adminMember) { Fail 'WINDOWS_ELEVATION_REQUIRED' 'The SSH session belongs to the Administrators group but its token is not elevated (UAC filtered token). CH-J does not bypass UAC.' }66
Fail 'WINDOWS_ELEVATION_REQUIRED' 'The SSH account is not a member of the local Administrators group; administrative operations are not available.'67
}69
# Standard accounts logged on over SSH (network logon) usually get "Access denied" from WMI/CIM, so the70
# registry and .NET are the primary sources and CIM only adds details when it is permitted.71
function Get-SystemInfo {72
$nt = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue73
$openssh = Get-ItemProperty 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue74
$build = [string](Prop $nt 'CurrentBuildNumber')75
$caption = [string](Prop $nt 'ProductName')76
if ($caption -match '^Windows 10' -and [int]("0$build") -ge 22000) { $caption = $caption -replace '^Windows 10', 'Windows 11' }77
$info = [ordered]@{78
caption = $caption79
version = [Environment]::OSVersion.Version.ToString()80
build = $build81
displayVersion = [string](Prop $nt 'DisplayVersion')82
installationType = [string](Prop $nt 'InstallationType')83
productType = $null84
architecture = $env:PROCESSOR_ARCHITECTURE85
computerName = $env:COMPUTERNAME86
partOfDomain = $null87
domain = [string]$env:USERDNSDOMAIN88
domainRole = $null89
psVersion = $PSVersionTable.PSVersion.ToString()90
psEdition = [string]$PSVersionTable.PSEdition91
is64BitProcess = [Environment]::Is64BitProcess92
defaultShell = [string](Prop $openssh 'DefaultShell')93
localAccountsModule = [bool](Get-Command Get-LocalUser -ErrorAction SilentlyContinue)94
sshClient = [string]$env:SSH_CLIENT95
cim = $false96
}97
try {98
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop99
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop100
$info.caption = [string]$os.Caption; $info.version = [string]$os.Version; $info.productType = [int]$os.ProductType101
$info.partOfDomain = [bool]$cs.PartOfDomain; $info.domain = [string]$cs.Domain; $info.domainRole = [int]$cs.DomainRole; $info.cim = $true102
} catch {103
$type = [string](Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ProductOptions' -ErrorAction SilentlyContinue).ProductType104
$info.productType = @{ 'WinNT' = 1; 'LanmanNT' = 2; 'ServerNT' = 3 }[$type]105
}106
$info107
}109
function Get-SshdPath { $path = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'; if (Test-Path -LiteralPath $path) { return $path }; $cmd = Get-Command sshd.exe -ErrorAction SilentlyContinue; if ($cmd) { return $cmd.Source }; return $null }110
# sshd.exe never runs without a time limit: on some builds "sshd -T -C user=<other account>" cannot resolve111
# the account's groups without SYSTEM rights and then waits forever.112
function Invoke-Process([string]$File, [string[]]$Arguments, [int]$TimeoutMs = 15000) {113
$psi = New-Object Diagnostics.ProcessStartInfo $File114
$psi.Arguments = (@($Arguments) | ForEach-Object { '"' + ([string]$_).Replace('"', '') + '"' }) -join ' '115
$psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true116
$process = [Diagnostics.Process]::Start($psi)117
$process.StandardInput.Close()118
$out = $process.StandardOutput.ReadToEndAsync(); $err = $process.StandardError.ReadToEndAsync()119
if (-not $process.WaitForExit($TimeoutMs)) { try { $process.Kill() } catch {}; return [ordered]@{ code = -2; output = ('timeout: ' + $err.Result).Trim() } }120
$process.WaitForExit()121
[ordered]@{ code = $process.ExitCode; output = (($out.Result + "`n" + $err.Result).Trim() -replace "`r", '') }122
}123
function Invoke-Sshd([string[]]$Arguments) { Invoke-Process (Get-SshdPath) $Arguments 15000 }125
$SshdRunner = @'126
param([string]$JobPath)127
$ErrorActionPreference = 'Stop'128
$job = Get-Content -LiteralPath $JobPath -Raw | ConvertFrom-Json129
$sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'130
$results = @()131
foreach ($run in @($job.runs)) {132
$psi = New-Object Diagnostics.ProcessStartInfo $sshd133
$psi.Arguments = (@($run) | ForEach-Object { '"' + ([string]$_).Replace('"', '') + '"' }) -join ' '134
$psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true135
$process = [Diagnostics.Process]::Start($psi); $process.StandardInput.Close()136
$out = $process.StandardOutput.ReadToEndAsync(); $err = $process.StandardError.ReadToEndAsync()137
if (-not $process.WaitForExit(15000)) { try { $process.Kill() } catch {}; $results += [ordered]@{ code = -2; output = 'timeout' }; continue }138
$process.WaitForExit()139
$results += [ordered]@{ code = $process.ExitCode; output = (($out.Result + "`n" + $err.Result).Trim() -replace "`r", '') }140
}141
[IO.File]::WriteAllText($job.output, (ConvertTo-Json -InputObject @($results) -Depth 4 -Compress), (New-Object Text.UTF8Encoding($false)))142
'@144
# Runs a batch of sshd.exe argument lists as SYSTEM through a one-shot scheduled task (the script lives in the145
# SYSTEM/Administrators-only state directory). Returns one {code, output} per run.146
function Invoke-SshdBatch([object[]]$Runs) {147
if ((Get-TokenInfo).isSystem) { return @($Runs | ForEach-Object { Invoke-Sshd @($_) }) }148
Require-Elevation149
Initialize-StateDir150
$id = [guid]::NewGuid().ToString('N')151
$runner = Join-Path $StateDir 'sshd-query.ps1'152
[IO.File]::WriteAllText($runner, $SshdRunner, (New-Object Text.UTF8Encoding($true)))153
$jobPath = Join-Path $StateDir "query-$id.json"; $outputPath = Join-Path $StateDir "query-$id.out"154
[IO.File]::WriteAllText($jobPath, (ConvertTo-Json -InputObject ([ordered]@{ runs = @($Runs | ForEach-Object { ,@($_) }); output = $outputPath }) -Depth 5 -Compress), (New-Object Text.UTF8Encoding($false)))155
$bootstrap = "`$s=[IO.File]::ReadAllText('$runner'); & ([ScriptBlock]::Create(`$s)) '$jobPath'"156
$taskName = "CH-J chj.users sshd query $id"157
try {158
$action = New-ScheduledTaskAction -Execute "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument ("-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand " + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($bootstrap)))159
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest160
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5)161
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null162
Start-ScheduledTask -TaskName $taskName163
$deadline = (Get-Date).AddSeconds(30 + 16 * @($Runs).Count)164
while (-not (Test-Path -LiteralPath $outputPath) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 }165
if (-not (Test-Path -LiteralPath $outputPath)) { Fail 'SSHD_QUERY_FAILED' 'The SYSTEM task evaluating sshd -T did not finish in time.' }166
Start-Sleep -Milliseconds 100167
# Windows PowerShell 5.1 emits a parsed JSON array as one object; assigning it unrolls it.168
$parsed = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($outputPath))169
return @($parsed)170
} finally {171
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue172
Remove-Item -LiteralPath $jobPath, $outputPath -Force -ErrorAction SilentlyContinue173
}174
}175
function Effective-Args([string]$Name, [string]$Address, [string]$ConfigPath) {176
$arguments = @('-T'); if ($ConfigPath) { $arguments += @('-f', $ConfigPath) }177
$arguments + @('-C', "user=$Name,host=$Address,addr=$Address")178
}179
function Filter-Effective($Result) {180
$keep = @($Result.output -split "`n" | Where-Object { $_ -match '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|authenticationmethods|permitrootlogin|denyusers|allowusers|denygroups|allowgroups|authorizedkeysfile|authorizedkeyscommand|addressfamily|listenaddress|port|strictmodes|usedns) ' -or ($Result.code -ne 0) })181
[ordered]@{ code = $Result.code; text = ($keep -join "`n") }182
}184
function Get-SshInfo {185
$sshd = Get-SshdPath186
$service = Get-Service sshd -ErrorAction SilentlyContinue187
$info = [ordered]@{ installed = [bool]$sshd; path = $sshd; version = $null; service = $null; startType = $null; configPath = $SshConfig; configExists = (Test-Path -LiteralPath $SshConfig -PathType Leaf); configSha256 = 'absent'; configData = $null; configReadable = $false }188
if ($service) { $info.service = [string]$service.Status; $info.startType = [string]$service.StartType }189
if ($sshd) { $info.version = ((Invoke-Sshd @('-V')).output -split "`n")[0]; if (-not $info.version) { $info.version = (Get-Item -LiteralPath $sshd).VersionInfo.ProductVersion } }190
if ($info.configExists) {191
try { $bytes = [IO.File]::ReadAllBytes($SshConfig); $info.configSha256 = Sha256Hex $bytes; $info.configData = [Convert]::ToBase64String($bytes); $info.configReadable = $true } catch { $info.configReadable = $false }192
}193
$info194
}196
function Get-MemberSids($Group) {197
try {198
return @(Get-LocalGroupMember -SID $Group.SID.Value | ForEach-Object { [ordered]@{ sid = [string]$_.SID.Value; name = [string]$_.Name; objectClass = [string]$_.ObjectClass; source = [string]$_.PrincipalSource } })199
} catch {200
# Get-LocalGroupMember fails on orphaned or cloud SIDs; ADSI still lists the members.201
$result = @()202
$adsi = [ADSI]("WinNT://./" + $Group.Name + ",group")203
foreach ($member in @($adsi.Invoke('Members'))) {204
$bytes = $member.GetType().InvokeMember('objectSid', 'GetProperty', $null, $member, [NullString]::Value)205
$sid = (New-Object Security.Principal.SecurityIdentifier($bytes, 0)).Value206
$name = $member.GetType().InvokeMember('Name', 'GetProperty', $null, $member, [NullString]::Value)207
$result += [ordered]@{ sid = $sid; name = [string]$name; objectClass = 'unknown'; source = 'unknown' }208
}209
return $result210
}211
}212
function Get-GroupsWithMembers { @(Get-LocalGroup | ForEach-Object { [ordered]@{ sid = $_.SID.Value; name = $_.Name; description = $_.Description; members = @(Get-MemberSids $_) } }) }213
function Convert-User($User) {214
[ordered]@{215
name = $User.Name; sid = $User.SID.Value; enabled = [bool]$User.Enabled; fullName = [string]$User.FullName; description = [string]$User.Description216
source = [string]$User.PrincipalSource; lastLogon = (Iso $User.LastLogon); passwordLastSet = (Iso $User.PasswordLastSet); passwordRequired = [bool]$User.PasswordRequired217
passwordExpires = (Iso $User.PasswordExpires); accountExpires = (Iso $User.AccountExpires); userMayChangePassword = [bool]$User.UserMayChangePassword218
}219
}220
function Resolve-LocalUser($A) {221
$sid = [string](Prop $A 'sid')222
if ($sid) {223
if ($sid -notmatch '^S-1-5-21-\d+-\d+-\d+-\d+$') { Fail 'SID_INVALID' 'Invalid account SID.' }224
$user = Get-LocalUser -SID $sid -ErrorAction SilentlyContinue225
} else {226
$name = [string](Prop $A 'name')227
$user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue228
}229
if (-not $user) { Fail 'USER_NOT_FOUND' 'The local account does not exist.' }230
$user231
}232
function Get-ProfilePath([string]$Sid) {233
$entry = Get-ItemProperty ("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + $Sid) -ErrorAction SilentlyContinue234
if ($entry -and (Prop $entry 'ProfileImagePath')) { return [Environment]::ExpandEnvironmentVariables([string]$entry.ProfileImagePath) }235
return $null236
}238
function Op-Probe { [ordered]@{ system = (Get-SystemInfo); token = (Get-TokenInfo) } }240
function Op-Inventory {241
if (-not (Get-Command Get-LocalUser -ErrorAction SilentlyContinue)) { Fail 'LOCALACCOUNTS_UNAVAILABLE' 'The Microsoft.PowerShell.LocalAccounts module is not available in this PowerShell.' }242
$users = @(Get-LocalUser | ForEach-Object { Convert-User $_ })243
[ordered]@{ system = (Get-SystemInfo); token = (Get-TokenInfo); users = $users; groups = @(Get-GroupsWithMembers); ssh = (Get-SshInfo) }244
}246
function Get-KeyFileInfo([string]$Spec, [string]$Path, [bool]$Shared) {247
$info = [ordered]@{ spec = $Spec; path = $Path; shared = $Shared; type = 'absent'; dirExists = $false; reparse = $false; owner = $null; acl = @(); sha256 = 'absent'; data = $null; tooLarge = $false }248
$dir = Split-Path -Parent $Path249
if ($dir -and (Test-Path -LiteralPath $dir -PathType Container)) {250
$info.dirExists = $true251
if (((Get-Item -LiteralPath $dir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $info.reparse = $true }252
}253
if (Test-Path -LiteralPath $Path) {254
$item = Get-Item -LiteralPath $Path -Force255
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $info.type = 'reparse'; $info.reparse = $true; return $info }256
if ($item.PSIsContainer) { $info.type = 'other'; return $info }257
$info.type = 'file'258
try {259
$acl = Get-Acl -LiteralPath $Path260
$info.owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value261
$info.acl = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]) | ForEach-Object { [ordered]@{ sid = $_.IdentityReference.Value; rights = [string]$_.FileSystemRights; type = [string]$_.AccessControlType; inherited = $_.IsInherited } })262
if ($item.Length -gt $MaxKeysBytes) { $info.tooLarge = $true; return $info }263
$bytes = [IO.File]::ReadAllBytes($Path)264
$info.sha256 = Sha256Hex $bytes265
$info.data = [Convert]::ToBase64String($bytes)266
} catch { $info.type = 'unreadable' }267
} elseif (-not $info.dirExists) { $info.type = 'absent-dir' }268
$info269
}270
function Expand-KeysSpec([string]$Spec, [string]$ProfilePath, [string]$Name) {271
$value = $Spec.Replace('__PROGRAMDATA__', $env:ProgramData)272
if ($value -match '%[^%hu]') { return $null }273
$value = $value.Replace('%%', [string][char]1)274
if ($value -match '%h' -and -not $ProfilePath) { return $null }275
$value = $value.Replace('%h', [string]$ProfilePath).Replace('%u', $Name).Replace([string][char]1, '%').Replace('/', '\')276
if (-not [IO.Path]::IsPathRooted($value)) { if (-not $ProfilePath) { return $null }; $value = Join-Path $ProfilePath $value }277
[IO.Path]::GetFullPath($value)278
}280
function Op-Detail($A) {281
$user = Resolve-LocalUser $A282
$sid = $user.SID.Value283
$groups = @(Get-GroupsWithMembers | Where-Object { @($_.members | ForEach-Object { $_.sid }) -contains $sid } | ForEach-Object { [ordered]@{ sid = $_.sid; name = $_.name } })284
$token = Get-TokenInfo285
$profilePath = Get-ProfilePath $sid286
$ssh = Get-SshInfo287
$result = [ordered]@{ user = (Convert-User $user); groups = $groups; profilePath = $profilePath; token = $token; system = (Get-SystemInfo); ssh = $ssh; syntax = $null; effective4 = $null; effective6 = $null; keyFiles = @() }288
$specs = @('.ssh/authorized_keys')289
if (@($groups | ForEach-Object { $_.sid }) -contains $AdminsSid) { $specs = @('__PROGRAMDATA__/ssh/administrators_authorized_keys') }290
if ($token.elevated -and $ssh.installed) {291
$sshName = $user.Name.ToLowerInvariant()292
$addr4 = [string](Prop $A 'addr4'); $addr6 = [string](Prop $A 'addr6')293
if (($addr4 -and $addr4 -notmatch '^[0-9a-fA-F:.]{2,45}$') -or ($addr6 -and $addr6 -notmatch '^[0-9a-fA-F:.]{2,45}$')) { Fail 'ARGUMENTS_INVALID' 'Invalid address.' }294
$runs = @(,@('-t'))295
if ($addr4) { $runs += ,(Effective-Args $sshName $addr4 $null) }296
if ($addr6) { $runs += ,(Effective-Args $sshName $addr6 $null) }297
try {298
$batch = @(Invoke-SshdBatch $runs)299
$result.syntax = [ordered]@{ code = $batch[0].code; output = $batch[0].output }300
$index = 1301
if ($addr4) { $result.effective4 = Filter-Effective $batch[$index]; $index++ }302
if ($addr6) { $result.effective6 = Filter-Effective $batch[$index] }303
} catch { $result.syntax = [ordered]@{ code = $null; output = $_.Exception.Message } }304
$source = $result.effective4; if (-not $source) { $source = $result.effective6 }305
if ($source -and $source.code -eq 0) {306
$line = @($source.text -split "`n" | Where-Object { $_ -match '^authorizedkeysfile ' }) | Select-Object -First 1307
if ($line) { $specs = @(($line -replace '^authorizedkeysfile\s+', '') -split '\s+' | Where-Object { $_ -and $_ -ne 'none' }) }308
}309
}310
foreach ($spec in @($specs | Select-Object -First 4)) {311
$path = Expand-KeysSpec $spec $profilePath $user.Name312
if (-not $path) { $result.keyFiles += [ordered]@{ spec = $spec; path = $null; type = 'unresolved'; shared = $false }; continue }313
$shared = $path.StartsWith($env:ProgramData, [StringComparison]::OrdinalIgnoreCase)314
$selfOnly = (-not $token.elevated) -and ($token.sid -ne $sid)315
if ($selfOnly -or ($shared -and -not $token.elevated)) { $result.keyFiles += [ordered]@{ spec = $spec; path = $path; type = 'needs-elevation'; shared = $shared }; continue }316
$result.keyFiles += (Get-KeyFileInfo $spec $path $shared)317
}318
$result319
}321
function Assert-Mutable($User, [object]$A) {322
$rid = [int](($User.SID.Value -split '-')[-1])323
if ($rid -lt 1000) { Fail 'PROTECTED_USER' 'Built-in accounts (Administrator, Guest, DefaultAccount, WDAGUtilityAccount) are protected.' }324
if ($User.SID.Value -eq (Get-TokenInfo).sid) { Fail 'PROTECTED_USER' 'The account of the active SSH session is protected.' }325
}326
function Get-EnabledAdmins {327
@(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | Where-Object { $_.sid -match '^S-1-5-21-' } | ForEach-Object {328
$member = $_329
$local = Get-LocalUser -SID $member.sid -ErrorAction SilentlyContinue330
if ($local) { if ($local.Enabled) { $member.sid } } else { $member.sid }331
})332
}333
function Assert-NotLastAdmin($User) {334
$others = @(Get-EnabledAdmins | Where-Object { $_ -ne $User.SID.Value })335
if ($others.Count -eq 0) { Fail 'LAST_ADMIN' 'This is the last enabled member of the local Administrators group.' }336
}337
function New-SecurePassword([string]$Plain) {338
if (-not $Plain -or $Plain.Length -gt 256 -or $Plain -match "[`0`r`n]") { Fail 'PASSWORD_INVALID' 'Invalid password.' }339
ConvertTo-SecureString -String $Plain -AsPlainText -Force340
}341
function Resolve-Group([string]$Sid) {342
if ($Sid -notmatch '^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$') { Fail 'GROUP_INVALID' 'Invalid group SID.' }343
$group = Get-LocalGroup -SID $Sid -ErrorAction SilentlyContinue344
if (-not $group) { Fail 'GROUP_NOT_FOUND' 'The local group does not exist.' }345
$group346
}348
function Op-Create($A) {349
Require-Elevation350
$name = [string]$A.name351
if ($name -notmatch '^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$' -or $name -match '[. ]$') { Fail 'NAME_INVALID' 'Invalid account name.' }352
if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { Fail 'USER_EXISTS' 'The account already exists.' }353
if (Get-LocalGroup -Name $name -ErrorAction SilentlyContinue) { Fail 'GROUP_EXISTS' 'A local group with this name exists.' }354
$params = @{ Name = $name; Description = [string](Prop $A 'description'); FullName = [string](Prop $A 'fullName') }355
$password = [string](Prop $A 'password')356
if ($password) { $params.Password = New-SecurePassword $password } else { $params.NoPassword = $true }357
$user = New-LocalUser @params358
$steps = @([ordered]@{ step = 'create'; status = 'ok'; message = '' })359
$result = 'ok'360
foreach ($groupSid in @(@($UsersSid) + @(Prop $A 'groups') | Where-Object { $_ } | Select-Object -Unique)) {361
try { Add-LocalGroupMember -SID (Resolve-Group $groupSid).SID.Value -Member $user.SID.Value; $steps += [ordered]@{ step = "group $groupSid"; status = 'ok'; message = '' } }362
catch { $steps += [ordered]@{ step = "group $groupSid"; status = 'failed'; message = $_.Exception.Message }; $result = 'partial' }363
}364
if ((Prop $A 'admin') -eq $true) {365
try { Add-LocalGroupMember -SID $AdminsSid -Member $user.SID.Value; $steps += [ordered]@{ step = 'administrators'; status = 'ok'; message = '' } }366
catch { $steps += [ordered]@{ step = 'administrators'; status = 'failed'; message = $_.Exception.Message }; $result = 'partial' }367
}368
[ordered]@{ result = $result; steps = $steps; sid = $user.SID.Value }369
}371
function Op-Account($A) {372
Require-Elevation373
$user = Resolve-LocalUser $A374
Assert-Mutable $user $A375
$action = [string]$A.action376
$isAdmin = @(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | ForEach-Object { $_.sid }) -contains $user.SID.Value377
switch ($action) {378
'disable' { if ($isAdmin) { Assert-NotLastAdmin $user }; Disable-LocalUser -SID $user.SID.Value }379
'enable' { Enable-LocalUser -SID $user.SID.Value }380
'password' { Set-LocalUser -SID $user.SID.Value -Password (New-SecurePassword ([string]$A.password)) }381
'fullName' { $value = [string]$A.value; if ($value.Length -gt 256 -or $value -match '[\x00-\x1f]') { Fail 'COMMENT_INVALID' 'Invalid full name.' }; Set-LocalUser -SID $user.SID.Value -FullName $value }382
'description' { $value = [string]$A.value; if ($value.Length -gt 48 -or $value -match '[\x00-\x1f]') { Fail 'COMMENT_INVALID' 'The description may contain up to 48 characters.' }; Set-LocalUser -SID $user.SID.Value -Description $value }383
'group-add' { $group = Resolve-Group ([string]$A.group); Add-LocalGroupMember -SID $group.SID.Value -Member $user.SID.Value }384
'group-remove' {385
$group = Resolve-Group ([string]$A.group)386
if ($group.SID.Value -eq $AdminsSid) { Assert-NotLastAdmin $user }387
Remove-LocalGroupMember -SID $group.SID.Value -Member $user.SID.Value388
}389
'admin-grant' { Add-LocalGroupMember -SID $AdminsSid -Member $user.SID.Value }390
'admin-revoke' { Assert-NotLastAdmin $user; Remove-LocalGroupMember -SID $AdminsSid -Member $user.SID.Value }391
default { Fail 'ACTION_INVALID' 'Unsupported account action.' }392
}393
[ordered]@{ result = 'ok'; steps = @([ordered]@{ step = $action; status = 'ok'; message = '' }) }394
}396
function Op-Delete($A) {397
Require-Elevation398
$user = Resolve-LocalUser $A399
Assert-Mutable $user $A400
if (@(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | ForEach-Object { $_.sid }) -contains $user.SID.Value) { Assert-NotLastAdmin $user }401
$sid = $user.SID.Value402
$profile = Get-CimInstance Win32_UserProfile -Filter "SID='$sid'" -ErrorAction SilentlyContinue403
$removeProfile = (Prop $A 'removeProfile') -eq $true404
# Windows may keep a profile hive loaded for a while after SSH logons; only profile removal depends on it.405
if ($removeProfile -and $profile -and $profile.Loaded) { Fail 'PROFILE_IN_USE' 'The user profile is still loaded (the user is logged on or Windows has not released it yet). Try again later or delete the account without its profile.' }406
Remove-LocalUser -SID $sid407
$steps = @([ordered]@{ step = 'delete'; status = 'ok'; message = '' })408
$result = 'ok'409
if (-not $removeProfile -and $profile) { $steps += [ordered]@{ step = 'profile'; status = 'kept'; message = [string]$profile.LocalPath } }410
if ($removeProfile -and $profile) {411
try { Remove-CimInstance -InputObject $profile; $steps += [ordered]@{ step = 'profile'; status = 'ok'; message = [string]$profile.LocalPath } }412
catch { $steps += [ordered]@{ step = 'profile'; status = 'failed'; message = $_.Exception.Message }; $result = 'partial' }413
}414
[ordered]@{ result = $result; steps = $steps }415
}417
function Set-StrictKeyAcl([string]$Path, [string]$OwnerSid, [bool]$Shared) {418
$acl = New-Object Security.AccessControl.FileSecurity419
$acl.SetAccessRuleProtection($true, $false)420
$grant = @($SystemSid, $AdminsSid)421
if (-not $Shared) { $grant += $OwnerSid }422
foreach ($sid in $grant) { $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($sid)), 'FullControl', 'Allow'))) }423
$owner = $OwnerSid; if ($Shared) { $owner = $AdminsSid }424
$acl.SetOwner((New-Object Security.Principal.SecurityIdentifier($owner)))425
Set-Acl -LiteralPath $Path -AclObject $acl426
}427
function Op-KeysWrite($A) {428
$user = Resolve-LocalUser $A429
$token = Get-TokenInfo430
$self = $token.sid -eq $user.SID.Value431
if (-not $self) { Require-Elevation }432
$path = [string]$A.path433
# The path is recomputed on the server; Core cannot point the write at an arbitrary file.434
$detail = Op-Detail ([pscustomobject]@{ sid = $user.SID.Value; addr4 = [string](Prop $A 'addr4'); addr6 = [string](Prop $A 'addr6') })435
$file = @($detail.keyFiles | Where-Object { $_.path -and $_.path -eq $path }) | Select-Object -First 1436
if (-not $file) { Fail 'KEYS_PATH_UNSUPPORTED' 'The key file is not an effective AuthorizedKeysFile of this account.' }437
if ($file.type -eq 'needs-elevation') { Require-Elevation }438
if ($file.reparse -or $file.type -eq 'reparse') { Fail 'KEYS_SYMLINK' 'The key file or its directory is a reparse point (symbolic link or junction); it was not changed.' }439
if ($file.type -notin @('file', 'absent', 'absent-dir')) { Fail 'KEYS_NOT_REGULAR' 'The key file is not a regular file.' }440
if ((FileSha $path) -ne [string]$A.expected) { Fail 'KEYS_CHANGED' 'The key file changed after it was read. Reload and try again.' }441
$bytes = FromB64 ([string]$A.data)442
if ($bytes.Length -gt $MaxKeysBytes) { Fail 'KEYS_FILE_TOO_LARGE' 'The key file would be too large.' }443
$dir = Split-Path -Parent $path444
if (-not (Test-Path -LiteralPath $dir -PathType Container)) {445
if ($file.shared) { Fail 'KEYS_DIRECTORY_FAILED' 'The OpenSSH ProgramData directory is missing.' }446
$profilePath = $detail.profilePath447
if (-not $profilePath -or -not (Test-Path -LiteralPath $profilePath -PathType Container)) { Fail 'PROFILE_MISSING' 'The user profile does not exist yet (the account has never logged on).' }448
New-Item -ItemType Directory -Path $dir | Out-Null449
}450
$temp = Join-Path $dir ('.chj-keys-' + [guid]::NewGuid().ToString('N') + '.tmp')451
try {452
[IO.File]::WriteAllBytes($temp, $bytes)453
if (Test-Path -LiteralPath $path) {454
# ReplaceFile keeps the target's owner and DACL, so existing ACLs are not rewritten.455
[IO.File]::Replace($temp, $path, [NullString]::Value)456
} else {457
Set-StrictKeyAcl $temp $user.SID.Value $file.shared458
Move-Item -LiteralPath $temp -Destination $path459
}460
} finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force } }461
$after = Get-KeyFileInfo $file.spec $path $file.shared462
[ordered]@{ result = 'ok'; sha256 = $after.sha256; owner = $after.owner; acl = $after.acl }463
}465
function Initialize-StateDir {466
if (-not (Test-Path -LiteralPath $StateDir)) { New-Item -ItemType Directory -Path $StateDir -Force | Out-Null }467
# Only SYSTEM and Administrators may write here: the SYSTEM task executes files from this directory.468
$acl = New-Object Security.AccessControl.DirectorySecurity469
$acl.SetAccessRuleProtection($true, $false)470
foreach ($sid in @($SystemSid, $AdminsSid)) { $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($sid)), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) }471
$acl.SetOwner((New-Object Security.Principal.SecurityIdentifier($AdminsSid)))472
Set-Acl -LiteralPath $StateDir -AclObject $acl473
}474
function Get-ActiveApply {475
@(Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName.StartsWith($TaskPrefix) -and $_.State -eq 'Running' })476
}478
function Op-SshStage($A) {479
Require-Elevation480
if (-not (Get-SshdPath)) { Fail 'SSHD_MISSING' 'OpenSSH Server (sshd.exe) is not installed.' }481
if (@(Get-ActiveApply).Count) { Fail 'SSH_APPLY_IN_PROGRESS' 'Another SSH configuration change is still being applied or awaiting confirmation.' }482
if ((FileSha $SshConfig) -ne [string]$A.expected) { Fail 'SSH_POLICY_CHANGED' 'sshd_config changed after it was read.' }483
Initialize-StateDir484
$id = [guid]::NewGuid().ToString('N')485
$candidate = Join-Path $StateDir "candidate-$id.conf"486
[IO.File]::WriteAllBytes($candidate, (FromB64 ([string]$A.data)))487
Get-ChildItem -LiteralPath $StateDir -Filter 'candidate-*.conf' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddHours(-1) } | Remove-Item -Force -ErrorAction SilentlyContinue488
$keys = @()489
$runs = @(,@('-t')); $runs += ,@('-t', '-f', $candidate)490
foreach ($tuple in @($A.tuples)) {491
$name = [string]$tuple.user; $addr = [string]$tuple.addr492
if ($name -notmatch '^[a-z0-9_][a-z0-9._-]{0,19}$' -or $addr -notmatch '^[0-9a-fA-F:.]{2,45}$') { Fail 'ARGUMENTS_INVALID' 'Invalid verification target.' }493
$keys += "$name|$addr"494
$runs += ,(Effective-Args $name $addr $null)495
$runs += ,(Effective-Args $name $addr $candidate)496
}497
$batch = @(Invoke-SshdBatch $runs)498
if ($batch[0].code -ne 0) { Remove-Item -LiteralPath $candidate -Force; Fail 'SSH_CONFIG_ALREADY_INVALID' $batch[0].output }499
$test = $batch[1]500
$before = [ordered]@{}; $after = [ordered]@{}501
for ($i = 0; $i -lt $keys.Count; $i++) {502
$before[$keys[$i]] = Filter-Effective $batch[2 + 2 * $i]503
$after[$keys[$i]] = Filter-Effective $batch[3 + 2 * $i]504
}505
if ($test.code -ne 0) { Remove-Item -LiteralPath $candidate -Force }506
[ordered]@{ id = $id; syntaxCode = $test.code; syntaxOutput = $test.output; before = $before; after = $after; candidateSha256 = (Sha256Hex (FromB64 ([string]$A.data))) }507
}509
# Runs as SYSTEM from a scheduled task, independent of the SSH connection and of CH-J.510
$ApplyScript = @'511
param([string]$Id)512
$ErrorActionPreference = 'Stop'513
$ProgressPreference = 'SilentlyContinue'514
$StateDir = Join-Path $env:ProgramData 'CH-J\chj-users'515
$SshConfig = Join-Path $env:ProgramData 'ssh\sshd_config'516
$sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'517
$params = Get-Content -LiteralPath (Join-Path $StateDir "apply-$Id.json") -Raw | ConvertFrom-Json518
$statusPath = Join-Path $StateDir "status-$Id.json"519
function Status([string]$State, [string]$Message) { [ordered]@{ id = $Id; state = $State; message = $Message; at = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress | Set-Content -LiteralPath $statusPath -Encoding ASCII }520
function Sha([string]$Path) { $s = [Security.Cryptography.SHA256]::Create(); try { (($s.ComputeHash([IO.File]::ReadAllBytes($Path)) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $s.Dispose() } }521
function RestartSshd { Restart-Service sshd -Force; (Get-Service sshd).WaitForStatus('Running', [TimeSpan]::FromSeconds(30)) }522
$backup = Join-Path $StateDir "backup-$Id.conf"523
$backupAcl = Join-Path $StateDir "backup-$Id.sddl"524
$candidate = Join-Path $StateDir "candidate-$Id.conf"525
$token = Join-Path $StateDir "confirm-$Id.token"526
function Restore([string]$Reason) {527
try {528
$temp = "$SshConfig.chj-restore"529
Copy-Item -LiteralPath $backup -Destination $temp -Force530
$acl = Get-Acl -LiteralPath $SshConfig; $acl.SetSecurityDescriptorSddlForm((Get-Content -LiteralPath $backupAcl -Raw)); Set-Acl -LiteralPath $temp -AclObject $acl531
[IO.File]::Replace($temp, $SshConfig, [NullString]::Value)532
RestartSshd533
Status 'rolled-back' $Reason534
} catch { Status 'rollback-failed' ("$Reason; rollback error: " + $_.Exception.Message) }535
}536
try {537
Status 'armed' 'Rollback guard active.'538
if ((Sha $SshConfig) -ne $params.expected) { Status 'aborted' 'sshd_config changed before apply.'; return }539
if ((Sha $candidate) -ne $params.candidateSha256) { Status 'aborted' 'Candidate changed before apply.'; return }540
Copy-Item -LiteralPath $SshConfig -Destination $backup -Force541
(Get-Acl -LiteralPath $SshConfig).Sddl | Set-Content -LiteralPath $backupAcl -Encoding ASCII542
$temp = "$SshConfig.chj-new"543
Copy-Item -LiteralPath $candidate -Destination $temp -Force544
$acl = Get-Acl -LiteralPath $SshConfig; Set-Acl -LiteralPath $temp -AclObject $acl545
[IO.File]::Replace($temp, $SshConfig, [NullString]::Value)546
# ProcessStartInfo instead of Start-Process: Windows PowerShell 5.1 may report a $null ExitCode otherwise.547
$psi = New-Object Diagnostics.ProcessStartInfo $sshd, '-t'548
$psi.UseShellExecute = $false; $psi.RedirectStandardError = $true; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true549
$check = [Diagnostics.Process]::Start($psi); $check.StandardInput.Close()550
$checkErr = $check.StandardError.ReadToEndAsync(); $null = $check.StandardOutput.ReadToEndAsync()551
if (-not $check.WaitForExit(20000)) { try { $check.Kill() } catch {}; Restore 'sshd -t did not finish after writing the configuration.'; return }552
$check.WaitForExit()553
if ($check.ExitCode -ne 0) { Restore ('sshd -t failed after writing the configuration: ' + $checkErr.Result); return }554
try { RestartSshd } catch { Restore ('sshd restart failed: ' + $_.Exception.Message); return }555
Status 'awaiting-confirmation' 'sshd restarted with the new configuration.'556
$deadline = (Get-Date).AddSeconds([int]$params.timeoutSeconds)557
while ((Get-Date) -lt $deadline) {558
if (Test-Path -LiteralPath $token) {559
if ((Get-Content -LiteralPath $token -Raw).Trim() -eq $params.nonce) { Status 'confirmed' 'A new SSH login confirmed the change.'; return }560
}561
Start-Sleep -Milliseconds 500562
}563
Restore 'No confirmation from a new SSH login before the timeout.'564
} catch { Restore ('Apply failed: ' + $_.Exception.Message) }565
finally {566
Remove-Item -LiteralPath $candidate, $token -Force -ErrorAction SilentlyContinue567
Get-ChildItem -LiteralPath $StateDir -Filter 'backup-*' | Sort-Object LastWriteTime -Descending | Select-Object -Skip 20 | Remove-Item -Force -ErrorAction SilentlyContinue568
}569
'@571
function Op-SshCommit($A) {572
Require-Elevation573
$id = [string]$A.id574
if ($id -notmatch '^[0-9a-f]{32}$') { Fail 'ARGUMENTS_INVALID' 'Invalid apply id.' }575
$nonce = [string]$A.nonce576
if ($nonce -notmatch '^[0-9a-f]{32,64}$') { Fail 'ARGUMENTS_INVALID' 'Invalid nonce.' }577
$timeout = [int]$A.timeoutSeconds578
if ($timeout -lt 30 -or $timeout -gt 600) { Fail 'ARGUMENTS_INVALID' 'Invalid timeout.' }579
if (@(Get-ActiveApply).Count) { Fail 'SSH_APPLY_IN_PROGRESS' 'Another SSH configuration change is still being applied.' }580
Initialize-StateDir581
$candidate = Join-Path $StateDir "candidate-$id.conf"582
if (-not (Test-Path -LiteralPath $candidate)) { Fail 'SSH_POLICY_CHANGED' 'The staged configuration no longer exists.' }583
if ((FileSha $candidate) -ne [string]$A.candidateSha256) { Fail 'SSH_POLICY_CHANGED' 'The staged configuration changed.' }584
if ((FileSha $SshConfig) -ne [string]$A.expected) { Fail 'SSH_POLICY_CHANGED' 'sshd_config changed after it was read.' }585
$scriptPath = Join-Path $StateDir 'apply.ps1'586
[IO.File]::WriteAllText($scriptPath, $ApplyScript, (New-Object Text.UTF8Encoding($true)))587
[ordered]@{ expected = [string]$A.expected; candidateSha256 = [string]$A.candidateSha256; nonce = $nonce; timeoutSeconds = $timeout } | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $StateDir "apply-$id.json") -Encoding ASCII588
$taskName = $TaskPrefix + $id589
$action = New-ScheduledTaskAction -Execute "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`" -Id $id"590
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest591
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds ($timeout + 180)) -MultipleInstances IgnoreNew592
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null593
Start-ScheduledTask -TaskName $taskName594
$deadline = (Get-Date).AddSeconds(20)595
$status = $null596
while ((Get-Date) -lt $deadline) {597
$statusPath = Join-Path $StateDir "status-$id.json"598
if (Test-Path -LiteralPath $statusPath) { $status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json; if ($status.state -ne 'armed') { break } }599
Start-Sleep -Milliseconds 300600
}601
if (-not $status) { Fail 'SSH_APPLY_NOT_STARTED' 'The rollback guard task did not start; nothing was changed.' }602
[ordered]@{ id = $id; state = [string]$status.state; message = [string]$status.message }603
}605
function Op-SshConfirm($A) {606
Require-Elevation607
$id = [string]$A.id; $nonce = [string]$A.nonce608
if ($id -notmatch '^[0-9a-f]{32}$' -or $nonce -notmatch '^[0-9a-f]{32,64}$') { Fail 'ARGUMENTS_INVALID' 'Invalid confirmation.' }609
$statusPath = Join-Path $StateDir "status-$id.json"610
if (-not (Test-Path -LiteralPath $statusPath)) { Fail 'SSH_APPLY_UNKNOWN' 'The apply task is unknown.' }611
$status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json612
if ($status.state -eq 'awaiting-confirmation' -and (Prop $A 'confirm') -eq $true) { Set-Content -LiteralPath (Join-Path $StateDir "confirm-$id.token") -Value $nonce -Encoding ASCII }613
$deadline = (Get-Date).AddSeconds([int](Prop $A 'waitSeconds'))614
do {615
$status = Get-Content -LiteralPath $statusPath -Raw | ConvertFrom-Json616
if ($status.state -in @('confirmed', 'rolled-back', 'rollback-failed', 'aborted')) { break }617
Start-Sleep -Milliseconds 400618
} while ((Get-Date) -lt $deadline)619
if ($status.state -in @('confirmed', 'rolled-back', 'rollback-failed', 'aborted')) {620
Unregister-ScheduledTask -TaskName ($TaskPrefix + $id) -Confirm:$false -ErrorAction SilentlyContinue621
Remove-Item -LiteralPath (Join-Path $StateDir "apply-$id.json") -Force -ErrorAction SilentlyContinue622
}623
[ordered]@{ id = $id; state = [string]$status.state; message = [string]$status.message; service = [string](Get-Service sshd).Status }624
}626
$Operations = @{ 'probe' = { Op-Probe }; 'inventory' = { Op-Inventory }; 'detail' = { param($a) Op-Detail $a }; 'create' = { param($a) Op-Create $a }627
'account' = { param($a) Op-Account $a }; 'delete' = { param($a) Op-Delete $a }; 'keys-write' = { param($a) Op-KeysWrite $a }628
'ssh-stage' = { param($a) Op-SshStage $a }; 'ssh-commit' = { param($a) Op-SshCommit $a }; 'ssh-confirm' = { param($a) Op-SshConfirm $a } }629
try {630
$A = $ArgsJson | ConvertFrom-Json631
$op = [string]$A.op632
if (-not $Operations.ContainsKey($op)) { Fail 'ACTION_INVALID' 'Unsupported operation.' }633
$value = & $Operations[$op] $A634
[Console]::Out.WriteLine('CHJ_RESULT:' + (B64 ($value | ConvertTo-Json -Depth 10 -Compress)))635
} catch {636
$exception = $_.Exception637
while ($exception.InnerException -and -not $exception.Message.StartsWith('CHJ:')) { $exception = $exception.InnerException }638
$message = $exception.Message639
$code = 'WINDOWS_OPERATION_FAILED'640
if ($message -match '^CHJ:([A-Z0-9_]+):([\s\S]*)$') { $code = $Matches[1]; $message = $Matches[2] }641
else {642
switch -Regex ($exception.GetType().FullName) {643
'AccessDenied|UnauthorizedAccess' { $code = 'ACCESS_DENIED' }644
'UserNotFound' { $code = 'USER_NOT_FOUND' }645
'UserExists|NameInUse' { $code = 'USER_EXISTS' }646
'GroupNotFound' { $code = 'GROUP_NOT_FOUND' }647
'MemberExists' { $code = 'GROUP_MEMBER_EXISTS' }648
'MemberNotFound' { $code = 'GROUP_NOT_MEMBER' }649
'InvalidPassword' { $code = 'PASSWORD_POLICY' }650
'InvalidName' { $code = 'NAME_INVALID' }651
}652
if ($code -eq 'WINDOWS_OPERATION_FAILED' -and $exception.HResult -eq -2147024891) { $code = 'ACCESS_DENIED' }653
}654
[Console]::Out.WriteLine('CHJ_ERR:' + $code + ':' + (B64 $message))655
}SHA-256
e3ca28de32143cbd6b508f6fcc4c74703adea0ea17aeedfdd63cd4fd0e7e4fbdCH-J Proprietary Software License 1.14 · Kod sumber disediakan di bawah CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah terma lesen atau memberikan hak tambahan.
SHA-256 arkib
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a