全隧道 VPN 启动后主机仍能响应 SSH,而同一台机器上通过 Docker 发布的服务却没了声音。监听器本身没有问题:容器在运行,端口映射存在,数据包也确实到达。容器是健康的,请求真的进入了容器。差别在于 Linux 选择路由时回包从哪个地址发出,而这个差别决定了你需要下面两种设计中的哪一种。

顺着元组走一遍目的地址 NAT

设想一台主机位于 192.0.2.10,把 TCP 2222 发布到位于 172.30.240.5:22 的容器。来自 198.51.100.25 的请求在到达容器之前就被转换,而容器以自身身份回包。

WAN request:      198.51.100.25 -> 192.0.2.10:2222
after DNAT:       198.51.100.25 -> 172.30.240.5:22
container reply:  172.30.240.5:22 -> 198.51.100.25

在主机做路由决策的那一刻,回包仍然携带容器的源地址。反向转换要到之后才把已发布的元组还原,所以数据包恰恰在选路的那一刻看起来是错的。

为什么只针对主机的规则会漏掉它

只对来自 192.0.2.10 的数据包选择 WAN 表的规则,覆盖了主机自身的守护进程,却完全漏掉了这条被转发的回包。回包上的源地址是 172.30.240.5,而不是主机。

过度纠正的做法是让容器的一切都走 WAN,这同时会让容器那些无关的出站连接也脱离 VPN。那是与需求不同的策略,通常会在日后让某个人感到意外。

DNAT 之后的匹配是另一个钩子

Docker 关于 防火墙的文档 指出,普通用户态过滤看到的是 DNAT 之后的目的地址,因此在普通规则里匹配已发布的端口 2222 并不等于匹配容器的 22 端口。如果原始元组重要,请使用受支持的原始目的地址机制,而不是猜测哪个端口可见。

这种钩子混淆会产生看起来正确、却永远不匹配的规则。改写策略之前,先看规则自身的计数器。零命中的规则告诉你:它被评估的位置,数据包根本到不了。

设计 A:把决策当作连接状态携带

连接标记可以带着入口决策穿过地址转换,但必须在真正重要的那次路由查找之前写回数据包。步骤如下:

  1. 预留一个不与 Docker、VPN 或其他策略所有者冲突的标记字段和规则优先级。
  2. 只在连接跟踪可用的 DNAT 之前的位置,识别预期的新 WAN 连接。
  3. 把决策记录到连接标记上,而不强行让原始请求本身走 WAN 表。
  4. 在回包方向上,只在路由之前把自己那几位标记写回数据包。
  5. 足够早地选择一张完整的 WAN 表,让 VPN 规则无法抢先命中。

那张表需要真实的直连路由和网关。它还需要定义路由缺失时的行为。静默落入隧道是最糟的结果,因为配置看上去仍然在工作。应当失败关闭并记录日志。

设计 B:在主机上终止

在容器前面刻意配置一个主机侧前端,例如 TCP 代理或反向代理,就能让回包始终从主机地址发出。为主机编写的规则于是按预期生效,而容器仍留在自己的私有网络里。

代价是多了一个会动的部件,它有自己的配置、源地址处理和访问控制。这通常是更简单的受支持边界,而当路由策略只能由某个随时可能离开的人维护时,它往往就是正确答案。

验证变更,而不是意图

从公网侧新开一条已授权的连接,确认回包从预期接口发出。然后检查一条与容器无关、由容器发起的请求是否仍然走 VPN。

不要为了测试一条规则就清空整台机器的连接表:已有条目会保留先前的映射,而清空会破坏主机上其他一切。发布端口定义的是可达性,并不定义回包路径。把选定的设计连同表名写下来,让下一个人不必在事故中重新发现它。

相关文章