在日常工作中,我经常需要登录堡垒机。传统的登录方式需要手动输入密码和一次性密码(OTP),操作繁琐且效率低下。即使改为 SSH 密钥登录,也还是需要输入一次性密码,只能算是半自动化登录。每天登录个几次下来,就有点烦人了。

最近,我在许久没打开过的涛叔博客上,看到了他最新发布的文章:《自动化登录堡垒机》。在文章中,他通过编写一个脚本模拟输入密码来实现自动化登录堡垒机。

这让我意识到,是时候将我的半自动化登录流程升级为全自动化了。

方案一:使用 1Password CLI

首先,需要安装 1Password CLI

# macOS
brew install 1password-cli
# 其它系统
# https://developer.1password.com/docs/cli/get-started/#step-1-install-1password-cli

接着打开 1Password 的设置,启用「与 1Password CLI 集成」:

启用与 1Password CLI 集成

完成配置后,就可以在终端访问 1Password 了,下面是一些常用的命令:

# 获取保险库列表
op vault list

# 获取项目列表
op item list

# 获取项目信息
op item get {id/name}

# 获取项目信息(包含密码)
op item get {id/name} --reveal

# 获取 JSON 格式的项目信息(包含密码)
op item get {id/name} --reveal --format json

# 获取项目的一次性密码
op item get {id/name} --otp

除了 expectop 之外,还需要安装 jq 工具,用于解析 JSON,用来从 1Password 的项目信息中提取出密码和一次性密码。

# macOS
brew install jq
# Debian/Ubuntu
sudo apt-get install jq
# RedHat/CentOS
yum install jq

对原始脚本的改动很简单,只需要将脚本中的「自动读取动态口令」和「硬编码的密码」改为使用上述命令获取即可。

#!/usr/bin/expect

# 自动调整窗口大小
trap {
    set XZ [stty rows   ]
    set YZ [stty columns]
    stty rows $XZ columns $YZ < $spawn_out(slave,name)
} WINCH

set OP_ITEM_ID "Bastion-Host-Login"  # 使用项目名称

# 使用 1Password CLI 获取堡垒机的项目信息
if {[catch {exec op item get ${OP_ITEM_ID} --reveal --format json} data]} {
    puts "错误: 无法获取1Password数据,请确保已登录op CLI"
    exit 1
}

# 从 JSON 中提取密码
if {[catch {exec echo $data | jq -r {.fields[] | select(.id == "password") | .value}} password]} {
    puts "错误: 无法解析密码"
    exit 1
}

# 从 JSON 中提取一次性密码
if {[catch {exec echo $data | jq -r {.fields[] | select(.type == "OTP") | .totp}} totp]} {
    puts "错误: 无法解析TOTP"
    exit 1
}

# 发起 ssh 会话
spawn ssh foo@example.zz.ac

# 自动输入登录密码
expect "Password:"
send "$password\r"

# 自动输入动态口令
expect "Verification code:"
send "$totp\r"

# 将终端控制权交还给 ssh 会话,完成登录
interact

在上面的脚本中,使用了 catch 命令用来捕获异常,类似于编程语言中的 try-catch。如果命令执行失败,catch 会返回非零值(通常是 1),命令执行成功,catch 会返回 0,并将命令执行结果保存到 result 中。

在使用脚本前,你需要将 OP_ITEM_ID 的值替换成项目的实际 ID 或者名称。可以使用以下命令获取:

# 列出所有项目,找到堡垒机相关的项目
op item list

# 或者搜索特定名称
op item list | grep "堡垒机"

例如:

set OP_ITEM_ID "Bastion-Host-Login"  # 使用项目名称
# 或者
set OP_ITEM_ID "abc123def456"        # 使用项目ID

1Password CLI 的性能问题

原本以为到这里就结束了,但是在实际使用过程中我发现了一个问题:这个脚本非常慢!慢到什么程度呢?在不需要认证的情况下,从执行命令到登录成功,需要 3 秒左右,最慢的时候差不多需要 10 秒。

自动化的目的是为了提升效率,现在操作虽然自动化了,但时间效率没有提升多少,跟手动登录差不多,这显然无法满足需求。

研究了一会儿,发现问题出在 op 命令获取密码太慢了,不仅获取密码慢,获取一次性密码也慢。我尝试过指定保险库、使用 --fields 选项减少获取的字段,没什么效果。

Google 搜索发现很早之前就有这个问题,但都没有得到解决。

官方回复说在 macOS 上会默认开启缓存来提升执行速度,在命令中加上 --debug 选项,确实可以看到使用了缓存,并且也命中了缓存。但是,为什么还这么慢?

op item get xxxx --reveal --debug
23:19PM | DEBUG | Session delegation enabled
23:19PM | DEBUG | NM request: NmRequestAccounts
23:19PM | DEBUG | NM response: Success
23:19PM | DEBUG | NM request: NmRequestAccounts
23:19PM | DEBUG | NM response: Success
23:19PM | DEBUG | InitDefaultCache: successfully initialized cache
23:19PM | DEBUG | EncryptedKeysets: Cache hit on keyset
23:19PM | DEBUG | AllVaults: cache hit on vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | AllVaults: cache hit on vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | AllVaults: cache hit on vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | Item: VaultItems cache hit for vault xxxxxxxxxxxxxxxxx - validating staleness using item version
23:19PM | DEBUG | Item: cache hit on item xxxxxxxxxxxxxxxxx of vault xxxxxxxxxxxxxxxxx

折腾无果后,既然 1Password CLI 的性能问题无法解决,不如换个思路:用 SSH 密钥解决认证问题,用独立的 2FA 工具处理 OTP。

方案二:使用 SSH 密钥 + 2FA

1Password 还提供了另一项功能:1Password SSH Agent。你可以将本地的 SSH 密钥保存到 1Password 上。每次需要使用 SSH 密钥时,都会弹出一个 1Password 的授权框,上面显示了哪个客户端正在请求使用哪个 SSH 密钥,你可以使用指纹或者密码同意这次授权。

1Password SSH 认证请求

这样带来的好处是:

  • 本地不用再存储任何私钥文件,避免了恶意扫描导致私钥被泄露的风险
  • 基于 1Password 的同步功能,可以在任何登录了 1Password 账号的设备上使用这些 SSH 密钥

为了使用这项功能,你需要在 1Password 的设置中启用「使用 SSH Agent」。

配置 1Password SSH Agent

首先,在 1Password 上创建一个 SSH 密钥项目,你可以选择将堡垒机的密钥导入进来,也可以生成一个新的私钥。

创建 SSH Key

创建完成后,将公钥保存到 ~/.ssh/bastion.pub,避免 SSH 密钥过多,产生 Too many authentication failures 问题。

下载公钥

然后在 ~/.ssh/config 文件中添加以下内容:

Host bastion
    HostName example.zz.ac  # 替换为堡垒机的地址
    Port 22                 # 替换为堡垒机的端口
    IdentitiesOnly yes
    IdentityFile ~/.ssh/bastion.pub
    IdentityAgent "~/.1password/agent.sock"

这时候在终端中执行 ssh bastion 命令,你就可以免密登录到堡垒机了,但是还需要输入一次性密码,接下来我们解决这个问题。

配置 2FA 工具

执行以下命令安装 2fa 并添加一次性密码。

# 安装
go install rsc.io/2fa@latest
# 添加一次性密码
2fa -add bastion
2fa key for bastion: 
# 获取一次性密码
2fa bastion
211762

我对脚本做了一些优化,增加了超时处理、登录后执行命令等:

#!/usr/bin/expect

# 自动调整窗口大小
trap {
    set XZ [stty rows   ]
    set YZ [stty columns]
    stty rows $XZ columns $YZ < $spawn_out(slave,name)
} WINCH

# 自动读取动态口令
spawn 2fa bastion
expect -re "(.*)\n"
set totp $expect_out(1,string)

# 发起 ssh 会话
spawn ssh example.zz.ac

# 等待 OTP 提示并自动输入
expect {
    "Verification code:" {
        send "$totp\r"
    }
    timeout {
        puts "超时: 未收到 OTP 提示"
        exit 1
    }
}

# 等待登录成功(通常是 shell 提示符)
expect {
    "*$" { }
    "*#" { }
    "*>" { }
    timeout {
        puts "登录超时"
        exit 1
    }
}

# 你可以在登录成功后执行一些命令
# send "ls -l\r"

# 将终端控制权交还给 ssh 会话,完成登录
interact

将上面脚本保存到文件中,就可以愉快的登录堡垒机了。实测下来,响应速度基本在 1 秒左右,相比之前的 3-10 秒有了非常大的提升。更重要的是,整个流程的可靠性也更好了。

总结

虽然最终没能用上 1Password CLI 有点遗憾(主要是太慢了),但好在找到了替代方案,日常使用体验还是很不错的。期待 1Password 官方后续能优化一下 CLI 的性能吧。