OpenVPN 的 auth-user-pass-verify 脚本:把退出码写对
钩子的退出状态才是决定,日志行不是。把验证器结果映射为 0、1 或延迟,把凭据文件当作数据读取,出错时默认拒绝。
OpenVPN 只依据登录脚本的一项输出行事:退出状态。脚本做的其他一切都是装饰。钩子可能找到了正确的验证器、解析了响应、打印了一行友好的话,却返回了与作者本意相反的状态。先读契约,再围绕它写适配器,这一类 bug 就整体消失了。
契约很小:确认提交的账号,向获批的验证器提出该问的问题,只接受该验证器针对该请求给出的、有文档记录的成功结果。其余都是管道。
钩子可以返回的三种状态
对于 auth-user-pass-verify,OpenVPN 2.6 手册把退出码 0 定义为接受、1 定义为拒绝、2 定义为延迟认证。服务器只认这三种答案,每种对连接的影响都不同。如果脚本以最后一条命令的状态退出,就等于让一次日志调用或清理步骤投了票。在每个分支里显式写出状态,包括那些从未到达验证器的分支。
HTTP 成功和认证成功是两个层面,把它们混为一谈是这类钩子最常出错的地方。202 响应只表示请求已被接受处理,并不表示密码已验证。根据你拿到的 API 契约,定义期望的状态、响应的形状和最终判定,不要因为某个字段名看起来合理就凭空编造。
如果答案确实稍后才到,那就是延迟路径,必须按延迟路径来实现。永远不完成的延迟尝试会一直占着资源,直到超时。
按原样读取凭据文件
在文档所述的 via-file 方式下,服务器把用户名和密码写成临时文件的前两行,把路径传给脚本,并在脚本返回后删除文件。把这两行当数据读:绝不要 source 该文件,绝不要对它求值,也绝不要把内容粘到进程表能看到的命令行里。密码要逐字节保留,因为像规范化用户名那样去规范化它,会在不知不觉中改掉凭据。临时文件所在的目录属于部署决策,所以要确认它存在,并且不是主机上所有人都可读。
验证器自己的凭据要和用户的凭据分开:它属于网关集成,放在平台的密钥库里,只需要校验权限,因此不能证明某个具体的人通过了认证。也不要让钩子变成第二个密码验证器。手工重写哈希比较不会因为优化循环就变安全,而维护良好的验证器早已理解账号存储的加盐与成本方案。
决定无法归类的回答意味着什么
超时、传输错误、格式错误的响应和后端不可用有一个共同点:它们都不是肯定性的验证。钩子应当对每一种都默认拒绝,并记录操作员可以据此处理的原因。这个原因应当写进受保护的日志,而不是返回给尝试连接的人的消息里。
对那些根本到不了验证器的路径也要同样小心。缺少配置值、读不到密钥或依赖未安装,应当产生带独立运维标记的拒绝,而不是意外的接受。这些分支要在实验室里移除依赖去测试,而不是凭空推理。当记录日志失败可能改变退出状态时,日志记录已经成为决策的一部分,必须让它无害。
服务器配置在这里也重要:用户自定义脚本只有在运行配置允许所需安全级别时才会被采纳,而这一设置是有意为之的选择,不是可以随手调高的默认值。
验证判定,而不是打印出来的那行字
构建一个直接检查退出状态的测试。写一个示例凭据文件,用已知正常和已知异常的账号各跑一次钩子,读取 shell 报告的状态,而不是脚本打印的文本。仅这一项检查就能在客户端连上之前抓住大多数契约 bug。
然后用真实客户端对非生产监听器跑同样的用例,确认服务器记录了什么:接受、拒绝还是延迟。覆盖被禁用的账号、错误密码、格式错误的验证器响应、超时和不可达的验证器。如果任何一个用例落进你说不出名字的状态,适配器就没做完。
把 验证器结果到退出状态的映射放在仓库里脚本旁边,好让下一位工程师不用逆向代码就能读懂契约。