Skip to content

Instantly share code, notes, and snippets.

@YoraiLevi
Last active July 10, 2026 01:45
Show Gist options
  • Select an option

  • Save YoraiLevi/6a798fc49f4b10d688c8690b694eeba3 to your computer and use it in GitHub Desktop.

Select an option

Save YoraiLevi/6a798fc49f4b10d688c8690b694eeba3 to your computer and use it in GitHub Desktop.
Windows OpenSSH server for a Tailscale mesh — per-user keys, key-only, pwsh shell (two-phase runbook)

Push this machine's SSH public key to a remote server (cross-platform)

Append this machine's public key to a remote server's authorized_keys over a single SSH session — from a pwsh (Windows) or bash/zsh (Linux/macOS) client, to a Windows or Unix server. Set user / host / target-OS in the variables at the top of a block; the rest is fixed.

Two tricks make it robust (learned the hard way):

  • The key is piped in over stdin, never embedded in the command — so there are no long lines to wrap into a broken multi-line command, and no key-quoting.
  • The remote command is assembled from short pieces (-join '; ' / printf '%s; ') so every source line stays short and paste-safe.

Notes:

  • A Linux/macOS server already running Tailscale SSH authenticates you by tailnet identity, so you don't strictly need this for those — it's for Windows servers and any non-tailnet access. The key still gets appended for that future case.
  • First run prompts for the server's password (until your key is there). This pushes one direction; run it from each side for bidirectional.
  • Prereq: the server is already an SSH server (sshd running; on a Windows server the admin Match Group administrators block is commented so per-user authorized_keys is honored).

From a pwsh client (Windows)

$RemoteUser = 'USER'
$RemoteHost = 'HOST_OR_IP'
$TargetOS   = 'unix'          # 'windows' | 'unix' (Linux/macOS)

$winCmd = @(
  '$k=[Console]::In.ReadToEnd().Trim()'
  'ni -Force $HOME\.ssh -ItemType Directory >$null'
  'if(!(Test-Path $HOME\.ssh\authorized_keys)){ni $HOME\.ssh\authorized_keys -ItemType File >$null}'
  'if(!(Select-String -Path $HOME\.ssh\authorized_keys -Pattern $k -SimpleMatch -Quiet)){ac $HOME\.ssh\authorized_keys $k -Encoding ascii}'
  'icacls $HOME\.ssh\authorized_keys /inheritance:r /grant "$($env:USERNAME):F" /grant SYSTEM:F /grant Administrators:F >$null'
) -join '; '

$unixCmd = @(
  'k=$(tr -d "\r")'
  'umask 077; mkdir -p ~/.ssh'
  'grep -qxF "$k" ~/.ssh/authorized_keys 2>/dev/null || printf "%s\n" "$k" >> ~/.ssh/authorized_keys'
  'chmod 600 ~/.ssh/authorized_keys'
) -join '; '

$remote = if ($TargetOS -eq 'windows') { $winCmd } else { $unixCmd }
Get-Content "$HOME\.ssh\id_ed25519.pub" | ssh.exe -o StrictHostKeyChecking=accept-new "$RemoteUser@$RemoteHost" $remote

From a bash / zsh client (Linux / macOS)

Keep this comment-free: interactive zsh runs # as a command (a VAR=val # note line then fails to persist the variable). Either paste it as-is, or run setopt interactivecomments first if you add notes.

REMOTE_USER='USER'
REMOTE_HOST='HOST_OR_IP'
TARGET_OS='unix'

[ -f ~/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N '' -C "$(whoami)@$(hostname)-mesh"

if [ "$TARGET_OS" = windows ]; then
  parts=(
    '$k=[Console]::In.ReadToEnd().Trim()'
    'ni -Force $HOME\.ssh -ItemType Directory >$null'
    'if(!(Test-Path $HOME\.ssh\authorized_keys)){ni $HOME\.ssh\authorized_keys -ItemType File >$null}'
    'if(!(Select-String -Path $HOME\.ssh\authorized_keys -Pattern $k -SimpleMatch -Quiet)){ac $HOME\.ssh\authorized_keys $k -Encoding ascii}'
    'icacls $HOME\.ssh\authorized_keys /inheritance:r /grant "$($env:USERNAME):F" /grant SYSTEM:F /grant Administrators:F >$null'
  )
else
  parts=(
    'k=$(tr -d "\r")'
    'umask 077; mkdir -p ~/.ssh'
    'grep -qxF "$k" ~/.ssh/authorized_keys 2>/dev/null || printf "%s\n" "$k" >> ~/.ssh/authorized_keys'
    'chmod 600 ~/.ssh/authorized_keys'
  )
fi
REMOTE=$(printf '%s; ' "${parts[@]}")

cat ~/.ssh/id_ed25519.pub | ssh -o StrictHostKeyChecking=accept-new "$REMOTE_USER@$REMOTE_HOST" "$REMOTE"

Verify the key landed

  • Windows server (or any server NOT behind Tailscale SSH) — confirm key-auth works:
    ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no USER@HOST hostname
    
  • Tailscale-SSH server (Linux/macOS) — it authed you by identity, so just read the file:
    ssh USER@HOST 'grep mesh ~/.ssh/authorized_keys'
    

How the OS axes map

target Windows  -> the pwsh remote branch (Add-Content + icacls StrictModes ACL)
target Linux/macOS -> the sh remote branch (chmod 700/600); identical for both
client pwsh     -> Block 1
client bash/zsh -> Block 2 (byte-identical in bash and zsh)

The remote command reads the key from stdin in both client blocks ([Console]::In.ReadToEnd() on Windows, tr/$(...) on Unix), which is why neither block has to quote the key.

Windows OpenSSH server for a Tailscale mesh — per-user keys, key-only, pwsh shell

A reproducible runbook for turning a Windows machine into an SSH server that fits a personal Tailscale mesh — Tailscale SSH's server component runs only on Linux and macOS, so on Windows you run the built-in OpenSSH server and reach it over the tailnet (which Tailscale's own docs offer as the fallback: "run traditional SSH servers and clients on top of Tailscale as the network layer"). One uniform script runs on every Windows box — it self-adapts via $env:USERNAME / $HOME, so there are no per-box variants.

Every step shows its output and fails loudly ($ErrorActionPreference = 'Stop') — nothing is swallowed, so you always see what happened.

Design choices, and the non-obvious reasons behind them (the gotchas that bite people):

  • Per-USER keys, not the system-wide admin file. Windows OpenSSH ships a Match Group administrators block that forces admin accounts to a single shared C:\ProgramData\ssh\administrators_authorized_keys and ignores each user's ~/.ssh/authorized_keys — Microsoft documents this behaviour directly: "if the user belongs to the administrator group, %programdata%/ssh/administrators_authorized_keys is used instead". We comment that block so admins use per-user keys like everyone else. (Bonus trap: that block matches the group by its English name, so on a non-English Windows locale it silently never matches — see Limits.)
  • StrictModes ACL. A per-user authorized_keys is silently ignored if its permissions are loose. It (and the .ssh folder) must grant only SYSTEM, Administrators, and the user — else sshd refuses it with "Bad ownership or modes." We lock it with icacls, following Microsoft's documented permission rule for the admin key file (icacls … /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"; the per-user requirement is the same StrictModes rule — see Limits).
  • pwsh shell + a silent-non-interactive profile guard. We set the default shell to PowerShell (nicer than cmd) via the DefaultShell string value under HKLM:\SOFTWARE\OpenSSH that Microsoft documents, but a PowerShell profile that prints anything corrupts the scp/sftp protocol channel ("Connection closed"). The guard if ([Console]::IsOutputRedirected) { return } at the top of the profile keeps non-interactive sessions (scp/sftp, ssh host "cmd") silent while interactive logins still get the profile.
  • Key-only — but only AFTER a key login is verified. Disabling password auth before proving the key works can lock SSH out. Order is: install → add key → verify a key login → then disable passwords. RDP/console is the fallback — make sure it works first.
  • No reboot needed (the server starts the moment it's installed). Firewall scopes :22 to the tailnet (Tailscale IPs come from the 100.64.0.0/10 CGNAT range)
    • LAN only, never the public internet, and leaves RDP untouched. This host firewall is defence-in-depth; who on the tailnet may actually reach :22 is governed by your Tailscale ACLs / grants.

Prereqs

  • Run each block in an elevated PowerShell (Run as Administrator).
  • Confirm RDP or console access works to the box first — that's your fallback if SSH misconfigures.
  • Tailscale is installed and the node is up.

Phase 1 — setup (run on each Windows box, elevated)

Installing the OpenSSH.Server capability enables the sshd service and, per Microsoft, creates + enables a firewall rule named OpenSSH-Server-In-TCP for inbound port 22 — step 5 below only re-scopes that existing rule to the tailnet.

$ErrorActionPreference = 'Stop'   # surface failures — a failing step halts loudly instead of scrolling past

# 1. Install + enable the SSH server (no reboot needed).
#    Use dism.exe, NOT Add-WindowsCapability: the DISM *PowerShell module* throws
#    "Class not registered" under PowerShell 7 (pwsh). dism.exe is shell-agnostic.
dism /online /add-capability /capabilityname:OpenSSH.Server~~~~0.0.1.0
if ($LASTEXITCODE -ne 0) { throw "dism add-capability failed (exit $LASTEXITCODE)" }
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd

# 2. Default shell = PowerShell (prefer pwsh 7, else Windows PowerShell)
$pwsh  = "$env:ProgramFiles\PowerShell\7\pwsh.exe"
$shell = if (Test-Path $pwsh) { $pwsh } else { "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" }
if (-not (Test-Path 'HKLM:\SOFTWARE\OpenSSH')) { New-Item -Path 'HKLM:\SOFTWARE\OpenSSH' }
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name DefaultShell -Value $shell -PropertyType String -Force

# 2b. Seed the GUARD into the AllUsers profile so scp/sftp channels stay clean. (Prepend so it runs first.)
#     IMPORTANT: PowerShell loads several profiles in order, and a `return` exits ONLY the current file —
#     so this AllUsers guard does NOT stop a noisy PER-USER profile ($PROFILE) from polluting scp/sftp.
#     Any profile that prints, or auto-launches another program (a terminal multiplexer like tmux/zellij,
#     a prompt theme, an auto-attach, etc.), must have the SAME guard as its FIRST line.
$profDir  = Split-Path $shell
$profPath = Join-Path $profDir 'profile.ps1'
$guard = "# keep scp/sftp/ssh-command channels clean: bail (no output) when not an interactive terminal`r`nif ([Console]::IsOutputRedirected) { return }`r`n"
$existing = if (Test-Path $profPath) { Get-Content $profPath -Raw } else { '' }
if ($existing -notmatch 'IsOutputRedirected') { Set-Content -Path $profPath -Value ($guard + $existing) -Encoding ascii }

# 3. Per-USER authorized_keys: comment the admin override so admin accounts use ~/.ssh/authorized_keys
$cfg = "$env:ProgramData\ssh\sshd_config"
(Get-Content $cfg) | ForEach-Object {
  if ($_ -match '^\s*Match Group administrators\s*$' -or
      $_ -match '^\s*AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys\s*$') { '#' + $_ } else { $_ }
} | Set-Content -Path $cfg -Encoding ascii

# 4. This box's OWN key (to SSH OUT to other nodes). No passphrase now (add later: ssh-keygen -p -f <key>).
New-Item -ItemType Directory -Force "$HOME\.ssh"
if (-not (Test-Path "$HOME\.ssh\id_ed25519")) {
  cmd /c "ssh-keygen -t ed25519 -f `"%USERPROFILE%\.ssh\id_ed25519`" -N `"`" -C %USERNAME%@%COMPUTERNAME%-mesh"
}
Write-Host "`n==== THIS BOX'S PUBLIC KEY (add to OTHER boxes' authorized_keys to SSH FROM here) ====" -ForegroundColor Cyan
Get-Content "$HOME\.ssh\id_ed25519.pub"
Write-Host "=======================================================================================`n"

# 5. Firewall: the OpenSSH.Server install created the 'OpenSSH-Server-In-TCP' rule; scope it to
#    the TAILNET (v4 CGNAT + v6 ULA) + local LAN only — never the public internet.
Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -Enabled True -Action Allow `
  -RemoteAddress @('100.64.0.0/10','fd7a:115c:a1e0::/48','LocalSubnet')

# Apply the sshd_config edits
Restart-Service sshd
Write-Host ("Ready. SSH in as: {0}   (this box's Tailscale IP: run  tailscale ip -4 )" -f $env:USERNAME) -ForegroundColor Green

If step 5 errors that the rule doesn't exist, the install didn't create it — create it explicitly:

New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH SSH Server (tailnet+LAN)' `
  -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 `
  -RemoteAddress @('100.64.0.0/10','fd7a:115c:a1e0::/48','LocalSubnet')

Authorize clients (per-USER keys — repeat per client that should SSH into this box)

$ErrorActionPreference = 'Stop'
$ak = "$HOME\.ssh\authorized_keys"
New-Item -ItemType Directory -Force "$HOME\.ssh"

# Paste each CLIENT's PUBLIC key (from that client's ~/.ssh/id_ed25519.pub) — one per line:
Add-Content -Path $ak -Encoding ascii -Value 'ssh-ed25519 AAAA...keyA comment-A'
Add-Content -Path $ak -Encoding ascii -Value 'ssh-ed25519 AAAA...keyB comment-B'

# Lock down for StrictModes (only the user + SYSTEM + Administrators) — else sshd silently ignores the file.
# icacls prints what it changed, so you can confirm it applied:
icacls "$HOME\.ssh"  /inheritance:r /grant "${env:USERNAME}:(OI)(CI)F" /grant "SYSTEM:(OI)(CI)F" /grant "Administrators:(OI)(CI)F"
icacls $ak           /inheritance:r /grant "${env:USERNAME}:F"          /grant "SYSTEM:F"          /grant "Administrators:F"

Verify (do this BEFORE Phase 2 — keep RDP open)

From a client that holds the matching private key:

# Use ssh.exe / scp.exe — a PowerShell alias or function can otherwise shadow `ssh`/`scp`.
ssh.exe -o StrictHostKeyChecking=accept-new <user>@<box-tailnet-ip> "hostname"
# Optional — confirm scp still works with the pwsh shell + guard:
#   "test" > t.txt; scp.exe t.txt <user>@<box-tailnet-ip>:t.txt; ssh.exe <user>@<box-tailnet-ip> "del t.txt"

If a key login is refused, the two usual suspects are the admin Match block (step 3) and the StrictModes ACL (icacls above). Read the real cause:

Get-WinEvent -LogName 'OpenSSH/Operational' -MaxEvents 20 | Format-List TimeCreated, Message

Phase 2 — lock to key-only (run ONLY after a key login succeeded)

$ErrorActionPreference = 'Stop'
$cfg = "$env:ProgramData\ssh\sshd_config"
(Get-Content $cfg) -replace '^\s*#?\s*PasswordAuthentication.*','PasswordAuthentication no' | Set-Content -Path $cfg -Encoding ascii
Restart-Service sshd

Limits / what's unverified

The steps above are verified against the linked Microsoft/Tailscale docs, but these caveats are where this runbook stops being load-bearing — read them before you rely on it on a new box:

  • Key-only rests on a single factor. Windows OpenSSH exposes only password and publickey authentication methods (no Entra/MFA at the sshd layer), so after Phase 2 the client's private key is the sole factor. Any per-connection re-auth/MFA has to come from the Tailscale layer (ACL check mode), not sshd.
  • The per-user ACL rule is inferred, not MS-documented. Microsoft states the SYSTEM+Administrators permission requirement only for the admin key file; the identical lock this runbook applies to the per-user authorized_keys/.ssh is standard OpenSSH StrictModes behaviour — and StrictModes is one of the directives the Windows build does not expose, so you can only satisfy it (via icacls), never toggle it. Not independently re-tested here.
  • Version drift is real. The DISM capability string OpenSSH.Server~~~~0.0.1.0 and the exact sshd_config lines this script greps for assume the stock modern layout: Windows 10 build 1809 / Server 2019 is the documented floor, Windows Server 2025 ships OpenSSH enabled by default, and on a much older/newer build the capability name, default config contents (the Match Group administrators lines the regex targets), and shipped OpenSSH version can all differ.
  • Two claims are experience-based, not primary-sourced. The non-English-locale failure of Match Group administrators, and the "any printing profile corrupts the scp/sftp channel" + IsOutputRedirected guard, come from operational experience — not a cited doc. The Verify step's scp round-trip is how you confirm the guard on your own box.
  • The IPv6 firewall range wasn't re-verified. The 100.64.0.0/10 CGNAT scope is primary-sourced; the paired ULA fd7a:115c:a1e0::/48 is Tailscale's documented v6 range but was not re-checked against a primary source this pass — confirm with tailscale ip -6 on the node.
  • Not re-executed per Windows SKU. The command sequence was not re-run on every Windows edition/version here — the Verify phase (before Phase 2, with RDP still open) is the gate that proves it on the actual box.

Notes

  • One script, every box — $env:USERNAME/$HOME make it self-adapting; it's idempotent (safe to re-run).
  • cmd /c "...-N \"\" ..." is used for the keygen because passing an empty passphrase from PowerShell is version-dependent; cmd passes it reliably and non-interactively.
  • Don't install this on Linux/macOS tailnet nodes — Tailscale SSH already owns :22 there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment