VpnService 的隧道实现,MTU 和 DNS 配置细节
「VpnService 的隧道实现,MTU 和 DNS 配置细节」
Android 的 VpnService 从 API 14 就有了,但真正能做出稳定可用的隧道实现,踩的坑远比文档上写的多。我去年维护一个基于 VpnService 的代理工具时,花了将近三周时间才搞清楚 MTU 和 DNS 配置里那些不会报错的隐性陷阱。这篇文章只讲这两个具体问题,因为它们是生产环境里最常见的故障来源。
建立隧道时的 Builder 配置盲区
VpnService.Builder 的 API 表面看起来很简单:addAddress、addRoute、addDnsServer、establish,四步完成。但实际运行中,establish 返回的 ParcelFileDescriptor 只是拿到了一个文件描述符,真正的数据流行为取决于前面每一步的参数组合。
我最开始直接抄了官方示例的代码:
Builder builder = new Builder();
builder.addAddress("10.0.0.2", 24);
builder.addRoute("0.0.0.0", 0);
builder.addDnsServer("8.8.8.8");
builder.setMtu(1500);
ParcelFileDescriptor fd = builder.establish();这段代码在模拟器上跑得很顺,但到真机上出现了诡异的分片问题。某些 HTTPS 网站能打开,另一些直接超时,抓包发现 TCP 握手完成后第一个大数据包就丢了。问题出在 MTU 1500 这个值上,它不等于"我设了 1500 就能用 1500"。
Android 的 VpnService 隧道接口是 TUN 设备,TUN 本身不处理物理链路,但系统会根据 MTU 值决定向你的 VPN 应用写入多大的数据包。当底层物理网络是 Wi-Fi 时,MTU 通常确实是 1500;但切换到移动数据,尤其是某些运营商的 LTE 网络,底层 MTU 可能是 1420 甚至 1350。你的 TUN 设备声明 1500,系统就会往上塞 1500 字节的数据包,但你的 VPN 应用把这个包封装进 UDP 再发出去,加上外层头部就超了。如果外层是 WireGuard 的封装,UDP 头部 8 字节 + WireGuard 头部 16 字节 + 可能的 IP 头部 20 字节,1500 字节的 payload 变成 1544 字节,必被分片。
分片本身不致命,但许多移动网络的中间设备对 IP 分片处理得很差,尤其是 UDP 分片。更隐蔽的是,IPv6 不允许中间路由器分片,DF 标志默认置位,这会导致直接丢包。
我实际测试下来的经验:MTU 应该按 底层 MTU - 封装开销 来设,而不是固定 1500。但 VpnService 没有 API 让你查询底层物理 MTU,只能保守估计。对于基于 UDP 的隧道(WireGuard、QUIC 等),设 1280 是最安全的选择,这是 IPv6 要求的最小 MTU,能保证不被分片。对于 TCP 封装的隧道,可以稍微激进到 1400,但 1280 仍然是最少纠纷的值。
// 保守但可靠的设置
builder.setMtu(1280);这个改动解决了我遇到的大部分"部分网站打不开"问题。但 MTU 只是第一层,DNS 的配置陷阱更深。
DNS 的劫持与泄露:addDnsServer 的语义
VpnService.Builder.addDnsServer 这个 API 的名字极具误导性。它添加的 DNS 服务器地址,不是"你的 VPN 应用要使用的 DNS",而是"系统认为当前网络应该使用的 DNS"。这个区别决定了 DNS 请求会不会进入你的隧道。
当你调用 addDnsServer("8.8.8.8") 时,Android 系统会做两件事:一是把这个地址写入系统的 VPN 接口配置,让系统的 DNS 解析器知道;二是决定哪些 DNS 查询走这个服务器。关键问题是:这些查询的流量,会不会经过你的 VpnService 的 ParcelFileDescriptor?
答案是:取决于查询的来源和 Android 版本。
从 Android 8.0(API 26)开始,系统引入了私有 DNS(Private DNS,即 DNS-over-TLS)功能。用户在设置里可以开启"自动"或指定主机名。当 Private DNS 开启时,系统的 DNS 解析器会尝试直接建立 TLS 连接到指定服务器,绕过你的 VPN 隧道。这不是 bug,是 Android 的安全设计:系统认为 DoT 比你的 VPN 更可信。
但对你的 VPN 应用来说,这意味着 DNS 泄露。用户以为所有流量都经过 VPN,实际上 DNS 查询走了直接连接,中间设备能看到用户访问了哪些域名。
我最初的做法是在 Builder 里加 allowBypass,试图阻止这种行为:
builder.allowBypass(false); // 默认值,但显式写出来这没用。allowBypass 控制的是应用层能否绕过 VPN(比如设置 android.net.VpnService 权限的应用),不影响系统级 DNS 解析器的行为。Private DNS 是系统组件,不受 allowBypass 约束。
真正的解决方案分两步。第一步,如果你的 VPN 本身提供 DNS 解析服务,应该拦截所有 DNS 流量并重定向到本地。这需要在 TUN 接口的 fd 上读到目的端口 53 的 UDP 包时,不转发到远端,而是本地处理。但这里有个坑:Android 系统自己的 DNS 解析器可能不走 53 端口,而是走 DoT 的 853 端口,或者通过本地 netd 的代理。
第二步,更彻底的做法是,在建立 VPN 后,用 ConnectivityManager 的 API 检查当前网络的 DNS 配置,并提示用户关闭 Private DNS。但这需要 API 29 以上的 getLinkProperties,而且用户体验很差。
我最终采用的方案是混合策略:在 VPN 隧道内运行一个本地 DNS 代理(监听 10.0.0.2:53),Builder 里把 addDnsServer 指向这个地址,同时在 TUN 接口上拦截所有目的端口 53 的流量,以及所有目的端口 853 的 TCP 流量(阻止 DoT)。对于 DoH(DNS-over-HTTPS,端口 443),很难单纯靠端口区分,只能依赖域名黑名单。
// 本地 DNS 代理地址
builder.addDnsServer("10.0.0.2");
builder.addAddress("10.0.0.2", 32);
// 在 TUN 读取循环中
if (packet.isUdp() && packet.dstPort == 53) {
handleLocalDns(packet);
continue;
}
if (packet.isTcp() && packet.dstPort == 853) {
// 阻断或重定向 DoT
sendRst(packet);
continue;
}这个方案在 Android 10 到 14 上测试有效,但 Android 15 又有了变化。Android 15 开始,系统对 DNS 的处理更加集中,netd 的行为有调整,某些情况下系统 DNS 查询不再走 TUN 接口,而是直接通过底层网络发出。这是为了优化性能,但对 VPN 应用来说是灾难。目前社区里还没有完美的解决方案,只能持续跟踪 AOSP 的提交记录。
读取 TUN 设备时的数据包边界
回到 ParcelFileDescriptor 的读取。很多人直接用 FileInputStream 包装 fd,然后 read(byte[]),这能工作,但有个性能细节:TUN 设备的 read 每次返回一个完整的数据包,不会分包,也不会合并。这个保证让你可以省略很多流式解析的复杂度,但 byte[] 的大小必须足够。
我一开始设了 2048 字节的缓冲区,认为 1280 MTU 加头部绰绰有余。但忘了 IPv6 的 Jumbogram 扩展,以及某些情况下 TUN 设备会返回带辅助数据(ancillary data)的包。实际上,TUN 接口返回的是完整的 IP 包,包括头部,而 IPv6 头部 40 字节加上扩展头部,再加 1280 payload,已经超过 1320。更关键的是,如果系统配置 MTU 大于你声明的值(某些 ROM 会忽略 setMtu),或者存在 TSO(TCP Segmentation Offload)的模拟,你可能收到远大于 MTU 的包。
我遇到的具体案例是某款小米手机的 MIUI 14,系统忽略了 VpnService 的 setMtu(1280),实际往 TUN 设备写入 1500 字节的数据包。我的 2048 缓冲区理论够用,但加上自己的协议封装头部后,发送缓冲区计算错误,导致溢出。最后把读取缓冲区固定到 32768,发送侧动态计算,才消除这个隐患。
// 读取侧:足够大的固定缓冲区
byte[] buffer = new byte[32768];
FileInputStream in = new FileInputStream(fd.getFileDescriptor());
while (true) {
int length = in.read(buffer);
if (length > 0) {
// buffer[0:length] 是一个完整 IP 包
processPacket(buffer, length);
}
}另一个细节是写入 TUN 设备。FileOutputStream 的 write(byte[]) 是线程安全的,但 write(byte[], int, int) 的原子性保证只针对单个调用。如果你从多个线程往 TUN 写,必须用锁同步,否则两个线程的包可能交错,产生无效的 IP 包。这个在文档里没提,是我用 ThreadSanitizer 跑测试时发现的。
路由表的精细控制:addRoute 的副作用
addRoute("0.0.0.0", 0) 是最常见的配置,表示所有 IPv4 流量走 VPN。但这条路由在系统层面的实现,比你想象的复杂。
Android 的 VPN 路由不是简单的路由表条目,而是通过 VpnService 的 IPackageBasedVpnService 接口,让系统的网络选择逻辑优先选择 VPN 接口。对于目标地址匹配路由的流量,系统会标记为需要经过 VPN。但标记不等于实际转发,如果 VPN 应用本身没有运行,或者 establish 后的 fd 没有读取线程,这些流量会被丢弃,应用表现为超时。
更精细的控制可以通过 addRoute 的排除语义实现。从 API 33(Android 13)开始,Builder 支持 excludeRoute:
builder.addRoute("0.0.0.0", 0);
builder.excludeRoute(new IpPrefix("192.168.1.0/24"));这解决了家庭局域网访问的常见需求。之前要实现这个,必须用 addRoute 逐条添加所有公网段,或者维护一个庞大的路由表,性能很差。excludeRoute 的语义是:先匹配 include,再排除 exclude,逻辑清晰。
但 excludeRoute 有个我踩过的坑:它只影响系统路由决策,不影响你的 VPN 应用本身通过其他网络接口发出的连接。也就是说,如果你的 VPN 应用需要直接访问某个地址(比如 VPN 服务器本身),这个地址必须在 excludeRoute 里,否则应用自己的连接也会尝试走 TUN,造成环路。我花了一整天才定位到这个问题:VPN 客户端连不上服务器,因为连接服务器的流量被自己建立的 VPN 路由劫持了。
解决方案是在建立 VPN 前,记录当前网络的服务器地址,加入 excludeRoute:
// 在 establish 之前
InetAddress serverAddr = InetAddress.getByName(serverHost);
if (serverAddr instanceof Inet4Address) {
builder.excludeRoute(new IpPrefix(serverAddr.getHostAddress() + "/32"));
}MTU 与 TCP MSS 的联动
回到 MTU,还有一个传输层的问题。TCP 连接的 MSS(Maximum Segment Size)通常由两端在握手时协商,中间网络的 MTU 不会直接参与。但当你的 VPN 隧道降低了有效 MTU,已经建立的 TCP 连接可能发送大于隧道承载能力的包,导致分片或丢包。
理想情况下,VPN 应用应该在 TUN 接口上实现 TCP MSS clamping:修改经过的 SYN 包的 TCP 选项,把 MSS 限制为 MTU - IP头部 - TCP头部。对于 1280 MTU,IPv4 下 MSS 应该是 1280 - 20 - 20 = 1240,IPv6 下是 1280 - 40 - 20 = 1220。
但 VpnService 运行在应用层,修改每个 TCP 包的头部需要完整的 IP 协议栈实现,成本很高。我试过用开源的 tun2socks 方案,它内部集成了 lwIP 协议栈,能自动处理 MSS clamping。但引入 lwIP 的代价是额外的内存拷贝和上下文切换,在低端机上 CPU 占用明显。
另一个选择是依赖系统。Android 的 TUN 驱动本身支持 IFF_NO_PI(no packet information)标志,但 VpnService 建立的 TUN 默认不带这个标志,头部有 4 字节的 packet information。这 4 字节包含了 flags 和 protocol 信息,不影响 payload 大小,但说明 Android 的 TUN 实现有自己的设计考量。
我最终的妥协是:对于 TCP 流量,不做 MSS clamping,而是把 MTU 设得足够保守(1280),同时在外层封装使用支持 PMTU 的协议。WireGuard 本身有 PMTU 发现机制,能动态调整。对于 UDP 流量,特别是 QUIC,依赖端点的 PMTU 探测。这个方案不完美,但在实际用户环境中故障率可接受。
多 DNS 服务器的优先级陷阱
Builder 允许调用多次 addDnsServer,系统会按添加顺序建立 DNS 服务器列表。但这个顺序的语义在不同 Android 版本上有差异。
在 Android 7.0 之前,系统会并行向所有 DNS 服务器查询,取最先返回的结果。这本来是为了加速解析,但如果你的 VPN 隧道延迟较高,而系统同时查询了直连的 DNS(比如运营商 DNS),用户可能拿到被劫持的结果,然后才收到你的 VPN DNS 的响应。浏览器通常用第一个返回的,这就造成了 DNS 污染。
Android 7.0 到 9.0 改为按顺序查询,先查第一个,超时再查下一个。这改善了确定性,但超时时间默认是 5 秒,用户体验很差。
Android 10 开始引入了 DNS 加权机制,结合网络评分和过去响应速度动态选择,行为更复杂。对于 VPN 接口,系统通常会给较高优先级,但不是绝对优先。
我的应对策略是:只添加一个 DNS 服务器地址,就是 VPN 隧道内的本地代理。如果本地代理需要转发到多个上游,由代理自己管理逻辑,而不是让系统决定。这样解析行为完全可控。
// 只添加一个,确保所有查询进入隧道
builder.addDnsServer("10.0.0.2");但这也带来单点故障问题:如果本地 DNS 代理崩溃,整个系统的 DNS 解析就挂了。我加了保活机制,在另一个线程监控代理进程,崩溃后自动重建 VPN 会话。
Android 14 的 `VpnService` 权限收紧
Android 14(API 34)对后台启动 VpnService 增加了限制。如果应用不在前台,或者没有 FOREGROUND_SERVICE_SPECIAL_USE 权限,系统会拒绝 establish() 调用,抛出 SecurityException。这个变更在官方文档的迁移指南里有提到,但实际测试时发现一个细节:异常不是在 establish() 时立即抛出,而是在系统服务端的异步处理中失败,应用端的 fd 看起来正常,但后续读写直接返回 -1(EOF)。
这导致我的错误处理逻辑没 catch 到问题,读取线程直接退出,VPN 静默失效。修复方案是启动 VPN 前显式检查权限,并把 establish() 包在 try-catch 里,同时监控 fd 的读写状态:
ParcelFileDescriptor fd;
try {
fd = builder.establish();
if (fd == null throw new IOException("Invalid VPN fd");
}
} catch (SecurityException e) {
// Android 14+ 后台启动限制
Log.e(TAG, "VPN start denied", e);
return;
}另外,Android 14 要求 FOREGROUND_SERVICE 必须配合 foregroundServiceType 声明。对于 VPN,应该用 connectedDevice 类型,但 Google Play 的审核对这个类型的使用有额外要求,需要确保应用确实在管理网络连接。我因为类型声明不匹配被拒审过一次,改成 specialUse 并提交声明视频才通过。
性能数据:用户态 vs 内核态
最后给一些我实际测试的性能数字。测试设备是 Pixel 7,Android 14,VPN 方案基于 WireGuard 的 userspace 实现(wireguard-android 的 tun 库)。
瓶颈主要在两个方面:一是 TUN 设备的 read/write 每次系统调用开销,二是 Go runtime 的调度延迟。我尝试过把 Go 的部分换成 Rust 的 tun 库 + tokio,性能提升到 550 Mbps,但包体积增加 2MB,启动延迟也变差。
对于移动场景,350 Mbps 其实足够,但 CPU 占用高意味着发热和耗电。我的优化方向是减少 TUN 读取线程到加密线程的数据拷贝,用 ByteBuffer 的 direct buffer 让加密库原地处理。这个优化提升了约 15% 的吞吐,但代码复杂度显著增加。
另一个发现是:Android 的 FileChannel 不支持 ParcelFileDescriptor 的 FileDescriptor,所以不能用 NIO 的零拷贝 API。只能用传统的 FileInputStream/FileOutputStream,或者 JNI 层直接 read/write fd。后者有轻微性能优势,但打破了纯 Java/Kotlin 的边界。
一个未解决的边缘 case
写到这里,还有一个我至今没完全搞清的问题。在某些 Samsung 设备上(One UI 6.0,Android 14),当 VPN 连接建立后,系统的 NetworkMonitor 会主动探测 connectivitycheck.gstatic.com 来验证网络可用性。这个探测流量应该走 VPN 隧道,但 Samsung 的定制系统似乎有额外的网络验证逻辑,偶尔会绕过 VPN 直接发送,导致探测失败时系统弹窗提示"Wi-Fi 网络无法访问互联网",即使用户实际能正常上网。
这个弹窗很恼人,用户会以为 VPN 坏了。我尝试过把 connectivitycheck.gstatic.com 加入 VPN 路由并确保响应,但弹窗仍然间歇出现。目前怀疑是 Samsung 的探测使用了特定的 Network 对象绑定,绕过 VPN 接口。这个问题没有干净的解决方案,只能引导用户在系统设置里关闭网络质量监控。
这类 OEM 定制行为的差异,是 VpnService 开发中最耗时的部分。AOSP 的行为相对可预测,但市面上 Samsung、Xiaomi、OPPO、vivo 的定制系统各有网络栈的修改,往往需要逐个机型测试,积累黑名单或 workaround。
以上就是我在 VpnService 隧道实现中,关于 MTU 和 DNS 配置的具体踩坑记录。这些细节不会出现在官方文档的示例代码里,但决定了你的 VPN 应用能不能在真实用户手里稳定运行。