VpnService protect() 还是排除应用:究竟哪个能修正路由
保护承载隧道的套接字,或把整个应用移出 VPN:决定你的 Android VPN 需要多大范围的三个条件。
要把隧道自身的报文挡在隧道之外,有两种做法:保护传输套接字,或把整个应用移出 VPN。两者解决的是不同问题,而因为更容易接上就选范围更大的那个,正是应用在不知不觉中丢掉本地网络访问和分离 DNS 的原因。先想清楚哪些东西必须留在隧道之外,再选出能达到这一点的最小范围。
protect() 从隧道中去掉了什么
VpnService.protect() 是针对单个套接字的调用。你把承载隧道的套接字或描述符交给它,Android 就会给该套接字打上标记,让它的流量改用底层网络,而不是 VPN 接口。应用发出的其他一切仍然走正常的 VPN 路由。
范围窄小正是要点所在。被保护的套接字不会改变你的 HTTP 客户端如何解析名称、你的分析请求走哪个接口,也不会改变稍后打开的第二个连接会怎样。
决定范围的三个条件
条件一是:是否只有隧道的传输需要出去。如果应用的其他流量仍应走 VPN,那么套接字保护就是正确的范围。条件二是:套接字的创建是否由你掌控。某个库在内部深处自己打开套接字,就不会给你调用 protect() 的位置,除非它留出了钩子,或接受一个预先构建好的套接字。条件三是:应用是否必须直接访问本地网络或强制门户,这与保持隧道存活是两回事。
用 addDisallowedApplication() 排除应用是把钝器。它把应用建立的每个连接都从 VPN 中移除,包括你本想检查的那些,而且在某些设备上还会改变本地发现的行为。
第三种选择是用 bindProcessToNetwork() 或套接字级的网络句柄,把传输绑定到指定的网络上。当设备同时具备 Wi-Fi 和蜂窝网络,而你又希望隧道走某条特定的上行链路时,这就很有用。
在创建传输的过程中调用它
在创建传输的同一个代码块里、connect() 或 send() 之前调用 protect()。如果调用返回 false,就关闭套接字并让初始化失败;继续下去只会留下一个报文可能重新进入你自己接口的传输。
这段代码足够小,可以只放一处:
DatagramSocket transport = new DatagramSocket();
if (!protect(transport)) {
transport.close();
throw new IOException("transport not protected");
}
transport.connect(serverAddress);这里有三个细节会咬人。第一,布尔值是你能得到的唯一信号,所以记录失败时要带上足够的上下文,以便区分用户撤回了授权和调用被拒绝这两种情况。第二,protect() 覆盖的是套接字,而不是描述符编号:如果你关闭套接字后又把同一个整数用于另一个套接字,新套接字并没有受到保护。第三,当原生库打开传输时,要在原生代码连接之前保护描述符,否则路由可能已经选定了。VpnService 参考文档才是确认目标 API 级别实际提供哪些重载的地方。
测试被阻断的路径,而不只是顺利的路径
在隧道开启的状态下验证,因为只有在这种状态下两条路径才可能不同。通过受保护的传输发一个请求,再从一条从未调用过 protect() 的代码路径发一个,然后比对远端看到的结果:受保护的连接应以底层网络为源地址到达,而未受保护的连接则显示隧道自己的地址,并推动隧道的字节计数器。在设备上,ss 或 netstat 会在任何数据离开射频之前显示同样的分流。
单独测试撤销,因为授权变化会拆掉接口,却不碰你的套接字代码。撤销 VPN 授权或停止服务,重建传输,然后确认 protect() 仍然返回 true,并且授权重新授予后传输会重新连接。
如果受保护的请求仍然走了隧道,那很可能是保护调用发生在套接字已经被路由之后。把它往前移,然后重新测试。
把决定写在传输初始化代码旁边:哪些套接字受保护、哪些应用流量被排除、保护失败时应用怎么办。正是这一行能阻止后来的重构因为一个测试失败就把排除范围扩大到整个应用。它也让下一个人无需逆向 manifest 就能核对意图。