#SSH #Windows #PowerShell #运维工具

背景

Linux/Mac 自带 ssh-copy-id,一条命令把本机公钥安装到远端的 authorized_keys,实现免密登录。Windows 没有这个工具。

日常在 Windows 上需要频繁向 Linux 服务器和其他 Windows 主机推送 SSH 公钥,每次都要手动操作很繁琐,因此写了一个 PowerShell 版本的 ssh-copy-id


实现思路

难点一:判断远端 OS

传统做法是先 SSH 连上去执行探测命令,但如果是新主机、密钥还没装,连接本身就需要密码,探测和安装成了先有鸡还是先有蛋的问题。

解决方案:用 ssh-keyscan 读取 SSH Banner。Banner 在 TCP 握手阶段就会发送,无需任何认证

  • Windows:SSH-2.0-OpenSSH_for_Windows_10.0p2

  • Linux:SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13

难点二:Windows 目标的双重 authorized_keys

Windows OpenSSH 对管理员账户有特殊规定(来自 sshd_config):

1
2
Match Group administrators
AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys

普通用户走 %USERPROFILE%\.ssh\authorized_keys,管理员走 C:\ProgramData\ssh\administrators_authorized_keys,且后者权限必须严格(只允许 SYSTEM 和 Administrators 组)。

脚本会自动检测远端用户是否是本地管理员,两处都写入。

难点三:命令经过 SSH → cmd → PowerShell 的引号地狱

向 Windows 远端推送 PowerShell 命令时,命令经过三层:

1
本地 PowerShell → SSH 传输 → 远端 cmd.exe → 远端 powershell.exe

每层都有各自的引号规则,公钥字符串(含空格、+/=)极易损坏。

解决方案:

  1. 公钥先在本地编码为 base64

  2. 远端 PowerShell 脚本整体用 UTF-16LE base64 编码,通过 powershell -EncodedCommand 执行

  3. 完全消除引号问题


脚本代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
#Requires -Version 5.1
<#
.SYNOPSIS
Windows port of ssh-copy-id — installs your public key on a remote host.

.DESCRIPTION
Automatically detects whether the remote host is Windows or Linux/Mac via
SSH banner (no authentication required), then installs the public key into
the correct authorized_keys location.

Windows targets: writes to both %USERPROFILE%\.ssh\authorized_keys and
C:\ProgramData\ssh\administrators_authorized_keys (if the remote user is
a local administrator), then fixes file permissions via icacls.

Linux/Mac targets: appends to ~/.ssh/authorized_keys and sets chmod 600.

.PARAMETER Target
Remote host in [user@]host format.

.PARAMETER i
Path to the public key file. Defaults to the first found key among
id_ed25519, id_rsa, id_ecdsa, id_dsa in %USERPROFILE%\.ssh\.

.PARAMETER p
SSH port. Defaults to 22.

.EXAMPLE
ssh-copy-id user@192.168.0.100
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@hostname
ssh-copy-id -p 2222 user@hostname

.NOTES
Requires OpenSSH client (ssh, ssh-keyscan) to be available in PATH.
On Windows 10/11 this is bundled; install via:
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
#>

param(
[string]$i = "",
[int]$p = 22,
[Parameter(Mandatory, Position = 0)][string]$Target
)

# ---------------------------------------------------------------------------
# Parse target
# ---------------------------------------------------------------------------
if ($Target -match '^(.+)@(.+)$') {
$RemoteUser = $Matches[1]
$RemoteHost = $Matches[2]
} else {
$RemoteUser = $env:USERNAME
$RemoteHost = $Target
}

# ---------------------------------------------------------------------------
# Locate public key
# ---------------------------------------------------------------------------
if (-not $i) {
$found = $null
foreach ($name in @("id_ed25519", "id_rsa", "id_ecdsa", "id_dsa")) {
$path = $env:USERPROFILE + "\.ssh\" + $name + ".pub"
if (Test-Path $path) { $found = $path; break }
}
if (-not $found) {
Write-Error "No public key found. Generate one with ssh-keygen or specify with -i."
exit 1
}
$i = $found
}

if (-not (Test-Path $i)) {
Write-Error "Key file not found: $i"
exit 1
}

$pubkey = (Get-Content $i -Raw).Trim()
Write-Host "Key file : $i"
Write-Host "Target : $RemoteUser@${RemoteHost}:$p"

# ---------------------------------------------------------------------------
# Detect remote OS via SSH banner (requires no authentication)
# Windows : "SSH-2.0-OpenSSH_for_Windows_x.x"
# Linux : "SSH-2.0-OpenSSH_8.x Ubuntu-..."
# ---------------------------------------------------------------------------
$banner = (ssh-keyscan -p $p -T 5 $RemoteHost 2>$null) -join " "
if (-not $banner) {
Write-Warning "ssh-keyscan got no response from ${RemoteHost}:$p — defaulting to Linux/Mac."
}
$isWindows = $banner -match "OpenSSH_for_Windows"
Write-Host "Remote OS: $(if ($isWindows) { 'Windows' } else { 'Linux/Unix/Mac' })"

# TODO: support Xray/custom SSH servers whose banners don't match standard patterns

Write-Host "Connecting to $RemoteHost (you may be prompted for a password)..."

# ---------------------------------------------------------------------------
# Install key
# ---------------------------------------------------------------------------
if ($isWindows) {
# Encode the public key as base64 so it survives the SSH → cmd → PowerShell
# quoting gauntlet without any escaping issues.
$keyB64 = [Convert]::ToBase64String(
[System.Text.Encoding]::UTF8.GetBytes($pubkey)
)

$remoteScript = @"
`$key = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$keyB64'))
`$dir = "`$env:USERPROFILE\.ssh"
New-Item -ItemType Directory -Force -Path `$dir | Out-Null

# User-level authorized_keys
`$ak = `$dir + '\authorized_keys'
`$lines = if (Test-Path `$ak) { [IO.File]::ReadAllLines(`$ak) } else { @() }
if (`$lines -notcontains `$key) {
Add-Content `$ak `$key
Write-Host 'KEY_ADDED_USER'
} else {
Write-Host 'KEY_EXISTS_USER'
}

# Administrator-level authorized_keys (required when remote user is a local admin)
`$adminAk = 'C:\ProgramData\ssh\administrators_authorized_keys'
`$adminList = (net localgroup administrators 2>`$null) -join ''
if (`$adminList -match [regex]::Escape(`$env:USERNAME)) {
`$l2 = if (Test-Path `$adminAk) { [IO.File]::ReadAllLines(`$adminAk) } else { @() }
if (`$l2 -notcontains `$key) { Add-Content `$adminAk `$key }
icacls `$adminAk /inheritance:r /grant 'SYSTEM:(F)' /grant 'BUILTIN\Administrators:(F)' | Out-Null
Write-Host 'KEY_ADDED_ADMIN'
}

# TODO: fix permissions on authorized_keys for non-admin users as well
# TODO: handle Microsoft-account usernames (contain backslash) in admin check
"@

# Encode script as UTF-16LE base64 for powershell -EncodedCommand (no quoting issues)
$encoded = [Convert]::ToBase64String(
[System.Text.Encoding]::Unicode.GetBytes($remoteScript)
)
$result = (ssh -p $p -o StrictHostKeyChecking=no `
"${RemoteUser}@${RemoteHost}" "powershell -EncodedCommand $encoded") -join "`n"

} else {
# Linux / Mac
$escaped = $pubkey -replace "'", "'\''"
$linCmd = "mkdir -p ~/.ssh && chmod 700 ~/.ssh && " +
"grep -qxF '$escaped' ~/.ssh/authorized_keys 2>/dev/null || " +
"(printf '%s\n' '$escaped' >> ~/.ssh/authorized_keys && echo KEY_ADDED) && " +
"chmod 600 ~/.ssh/authorized_keys"

# TODO: support non-standard authorized_keys paths (AuthorizedKeysFile in sshd_config)
# TODO: remove duplicate keys if present

$result = (ssh -p $p -o StrictHostKeyChecking=no `
"${RemoteUser}@${RemoteHost}" $linCmd) -join "`n"
}

# ---------------------------------------------------------------------------
# Report result
# ---------------------------------------------------------------------------
Write-Host ""
if ($result -match "KEY_ADDED") {
Write-Host "Done: key installed on $RemoteHost"
} elseif ($result -match "KEY_EXISTS") {
Write-Host "Done: key already present on $RemoteHost"
} else {
Write-Host "Result: $result"
# TODO: better error parsing — distinguish auth failure / network error / permission denied
}

# ---------------------------------------------------------------------------
# TODO (future improvements)
# ---------------------------------------------------------------------------
# [ ] -n / --dry-run flag: show what would be done without modifying anything
# [ ] support Ed25519 key generation hint when no key exists
# [ ] -f flag: force overwrite even if key exists (refresh comment/type)
# [ ] support SSH config file aliases as Target (resolve via ssh -G)
# [ ] support ProxyJump hosts (pass -J flag through to ssh-keyscan and ssh)
# [ ] verify key was actually installed by doing a BatchMode=yes test connect
# [ ] publish as a PowerShell Gallery module (Install-Module ssh-copy-id)

安装方式

以管理员身份运行 PowerShell:

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 下载脚本到 System32(全局可用)
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/YOUR_REPO/main/ssh-copy-id.ps1" `
-OutFile "C:\Windows\System32\ssh-copy-id.ps1"

# 2. 创建 cmd 包装器(让 cmd 也能调用)
@'
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-copy-id.ps1" %*
'@ | Set-Content "C:\Windows\System32\ssh-copy-id.bat"

# 3. 允许执行本地脚本
Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force

使用示例

1
2
3
4
5
6
7
8
# 推送到 Linux/Mac
ssh-copy-id milin@192.168.0.155

# 推送到 Windows(自动检测并写入管理员密钥文件)
ssh-copy-id milin_win11@192.168.0.195

# 指定公钥和端口
ssh-copy-id -i $env:USERPROFILE\.ssh\id_ed25519.pub -p 2222 user@host

踩坑记录

1. cmd /c ssh 在 PowerShell 管道中挂起

当 PowerShell 通过 $output = cmd /c "ssh ..." 捕获输出时,SSH 进程会继承 PowerShell 的管道 stdin,导致在非交互式环境(如远程 SSH 会话中再次调用)永久挂起。

最终通过 System.Diagnostics.ProcessStartInfo + RedirectStandardInput = $true + StandardInput.Close() 解决,强制断开 stdin。

2. $args 是 PowerShell 保留变量

在函数内部将构建好的参数字符串赋值给 $args,会与 PowerShell 的自动变量 $args(函数的未绑定参数列表)冲突,导致 SSH 命令行错误。重命名为 $sshOpt 解决。

3. PowerShell here-string 中的变量展开

@"..."@ 内的 $key$dir 等变量名,在构建远端脚本字符串时会被本地 PowerShell 展开为空值。需要用反引号转义:`$key`$dir,让这些变量留到远端执行时再展开。

4. Windows 管理员账户的双重 authorized_keys

Windows OpenSSH 默认对管理员组成员使用系统级密钥文件,且文件权限必须只有 SYSTEM 和 Administrators 组,否则认证失败。普通配置指南常常忽略这点。


TODO

优先级 功能
-n dry-run 模式
安装后做 BatchMode 验证连接是否成功
-f 强制刷新已存在的 key
支持 SSH config alias 作为 Target(via ssh -G
支持 ProxyJump 跳板机
更好的错误区分(认证失败 / 网络错误 / 权限拒绝)
打包为 PowerShell Gallery 模块

延伸阅读