端口 80 和 443 的规则不会把浏览器送进 VPN
端口匹配选择的是 TCP 流,而不是浏览器。HTTP/3 跑在 UDP 上,DNS 可能走另一条路,而更新程序会命中你写下的同一条规则。
有人要求让浏览器走隧道,返回的却是一条匹配目标端口 80 和 443 的防火墙规则。它看起来很精确,其实并不精确。端口匹配按传输层首部挑选数据包,而不是按哪个程序打开了套接字,而现代浏览并不把自己限制在 TCP 的这两个端口上。这条规则会漏掉你本想包含的流量,又会抓到从未打算触碰的流量。在写下它之前,先想清楚三种不同边界里你真正要的是哪一种。
这条规则描述的是传输类别,不是某个应用
在 Linux 上,目标端口匹配只检查 TCP 首部,不检查它上面的任何东西。给这些包打标记并用策略路由引流,完全按文档工作,而这些机制都不知道载荷是什么意思、由哪个进程发出。标记是贴在数据包上的一个数字,不是贴在应用上的身份。
nft add rule inet mangle output tcp dport { 80, 443 } meta mark set 0x1
ip rule add fwmark 0x1 table 100
ip route add default dev wg0 table 100
两个后果立刻出现。连接 TCP 443 的软件更新程序会得到和浏览器一样的待遇,这也许没问题,也许就是一场要等到下个更新周期才爆发的故障。而故意发布在另一个端口、比如 8443 上的 Web 服务,则完全落在规则之外,尽管它仍然是用户在意的那部分 Web 流量。
当需求确实针对这些端口时,数字是合法的选择器。当工单上写的是「浏览器」,而规则集里实现的却是端口范围时,它们就成了虚构。这个落差不是文档上的讲究。它决定了这条规则是能在评审里站住,还是一条谁都没法测试的规则。
HTTP/3 会悄悄绕过你的 TCP 规则
HTTP/3 跑在 QUIC 上,而 QUIC 装在 UDP 里。只匹配 TCP 的规则看不到它,所以通过你测试页面的同一个浏览器,可以随后用 UDP 发出请求、绕开隧道,而没有人改过任何设置。RFC 9114 把 HTTP/3 描述为基于 QUIC 的协议,而 QUIC 在常见部署里使用默认端口 443 的 UDP。
某个浏览器是否会尝试 HTTP/3,取决于它的配置、站点,以及上一次尝试是否成功。这让它不适合凭记忆推断。去问工具:
curl --http3 -sS -o /dev/null -w '%{http_version} %{time_total}\n' https://example.com
ss -tunp | grep -E ':443\b'
curl 那一行会打印协商出的版本,所以 3 就确认走的是 UDP。随后套接字列表显示这条连接绑在隧道接口上还是物理接口上。如果你的 curl 构建里没有 HTTP/3,这个开关会大声失败而不是悄悄回退,这算是它自己的一点慈悲。
DNS 是一个你必须单独做出的决定
Web 流量的端口规则对域名解析只字不提。浏览器可能用系统解析器、浏览器内配置的解析器,或者上一个连接推给它的解析器。其中任何一个都可能沿与规则所选不同的路径发出查询,于是产生经典的分裂:页面加载了,名字却在没预料到的地方被解析,内部站点要么解析成公网地址,要么根本解析不了。
明确把它定下来。如果内部域名必须通过隧道解析,解析器就必须在隧道里面,规则集必须允许到该解析器的 DNS,同时把其他查询挡在外面。如果浏览器使用到公共提供商的 DNS over HTTPS,查询就完全绕过你的解析器,端口规则帮不上忙。把这一选择写进与路由规则相同的变更记录,因为下一个排障的人会把两者放在一起读。
然后用解析器而不是页面来验证:
resolvectl query intranet.example.internal
resolvectl status | grep -A3 'Current DNS Server'
拿不到应答就是解析问题,哪怕浏览器的报错把责任推给网络。这两种故障在用户眼里一模一样,却是完全不同的工单。
三种边界,三种不同的机制
一旦你给想要的边界命名,大部分混乱就消失了。它们不能互换,而且在隧道中断时,各自的失效方式不同。
| What you actually want | Mechanism that fits | What it does not cover |
|---|---|---|
| These transport ports use this exit | Firewall mark plus policy route | Applications, HTTP/3 over UDP, DNS |
| This browsing session stays separate | Application-aware VPN, or a separate network namespace plus a proxy | Other applications in the same session |
| Only these private sites are reachable | Destination-based routes with private DNS | Public sites on the same address family |
独立的浏览器配置是中间那一行的陷阱。它分离了 Cookie 和历史记录,却完全不改主机的路由表,所以两个配置走同一条路。隔离需要内核强制的东西,也就是命名空间、容器,或者一个拥有自己隧道接口的应用。
不论适用哪一行,都要写下隧道掉线时应该发生什么。随接口一起消失的基于标记的路由通常会回落到物理默认路由,这就是 fail-open 行为。这对浏览也许可以接受,对其他任何事都不可接受,而这是一个决定,不是实现细节。
测试你声称覆盖的那些流
验收测试必须沿着你写下的边界,而不是方便的端口号。挑一条必须走隧道的流、一条不该走的流,以及一条用上你没想过的传输方式的流:
ip route get 203.0.113.40
curl -sS -o /dev/null -w 'v4 %{http_version} %{remote_ip}\n' https://example.com
nft list ruleset | grep -A2 'dport'
路由查询会告诉你某个目的地实际走哪个接口,而这正是大多数报告搞错的事实。curl 那一行确认协商版本和地址族。规则集转储能抓到测试时加的、忘了收窄的那条规则,临时标记就是这样变成永久 fail-open 路由的。
把三份输出连同时间戳记录下来,与变更放在一起。日后有人说「VPN 把内网弄坏了」,预期边界和实际边界之间的差距就会出现在那份记录里,而修复会是改规则,不是吵一架。