param([Parameter(Mandatory = $true)][string]$NameEncoded) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2 $identity = [Security.Principal.WindowsIdentity]::GetCurrent() if (-not ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Open PowerShell as Administrator and run this command again.' } $deviceName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($NameEncoded)).Trim() if ($deviceName.Length -lt 1 -or $deviceName.Length -gt 40 -or $deviceName -match '[\x00-\x1f]') { throw 'Invalid device name.' } $architecture = $env:PROCESSOR_ARCHITECTURE if ($env:PROCESSOR_ARCHITEW6432) { $architecture = $env:PROCESSOR_ARCHITEW6432 } if ($architecture -ne 'AMD64') { throw 'This temporary channel supports Windows x64 only.' } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $api = 'https://sshmcp.blogsummer.cn/bootstrap' $request = [Text.Encoding]::UTF8.GetBytes((@{name=$deviceName} | ConvertTo-Json -Compress)) $session = Invoke-RestMethod -Uri "$api/sessions" -Method Post -ContentType 'application/json; charset=utf-8' -Body $request -TimeoutSec 30 if ($session.schema -ne 1 -or $session.id -cnotmatch '^[0-9a-f]{32}$' -or $session.token -cnotmatch '^[A-Za-z0-9_-]{43}$') { throw 'Invalid bootstrap response.' } if ($session.port -lt 23000 -or $session.port -gt 23999 -or $session.ttlSeconds -lt 1 -or $session.ttlSeconds -gt 14400) { throw 'Invalid bootstrap limits.' } if ($session.gateway -cne 'qd2.blogsummer.cn' -or $session.gatewayUser -cne 'revproxy') { throw 'Unexpected bootstrap gateway.' } foreach ($key in $session.publicKey, $session.gatewayHostKey) { if ($key -cnotmatch '^ssh-ed25519 [A-Za-z0-9+/]{68}$') { throw 'Invalid bootstrap public key.' } } if (-not ('SshmcpBootstrap.SessionJob' -as [type])) { Add-Type -TypeDefinition @' using System; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; namespace SshmcpBootstrap { public sealed class SessionJob : IDisposable { [StructLayout(LayoutKind.Sequential)] struct Basic { public long ProcessTime, JobTime; public uint Flags; public UIntPtr MinWorkingSet, MaxWorkingSet; public uint ActiveProcesses; public UIntPtr Affinity; public uint Priority, Scheduling; } [StructLayout(LayoutKind.Sequential)] struct Counters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; } [StructLayout(LayoutKind.Sequential)] struct Limits { public Basic Basic; public Counters IO; public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; } [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr security, string name); [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int info, IntPtr value, uint length); [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); IntPtr handle; Timer expiry; readonly object gate = new object(); public SessionJob(int seconds) { handle = CreateJobObject(IntPtr.Zero, null); if (handle == IntPtr.Zero) throw new Win32Exception(); var limit = new Limits(); limit.Basic.Flags = 0x2000; // Closing the owner handle terminates all descendants. int size = Marshal.SizeOf(typeof(Limits)); IntPtr buffer = Marshal.AllocHGlobal(size); try { Marshal.StructureToPtr(limit, buffer, false); if (!SetInformationJobObject(handle, 9, buffer, (uint)size)) throw new Win32Exception(); } catch { CloseHandle(handle); handle=IntPtr.Zero; throw; } finally { Marshal.FreeHGlobal(buffer); } expiry = new Timer(delegate { Dispose(); }, null, checked(seconds * 1000), Timeout.Infinite); } public void Add(Process process) { lock(gate) { if (handle == IntPtr.Zero || !AssignProcessToJobObject(handle, process.Handle)) { try { process.Kill(); } catch (InvalidOperationException) { } throw new Win32Exception(); } } } public void Dispose() { lock(gate) { if (handle != IntPtr.Zero) { CloseHandle(handle); handle=IntPtr.Zero; } if (expiry != null) { expiry.Dispose(); expiry=null; } } } } } '@ } function Set-SessionPermissions([string]$Path, [bool]$IsDirectory) { $security = if ($IsDirectory) { New-Object Security.AccessControl.DirectorySecurity } else { New-Object Security.AccessControl.FileSecurity } $security.SetAccessRuleProtection($true, $false) $security.SetOwner($identity.User) foreach ($sid in @($identity.User.Value, 'S-1-5-18', 'S-1-5-32-544') | Select-Object -Unique) { $principal = New-Object Security.Principal.SecurityIdentifier($sid) $rule = if ($IsDirectory) { New-Object Security.AccessControl.FileSystemAccessRule($principal, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') } else { New-Object Security.AccessControl.FileSystemAccessRule($principal, 'FullControl', 'Allow') } $security.AddAccessRule($rule) } Set-Acl -LiteralPath $Path -AclObject $security } function New-SessionKey([string]$Path) { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = Join-Path $binaryDirectory 'ssh-keygen.exe' $start.Arguments = '-q -t ed25519 -N "" -C sshmcp-temporary -f "' + $Path + '"' $start.UseShellExecute = $false $start.CreateNoWindow = $true $process = [Diagnostics.Process]::Start($start) $process.WaitForExit() if ($process.ExitCode -ne 0) { throw 'Temporary SSH key generation failed.' } Set-SessionPermissions $Path $false } function Start-SessionProcess([string]$Executable, [string]$Arguments) { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $Executable $start.Arguments = $Arguments $start.UseShellExecute = $false $start.CreateNoWindow = $true $process = [Diagnostics.Process]::Start($start) $job.Add($process) return $process } function Get-TemporaryBinaries { $archive = Join-Path $directory 'OpenSSH-Win64.zip' $client = New-Object Net.WebClient try { $client.DownloadFile('https://qd2.blogsummer.cn/onboard/openssh-10.0p2/OpenSSH-Win64.zip', $archive) } finally { $client.Dispose() } if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne '23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5') { throw 'OpenSSH portable package checksum mismatch.' } Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $directory 'package') $bin = Join-Path $directory 'package\OpenSSH-Win64' foreach ($name in 'ssh.exe', 'sshd.exe', 'ssh-keygen.exe') { if (-not (Test-Path -LiteralPath (Join-Path $bin $name))) { throw "OpenSSH package missing: $name" } } return $bin } function Wait-SessionListener([Diagnostics.Process]$Process, [int]$Port) { for ($attempt = 0; $attempt -lt 40; $attempt++) { if ($Process.HasExited) { throw "Temporary SSH exited: $($Process.ExitCode). See $directory\sshd.log" } $listener = Get-NetTCPConnection -LocalAddress '127.0.0.1' -LocalPort $Port -State Listen -ErrorAction SilentlyContinue if ($listener -and $listener.OwningProcess -contains $Process.Id) { return } Start-Sleep -Milliseconds 250 } throw 'Temporary SSH did not become ready.' } $directory = Join-Path $env:ProgramData ('sshmcp-temporary-' + $session.id) if (Test-Path -LiteralPath $directory) { throw 'Temporary session directory already exists.' } New-Item -ItemType Directory -Path $directory | Out-Null Set-SessionPermissions $directory $true $job = $null $clock = [Diagnostics.Stopwatch]::StartNew() try { $job = New-Object SshmcpBootstrap.SessionJob([int]$session.ttlSeconds) Write-Host 'Preparing a temporary channel. Existing SSH services and startup settings are unchanged.' $binaryDirectory = Get-TemporaryBinaries $hostKey = Join-Path $directory 'host_ed25519' $reverseKey = Join-Path $directory 'reverse_ed25519' New-SessionKey $hostKey New-SessionKey $reverseKey $authorized = Join-Path $directory 'authorized_keys' [IO.File]::WriteAllText($authorized, $session.publicKey + "`n", [Text.Encoding]::ASCII) Set-SessionPermissions $authorized $false $knownHosts = Join-Path $directory 'known_hosts' [IO.File]::WriteAllText($knownHosts, $session.gateway + ' ' + $session.gatewayHostKey + "`n", [Text.Encoding]::ASCII) $clientConfig = Join-Path $directory 'ssh_config' [IO.File]::WriteAllText($clientConfig, '', [Text.Encoding]::ASCII) $reserve = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, 0) $reserve.Start() $sshPort = $reserve.LocalEndpoint.Port $reserve.Stop() $sshConfig = Join-Path $directory 'sshd_config' $configLines = @( "Port $sshPort", 'ListenAddress 127.0.0.1', ('HostKey "' + $hostKey.Replace('\','/') + '"'), ('AuthorizedKeysFile "' + $authorized.Replace('\','/') + '"'), ('AllowUsers "' + $env:USERNAME.ToLowerInvariant().Replace('"','\"') + '"'), 'AuthenticationMethods publickey', 'PubkeyAuthentication yes', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no', 'PermitEmptyPasswords no', 'AllowTcpForwarding no', 'AllowAgentForwarding no', 'PermitTTY no', 'StrictModes yes', 'LogLevel ERROR', 'Subsystem sftp internal-sftp' ) [IO.File]::WriteAllText($sshConfig, ($configLines -join "`n") + "`n", (New-Object Text.UTF8Encoding($false))) $sshd = Join-Path $binaryDirectory 'sshd.exe' & $sshd -t -f $sshConfig if ($LASTEXITCODE -ne 0) { throw 'Temporary sshd configuration validation failed.' } $serverProcess = Start-SessionProcess $sshd ('-D -f "' + $sshConfig + '" -E "' + (Join-Path $directory 'sshd.log') + '"') Wait-SessionListener $serverProcess $sshPort $publicIdentity = [ordered]@{ user=$env:USERNAME; computer=$env:COMPUTERNAME; identity=$identity.Name; sshPort=$sshPort tunnelPublicKey=(Get-Content -LiteralPath "$reverseKey.pub" -Raw).Trim() hostPublicKey=(Get-Content -LiteralPath "$hostKey.pub" -Raw).Trim() } $body = [Text.Encoding]::UTF8.GetBytes(($publicIdentity | ConvertTo-Json -Compress)) $null = Invoke-RestMethod -Uri "$api/sessions/$($session.id)/identity" -Method Post -ContentType 'application/json; charset=utf-8' -Body $body -Headers @{Authorization=('Bearer ' + $session.token)} -TimeoutSec 30 Write-Host "`n===== SSHMCP_ENROLLMENT =====" [ordered]@{name=$deviceName; id=$session.id; computer=$env:COMPUTERNAME; identity=$identity.Name} | ConvertTo-Json -Compress Write-Host '===== END SSHMCP_ENROLLMENT =====' Write-Host 'Send this result to your operator. Keep the window open until the permanent channel is verified.' Write-Host 'This temporary channel expires in at most 4 hours. Ctrl+C disconnects it.' $clientArguments = '-F "' + $clientConfig + '" -N -T -i "' + $reverseKey + '" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes -o HostKeyAlgorithms=ssh-ed25519 -o "UserKnownHostsFile=' + $knownHosts + '" -o GlobalKnownHostsFile=NUL -o ExitOnForwardFailure=yes -o ServerAliveInterval=20 -o ServerAliveCountMax=3 -R 127.0.0.1:' + $session.port + ':127.0.0.1:' + $sshPort + ' revproxy@qd2.blogsummer.cn' $stopRequest = Join-Path $directory 'stop' while ($clock.Elapsed.TotalSeconds -lt $session.ttlSeconds -and -not (Test-Path -LiteralPath $stopRequest)) { if ($serverProcess.HasExited) { throw 'Temporary SSH server stopped.' } $reverse = Start-SessionProcess (Join-Path $binaryDirectory 'ssh.exe') $clientArguments while (-not $reverse.HasExited -and $clock.Elapsed.TotalSeconds -lt $session.ttlSeconds -and -not (Test-Path -LiteralPath $stopRequest)) { Start-Sleep -Seconds 1 } if ($clock.Elapsed.TotalSeconds -lt $session.ttlSeconds) { Start-Sleep -Seconds 5 } } } catch { $errorLog = Join-Path $directory 'sshd.log' if (Test-Path -LiteralPath $errorLog) { Get-Content -LiteralPath $errorLog -Tail 10 } throw } finally { if ($job) { $job.Dispose() } $clock.Stop() $session = $null Write-Host 'Temporary channel closed.' # The directory contains only this session's binaries, keys and diagnostic log. if ((Split-Path -Parent $directory) -eq $env:ProgramData -and (Split-Path -Leaf $directory) -cmatch '^sshmcp-temporary-[0-9a-f]{32}$') { Start-Sleep -Milliseconds 400 Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction SilentlyContinue } }