Android 按应用分流 VPN:排除某个应用不等于封禁它
屏幕上的按应用列表只属于一个隧道所有者,而排除某个应用只是让它绕过该隧道,并没有把它移出网络。先从那里看起。
两个人问同一个问题,却得到互相矛盾的答案,因为他们站在不同的位置上。一个人打开的是系统 VPN 设置界面,正在编辑一份应用列表;另一个人在编辑器里写 VpnService.Builder.addDisallowedApplication()。两人都把它叫作按应用的 VPN 过滤,但只有一个人真正拥有他所描述的那项控制,而且谁也没有封禁任何东西。
从用户看到的那块界面开始
打开设置,找到 VPN 条目,看看真正处于活动状态的那条连接。如果客户端提供按应用列表,那么这份列表属于该客户端自己的隧道。平台并没有公开一套通用 API,让无关的应用去读取或修改另一个 VPN 的应用选择,所以第二个应用无法替用户修正那份列表。
这是排查路上的第一个岔口。如果已安装的 VPN 没有这项设置,就没有什么可以用代码绕开的:拥有该隧道的产品本身就没有这个功能。如果它有,正确的修复就在那块界面上,由能打开它的人完成。
跟着数据包走进隧道
你自己的应用在建立隧道时,会创建一个 TUN 接口,并声明哪些应用的流量进入其中。成员关系的设置发生在接口建立之前,一旦建立,这套规则在该会话内就固定下来:
val builder = VpnService.Builder()
builder.addAddress("10.8.0.2", 32)
builder.addRoute("0.0.0.0", 0)
builder.addDisallowedApplication("com.example.bypass")
val tun = builder.establish() // membership decided here那些调用只作用于你的应用刚刚创建的隧道,不影响任何其他东西。它们不是整台设备的策略,不会为其他客户端保留,也无法伸进别人的 VPN。当某个构建调用因为包名无法解析而抛出异常时,接口根本不会被建立,这是一次干净的失败,而不是静默失败。
为什么排除看起来像封禁
被排除的应用会绕过你的隧道,走到底层的网络上。这恰恰是封禁的反面:流量仍然离开设备,只是走在你不控制的接口之外。防火墙式的应用做的正好相反。它把应用的流量接进自己的隧道,然后把数据包丢掉,或者拒绝转发,于是接口相同,而策略方向反了过来。
这种反转背后的工作并不是一个配置开关。你要从隧道描述符里读取 IP 数据包,逐条流做判断,然后要么通过受保护的套接字转发,要么丢弃。你没有处理的一切都会停下来,所以半成品隧道看起来像坏掉的网络,而不像一个能用的过滤器。如果你的目标是封禁,就要为数据包处理、前台服务,以及你无法分类的流量准备一份书面策略,并把这些都算进预算。
自己动手时会坏在哪里
Android 通常在同一时间只允许一个活动的 VPN,所以除非产品支持链式连接,你的过滤隧道和一条隐私隧道会在同一个位置上竞争。开启拦截无 VPN 连接的常驻 VPN 会改变失败方式,而这一点在这里很重要:无法进入隧道的流量会被丢弃,而不是直接发出,这正是防泄漏设计想要的,却也是调试时很少预料到的。
包可见性是第二个陷阱。解析某个应用的包名可能以 NameNotFoundException 失败,而在较新的 Android 版本上,你能看到的包集合取决于你声明过的查询。不要假设每个名字都能解析,要处理这个异常;应用被安装或卸载时重新读取列表,而不是永久缓存。
受管设备会带来第三个所有者。企业移动性策略可以设定按应用的 VPN 或常驻配置,而普通应用无法覆盖它。在调试自己的代码之前,先确认设备是否受管,因为在那种情况下,你想改的那份列表,可能拿着手机的人谁都改不了。
用一个你自己控制的应用来验证
不要用你正想过滤的那个应用做测试。用第二个应用,它的网络行为你可以观察,然后确认它的流量是否从你的隧道出去,而目标应用的流量没有。如果两者都留在里面,说明策略生效、目标已被排除;如果两者都没出去,那就是隧道本身有问题,应用列表不是原因。
然后判断你真正在造的是三样东西中的哪一样。按应用的隧道是构建器 API,列表是你配置的一部分。设备级防火墙是数据包处理加上一条你能辩护的生命周期。修改别人的 VPN 根本不是受支持的路径,那里的诚实答案是:这项设置属于那个产品。