把 SSH 换个端口:一次完整的迁移流程
在只有一条 SSH 入口的机器上把端口从 22 换到 2222:为什么用 reload 而非 restart、Port 指令会叠加监听的坑、fail2ban 与防火墙必须同步改、先开后关的顺序,以及换端口为何只是降噪。
现象与动机
公网上的 22 端口每天都会被扫。auth.log 里绝大多数行是陌生 IP 的 Invalid user ... 和 Connection closed by ...,自己那几次正常登录被埋在中间。让它安静下来的最直接手段是换个端口。
这件事技术上很简单,但在「只有一条 SSH 入口」的机器上,它是一次不能出错的操作——顺序错了、或者漏改了某一处,代价可能是永久失去访问。下面按顺序记录一次完整的迁移,以及每一步为什么必须是这样。
reload 而不是 restart
ssh.service 的重载走的是这一行:
systemctl show ssh -p ExecReload -p KillMode
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
sshd 收到 SIGHUP 后是原地重新执行(re-exec):重新读取配置、重新绑定监听端口,而已经建立的连接由各自的子进程继续持有,完全不受影响。KillMode=process 保证信号只发给主进程,不去动那些子进程。
restart 走的是另一条路:停掉主进程,连带杀掉全部子进程——也就是你当前正在用的这条会话。在只有 SSH 一条入口、又没有带外通道(服务商控制台 / 串口)的机器上,systemctl restart ssh 的结果就是把自己关在门外,而且没有第二次机会。
所以这里没有任何选择余地:
sshd -t && systemctl reload ssh
sshd -t 先做语法与配置校验。语法错误时 reload 会失败并保留原进程,你还有机会改回来;如果直接 restart,会话已经断了。
配置优先级:首次出现的值生效,但 Port 是例外
Ubuntu 的 /etc/ssh/sshd_config 顶部有一行:
Include /etc/ssh/sshd_config.d/*.conf
sshd 的解析规则是首次出现的值生效(不是最后一次覆盖)。Include 在文件顶部,所以 sshd_config.d/ 目录里的设置优先于主文件——这正是把加固项写进 drop-in 而不是改主文件的原因,主文件里那些被注释掉的旧值不会构成矛盾。
但 Port 不遵守这个「覆盖」直觉。 Port 是可以多次出现的指令,每出现一次就多一个监听。主文件里留着一个、drop-in 里再写一个,sshd 会同时监听两个端口:
$ sshd -T | grep -i '^port'
port 22
port 2222
这不是迁移,是新增——旧端口依然对全网开放,迁移的安全意义和日志收益都是零。要真正迁移,必须让生效集合里只剩一个 Port:把主文件里那行注释掉,新端口写在 drop-in 里;或者反过来,但不要两边各写一个。
判断生效集合的方法很直接,别靠读文件:
sshd -T | grep -i '^port'
必须同步的三处
| 面 | 默认状态 | 不改的后果 |
|---|---|---|
| ufw | 默认 deny incoming,只有 22/tcp | 新端口在 listen 但包被丢弃,连接超时 |
| fail2ban | jail.conf 里 port = ssh |
封禁规则只针对 22,新端口上暴力破解不被封 |
| 客户端 / 脚本 | ssh user@host 隐式用 22 |
连不上;备份、监控、CI 静默失败 |
防火墙
ufw allow 2222/tcp comment 'ssh'
ufw status numbered
新端口不先放行,sshd reload 之后端口确实在监听,但连接会被 DROP——表现是超时而不是拒绝。这两者的区别后面会用到。
fail2ban
Ubuntu 自带的 jail.conf 里,sshd jail 写的是 port = ssh。这里的 ssh 是一个服务名,经 /etc/services 解析后等于 22。日志路径没有问题(fail2ban 照样能看到失败记录),但 banaction 生成的封禁规则只针对 22 端口:
iptables -S f2b-sshd | grep dports
换端口之后,这条规则指向的是一个已经没人监听的端口——封禁静默失效,而 fail2ban 的界面看起来一切正常。这是整次迁移里最容易漏、后果最隐蔽的一处。
修法是不动 jail.conf(发行版升级会覆盖它),用 drop-in 覆盖:
# /etc/fail2ban/jail.d/sshd.local
[sshd]
port = 2222
fail2ban-client reload sshd
iptables -S f2b-sshd | grep dports # 必须显示 2222
改完不看一眼 dports,等于没改。
客户端与脚本
给主机起个别名,把端口固化在一处:
Host blog-server
HostName <主机>
Port 2222
User deploy
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
之后 ssh blog-server 与端口无关了。但别名覆盖不到的地方必须逐个排查:scp -P、rsync -e 'ssh -p ...'、git 的 ssh://host:port/path 写法、备份脚本、监控探针、CI 里的部署步骤。这类地方漏改的症状是静默失败——任务跑着没报错,只是永远连不上。
顺序:先开后关
如果反过来——先删旧端口、再放行新端口——中间会有一段两个端口都不通的窗口。顺序是固定的:
1. ufw allow 2222/tcp
2. 写 /etc/fail2ban/jail.d/sshd.local,reload fail2ban
3. 注释主文件里的 Port 22,drop-in 写 Port 2222
4. sshd -t # 校验
5. systemctl reload ssh
6. 验证监听 + 真实登录测试 # 见下一节
7. ufw delete allow 22/tcp
第 6 步通过之前不要做第 7 步,也不要关掉当前这条会话。旧端口的放行规则多留一会儿没有成本,它是回退路径的一部分:万一新端口有问题,sshd -t 能过、reload 能回滚,但前提是你能连上去执行回滚。
验证:从监听面到真实登录
三个层次,缺一不可。
内核实际绑定了什么:
ss -ltnp | grep sshd
配置解析出什么:
sshd -T | grep -i '^port'
两者都要只剩新端口。sshd -T 是「配置视角」,配置里有两行 Port 它就会打印两行;ss 是「进程视角」,反映的是当前进程真正绑定的 socket。前面那个叠加监听的坑,可观测形态就是这里输出不一致。
真实登录测试。 不要用自己手上已有的密钥直连——那只能证明「你的密钥还能用」,证明不了完整路径。用一个临时密钥走一遍端到端认证,测完立刻还原 authorized_keys:
ssh-keygen -t ed25519 -f /tmp/probe_key -N ''
cat /tmp/probe_key.pub >> ~/.ssh/authorized_keys
ssh -i /tmp/probe_key -p 2222 -o IdentitiesOnly=yes user@<主机> 'echo PROBE-OK'
ssh-keygen -lf /tmp/probe_key.pub # 记下指纹
# 按指纹精确移除那一行,再确认 authorized_keys 内容与测试前一致
IdentitiesOnly=yes 是必要的:否则 agent 里其他密钥会代替这把临时密钥去认证,你验证的就不是它了。测试完成后临时密钥与新增的 authorized_keys 行都要清掉,别留着。
三种结果的含义完全不同:
| 客户端输出 | 网络层发生了什么 | 说明 |
|---|---|---|
Connection refused |
TCP RST | 该端口没有进程监听(sshd 没绑上),或防火墙用 reject |
Connection timed out |
无任何响应 | 包被 DROP,多半是 ufw 没放行新端口 |
Permission denied (publickey) |
到达 sshd,密钥交换完成,认证被拒 | 端口通了、sshd 在听,问题在密钥或 authorized_keys |
拿到 shell 并打印 PROBE-OK |
全链路成功 | 迁移可用 |
第三种最容易被误判成失败,但它在换端口这个场景里恰恰是好消息:它证明网络路径、sshd 监听、协议协商都是好的,剩下的只是认证细节。
换端口不提升安全性
迁移后一周的观测:
| 指标 | 旧端口 | 新端口(已迁移) |
|---|---|---|
| sshd 记录的失败认证 | 迁移前观测窗口内约 460 次,fail2ban 累计封禁 30+ 个 IP | 0 |
| 到达 sshd 的连接 | 持续不断 | 只有自己的客户端 |
| 被防火墙拦下的扫描 | 迁移后仍有大量 [UFW BLOCK] 记录 |
—— |
(观测窗口长度不同,绝对值只作量级参考;统计口径见下。)
# 新端口上有没有人尝试认证
journalctl -u ssh --since '7 days ago' | grep -cE 'Failed password|Invalid user'
# 旧端口上还有多少扫描(已被 ufw 丢弃,进不了 sshd 日志)
journalctl -k --since '7 days ago' | grep -c 'UFW BLOCK.*DPT=22'
新端口那一列是 0,但这是噪声的转移,不是安全性的提升。扫描器不来,只说明它没扫描这个端口;不代表它来了会失败。真实的防护由两条承担:
PasswordAuthentication no:密码这面根本不存在,暴力破解没有可攻击的靶子。即使扫到了端口,能做的也只是公钥认证失败。- fail2ban:对失败认证做速率限制。它防的是「有人拿着正确的公钥、但行为异常」以及日志层面的持续消耗,不是防破解。
如果为了提高所谓「隐蔽性」而关掉 fail2ban,或者为了「兜底」把 PasswordAuthentication 打开,换端口带来的那点收益会立刻归零——端口号从来不是防护的一部分。反过来,只要上面两条在,端口是 22 还是 2222 在安全性上是等价的。
所以改端口的唯一正当理由是日志可读性:auth.log 里只剩自己的连接,异常一眼可见。判断依据也很简单:
sshd -T | grep -iE 'passwordauthentication|permitrootlogin|pubkeyauthentication'
fail2ban-client status sshd
先确认这两条到位,再动手改端口。如果顺序反过来——先靠换端口「加固」,再去配 fail2ban——那段时间里机器其实一直是裸的,只是没人看见而已。
相关:/posts/ssh-host-key-drift.html(reload 的语义与单入口机器的风险)、/posts/docker-bypasses-ufw.html(防火墙链路的另一层)。