VPN 客户端连上了,浏览器也能上网,可你真正关心的那条命令依旧从家里的线路出去。或者反过来:隧道抢走默认路由,本地文件服务器从桌面上消失了。我是在一个构建代理上遇到这事的:它要访问内部构件镜像,主机其余部分却必须留在办公网,而客户端只给了两个选项,全走或全不走。两个方向的需求其实是同一个,只让这个程序走,而不是整台机器,但大多数客户端没有这个勾选框,因为路由表从来就不知道什么叫程序。

为什么路由表看不见你的程序

进程发包时,协议栈在路由表里查目的地址。可执行文件名和端口都不参与这次查找。域名也是同样的道理:隧道把流量送到某个地址,而当一个名字解析成地址时,内核手里只剩路由这一根杠杆。所以"VPN 只给这个网站"最后等于代理,"VPN 只给这个程序"则等于少数几种机制之一。

机制靠什么选择覆盖范围隧道断开时
绑定套接字源地址你能控制的客户端连接失败
UID 规则(Linux)属主用户同一账号的所有进程连接失败
防火墙配置文件(Windows)适配器配置文件单个程序失败关闭
网络命名空间命名空间成员身份在里面启动的一切连接失败
SOCKS 跳板应用的代理设置单个应用或单个浏览器配置文件连接失败

这些都不是按进程的路由。每一种都把判断交给了一个能看见程序、用户或应用自身配置的层次。

单个目的地仍然只是路由,不是程序规则。在 macOS 上,常见做法是取消勾选"通过 VPN 发送所有流量",再加一条主机路由:sudo route add -host internal-tuner.example -interface ppp0,再用sudo route delete -host internal-tuner.example -interface ppp0删掉。先从ifconfig里读出接口名,因为ppp0只是示例。主机路由不支持通配符,所以一个要访问多个名字的服务需要为每个名字单独加一条,而且这条规则覆盖机器上的所有进程,并不只是你想的那个。

把套接字绑定到隧道地址

当客户端由你控制时,绑定是最便宜的选择器。绑定到隧道本地地址的套接字,只要那里存在通往目的地的路由,就会从该接口出去,机器上其他一切照旧走原路。微妙之处在于:绑定选的是源地址,出口仍然由路由表决定。只做绑定并不会把没有路由的流量搬进隧道,这正是大多数"我绑了,它还是走错路"报告的根源。

import socket
s = socket.socket()
s.bind(("10.8.0.2", 0))
s.connect(("build.internal.example", 443))

更高层的工具暴露了同样的思路:curl --interface 10.8.0.2、PHP 的CURLOPT_INTERFACE,以及 Python HTTP 客户端里的source_address参数。地址必须属于隧道,这里写错会表现为普通的连接错误,而不是路由报错。绑定设备名而不是地址是 Linux 的特权,无法带到 Windows 或 macOS,所以可移植脚本都传地址。

Linux:匹配属主,而不是程序名

内核能匹配套接字的属主用户,于是一个专用账号就成了选择器。把隧道放进独立的路由表,只把这个账号的流量送过去:

ip rule add uidrange 1001-1001 lookup 100
ip route add default dev tun0 table 100

这只有在隧道还不是默认路由时才成立。启动客户端时加--route-nopull,或者用--pull-filter ignore "redirect-gateway" 在 OpenVPN 2.4 及以后版本上过滤掉服务端推来的重定向,然后自己添加表项。否则两个默认路由并存,优先级低的那个赢。故意为之的版本是把默认路由指向隧道网关,或者在断开脚本里做:隧道一断流量就出不了网,但那是断路开关,不是选择器,而且它会一直留着,直到有人删掉。

更强的版本是网络命名空间。创建它,把客户端跑在里面,再用ip netns exec启动应用。命名空间之外看不到隧道接口,应用就不可能意外回落到宿主路由。代价是实打实的:命名空间需要一对 veth 才能接入物理网络,还需要自己的解析器文件,因为挂载命名空间并不共享宿主的域名解析设置。

两种失效模式解释了大多数泄漏。域名解析仍发生在宿主的解析器里,所以即使 TCP 连接走隧道,查询也从隧道外发出。而 IPv6 需要自己的ip -6 rule,因为 v4 规则覆盖不到它。

Windows 和 macOS:限定防火墙,或交出代理

Windows 同样没有按应用的路由选择器,但防火墙规则可以限定到某个配置文件,VPN 适配器可以被划为公用网络,而家用局域网保持专用。为这个程序在专用和域配置文件上建出站阻止规则,把公用留着开放,应用就只能走隧道出网。先设置连接的网络类别,再到防火墙的监视面板里确认规则确实命中。这是阻断而不是路由:隧道一断就失败关闭,但它拦不住无视防火墙的应用。

macOS 用另一套工具遵循同样的逻辑。通过 SSH 到达的 SOCKS 监听是最不侵入的选择,因为ssh -D 1080 user@host只开一个本地端口,只有配置了走它的应用才走这条路。pf规则可以按用户或组阻断某个程序,但仍然没法只给那个程序选隧道。Tunnelblick 根本不能按域名过滤,因为路由只认地址;OpenConnect 用户在服务端推送的前缀列表太宽时会用vpn-slice。

两种变通补上了平台工具的缺口。按域名切换代理的浏览器扩展是"只给这个网站"的实用答案,因为它们在名字仍然存在的应用层起作用;代价是只覆盖一个浏览器,别的都不管。粗暴一点的做法是把厂商客户端放进虚拟机,让来宾系统去改路由,宿主保持原路径,代价是要维护两套网络栈。

给没有绑定选项的工具一个 SOCKS 跳板

大多数应用即使完全不懂网络接口,也能被指向 HTTP 或 SOCKS 代理。SSH 客户端不用在服务端做任何改动就能提供一个:

ssh -N -D 127.0.0.1:9999 [email protected]

任何被配置为使用该端口的东西都会从堡垒机出去,桌面其余部分照旧。绑定到隧道地址的本地3proxy实例不用 SSH 也能为单个端口做到同样的事,stunnel则覆盖单个用 TLS 包裹的端口。限制很实际:代理只承载知道怎么用它的协议;UDP 需要 SOCKS5 的 UDP 关联,而很多客户端勾了"SOCKS5"并不会启用它;DNS 必须由代理解析(curl 里的socks5h),否则名字会泄漏给本地解析器。

除非别的机器需要,监听器就留在回环地址上。

用抓包验证路径,而不是状态提示

应用的状态窗口描述的是设置,不是数据包。在两个接口上都抓包并对比:

tcpdump -i any -n port 443
ss -tnp | grep 443

如果连接出现在物理接口上,而隧道上没有对应记录,那么无论客户端怎么显示,选择器都没有生效。单独检查 IPv6 和解析器,再找找第二条路径,比如某个辅助进程或继承来的系统代理。

然后挑与需求相符的边界:代理够用于一个网站,UID 规则够用于一个账号,连接必须失败关闭时防火墙配置文件够用,而命名空间是唯一能把隧道对空间外一切隐藏起来的选择。

相关文章