命令行自动登录 JumpServer:SSH、TOTP 与密码提示的自动化
JumpServer 的命令行登录通常不只是一次 ssh:先走 SSH 公钥或密码认证,再输入 MFA 动态验证码,某些环境还会额外进入 password prompt。每次手动复制验证码既慢,也容易在多个跳板机之间输错。
我的处理方式是把流程拆成两层:
zsh负责准备凭据:检查依赖、解锁密码库、读取 TOTP seed、必要时读取 SSH password。expect负责终端交互:启动ssh,遇到 password prompt 就发送密码,遇到 MFA Code prompt 就生成并发送当前 TOTP,最后把终端控制权交还给用户。
这样做的好处是:敏感信息不写进脚本,交互逻辑也不会和密码管理器逻辑混在一起。
前置条件
这套方案依赖以下工具:
brew install expect oath-toolkit jq还需要安装并登录 Bitwarden CLI:
bw login如果使用 Bitwarden SSH Agent,还要确保 SSH 配置里显式指定 agent 和 key,避免 agent 里多把 key 被逐个尝试,提前触发服务端的 MaxAuthTries。
Host jumpserver-idc User <ssh-user> HostName <jumpserver-host> Port <ssh-port> IdentityAgent <bitwarden-ssh-agent-sock> IdentityFile ~/.ssh/<jumpserver-key>.pub IdentitiesOnly yes注意:这里的 <jumpserver-host>、<ssh-user>、<jumpserver-key> 都应该替换成你自己的环境值,不要把真实地址和账号提交到公开仓库。
命令入口
最终希望日常只执行一个函数:
ssh_jumpserver_idc() { _ssh_jumpserver <ssh-host-alias> <bitwarden-item-name> --bw-password}这个入口有三个参数含义:
<ssh-host-alias>:本机~/.ssh/config里的 Host 别名。<bitwarden-item-name>:Bitwarden 中保存 JumpServer 登录信息的 item 名。-bw-password:表示 SSH password 也从 Bitwarden item 中读取;如果目标环境只需要公钥和 MFA,可以不传。
zsh 负责读取凭据
核心函数先检查依赖,再处理 Bitwarden 状态:
_ssh_jumpserver() { local target="$1" local totp_item="$2" local password_source="${3:-}" local item_json totp_secret target_passwd
if [[ -z "$target" || -z "$totp_item" ]]; then echo "usage: _ssh_jumpserver <ssh-target> <bitwarden-item> [--bw-password|password]" return 2 fi
for cmd in expect oathtool bw jq; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "$cmd not found" return 127 fi done
case "$(bw status 2>/dev/null)" in *'"status":"unlocked"'*) ;; *'"status":"locked"'*) bw_unlock || return $? ;; *'"status":"unauthenticated"'*) echo "Bitwarden CLI is not logged in. Run bw login first." return 1 ;; *) echo "Unexpected Bitwarden status. Run bw status to inspect it." return 1 ;; esac
bw sync --force >/dev/null 2>&1 || :
item_json="$(_bw_get_exact_item "$totp_item")" || return $? totp_secret="$(_bw_totp_secret_from_json "$totp_item" "$item_json")" || return $?
if [[ "$password_source" == "--bw-password" ]]; then target_passwd="$(_bw_password_from_json "$totp_item" "$item_json")" || return $? else target_passwd="$password_source" fi
_ssh_jumpserver_expect "$target" "$totp_secret" "$target_passwd"}这里有几个关键点:
bw sync --force:避免桌面端刚更新了密码,但 CLI 还读到旧缓存。_bw_get_exact_item:校验返回的 item name 必须完全等于请求值,避免 Bitwarden 的模糊匹配拿错条目。_bw_totp_secret_from_json:读取.login.totp,同时兼容otpauth://...?secret=...这种格式。_bw_password_from_json:优先读.login.password,也可以兼容自定义字段。
避免 Bitwarden 模糊匹配
bw get item <name> 在某些情况下可能返回相近名称的条目。对 JumpServer 来说,这很危险:拿错 TOTP seed 会生成错误验证码,而且错误不一定直观。
因此我会加一层精确校验:
_bw_get_exact_item() { local item="$1" local item_json item_name
item_json="$(bw get item "$item")" || return $? item_name="$(jq -r '.name // empty' <<<"$item_json")" || return $?
if [[ "$item_name" != "$item" ]]; then echo "Bitwarden item mismatch: requested '$item', got '$item_name'" >&2 return 1 fi
print -r -- "$item_json"}从 Bitwarden 解析 TOTP seed
Bitwarden 的 TOTP 字段可能是纯 base32 secret,也可能是 otpauth:// URL。下面的 helper 会统一转成 oathtool 可用的 base32 seed:
_bw_totp_secret_from_json() { local item="$1" local item_json="$2" local totp secret
totp="$(jq -r '.login.totp // empty' <<<"$item_json")" || return $? if [[ -z "$totp" || "$totp" == "null" ]]; then echo "Bitwarden item has no login.totp: $item" >&2 return 1 fi
if [[ "$totp" == otpauth://* ]]; then secret="${totp#*secret=}" secret="${secret%%&*}" secret="$(printf '%b' "${secret//\\%/\\\\x}")" else secret="$totp" fi
print -r -- "${secret//[[:space:]]/}"}脚本里不要保存当前 6 位验证码。验证码是短时值,应该在 expect 收到 Code prompt 的那一刻再生成,避免过期。
expect 只处理 SSH 交互
expect 部分保持简单:只做终端自动输入,不直接访问 Bitwarden。
_ssh_jumpserver_expect() { local target="$1" local totp_secret="$2" local target_passwd="${3:-}" local expect_script expect_status
expect_script="$(mktemp "${TMPDIR:-/tmp}/ssh-jumpserver.XXXXXX")" || return $? command cat > "$expect_script" <<'EXPECT'set target [lindex $argv 0]set secret [lindex $argv 1]set target_passwd [lindex $argv 2]
set timeout 30spawn ssh $target
expect { "*password:*" { if {$target_passwd eq ""} { send_error "SSH password prompt received, but no password was provided\\n" exit 1 } send -- "$target_passwd\\r" exp_continue }
"*Code]:" { set kdauth [exec oathtool --totp --base32 $secret] send -- "$kdauth\\r" }
timeout { send_error "Timed out waiting for SSH jumpserver prompt\\n" exit 1 }}
interactEXPECT
expect -f "$expect_script" -- "$target" "$totp_secret" "$target_passwd" expect_status=$? rm -f "$expect_script" return $expect_status}这里不要用 expect -f - 直接喂 heredoc。那样 expect 的 stdin 会被脚本内容占用,后面的 interact 可能无法继续接管当前终端,表现为发送验证码后会话直接退出。
password 的处理
有些 JumpServer 环境会先走公钥,再进入 SSH password prompt。不要把 password 硬编码在 .zshrc 里,可以从 Bitwarden item 的 .login.password 或自定义字段读取:
_bw_password_from_json() { local item="$1" local item_json="$2" local password
password="$(jq -r ' [ .login.password, ( .fields[]? | select( (.name // "" | ascii_downcase) as $name | [ "password", "passwd", "ssh_password", "ssh-password", "target_password", "target-password", "jumpserver_password", "jumpserver-password" ] | index($name) ) | .value ) ] | map(select(. != null and . != "")) | first // empty ' <<<"$item_json")" || return $?
if [[ -z "$password" || "$password" == "null" ]]; then password="$(bw get password "$item" 2>/dev/null)" || password="" fi
if [[ -z "$password" || "$password" == "null" ]]; then echo "Bitwarden item has no supported password field: $item" >&2 return 1 fi
print -r -- "$password"}常见问题
too many authentication failures
如果 Bitwarden SSH Agent 里有多把 key,OpenSSH 默认可能逐个尝试。服务端在正确 key 被尝试之前就可能达到 MaxAuthTries,返回 too many authentication failures。
解决方法是在对应 Host 中配置:
IdentitiesOnly yesIdentityFile ~/.ssh/<jumpserver-key>.pubIdentityAgent <bitwarden-ssh-agent-sock>Code prompt 匹配不到
不同 JumpServer 版本的 MFA 提示文字可能不同。可以先手动执行一次 ssh <host-alias>,观察提示文本,再调整 expect 分支里的 pattern。
例如:
"*Code]:" { set kdauth [exec oathtool --totp --base32 $secret] send -- "$kdauth\\r"}如果你的提示是 Verification code:,就改成对应 pattern。
发送验证码后会话退出
优先检查是否使用了 expect -f - 加 heredoc。更稳的做法是写入临时 expect 脚本文件,再用 expect -f "$expect_script" 执行,最后 rm -f 清理。
安全边界
这套方案的原则是:
.zshrc只保存函数和占位式配置,不保存密码、TOTP seed、真实主机信息。- TOTP seed 和 password 只从密码管理器读取。
- expect 不直接调用密码管理器,减少交互层复杂度。
- SSH config 使用 Host 别名,文章、脚本示例和公开文档只展示别名和占位符。
- 调试日志不要上传包含 HostName、User、公钥指纹、验证码、密码库 item 名的原始输出。
最终使用
配置完成后,日常登录就变成:
ssh_jumpserver_idc命令会自动完成 Bitwarden 同步、TOTP seed 读取、可选 password 读取和 JumpServer MFA 输入。成功后会进入正常 SSH 会话,后续操作仍然由当前终端接管。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!













