命令行自动登录 JumpServer:SSH、TOTP 与密码提示的自动化

1623 字
8 分钟
命令行自动登录 JumpServer:SSH、TOTP 与密码提示的自动化

JumpServer 的命令行登录通常不只是一次 ssh:先走 SSH 公钥或密码认证,再输入 MFA 动态验证码,某些环境还会额外进入 password prompt。每次手动复制验证码既慢,也容易在多个跳板机之间输错。

我的处理方式是把流程拆成两层:

  • zsh 负责准备凭据:检查依赖、解锁密码库、读取 TOTP seed、必要时读取 SSH password。
  • expect 负责终端交互:启动 ssh,遇到 password prompt 就发送密码,遇到 MFA Code prompt 就生成并发送当前 TOTP,最后把终端控制权交还给用户。

这样做的好处是:敏感信息不写进脚本,交互逻辑也不会和密码管理器逻辑混在一起。

前置条件#

这套方案依赖以下工具:

Terminal window
brew install expect oath-toolkit jq

还需要安装并登录 Bitwarden CLI:

Terminal window
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> 都应该替换成你自己的环境值,不要把真实地址和账号提交到公开仓库。

命令入口#

最终希望日常只执行一个函数:

Terminal window
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 状态:

Terminal window
_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 会生成错误验证码,而且错误不一定直观。

因此我会加一层精确校验:

Terminal window
_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:

Terminal window
_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。

Terminal window
_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 30
spawn 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
}
}
interact
EXPECT
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 yes
IdentityFile ~/.ssh/<jumpserver-key>.pub
IdentityAgent <bitwarden-ssh-agent-sock>

Code prompt 匹配不到#

不同 JumpServer 版本的 MFA 提示文字可能不同。可以先手动执行一次 ssh <host-alias>,观察提示文本,再调整 expect 分支里的 pattern。

例如:

Terminal window
"*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 名的原始输出。

最终使用#

配置完成后,日常登录就变成:

Terminal window
ssh_jumpserver_idc

命令会自动完成 Bitwarden 同步、TOTP seed 读取、可选 password 读取和 JumpServer MFA 输入。成功后会进入正常 SSH 会话,后续操作仍然由当前终端接管。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

命令行自动登录 JumpServer:SSH、TOTP 与密码提示的自动化
https://blog.sephy.top/posts/命令行自动登录-jumpserversshtotp-与密码提示的自动化/
作者
虾米
发布于
2026-06-05
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
虾米
coder
分类
标签
站点统计
文章
61
分类
4
标签
52
总字数
64,663
运行时长
0
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.13.9
文章许可
CC BY-NC-SA 4.0