NAT 后的 OpenVPN 客户端如何成为互联网出口
出口主动向外拨号,因此其路由器无需任何入站端口。梳理枢纽路由什么、出口转发什么,以及回程路径从何而来。
三台 Linux 主机,一套设计:一个公网可达的 OpenVPN 枢纽、一台消费级笔记本,以及一台位于普通家用路由器之后、不转发任何入站流量的出口机。出口自己向枢纽发起连接。消费级互联网流量随后在笔记本处进入隧道,并从出口自己的上行链路离开。这一安排之所以出人意料,全都源于一个细节:是出口发起了连接,所以向内路径已经存在,无需端口转发来创建它。
下文追踪的是数据包的路径,而不是配置顺序,因为只有知道哪个组件掌管哪个决策,配置才有意义。每一层只掌管一个决策,而错误往往聚集在把两层当成一层的地方。
两个传输会话,一条内部路径
每台主机各自维持到枢纽的外部连接,两条连接互不知晓。消费端发往互联网的流量是一条独立的内部流,它恰好被装在消费者自己的隧道里,随后又从出口的隧道中重新发出。枢纽必须路由的正是这条内部流,出口必须转发的也正是它。
outer: consumer --TLS--> hub
outer: exit --TLS--> hub
inner: consumer --tun--> hub --internal--> exit --tun--> uplink
由于两条外部连接都从客户端一侧向外发起,出口的路由器永远看不到入站 SYN,也永远不需要目的 NAT 规则。它只需维持已建立的传输存活,而它对任何长连接本来就是这样做的。公网上的任何东西都不必先到达出口,这也是那条上行链路使用动态地址不成问题的原因。路由器仍需放行这条出站会话,但那与浏览器已经在用的放行没有区别。
iroute 在服务器内部究竟安装了什么
枢纽是唯一决定哪个对端拥有哪个目的地的设备,而它是在自己的用户空间路由表中做这个决定的,不在内核表里。为出口写的一条名为 exit-node 的 client-config-dir 记录,可以在那里认领默认前缀:
# /etc/openvpn/server/ccd/exit-node
iroute 0.0.0.0 0.0.0.0
那一行告诉服务器进程「目的地任意的内部数据包都属于这个客户端」。它不会给枢纽的操作系统添加默认路由,也不会把枢纽变成互联网网关。OpenVPN 2.6 参考 把客户端专属的内部路由与内核路由当作两套彼此分离的机制,正是这种分离让本设计无需触碰枢纽的默认路由就能成立。
消费者需要镜像的那条指令,以 push 形式下发,好让它自己的流量进入隧道,而不是从本地网关离开。在子网拓扑下,地址池是出口 tun 设备上的一条直连路由,所以回程方向不需要额外 push;出口绝不能收到的,是下面那条重定向,它会把自己上行链路的流量送回它正试图使用的那条隧道。只把它发给消费者,并在客户端上读取实际生效的路由,而不是相信 CCD 文件已经被选中。
# /etc/openvpn/server/ccd/consumer-01
push "redirect-gateway def1"
转发由出口的内核完成
在出口内部,来自消费者的数据包抵达 tun 接口,目的地是某台互联网主机。内核查询自己的路由表,找到真实上行链路作为最佳匹配,然后应用两条彼此独立的策略:这个包究竟能否被转发,以及它能否带着那个源地址从那个接口离开。转发是一个 sysctl,在许多发行版上默认关闭。
因此出口需要持久开启的 IPv4 转发、一条允许消费者隧道源地址通往上行链路的转发规则,以及经 tun 返回的应答路径。为整台主机开启转发也会改变其他一切经它路由的东西的行为,所以规则应当点名源地址与接口,而不是一律接受。
回程路径是靠源地址转换买来的
互联网主机没有通往 10.90.0.x 地址的路由,所以应答会在抵达出口之前就被丢弃。在数据包离开上行链路时改写源地址可以解决这一点,而连接跟踪会在回程时把改写还原。
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 10.90.0.0/24 -o enp1s0 -j MASQUERADE
ip -4 route get 10.90.0.9 # run on the exit: must point at the tun
上面的规则只是一条上行接口的起点,不是防火墙策略。把源地址收窄到你真正授权的那些分配,并把出口自己的入站策略单独保留,这样隧道流量的伪装就不会悄悄暴露本地服务。在上行地址是动态的主机上,伪装也省去了手工跟踪地址的麻烦。
枢纽能在哪里强制执行,又在哪里不能
在 client-to-client 与传统用户空间数据路径下,枢纽在服务器进程内部把内部数据包交给对端之间。它们从不经过枢纽内核的 FORWARD 链,所以那里的内核规则无论写得多仔细,都无法限制对端之间的通行。强制执行必须落在服务器自己的策略里:谁可以连接、拿到哪个 CCD 文件,以及是否授予出口的默认所有权。
这也正是为什么默认 iroute 是一项宽泛的信任决定。出口必须合法地接受来自任意互联网地址的回程流量,而且它必须是一台你掌控、打补丁并监控的机器。把同样的所有权授予 一个你不会把路由器交给它的对端,正是这套设计招来的错误。
成员资格才是真正的控制。任何能配置自己路由的客户端都可能尝试使用出口,所以限制谁可以连接、或出口服务哪些对端,是设计的一部分,而不是事后的想法。
出口消失时安全失败
把出口下线,内部默认所有权会随它的会话一起消失。不该发生的是枢纽悄悄变成替代出口:没有从 VPN 地址池通往枢纽上行链路的默认路由,也没有枢纽上那条会乐于转发同样流量的宽泛伪装规则。故意测试这种失败,然后用新的流而不是已经打开的页面来测试恢复。
# on the consumer, with the exit up and then down
ip -4 route get 1.1.1.1
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com
如果出口消失之后消费者的流量仍能到达互联网,那就是别的东西在承载它,你设想的边界并不存在。DNS 与 IPv6 也要当作彼此独立的决策:一条 IPv4 路由加伪装,对 AAAA 流量什么也没说;而枢纽推送的解析器会从查询真正离开的地方作答。