隧道建起来了,钥匙图标也出现了,但浏览器里的每个请求依然超时。在 VpnService 应用里,这个组合通常说明读取循环还活着,而输出那一半坏了,并不是服务器拒绝了握手。Android 已经创建了接口,把应用流量搬了进去,并把一个文件描述符交给你的进程。这个描述符之后的一切都是你的代码,少一次调用就足以吞掉整条数据流。

系统给你什么,不给什么

establish() 返回 tun 接口的 ParcelFileDescriptor。系统把出站 IP 包写进去,期待你的进程读出它们、判断去向,并经由普通套接字发出去。下面没有内核转发,所以没人读取时数据包就被丢弃,接口计数器永远不会动。

从计数开始,而不是靠猜。每次成功读取就递增计数器,并记录前几个包的目的地和协议。如果流量在产生而计数器停在零,故障就在你的转发代码之上,搜索范围可以缩小到 builder 配置和读取模式。如果计数器在动而浏览器仍然失败,故障在发送路径或返回路径上。

阻塞读取决定循环是否推进

setBlocking(true) 把描述符切到阻塞模式,自 API 21 起就存在。在旧版本上,绕过办法是一个小的 JNI 辅助函数,用 fcntl 清除原始描述符上的 O_NONBLOCK。模式很重要,因为它决定在没有待处理包时读取可以做什么。

在非阻塞模式下,这次读取会立刻返回负值,忽略结果的循环会把一个核跑满却完全不搬运流量。在阻塞模式下读取会等待,这正是转发线程需要的,但此后线程就无法被中断了。关闭描述符是让读取失败的受支持做法,这样循环才能退出。改用杀进程或中断线程,可能让下一次启动持有已经不再匹配接口的描述符。

每次调用只读一个包到接口 MTU 大小的缓冲区里,并把零或负返回值当作错误,而不是空包。

第一次发送之前就要 protect(),而不是之后

每个承载封装包的套接字都必须先被保护。未受保护的套接字会服从你刚装好的同一套路由规则,于是它的包又回到隧道里,被你自己的读取循环重新收走。隧道随后无休止地转发自己的输出,从外面看就像一个连上了却永远不回话的连接。

顺序是 protect、再 connect 或 bind、再 send。在 connect 之后调用 protect 可能让先前的路由决定保留下来,所以即使调用返回 true,套接字还在走错误路径。检查它返回的布尔值,为 false 时就大声失败,而不是走进一次不可能成功的发送。

在从未碰过 protect() 的套接字上,sendto 返回权限错误,就是这个错误的典型特征。

UDP 示例并不承载 HTTPS

大多数教程抄来的转发示例只处理 UDP。TCP 需要握手、序列号、确认和重传,而在 tun 描述符和套接字之间拷贝载荷的循环一样都不提供。这就是为什么 UDP 的 DNS 查询能通,而每个 HTTPS 请求都卡住。要么在用户态跑一套 TCP 栈,要么把连接交给一个在设备上终结 TCP 并转发数据流的本地代理。

在重写任何东西之前先做拆分测试:发一个 UDP 查询,再向同一主机开一个 TCP 连接。如果 UDP 路径正常而 TCP 路径卡在第一个 SYN,问题就在转发代码而不是服务器。这一次比较能省下大量用来读毫无用处服务器日志的时间。

路由列表和按应用过滤限定了你能看到什么

addAddress、addRoute 和 addDns 决定系统往隧道里送什么。路由列表之外的目的地永远到不了你的描述符,所以再多转发逻辑也修不好。记录你做的 builder 调用,并把它们和应用真正试图访问的地址做对比。

按应用选择有自己的下限:addAllowedApplication 和 addDisallowedApplication 需要 API 21,旧版本上这些方法根本不存在。按包过滤的设计在那里完全跑不起来。

重新建立会留下过期的描述符

再次调用 establish() 会返回新的描述符并关闭上一个接口。旧描述符随之失效,还阻塞在它上面的线程会拿到错误,为旧路径创建的套接字可能也需要替换。onRevoke 是用户或系统收回隧道时的回调。把生命周期交给同一个所有者,让它在重新建立之前先停掉读取线程,能避免大多数“已连接但已死”的状态。

按错误所属的步骤去读它。recvfrom 返回连接重置,说明远端在你的代码还在读取时就关闭了。地址错误的报错指向头部解析或偏移量缺陷,而不是网络。

工作顺序:打开阻塞模式,每次读一个包,解析头部,保护套接字,发送载荷,交接时干净关闭,以及覆盖目的地的路由列表。

相关文章