Skip to content

Instantly share code, notes, and snippets.

@shrimpwagon
Last active August 25, 2026 17:41
Show Gist options
  • Select an option

  • Save shrimpwagon/96e1d565827e1a0d6d274f006c38f6bd to your computer and use it in GitHub Desktop.

Select an option

Save shrimpwagon/96e1d565827e1a0d6d274f006c38f6bd to your computer and use it in GitHub Desktop.
Windows 10/11: install the OpenSSH server, start it at boot, and open TCP 22 in the firewall. Run from an administrator PowerShell.
<#
Install and start the OpenSSH server on Windows 10 / 11.
Run from an ADMINISTRATOR PowerShell:
right-click PowerShell (or Windows Terminal) -> Run as administrator
One-liner:
irm https://gist.githubusercontent.com/shrimpwagon/96e1d565827e1a0d6d274f006c38f6bd/raw/install-openssh-server.ps1 | iex
Installs the server, sets it to start at boot, starts it, and allows
inbound TCP 22 through the firewall.
#>
$ErrorActionPreference = 'Stop'
# --- must be elevated ------------------------------------------------------
$principal = New-Object Security.Principal.WindowsPrincipal(
[Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host "Right-click PowerShell and choose Run as administrator, then run this again." -ForegroundColor Red
# return, not exit: this script is meant to be run as `irm ... | iex`, where
# exit would close the console window before the message could be read.
return
}
function Get-Sshd { Get-Service -Name sshd -ErrorAction SilentlyContinue }
function Wait-ForSshd([int]$Seconds = 20) {
# Service registration can lag a few seconds behind the installer returning.
for ($i = 0; $i -lt $Seconds; $i++) {
if (Get-Sshd) { return $true }
Start-Sleep -Seconds 1
}
return [bool](Get-Sshd)
}
function Invoke-InstallSshdScript {
# The Feature on Demand drops the binaries plus a registration script. On
# some machines the files arrive but the services are never created; running
# this by hand is what fixes it. Same script ships inside the GitHub zip.
foreach ($dir in @("$env:SystemRoot\System32\OpenSSH", "$env:ProgramFiles\OpenSSH")) {
$installer = Join-Path $dir 'install-sshd.ps1'
if (Test-Path $installer) {
Write-Host "Registering services with $installer ..." -ForegroundColor Cyan
try {
& $installer
if (Wait-ForSshd 10) { return $true }
} catch {
Write-Host (" install-sshd.ps1 failed: " + $_.Exception.Message) -ForegroundColor Yellow
}
}
}
return $false
}
function Install-FromGitHub {
# Fallback for boxes where the Feature on Demand cannot be fetched --
# Windows Update disabled, pointed at a WSUS that has no FoD content, or
# metered/blocked. Error 0x800f0954 is the usual signature. The GitHub
# build is the same upstream project Microsoft ships.
Write-Host "Falling back to the Win32-OpenSSH release from GitHub..." -ForegroundColor Cyan
$arch = switch ($env:PROCESSOR_ARCHITECTURE) {
'AMD64' { 'Win64' }
'ARM64' { 'ARM64' }
'x86' { 'Win32' }
default { 'Win64' }
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$release = Invoke-RestMethod -UseBasicParsing `
-Uri 'https://api.github.com/repos/PowerShell/Win32-OpenSSH/releases/latest' `
-Headers @{ 'User-Agent' = 'install-openssh-server.ps1' }
# Every Win32-OpenSSH release is tagged "-Preview" upstream; that is normal,
# they are the shipping builds. Skip the _Symbols archives.
$assets = $release.assets | Where-Object { $_.name -notlike '*_Symbols*' }
$asset = $assets | Where-Object { $_.name -like "OpenSSH-$arch-*.msi" } | Select-Object -First 1
if (-not $asset) {
$asset = $assets | Where-Object { $_.name -eq "OpenSSH-$arch.zip" } | Select-Object -First 1
}
if (-not $asset) { throw "No OpenSSH-$arch asset in release $($release.tag_name)." }
$dl = Join-Path $env:TEMP $asset.name
Write-Host " downloading $($asset.name) ..." -ForegroundColor Cyan
Invoke-WebRequest -UseBasicParsing -Uri $asset.browser_download_url -OutFile $dl
if ($dl -like '*.msi') {
Write-Host " installing (msiexec, quiet) ..." -ForegroundColor Cyan
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$dl`"", '/qn', '/norestart' -Wait -PassThru
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
throw "msiexec exited $($p.ExitCode)."
}
} else {
$dest = Join-Path $env:ProgramFiles 'OpenSSH'
Write-Host " extracting to $dest ..." -ForegroundColor Cyan
Expand-Archive -Path $dl -DestinationPath $env:TEMP -Force
$src = Get-ChildItem -Path $env:TEMP -Filter "OpenSSH-$arch*" -Directory | Select-Object -First 1
if (-not $src) { throw "Extracted archive did not contain an OpenSSH-$arch folder." }
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
Move-Item $src.FullName $dest
& (Join-Path $dest 'install-sshd.ps1')
# Put it on PATH so ssh/sshd/scp are callable without the full path.
$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
if ($machinePath -notlike "*$dest*") {
[Environment]::SetEnvironmentVariable('Path', "$machinePath;$dest", 'Machine')
$env:Path = "$env:Path;$dest"
}
}
return (Wait-ForSshd 20)
}
# --- install ---------------------------------------------------------------
# OpenSSH Server is a Feature on Demand: it is pulled from Windows Update, so
# this needs a working internet connection. It is not bundled with the ISO.
$restartNeeded = $false
if (Get-Sshd) {
Write-Host "sshd service already present." -ForegroundColor Green
} else {
# Wildcard match can return more than one object; take the server capability.
$cap = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like 'OpenSSH.Server~*' } | Select-Object -First 1
if ($cap -and $cap.State -eq 'Installed') {
Write-Host "OpenSSH Server capability already installed." -ForegroundColor Green
} elseif ($cap) {
Write-Host "Installing OpenSSH Server (downloads from Windows Update, can take a few minutes)..." -ForegroundColor Cyan
try {
$result = Add-WindowsCapability -Online -Name $cap.Name
# RestartNeeded is the quiet one: the capability reports success but
# the services do not exist until after a reboot.
if ($result.RestartNeeded) { $restartNeeded = $true }
Write-Host "Capability installed." -ForegroundColor Green
} catch {
# 0x800f0954 = cannot reach the FoD source (WSUS / policy / offline).
Write-Host ("Feature on Demand install failed: " + $_.Exception.Message) -ForegroundColor Yellow
}
} else {
Write-Host "No OpenSSH.Server capability on this build." -ForegroundColor Yellow
}
}
# The capability reporting success does NOT mean the service exists. This is
# the failure the script used to die on: Set-Service could not find sshd.
if (-not (Wait-ForSshd 10)) {
Write-Host "sshd service not registered yet; recovering..." -ForegroundColor Yellow
if (-not (Invoke-InstallSshdScript)) {
try {
if (-not (Install-FromGitHub)) { throw "sshd still not present after the GitHub install." }
} catch {
Write-Host ""
Write-Host ("Could not get sshd installed: " + $_.Exception.Message) -ForegroundColor Red
if ($restartNeeded) {
Write-Host "Windows reported a restart is required. Reboot and run this again." -ForegroundColor Yellow
} else {
Write-Host "Check that Windows Update is reachable and not disabled or WSUS-managed:" -ForegroundColor Yellow
Write-Host " Get-Service wuauserv | Select Status,StartType" -ForegroundColor DarkGray
Write-Host " Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' -EA SilentlyContinue" -ForegroundColor DarkGray
Write-Host "A UseWUServer=1 value there is what causes error 0x800f0954." -ForegroundColor DarkGray
}
return
}
}
Write-Host "sshd service registered." -ForegroundColor Green
}
# --- start it, and keep it started ----------------------------------------
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
# ssh-agent is optional; enable it so keys can be held on this machine too.
Set-Service -Name ssh-agent -StartupType Automatic -ErrorAction SilentlyContinue
Start-Service ssh-agent -ErrorAction SilentlyContinue
# --- firewall --------------------------------------------------------------
# Two things go wrong here, both seen on real machines:
#
# 1. The rule already exists but is SCOPED - to a profile, or to a remote
# address - so merely enabling it changes nothing for the network you are
# actually on. So widen it rather than just enabling it.
# 2. The network is classified Public, where Windows blocks inbound traffic
# regardless of the rule. sshd is running, the rule looks correct, and
# nothing can connect. This is the one that wastes an afternoon.
$ruleName = 'OpenSSH-Server-In-TCP'
$rule = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
if (-not $rule) {
New-NetFirewallRule -Name $ruleName -DisplayName 'OpenSSH Server (sshd)' `
-Enabled True -Direction Inbound -Protocol TCP -Action Allow `
-LocalPort 22 -Profile Any | Out-Null
Write-Host "Firewall rule created for TCP 22 (all profiles)." -ForegroundColor Green
} else {
Set-NetFirewallRule -Name $ruleName -Enabled True -Action Allow -Profile Any
Set-NetFirewallRule -Name $ruleName -RemoteAddress Any
Write-Host "Firewall rule already present; enabled and widened to all profiles." -ForegroundColor Green
}
# Some builds ship a second, differently-named rule. Enable any of them.
Get-NetFirewallRule -DisplayName '*OpenSSH*' -ErrorAction SilentlyContinue |
Where-Object { $_.Direction -eq 'Inbound' -and -not $_.Enabled } |
ForEach-Object { Enable-NetFirewallRule -Name $_.Name; Write-Host ("Also enabled: " + $_.DisplayName) }
# A Public network blocks inbound whatever the rule says. Private is the right
# classification for a home or office LAN you trust.
Get-NetConnectionProfile | ForEach-Object {
if ($_.NetworkCategory -eq 'Public') {
Write-Host ("Network '{0}' is set to Public - switching it to Private so inbound is allowed." -f $_.Name) -ForegroundColor Yellow
try {
Set-NetConnectionProfile -InterfaceIndex $_.InterfaceIndex -NetworkCategory Private
Write-Host " done." -ForegroundColor Green
} catch {
Write-Host " could not change it automatically; do it in Settings -> Network -> Properties -> Private." -ForegroundColor Red
}
}
}
# To narrow SSH back to your own network later:
# Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -RemoteAddress LocalSubnet
# Allow ping too, so basic reachability checks tell the truth.
Get-NetFirewallRule -DisplayName '*ICMP Echo Request (ICMPv4-In)*' -ErrorAction SilentlyContinue |
Enable-NetFirewallRule -ErrorAction SilentlyContinue
# --- optional: make PowerShell the shell you land in ----------------------
# Without this you get cmd.exe, which makes quoting painful from a remote host.
# The key does not exist when OpenSSH came from the MSI rather than the FoD.
if (-not (Test-Path 'HKLM:\SOFTWARE\OpenSSH')) {
New-Item -Path 'HKLM:\SOFTWARE\OpenSSH' -Force | Out-Null
}
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name DefaultShell `
-Value "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
-PropertyType String -Force | Out-Null
# --- report ----------------------------------------------------------------
Write-Host ""
Write-Host "sshd status : $((Get-Service sshd).Status)" -ForegroundColor Cyan
Write-Host "startup : $((Get-Service sshd).StartType)"
Write-Host "listening : $((Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue | Measure-Object).Count) socket(s) on port 22"
Write-Host "user : $env:USERNAME"
Write-Host ""
Write-Host "Firewall rules for SSH:" -ForegroundColor Cyan
Get-NetFirewallRule -DisplayName '*OpenSSH*' -ErrorAction SilentlyContinue |
ForEach-Object { Write-Host (" {0,-34} enabled={1} profile={2}" -f $_.DisplayName, $_.Enabled, $_.Profile) }
Write-Host "Network profile(s):" -ForegroundColor Cyan
Get-NetConnectionProfile | ForEach-Object { Write-Host (" {0,-20} {1}" -f $_.Name, $_.NetworkCategory) }
Write-Host ""
Write-Host "Addresses you can reach this machine on:" -ForegroundColor Cyan
Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } |
ForEach-Object { Write-Host (" ssh {0}@{1}" -f $env:USERNAME, $_.IPAddress) }
Write-Host ""
Write-Host "Password auth works out of the box. For key auth as an admin user, the" -ForegroundColor DarkGray
Write-Host "key goes in C:\ProgramData\ssh\administrators_authorized_keys - not your" -ForegroundColor DarkGray
Write-Host "profile - and that file must be owned by Administrators/SYSTEM only." -ForegroundColor DarkGray
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment