1. 项目源头:为什么不认识的IP会被SSH拦住
做运维和开发这些年,SSH连接遇到最多的一个问题就是“新IP的 SSH 指纹添加到 known_hosts 文件”。尤其是刚接手一批新服务器、或者公司内部网络换了一波IP的时候,终端里永远是一屏警告。第一次连接一台新主机,SSH客户端会提示:
code复制The authenticity of host '192.168.1.20 (192.168.1.20)' can't be established.
ED25519 key fingerprint is SHA256:jKx+VnZgtQ7xxuOP+G1b9K0vM9K/xxx.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
很多人直接敲一个yes就过去了,后面也就正常用。但如果你面对的不是一台,而是三十台、五十台新IP,每一台都要手动确认一遍,这活儿就变得极其机械。更麻烦的是,虚拟机、容器、云主机这类场景经常是脚本批量创建的,今天IP是这个,明天重建就换了,于是known_hosts里残留的旧指纹和新的指纹打架,终端直接给你一句“Host key verification failed”。
这个警告背后其实是一整套信任机制。SSH在建立连接时,除了验证用户名密码或者密钥,还要验证“对方确实是那台机器”。新IP意味着本地还没有它的指纹记录,所以SSH需要你来做一次信任确认。这篇文章我会把从“不认识”到“写入known_hosts”的整个流程拆开讲:known_hosts文件里到底存了什么、第一次连接时发生了什么、为什么有时换个IP也会被卡住、有哪些办法能把指纹提前写进文件、批量场景下怎么处理最省事,以及指纹校验的安全边界在哪里。不管你是刚入门的小白,还是已经在生产环境里摸爬滚打过几年的运维,这套内容应该都能直接用上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 指纹机制拆解:known_hosts 文件里到底存了什么
2.1 主机密钥是服务器的“身份证”
每个SSH服务端在安装或者首次启动时,都会生成一组主机密钥,默认存放在 /etc/ssh/ 下面。不同版本、不同发行版的密钥文件命名略有差别,常见的包括:
- ssh_host_rsa_key / ssh_host_rsa_key.pub
- ssh_host_ecdsa_key / ssh_host_ecdsa_key.pub
- ssh_host_ed25519_key / ssh_host_ed25519_key.pub
这些密钥是这台机器的“身份证”。客户端连接时,服务端会把自己的主机公钥发给客户端。公钥是一长串base64编码的字符串,直接展示根本没法用人眼比对,所以SSH协议会把公钥再做一次哈希,生成一个短指纹,默认是SHA256格式,形如:
code复制SHA256:9nWnQDVhKakB5dN7eLvWxwFD8uBkCVWGl2XmY6VZPQQ
客户端拿到这个指纹后,和known_hosts文件里记录的内容做比对,一致就继续,不一致就终止并报警。这个机制和HTTPS的证书校验有相似之处,都是要解决“我连的到底是不是我想连的那台机器”的问题。
有个点要特别说明:主机密钥和管理员密码是两回事。你改密码、加用户、重启服务,都不会影响主机密钥;但如果你重装系统、用虚拟机模板复制机器、或者重建容器,主机密钥基本都会变,这就直接导致后面要说的指纹不匹配问题。所以,一台机器只要身份变了,再有权限的运维也没法让老客户端的known_hosts自动“原谅它”,必须主动更新指纹记录。
2.2 known_hosts 文件的行格式和路径
known_hosts的默认位置是当前用户的 ~/.ssh/known_hosts,系统级位置是 /etc/ssh/ssh_known_hosts。每个用户维护一份自己的known_hosts,root维护一份root的,普通用户维护一份普通用户的,彼此不互通。具体每一行记录大致长这样:
bash复制192.168.1.20 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGq3fN...
如果你开启了HashKnownHosts(很多发行版默认开启),这一行的开头会变成一个哈希串,形如:
bash复制|1|abc123...=|xyz456...= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGq3fN...
这段哈希就是主机名/IP经过SHA1加盐处理之后的结果,好处是known_hosts文件不会直接暴露你常连的服务器地址,坏处是没法通过文本搜索反查某条记录对应哪台机器。
一行记录一共三个字段:主机名标记、密钥类型、base64编码的公钥内容。密钥类型常见的包括 ssh-rsa、ecdsa-sha2-nistp256、ssh-ed25519。在known_hosts中,同一个IP可以有多行,分别对应不同类型的密钥,因为服务端可能同时支持多种算法,客户端会根据协商结果选择其中一种来校验。所以用ssh-keyscan扫一台机器,默认会输出好几行,每行密钥类型不一样,这很正常。
还需要注意一个细节:如果连接的端口不是22,known_hosts里的主机名标记需要写成带方括号的形式,例如:
bash复制[192.168.1.20]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGq3fN...
这个格式在手写known_hosts时特别容易踩坑,写成 192.168.1.20:2222 是匹配不上的。原因在于SSH协议在解析known_hosts时,遇到带冒号的字符串会按照形如 [host]:port 的语法去解析,如果不带方括号,就会把整个 192.168.1.20:2222 当作普通主机名去匹配,自然对不上。
2.3 连接新主机时,SSH客户端内部经历了什么
简单梳理一下,SSH客户端连接一台新主机的流程大致是五步:
- 客户端发起TCP连接;
- 服务端返回自己的主机公钥以及支持的算法列表;
- 客户端在known_hosts文件里搜索匹配的记录;
- 如果没有匹配记录,进入首次连接流程,交互式提示用户确认;
- 如果有匹配记录,比对公钥内容;一致放行,不一致直接报 Host key verification failed 并终止连接。
拿生活中的场景类比:你第一次去一家公司拜访,前台会问你找谁、留下联系方式,相当于首次连接的确认;以后再去,前台一看认识你,就直接放行,相当于known_hosts里已经有记录;但如果某天你自己说你是张三,前台核查发现系统里的张三和你长得完全不一样,那就一定不会让你进去,甚至怀疑有人冒充,这就是指纹不匹配时的处理逻辑。
明白了这个流程,后面所有的操作就都有了解释:往known_hosts里添加指纹,本质就是提前告诉客户端“这台机器是可信的,以后不用再问我”;清理某条指纹,本质是把这份信任撤销,恢复到不认识的状态;批量添加指纹,本质是一次性在一堆机器上预设信任关系。
3. 手工添加新IP指纹的几种做法
3.1 最省事的交互式确认:为什么yes不能省
对于一两台新机器,最直接的做法就是连接时根据提示键入yes。键入yes后,SSH客户端会在known_hosts文件里追加一条记录,后续连接就不会再问了。整个过程不需要任何额外工具,也不需要了解指纹内容,对新手来说是最友好的。
但有几个细节值得注意。第一,这里的yes必须是完整输入,不能简写成y。很多人在脚本里遇到过这个问题,以为输入y就是同意,实际上SSH官方解析逻辑要求完整输入yes,或者直接粘贴 fingerprint 字段的内容,输入y会被当成无效输入。第二,确认时提示里给出的 fingerprint 是客户端自己计算出来的,如果你担心网络链路被劫持,应该通过电话、内部IM、运维工单等独立渠道和服务端管理员核对这个指纹值,确认无误后再键入yes。第三,交互式确认只解决“当前这台机器”的信任问题,如果服务端有多个公钥类型(比如同时有rsa和ed25519),客户端通常只记录它当前协商用的那一个,其他类型的密钥不会被记录下来,这也是后面讨论ssh-keyscan时的一个对比点。
实际工作中,我见过不少人在开发环境里直接把这步跳过去了,后面遇到Host key verification failed才发现。我的建议是,开发环境的机器你随便yes都没太大关系,但生产环境、有敏感数据的机器,务必走正规核对流程。
3.2 ssh-keyscan 采集:不连接也能拿到指纹
如果你不想交互式确认,还想在一开始就把指纹写进known_hosts,可以用ssh-keyscan。这个命令不会真的建立SSH会话,它只做两件事:连上目标端口,抓取对方SSH服务端的主机公钥,然后退出。基本用法:
bash复制ssh-keyscan 192.168.1.20
执行后输出类似这样:
bash复制# 192.168.1.20:22 SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5
192.168.1.20 ssh-rsa AAAAB3NzaC1yc2EAAA...
192.168.1.20 ecdsa-sha2-nistp256 AAAAE2VjZH...
192.168.1.20 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
前两行带井号的是注释,记录的是版本信息,可以直接忽略,后面才是真正要写入known_hosts的记录。把输出追加到known_hosts即可:
bash复制ssh-keyscan 192.168.1.20 >> ~/.ssh/known_hosts
如果目标端口不是22,用-p指定:
bash复制ssh-keyscan -p 2222 192.168.1.20 >> ~/.ssh/known_hosts
ssh-keyscan还支持 -t 参数限制密钥类型。默认会抓取rsa、ecdsa、ed25519等所有类型,如果你只想记录ed25519,可以这样:
bash复制ssh-keyscan -t ed25519 192.168.1.20 >> ~/.ssh/known_hosts
我在实际使用中更推荐只记录ed25519。一是因为ed25519是目前推荐度更高的算法,密钥短、性能好;二是因为known_hosts里记录类型越少,以后排查指纹不匹配时干扰就越少。rsa那条记录万一哪天出问题,而文件里既有rsa又有ed25519,客户端实际上会优先匹配它能用的算法,误判的可能性会增加。
3.3 从信任主机同步known_hosts:堡垒机场景
第三种方式是从一台已经被信任的主机上把known_hosts条目复制过来。这种场景在公司内网很常见:你有一台跳板机或者堡垒机,大家都要经过它去访问后端服务器,后端的IP和密钥是固定的。你在一台机器上连接过一次后端,known_hosts里就会有记录,这时可以直接把对应行复制到出问题的那台机器上。
具体操作很简单,在源机器上先找到那台主机的记录:
bash复制grep 192.168.1.20 ~/.ssh/known_hosts
把这一行追加到目标机器的 ~/.ssh/known_hosts 即可。如果源机器开启了HashKnownHosts,你看到的可能是哈希后的记录,没法直接通过IP搜索。这时候可以用ssh-keygen的-F参数来查询:
bash复制ssh-keygen -F 192.168.1.20
-F会通过哈希算法在known_hosts里反向匹配,即使文件里存的是哈希形式,也能把目标主机的记录完整打印出来,再手工复制到另一台机器。
这个方案的适用条件比较严格:两台机器必须信任同一个IP上的同一套主机密钥,否则复制过去也是白搭。但它在堡垒机统一管理场景下非常好用,因为堡垒机的known_hosts往往是经过审核的。
3.4 临时绕过:StrictHostKeyChecking 参数
很多自动化脚本和临时排查场景里,会用到这个参数,作用是关闭首次连接的校验:
- StrictHostKeyChecking=no 或 off:首次连接时不会提示确认,自动接受并写入known_hosts;
- StrictHostKeyChecking=yes:首次连接时必须确认,且指纹变化时直接拒绝;
- StrictHostKeyChecking=accept-new:首次连接自动接受并写入,但指纹变化时会拒绝。
三种模式用一句话区分:yes是最严格的,no是最宽松的,accept-new是折中。实际命令可以这样写:
bash复制ssh -o StrictHostKeyChecking=accept-new user@192.168.1.20
还可以配合把known_hosts指定到临时文件,避免污染用户真正的known_hosts:
bash复制ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null user@192.168.1.20
注意:UserKnownHostsFile=/dev/null 的意思是“不记录任何主机指纹”,所以这次连接不会留下任何信任记录,下次连接依然会提示确认。这个写法适合临时测试,不适合作为日常连接方式。
批量脚本里最常见的反模式是这样:
bash复制ssh -o StrictHostKeyChecking=no user@192.168.1.20 'uptime'
这种做法虽然能跳过确认,但等于关闭了SSH安全机制里非常重要的一道防线。如果攻击者在网络层面做了手脚,你连到的可能是冒名顶替的机器,而你的客户端完全不会提醒。我的建议是:能不用就不用,实在要用也要限制在隔离环境或一次性容器里,生产环境最好别出现StrictHostKeyChecking=no。
4. 批量场景:一次处理几十台新IP的经验
4.1 用循环脚本批量采集指纹
新IP数量一多,手工一条条ssh-keyscan就不现实了。最简单的方法是准备一份IP列表,每行一个IP,然后用while循环批量扫描:
bash复制while read ip; do
ssh-keyscan -t ed25519 "$ip" 2>/dev/null >> ~/.ssh/known_hosts
done < ips.txt
这个脚本里有个小细节:ssh-keyscan在扫不通的IP时会打印连接失败信息到stderr,用 2>/dev/null 可以把这些噪音屏蔽掉,只保留正常输出。执行完后,还可以用sort去重,避免同一IP重复出现多次:
bash复制sort -u -o ~/.ssh/known_hosts ~/.ssh/known_hosts
如果IP列表里有些机器端口不同,可以在循环内用-p参数配合端口变量,或者把每一行写成 ip:port 的格式再解析,总之核心思路都一样:把“扫描-追加-去重”这三步串起来。
这里还有一个实际经验:批量扫描的时间取决于网络状况。几百台机器如果在一个机房里,通常几十秒就能扫完;如果跨机房、跨网络链路,ssh-keyscan默认超时时间可能要几十秒,容易拖得很久。可以用 -T 参数设置超时,比如 -T 3 表示单个目标最多等3秒,防止某台不通的机器把整个循环卡住。
4.2 动态主机名的处理:通配符与多IP
实际运维中,很多机器不只一个IP,或者同一个服务通过多个域名暴露。known_hosts是支持通配符的,*和?都可以用。比如你有一组机器都叫 node1.example.com、node2.example.com,可以手动写一行:
bash复制*.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
但这一招要非常谨慎。通配符意味着所有匹配的主机都会使用同一份指纹,如果其中一台机器密钥变了,客户端会误认为其他机器也不可信,反而带来更大的问题。所以我一般只在明确知道“这些机器共用一套密钥”的情况下才用通配符,比如负载均衡后面的一组完全克隆出来的节点,或者容器服务里基于同一镜像创建的多个实例。
对于多IP场景,known_hosts文件里可以同时保存多个主机名对应同一个公钥。比如公网IP、内网IP、域名分别写一行,指向同一个公钥内容,客户端无论从哪个地址连接都能通过校验。生成方式可以一次性把这个IP和域名都传递给ssh-keyscan:
bash复制ssh-keyscan -t ed25519 192.168.1.20 server.example.com
ssh-keyscan会为每个名字分别生成一行记录,公钥内容相同,主机名标记不同。这种方法在配置多个入口地址时很实用。
4.3 结合自动化工具:Ansible、Terraform里的常见做法
如果你的服务器是Terraform、CloudFormation这类基础设施即代码工具创建的,最好的做法不是在创建后手动扫指纹,而是在已知的、可信的环境里把指纹内容确定下来,然后通过模板直接写入known_hosts。比如Terraform中可以用模板文件渲染known_hosts,Ansible则可以用known_hosts模块来管理。
Ansible的known_hosts模块会逐条管理目标机器的known_hosts条目,典型写法:
yaml复制- name: 添加新IP的SSH指纹
ansible.builtin.known_hosts:
name: "192.168.1.20"
key: "{{ lookup('pipe', 'ssh-keyscan -t ed25519 192.168.1.20') }}"
state: present
直接通过lookup调用本地的ssh-keyscan,把结果写入受管机器的known_hosts。这样做的好处是幂等,重复执行不会产生重复条目。
在Terraform里,可以把指纹作为变量或map传入,再渲染到云主机的初始化脚本里,比如cloud-init中写入known_hosts。这种方式的优点是从机器创建那一刻开始,它就是“可信”的,不需要事后手工处理。
4.4 自动化场景里的纪律:别写死no
批量处理新IP时,最容易出现的坏味道就是图省事,在脚本里写死:
bash复制ssh -o StrictHostKeyChecking=no user@192.168.1.20 'uptime'
前面已经讲过,这会关掉中间人防护。就算环境再可控,我也建议用accept-new取代no:
bash复制ssh -o StrictHostKeyChecking=accept-new user@192.168.1.20 'uptime'
accept-new的行为是:首次连接自动接受,但指纹发生变化时依然拒绝。这保留了第一道防线,同时省掉了交互确认。自动化脚本里能写成accept-new,就不要写成no。
如果是批量执行任务,还可以先把所有主机的指纹收集好,再正常执行任务。也就是“先扫描指纹,后跑业务命令”的两阶段法,这样业务命令本身不需要带任何绕过参数。这个思路在4.1的循环脚本里已经体现了:先统一ssh-keyscan,再正常ssh执行操作。
5. 安全边界与排查实录
5.1 为什么不能盲目地yes
SSH首次连接提示的目的,是让你确认“这台服务器是你要连的那台”。可是很多人从来不看指纹,直接yes。这个习惯在局域网或者自己的服务器上问题不大,但在不受控的网络环境里,风险很高。
攻击者如果能在网络链路上做手脚,比如伪造IP、劫持DNS、在无线路由器上做ARP欺骗,就可能在你还未连接真正服务器之前,用自己伪造的SSH服务端先应答你的连接请求。此时客户端第一次连接,known_hosts里没有记录,会提示你是否信任一个陌生的SSH指纹。如果你直接yes,等于把中间人认证为合法主机,后续你输入的任何用户名、密码、私钥,全都会被假冒服务器截获。
所以安全上有一条不成文的原则:首次连接时的信任决策,必须发生在带外。也就是说,不要只依赖当前这台客户端发出的提示,而是要通过另一个渠道确认指纹。比如SSH到服务器本机,执行:
bash复制ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
得到 SHA256:... 的指纹值,然后和客户端提示中的指纹比对。两人核对时,最好用电话或者别的独立通信方式说这个值,不要在同一条可能被劫持的链路上发。这个习惯成本很低,但在接管新服务器、或者被攻击后恢复服务器身份时,价值非常大。
5.2 指纹变了怎么办:ssh-keygen -R 清理
最让人头疼的提示之一是这个:
code复制@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
...
Host key verification failed.
一旦出现这个提示,说明known_hosts里已有该IP的记录,但当前拿到的指纹和记录不一致。这时要冷静分析:如果是你自己刚重装了系统、重建了虚拟机,或者服务端主动更换了主机密钥,那属于正常变更,直接清理旧记录即可:
bash复制ssh-keygen -R 192.168.1.20
-R会删除known_hosts里与该IP相关的所有条目,之后重新连接会回到首次连接流程,你再用前面介绍的方式重新添加新指纹。如果是ssh-keyscan批量加入时把手误的主机公钥写进去了,也可以用同样的方式清理。
如果IP和旧记录匹配,但密钥变了,而你完全没有任何服务器变更记录,那就要警惕了。先去服务端本机确认主机密钥有没有变:比较ssh-keygen -lf命令的输出和known_hosts里的旧记录,确认是完全不同,还是只有某行算法类型有差异。排查思路是先把风险降下来,再决定是否信任新指纹。
我这里还有一个实际操作习惯:在清理旧记录之前,先备份known_hosts。命令是:
bash复制cp ~/.ssh/known_hosts ~/.ssh/known_hosts.bak
不要小看这一步。有时候改完发现加错了,或者清了不该清的记录,备份可以让你立刻恢复原状,省去重新扫描几十台机器的时间。
5.3 常见问题速查表
我整理了几个高频问题,都是运维和开发日常经常遇到的:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 首次连接提示确认,输入yes后正常 | 正常流程,known_hosts无记录 | 直接输入完整yes |
| Host key verification failed | known_hosts中记录的服务端指纹与当前不一致 | ssh-keygen -R 清理该IP后重新连接 |
| REMOTE HOST IDENTIFICATION HAS CHANGED | 服务端主机密钥变更或存在冒充 | 先确认服务端是否重装/重建,再决定是否清理 |
| ssh-keyscan 无输出 | 目标端口未开启SSH、防火墙拦截或 -t 类型不受支持 | 使用nc/curl确认端口通,换其他算法类型 |
| 连接成功但known_hosts一直不新增记录 | UserKnownHostsFile指向了/dev/null或只读位置 | 检查ssh config和命令行参数 |
| known_hosts里条目存在但ssh连接仍提示确认 | 主机名或端口格式不对,比如端口写法少了方括号 | 确保使用 [ip]:port 格式 |
| 批量扫描后known_hosts重复条目越来越多 | 重复追加未去重 | sort -u 去重或使用工具幂等管理 |
这个表适合打印出来贴在工位上。前三条最常见,后面的几类问题多出现在自定义端口、多算法支持、自动化脚本等场景中。
5.4 在受控环境里自动化核对指纹
如果服务器数量多,手动逐台核对又不现实,可以采用“带外录入、客户端校验”的思路。比如由负责批量交付的同事在服务器初始化时统一生成一个信任链:先通过带外渠道把IP、主机类型、指纹整理成表格,再写脚本批量调用ssh-keygen -F或ssh-keyscan和表格对比,不一致的直接报警。
按我的经验,这个流程的脚本并不复杂,核心就两步。第一步,对所有目标IP跑一次ssh-keyscan,把输出转换sha256指纹;第二步,和台账表格里的指纹比对。差异大就说明交付环节出问题了。
这种方式尤其适合私有化交付、机房搬迁、容器平台批量扩容这类任务。指纹台账本身就是一种资产记录,任何一次批量连接异常,都可以直接对比台账判断是合法变更还是安全隐患。
最后再分享一个我自己的习惯:每次批量处理完服务器指纹后,我都会顺手把known_hosts文件做一次归档,并且把指纹台账单独存一份。这不是形式主义,而是因为SSH主机指纹一旦变更,如果没有任何记录可对照,排查的时候只能靠猜。尤其是几百台机器的环境,一条IP对应多个算法类型的记录,时间久了之后,没台账寸步难行。
我这几年踩过的最大的坑,就是在大批量自动化脚本里滥用StrictHostKeyChecking=no。当时看着确实方便,后来有一次机房网络异常,连接被中间设备引导到一台伪造SSH服务端上,幸好那次只是跑了一个只读命令,没造成实际损失,但从那以后我所有的脚本都改成了accept-new,并且把“扫描指纹”和“执行任务”拆成了两个明确的阶段。技术上的便利和安全性之间的平衡,往往就是在一次次教训里建立起来的。希望这篇文章能让你少走一次弯路。
