可以有两条路径把 IP 接口放到 Android 应用之前,而两者最终都落在同一种文件描述符上,只是所有者截然不同。一条经过 VpnService 及其 builder,另一条经过 root shell 和 /dev/net/tun。对该描述符的每次读取都是一个出站数据包,每次写入都是一个入站数据包。区分这两条路径的,是谁掌握策略、谁能挺过系统升级,以及你能改动多少路由表。

除非你能说出平台明确拒绝做的具体事情,否则就走平台路径。这种拒绝通常是 TAP、捕获自己应用集合之外的流量,或者框架从不暴露的路由规则。

在平台路径上,你声明的是意图而不是命令。你提供至少一个地址、想要的路由、DNS 服务器和一个可选的应用列表,然后 Android 的系统服务以自己的身份完成特权配置。你永远不会提前知道接口名,也永远不会发出创建它的 ioctl,这正是关键所在。

获得 IP 接口的两条路径

root 路径是更老的做法。你打开 /dev/net/tun,用 IFF_TUN 调用 TUNSETIFF,用 ip link 把接口拉起来,再手动添加地址和路由。每一步都要你亲自做对,包括框架本来会静默处理的部分。少一条路由,用户看到的报告和服务器挂掉时一模一样。

调试同样差别很大。在 root 路径上,接口要么存在,要么你的 ioctl 返回了一个可以直接读取的错误。在平台路径上,接口只有在 establish() 成功之后才存在,所以失败会以异常或 null 描述符的形式出现,而不是一个可以随手捅一捅的设备节点。

establish() 实际返回什么

Builder.establish() 有三种结果,各自需要不同的应对。返回描述符意味着接口已配置好、归你使用;返回 null 意味着请求没有通过;抛出异常意味着系统服务拒绝了它。把 null 和成功合并成一个布尔值,是应用最常见的做法,会导致一个从未承载任何数据包的隧道被报告为正常工作。Builder 参考 写明了地址要求,在怪罪服务器之前值得读一读。

方向由描述符固定,而不是由你的协议决定。读取给你的是已经被选中进入 VPN 的出站数据包,写入则把入站数据包注回接口。加密、认证和分帧都由你自己的传输层完成,因为平台止步于数据包边界。

Binder 是一次跳转,JNI 是另一个维度

builder 请求通过 Binder 进入系统,而在它下面的某个环节,平台调用原生代码来配置设备。这是同一次调用里叠着的两套机制。Binder 是你的应用与系统服务之间的进程间通信,而 JNI 是进程内从 Java 到原生代码的桥。读 Android 9 的 AOSP 树会让这个栈看起来字面可循,也要记住内部类名和文件路径会随版本变动。

不变的是你可以依赖的契约:builder、它返回的描述符,以及围绕它的服务生命周期。任何应用都不应该为了拿到接口而去调用内部服务事务或重建设备节点。如果你的代码依赖某个 AOSP 类名,它会在你没测过的版本上断掉。

TUN 承载 IP,TAP 承载 Ethernet

TUN 是 IP 接口。TAP 是 Ethernet 接口,这个差别决定了桥接是否可能。TUN 设备没有 MAC 地址,也没有可转发的 Ethernet 首部,所以无论路由怎么写,TUN 客户端都无法加入2 层网桥。

VpnService 给你的是 IP 变体,这正是应用级 VPN 需要的,也是它在没有原始 Ethernet 栈时能支持的。如果你的设计需要桥接,平台路径走不到那里;root 路径只有在内核和设备权限配合时才能做到。

root 路径仍然占优的场合

三件事会把人们推离平台路径。2 层桥接需要 TAP。捕获自己 VPN 范围之外的应用流量,需要 框架不会给予的可见性。builder 从不暴露的策略路由,比如为某个目的地单独建表,需要你自己安装规则。

代价在后面才到:解锁的 bootloader 或 root 过的镜像、随时会变动的 SELinux 策略,以及 OTA 之后行为不再正确的设备。root 方案也无法通过正常应用分发发布,所以多数团队把它留作内部诊断路径,而不是产品。

在两者之间选择

从你需要的接口出发,而不是从你手上的代码出发。如果答案是一个带应用范围路由的 TUN 设备,平台路径更短,也能扛过升级。如果答案涉及 Ethernet 帧、捕获其他应用,或 builder 拒绝的路由规则,那你就在 root 的地界上,应该把它当作运营成本来预算。

一个能了结多数争论的检查:先做平台路径,把 establish() 的结果与远端会话状态分开记录,并确认每个方向都有一个数据包通过。只有到那时,才判断缺失的能力是否值得离开受支持的路径。

相关文章