param([Parameter(Mandatory = $true)][string]$Config) $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 the command again.' } $settings = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Config)) | ConvertFrom-Json if ($settings.schema -ne 1 -or $settings.device -cnotmatch '^[a-z][a-z0-9-]{0,47}$') { throw 'Invalid device configuration.' } if (($settings.port -isnot [int] -and $settings.port -isnot [long]) -or $settings.port -lt 1024 -or $settings.port -gt 65535) { throw 'Invalid reverse port.' } if (($settings.sshPort -isnot [int] -and $settings.sshPort -isnot [long]) -or $settings.sshPort -lt 1 -or $settings.sshPort -gt 65535) { throw 'Invalid SSH port.' } if ($settings.publicKey -cnotmatch '^ssh-ed25519 [A-Za-z0-9+/]{68}$') { throw 'An Ed25519 public key is required.' } $keyBytes = [Convert]::FromBase64String($settings.publicKey.Split(' ')[1]) $keyPrefix = [byte[]](0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, 0, 0, 0, 32) if ($keyBytes.Length -ne 51) { throw 'Invalid public key length.' } for ($index = 0; $index -lt $keyPrefix.Length; $index++) { if ($keyBytes[$index] -ne $keyPrefix[$index]) { throw 'Invalid public key structure.' } } function Assert-PlainPath([string]$Path) { $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Refusing a linked path: $Path" } } function Protect-KeyFile([string]$Path, [bool]$ForAdministrator) { $acl = New-Object Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') { $principal = New-Object Security.Principal.SecurityIdentifier($sid) $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($principal, 'FullControl', 'Allow'))) } if ($ForAdministrator) { $acl.SetOwner((New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544'))) } else { $acl.SetOwner($identity.User) $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($identity.User, 'FullControl', 'Allow'))) } Set-Acl -LiteralPath $Path -AclObject $acl } function Resolve-SshService { $service = Get-CimInstance Win32_Service -Filter "Name='sshd'" if (-not $service) { $architecture = $env:PROCESSOR_ARCHITECTURE if ($env:PROCESSOR_ARCHITEW6432) { $architecture = $env:PROCESSOR_ARCHITEW6432 } if ($architecture -ne 'AMD64') { throw 'Automatic OpenSSH installation supports Windows x64 only.' } $installer = Join-Path $directory 'OpenSSH-Win64-v10.0.0.0.msi' Assert-PlainPath $installer Write-Host 'Installing the verified Microsoft OpenSSH 10.0p2 Preview package...' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $download = New-Object Net.WebClient try { $download.DownloadFile('https://qd2.blogsummer.cn/onboard/openssh-10.0p2/OpenSSH-Win64-v10.0.0.0.msi', $installer) } finally { $download.Dispose() } if ((Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash -ne 'ddec9c53864280759cf9f74791cefd387100e3946aa849a1c138a4ed1b96b7d9') { throw 'OpenSSH installer checksum mismatch.' } $arguments = '/i "' + $installer + '" /qn /norestart /L*v "' + (Join-Path $directory 'install.log') + '"' $process = Start-Process msiexec.exe -ArgumentList $arguments -WindowStyle Hidden -PassThru -Wait if ($process.ExitCode -eq 3010) { throw 'Windows restart required. Restart, then run this command again.' } if ($process.ExitCode -ne 0) { throw "OpenSSH installation failed: $($process.ExitCode). See install.log." } $service = Get-CimInstance Win32_Service -Filter "Name='sshd'" } if (-not $service) { throw 'The sshd service was not installed.' } if ($service.PathName -match '^"([^"]+sshd\.exe)"\s*$') { return $Matches[1] } if ($service.PathName -match '^([^"\r\n]+sshd\.exe)\s*$') { return $Matches[1] } throw 'The existing sshd service has custom arguments. Ask the operator to inspect it before onboarding.' } $deviceRoot = Join-Path $env:ProgramData 'sshmcp' $devices = Join-Path $deviceRoot 'devices' $directory = Join-Path $devices $settings.device foreach ($path in $deviceRoot, $devices, $directory) { Assert-PlainPath $path } $mutex = New-Object Threading.Mutex($false, 'Global\SSHMCPDeviceBootstrap') $locked = $false try { $locked = $mutex.WaitOne(0) if (-not $locked) { throw 'Another device setup is already running.' } New-Item -ItemType Directory -Path $directory -Force | Out-Null $directoryAcl = New-Object Security.AccessControl.DirectorySecurity $directoryAcl.SetAccessRuleProtection($true, $false) $directoryAcl.SetOwner((New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544'))) foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') { $principal = New-Object Security.Principal.SecurityIdentifier($sid) $directoryAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($principal, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) } Set-Acl -LiteralPath $directory -AclObject $directoryAcl $configurationPath = Join-Path $directory 'device.json' foreach ($name in 'device.json', 'reverse_ed25519', 'reverse_ed25519.pub', 'known_hosts', 'ssh_config', 'enrollment.json', 'install.log') { Assert-PlainPath (Join-Path $directory $name) } if (Test-Path -LiteralPath $configurationPath) { $previous = Get-Content -LiteralPath $configurationPath -Raw | ConvertFrom-Json foreach ($field in 'schema', 'device', 'port', 'sshPort', 'publicKey') { if ($previous.$field -cne $settings.$field) { throw 'This device ID already has different settings. Ask the operator to reconcile them.' } } } $sshd = Resolve-SshService $bin = Split-Path -Parent $sshd foreach ($exe in 'ssh.exe', 'ssh-keygen.exe', 'sshd.exe') { if (-not (Test-Path -LiteralPath (Join-Path $bin $exe))) { throw "Missing OpenSSH executable: $exe" } } Start-Service sshd & $sshd -t if ($LASTEXITCODE -ne 0) { throw 'sshd configuration validation failed.' } $effective = & $sshd -T -C "user=$($env:USERNAME.ToLowerInvariant()),host=localhost,addr=127.0.0.1" if ($LASTEXITCODE -ne 0 -or -not ($effective -match '^pubkeyauthentication yes$')) { throw 'The existing sshd does not accept public-key authentication.' } if (-not ($effective -match '^authorizedkeysfile .*administrators_authorized_keys')) { throw 'Custom AuthorizedKeysFile detected. Ask the operator to inspect it.' } if (-not ($effective -contains "port $($settings.sshPort)")) { throw 'The selected local SSH port does not match sshd configuration.' } $tcp = New-Object Net.Sockets.TcpClient try { $connection = $tcp.ConnectAsync('127.0.0.1', $settings.sshPort) if (-not $connection.Wait(5000)) { throw 'Local SSH connection timed out.' } } finally { $tcp.Dispose() } $hostPublicKey = Join-Path $env:ProgramData 'ssh\ssh_host_ed25519_key.pub' if (-not (Test-Path -LiteralPath $hostPublicKey)) { throw 'Default Ed25519 host key missing. Ask the operator to inspect sshd.' } $reverseKey = Join-Path $directory 'reverse_ed25519' if (-not (Test-Path -LiteralPath $reverseKey)) { # ProcessStartInfo preserves the empty passphrase on Windows PowerShell 5.1. $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = Join-Path $bin 'ssh-keygen.exe' $start.Arguments = '-q -t ed25519 -N "" -C sshmcp-device-reverse -f "' + $reverseKey + '"' $start.UseShellExecute = $false $process = [Diagnostics.Process]::Start($start) $process.WaitForExit() if ($process.ExitCode -ne 0) { throw 'Reverse key generation failed.' } } Protect-KeyFile $reverseKey $false $authorization = Join-Path $env:ProgramData 'ssh\administrators_authorized_keys' Assert-PlainPath $authorization if (-not (Test-Path -LiteralPath $authorization)) { New-Item -ItemType File -Path $authorization | Out-Null } $publicLine = "$($settings.publicKey) sshmcp-device-$($settings.device)" if (-not ((Get-Content -LiteralPath $authorization) -contains $publicLine)) { Copy-Item -LiteralPath $authorization -Destination ($authorization + '.before-' + $settings.device + '-' + [Guid]::NewGuid().ToString('N')) [IO.File]::AppendAllText($authorization, "`r`n$publicLine`r`n", [Text.Encoding]::ASCII) } Protect-KeyFile $authorization $true Set-Service sshd -StartupType Automatic $settings | ConvertTo-Json | Set-Content -LiteralPath $configurationPath -Encoding UTF8 'qd2.blogsummer.cn ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeVEGzugJPFTdtkbBCwsV04xftv71wCt8Wb8aZeMqCR' | Set-Content -LiteralPath (Join-Path $directory 'known_hosts') -Encoding ASCII Set-Content -LiteralPath (Join-Path $directory 'ssh_config') -Value '' -Encoding ASCII $enrollment = [ordered]@{ schema = 1; device = $settings.device; user = $env:USERNAME; identity = $identity.Name computer = $env:COMPUTERNAME; reverse_port = $settings.port; ssh_port = $settings.sshPort tunnel_public_key = (Get-Content -LiteralPath "$reverseKey.pub" -Raw).Trim() host_public_key = (Get-Content -LiteralPath $hostPublicKey -Raw).Trim() } | ConvertTo-Json -Compress $enrollment | Set-Content -LiteralPath (Join-Path $directory 'enrollment.json') -Encoding UTF8 } finally { if ($locked) { $mutex.ReleaseMutex() } $mutex.Dispose() } Write-Host "`n===== SSHMCP_ENROLLMENT =====" Write-Output $enrollment Write-Host '===== END SSHMCP_ENROLLMENT =====' Write-Host 'Send the result above to your operator. Keep this window open.' Write-Host 'Permission denied is expected until the operator authorizes this device. Ctrl+C stops this connection.' $sshArguments = @('-F', (Join-Path $directory 'ssh_config'), '-N', '-T', '-i', $reverseKey, '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=yes', '-o', 'HostKeyAlgorithms=ssh-ed25519', '-o', "UserKnownHostsFile=$(Join-Path $directory 'known_hosts')", '-o', 'GlobalKnownHostsFile=NUL', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=20', '-o', 'ServerAliveCountMax=3', '-R', "127.0.0.1:$($settings.port):127.0.0.1:$($settings.sshPort)", 'revproxy@qd2.blogsummer.cn') while ($true) { # Native stderr must not turn a rejected enrollment into a terminated retry loop in PS 5.1. $ErrorActionPreference = 'Continue' & (Join-Path $bin 'ssh.exe') @sshArguments $ErrorActionPreference = 'Stop' Start-Sleep -Seconds 10 }