1. 先把这件事看清楚:Server 2016 装 OpenSSH Server 的难点在哪
1.1 一个真实的管理场景
我手上有一批 Windows Server 2016,角色很杂:有单纯跑 IIS 的,有做文件共享的,也有当 RDS 会话主机用的。日常工作里最烦的其实不是业务问题,而是"进不去机器"这件事。远程桌面偶尔抽风、控制台需要现场、批处理脚本想定时拉取日志却只能用 WinRM 或者共享目录绕。后来我把 OpenSSH Server 统一装到了这些机器上,情况就完全变了:登录、传文件、跑命令、拉日志、批量巡检,全都在一个终端里解决,键盘敲下去就有反馈,不用再等图形界面慢慢加载。
Windows Server 2016 上装 OpenSSH Server,说难不难,说简单也真谈不上简单。难的从来不是那几条命令,而是那些没人提前告诉你的坑:服务注册上了却起不来、公钥塞进去还是 Permission denied、连上以后中文全是问号、默认 Shell 是 cmd 导致方向键和退格键难受得要命、给多台机器批量部署时脚本一跑就报权限错误。这些问题没有一个能在官方 README 里找到完整答案,基本都是靠一次次试出来的。
这篇文章面向的是真实在机房或者远程管服务器的人,不管你是刚接手几台 Windows 服务器的新手,还是已经用惯了 Linux 那一套、第一次在 Windows 上碰 sshd 的老运维,都能照着走通。我会把在线装、离线装两条路线都写清楚,然后把 sshd_config 里必须动的默认值、公钥认证的权限深水区、故障排查顺序、以及 RDS 场景下要多留的心眼,全部摊开讲。代码和命令都是可以直接抄的,参数我会解释为什么这么填。
1.2 为什么 Add-WindowsCapability 这条路在 2016 上走不通
如果你搜过相关资料,大概率会看到这样一段命令:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0这段命令本身没错,但它在 Windows Server 2016 上会直接告诉你找不到这个功能。原因很简单:OpenSSH 作为系统可选功能(按需功能,FoD)集成进系统,是从 Windows 10 1809 和 Windows Server 2019 这一代开始的。Server 2016 的组件库里压根没有这个包,你敲多少次都是一样的报错,和权限、网络、系统语言都没关系。
这个认知很重要,因为它决定了你的技术路线。很多人卡在这一步,以为是系统没打补丁、以为要装什么更新包,然后开始折腾 Windows Update,浪费一两个小时。实际上 Server 2016 只有一条正路:用微软在开源仓库里维护的 Win32-OpenSSH 独立发布包。它是一套纯绿色的可执行文件加几个 PowerShell 脚本,自带服务注册逻辑,不依赖系统组件库,也不需要联网激活什么功能。
顺带说一句,Server 2016 自带的是 Windows PowerShell 5.1,这个版本足够跑 Win32-OpenSSH 的安装脚本,不需要额外去装 PowerShell 7。但如果你打算在 SSH 会话里长期用更现代的语法和跨平台能力,后面可以再补一个 PowerShell 7,这个不影响 sshd 本身的运行。
1.3 三条可选路线与取舍
把可选方案摆在一起看,选型逻辑其实很清晰:
| 路线 | 适用场景 | 优点 | 需要注意的点 |
|---|---|---|---|
| 在线拉取官方发布包 | 服务器能访问外部网络,或者有内部镜像源 | 版本最新,脚本最全,一次成型 | 需要走发布渠道下载,内网机器要先搬运 |
| 离线拷贝安装 | 生产内网、隔离网段、无外网出口 | 完全可控,可以先用测试机验证版本 | 包的完整性校验要自己做,路径不能带中文 |
| 第三方打包(如包管理器) | 已有统一软件分发平台 | 与现有流程融合 | 版本可能滞后,脚本被改造过,出问题不好对源码 |
我个人的选择是:新机器一律用官方发布包的离线版,先在测试机上把整个流程跑一遍,确认没问题后把同一个压缩包放进文件服务器,所有生产机从文件服务器取。这样做的好处是版本绝对一致,以后排查问题不会出现"A 机器是 8.x、B 机器是 9.x、行为不一样"这种尴尬情况。
至于"能不能直接从某台机器上把已经装好的目录拷到另一台",技术上可行,但我不推荐作为首选。原因是服务注册这一步(注册表项、服务账户、权限设置)不是拷文件就能带过去的,你省下的那点时间,后面调试服务起不来会加倍还回去。
2. 动手前的环境体检清单
2.1 系统与补丁基线确认
正式动手前,先花三分钟把机器摸清楚,这三分钟能省掉后面很多无意义的排查。
# 系统版本与内部版本号 Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber, OsArchitecture # 系统补丁最后安装时间,判断这台机器的维护状态 Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First 10 HotFixID, InstalledOn # 是否为 64 位系统 [Environment]::Is64BitOperatingSystem作用是什么?第一,确认架构。Server 2016 基本都是 x64,但如果你手上有一台老旧的 32 位环境,包要换 Win32 版本,脚本也不一样。第二,确认补丁基线。一台半年没打过补丁的机器,装完 sshd 之后可能因为加密算法支持问题连不上,这种问题排查起来最耗时,不如先确认机器处于正常维护状态。第三,确认系统本身来源正规、授权正常。这一点不是技术问题,但它是你后续所有变更的前提,来路不明的系统不要拿来做生产入口。
需要特别提醒的是:如果你计划在装 OpenSSH 之前给服务器补丁,请把两件事分开做,中间隔一次重启和一轮业务验证。原因我后面在第 8 章会讲——补丁、重启、会话层状态这三样东西叠在一起的时候,问题会变得非常难定位,你分不清是 sshd 的问题还是系统本身的问题。
2.2 PowerShell 与依赖检查
# PowerShell 版本 $PSVersionTable # 执行策略,安装脚本需要能跑 Get-ExecutionPolicy -List # 是否已经有人装过 sshd,避免重复安装 Get-Service -Name sshd, ssh-agent -ErrorAction SilentlyContinue Get-Command sshd.exe -ErrorAction SilentlyContinueGet-ExecutionPolicy -List这一步特别重要。Win32-OpenSSH 的安装脚本是 PowerShell 脚本,如果当前会话的执行策略是 Restricted,脚本一跑就红字报错,而报错信息往往被新手当成"脚本有问题"。实际上用powershell.exe -ExecutionPolicy Bypass -File .\install-sshd.ps1这种一次性绕过的方式就能解决,不用去改机器的全局策略——改全局策略是给自己留隐患。
再说检测是否已经装过。生产环境经常出现的情况是:前任运维装过一半,文件在但服务没注册,或者服务在但配置文件被改乱了。这时候直接覆盖安装比从零开始更省事,但你得先知道现状,否则你会在"为什么服务名已经存在"这个报错上卡很久。
2.3 端口、磁盘与权限预检
# 22 端口是否被占用(有些管理组件会偷偷监听) Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, State, OwningProcess # 反查占用进程 Get-Process -Id (Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue).OwningProcess -ErrorAction SilentlyContinue # 系统盘剩余空间 Get-PSDrive -PSProvider FileSystem | Select-Object Name, @{n='FreeGB';e={[math]::Round($_.Free/1GB,2)}} # 防火墙三个配置文件的状态 Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction22 端口被占用这件事在 Windows 上比在 Linux 上更常见,因为某些监控组件、备份组件、远程管理工具会挑一个低位端口监听,恰好撞上 22。如果你发现端口被占,有两条路:改 sshd 的监听端口,或者让那个组件换端口。我一般选前者,因为改动面更小,只需要在 sshd_config 里加一行 Port,再放行对应防火墙规则。
磁盘空间这块,OpenSSH 本体只占几 MB,不用担心。真正要留意的是日志目录,如果你把 LogLevel 开到 DEBUG3 排查问题,日志量会暴涨,排查完记得调回来。
防火墙这块,先看清楚三个配置文件(Domain、Private、Public)哪个是启用的。很多"能 ping 通但连不上 22"的问题,根因就是入站规则加在了 Domain 配置文件上,而服务器实际网络位置被识别成了 Public。
3. 在线安装:从零到 sshd 服务跑起来
3.1 下载与解压目录的选择
拿到官方发布包之后,第一个要做决定的是装到哪。我的建议是固定在C:\Program Files\OpenSSH。理由有三:一是这个路径不含空格以外的特殊字符("Program Files" 中间那个空格虽然有点烦,但只要所有引用都加引号就没问题),二是它在系统盘的默认权限体系里,普通用户无法随手修改,三是路径固定之后,后面写批量脚本、写监控、写文档都不用再改。
解压这一步有个非常容易被忽略的坑:官方压缩包解开之后,里面自带一层OpenSSH-Win64目录。也就是说,如果你把压缩包直接解到C:\Program Files,最终你会得到C:\Program Files\OpenSSH-Win64\sshd.exe。这时候你如果按文档去C:\Program Files\OpenSSH找脚本,当然找不到。
# 假设压缩包已经放在 C:\Temp\OpenSSH-Win64.zip $zip = 'C:\Temp\OpenSSH-Win64.zip' $dest = 'C:\Program Files' # 方式一:Expand-Archive,脚本里最直观 Expand-Archive -Path $zip -DestinationPath $dest -Force Rename-Item -Path 'C:\Program Files\OpenSSH-Win64' -NewName 'OpenSSH' # 方式二:文件数量多时快很多,推荐在自动化脚本里用 Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, 'C:\Temp\Expand') Move-Item 'C:\Temp\Expand\OpenSSH-Win64' 'C:\Program Files\OpenSSH'方式二比方式一快,原因是 Expand-Archive 在 PowerShell 5.1 里是逐文件调 .NET 接口,几百个小文件时会明显拖时间。这在批量部署几十台机器的时候差别很大,一台省二十秒,三十台就是十分钟。
解压完之后先别急着跑脚本,把目录内容过一眼:
Get-ChildItem 'C:\Program Files\OpenSSH' | Select-Object Name, Length | Sort-Object Name你应该看到 sshd.exe、ssh.exe、ssh-keygen.exe、sftp-server.exe、scp.exe 这些核心文件,以及 install-sshd.ps1。老一点的包里还会有 OpenSSHUtils 模块目录和 FixHostFilePermissions.ps1,新版本已经把权限修复的功能合并进主脚本了。如果你拿到的是新包,却看到网上教程让你去导入 OpenSSHUtils 模块,那说明教程的版本对不上——遇到这种版本错配,正确做法是整体换一个匹配的包,而不是东拼西凑地把模块拷进去。
3.2 执行安装脚本注册服务
这一步是整个流程的核心。安装脚本干的事情是把 sshd 和 ssh-agent 注册成 Windows 服务,设置好服务账户和启动参数,并生成一些必要的注册表项和权限。
cd 'C:\Program Files\OpenSSH' powershell.exe -ExecutionPolicy Bypass -File .\install-sshd.ps1正常输出是类似sshd and ssh-agent services successfully installed的提示。如果这一步报错,八成是三件事:一是没用管理员权限开 PowerShell;二是路径不对,脚本根本没找到;三是包版本太老,脚本里的某些 .NET 调用在当前系统上跑不通。
一个值得记住的经验:每次装完先看看服务是否存在,再谈能不能启动。这两个是不同性质的问题。
Get-Service -Name sshd, ssh-agent | Select-Object Name, Status, StartType如果服务不存在,是注册失败;如果服务存在但启动报错,是运行环境问题。把这两类问题分清楚,排查效率会高很多。
3.3 启动服务、放行端口、开机自启
默认情况下安装脚本把两个服务设成手动启动,这是有意为之的设计——不强制用户一装完就开。生产机器上我们肯定要设自动:
Set-Service -Name sshd -StartupType Automatic Set-Service -Name ssh-agent -StartupType Automatic Start-Service sshd Get-Service sshd, ssh-agent | Select-Object Name, Status, StartType关于 ssh-agent 这个服务,网上有说法是"公钥认证必须要它"。这个说法不准确。ssh-agent 是密钥管家服务,主要服务于客户端场景(比如你在服务器上主动去连别的机器时不用反复输密码)。服务端只用公钥登录的话,它不是必须的。但我还是建议一起设成自动,原因很简单:以后你哪天要在服务器上做密钥转发、或者用它去拉备份,再回头改启动类型,容易出现"配置改了但没人记得"的情况。
防火墙规则:
# 全放行,适用于测试环境 New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' ` -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 # 限制来源网段,生产环境推荐这种 New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' ` -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 ` -RemoteAddress 10.20.0.0/16, 10.30.5.0/24我强烈建议生产环境直接用第二种。原因不是"限制网段能防住谁",而是它能把误连和扫描噪音挡在门外,日志干净了,你才看得清真正的异常登录。
还有一个经常被忽略的动作:把安装目录加进系统 PATH。
$oldPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') if ($oldPath -notlike '*OpenSSH*') { [Environment]::SetEnvironmentVariable('Path', "$oldPath;C:\Program Files\OpenSSH", 'Machine') }不加 PATH 的后果是:sshd_config 里的Subsystem sftp sftp-server.exe用的是相对路径,服务本身因为在安装目录下运行所以能找到,但你在命令行里敲 ssh-keygen、sftp 就得写全路径,久而久之脚本里全是长路径,既难读又容易出错。
3.4 第一次登录验证与指纹确认
服务起来了,从另一台机器上验证:
ssh -p 22 -v 用户名@10.20.1.30第一次连接会出现主机指纹确认提示。这一步别随手回车,正确做法是把指纹和服务器上的实际指纹对一下:
# 在服务器上查看主机公钥指纹 Get-ChildItem "$env:ProgramData\ssh\ssh_host_*_key.pub" | ForEach-Object { $fp = & 'C:\Program Files\OpenSSH\ssh-keygen.exe' -lf $_.FullName "$($_.Name): $fp" }对上了再确认,这是防止中间人篡改的最基本一环。很多事故不是技术漏洞导致的,而是"顺手按了 yes"。
如果服务器上%ProgramData%\ssh目录里一个 host key 都没有,服务通常会报no hostkeys available -- exiting.然后退出。解决办法就一条命令:
& 'C:\Program Files\OpenSSH\ssh-keygen.exe' -A-A的参数含义是为所有缺失的密钥类型各生成一套,比自己一条条敲 rsa、ecdsa、ed25519 省事得多,而且不会漏。
4. 离线安装:内网机器的搬运方案
4.1 包怎么带进去,校验怎么做
生产内网一般没有直接的对外出口,所以包得先在外网机器上下载,再通过文件服务器或者跳板机搬进去。这一步有个细节值得强调:搬运之前先在本地校验一次哈希。
Get-FileHash -Algorithm SHA256 .\OpenSSH-Win64.zip把哈希值记在变更单或者部署文档里。到了内网再算一次,两个值一致才继续。为什么这么较真?因为压缩包在多次拷贝过程中损坏的概率虽然低,但一旦损坏,症状通常是"某些文件解不出来"或者"脚本跑一半报奇怪的错",这类问题排查起来非常痛苦,而校验哈希只要十秒钟。
搬运路径也有讲究:不要放在带中文或空格的目录下。我见过最典型的问题是压缩包放在某个用户桌面上,桌面路径里带了中文用户名,解压之后脚本读取路径时出现编码问题,报错信息完全不知所云。统一放到C:\Temp这种干净路径下操作,最省心。
4.2 脚本执行策略的处理
内网机器上常见的一种情况是执行策略被统一管控成了 AllSigned 或者 Restricted。这时候跑安装脚本会直接被拦。处理方式有两种,我推荐第一种:
# 第一种:只对这一次调用绕过,不留痕迹 powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\install-sshd.ps1 # 第二种:临时改当前进程的策略,会话结束就失效 Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass千万不要用Set-ExecutionPolicy -Scope LocalMachine Unrestricted图省事。这个改动是持久的,而且会在安全基线检查里被标出来,等于给自己挖坑。
4.3 版本升级时怎么覆盖不丢配置
OpenSSH 用久了肯定要升级,比如为了支持更新的算法、或者修掉某个已公开的问题。升级的正确顺序是:
- 先停止服务:
Stop-Service sshd, ssh-agent - 备份配置:把
C:\ProgramData\ssh\sshd_config复制一份带日期后缀 - 备份主机密钥:
%ProgramData%\ssh\ssh_host_*_key整个目录复制走 - 用新包覆盖
C:\Program Files\OpenSSH下的程序文件 - 启动服务,验证登录
其中第 3 步是最容易被跳过、代价也最大的一步。主机密钥一换,所有客户端都会弹出指纹变更警告,某些做了严格校验的工具会直接拒绝连接,你得挨个去清理 known_hosts,规模大的时候非常烦。
还有一个细节:程序文件覆盖的时候,如果 sshd 服务还在跑,文件会提示被占用。这就是为什么第 1 步必须先停服务。我见过有人强行覆盖失败后,只覆盖了成功的部分文件,结果新旧版本混在一起,服务起不来还找不到原因。
值得注意的是,新版本的包不会覆盖C:\ProgramData\ssh\sshd_config,因为配置文件和程序文件是两个目录。所以你改过的配置会保留,但同时也要留意:新版可能引入了新的默认行为,升级后建议把sshd -T的输出和升级前对比一下。
5. sshd_config 逐项拆解:哪些默认值必须动
配置文件在C:\ProgramData\ssh\sshd_config。Windows 下的路径和 Linux 不同,这一点先记住,别去/etc/ssh找。
改配置的通用流程是:改完用sshd -T检查语法,再重启服务。
& 'C:\Program Files\OpenSSH\sshd.exe' -T Restart-Service sshd-T会把生效后的完整配置打印出来,包括那些你没写、用的是默认值的项。这个习惯非常重要——很多"我明明改了却没生效"的问题,都是因为配置写在了错误的层级,或者被后面的 Match 块覆盖了。
5.1 端口、监听地址与协议相关项
Port 22 # ListenAddress 0.0.0.0默认监听所有地址。如果这台机器有多块网卡,一块走业务、一块走管理,强烈建议用 ListenAddress 只监听管理网卡的地址。这样即使业务网段被扫,SSH 也不会暴露。注意 ListenAddress 一旦显式指定,默认的全地址监听就失效了,需要几个地址就写几行。
关于改端口这件事,我的观点比较明确:改端口不是安全措施,只是降低日志噪音。它挡不住有针对性的扫描,但能挡掉大部分无差别扫段。如果你们的日志告警体系很敏感,天天被无效登录刷屏,那么把端口换到一个高位端口是有实际收益的。改完别忘了同步改防火墙规则,我见过好几次改了端口忘了改防火墙,然后自己把自己锁在外面。
5.2 认证相关:PasswordAuthentication 与公钥
PubkeyAuthentication yes PasswordAuthentication no PermitEmptyPasswords no MaxAuthTries 3 LoginGraceTime 30这几行是整个配置里最需要想清楚的。PasswordAuthentication no是安全上的最优解,但一刀切容易出事:万一你的公钥没配对,密码又关了,就只能去控制台救了。
我的做法是分两步走。第一步先保持密码认证开着,把公钥认证跑通,确认每个需要登录的账号都能用密钥进。第二步再关密码认证,而且关之前先保留一个应急的管理员账号用密码登录,同时给这个账号配一个非常复杂的口令并限制来源网段。等所有客户端都切换到密钥方式,再把这个应急口子也关掉。
MaxAuthTries 3和LoginGraceTime 30是限制暴力尝试的两个参数。默认值偏宽松,收紧一点没有副作用,因为正常登录一次就够了,不可能重试三次。
5.3 默认 Shell 与 Subsystem sftp
Windows 上 SSH 登录之后默认落到 cmd.exe,这个体验说实话不太好:方向键调不出历史命令、Tab 补全基本没有、翻页和某些控制字符显示异常。解决办法是通过注册表指定默认 Shell:
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name DefaultShell ` -Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' ` -PropertyType String -Force Restart-Service sshd这里有两个必须注意的点。第一,这个注册表项只接受可执行文件的完整路径,不要在后面加参数,比如想加-NoLogo,写了会被当成路径的一部分导致登录失败。第二,如果你装了 PowerShell 7,也可以指向 pwsh.exe,效果更好,但前提是它已经正确安装并且路径存在这个位置。
sftp 子系统:
Subsystem sftp sftp-server.exe如果你已经把 OpenSSH 目录加进了系统 PATH,这样写没问题。如果没加,就写全路径,用正斜杠或者双反斜杠:
Subsystem sftp C:/Program Files/OpenSSH/sftp-server.exesftp 通不通,直接影响你后面传文件顺不顺。验证方式很简单,从客户端执行sftp 用户名@主机看能不能进交互界面。
5.4 日志级别与调试开关
LogLevel INFO排查阶段可以临时调到 VERBOSE 或 DEBUG3,问题定位完立刻调回来。为什么强调"立刻"?因为 DEBUG3 会把每次连接的内部细节全写进日志,一台被扫的机器一天能产生几百 MB 日志,把系统盘写满的情况我见过不止一次。
日志的位置取决于包版本,两种都查一下:
# 老版本走 Application 日志,来源是 sshd Get-EventLog -LogName Application -Source sshd -Newest 20 -ErrorAction SilentlyContinue # 新版本走独立通道 Get-WinEvent -LogName 'OpenSSH/Operational' -MaxEvents 30 -ErrorAction SilentlyContinue把这两条命令记住,后面排查问题时会反复用到。
6. 公钥登录的深水区:权限与路径
6.1 普通用户与管理员走的是两套文件
这是 Windows 版 OpenSSH 和 Linux 版差异最大、也最容易踩坑的地方。
普通用户的公钥放在:C:\Users\<用户名>\.ssh\authorized_keys
管理员组成员的公钥放在:C:\ProgramData\ssh\administrators_authorized_keys
关键在于:如果一个账号属于 Administrators 组,sshd 默认会去读administrators_authorized_keys,压根不看用户目录下的那个文件。这个规则写在 sshd_config 的 Match 块里:
Match Group administrators AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys所以现象就是:你明明把公钥正确放进用户的.ssh\authorized_keys,权限也修好了,登录还是 Permission denied。这时候别怀疑密钥,去检查这个账号是不是管理员。
6.2 icacls 权限修复的正确姿势
Windows 版 sshd 对 authorized_keys 文件的权限检查比 Linux 严格得多。原因是 Windows 的权限继承很复杂,如果一个文件的权限能被其他用户改写,那这个文件就不能作为认证依据——别人改一下就能登录你的账号。所以 sshd 会校验:这个文件只能被该用户和 SYSTEM 访问。
管理员文件修复:
$ak = "$env:ProgramData\ssh\administrators_authorized_keys" icacls.exe $ak /inheritance:r icacls.exe $ak /grant "Administrators:F" icacls.exe $ak /grant "SYSTEM:F"普通用户文件修复:
$ak = "$env:USERPROFILE\.ssh\authorized_keys" icacls.exe $ak /inheritance:r icacls.exe $ak /grant "$($env:USERNAME):F" icacls.exe $ak /grant "SYSTEM:F"/inheritance:r的作用是切断父目录的权限继承,这一步是必须的,不切断的话父目录上的 Users 组权限会继承下来,校验就不过。
顺手把 .ssh 目录本身的权限也收一下:
icacls.exe "$env:USERPROFILE\.ssh" /inheritance:r icacls.exe "$env:USERPROFILE\.ssh" /grant "$($env:USERNAME):F" icacls.exe "$env:USERPROFILE\.ssh" /grant "SYSTEM:F"有一点需要说明:不同版本的 sshd 对权限检查的严格程度有细微差异,有的版本会明确告诉你"哪个权限不对",有的版本只给你一句 Permission denied。所以修完之后,用ssh -v看客户端输出,再用服务端日志对照,是最快的定位方式。
6.3 authorized_keys 常见坑:BOM、换行、注释
权限修好了还是连不上,那大概率是文件内容的问题。有三个坑我全都踩过:
第一个是UTF-8 BOM。用Set-Content -Encoding UTF8写文件,在 PowerShell 5.1 里会写入 BOM 头,也就是文件开头的三个隐藏字节。sshd 读到这三个字节,第一行公钥就解析失败了。正确写法是明确指定不带 BOM:
$key = 'ssh-ed25519 AAAAC3Nza... 运维笔记本' $ak = "$env:ProgramData\ssh\administrators_authorized_keys" [System.IO.File]::WriteAllText($ak, $key + "`r`n", (New-Object System.Text.UTF8Encoding($false)))第二个是换行符。Windows 用 CRLF,Linux 用 LF。Windows 版 sshd 两种都能处理,但如果你是从 Linux 那边拷文件过来、中间经过了文本模式传输,可能会出现行尾混杂,极端情况下把两行公钥连成一行。建议一条公钥一行,多余的空白和换行清干净。
第三个是把注释当命令。authorized_keys 每行开头可以用#注释,但注释必须在行首,不能写在公钥后面另有含义;公钥本身的行尾部分(最后那个字段)才是备注,随便写中文英文都行,不影响认证。
7. 安全加固:把它当成生产入口来对待
7.1 关闭密码认证的过渡策略
前面提过,密码认证要关,但不能一刀切。这里给一个我实际用过的过渡方案。
第一步,先在 sshd_config 里保持密码认证开启,同时打开详细日志:
PasswordAuthentication yes PubkeyAuthentication yes LogLevel VERBOSE第二步,把所有需要登录的账号都配好公钥,逐个验证。验证的时候用ssh -o PreferredAuthentications=publickey强制走公钥,确保不是靠密码悄悄进去的。
第三步,观察一到两周日志,确认公钥登录稳定、没有遗漏的账号。
第四步,关闭密码认证,同时把应急账号的来源限制写进防火墙规则:
PasswordAuthentication noLogLevel VERBOSE在这里的价值是:它会记录每次认证用了什么方式、哪个密钥指纹。日志里如果还有大量password记录,说明有客户端还在用密码,这时候贸然关闭会导致业务中断。
7.2 限制来源与登录对象
除了防火墙层面的来源限制,sshd_config 层面还能再做一层收口:
AllowGroups "SSH Users" DenyUsers AdministratorAllowGroups是白名单机制,只有指定组的成员才能登录。这个做法比在每台机器上维护用户列表更容易管理,尤其是在域环境里——建一个域安全组,需要 SSH 权限的人加进去,不需要的移出来,权限变更一个地方搞定。
需要提醒的是:AllowGroups一旦启用,所有不在组里的账号都会被拒,包括你常用的管理员账号。所以配置之前先确认自己在组里,否则重启服务之后你会发现自己被关在门外。这种"自己把自己锁了"的事故,在有控制台的情况下还能救,纯远程环境下就得走带外管理,非常麻烦。
7.3 日志审计与异常登录发现
日志不拿来看就是白记。我一般会配一个简单的巡检任务,每天扫一遍登录失败记录:
# 统计最近 24 小时的失败认证来源 $since = (Get-Date).AddHours(-24) Get-WinEvent -LogName 'OpenSSH/Operational' -MaxEvents 2000 -ErrorAction SilentlyContinue | Where-Object { $_.TimeCreated -gt $since -and $_.Message -match 'Failed|Invalid' } | Group-Object { ($_.Message -split "`n" | Select-String -Pattern 'from\s+(\d+\.\d+\.\d+\.\d+)').Matches.Groups[1].Value } | Sort-Object Count -Descending | Select-Object -First 10 Name, Count这条命令的效果是把失败次数最多的来源 IP 排出来。如果一个来源在短时间内有几百次失败,那基本就是扫描或者暴力尝试,直接加到防火墙黑名单里就行。
配套的建议是:把 SSH 端口只对内网管理网段开放,日志量会下降一个数量级,异常才看得清楚。日志淹没在噪音里,等于没有日志。
8. 故障排查速查表:连不上、起不来、乱码
8.1 服务侧的排查顺序
服务起不来的时候,按这个顺序走,不要跳步:
# 1. 服务状态和最后一次错误 Get-Service sshd | Select-Object Name, Status, StartType Get-WinEvent -LogName System -MaxEvents 50 | Where-Object { $_.Message -match 'sshd' } # 2. 配置语法 & 'C:\Program Files\OpenSSH\sshd.exe' -T # 3. 主机密钥是否存在 Get-ChildItem "$env:ProgramData\ssh\ssh_host_*" # 4. 端口是否被占 Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue # 5. 停掉服务,前台跑一次看实时输出 Stop-Service sshd & 'C:\Program Files\OpenSSH\sshd.exe' -d第 5 步是最有价值的。-d让 sshd 在前台运行并打开调试输出,所有启动过程中的错误都直接打在屏幕上,比翻日志快得多。注意必须先停服务,否则端口冲突会报错,那个报错是假象,会让你误判。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 服务启动后立刻停止 | 主机密钥缺失 | ssh-keygen -A生成全部密钥 |
| 服务完全无法启动 | 配置文件语法错误 | sshd -T检查,看报错行号 |
| 启动报端口占用 | 22 被其他组件监听 | 换端口或让占用方让出 |
| 服务在但连不上 | 防火墙未放行或网段不符 | 检查入站规则和 RemoteAddress |
| 连上后立刻断开 | 默认 Shell 路径错误 | 检查 DefaultShell 注册表值 |
| 登录提示 Permission denied (publickey) | 权限或文件路径不对 | 检查是否管理员账号、修 icacls |
| 用密码也进不去 | 密码认证被关闭或账号无密码 | 检查 PasswordAuthentication、账号口令 |
8.2 客户端侧的排查顺序
服务端没问题,就从客户端看:
ssh -vvv -p 22 用户名@10.20.1.30-vvv会打印完整的握手和认证协商过程。重点看三处:一是协商出的算法列表,如果两边没有交集会明确报出来;二是认证方式的顺序和结果,看服务器允许哪些方式;三是最终失败的具体原因。
一个常见的误判是:客户端提示"拒绝连接",但实际原因是网络不通。先在客户端Test-NetConnection 10.20.1.30 -Port 22,把连通性和应用层问题分开。
Test-NetConnection -ComputerName 10.20.1.30 -Port 22 -InformationLevel Detailed8.3 中文乱码与终端体验优化
这是我自己踩得最深的一个坑。现象是:SSH 登录之后执行带中文输出的命令,客户端看到的全是问号或者方块。根因是中文版 Windows 的默认控制台代码页是 GBK(936),而 SSH 通道按 UTF-8 处理,两边对不上。
按代价从轻到重有三个方案。
轻量方案,在 PowerShell 的 profile 里加上编码设置:
# 写入 $PROFILE(当前用户),对当前用户的所有 PowerShell 会话生效 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8这个方案的优点是不需要重启,不影响其他程序。缺点是对 cmd 里跑的原生程序输出不一定管用。
中等方案,把默认 Shell 换成 PowerShell 7(如果装了),它在编码处理上比 5.1 现代得多。
重量方案,开启系统的 UTF-8 全局支持(区域设置里的 Beta 选项)。这个方案要慎用:它会让部分老程序读取中文路径和配置文件时出错,尤其是年代久远的国产行业软件。如果这台机器上有关键业务系统,别动这个开关。
另外提一句,把 Win32-OpenSSH 升级到较新的版本,编码表现会明显改善。所以如果你的乱码问题很顽固,先确认一下包版本是不是太老,这可能比折腾编码设置更直接。
终端体验方面,除了前面说的把默认 Shell 换成 PowerShell,还可以给客户端配一下。用 Windows 自带的终端或者成熟的 SSH 客户端,都比老的命令行窗口舒服。字体选支持中英文等宽的,中文不会挤在一起。
8.4 RDS 与多会话场景下的注意事项
RDS 会话主机上的情况要单独说,因为这类机器的运维逻辑和普通服务器不一样。
第一,从我的实际观察看,SSH 登录走的是独立的会话通道,不经过远程桌面会话层,因此不会占用远程桌面授权名额。这一点在应急时候很有用:当会话数量已经吃紧、或者图形通道因为各种原因连不上时,你还有一条通道能进去看日志、看服务、抓现场信息。
第二,补丁重启之后出现的各种会话异常提示,很多时候根因在会话层和授权层,跟 OpenSSH 没有关系。这类问题的正确处理方式是从后台通道进去查事件日志、确认相关服务状态,把现场信息留全,然后再决定后续动作。靠反复重启碰运气是最差的做法,因为重启会覆盖掉很多有用的状态信息。同时也要清楚,授权相关问题最终还是要走正规的解决途径,不要试图用技术手段绕过。
第三,RDS 主机上的资源是共享的。SSH 会话会消耗内存和 CPU,虽然单个会话的开销不大,但如果有人写了一堆并发连接去跑脚本,几十个会话同时起来,对会话主机上的用户体验会有可感知的影响。我的做法是给运维脚本加上并发限制,一般控制在 8 到 16 之间,够用且不会把机器压垮。
第四,多台会话主机做统一部署时,建议用统一的分发流程,不要一台台手工装。手工装的结果一定是配置漂移:三台机器三种 sshd_config,出问题的时候你还得先搞清楚每台的配置到底长什么样。
9. 批量落地的自动化思路与巡检脚本
9.1 一段可复用的部署脚本
如果只有三五台机器,手工装也无所谓。到十台以上,就该写成脚本了。下面这段是我实际用过的结构,可以按需裁剪:
# 在管理机上执行,依赖 WinRM 可达 $hosts = Get-Content '.\ssh_hosts.txt' $pkg = '\\fileserver\deploy\OpenSSH-Win64.zip' $sb = { param($pkgPath) $dest = 'C:\Program Files' $tmp = 'C:\Temp\OpenSSH-Win64.zip' # 拷贝并校验 Copy-Item $pkgPath $tmp -Force $hash = (Get-FileHash $tmp -Algorithm SHA256).Hash Write-Output "PKG_HASH=$hash" # 解压 Add-Type -AssemblyName System.IO.Compression.FileSystem if (Test-Path 'C:\Temp\Expand') { Remove-Item 'C:\Temp\Expand' -Recurse -Force } [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, 'C:\Temp\Expand') # 落位 if (Test-Path 'C:\Program Files\OpenSSH') { Stop-Service sshd -ErrorAction SilentlyContinue Stop-Service ssh-agent -ErrorAction SilentlyContinue } else { Move-Item 'C:\Temp\Expand\OpenSSH-Win64' 'C:\Program Files\OpenSSH' } # 注册并启动 Push-Location 'C:\Program Files\OpenSSH' powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\install-sshd.ps1 Pop-Location Set-Service sshd -StartupType Automatic Start-Service sshd # 防火墙 if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) { New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' ` -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 ` -RemoteAddress 10.20.0.0/16 } Get-Service sshd | Select-Object MachineName, Name, Status, StartType } Invoke-Command -ComputerName $hosts -ScriptBlock $sb -ArgumentList $pkg -ThrottleLimit 8几个使用要点:一是-ThrottleLimit 8控制并发,别调太高,WinRM 通道本身也会被压;二是脚本里带了哈希输出,跑完把每台机器的哈希对一遍,确认版本一致;三是覆盖安装的分支里先停服务再动文件,这是前面说过的坑,写在脚本里就不会忘。
如果目标机器是隔离网段、WinRM 也不通,那就退化成"登录一次、跑一次脚本"的方式,把脚本放在文件服务器上,通过计划任务或者手工执行。虽然笨一点,但比手工改配置可靠。
9.2 部署后的巡检项
装完之后,我一般用一个体检脚本确认状态,把这些项目固化下来:
$result = [ordered]@{} # 服务状态 $svc = Get-Service sshd -ErrorAction SilentlyContinue $result['ServiceStatus'] = $svc.Status $result['ServiceStart'] = $svc.StartType # 监听端口 $listen = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -eq 22 } $result['Listening'] = if ($listen) { 'Yes' } else { 'No' } $result['ListenAddress'] = ($listen.LocalAddress -join ',') # 版本 $result['Version'] = (& 'C:\Program Files\OpenSSH\ssh.exe' -V 2>&1) -join ' ' # 主机密钥 $result['HostKeys'] = (Get-ChildItem "$env:ProgramData\ssh\ssh_host_*_key" -ErrorAction SilentlyContinue).Count # 配置中的关键项 $cfg = Get-Content "$env:ProgramData\ssh\sshd_config" -ErrorAction SilentlyContinue $result['PasswordAuth'] = ($cfg | Select-String '^\s*PasswordAuthentication').Line $result['PubkeyAuth'] = ($cfg | Select-String '^\s*PubkeyAuthentication').Line # 管理员公钥文件权限是否合规 $ak = "$env:ProgramData\ssh\administrators_authorized_keys" $result['AdminKeyExists'] = Test-Path $ak if (Test-Path $ak) { $result['AdminKeyAcl'] = (icacls.exe $ak) -join ' | ' } [pscustomobject]$result | Format-List这个脚本的价值在于把"我觉得应该没问题"变成"我确认过这些项目"。特别是主机密钥数量和权限 ACL 这两项,肉眼是看不出来的,只能靠脚本查。
跑完如果发现 PasswordAuth 是 yes,而你的基线要求是 no,那就说明这台机器漏配了。这种差异在几十台机器的环境里非常常见,靠人工比对基本不可能发现。
最后分享一个我在多机环境里养成的小习惯:把每台机器的 SSH 配置差异做成一份清单,每次巡检后更新。哪台机器因为特殊原因必须保留密码认证、哪台机器的端口做过调整,都记下来。等技术交接的时候,这份清单比任何文档都值钱——接手的人最怕的不是复杂,而是"没人知道为什么这台跟别人不一样"。