启动厂商 VPN 客户端的脚本会一直挂到超时,日志显示客户端正在索要一个没人输入过的验证码。密码明明已经传进去了,于是最直觉的结论是密码错了。并不是。索要验证码是另一个通道上的另一段对话,而你的脚本从未打开过它。

按顺序完成下面五项检查。在第一项失败处停下,因为后面每一步都假定前面的结论成立。

检查 1:客户端到底读不读标准输入?

在写任何自动化之前先测一下:把一段假密码通过管道喂给客户端,看会发生什么。如果它打印出提示后就开始等待,说明它没有在读管道。

很多客户端会直接打开终端设备,或者拒绝从非终端流里读取密码,以免误接的管道泄露口令。OpenVPN 是有文档记载的例外,因为 --auth-user-pass 接受一个文件路径。而那些索要一次性验证码的厂商客户端,通常没有等价的开关,它们的提示在等待一个你的脚本从未提供的终端。

检查 2:子进程有终端吗?

pty 会为子进程分配一个终端,从而把提示变成可读的输出,让你能按顺序应答。

import pexpect

child = pexpect.spawn("openconnect vpn.example.net", encoding="utf-8", timeout=30)
child.expect("GROUP:")
child.sendline("staff")
child.expect("USERNAME:")
child.sendline("alice")
child.expect("PASSWORD:")
child.sendline(PASSWORD)
child.expect("Connected as", timeout=60)

上面的提示字符串只是示例;请按你的客户端实际打印的内容逐字复制,包括大小写和标点。每个 expect 都要带上超时,而子进程被关闭时会抛出异常而不是返回状态码,所以要把整段包起来,并把文件结束当作失败。注意这段代码没有做什么:子进程会随脚本一起结束,因为 pty 并不是脱离机制,而 disown 是 shell 内建命令,够不到你脚本创建的进程。

检查 3:有没有官方支持的非交互输入?

屏幕抓取在客户端升级时就会失效,所以先找有文档记录的开关。

在帮助输出里找令牌或一次性验证码选项,并确认服务是否接受把验证码附加在密码字段后面。如果客户端读取凭据文件,那就给它文件,而不是给它一个提示:

openvpn --config office.ovpn --auth-user-pass <(printf "%s\n%s\n" "$USER_NAME" "$PASSWORD")

进程替换在提供 /dev/fd 的系统上会给客户端一个可读路径。从客户端的角度看密码仍是密码,所以要让它远离日志、shell 历史和进程列表。如果客户端拒绝 /dev/fd,或者会不止一次打开凭据路径,那就写一个权限严格受限的真实文件,并在同一个脚本里删掉它。

检查 4:隧道能活得比脚本更久吗?

默认情况下隧道会随父进程一起退出,这正好和引导脚本需要的行为相反。

disown -h 只在你把客户端作为 shell 作业启动时才有用,因为它改的是那个 shell 的作业表,够不到你脚本通过 pty 创建的进程。凡是需要无人值守重连的东西,更适合交给服务管理器,因为它会重启客户端并记录下退出原因。无论如何,每次重连后都要预期一个新验证码:一次性令牌会在第一次认证成功时被消耗掉。

留意 sudo 会把这个顺序搅成什么样。sudo 的密码提示不是 VPN 的提示,用 VPN 密码去回答它,是本来正确的 expect 顺序仍然失败的最常见原因。

检查 5:一个人的验证码应该写进脚本吗?

这一项最常被跳过。令牌属于人,而必须按时自动起来的隧道属于机器。

如果任务必须无人值守地重连,先问一句:能不能用机器证书或服务账号替代交互式登录。把某个人的一次性验证码自动化,等于把第二因素变成了一份被存下来的密码,这是策略变更,而不是脚本便利。在服务同时支持两者的地方,选那种和任务实际运行方式相符的凭据。把答案写下来,因为下一个人一定会问:为什么脚本里存着一个令牌。

一个能用的脚本长什么样

脚本会打印它走到哪一步,绝不打印密码,并且任何 expect 失败都以非零码退出。

故意把它弄坏来测试:错误密码、错误组、过期令牌。每一次失败都应该停在某个有名字的步骤上,而不是挂住。然后再对真实服务跑一次,并从脚本外面检查隧道,而不是从里面。

相关文章