Chips

把 SSH 换个端口:一次完整的迁移流程

· 约 7 分钟 · 2364 字

在只有一条 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.confport = 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 -Prsync -e 'ssh -p ...'gitssh://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,但这是噪声的转移,不是安全性的提升。扫描器不来,只说明它没扫描这个端口;不代表它来了会失败。真实的防护由两条承担:

如果为了提高所谓「隐蔽性」而关掉 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(防火墙链路的另一层)。

← 全部文章