没有「排除这个应用」的开关:让进程拥有自己的源地址
路由匹配的是地址,而不是进程名。让被排除的程序拥有自己的源地址和路由表,或者把决定交给路由器——在那里这样做才诚实。
一个程序走普通连接、其余全部留在隧道里,而客户端并没有为此提供开关。也没有任何隐藏菜单可以翻找。路由是从数据包内部的字段中选出的,而数据包携带的是地址和端口,不是创建它的进程身份,所以排除只能在这些字段之一里表达。把数据包走一遍,可选的手段就不再神秘。
为什么客户端无法提供
目的路由把目的地址与前缀比较,保留最长匹配。在那一刻,每个数据包看起来都一样,无论它是谁产生的。源策略路由把比较范围扩展到源地址,这正是所有实用变通做法共用的钩子:改变源,另一张表就变得可用。
端口帮不上忙。按端口排除必须在目的查找之前评估,而端口在那个决定里毫无位置。一切宣传为「按端口排除」的东西,实际上都是按别的字段排除,只是描述得含糊。
真正的按应用路由只存在于平台为此加了机制的地方,例如 Windows 上的筛选器回调,或 Android 上的 VPN 服务绑定。桌面版 OpenVPN 一样都没有,任何配置开关也变不出来。
Linux:先标记进程,再按标记路由
最干净的做法是把决定绑定到拥有套接字的进程上。owner 匹配对 OUTPUT 链中本地产生的流量有效,因此在那里打上的标记可以选中另一张表:
iptables -t mangle -A OUTPUT -m owner --uid-owner 1001 -j MARK --set-mark 0x1
ip rule add fwmark 0x1 table 200
ip route add 192.168.1.0/24 dev eth0 src 192.168.1.0 table 200
ip route add default via 192.168.1.1 dev eth0 table 200
用户 1001 的数据包随后查表 200,经普通网关出去,而主表仍把其余一切都送进隧道。owner 匹配只适用于本地产生的数据包,而这正是这里关心的流量,它无法分类为另一台主机转发的数据包。用工具而不是用意图去验证:ss -tnp 显示已建立连接的本地地址和所属进程,ip route get 1.1.1.1 mark 0x1 显示标记会选中哪张表。
网络命名空间对自足的程序是更稳的答案,因为其中创建的每个套接字在构造上就归属它,于是辅助进程和子连接无需任何分类就继承了行为。代价是管道工程:一对 veth、两端各一个地址,以及命名空间内指回宿主机的默认路由。
ip netns add novpn
ip link add veth-host type veth peer name veth-ns
ip link set veth-ns netns novpn
ip addr add 10.200.0.1/24 dev veth-host
ip netns exec novpn ip addr add 10.200.0.2/24 dev veth-ns
ip netns exec novpn ip route add default via 10.200.0.1
Windows:目的地址依然说了算
Windows 根据目的地址选择出口接口,所以想要别的路径的程序,必须改变协议栈在那个选择之前会查询的东西。ForceBindIP 覆盖前半段:它启动程序时,套接字已经绑定到你指定的本地地址。仅此并不能改变数据包从哪个接口出去,因为路由仍由目的地址选择。在默认路由指向隧道的笔记本上绑定一个源,数据包照样会进入隧道。
只要认识它,这个症状就有可读的形状。它对所选接口能直达的 LAN 地址成立,而对互联网上任何路由指向隧道的东西都会失败。它看起来也能用,直到程序把工作交给辅助进程,因为打开自己套接字的辅助进程从未被告知绑定的事。
当单个进程是错误单位时,把决定移走
如果真正的需求是设备而不是程序,就把它放到独立的 SSID 或 VLAN 上,让路由器来决定:那个源走普通路径,其他源使用隧道。专用网关机器是另一种诚实的方案:它持有 VPN,只接收路由器发给它的目的地址。在 VLAN 上,策略覆盖该设备打开的每一条流,包括破坏 Windows 做法的那些辅助进程。交换的是范围:该设备上的两个程序再也无法区别对待。
两者都是一句话就能说清范围的源或目的策略。都不是按应用的,而自称按应用的设计,在程序把工作委托给第二个进程的第一次就失败。