VpnService 的隧道实现,MTU 和 DNS 配置细节

VpnService 的隧道实现,MTU 和 DNS 配置细节

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 路由不是简单的路由表条目,而是通过 VpnServiceIPackageBasedVpnService 接口,让系统的网络选择逻辑优先选择 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-androidtun 库)。


  • 纯内核 WireGuard(需要 root):单线程 iperf3 能达到 900 Mbps,CPU 占用约 15%
  • 用户态 VpnService + WireGuard-Go:同样条件下 350 Mbps,CPU 占用 45-60%
  • 用户态 + 我自己写的简化协议(ChaCha20-Poly1305,无握手状态机):480 Mbps,CPU 占用 35%

  • 瓶颈主要在两个方面:一是 TUN 设备的 read/write 每次系统调用开销,二是 Go runtime 的调度延迟。我尝试过把 Go 的部分换成 Rust 的 tun 库 + tokio,性能提升到 550 Mbps,但包体积增加 2MB,启动延迟也变差。


    对于移动场景,350 Mbps 其实足够,但 CPU 占用高意味着发热和耗电。我的优化方向是减少 TUN 读取线程到加密线程的数据拷贝,用 ByteBuffer 的 direct buffer 让加密库原地处理。这个优化提升了约 15% 的吞吐,但代码复杂度显著增加。


    另一个发现是:Android 的 FileChannel 不支持 ParcelFileDescriptorFileDescriptor,所以不能用 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 应用能不能在真实用户手里稳定运行。

    Android 的功耗归因:WakeLock、AlarmManager、JobScheduler 谁更省电 2026-07-06
    F-Droid 开源应用市场的使用体验 2026-07-07

    评论区