
当服务器从 3 台变成 30 台,ssh 这件事就开始变得难受了。IP 记不住,密钥和密码混着来,有的机器登进去还得手动 sudo -i,换台电脑连密码都得重配。我一直想要一个足够简单的命令:记住一个短别名、甚至什么都不用记,敲一下就进去。
于是有了 s——一个 bash 脚本,把日常 SSH 运维压缩成一个字母开头的命令。这篇文章记录它的设计取舍、完整功能,以及几个写它时踩到的坑。
一分钟感受
直接敲一个 s,弹出一个交互菜单:所有机器按分组列好,打字就地模糊搜索,回车即连。

分组标题用一条横线拉到定宽、机器缩进挂在下面,层次一眼看清;别名按登录方式着色——青色是密钥登录,琥珀色是密码登录;打字时别名、user@host、描述、分组名全都能搜,搜 iai 就只剩那一组,搜某段 IP 直接定位到那台。
记得住别名的话,更快:
s hk-01 # 直接连,密钥/密码自动判断
s ping # 全部主机一眼看在线/离线
s run 'db-*' 'df -h /' # 一批机器上跑同一条命令
s export ~/s-backup.enc # 打成加密备份,换电脑一条 s import 全恢复一个核心决定:不发明新格式,就用 ~/.ssh/config
市面上的 SSH 管理器大多有自己的一套配置文件/数据库。我不想要——那意味着 scp、rsync、VS Code Remote 这些工具都得再教一遍。
s 的所有主机就是标准的 ~/.ssh/config 条目:
Host hk-01
HostName 1.2.3.4
User root
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes好处是白拿的:
s hk-01能连,原生ssh hk-01也能连;scp file hk-01:/tmp/、rsync ... hk-01:...全部自动认识这个别名;- 别的工具、别的机器只要有这份 config 就通用,
s只是个更顺手的入口,不是一道锁。
那些 s 自己需要、而 SSH 又不认识的元数据(分组、描述、登录方式、是否自动切 root),我用注释行存在同一个 Host 块里:
Host win-box
#s-group 跳板
#s-desc 跳板机
#s-auth password
#s-root yes
HostName 9.9.9.9
User adminssh 会忽略所有 # 开头的行,所以这些标记对原生 ssh 完全透明,而 s 用 awk 把它们读出来。一份配置,两套解读,互不打架:
~/.ssh/config
┌───────────────────────┐
│ Host win-box │
#s-* 注释 │ #s-group 跳板 │ ← ssh 视而不见
只有 s 读 │ #s-auth password │ (# 开头一律忽略)
│ #s-root yes │
│ HostName 9.9.9.9 │ ← ssh 和 s 都读
标准字段 │ User admin │
└───────────┬───────────┘
│
┌───────────┴───────────┐
▼ ▼
原生 ssh / scp s(多读几行注释,
rsync / VS Code 知道要分组、走密码、切 root)
(只认标准字段)列表、分组、批量改字段
裸敲 s 进的是交互菜单;想要一份纯文本清单,s ls 按分组列出全部:

机器一多,平铺一长条看着乱,所以有了分组。分组信息就是前面那行 #s-group <组名>,s group <别名> <组名> 设置。但十几台一台台敲太蠢,于是让它跟 s ping / s run 一样支持通配符和逗号选择器批量操作:

s set 'lbc-*' group LBC 一条命令就把所有 lbc- 开头的机器归到一组。顺带一提,s set 现在也接管了 group / desc 字段,改 IP、改描述、改分组都能走它,还都支持通配符批量:
s set hk-01 host 1.2.3.9 # 改 IP(云主机重启后公网 IP 变了很常见)
s set 'lbc-*' port 2222 # 一批机器一起改端口
s desc 'it-*' "iai 集群" # 描述也能批量密钥与密码:密码不落明文
加机器就一条命令,密钥、密码都支持:
s add hk-01 root@1.2.3.4 -i ~/.ssh/id_ed25519 -d "香港 主力节点" -g 香港
s add win-box admin@9.9.9.9 --pass -d "跳板机" -g 跳板 # --pass 会提示输入密码密钥登录没什么好说的,交给 ssh 就行。密码登录麻烦在两点:一是明文存哪,二是怎么喂给 ssh。
~/.ssh/config 里只留一行 #s-auth password 做标记,真正的密码用 security add-generic-password 写入钥匙串;连接时再 security find-generic-password 取出,通过 sshpass 喂给 ssh。配置文件即使被人翻到,也拿不到密码。连接时 s 看到 #s-auth password 就自动走 sshpass,你完全无感:
s win-box # 自动取出密码登录,你什么都不用输改密码、把密码机升级成免密,也各有一条命令:
s passwd win-box # 只更新 Keychain 里的密码
s cp win-box # 把本机公钥拷过去(ssh-copy-id),之后就能免密了登录后自动切 root
有些机器登进去是普通用户,第一件事永远是 sudo -i。把它交给 s:
s root win-box on # 开启:以后 s win-box 连上就自动 sudo -i对密码机还有个细节:sudo 通常要再输一次密码,而这个密码一般和登录密码是同一个。所以 s 会把 Keychain 里那把密码顺手喂给 sudo——真正做到一条命令直达 root shell,中间一次都不用输密码。喂失败(比如两个密码确实不一样)就自动回退成手动输入,不会卡住。
s win-box
# → 自动填 SSH 密码 → 自动用同一密码过 sudo → 直接落在 root@win-box换电脑不怕:加密备份与迁移
这是我最想要的一个功能,也是「换电脑就没了」的正解。
s 的数据其实分两处:主机定义在 ~/.ssh/config(明文,好拷),但密码机的密码在 macOS Keychain 里,跟机器绑定,不随文件走——光把 config 拷到新电脑,所有密码机都连不上。
s export 把两处一起打包:所有主机块 + Keychain 里对应的密码,用你设的口令走 openssl aes-256-cbc + pbkdf2 加密成一个 .enc 文件,全程不落明文。新电脑 s import 输口令解密,主机块 merge 进 config、密码写回 Keychain,已存在的别名自动跳过、不覆盖。

.enc 文件 + 口令 = 你全部服务器的凭据。别丢公共网盘,口令要记牢——解不开是没有找回的。顺手放一张在自己终端里实际跑起来的样子(配着壁纸,IP 列打了码):

传输、连通性、批量执行、转发
日常琐事都收敛成子命令,全部命令长这样:

挑几个每天在用的:
文件传输——put/get 会自动从参数里认出 别名:路径 那部分,判断走原生 scp 还是 sshpass scp,你不用关心目标机是密钥还是密码登录:
s put ./app.tar.gz db-1:/root/ # 上传
s get db-1:/var/log/app.log ./ # 下载连通性检查——s ping 逐台 TCP 探测,标注在线/离线,支持通配符只查一部分:
s ping # 全部
s ping 'db-*' win-box # 只查一组批量执行——一条命令扫一批机器,逐台执行、带标题分隔、末尾汇总成功/失败数,某台挂了不影响其它:
s run 'k8s-*' 'kubectl get nodes'
s run 'db-1,win-box' 'df -h /'端口转发——把远端只监听在本机的服务(数据库、内部 API 等)拉到本地直连:
s fwd db-1 8123 # 简写:本地:8123 → 远端 localhost:8123
s fwd jump 5432:10.0.0.100:5432 # 借 jump 当跳板,连它内网里的库5432:10.0.0.100:5432 中间那个地址是从服务器角度解析的,不是你本机。写 localhost 指的是「SSH 连上的那台服务器自己」,写内网 IP 则是让那台服务器当跳板去连它内网里的第三台机器——后者能帮你够到公网根本访问不到的机器。Tab 补全
作为一个每天要敲很多次的命令,补全是刚需。给 zsh 写了个补全函数丢进 fpath:
s <Tab>列出所有别名 + 子命令;s hk<Tab>补成hk-01;s rm <Tab>/s group <Tab>/s ping <Tab>补别名。
候选是实时从 ~/.ssh/config 读的——所以 s add 新加的机器会自动出现在补全里,不用维护第二份清单。
几个踩到的坑
写这种「看起来很简单」的小工具,坑往往在细节里。挑几个有代表性的。
1. 中文变量名后面跟全角括号,会报 unbound variable。 开了 set -u 后,"未找到别名:$alias(...)" 这一句直接崩:
line 147: alias�: unbound variable原因是 bash 把 $alias 后面紧跟的全角括号 ( 的字节吞进了变量名,去找一个不存在的变量。解法是老老实实用花括号界定:${alias}。
2. 中文对齐不能用 printf %-Ns。 printf 按字符个数补空格,但中文在终端里占两格,于是带中文的列全歪了。得按显示宽度补齐。有个不用起子进程的小技巧——对「纯 ASCII + 三字节中文」的字符串,显示宽度正好等于 (字符数 + 字节数) / 2:
dwidth() { local s="$1" c b; c=${#s}; local LC_ALL=C; b=${#s}; echo $(( (c + b) / 2 )); }${#s} 在 UTF-8 locale 下数的是字符数,临时切到 LC_ALL=C 再数一次就是字节数,两者一平均就得到显示宽度。
3. 密码对了却 Permission denied。 强制 PreferredAuthentications=password 时,某些带 PAM 的服务器其实走的是 keyboard-interactive,于是密码正确也被拒。把两种都允许即可:password,keyboard-interactive。
4. set -e 会让批量执行第一台失败就整体退出。 因为 set -e 遇到非零退出就中止。解法是把 ssh 放进 if 条件里——条件语境下的命令失败不触发 set -e:
if ssh ... "$alias" "$cmd"; then rc=0; else rc=$?; fi同一个坑在写交互菜单时又踩了一次:管道左侧的 while 循环,末句如果是个求值为假的判断,整个子 shell 退出码就是非零,配合 set -o pipefail 会把外层函数一起带崩。把那种收尾判断改成 if ... fi 就好了。
5. 密码机交互登录,连上却「秒退、无输出、退出码 0」。 这个最迷惑。原因是 sshpass 介入后,ssh 的 stdin 不再被识别成终端,于是它不向远端申请伪终端;远端 shell 一起来就读到 EOF,立刻正常退出——所以退出码是 0,什么都没留下。解法是交互登录时显式 -t 强制分配伪终端:
sshpass -e ssh -t "$alias" # 少了这个 -t,交互 shell 会瞬间关掉6. 密码机第一次连,sshpass 直接以退出码 6 失败。 因为这台的主机公钥还不在 known_hosts 里,ssh 想弹「是否信任该主机」的确认,而 sshpass 没法回答这个交互提示,只好放弃。解法是加 StrictHostKeyChecking=accept-new:首次自动接受新主机密钥,但主机密钥若变更仍然拒绝——安全性上是个合理折中。
小结
s 没做什么高深的事,它只是把「一堆 SSH 服务器」这件事里所有重复动作——记 IP、切密钥密码、切 root、传文件、查状态、批量跑命令、分组、备份迁移——各压成一条短命令,同时坚持一个原则:底层永远是标准 ~/.ssh/config,绝不发明新格式,绝不把自己变成一道锁。
如果你也管着一堆机器,不妨拿去改。代码已经放到 GitHub,clone 下来 ./install.sh 就能用。
还没有评论
欢迎留下你的观点,保持交流的清晰和友好。