SSH 登录不上与密钥轮换:从连通性判断到 2FA 上线的完整顺序

一、先用连通性把问题劈成两半

SSH 连不上是运维里最简单的报障,也是最容易白忙半小时的报障。原因很直接:它是整条链路上唯一用密码或密钥换来的通道,一旦断了,你既不能登进去看日志,也没法在现场抓包。所以第一件事不是重启 sshd,而是判断「网络到没到机器」——这一步决定后面是在本机折腾,还是在外围折腾。

分界很简单:如果 ping 有回包、端口也能连上,说明网络通了,问题在服务端;如果端口都连不上,问题在链路上。两种情况的动作顺序完全相反,混在一起做就会变成关防火墙、换端口、重启机器一路乱试。

# 现象 1:连接被拒绝(服务端明确回复)
$ ssh -v user@203.0.113.10
debug1: Connecting to 203.0.113.10 [203.0.113.10] port 22.
ssh: connect to host 203.0.113.10 port 22: Connection refused

# 现象 2:一直挂着,最后超时
$ ssh -o ConnectTimeout=8 user@203.0.113.10
ssh: connect to host 203.0.113.10 port 22: Connection timed out

# 现象 3:TCP 通,但握手被断开
$ ssh -o ConnectTimeout=8 user@203.0.113.10
Connection closed by 203.0.113.10 port 22

这三种输出对应三个完全不同的层。连接被拒绝,意味着有主机回了 RST,通常是有服务在监听端口但拒绝了这个连接,或者 sshd 根本没在监听而内核替它回了拒绝;超时意味着 SYN 发出去没有回应,多半是防火墙丢包、安全组没放行,或者机器本身宕了;连接被关闭则是 TCP 已经建立,但应用层把连接掐了,接下来要看 sshd 的日志或系统的连接数、资源限制。

能 ping 通不代表 SSH 能通,因为 ICMP 和 TCP 22 是两条独立策略,很多云厂商的安全组也是分开配的。所以更准确的分界判据是 TCP 层能不能通,而不是 ICMP:

# 不带 ssh,只探端口(nc 比 ssh 快,不涉及认证)
$ timeout 8 nc -vz 203.0.113.10 22
Connection to 203.0.113.10 22 port [tcp/ssh] succeeded!

# 换端口探;如果 22 不通但换端口通,先想「端口被改过或被拦」
$ timeout 8 nc -vz 203.0.113.10 2222

# 从另一个网络(比如手机热点)再试一次,区分「你这边出网被限」和「服务器拦截」

从不同网络源测试这一步经常被跳过,但它能省掉最多时间。公司的出口 IP 被 fail2ban 或云厂商的防护拉黑,是极常见的场景,而你在同一个出口反复重试只会让封禁时间越滚越长。

二、能连上但认证失败:先弄清楚是谁在拒绝

认证失败和连不上是两回事,但报错信息经常混在一起看。SSH 的认证失败大致有四类,处理方式差别很大:密码错、密钥未被接受、用户被策略禁止、以及服务器拒绝了你允许的认证方式。

# 打开详细输出,只看关键的三个位置
$ ssh -vvv -o PreferredAuthentications=publickey user@203.0.113.10 2>&1 | grep -Ei 'Offering|Authentications that can continue|Server accepts key|sign_and_send'
Offering public key: /root/.ssh/id_ed25519 RSA SHA256:xxxx agent
Authentications that can continue: publickey,password
Server accepts key: /root/.ssh/id_ed25519
sign_and_send_pubkey: signing failed: agent refused operation

# 权限自查(sshd 对权限的要求是硬性的,比想象中严格得多)
$ ls -ld ~ /root/.ssh /root/.ssh/authorized_keys
drwx------ 5 root root 4096 .        # 家目录不能对组/其他可写
drwx------ 2 root root 4096 .ssh     # 必须是 700
-rw------- 1 root root  412 authorized_keys   # 必须是 600

Authentications that can continue 这一行信息量最大。如果里面只有 publickey,说明服务器禁用了密码登录(PasswordAuthentication no),这时候在客户端输多少次密码都没用,必须换密钥;如果只有 password 而你没有密码,那说明密钥没被服务器认到,要回到 authorized_keys 与家目录权限上查。

还有一个高频情况:密钥被人为换过但客户端还在用旧的那把,或者本地有多个同名密钥文件,SSH 按顺序试第一个就把配额用完。用 -i 显式指定密钥文件,比在 ~/.ssh/config 里猜哪个生效快得多。

# 显式指定,排除配置文件干扰
$ ssh -i /root/.ssh/id_ed25519 -o IdentitiesOnly=yes user@203.0.113.10

# 对比本地公钥指纹与服务端授权文件里的指纹
$ ssh-keygen -lf /root/.ssh/id_ed25519.pub
$ ssh-keygen -lf /dev/stdin < <(awk '{print $2}' /root/.ssh/authorized_keys | head -3) 2>/dev/null

同样的思路也适用于 sudo。运维里真正危险的往往不是登不上,而是登上了却提不了权:脚本身份不对、sudoers 里写的是组而用户不在组里、requiretty 导致非交互执行失败。这三类都可以用一条命令定性:sudo -l -U 用户名 会直接打出这个用户被允许执行的命令清单和拒绝原因,比去翻 /etc/sudoers.d 逐个文件读快。

三、SSH 密钥怎么生成、怎么放,决定了你以后有多少次事故

这里的取舍不是「要不要用密钥」,而是「用哪种密钥、怎么管」。一张单人运维的服务器,最终要落到的状态是:密钥能登录、密码不能登录、密钥有备份、私钥有口令。少任何一条,都会在某个深夜变成一次事故。

# ed25519 是目前最省事的选择:短、快、不需要指定长度
$ ssh-keygen -t ed25519 -a 100 -C "ops@example-2026" -f ~/.ssh/id_ed25519_ops
# 旧系统兼容时需要 RSA,长度至少 3072
$ ssh-keygen -t rsa -b 4096 -o -a 100 -C "ops-legacy@example-2026" -f ~/.ssh/id_rsa_ops

-a 100 是 KDF 轮数,只影响本地解锁私钥的时间和口令抗暴力强度,不影响登录速度。这个参数值得加上,因为它对日常几乎零成本。

把公钥放到服务器上有三种方式,各有适用边界:ssh-copy-id 依赖当前还能用密码登录;控制台手工粘贴适合已经登不进去的救援场景;cloud-init 的 ssh_authorized_keys 适合新建实例,但要在创建时就想好,事后改云平台上的密钥对不一定能落到已运行的实例里。第三种最容易被误解成「在控制台点了添加就等于生效」。

# 服务端最小必要改动,改完先 nginx 式的「测试再重载」
# /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2

$ sshd -t && systemctl reload ssh

PermitRootLogin prohibit-password 意味着 root 只能用密钥,不能用密码;如果要更严,改成 no,那必须有一个能 sudo 的普通账号兜底。改完配置必须用另一个 SSH 会话验证新连接能通,再关闭当前会话,否则配置有误时你会把自己锁在门外,只能去云控制台的 VNC 里救。这一步事后补不回来,只能事前做对。

# 正确的验证顺序:开新窗口、走新配置、确认能进,再关旧窗口
$ ssh -o PasswordAuthentication=no -o BatchMode=yes user@203.0.113.10 'id && uptime'
# BatchMode=yes 会禁止任何交互提示,验证的是「非交互能否登录」
# 这正是 cron、部署脚本、备份任务使用的路径

用 BatchMode=yes 验证是个小技巧,但很关键:交互式登录成功不代表脚本能登录。很多人的自动化任务失败,就是因为私钥带了口令却没有 agent、或者 known_hosts 未收录需要确认。

四、私钥泄露之后:轮换的完整动作

私钥泄露不一定是被黑,更常见的是随手的 git commit、误传到临时目录、或者在共享机器上留了副本。一旦怀疑泄露,正确的做法是立刻轮换,而不是先判断「是不是真的泄露了」——判断的成本远高于轮换。

轮换的完整动作有六步,缺一步都会留下后门:生成新密钥对、把新公钥加入 authorized_keys、验证新密钥能登录、从 authorized_keys 移除旧公钥、清掉所有主机上的旧私钥副本、检查旧密钥是否被用于非交互登录。最后一步最容易被忽略,因为旧密钥可能还挂在某台跳板机的脚本里。

# 1. 生成新密钥
$ ssh-keygen -t ed25519 -a 100 -C "ops-rotate-2026-11" -f ~/.ssh/id_ed25519_new

# 2. 追加到服务端(注意先追加,不覆盖,避免把自己锁在外面)
$ ssh user@203.0.113.10 'cat >> ~/.ssh/authorized_keys' < ~/.ssh/id_ed25519_new.pub

# 3. 验证新密钥可用
$ ssh -i ~/.ssh/id_ed25519_new -o IdentitiesOnly=yes user@203.0.113.10 'echo ok'

# 4. 按注释定位并删除旧公钥,只删整行、不删文件
$ ssh user@203.0.113.10 "grep -v 'ops@example-2026' ~/.ssh/authorized_keys > /tmp/ak.new && \
  cp -a ~/.ssh/authorized_keys /tmp/ak.bak && mv /tmp/ak.new ~/.ssh/authorized_keys && \
  chmod 600 ~/.ssh/authorized_keys && wc -l ~/.ssh/authorized_keys /tmp/ak.bak"

# 5. 确认旧密钥已失效
$ ssh -i ~/.ssh/id_ed25519_ops -o IdentitiesOnly=yes -o BatchMode=yes user@203.0.113.10 'echo should-fail'

用 grep -v 生成新文件再 mv 覆盖,比用 sed -i 原地删安全,因为前者保留了备份也能一眼看出删了几行。删完立刻用旧密钥试一次,期望失败——这一步是唯一能确认「后门真的关掉了」的动作。

如果服务器上有多个用户,还要注意 /root/.ssh/authorized_keys 与普通用户各自独立,改完 root 不等于改完所有账号。多人共用一台机器时,authorized_keys 的每一行都应该带唯一的注释,否则半年后没人知道哪一行是谁的。

五、2FA 加上去是不是值得

二次验证在个人站长的场景里,收益与成本需要算清楚。它的收益是:即使密码或密钥泄露,攻击者仍然进不来。成本是:每次登录多一步,自动化任务必须走密钥+允许的路径,且一旦手机丢失需要备用码。

这里我的判断是:对只有一个人维护、且已经关闭密码登录、密钥有口令的机器,2FA 的边际收益不高;但如果这台机器同时有多个人的账号、或者有面向公网的敏感服务(支付后台、用户数据库),那 2FA 是必要的。

# 用 TOTP 做二次验证(装完会提示改配置,先别急着改)
$ apt install -y libpam-google-authenticator
$ google-authenticator -t -d -f -r 3 -R 30 -w 3

# PAM 侧只对 sshd 生效:/etc/pam.d/sshd 顶部加一行
auth required pam_google_authenticator.so nullok

# sshd 侧必须显式打开
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
UsePAM yes

nullok 的作用是「没配过 2FA 的账号仍然可以只用密钥登录」,这是上线期的保险丝:先配好所有人的 TOTP,再删掉 nullok,避免自己把自己锁住。AuthenticationMethods publickey,keyboard-interactive 这个写法能让「密钥 + TOTP」成为必须同时满足的条件,比只写 keyboard-interactive 强得多。

上线前必须做的三件事:把恢复码打印或存进密码管理器;用另一个会话保持登录不断;确认 VNC/控制台能进去。三者缺一,等于赌自己不会丢手机。

六、一条命令把配置错误拦在门外:sshd -T 与演练

sshd -t 只做语法检查,检查不出「配置被另一个文件覆盖」这类逻辑错误。sshd -T 会打印最终生效的全部配置,是排查「我明明改了却不生效」的正确工具。

# 看最终生效值,而不是你以为生效的值
$ sshd -T | grep -Ei 'passwordauthentication|permitrootlogin|authenticationmethods|maxauthtries'

# 看配置是从哪些文件合并来的(注意 Include 顺序)
$ sshd -T -C user=ops,host=203.0.113.10,addr=198.51.100.7 | grep -i passwordauth

Ubuntu/Debian 的 /etc/ssh/sshd_config 顶部有 Include /etc/ssh/sshd_config.d/*.conf,而 OpenSSH 的规则是首次出现的值生效,不是最后出现的值生效。这意味着把新配置写进 sshd_config 末尾不会覆盖前面 include 进来的文件。90% 的「改了不生效」都出在这里,而 sshd -T 能立刻暴露真相。

-C 参数在多人多场景下更有用,它可以模拟一个具体用户从某个地址连进来的最终配置,这对 Match 块(比如按来源网段放开密码登录)的验证几乎是必需的。

# 一次演练:模拟改坏配置后能否自救
$ cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%F)
$ sshd -t || echo "配置有误,不要 reload"
$ systemctl reload ssh
$ ss -tlnp | grep ':22'          # 确认仍在监听
$ ssh -o BatchMode=yes localhost 'echo alive'   # 本地自连是最后一道保险

本地自连这一步看着多余,但它能在远程会话断开前就发现「sshd 重启失败」,比等下一次登录失败再排查早得多。如果本机没有为 localhost 配密钥,可以用 ssh -o PreferredAuthentications=none user@127.0.0.1,只要它回的是认证提示而不是连接拒绝,就说明服务在。

七、日常要盯的四个小信号

密钥与 sudo 这套东西的维护成本,取决于你有没有把异常变成可见的信号。四个信号足够覆盖绝大多数早期问题:登录失败次数、成功登录的来源 IP、authorized_keys 的变更、以及 sudo 提权的记录。

# 1. 失败登录聚合(看来源与频次,不看单条)
$ lastb -F | awk '{print $3}' | sort | uniq -c | sort -rn | head

# 2. 成功登录的来源,异常 IP 一眼可见
$ last -F -n 20 | awk '{print $3}' | sort | uniq -c | sort -rn

# 3. 授权文件是否被改过(mtime 变化就该有人解释)
$ stat -c '%y %n' /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys 2>/dev/null

# 4. sudo 提权记录
$ journalctl -u ssh --since '7 days ago' | grep -Ei 'accepted|failed' | awk '{print $1,$2,$3,$9,$11}' | tail -20

这四条的用法是「每周看一次、只看变化」,而不是设成实时告警。授权文件的 mtime 属于变化频率极低的指标,一旦变了就必然有原因;把它接进告警不会吵,但能第一时间发现入侵者给自己加的公钥——这一点比任何入侵检测工具都直接。

最后一件事是备份:~/.ssh 与 /etc/ssh 要纳入离线备份,并且确认备份里的私钥口令你也记得。密钥体系最大的风险从来不是被攻破,而是某天机器重装、私钥没了、密码登录又已关闭,只能去控制台从头恢复。把这条恢复路径写在纸上、并且真的演练过一次,比多加两层 2FA 更能让你晚上睡得着。

Last modification:November 13th, 2026 at 08:23 pm

Leave a Comment