<# Give Emon SSH access to this Windows PC or server. https://bootstrap.ihemon.me Open PowerShell as Administrator, then: irm https://bootstrap.ihemon.me/grant.ps1 | iex For another (non-admin) local user instead of you: & ([scriptblock]::Create((irm https://bootstrap.ihemon.me/grant.ps1))) -User NAME Also join Emon's Tailscale as a guest (key and name only ever come from the page, after Emon approved): & ([scriptblock]::Create((irm https://bootstrap.ihemon.me/grant.ps1))) -TailscaleKey tskey-auth-... -TailscaleHostname guest-... Windows Server 2016 (older TLS): run this line first: [Net.ServicePointManager]::SecurityProtocol = 'Tls12' What it does: 1. Makes sure the OpenSSH server is installed (Windows feature, or the official Win32-OpenSSH zip on Server 2016 / when Windows Update can't add it; the zip is deleted afterwards), set to Automatic, running, and allowed through Windows Firewall on its port. 2. Adds ONE public key, Emon-Key, read from https://github.com/emon5122.keys (only the line with Emon-Key's fingerprint). Admin users: C:\ProgramData\ssh\administrators_authorized_keys (ACL: Administrators + SYSTEM only). Other users: C:\Users\\.ssh\authorized_keys. Running it again adds nothing new. 3. Only with -TailscaleKey: installs Tailscale if missing (official MSI, deleted afterwards) and joins Emon's tailnet as a guest that can reach nothing there and disappears when it goes offline. 4. Prints a short block to send to Emon. It leaves no script, installer or log behind and never changes passwords or other keys. Undo: irm https://bootstrap.ihemon.me/revoke.ps1 | iex #> param([string]$User, [string]$TailscaleKey, [string]$TailscaleHostname) $EmonKeysUrl = 'https://github.com/emon5122.keys' $EmonFingerprint = 'SHA256:wVrLsx8kwxTscCD6oAcmcslPxagnZDKl1yaM2rwl39g' $EmonKeyComment = 'emon-key@bootstrap.ihemon.me' $TraceUrls = @('https://bootstrap.ihemon.me/cdn-cgi/trace', 'https://1.1.1.1/cdn-cgi/trace') # Pinned release + SHA256 (the zip's install-sshd.ps1 runs as admin): change all three together. $OpenSshZipBase = 'https://github.com/PowerShell/Win32-OpenSSH/releases/download/10.0.0.0p2-Preview' $OpenSshZipSha256 = @{ 'Win64' = '23F50F3458C4C5D0B12217C6A5DDFDE0137210A30FA870E98B29827F7B43ABA5' 'Win32' = 'C61D7FEA20DDFE0FC50EB56210A66464557721120F7794FF9CC883B5BA526ABD' } $TailscaleMsiBase = 'https://pkgs.tailscale.com/stable' # Emon's tailnet (MagicDNS suffix): a guest node of THIS tailnet is recognised by it (revoke.ps1 pins it too). $TailnetSuffix = 'tailbdfb8.ts.net' function Write-Step([string]$Text) { Write-Host ''; Write-Host "==> $Text" -ForegroundColor Cyan } function Write-Note([string]$Text) { Write-Host " $Text" } function Get-KeyFingerprint { # "type blob [comment]" -> SHA256:, like ssh-keygen -l param([string]$Line) $parts = @(($Line.Trim()) -split '\s+') if ($parts.Count -lt 2 -or $parts[1] -notmatch '^[A-Za-z0-9+/]+=*$') { return $null } try { $bytes = [Convert]::FromBase64String($parts[1]) } catch { return $null } $sha = [Security.Cryptography.SHA256]::Create() try { $hash = $sha.ComputeHash($bytes) } finally { $sha.Dispose() } return 'SHA256:' + ([Convert]::ToBase64String($hash)).TrimEnd('=') } function Select-EmonKey { # Text of an authorized_keys-style list -> "type blob" of the line whose fingerprint matches, else $null param([string]$KeysText, [string]$Fingerprint) $found = $null foreach ($line in ($KeysText -split "`r?`n")) { $parts = @(($line.Trim()) -split '\s+') if ($parts.Count -lt 2 -or $parts[0] -notmatch '^(ssh-|ecdsa-|sk-)') { continue } if ((Get-KeyFingerprint -Line $line) -ceq $Fingerprint) { $found = "$($parts[0]) $($parts[1])" } } return $found } function Add-KeyLine { # Appends "$Key $Comment" to $Path unless the key blob is already there. Returns $true when it added. param([string]$Path, [string]$Key, [string]$Comment) $blob = ($Key -split ' ')[1] $text = '' if (Test-Path -LiteralPath $Path) { $text = [IO.File]::ReadAllText($Path) } # present = on a line that is not commented out foreach ($l in ($text -split "`r?`n")) { if ($l.Contains($blob) -and $l -notmatch '^\s*#') { return $false } } $prefix = '' if ($text.Length -gt 0 -and -not $text.EndsWith("`n")) { $prefix = "`r`n" } [IO.File]::AppendAllText($Path, "$prefix$Key $Comment`r`n", (New-Object Text.UTF8Encoding $false)) return $true } function Get-SshPort { param([string]$ConfigPath = (Join-Path $env:ProgramData 'ssh\sshd_config')) if (Test-Path -LiteralPath $ConfigPath) { foreach ($l in (Get-Content -LiteralPath $ConfigPath)) { if ($l -match '^\s*Port\s+(\d+)\s*$') { return [int]$Matches[1] } } } return 22 } function Test-IsElevated { $id = [Security.Principal.WindowsIdentity]::GetCurrent() return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Get-UserSid([string]$Name) { return (New-Object Security.Principal.NTAccount($Name)).Translate([Security.Principal.SecurityIdentifier]).Value } function Test-UserIsLocalAdmin([string]$Sid) { # Members of the local Administrators group (S-1-5-32-544), compared by SID so it works in any language. $groupName = (New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')).Translate([Security.Principal.NTAccount]).Value.Split('\')[-1] $group = [ADSI]"WinNT://./$groupName,group" foreach ($m in @($group.psbase.Invoke('Members'))) { $raw = $m.GetType().InvokeMember('objectSid', 'GetProperty', $null, $m, $null) if ((New-Object Security.Principal.SecurityIdentifier($raw, 0)).Value -eq $Sid) { return $true } } return $false } function Get-ProfilePath([string]$Sid) { $p = Get-ItemProperty -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$Sid" -ErrorAction SilentlyContinue if ($p -and $p.ProfileImagePath) { return [Environment]::ExpandEnvironmentVariables($p.ProfileImagePath) } return $null } function Install-OpenSshFromZip { # Server 2016 / no Windows Update: the official Win32-OpenSSH release zip. The zip and its temp folder are deleted. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $arch = if ([Environment]::Is64BitOperatingSystem) { 'Win64' } else { 'Win32' } $tmp = Join-Path ([IO.Path]::GetTempPath()) ('openssh-' + [guid]::NewGuid().ToString('N')) $zip = Join-Path $tmp "OpenSSH-$arch.zip" try { New-Item -ItemType Directory -Path $tmp -Force | Out-Null Write-Note "downloading OpenSSH-$arch.zip from github.com/PowerShell/Win32-OpenSSH" $old = $ProgressPreference; $ProgressPreference = 'SilentlyContinue' try { Invoke-WebRequest -UseBasicParsing -Uri "$OpenSshZipBase/OpenSSH-$arch.zip" -OutFile $zip } finally { $ProgressPreference = $old } $sum = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash if ($sum -ne $OpenSshZipSha256[$arch]) { throw "OpenSSH-$arch.zip has the wrong SHA256 ($sum); nothing was installed from it" } Expand-Archive -LiteralPath $zip -DestinationPath $env:ProgramFiles -Force $dir = Join-Path $env:ProgramFiles "OpenSSH-$arch" & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $dir 'install-sshd.ps1') | Out-Null } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } } function Install-OpenSshServer { # Returns $true when it installed the server now. if (Get-Service -Name sshd -ErrorAction SilentlyContinue) { return $false } Write-Note 'installing the OpenSSH server (this can take a few minutes)' $cap = $null try { $cap = Get-WindowsCapability -Online -ErrorAction Stop | Where-Object { $_.Name -like 'OpenSSH.Server*' } | Select-Object -First 1 } catch { $cap = $null } if ($cap) { try { Add-WindowsCapability -Online -Name $cap.Name -ErrorAction Stop | Out-Null } catch { Write-Note "Windows could not add the OpenSSH feature ($($_.Exception.Message.Trim())); using the GitHub release instead" } } if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) { Install-OpenSshFromZip } if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) { throw 'the OpenSSH server did not install' } return $true } function Enable-SshFirewall([int]$Port) { if (-not (Get-Command Get-NetFirewallRule -ErrorAction SilentlyContinue)) { return 'unknown (no firewall cmdlets)' } $rule = Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue if (-not $rule) { New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound ` -Protocol TCP -Action Allow -LocalPort $Port -Profile Any | Out-Null return "rule added for TCP $Port" } $ports = @(($rule | Get-NetFirewallPortFilter).LocalPort) if ($ports -notcontains "$Port" -and $ports -notcontains 'Any') { Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -LocalPort $Port } if ("$($rule.Enabled)" -ne 'True') { Enable-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' return "rule turned on for TCP $Port" } return "already allows TCP $Port" } function Set-AdminKeysAcl([string]$Path) { # The ACL sshd demands for administrators_authorized_keys: only Administrators and SYSTEM, nothing inherited. & icacls.exe $Path /inheritance:r /grant '*S-1-5-32-544:F' /grant '*S-1-5-18:F' | Out-Null if ($LASTEXITCODE -ne 0) { throw "icacls failed on $Path" } } function Set-UserKeysAcl([string]$Path, [string]$Sid) { & icacls.exe $Path /inheritance:r /grant "*${Sid}:F" /grant '*S-1-5-18:F' /grant '*S-1-5-32-544:F' | Out-Null if ($LASTEXITCODE -ne 0) { throw "icacls failed on $Path" } } function Get-PublicIp { foreach ($u in $TraceUrls) { try { $t = Invoke-RestMethod -UseBasicParsing -Uri $u -TimeoutSec 5 foreach ($l in ($t -split "`n")) { if ($l -match '^ip=(.+)$') { return $Matches[1].Trim() } } } catch { continue } } return 'unknown' } function Get-TailscaleIp { $exe = Get-TailscaleExe if (-not $exe) { return 'none' } $ip = $null try { $ip = (& $exe ip -4 2>$null | Select-Object -First 1) } catch { $ip = $null } if ($ip) { return "$ip".Trim() } return 'none' } function Get-TailscaleExe { if ($env:ProgramFiles) { $exe = Join-Path $env:ProgramFiles 'Tailscale\tailscale.exe' if (Test-Path -LiteralPath $exe) { return $exe } } $c = Get-Command tailscale.exe -ErrorAction SilentlyContinue if ($c) { return $c.Source } return $null } function Get-TailscaleState([string]$Exe) { # -> @{ Running; Guest (a guest-* tag:guest node of Emon's tailnet); Fresh (in no tailnet: logged out, no node key) } # An unreadable status leaves all three $false, so nothing is joined. $st = @{ Running = $false; Guest = $false; Fresh = $false } try { $j = (& $Exe status --json --peers=false 2>$null | Out-String) | ConvertFrom-Json $state = "$($j.BackendState)" $st.Running = $state -eq 'Running' $st.Guest = (@($j.Self.Tags) -contains 'tag:guest') -and ("$($j.Self.DNSName)" -like "guest-*.$TailnetSuffix.") $st.Fresh = ($state -eq 'NeedsLogin' -or $state -eq 'NoState') -and ($j.HaveNodeKey -ne $true) } catch { $st = @{ Running = $false; Guest = $false; Fresh = $false } } return $st } function Install-Tailscale { # Official MSI from pkgs.tailscale.com, Authenticode-checked, deleted afterwards. $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } elseif ([Environment]::Is64BitOperatingSystem) { 'amd64' } else { 'x86' } $tmp = Join-Path ([IO.Path]::GetTempPath()) ('tailscale-' + [guid]::NewGuid().ToString('N')) $msi = Join-Path $tmp "tailscale-setup-latest-$arch.msi" try { New-Item -ItemType Directory -Path $tmp -Force | Out-Null Write-Note "downloading tailscale-setup-latest-$arch.msi from pkgs.tailscale.com" $old = $ProgressPreference; $ProgressPreference = 'SilentlyContinue' try { Invoke-WebRequest -UseBasicParsing -Uri "$TailscaleMsiBase/tailscale-setup-latest-$arch.msi" -OutFile $msi } finally { $ProgressPreference = $old } $sig = Get-AuthenticodeSignature -LiteralPath $msi if ($sig.Status -ne 'Valid' -or "$($sig.SignerCertificate.Subject)" -notmatch 'Tailscale') { throw "the Tailscale installer is not signed by Tailscale ($($sig.Status)); not installed" } $p = Start-Process -FilePath msiexec.exe -ArgumentList @('/i', "`"$msi`"", '/quiet', '/norestart') -Wait -PassThru if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw "the Tailscale installer failed (exit $($p.ExitCode))" } } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } } function Join-TailscaleGuest { # -> @{ Ok = $bool; Text = '...'; Installed = $bool } param([string]$Key, [string]$Hostname) $r = @{ Ok = $false; Text = ''; Installed = $false } $exe = Get-TailscaleExe if (-not $exe) { Install-Tailscale $r.Installed = $true for ($i = 0; $i -lt 30 -and -not $exe; $i++) { Start-Sleep -Seconds 1; $exe = Get-TailscaleExe } if (-not $exe) { $r.Text = 'Tailscale did not install'; return $r } } $st = Get-TailscaleState $exe if ($st.Running -and $st.Guest) { $r.Ok = $true; $r.Text = "already joined as a guest: $(Get-TailscaleIp)"; return $r } # Only a client that is in no tailnet at all is joined: running, stopped, expired or unreadable = someone's own. if (-not $st.Fresh) { $r.Text = "skipped: this computer's Tailscale is already set up for a network (left as it is)"; return $r } Write-Note "joining Emon's Tailscale as $Hostname (guest: it can reach nothing there)" & $exe up --reset --unattended "--auth-key=$Key" "--hostname=$Hostname" --accept-routes=false --accept-dns=false --timeout=90s 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { $r.Text = 'tailscale up failed (key used already or older than 1 hour? ask Emon for a new one)'; return $r } $r.Ok = $true $r.Text = "joined as ${Hostname}: $(Get-TailscaleIp)" return $r } function Get-LocalIpList { try { return @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.)' } | ForEach-Object { $_.IPAddress }) } catch { return @() } } function Invoke-EmonGrant { param([string]$User, [string]$TailscaleKey, [string]$TailscaleHostname) $ErrorActionPreference = 'Stop' Write-Host 'Give Emon SSH access (https://bootstrap.ihemon.me)' if (-not (Test-IsElevated)) { throw 'PowerShell is not running as Administrator. Right-click PowerShell > "Run as administrator", then run the line again.' } [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 if ($TailscaleKey -or $TailscaleHostname) { if ($TailscaleKey -cnotmatch '^tskey-auth-[A-Za-z0-9-]+$') { throw '-TailscaleKey: that is not a Tailscale auth key (copy the whole line from the page)' } if ($TailscaleHostname -cnotmatch '^guest-[a-z0-9-]+$') { throw '-TailscaleHostname must be the guest-... name from the page' } } # The key first: a failed download or a missing fingerprint stops us before anything changes. $keysText = (Invoke-WebRequest -UseBasicParsing -Uri $EmonKeysUrl -TimeoutSec 30).Content if ($keysText -is [byte[]]) { $keysText = [Text.Encoding]::ASCII.GetString($keysText) } $key = Select-EmonKey -KeysText $keysText -Fingerprint $EmonFingerprint if (-not $key) { throw "no key with fingerprint $EmonFingerprint at $EmonKeysUrl. Nothing was changed; tell Emon." } # Who gets access if ($User) { $sid = Get-UserSid $User $isAdmin = Test-UserIsLocalAdmin $sid $who = $User } else { $id = [Security.Principal.WindowsIdentity]::GetCurrent() $sid = $id.User.Value $isAdmin = $true # elevated, so an administrator $who = $id.Name } Write-Step 'SSH server' $installed = Install-OpenSshServer $svc = Get-Service -Name sshd $wasRunning = $svc.Status -eq 'Running' $wasAuto = "$($svc.StartType)" -eq 'Automatic' if (-not $wasAuto) { Set-Service -Name sshd -StartupType Automatic } if (-not $wasRunning) { Start-Service -Name sshd } $newly = $installed -or -not $wasRunning -or -not $wasAuto if ($newly) { Write-Note 'OpenSSH server running and set to start automatically' } else { Write-Note 'OpenSSH server is already running' } $port = Get-SshPort $fw = Enable-SshFirewall -Port $port Write-Note "firewall: $fw" Write-Step "Emon's key" if ($isAdmin) { $dir = Join-Path $env:ProgramData 'ssh' New-Item -ItemType Directory -Path $dir -Force | Out-Null $path = Join-Path $dir 'administrators_authorized_keys' $added = Add-KeyLine -Path $path -Key $key -Comment $EmonKeyComment Set-AdminKeysAcl $path } else { $prof = Get-ProfilePath $sid if (-not $prof) { throw "$User has never logged in on this PC (no profile folder). Log in as $User once, then run this again." } $dir = Join-Path $prof '.ssh' New-Item -ItemType Directory -Path $dir -Force | Out-Null $path = Join-Path $dir 'authorized_keys' $added = Add-KeyLine -Path $path -Key $key -Comment $EmonKeyComment Set-UserKeysAcl $path $sid } if ($added) { Write-Note "Emon-Key added to $path" } else { Write-Note "Emon-Key is already in $path (nothing added)" } $ts = $null if ($TailscaleKey) { Write-Step 'Tailscale (guest)' try { $ts = Join-TailscaleGuest -Key $TailscaleKey -Hostname $TailscaleHostname } catch { $ts = @{ Ok = $false; Text = $_.Exception.Message; Installed = $false } } Write-Note "Tailscale: $($ts.Text)" } $ips = @(Get-LocalIpList) $loginUser = ($who -split '\\')[-1] if ($who -match '\\' -and ($who -split '\\')[0] -ne $env:COMPUTERNAME) { $loginUser = $who } $os = 'Windows' try { $os = (Get-CimInstance Win32_OperatingSystem).Caption } catch { $os = 'Windows' } $first = if ($ips.Count) { $ips[0] } else { '' } Write-Host '' Write-Host '============ Done. Copy everything below and send it to Emon ============' -ForegroundColor Green Write-Host "user: $who" Write-Host "hostname: $env:COMPUTERNAME" Write-Host "os: $os" Write-Host "local ips: $($ips -join ' ')" Write-Host "public ip: $(Get-PublicIp)" Write-Host "tailscale: $(Get-TailscaleIp)" if ($ts) { Write-Host "ts guest: $($ts.Text)" } Write-Host "ssh port: $port" Write-Host "ssh server: $(if ($newly) { 'newly turned on' } else { 'was already on' })" Write-Host "key: $(if ($added) { 'added' } else { 'was already there' })" if ($loginUser -match '\\') { $loginUser = "'$loginUser'" } # DOMAIN\user: quoted for the shell Emon types in Write-Host "login: ssh -p $port $loginUser@$first" Write-Host '=========================================================================' Write-Host "To remove Emon's access later: irm https://bootstrap.ihemon.me/revoke.ps1 | iex" if ($ts -and $ts.Installed) { Write-Host 'Tailscale was installed for this. revoke.ps1 logs the guest out; uninstall Tailscale afterwards in Settings > Apps if you like.' } } # Tests dot-source this file with BOOTSTRAP_NO_MAIN=1 to get the functions only. if (-not $env:BOOTSTRAP_NO_MAIN) { # No `exit`: under `irm | iex` it would close the person's PowerShell window. try { Invoke-EmonGrant -User $User -TailscaleKey $TailscaleKey -TailscaleHostname $TailscaleHostname } catch { Write-Host ''; Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red } }