You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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.
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.
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)
$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.0if ($LASTEXITCODE-ne0) { 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 editsRestart-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:
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:
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 publickeyauthentication 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-userauthorized_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.