为单个端口绕过 VPN 需要策略路由
NAT 规则改写地址,却不选择出口链路。真正把一个 TCP 端口留在隧道之外的,是规则、路由表和标记这三件事。
这个请求永远是同一句话的变体:浏览继续走 VPN,但 让 SSH 或备份任务使用普通连接。广为流传的做法——用 iptables NAT 规则转发端口——做不到这件事。NAT 规则只改写流内部的地址和端口,从不决定数据包从哪个接口出去。那个决定属于路由查找,而路由查找需要额外的帮助才能看到一个端口。
为什么普通路由匹配不到端口
Linux 会先在表中查找目的地址来选路,此前还要用策略规则筛选候选路由表。这些规则可以匹配防火墙标记、源地址、入接口或 UID。路由条目本身没有端口字段,因为选路发生在传输层头部起作用之前。
当前的 iproute2 和内核放宽了规则选择器:ipproto、sport 和 dport 都被接受,因此 ip rule add ipproto tcp dport 22 table 100 一行就能表达意图。在真实主机上运行 ip rule add help,看看你的构建是否列出 dport。如果列出,一条命令就够;如果没有,下面的标记路径是等价的。
无论哪种方式,规则只是一个指针。表 100 仍然需要一条路由,而这正是人们常忘掉的那一环。
三件套,还是一件
现代写法是上面那一条规则,加上备用表中的一条路由。经典写法在中间插入 netfilter:给数据包打标记,然后在规则里匹配标记。两者最终都落到同一次查找,区别只在于端口在哪里被读到。
标记方案之所以存在,是因为 netfilter 能看到传输层头部,而路由代码在过去看不到。这就是全部机制,也解释了为什么回退方案是三个互相配合的部件,而不是一条聪明的命令。
三件套的顺序
第一,在一条足够早运行的链里给要改道的数据流打标记,让标记在选路之前就存在:
nft add table inet vpnexempt
nft 'add chain inet vpnexempt out { type route hook output priority mangle; }'
nft add rule inet vpnexempt out tcp dport 22 meta mark set 0x1第二,把带标记的数据包送到一张单独的表中:
ip rule add fwmark 0x1/0x1 table 100第三,给那张表一条经物理链路的默认路由。地址只是示例,请换成你自己的:
ip route add default via 192.168.1.1 dev wlan0 table 100跳过第三步,带标记的数据包在表 100 里找不到路由,于是失败。症状看起来像是规则把网络弄坏了,实际上它删掉了数据包仅剩的那条路。
第一个数据包之后靠什么维持
标记作用于出站数据包。回包会带着该链路分配的地址从物理接口进来,而主表对这个目的地址可能仍偏向隧道。两个机制让回程保持一致:一是针对链路地址的源地址规则,二是把决定保留到整条流生命周期的连接跟踪。把标记存进 conntrack 并在回包时恢复,是让有状态例外保持稳定的常规做法。
反向路径过滤是另一个沉默的杀手。如果内核严格校验回程,而回包从主表不会选择的接口进来,数据包会被静默丢弃。rp_filter 的默认值因发行版以及接口是否转发而异,所以要读取生效值,而不是假设它是关闭的。
不要给隧道自身的传输打标记。WireGuard 或 UDP OpenVPN 隧道不是 TCP 22,所以上面的配方是安全的;但把例外写成“除隧道目的之外的一切”,可能把加密数据包也抓进来,并以看起来像路由环路的方式弄坏隧道。
去验证,而不是假设
问内核它会走哪条路,分别针对目的地址和端口,然后跑一次真实传输:
ip route get 203.0.113.10
ip rule show
nft list ruleset如果标记存在但计数器始终不动,说明规则没有匹配上。计数器区分了你写下的配置和内核真正执行的策略。
什么时候这是错误的工具
基于端口的例外天生脆弱。服务会换端口,HTTP/3 以 UDP 443 出现,一个应用也可能开出多种类型的多条流。如果真正的需求是“这个程序走普通链路”,那么路由层就是错的地方,网络命名空间或按应用代理更合适。
如果需求是“这个子网走普通链路”,那就完全跳过标记,直接加一条更具体的路由。它更简单,在一张表里就能看见,下一个人不用读规则集就能审计。
动防火墙之前,先写清楚你真正拥有三种需求中的哪一种。上面的机制很精确,而精确用错了层次,只会让故障持续更久。