默认 libvirt 网络上的来宾在 WireGuard 接口出现之前都能完美解析域名,之后每次查询都卡在同一个地方。dnsmasq 仍在 192.168.122.1:53 上监听,来宾仍把这个地址当作解析器,查询也仍然会到达。变的是主机自己的上游查询或它的回复,现在会被一条规则丢弃,而这条规则的求值早于任何你会想到去编辑的过滤链。出路是 一个足够早打上的标记,打在回复能够继承的连接上。

隧道一建立,来宾就失去 DNS

先确认现象再动手。关掉隧道,从来宾运行 dig @192.168.122.1 example.com,看它能应答。打开隧道,重复一次,看它超时。这一对结果一步就排除了 libvirt 网络损坏、解析器地址错误和 dnsmasq 死掉。

然后看主机一侧。如果 dnsmasq 记录了查询及其上游转发,说明包已经到了,故障出在出站。如果 dnsmasq 什么都没记录,来宾查询在入站就被丢弃,例外应该落在另一个 hook 上,而不是本文标记的那个。

为什么后面链条里的 accept 规则永远不生效

基于标记的 VPN 防火墙看的是包标记或连接标记,而不是你的源地址。任何到达物理接口却没有预期标记的东西都会被拒绝,而这次拒绝发生在拥有标记判断的那套规则里。ip saddr 192.168.122.0/24 accept 加进任何后面的链条,对该包来说根本不会被查到,所以这条看起来正确的规则什么也改不了。

把它放宽到能用是第二个陷阱。在主机的 input 路径上放行整个 libvirt 子网,会把主机的每项服务都对来宾敞开,而实际只需要一个端口。

让窄例外成为可能的机制是连接跟踪。当你在包上设置 ct mark 时,值会存到 conntrack 条目上而不只是包上,所以这条连接之后的每个包都带着它,包括主机回到来宾的回复。整个诀窍就是这个:请求标记一次,回程就已经带标记了。

在 VPN 表看到之前先标记查询

把例外放进它自己的表,这样后来的读者不必解析 VPN 规则就能找到它:

table inet allowedLocal {
  chain guest_dns {
    type filter hook input priority mangle; policy accept;
    ip saddr 192.168.122.0/24 meta l4proto { tcp, udp } \
      th dport 53 ct mark set 0x00000f41
  }
}

priority mangle 是负值,这让本链排在 VPN 软件包以优先级零安装的普通过滤链之前。真正让规则生效的是 hook 顺序;同样的规则放在排在 VPN 链之后的链条里只是摆设。

标记编号本身不可移植。取已安装的 VPN 规则为自己流量接受的那个值,从运行中的规则集里读出来,然后用它。标记错了会得到一条加载干净、匹配每次查询、却什么也放不进来的规则。

把洞保持在 53 端口那么宽

照现在这样写,它只允许 libvirt 子网到主机的 TCP 或 UDP 53 端口 DNS,别的都不行。它不让来宾绕过隧道访问互联网,也不让主机向来宾发起连接。如果以后某个来宾需要主机上的其他服务,请另加一条指明该目标端口的规则,而不是放宽这一条。

对你交换出去的东西要诚实。能访问解析器的来宾也能通过 DNS 查询把任意数据隧道出去,这个例外是 VPN 边界上刻意留下的缺口,而不是宣布本地网络可信。把源限制在 192.168.122.0/24 之内,能让这个缺口远离其他每个本地子网;如果威胁对你重要,统计该来源的查询并不贵。

让它熬过重启,并验证两半都成立

在 shell 提示符里敲的规则下次启动就没了,而在 VPN 接口已经通行之后再加载的规则会在第一个连接上输掉竞争。把表放进主机的持久 nftables 配置,或放进一个排在 VPN 服务之前的 unit,并且去检查顺序而不是假定它。

验证需要三个彼此独立的答案。确认链条已加载且排在最前,确认 conntrack 条目两个方向都显示标记,确认来宾查询能返回地址。然后再确认另一半:隧道断开或拦截时,来宾访问公网主机的普通网页流量仍然失败。如果 DNS 能用而其他都漏,说明你的规则比上面那条更宽。

如果标记就位后来宾查询仍然失败,别急着放宽源。检查 dnsmasq 转发到哪里、来宾的解析器指向 192.168.122.1 还是指向某人手填的公网地址,以及查询到底有没有到主机。标记只把包放进来,别的都不管,所以上游坏了或虚拟网络坏了,加再多规则也还是坏的。

相关文章