CH-J Server Managerpengurusan pelayan melalui SSH
Menu
Sumber yang diterbitkan

CH-J Server Manager

Layari direktori dan fail untuk keluaran aplikasi tertentu.

Muat turun ZIP sumber
CH-J / Server Manager 368 fail
core-20261017T201717Z-057348c4a70f Ditambah: Tarikh tidak direkodkan · Dibina:
remoteUsers.ps1 Ditambah: Tarikh tidak direkodkan 41.9 KB · 655 baris
Kod Muat turun fail
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 object
3# 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>".
6param([string]$ArgsJson)
7Set-StrictMode -Version 2
8$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 = 262144
22function Fail([string]$Code, [string]$Message) { throw [System.Exception]::new("CHJ:${Code}:$Message") }
23function 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).
25function FromB64([string]$Text) { ,[Convert]::FromBase64String($Text) }
26function Iso($Value) { if ($null -eq $Value) { return $null }; return ([datetime]$Value).ToUniversalTime().ToString('o') }
27function Sha256Hex([byte[]]$Bytes) {
28 $sha = [Security.Cryptography.SHA256]::Create()
29 try { return (($sha.ComputeHash($Bytes) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $sha.Dispose() }
31function FileSha([string]$Path) { if (Test-Path -LiteralPath $Path -PathType Leaf) { return Sha256Hex ([IO.File]::ReadAllBytes($Path)) }; return 'absent' }
32function Prop($Object, [string]$Name) { if ($null -ne $Object -and $Object.PSObject.Properties[$Name]) { return $Object.$Name }; return $null }
34function 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.Name
53 groupNames = @($groupNames | Select-Object -Unique)
54 sid = $identity.User.Value
55 elevated = $elevated
56 adminMember = ($elevated -or ($groupSids -contains $AdminsSid) -or ($denyOnly -contains $AdminsSid))
57 adminDenyOnly = ($denyOnly -contains $AdminsSid)
58 integrity = $integrity
59 isSystem = ($identity.User.Value -eq $SystemSid)
60 }
62function Require-Elevation {
63 $token = Get-TokenInfo
64 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.'
69# Standard accounts logged on over SSH (network logon) usually get "Access denied" from WMI/CIM, so the
70# registry and .NET are the primary sources and CIM only adds details when it is permitted.
71function Get-SystemInfo {
72 $nt = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue
73 $openssh = Get-ItemProperty 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue
74 $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 = $caption
79 version = [Environment]::OSVersion.Version.ToString()
80 build = $build
81 displayVersion = [string](Prop $nt 'DisplayVersion')
82 installationType = [string](Prop $nt 'InstallationType')
83 productType = $null
84 architecture = $env:PROCESSOR_ARCHITECTURE
85 computerName = $env:COMPUTERNAME
86 partOfDomain = $null
87 domain = [string]$env:USERDNSDOMAIN
88 domainRole = $null
89 psVersion = $PSVersionTable.PSVersion.ToString()
90 psEdition = [string]$PSVersionTable.PSEdition
91 is64BitProcess = [Environment]::Is64BitProcess
92 defaultShell = [string](Prop $openssh 'DefaultShell')
93 localAccountsModule = [bool](Get-Command Get-LocalUser -ErrorAction SilentlyContinue)
94 sshClient = [string]$env:SSH_CLIENT
95 cim = $false
96 }
97 try {
98 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
99 $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
100 $info.caption = [string]$os.Caption; $info.version = [string]$os.Version; $info.productType = [int]$os.ProductType
101 $info.partOfDomain = [bool]$cs.PartOfDomain; $info.domain = [string]$cs.Domain; $info.domainRole = [int]$cs.DomainRole; $info.cim = $true
102 } catch {
103 $type = [string](Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ProductOptions' -ErrorAction SilentlyContinue).ProductType
104 $info.productType = @{ 'WinNT' = 1; 'LanmanNT' = 2; 'ServerNT' = 3 }[$type]
105 }
106 $info
109function 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 resolve
111# the account's groups without SYSTEM rights and then waits forever.
112function Invoke-Process([string]$File, [string[]]$Arguments, [int]$TimeoutMs = 15000) {
113 $psi = New-Object Diagnostics.ProcessStartInfo $File
114 $psi.Arguments = (@($Arguments) | ForEach-Object { '"' + ([string]$_).Replace('"', '') + '"' }) -join ' '
115 $psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true
116 $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", '') }
123function Invoke-Sshd([string[]]$Arguments) { Invoke-Process (Get-SshdPath) $Arguments 15000 }
125$SshdRunner = @'
126param([string]$JobPath)
127$ErrorActionPreference = 'Stop'
128$job = Get-Content -LiteralPath $JobPath -Raw | ConvertFrom-Json
129$sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'
130$results = @()
131foreach ($run in @($job.runs)) {
132 $psi = New-Object Diagnostics.ProcessStartInfo $sshd
133 $psi.Arguments = (@($run) | ForEach-Object { '"' + ([string]$_).Replace('"', '') + '"' }) -join ' '
134 $psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.RedirectStandardInput = $true; $psi.CreateNoWindow = $true
135 $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", '') }
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 the
145# SYSTEM/Administrators-only state directory). Returns one {code, output} per run.
146function Invoke-SshdBatch([object[]]$Runs) {
147 if ((Get-TokenInfo).isSystem) { return @($Runs | ForEach-Object { Invoke-Sshd @($_) }) }
148 Require-Elevation
149 Initialize-StateDir
150 $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 Highest
160 $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
161 Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null
162 Start-ScheduledTask -TaskName $taskName
163 $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 100
167 # 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 SilentlyContinue
172 Remove-Item -LiteralPath $jobPath, $outputPath -Force -ErrorAction SilentlyContinue
173 }
175function 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")
179function 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") }
184function Get-SshInfo {
185 $sshd = Get-SshdPath
186 $service = Get-Service sshd -ErrorAction SilentlyContinue
187 $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 $info
196function 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)).Value
206 $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 $result
210 }
212function Get-GroupsWithMembers { @(Get-LocalGroup | ForEach-Object { [ordered]@{ sid = $_.SID.Value; name = $_.Name; description = $_.Description; members = @(Get-MemberSids $_) } }) }
213function Convert-User($User) {
214 [ordered]@{
215 name = $User.Name; sid = $User.SID.Value; enabled = [bool]$User.Enabled; fullName = [string]$User.FullName; description = [string]$User.Description
216 source = [string]$User.PrincipalSource; lastLogon = (Iso $User.LastLogon); passwordLastSet = (Iso $User.PasswordLastSet); passwordRequired = [bool]$User.PasswordRequired
217 passwordExpires = (Iso $User.PasswordExpires); accountExpires = (Iso $User.AccountExpires); userMayChangePassword = [bool]$User.UserMayChangePassword
218 }
220function 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 SilentlyContinue
225 } else {
226 $name = [string](Prop $A 'name')
227 $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue
228 }
229 if (-not $user) { Fail 'USER_NOT_FOUND' 'The local account does not exist.' }
230 $user
232function Get-ProfilePath([string]$Sid) {
233 $entry = Get-ItemProperty ("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + $Sid) -ErrorAction SilentlyContinue
234 if ($entry -and (Prop $entry 'ProfileImagePath')) { return [Environment]::ExpandEnvironmentVariables([string]$entry.ProfileImagePath) }
235 return $null
238function Op-Probe { [ordered]@{ system = (Get-SystemInfo); token = (Get-TokenInfo) } }
240function 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) }
246function 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 $Path
249 if ($dir -and (Test-Path -LiteralPath $dir -PathType Container)) {
250 $info.dirExists = $true
251 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 -Force
255 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 $Path
260 $info.owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
261 $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 $bytes
265 $info.data = [Convert]::ToBase64String($bytes)
266 } catch { $info.type = 'unreadable' }
267 } elseif (-not $info.dirExists) { $info.type = 'absent-dir' }
268 $info
270function 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)
280function Op-Detail($A) {
281 $user = Resolve-LocalUser $A
282 $sid = $user.SID.Value
283 $groups = @(Get-GroupsWithMembers | Where-Object { @($_.members | ForEach-Object { $_.sid }) -contains $sid } | ForEach-Object { [ordered]@{ sid = $_.sid; name = $_.name } })
284 $token = Get-TokenInfo
285 $profilePath = Get-ProfilePath $sid
286 $ssh = Get-SshInfo
287 $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 = 1
301 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 1
307 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.Name
312 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 $result
321function 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.' }
326function 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 SilentlyContinue
330 if ($local) { if ($local.Enabled) { $member.sid } } else { $member.sid }
331 })
333function 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.' }
337function 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 -Force
341function 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 SilentlyContinue
344 if (-not $group) { Fail 'GROUP_NOT_FOUND' 'The local group does not exist.' }
345 $group
348function Op-Create($A) {
349 Require-Elevation
350 $name = [string]$A.name
351 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 @params
358 $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 }
371function Op-Account($A) {
372 Require-Elevation
373 $user = Resolve-LocalUser $A
374 Assert-Mutable $user $A
375 $action = [string]$A.action
376 $isAdmin = @(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | ForEach-Object { $_.sid }) -contains $user.SID.Value
377 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.Value
388 }
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 = '' }) }
396function Op-Delete($A) {
397 Require-Elevation
398 $user = Resolve-LocalUser $A
399 Assert-Mutable $user $A
400 if (@(Get-MemberSids (Get-LocalGroup -SID $AdminsSid) | ForEach-Object { $_.sid }) -contains $user.SID.Value) { Assert-NotLastAdmin $user }
401 $sid = $user.SID.Value
402 $profile = Get-CimInstance Win32_UserProfile -Filter "SID='$sid'" -ErrorAction SilentlyContinue
403 $removeProfile = (Prop $A 'removeProfile') -eq $true
404 # 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 $sid
407 $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 }
417function Set-StrictKeyAcl([string]$Path, [string]$OwnerSid, [bool]$Shared) {
418 $acl = New-Object Security.AccessControl.FileSecurity
419 $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 $acl
427function Op-KeysWrite($A) {
428 $user = Resolve-LocalUser $A
429 $token = Get-TokenInfo
430 $self = $token.sid -eq $user.SID.Value
431 if (-not $self) { Require-Elevation }
432 $path = [string]$A.path
433 # 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 1
436 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 $path
444 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.profilePath
447 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-Null
449 }
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.shared
458 Move-Item -LiteralPath $temp -Destination $path
459 }
460 } finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force } }
461 $after = Get-KeyFileInfo $file.spec $path $file.shared
462 [ordered]@{ result = 'ok'; sha256 = $after.sha256; owner = $after.owner; acl = $after.acl }
465function 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.DirectorySecurity
469 $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 $acl
474function Get-ActiveApply {
475 @(Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName.StartsWith($TaskPrefix) -and $_.State -eq 'Running' })
478function Op-SshStage($A) {
479 Require-Elevation
480 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-StateDir
484 $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 SilentlyContinue
488 $keys = @()
489 $runs = @(,@('-t')); $runs += ,@('-t', '-f', $candidate)
490 foreach ($tuple in @($A.tuples)) {
491 $name = [string]$tuple.user; $addr = [string]$tuple.addr
492 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))) }
509# Runs as SYSTEM from a scheduled task, independent of the SSH connection and of CH-J.
510$ApplyScript = @'
511param([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-Json
518$statusPath = Join-Path $StateDir "status-$Id.json"
519function 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 }
520function Sha([string]$Path) { $s = [Security.Cryptography.SHA256]::Create(); try { (($s.ComputeHash([IO.File]::ReadAllBytes($Path)) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $s.Dispose() } }
521function 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"
526function Restore([string]$Reason) {
527 try {
528 $temp = "$SshConfig.chj-restore"
529 Copy-Item -LiteralPath $backup -Destination $temp -Force
530 $acl = Get-Acl -LiteralPath $SshConfig; $acl.SetSecurityDescriptorSddlForm((Get-Content -LiteralPath $backupAcl -Raw)); Set-Acl -LiteralPath $temp -AclObject $acl
531 [IO.File]::Replace($temp, $SshConfig, [NullString]::Value)
532 RestartSshd
533 Status 'rolled-back' $Reason
534 } catch { Status 'rollback-failed' ("$Reason; rollback error: " + $_.Exception.Message) }
536try {
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 -Force
541 (Get-Acl -LiteralPath $SshConfig).Sddl | Set-Content -LiteralPath $backupAcl -Encoding ASCII
542 $temp = "$SshConfig.chj-new"
543 Copy-Item -LiteralPath $candidate -Destination $temp -Force
544 $acl = Get-Acl -LiteralPath $SshConfig; Set-Acl -LiteralPath $temp -AclObject $acl
545 [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 = $true
549 $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 500
562 }
563 Restore 'No confirmation from a new SSH login before the timeout.'
564} catch { Restore ('Apply failed: ' + $_.Exception.Message) }
565finally {
566 Remove-Item -LiteralPath $candidate, $token -Force -ErrorAction SilentlyContinue
567 Get-ChildItem -LiteralPath $StateDir -Filter 'backup-*' | Sort-Object LastWriteTime -Descending | Select-Object -Skip 20 | Remove-Item -Force -ErrorAction SilentlyContinue
569'@
571function Op-SshCommit($A) {
572 Require-Elevation
573 $id = [string]$A.id
574 if ($id -notmatch '^[0-9a-f]{32}$') { Fail 'ARGUMENTS_INVALID' 'Invalid apply id.' }
575 $nonce = [string]$A.nonce
576 if ($nonce -notmatch '^[0-9a-f]{32,64}$') { Fail 'ARGUMENTS_INVALID' 'Invalid nonce.' }
577 $timeout = [int]$A.timeoutSeconds
578 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-StateDir
581 $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 ASCII
588 $taskName = $TaskPrefix + $id
589 $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 Highest
591 $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds ($timeout + 180)) -MultipleInstances IgnoreNew
592 Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null
593 Start-ScheduledTask -TaskName $taskName
594 $deadline = (Get-Date).AddSeconds(20)
595 $status = $null
596 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 300
600 }
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 }
605function Op-SshConfirm($A) {
606 Require-Elevation
607 $id = [string]$A.id; $nonce = [string]$A.nonce
608 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-Json
612 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-Json
616 if ($status.state -in @('confirmed', 'rolled-back', 'rollback-failed', 'aborted')) { break }
617 Start-Sleep -Milliseconds 400
618 } 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 SilentlyContinue
621 Remove-Item -LiteralPath (Join-Path $StateDir "apply-$id.json") -Force -ErrorAction SilentlyContinue
622 }
623 [ordered]@{ id = $id; state = [string]$status.state; message = [string]$status.message; service = [string](Get-Service sshd).Status }
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 } }
629try {
630 $A = $ArgsJson | ConvertFrom-Json
631 $op = [string]$A.op
632 if (-not $Operations.ContainsKey($op)) { Fail 'ACTION_INVALID' 'Unsupported operation.' }
633 $value = & $Operations[$op] $A
634 [Console]::Out.WriteLine('CHJ_RESULT:' + (B64 ($value | ConvertTo-Json -Depth 10 -Compress)))
635} catch {
636 $exception = $_.Exception
637 while ($exception.InnerException -and -not $exception.Message.StartsWith('CHJ:')) { $exception = $exception.InnerException }
638 $message = $exception.Message
639 $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))
SHA-256e3ca28de32143cbd6b508f6fcc4c74703adea0ea17aeedfdd63cd4fd0e7e4fbd

CH-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 arkibcf4455b698f0f28e456a37b1834976a0194d8486acb7101052d59a838745cca3
Fail