OpenVPN client-connect 钩子:地址到底去了哪里
client-connect 脚本无法通过打印来返回地址。了解钩子的临时文件、退出状态和拓扑如何决定客户端 IP。
从 client-connect 脚本返回地址,看起来应该能设置客户端 IP。其实不能:钩子与服务器之间唯一的通道是一个临时文件,其路径作为脚本的最后一个参数传入,地址必须按 ifconfig-push 语法写入该文件。只打印地址、或什么都不写就以零退出的脚本,只会得到一个从地址池分配地址的已连接客户端,而服务器日志看起来完全正常。
这种困惑很合理,因为其他几个钩子确实通过退出码或环境变量回传信息。这个钩子有自己的约定,而约定从一个文件名开始。
钩子写的是文件,不是返回值
声明有两行:script-security 2 允许调用,然后是 client-connect /etc/openvpn/server/assign-address.sh。客户端通过认证后,服务器运行该脚本,并把生成的文件路径作为最后一个参数追加;只有当声明本身不传额外参数时,它才是 $1。所有作用于该客户端的内容都以普通指令写入该文件,每行一条,与配置文件中的写法完全一致。额外添加自身参数的包装脚本会改变位置,典型症状是脚本写入了形如 --verbose 的路径。
退出状态是约定的另一半。零表示接受并读取文件;其他任何值都会拒绝连接,未映射的身份正是因此被拒绝,而不是悄悄退回地址池。请在返回成功前写完整条指令,避免写了一半的文件成为客户端配置。
有两条指令需要特别注意,因为它们与其他一切相互作用。ifconfig-push 固定地址,而推送的 route 告诉客户端通过隧道能到达什么;只返回前者而忘记后者,就会得到一个有地址却无处可去的客户端。文件内的顺序无关紧要,但完整性很重要。为任何对端都无法到达的前缀推送路由的钩子,是从另一端看到的同一个错误。
输入通过环境变量传入:common_name、trusted_ip、ifconfig_pool_remote_ip 等。要把它们全部当作来自服务器外部的字符串,绝不能当作 shell 片段。
地址映射是策略,不是算术
一个诱人的捷径是解析证书名,用其中的数字作为最后一个八位组。那不是分配器,而且会以常见方式失败:后缀相同的两个名字会冲突,以 300 结尾的名字不是地址,管理员改了名的证书会让设备悄悄移动。每次调用都要校验子网归属、可用主机范围、保留地址和唯一性,并把映射保存在人可读的表中。
谁控制名字,谁就控制地址,所以要决定 common_name 在这个部署中究竟代表什么。启用 username-as-common-name 时,身份是登录名而不是证书主体。绝不要把该值传入 shell 求值,也绝不要让客户端影响文件路径。
基于注册表的分配器必须能承受并发。以原子方式预留地址,在写入失败时不接纳配置了一半的客户端,并把每次分配标记为拥有它的连接,这样旧会话的延迟断开就无法释放新会话已持有的地址。多个服务器实例共用一个池会让这一要求更尖锐,而不是可选项。
拓扑决定 ifconfig-push 的含义
在 10.77.0.0/24 上使用 topology subnet 时,ifconfig-push 10.77.0.20 255.255.255.0 是地址加网络掩码。在 topology net30 下,第二个参数根本不是掩码,而是对端地址,所以把子网形式复制到 net30 服务器上,会得到一个能关联却无法通行流量的客户端。要有意更改拓扑,绝不要为了让示例看起来一致而更改。已经在运行 net30 的服务器上还有旧地址方案的活动客户端,因此切换是一次带维护窗口的迁移,而不是改一行。
静态地址必须位于动态池之外,也位于其他所有静态分配之外,包括普通 client-config-dir 文件发放的地址。如果两种机制能为不同客户端产生同一个地址,最终总有一个会这么做。
在信任它之前测试四种情况
对已映射身份、未映射身份、重连和模拟的注册表故障运行同样的检查。openvpn --config client.ovpn --verb 4 会在 push 回复中显示分配的地址,服务器上 grep -A2 PUSH_REPLY /var/log/openvpn/openvpn.log 可确认实际发送了什么。对未映射身份,预期结果是连接被拒绝,而不是池地址。
然后检查守护进程写入这些临时文件的目录。脚本以降权后的服务上下文运行,因此 root 可写而守护进程不可写的路径,会把每次连接变成拒绝,权限错误被埋在日志下方好几行。
分配不是授权:固定地址仍然不会授予访问服务器后面每个目标的权限。把路由和防火墙策略放在它自己的层里,并针对你刚刚固定的地址进行验证。