暗色模式

OpenSSH 客户端配置:从 ~/.ssh/config 匹配规则到连接复用

技术教程
2026-10-03
11
0
本文要点
  • ~/.ssh/config 的匹配是「按关键字取第一个值」,不是「整段覆盖」:man 原文是 for each parameter, the first obtained value will be used。实测两个 Host web1 段(先 User alice 再 User bob),ssh -G 展开出来的是 user alice——后面那段被静默忽略,一点报错都没有。同一条规则反过来说明 Host * 只能写在文件末尾:写在最前面,后面所有段的 User/Port 全部失效
  • 连接复用能把第二次连接的耗时打到零头:开启 ControlMaster auto + ControlPath + ControlPersist 5m 后,实测同主机 5 次连接是 1.007s → 0.092s / 0.092s / 0.093s / 0.093s;关掉复用的 5 次是 0.633 / 0.591 / 0.632 / 0.579 / 0.561s。第二次连接之所以「不需要认证」,是因为复用的是已经完成认证的那条 TCP 连接,ssh -v 里认证相关日志从 2 条变成 0 条
  • ControlPath 是按「连接参数哈希」而不是按别名匹配的:%C 是 %l%h%p%r%j 的哈希,所以两个不同别名 demo / demo-alt(同一 HostName:Port/User)展开出同一个 socket 路径,实测互相复用(两个别名同时报 Master running (pid=37341))。反过来说:别名起得再花,只要目的端三元组一样就会共用 master
  • 心跳有两套,只有一套防得住伪造:ServerAliveInterval 默认 0(不发),ServerAliveCountMax 默认 3。man 明确说 keepalive 消息「走加密通道、不可伪造」,而 TCPKeepAlive(默认 yes)「是可以被伪造的」。判死时间 ≈ Interval × CountMax,中间设备 5 分钟掐 idle 连接时,只配 Interval 60 刚好压线
  • 排错顺序是先 ssh -G 再 ssh -v:-G 不联网就把最终生效值全打出来(还会把 ControlPersist 5m 归一化成 300);-v 的 Reading configuration data / Applying options for 能直接告诉你某一行的值是被哪个文件哪一段吃下的。实测冷启动 82 行 debug1,复用 master 只剩 9 行
  • StrictHostKeyChecking no 并不会「自动接受新指纹」,它连指纹变了都照连:实测把 known_hosts 里 [43.248.3.161]:20150 的公钥换成另一把,accept-new/yes/ask 全部 rc=255 拦住,no 却 rc=0 连接成功(只警告,且不回写 known_hosts)。自动化场景要用的是 accept-new,不是 no

OpenSSH 客户端配置:从 ~/.ssh/config 匹配规则到连接复用

ssh 这条命令大多数人是这么用的:ssh root@10.0.0.12 -p 2222 -i ~/keys/prod.pem。能用,但每次都要在命令行上把参数凑齐,而且这些参数没有任何一处被留下来。而 OpenSSH 客户端其实有一个和 sshd_config 完全不同、却常常被忽略的配置文件:~/.ssh/config。它决定的是「我打 ssh web1 这四个字符时,到底连到哪、用哪个用户、拿哪把钥匙、要不要复用已有连接、断线了多久算死」。

这三个问题分开看都不难,难在它们互相影响:Host * 段放错位置,会让你后面所有段的 Port 失效;IdentityFile 不配 IdentitiesOnly,会被 ssh-agent 里的钥匙淹掉导致 Too many authentication failures;ControlPath 写得太长或者所在目录不见了,复用会直接失败(报错退出,而且可能发生在认证完成之后)。

本文的实测环境:客户端是 macOS 上的 OpenSSH_10.2p1, LibreSSL 3.3.6,服务端是一台 Ubuntu 22.04 演示机(OpenSSH_8.9p1 Ubuntu-3ubuntu0.17,sshd 监听在 20150 端口)。所有演示配置都放在 /tmp/sshcfg-demo/ 下、用 -F 指定文件,不碰真实的 ~/.ssh/config,也不改远端 sshd 的任何东西;服务端只执行 cat、true、ssh -V 这类只读命令。测试结束临时目录和 master 进程都会清理。

先划清边界:本文只讲客户端。服务端加固(改端口之外的 PermitRootLogin、PasswordAuthentication、AllowUsers 那一套)见 SSH 服务端安全加固;-L/-R/-D 这类端口转发是另一条线,只在文末给一个链接 SSH 隧道与端口转发,正文不展开。

匹配规则:按关键字取第一个值,不是整段覆盖

~/.ssh/config 的结构是「若干段,每段以一个 Host 或 Match 行开头,后面跟着若干 关键字 值」。关键字大小写不敏感,值区分大小写;关键字 和 值 之间可以用空格也可以用 =,参数值里如果要有空格就得加引号。这一段读起来像废话,但下一节那两个「静默失效」的现象全都来自这里。

关键在于多个段同时匹配一个目标时怎么办。man ssh_config(5) 的原文:

Unless noted otherwise, for each parameter, the first obtained value will be used.

也就是说:逐个关键字独立生效,取文件里第一次出现的那个值。做个最小实验——在同一个文件里写两个都叫 web1 的段:

# /tmp/sshcfg-demo/config 的节选
Host web1
    User alice

Host web1
    User bob
$ ssh -G -F config web1 | grep -E '^(user|hostname|port|compression|serveraliveinterval) '
user alice
hostname web1
port 22
compression yes
serveraliveinterval 60

user alice 胜出,User bob 被静默丢弃,ssh 不会给你任何警告。这条规则有三个直接推论:

  1. 段与段之间不是覆盖关系,是「先到先得」。想「改写」一个已经生效的值,你改不动,只能删掉前面那行。
  2. 少数关键字是累加的,man 会明确写 "may be specified multiple times",最典型的就是 IdentityFile(后面专门讲),LocalForward/RemoteForward 同理。
  3. 文件里段的顺序就是语义的一部分。两个段交换位置,结果可能就变了。

Host 行支持的是 shell 风格的通配:* 匹配任意长度(包括 .)、? 匹配单字符、[a-z0-9] 字符类,! 开头表示排除,一行里多个模式用空格分开。

# 匹配所有以 .prod 结尾、但不是 db 开头的名字
Host *.prod !db*
    User deploy

这里有个非常反直觉的点:Host 匹配的是你在命令行上敲的那个名字,不是最终的 HostName。所以下面这段连不上:

Host 10.0.0.12          # ❌ 你敲的是 ssh web2,名字里没有 10.0.0.12
    User root

正确写法是把 10.0.0.12 放进 HostName,让 Host 只负责匹配别名:

Host web2
    HostName 10.0.0.12  # ✅ 真正要连的地址
    User root

上面 ssh -G web1 的输出里 hostname web1 就是这个道理:我没写 HostName,于是 OpenSSH 直接把命令行上那个模式串当成地址去解析了。

如果需要更细的条件(按本地用户、按目标端口、按是否已在 master 里),用 Match。Match 和 Host 一样只是「一段生效条件」,它不改变优先级——依然是文件顺序 + 先到先得:

Match host *.example.com user deploy
    IdentityFile ~/.ssh/deploy_ed25519

Match final all            # final 表示「所有其它配置都算完之后」再匹配
    ServerAliveInterval 30

反例:Host * 写在最前面

这是现实里最常见的一个错误配置形状:有人先写了一个「全局默认」段,再在下面写具体主机。看这张图,文件里的顺序是 Host * 在前、Host web1 / Host web2 在后:

终端截图:cat trap.conf 显示 Host * 段在最前面(User deploy / Port 3600 / ServerAliveInterval 15 / StrictHostKeyChecking no),下面才是 Host web1(HostName web1.example.com / Port 2222 / User alice)和 Host web2(HostName 10.0.0.12);随后两次 ssh -G 展开,web1 得到 user deploy、hostname web1.example.com、port 3600、serveraliveinterval 15,web2 得到 user deploy、hostname 10.0.0.12、port 3600

结果非常有教学价值,注意 hostname 这一行:

关键字Host * 里写了Host web1 里写了最终生效
Userdeployalicedeploy
Port360022223600
ServerAliveInterval15—15
HostName—web1.example.comweb1.example.com

HostName 居然是对的!因为 Host * 段里没有写这个关键字,「先到先得」之后没人跟它抢,后面的值才被采纳。所以正确的理解不是「后面的段被忽略了」,而是「后面的段里,凡是前面已经出现过的关键字,才会被忽略」。这种「一半生效一半不生效」的配置比全不生效难查得多——毕竟地址是对的,你连不上只会怀疑密码错了或者对方 sshd 挂了,想不到是端口和用户名被 Host * 提前定掉了。

man 给的建议就是把这一句贴在配置文件顶上:

more host-specific declarations should be given near the beginning of the file, and general defaults at the end.

具体主机在最上面,通配模式在中间,Host * 兜底在最末尾。

记号、环境变量与 ~:三套各自独立的规则

值里可以写两类动态内容。第一类是 % 记号(TOKENS),在连接时替换。man 列出的全集如下,先记住这九个够用的:

记号含义(man 原文的直译)
%%一个字面的 %
%h远端主机名
%n命令行上敲的那个主机名(canonical 之前的原始值)
%p远端端口
%r远端用户名
%d本地用户的 home 目录(不是目标地址,很多博客把它写错)
%l / %L本地主机名 / 带域名的本地主机名
%u / %i本地用户名 / 本地 uid
%C%l%h%p%r%j 的哈希——ControlPath 里最好用的那一个

剩下的是给 KnownHostsCommand/LocalCommand 这类"要生成一条命令"的关键字用的:%f 服务端 host key 指纹、%t host key 类型、%K base64 host key、%H 正在查找的 known_hosts 名字或地址、%I 查找原因、%j ProxyJump 的内容、%k host key 别名、%T 分配的隧道网卡名。

关键在于:记号不是哪个关键字都能用。 man 直接给了白名单:

CertificateFile, ControlPath, IdentityAgent, IdentityFile, Include, KnownHostsCommand, LocalForward, Match exec, RemoteCommand, RemoteForward, RevokedHostKeys, UserKnownHostsFile and VersionAddendum accept the tokens %%, %C, %d, %h, %i, %j, %k, %L, %l, %n, %p, %r, and %u.

Hostname accepts the tokens %% and %h.

LocalCommand accepts all tokens.

ProxyCommand and ProxyJump accept the tokens %%, %h, %n, %p, and %r.

User 不在上面任何一条里,但它自己的小节说明支持除 %r 和 %C 之外的记号——因为它自己就是被替换的那个值。把 ControlPath(走第一条白名单)逐个记号试一遍,实测结果是这样:

$ for t in c s K k j T t H I f d i u L l; do ...; done   # 段里写的是 ControlPath /tmp/sshcfg-demo/x-%<t>
%c | vdollar_percent_expand: unknown key %c   |
%s | vdollar_percent_expand: unknown key %s   |
%K | vdollar_percent_expand: unknown key %K   |
%k | ok                                       | controlpath /tmp/sshcfg-demo/x-tz
%j | ok                                       | controlpath /tmp/sshcfg-demo/x-
%T | vdollar_percent_expand: unknown key %T   |
%t | vdollar_percent_expand: unknown key %t   |
%H | vdollar_percent_expand: unknown key %H   |
%I | vdollar_percent_expand: unknown key %I   |
%f | vdollar_percent_expand: unknown key %f   |
%d | ok                                       | controlpath /tmp/sshcfg-demo/x-/Users/Astarry
%i | ok                                       | controlpath /tmp/sshcfg-demo/x-502
%u | ok                                       | controlpath /tmp/sshcfg-demo/x-Astarry
%L | ok                                       | controlpath /tmp/sshcfg-demo/x-192
%l | ok                                       | controlpath /tmp/sshcfg-demo/x-192.168.5.10

几个信息点:%d 展开出来是 /Users/Astarry,它是本地 home,不是目标地址;%k 在没有 host key 别名时退回命令行上那个名字(tz);%j 没配 ProxyJump 时是空串(注意 x- 后面什么都没有);这台机器的主机名恰好是 192.168.5.10,于是 %l 给全称、%L 只给第一段——这两个记号的"域名"含义取决于本机怎么命名,别指望它们稳定。更重要的是老教程里常见的 %c(canonical 名)和 %s(ssh 目录)在这份 man(OpenSSH 10.2p1)的 TOKENS 列表里根本不存在,写了就是 fatal 错误,不是"退化成空串"。

把这些规则跑一遍,会得到三组"看起来一样其实不一样"的结果:

$ ssh -G -F t_h1.conf h1 | grep '^hostname '   # 段里写的是 HostName %h.example.com
hostname h1.example.com

$ ssh -G -F t_h3.conf h3                        # 段里写的是 HostName srv-%n
vdollar_percent_expand: unknown key %n
percent_expand: failed

第一条要留意语义而不是语法:在 HostName 里,%h 拿到的就是命令行上敲的那个短名,不是最终解析出来的地址(否则 %h 会自引用),所以 Host h1 + HostName %h.example.com 得到 h1.example.com。而 %n 在这条白名单之外,直接把整份配置打回失败。第二类:

$ ssh -G -F tok.conf t1 | grep '^user '        # 段里写的是 User dev-%n
user dev-t1

$ ssh -G -F tok.conf t2                         # 段里写的是 User dev-%r
vdollar_percent_expand: unknown key %r
percent_dollar_expand: failed

第三类最容易踩:记号在 Host 的模式串里根本不展开,写了也不报错,只是永远匹配不上。

$ ssh -G -F t_h4.conf h3 | grep '^user '       # 段里写的是 Host %n-%h / User foundme
user Astarry

user Astarry 是默认值,说明这个段没匹配上——%n-%h 被当成字面模式了。同理 Port 不在白名单里,写 Port %p 报的是值校验错误而不是记号错误:

$ ssh -G -F hosttok.conf h2
hosttok.conf line 10: Bad port '%p'.
hosttok.conf: terminating, 1 bad configuration options

这三段输出里有两条共同信息,值得单独记:这类错误的处置方式都是"放弃整份配置文件"(rc=255,ssh -G 的 stdout 一个字节都没有),不是"忽略这一行继续"。后面讲 ssh -G 时会再回到这点。还有一点安全上的注意:man 明确写了 ssh 不对记号做 shell 转义(ProxyCommand/RemoteCommand/LocalCommand 会拼成命令交给 shell,如果 %h 之类来自不可输入的地方,引号就是注入入口),要自己保证参数干净。

${VAR} 形式的环境变量是另一套规则,花括号是必需的,而且同样只有部分关键字支持。man 给的白名单是 CertificateFile、ControlPath、IdentityAgent、IdentityFile、KnownHostsCommand、UserKnownHostsFile(LocalForward/RemoteForward 只在 Unix socket 路径上支持),实测 User 也在其中:

$ ssh -G -F dollar.conf d1 | grep '^user '   # 段里写的是 User corp-$USER
user corp-$USER                              # 原样当字符串,没有任何提示

$ ssh -G -F dollar.conf d2 | grep '^user '   # 段里写的是 User corp-${USER}
user corp-Astarry

$USER 会被当成用户名的一部分直接送出去——连上之后你才会发现对方没有这个账号。第二组是变量没设置时的行为,这里有个很阴的分别:

$ ssh -G -F env6.conf k-user </dev/null          # 段里写的是 User corp-${ZZZ}
vdollar_percent_expand: env var ${ZZZ} has no value
invalid environment variable expansion
$ echo $?
255

$ ZZZ= ssh -G -F env6.conf k-user | grep '^user '   # ZZZ 存在但为空
user corp-

未设置是致命错误,设置为空串则是合法展开。 CI 里变量常常是"存在但为空",本地是"根本不存在",同一份配置在两边表现完全不同;而且失败时 ssh -G 的 stdout 是空的、rc=255,脚本里如果没检查退出码,看起来就像"配置突然全没了"。

第三组是展开时机的分别。User、ControlPath、UserKnownHostsFile 在解析阶段就展开,所以 -G 能直接看到结果;IdentityFile 不会——它在 -G 的输出里原样保留:

$ ssh -G -F env6.conf k-idfile | grep '^identityfile /'    # 段里写的是 IdentityFile /tmp/sshcfg-demo/${ZZZ}_id
identityfile /tmp/sshcfg-demo/${ZZZ}_id

同一条配置里的 ~ 也是同样的道理:IdentityFile ~/.ssh/id_ed25519 在 -G 的输出里仍然是 ~/... 原样,而 ControlPath 里的 %C 已经展开成了 40 位哈希。但"晚展开"不等于"不检查"——真到取钥匙那一步展开失败,同样是整个进程终止:

$ ssh -F env8.conf kid2 true </dev/null
vdollar_percent_expand: env var ${ZZZ} has no value
invalid environment variable expansion
$ echo $?
255

所以 % 记号、${VAR}、~ 是三套互不相同的规则,支持范围和展开时机都各自独立。实务上的建议是:能用记号就别用环境变量——记号是 ssh 自己算出来的,不依赖调用方环境;要用的话优先 %u(本地用户名)而不是 ${USER},%i(本地 uid)而不是 ${UID}。顺带一条实测:systemd 习惯写的 /run/user/%U/... 里的 %U 在 ssh_config 里不是合法记号,ControlPath /run/user/%U/ssh-%C 会直接 fatal(unknown key %U),正确写法是 %i。

回到 -G 的读法:它的输出要按「这个关键字到底什么时候被展开」来理解,不能一律当作最终字符串。

Include:把配置拆成片段管理

一个有几十台机器的 ~/.ssh/config 很快就会变成没人敢动的文件。Include 是官方给的分片机制:

Include ~/.ssh/conf.d/*

man 里三条容易漏的语义:

  • glob 会展开,展开出来的文件按字典序(lexical order)依次处理。所以序号前缀是正经的排版手段:00-common.conf、10-git.conf、90-defaults.conf。
  • Include 的路径里如果不是绝对路径也不是 ~/ 开头,按相对 ~/.ssh 处理。
  • Include 可以出现在 Host/Match 段内部,被引入的文件会继承当前的匹配上下文——这个特性很强大也很危险,除非明确需要,否则只把 Include 放在文件最顶层。

我用的演示结构:

/tmp/sshcfg-demo/
├── config              # 只有一行 Include + 具体主机段
└── conf.d/
    ├── 00-common.conf  # Host * 心跳与压缩(放在这里是为了演示,见下方警告)
    └── 10-git.conf     # Host github.com
# conf.d/00-common.conf
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    Compression yes

验证 Include 真的生效了——查一个只出现在片段里的目标:

$ ssh -G -F config github.com | grep -E '^(user|hostname|identitiesonly|identityfile|serveraliveinterval|compression) '
user git
hostname github.com
compression yes
identitiesonly yes
serveraliveinterval 60
identityfile ~/.ssh/id_ed25519

user git / identitiesonly yes 来自 10-git.conf,compression yes / serveraliveinterval 60 来自 00-common.conf——两个片段都参与了。

但请注意上面的 00-common.conf 里那个 Host *:由于 Include 在 config 文件的第一行,它的处理顺序就排在所有本地段之前,于是它变成了前一节那个反例——片段里的 Host * 会吃掉主文件里所有段的同名关键字。这就是分片管理最典型的一个坑。两个可靠的解法:把 Include 挪到主文件最后;或者干脆别在片段里写 Host *,把全局默认单独放一个 90-defaults.conf,靠字典序排到末尾。

想知道到底哪个文件在哪一步起了作用,ssh -v 会老老实实告诉你(这就是它比 -G 多给的信息):

debug1: Reading configuration data config
debug1: Reading configuration data /tmp/sshcfg-demo/conf.d/00-common.conf
debug1: /tmp/sshcfg-demo/conf.d/00-common.conf line 1: Applying options for *
debug1: Reading configuration data /tmp/sshcfg-demo/conf.d/10-git.conf
debug1: config line 4: Applying options for demo

Reading configuration data 的顺序就是 Include 的展开顺序,Applying options for 告诉你哪一段被匹配上、并且带文件名和行号。查「这个值到底是谁给的」,看这几行比翻文件快得多。

IdentityFile:ssh 到底拿哪把钥匙

IdentityFile 是累加型关键字(man: "may be specified multiple times"),一个段里写多行会组成一个有序列表:

Host listy
    HostName 127.0.0.1
    IdentityFile ~/.ssh/id_ed25519
    IdentityFile ~/.ssh/id_rsa
$ ssh -G -F config listy | grep '^identityfile '
identityfile ~/.ssh/id_ed25519
identityfile ~/.ssh/id_rsa

顺序就是尝试顺序。如果你什么都不写,OpenSSH 会按一份内置默认列表去找,ssh -G 在空配置下打出来的就是这份(本机 OpenSSH_10.2p1 实测):

$ ssh -G -F /dev/null nope.example.com | grep '^identityfile '
identityfile ~/.ssh/id_rsa
identityfile ~/.ssh/id_ecdsa
identityfile ~/.ssh/id_ecdsa_sk
identityfile ~/.ssh/id_ed25519
identityfile ~/.ssh/id_ed25519_sk

注意 id_rsa 排在最前面:只要 ~/.ssh/id_rsa 存在,它就永远先被递出去。这也是很多「我明明配了专用钥匙」的机器还在先试一把不相干的 RSA 钥匙的原因。

真正的麻烦来自 ssh-agent。默认行为是:agent 里的钥匙也算候选,而且它会优先按 agent 的顺序把钥匙一把把「递」给服务端试。如果你 ssh-add -l 里有十几把钥匙(管多个 git 平台、多台云主机的人经常如此),就会出现两种事故:

  • 服务端 MaxAuthTries(默认 6)或 MaxSessions 限制被触发,报 Too many authentication failures,连接被断开——而你的钥匙其实是对的,只是排在第 9 位。
  • 更隐蔽的一种:agent 里排在前面的一把钥匙也能登进某台机器,于是你以错误的身份进去了。

解法是 IdentitiesOnly yes,它把候选集严格锁到本段 IdentityFile 列出的那几把(外加命令行 -i),agent 里的其它钥匙一概不试:

Host github.com
    User git
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

-v 日志里能直接看出「这把钥匙是被配置指定的」:

debug1: Next authentication method: publickey
debug1: Offering public key: /Users/Astarry/.ssh/id_ed25519 ED25519 SHA256:LHCBNnJcA4io1PJG53MG0LC2vd/myPMpJ2k2HT1ARkk explicit
Authenticated to 43.248.3.161 ([43.248.3.161]:20150) using "publickey".

末尾那个 explicit 就是「来自 IdentityFile 的显式指定」(IdentitiesOnly 生效时同样会带)。看到 Offering public key 但一直 Next authentication method,就是钥匙对不上;一把 Offering 都没有,多半是路径写错或者权限被 ssh 拒绝加载(私钥文件必须 600)。

ControlMaster:一次认证,后续连接秒开

这是客户端配置里性价比最高的一项,也是最多人不知道的一项。

普通用法下,每一次 ssh / scp / git fetch 都要完整走一遍:TCP 握手 → 版本协商 → KEX(密钥交换)→ 主机密钥校验 → 用户认证 → 建立会话。一台跨洋机器上,这套流程就是几百毫秒到几秒。ControlMaster 的意思是:第一个连接把认证做完后,在本地留一个 Unix socket 作为「master」,之后的连接直接通过这个 socket 借用已经加密好的那条 TCP 连接,只需要再开一个逻辑 channel。三个关键字配套:

Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%C
    ControlPersist 5m
  • ControlMaster:no(默认,不玩这套)/ yes(这个连接必须当 master,没有就失败)/ auto(有 master 就借,没有就自己当 master,不提示)/ ask(交互式问你要不要当)/ autoask(有就借,没有时问一句)。日常用 auto。
  • ControlPath:master socket 的路径。推荐直接 %C(man 定义它等于 %l%h%p%r%j 的哈希)——定长、不含非法字符、天然按「本地用户 + 目标主机 + 端口 + 远端用户」区分。man 在这里给的原话是:include at least %h, %p, and %r (or alternatively %C) and be placed in a directory that is not writable by other users——前半句是「唯一标识一条连接」,后半句是安全要求(别人能写这个目录就能冒充你的 master)。
  • ControlPersist:master 在最后一条会话结束后还活多久。5m 是空闲超时(-G 会把它归一化成 300);yes/0 表示一直挂着直到 ssh -O exit;no 表示没会话就立刻退出。

实测:5 次连接的耗时

配置里对演示机开了复用(ControlMaster auto + ControlPath /tmp/sshcfg-demo/cm-%C + ControlPersist 5m),先 -O exit 确保从冷开始,然后连续 5 次 ssh ... true;对照组是同一个目标、把 ControlMaster 设成 no 的别名 demo-nomux,同样 5 次:

终端截图:cd 到演示目录后先 ssh -O exit 关掉 master,for 循环做 5 次计时,输出 复用 1 real 0m1.007s、复用 2 0m0.092s、复用 3 0m0.092s、复用 4 0m0.093s、复用 5 0m0.093s,中间一行 ls -l 显示 srw------- 的 cm-dd2160934928c23f73c26c73dddf27a3221ee6fd socket 文件,随后对照组 5 次为 0m0.633s / 0m0.591s / 0m0.632s / 0m0.579s / 0m0.561s

连接开复用(auto)不开复用(no)
第 1 次1.007s0.633s
第 2 次0.092s0.591s
第 3 次0.092s0.632s
第 4 次0.093s0.579s
第 5 次0.093s0.561s

三个结论:首次连接该付的握手钱一分不少(1.007s,甚至比不开复用的 0.633s 还慢——它要多创建并维护一个 master 进程);从第 2 次起差 6 倍左右(0.092s vs 0.56~0.63s);不开复用时那 5 次的波动(0.56~0.63s)就是 TCP 握手 + SSH 握手 + 认证这一整套流程在这条链路上的抖动,开了复用之后 4 次全部压在 0.09 秒——这点稳定性本身就是价值。对 git clone/rsync/ansible 这类会短时间内连很多次的工具,收益直接体现在总时长上——ansible 一轮 playbook 里一台机器几十次 ssh 是常态。

为什么第二次连接「不需要认证」

把 -v 的日志量一比一看就明白了。同样是冷/热两次连接:

ssh -O exit -F config demo >/dev/null 2>&1            # 确保没有 master
ssh -v -F config demo true </dev/null > cold.log 2>&1 # 冷启动
ssh -v -F config demo true </dev/null > warm.log 2>&1 # 复用
grep -c debug1 cold.log warm.log
grep -cE 'Offering public key|Authenticated to' cold.log warm.log

终端截图:三条 ssh 命令后跟两个计数,cold.log:82、warm.log:9,认证相关行 cold.log:2、warm.log:0,最后 tail -2 warm.log 打印出 debug1: auto-mux: Trying existing master at '/tmp/sshcfg-demo/cm-dd216...' 和 debug1: mux_client_request_session: master session id: 2

82 行 debug1 缩到 9 行,认证痕迹(Offering public key + Authenticated to)从 2 条变成 0 条。复用连接的全部 9 行日志长这样:

debug1: OpenSSH_10.2p1, LibreSSL 3.3.6
debug1: Reading configuration data config
debug1: Reading configuration data /tmp/sshcfg-demo/conf.d/00-common.conf
debug1: /tmp/sshcfg-demo/conf.d/00-common.conf line 1: Applying options for *
debug1: Reading configuration data /tmp/sshcfg-demo/conf.d/10-git.conf
debug1: config line 4: Applying options for demo
debug1: Authenticator provider $SSH_SK_PROVIDER did not resolve; disabling
debug1: auto-mux: Trying existing master at '/tmp/sshcfg-demo/cm-dd2160934928c23f73c26c73dddf27a3221ee6fd'
debug1: mux_client_request_session: master session id: 2

倒数第二行就是答案:它去找那个 socket 了。第 7 行那句 Authenticator provider $SSH_SK_PROVIDER did not resolve; disabling 不用管——ssh -G 里对应的默认值是 securitykeyprovider $SSH_SK_PROVIDER,你没设这个环境变量,于是 security-key(YubiKey 那类)支持被关掉,与本文无关。冷启动时同样这一段是三行(复用连接里只有中间那一行):

debug1: auto-mux: Trying existing master at '/tmp/sshcfg-demo/cm-dd2160934928c23f73c26c73dddf27a3221ee6fd'
debug1: Control socket "/tmp/sshcfg-demo/cm-dd2160934928c23f73c26c73dddf27a3221ee6fd" does not exist
debug1: Connecting to 43.248.3.161 [43.248.3.161] port 20150.

socket 不存在 → 只能自己连 → 自己认证 → 顺便成为 master。所以「第二次不用认证」不是缓存了密码或私钥,而是根本没有发生第二次认证:新进程只是把「开一个会话」的请求写进本地 socket,交给那个还活着的 master 进程去转发。这也解释了为什么 ControlPersist yes 的 master 会被安全审计盯上——它在后台长期持有一条已认证的连接(ps 里能看到:ssh: /tmp/sshcfg-demo/cm-dd216...6fd [mux])。

-O 管理命令与 socket 归属

$ ssh -O check -F config demo
Master running (pid=37341)

$ ssh -O exit -F config demo
Exit request sent.

$ ssh -O check -F config demo          # 已经没了
Control socket connect(/tmp/sshcfg-demo/cm-dd2160934928c23f73c26c73dddf27a3221ee6fd): No such file or directory

-O 后面可跟 check(活着吗)/ exit(温柔地让 master 退出)/ forward(在没有会话的 master 上强制建立转发)/ stop(拒绝新连接但让已有的跑完)。check 的退出码非 0 表示没有 master,脚本里可以直接用。

顺手验证一下「socket 是按连接参数还是按别名」:另开一个 Host demo-alt 文件,HostName/Port/User 与 demo 完全相同,别名不同、ControlPath 同样写 cm-%C:

$ ssh -G -F extra.conf demo-alt | grep '^controlpath '
controlpath /tmp/sshcfg-demo/cm-dd2160934928c23f73c26c73dddf27a3221ee6fd

和 demo 展开出来的哈希一模一样。实测结果:

cold demo:            real 0m0.537s
check demo:           Master running (pid=37341)
check demo-alt:       Master running (pid=37341)
warm demo-alt:        real 0m0.089s
warm demo-alt again:  real 0m0.092s
37341  00:00  ssh: /tmp/sshcfg-demo/cm-dd2160934928c23f73c26c73dddf27a3221ee6fd [mux]

用 demo 建的 master,用 demo-alt 直接就借上了,ssh -O check 两个别名报的是同一个 pid。结论有两条:

  • 好消息:一份主机用不同别名(内部名、IP、跳板名)访问时,只要「本地用户 + 目标 host + 端口 + 远端用户」相同就会共享连接,不会重复握手。
  • 需要注意:如果你故意想给同一个目标开两条独立的连接(比如一个跑长任务、一个交互用),换别名是没用的,得改 ControlPath——实测把模板写成 /tmp/sshcfg-demo/n-%n-%C(%n 是命令行上的原始主机名),demo 和 demo-alt 就展开成两个不同的 socket,各建各的 master;或者干脆对该别名显式 ControlMaster no。

三个坑:路径超长、目录不存在、master 被杀死

坑 1:ControlPath 超长——有两道检查,实际可用长度比手册给的数字短。

Unix domain socket 的路径要塞进 sockaddr_un.sun_path,本机 macOS 的头文件里是定长 104 字节(/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/un.h):

char            sun_path[104];  /* [XSI] path name (gag) */

(Linux 的 UAPI 头里这个数组是 108 字节,本文只在 macOS 实测。)

实测下来,坑不在 104,而在 87。因为 master 在 bind 之前会给路径加一个随机后缀避冲突(实测 17 个字节:一个点 + 16 位随机串),模板展开后的长度加上这 17 字节才是真正要放进 sun_path 的东西。同一台机器上的对比:

ControlPath 展开后结果
58 字符(long-%l@%h:%p-%r)正常复用
60 字符(cm-%C)正常复用
86 字符正常复用(实测的临界点)
87 字符unix_listener: path "…" too long for Unix domain socket,rc=255
104 字符ControlPath too long ('…' >= 104 bytes),rc=255

两条报错对应两道不同的检查:长度 ≥ 104 在发起连接之前就被拒(ControlPath too long);87–103 则是已经完成认证、在设置 master socket 时才 bind 失败(unix_listener)——钱已经付了,连接却没了。87 字符那条的完整报错:

unix_listener: path "/tmp/sshcfg-demo/aaaa…aaaa-dd2160934928c23f73c26c73dddf27a3221ee6fd.NV4qP7WpciB8nfJF" too long for Unix domain socket

所以安全的算法是 104 − 17 − 1 = 86 字符以内(1 是结尾的 \0)。用 %C 最省心:定长 40 字符,剩下 40 多个字节留给你放可读前缀。

坑 2:socket 所在目录不存在——不是「静默失效」,是认证完才炸。

Linux 上写 /tmp 会被 systemd-tmpfiles 定期扫掉,重启也没了;systemd 机器的正解是 ControlPath /run/user/%i/ssh-%C(%i 是本地用户 uid;${XDG_RUNTIME_DIR} 也可以,但它是环境变量,受上一节说的展开规则管)。目录不存在时 OpenSSH 的行为是什么?man 的说法只覆盖了一半:

If the ControlPath cannot be opened, ssh(1) will continue without connecting to a master instance.

这句 "cannot be opened" 指的是去连已有 master 那一步连不上——那确实会继续,退化为普通连接。但 ControlMaster auto 还要自己当 master,bind 那一步失败是致命的。实测把 ControlPath 指到不存在的目录,日志顺序是:

debug1: auto-mux: Trying existing master at '/tmp/sshcfg-demo/nosuchdir/ssh-dd216…ee6fd'
debug1: Control socket "/tmp/sshcfg-demo/nosuchdir/ssh-dd216…ee6fd" does not exist
…(正常走完整条认证流程)…
Authenticated to 43.248.3.161 ([43.248.3.161]:20150) using "publickey".
debug1: setting up multiplex master socket
unix_listener: cannot bind to path /tmp/sshcfg-demo/nosuchdir/ssh-dd216…ee6fd.hFnowj4ttjBOeW35: No such file or directory

退出码 255,远端命令没有执行——echo 一次都没跑。所以症状不是「今天好慢」,而是「这条命令偶发失败、重试就好了」,查起来比慢更迷惑。排错入口是日志最后那行 cannot bind to path。

顺带一个对照实验:master 进程被 kill -9 但 socket 文件还留在盘上,OpenSSH 会自己发现并重建——

debug1: auto-mux: Trying existing master at '/tmp/sshcfg-demo/cm-dd216…ee6fd'
debug1: Stale control socket /tmp/sshcfg-demo/cm-dd216…ee6fd, unlinking

之后正常重建、命令照跑,rc=0。代价只是这一次连接重付了完整握手(日志里重新出现 Authenticated to)。也就是说:socket 文件残留不是故障,真正的故障信号是 cannot bind 和 ControlPath too long。

坑 3:X11 / agent 转发跟着 master 走。复用之后的新会话拿的是建立 master 那次的转发环境,man 原文说得很死:

X11 and ssh-agent(1) forwarding is supported over these multiplexed connections, however the display and agent forwarded will be the one belonging to the master connection i.e. it is not possible to forward multiple displays or agents.

多用户共享机器、或者你换了 SSH_AUTH_SOCK 之后会有诡异表现——遇到「agent 转发时好时坏」,先 ssh -O exit 清掉 master 再看。

心跳保活:ServerAliveInterval / ServerAliveCountMax / TCPKeepAlive

SSH 长连接被中间设备悄悄掐死是日常事故:NAT 表项超时(很多家用路由和云上安全组 5 分钟)、负载均衡 idle 超时(常见 350s/600s)、公司防火墙。表现是你去倒了杯水回来,终端还挂在那里但按任何键都没反应,要等内核 TCP 超时(可能十几分钟)才报 Connection timed out。

三个参数各管一段,ssh -G 在空配置文件下的默认值是:

关键字默认值含义
ServerAliveInterval0每隔 N 秒发一次 keepalive;0 = 不发
ServerAliveCountMax3连续 N 次没回应就判定连接死了
TCPKeepAliveyes打开内核层 TCP keepalive

man 里那句关于「能不能伪造」的对比值得抄下来:

These messages are sent through the encrypted channel and therefore will not be spoofable. The TCP keepalive option enabled by TCPKeepAlive is spoofable.

差别在:ServerAliveInterval 的空包走加密通道、要 sshd 回应,所以它既能保活也能真的探活;TCPKeepAlive 只是内核层面的探测,中间设备可以随手替你回一个 ACK,于是出现「TCP 说活着、ssh 说已经连不上」这种情况。所以别只靠 TCPKeepAlive。

推荐组合:

Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3

判死时间约 60 × 3 = 180 秒,而 60 秒一次的空包也足够让绝大多数 300s/600s idle 超时的设备重置计时。太短(比如 5 秒)会在移动网络/高延迟链路上放大丢包影响,还会让笔记本更耗电。

这里给一个实测的负面结果,因为它能防止你去查错方向。把 ServerAliveInterval 调成 2、让远端 sleep 7(空闲 7 秒,理论上该发 3 次心跳),然后数日志:

$ ssh -O exit -F config demo >/dev/null 2>&1                       # 从冷开始,排除 mux 干扰
$ ssh -v   -F config demo -o ServerAliveInterval=2 'sleep 7' > ka1.log 2>&1
$ wc -l < ka1.log; grep -c 'debug1:' ka1.log; grep -ic alive ka1.log
74
73
0

-v 级别整份日志 74 行、73 行 debug1,alive 这个词一次都没出现。升到 -vvv:

$ ssh -O exit -F config demo >/dev/null 2>&1                       # 同样从冷开始
$ ssh -vvv -F config demo -o ServerAliveInterval=2 'sleep 7' > ka3.log 2>&1
$ wc -l < ka3.log; grep -ic alive ka3.log
285
4
$ grep -inE 'alive' ka3.log
3:debug3: Started with: ssh -vvv -F config demo -o ServerAliveInterval=2 "sleep 7"
172:debug3: mux_client_request_alive: entering
174:debug2: mux_master_process_alive_check: channel 1: alive check
175:debug3: mux_client_request_alive: done pid = 39505

alive 的四行命中里,第一行是 ssh 把你自己的命令行回显出来(Started with: ...,里面带了 ServerAliveInterval=2 才被抓到),后三行是 mux 的存活检查(mux_client_request_alive / mux_master_process_alive_check,那是本地会话与 master 之间的心跳,跟服务端无关)。但心跳其实已经在日志里了,只是不用 alive 这个词——要按包级别找:

$ grep -n 'send packet: type 80' ka3.log | head -6
234:debug3: send packet: type 80
235:debug3: receive packet: type 82
236:debug3: send packet: type 80
237:debug3: receive packet: type 82
238:debug3: send packet: type 80
239:debug3: receive packet: type 82

三对 send type 80 / receive type 82,正好对应 7 秒 ÷ 2 秒 = 3 次心跳。type 80 是 global request(心跳就是一条 keepalive@openssh.com 请求),type 82 是服务端的 REQUEST_FAILURE 回应——OpenSSH 把「有回应」就当成还活着。但请求名本身一次都不打(grep -c keepalive ka3.log 是 0),所以想确认在发心跳,别 grep 关键字,要数这种周期性的包对;-v 级别连包都不打,只能抓包。

还有个会让复现者抓狂的细节:上面 wc -l 读到的行数只在 ssh 刚返回那一刻成立。ControlPersist 把 master 转入后台时,它会继承你重定向的 stderr,之后它自己每 2 秒的心跳还会往同一个文件里追加一对 type 80/82——实测 25 秒后这份日志从 285 行涨到 311 行,ssh -O exit 之后才停(336 行)。所以数日志行数要先 ssh -O exit、数完立刻读,同一份 -vvv 日志隔一分钟行数就不一样了。

反过来验证效果的笨办法也很有效:把 ServerAliveInterval 设 0、让会话空闲超过中间设备的 idle 时间,看连接是否被单方面关闭。strings /usr/bin/ssh 里能看到 keepalive@openssh.com 和 server_alive_check,说明机制确实在,只是 -v 不打日志。顺带一条:-vvv 第 3 行那个 Started with: 很有用——它回显的是展开后的实际命令行,用来确认你的 -o 有没有传进去。

最后一点:ServerAliveInterval 只在登录成功之后才起作用,它救不了「握手阶段就卡住」。后者要的是 -o ConnectTimeout=(等价 ConnectionAttempts + 超时):

Host *
    ConnectTimeout 10

ssh -G:不用联网就把最终配置打出来

-G 是排错时最有用却被最少使用的开关。它把「解析完所有 Host/Match/Include 之后,这次连接实际会用到的值」全部打印出来,完全不联网。用法上除了 -G 之外和正常调用一模一样——所有能影响解析的选项都要放前面:

ssh -G -F config demo        # 用哪个文件、算哪个目标
ssh -G -o "User=deploy" web1 # 命令行临时覆盖也能反映出来

-F config demo 的展开结果(节选):

$ ssh -G -F config demo | grep -E '^(user|hostname|port|batchmode|compression|controlmaster|controlpath|controlpersist|identitiesonly|serveraliveinterval|identityfile) '
user root
hostname 43.248.3.161
port 20150
batchmode yes
compression yes
controlmaster auto
identitiesonly yes
serveralivecountmax 3
serveraliveinterval 60
controlpath /tmp/sshcfg-demo/cm-dd2160934928c23f73c26c73dddf27a3221ee6fd
identityfile ~/.ssh/id_ed25519
controlpersist 300

对比一份空配置(-F /dev/null,也就是「你什么都不配」时的真默认):

user Astarry
hostname nope.example.com
port 22
addressfamily any
compression no
controlmaster false
controlpersist no
identitiesonly no
stricthostkeychecking ask
tcpkeepalive yes
serveralivecountmax 3
serveraliveinterval 0
loglevel INFO
globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2
userknownhostsfile /Users/Astarry/.ssh/known_hosts /Users/Astarry/.ssh/known_hosts2

看这张表能纠正几个常见误解:compression 默认是 no(不是自动);controlmaster 默认 false;serveraliveinterval 默认 0,OpenSSH 客户端默认根本不发应用层心跳;stricthostkeychecking 默认 ask;userknownhostsfile 是 ~/.ssh/known_hosts 加上 known_hosts2 两个候选。

用 -G 时值得知道的几件事:

  • 值会被归一化:ControlPersist 5m 打出来是 300,ControlPath ...%C 打出来是展开后的实际哈希路径。想验证 token 替换是否正确,看这里就够了。
  • 但不是所有值都会被展开:IdentityFile 打出来的是 ~/.ssh/id_ed25519 原样(~ 和 % 都留着),因为它的展开被推迟到真正取钥匙的时候;而 ControlPath 是在解析阶段展开的。读 -G 的输出时要按关键字区分。
  • 命令行上的 -o 覆盖也会反映出来,所以它可以用来「预演」一次临时改参数:

    $ ssh -G -F config -o "User=deploy" web1 | grep -E '^(user|hostname) '
    user deploy
    hostname web1
  • 累加型关键字会多行出现(比如上面 identityfile 两行的 listy 例子),grep '^identityfile ' 是唯一稳妥的查法。
  • -G 不校验主机可达、不读私钥内容,所以它能告诉你「配置意图」,不能告诉你「为什么认证失败」。
  • 非 tty 环境下 ssh -G 会往 stderr 打一行 Pseudo-terminal will not be allocated because stdin is not a terminal.,在脚本里 ssh -G host | grep ... 会看到这行乱入——它不影响 stdout,加 2>/dev/null 即可。

把 ssh -G 当配置文件的语法检查器

这是 -G 最容易被低估的用法。配置文件里只要有一行语法错误,或者引用了一个不存在的变量,OpenSSH 会放弃整份文件,而不是跳过那一行。构造一个只有一处笔误的文件(第 5 行):

Host b1
    User first-user

Host b2
    BadKeyWord here
$ ssh -G -F bad.conf b1
bad.conf: line 5: Bad configuration option: badkeyword
bad.conf: terminating, 1 bad configuration options

stdout 一行都没有——也就是说连第 1~3 行那些正确的配置也不再生效。前文那个 ${ALSO_MISSING} 的例子是同一类失败。这在生产环境的表现非常迷惑:改完配置之后,所有主机突然全部「连不上」,报错却完全不指向配置文件。

所以养成一个习惯:每次改完 ~/.ssh/config,先跑一遍 ssh -G 某个别名 >/dev/null && echo ok,退出码为 0 且有输出才继续。它不联网、秒回、不会对远端产生任何影响。

读 ssh -v 的日志:按顺序看这七类行

-v(等价 -vvv 里最低的 VERBOSE 级别)把上面所有这些机制的执行过程打出来。别从头看到尾,按顺序抓七类:

顺序关键行能确认什么
1Reading configuration data <file> / Applying options for <pattern>用了哪个文件、Include 展开顺序、哪些段被匹配(不是被覆盖!)
2Connecting to 43.248.3.161 [43.248.3.161] port 20150.最终地址与端口、DNS 解析成了什么 IP——HostName/Port 写错在这里一眼看出来
3Server host key: ssh-ed25519 SHA256:gLWq... / load_hostkeys: fopen ...: No such file服务端给的指纹、known_hosts 有没有被读到
4Next authentication method: publickey / password服务端允许哪些方式(顺序由服务端给)
5Offering public key: <path> ... explicit递了哪把钥匙、是否来自 IdentityFile
6Authenticated to <host> (<ip>:<port>) using "publickey".认证成功,以及用的什么方式
7auto-mux / mux_client_request_session / Entering interactive session / channel 0: new mux listener复用是否生效、会话与 channel 的建立

级别选择:-v 够看上面全部七类;-vv 会多出各阶段的算法协商细节;-vvv 再叠加 transport 层包大小与压缩统计。上一节那次数出来的行数正好说明递增幅度:同一次会话 -v 是 74 行,-vvv 是 285 行上下(每次会有一两行浮动,所以别把行数当精确值)。绝大多数「连不上 / 连得慢 / 连错机器」在前 6 类里就能定位,不要一上来就 -vvv。

顺手记两个配套的排查工具:确认自己手里的配置没问题用 ssh -G;确认服务端到底在听哪个端口、允许哪些认证方式,那是服务端的配置,见 SSH 服务端安全加固。

known_hosts 与 StrictHostKeyChecking:四种取值实测差别

known_hosts 存的是「我信任哪个地址对应哪把主机公钥」。条目格式要注意:非 22 端口的条目会被方括号包起来,这是最容易踩的一点——

[43.248.3.161]:20150 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...

所以删旧记录时也必须带方括号并整体加引号,否则 ssh-keygen -R 43.248.3.161 匹配不到任何一行(它只会静默告诉你没找到):

ssh-keygen -R "[43.248.3.161]:20150"

实测的输出形如 # Host [43.248.3.161]:20150 found: line 1 / Original contents retained as ...known_hosts.old / ... updated,原文件自动备份成 .old。改过 IP、重装过系统、DHCP 重新分配过地址之后遇到的一堵红字就是这个文件在起作用。

StrictHostKeyChecking 四个值的差别,用「known_hosts 里该主机的公钥被换成另一把」这个场景实测(演示机地址 [43.248.3.161]:20150,用 UserKnownHostsFile 指向临时 fixture,不碰真实文件):

取值首见主机主机密钥已变实测退出码
ask(默认)交互问你 yes/no拒绝变化时 rc=255;配合 BatchMode yes(无法回答)时 Host key verification failed. rc=255
accept-new自动写入 known_hosts拒绝首见 rc=0 + Warning: Permanently added '[43.248.3.161]:20150' (ED25519) to the list of known hosts.;密钥已变 rc=255
yes只允许已在表里的拒绝rc=255
no自动写入照样连rc=0(只警告),且 known_hosts 不会被更新成新密钥

拦截时的红字块长这样(三种非 no 的取值内容基本一致):

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
...
Offending ED25519 key in /tmp/sshcfg-demo/kh-changed:1
Host key for [43.248.3.161]:20150 has changed and you have requested strict checking.
Host key verification failed.

而 no 的那次连接,警告有 13 行,末尾两句是关键:

Password authentication is disabled to avoid man-in-the-middle attacks.
Keyboard-interactive authentication is disabled to avoid man-in-the-middle attacks.

也就是说 no 的行为是:降级继续连——但把最容易被盗的密码类认证关掉,只允许公钥(所以实测 rc=0),并且不把错误指纹写回 known_hosts。这跟很多人以为的「no = 自动接受新密钥」正好相反:自动接受新主机密钥的是 accept-new。

给几条落地建议:

  • 人在终端里操作:保持默认 ask。
  • CI / 脚本 / 首次批量装机:用 StrictHostKeyChecking accept-new。它是「自动化友好 + 之后仍然严格」的唯一组合。no 只在完全隔离的实验网里用,并清楚它同时禁掉了密码认证。
  • 容器/一次性环境、每次都是新密钥的自愈集群:把 UserKnownHostsFile=/dev/null(GlobalKnownHostsFile 同理)与 StrictHostKeyChecking no 配对使用,避免污染镜像层——但要接受这等于放弃主机身份校验。
  • off 是比 no 更彻底的关闭(连不匹配都完全不检查),man 里 no 与 off 常被并列,差别就在 no 仍会记录新主机密钥。生产环境两者都不该出现。
  • 主机密钥指纹本身可以用 ssh-keygen -lf <(ssh-keyscan -p 20150 host 2>/dev/null) 取,跟控制台上贴出来的比对;跨 IPv4/IPv6 的机器要留意 known_hosts 里会各存一条。

权限、优先级与常见坑清单

关于权限,man ssh(1) 的 FILES 一节写得很硬:

~/.ssh/config ... must have strict permissions: read/write for the user, and not writable by others.

实测口径要说清楚:被检查的是默认的 ~/.ssh/config(以及 ~/.ssh/known_hosts、私钥文件),用 -F 显式指定的文件不做权限检查——我把同一个配置文件依次设成 644/664/604/600,四次 -F 运行的 ssh -G 结果完全一致,Include 照样展开。所以别拿演示目录里的 -F 经验去推断 home 目录里的行为;正确做法是照抄下面的权限设置:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/config ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub ~/.ssh/known_hosts

(~/.ssh 本身是 700;known_hosts 和公钥 644 即可;私钥 600。多机器共享 home / NFS 上 $HOME 时,权限位被服务端改掉就会导致 UNPROTECTED PRIVATE KEY FILE + 密钥被拒用。)

一份自查清单,按「现象 → 先查什么」排:

  • ssh alias 报 Permission denied (publickey),但 -i 手动指定钥匙就能进 → 段里没写 IdentityFile,或者被前面某个段的同名关键字抢先了;ssh -G alias | grep '^identityfile ' 直接看列表。
  • Too many authentication failures,而且根本没提示密码 → agent 钥匙太多;该段加 IdentitiesOnly yes。
  • 连上了但对方机器上 who 里的用户不是你以为的 → 上面有 Host * 段的 User 生效了;ssh -G <名字> | grep '^user '。
  • 同样的命令,第二次快得离谱 → 这是复用生效了,正常;想看是哪个 socket,ssh -G | grep '^controlpath ' + ssh -O check。
  • 短命令很稳,长会话十几分钟后卡死 → ServerAliveInterval 是 0;或者反过来:中间设备 idle 超时比你的心跳间隔短。
  • ControlPath 配了却直接报错退出 → 看日志最后那行:unix_listener: cannot bind to path ... 是 socket 所在目录不存在/不可写(实测发生在认证完成之后,远端命令根本没跑);ControlPath too long (...) 或 too long for Unix domain socket 是展开后超长(本机实测临界 86 字符)。
  • ControlPath 配了但每次都要重新认证(不报错、就是慢) → 三个可能:ControlPersist 太短或为 0、master 被上一条 ssh -O exit 关掉了、或者模板里用了 %n 这类按别名变化的记号导致每个别名一个 socket。ssh -O check 一条命令就能分辨「没有 master」和「master 不匹配」。
  • 重装服务端后连不上,报红字 → ssh-keygen -R "[地址]:端口"(端口非 22 必须带方括号并加引号)。
  • 脚本里加了 BatchMode yes 之后连新机器就失败 → 正是 ask 无法应答;改用 accept-new。
  • 配置文件里某一行完全没作用、也没报错 → 十有八九是同名关键字已在前面出现过(先到先得);用 -v 看 Applying options for 判断哪段真正被匹配。
  • 改完配置不生效 → 检查是不是改在了 Include 之前的片段里、而该关键字已被 Host * 占用;以及确认你改的是 -F 指定的那个文件而不是 ~/.ssh/config。
  • 改完配置之后所有主机突然全连不上,报错也不指向配置文件 → 多半是某一行语法错误让 OpenSSH 放弃了整份文件;先 ssh -G 某个别名 >/dev/null 看退出码(见上文「把 ssh -G 当配置文件的语法检查器」一节)。

一份可以直接抄的骨架

按「具体 → 通配 → 兜底」的顺序排列,全局默认放在最后一个片段文件里。

# ~/.ssh/config
# 1) 具体主机放在最前面
Host gw1 gw2
    HostName 203.0.113.10
    User ops
    IdentityFile ~/.ssh/keys/ops_ed25519
    IdentitiesOnly yes

Host db-prod
    HostName 10.0.0.21
    User postgres
    IdentityFile ~/.ssh/keys/db_ed25519
    IdentitiesOnly yes
    Compression no            # 局域网不需要

# 2) 按域名/项目组划分的中间层
Host *.corp.example.com
    User corp-%u              # %u = 本地用户名,比 ${USER} 稳(不受环境变量被清空影响)
    StrictHostKeyChecking accept-new

# 3) 最后再引入片段(把 Host * 类兜底放在片段末尾的 90-*)
Include ~/.ssh/conf.d/*

~/.ssh/conf.d/90-defaults.conf:

Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ConnectTimeout 10
    ControlMaster auto
    ControlPath ~/.ssh/cm-%C
    ControlPersist 5m
    VisualHostKey yes

~/.ssh/conf.d/10-git.conf:

Host github.com gitlab.com
    User git
    IdentityFile ~/.ssh/keys/git_ed25519
    IdentitiesOnly yes

补三条:cm-%C 需要 ~/.ssh 可写(本来就要写 known_hosts,所以没问题);systemd 机器上更稳妥的是 ControlPath /run/user/%i/ssh-%C(%i 是本地 uid,别写成 %U——那在 ssh_config 里不是合法记号,会直接 fatal);VisualHostKey yes 会在每次连接前打印主机指纹的图形化摘要,对人是很好的一道防中间人提示,对脚本无影响。用 scp/sftp 时这些参数一样适用——它们走的同一套解析逻辑,具体传输用法见 scp 与 sftp 远程文件传输。

小结

~/.ssh/config 的心法只有两条:「关键字逐个生效、取第一个值」,以及「文件顺序就是语义」。把这两条内化之后,Host * 该放哪、片段为什么要用序号前缀、为什么换别名还是复用同一个 master,全都是自然推论,不需要背。

排错路径也顺理成章:ssh -G 看「我以为的配置是什么」(不联网),ssh -v 看「实际发生了什么」(前 6 类日志足够),ssh -O check 看「有没有 master 活着」,ssh-keygen -R 处理「known_hosts 里的旧指纹」。

至于性能,ControlMaster auto + ControlPath + ControlPersist 这三行是整个客户端配置里回报最高的一段——实测把 0.6 秒左右的重复连接压到 0.09 秒,而代价只是本地一个 socket 文件和一个后台 ssh 进程。

相关阅读:

发表评论

暂无评论,快来抢沙发吧!