Mythsman


乐极生悲,苦尽甘来。


Gnirehtet生产环境实践

背景

目前业界的移动端爬虫在前端数据提取的部分大致有两套方案,一套是网络抓包、另一套是逆向Hook。无论是哪一种方案,都必须要解决一个问题,那就是网络要稳定。在一个较为狭小的空间内,如果是只有几十台设备,那一两台AP一般还能顶住。可如果有数百台设备,无脑堆AP的话就会出现各种问题(过载、负载不均、信道干扰、网段可用IP不足)等问题。

即使网络本身没有问题,在对抓包数据进行审计的时候,通常也需要维护一个设备号到IP的映射关系。随着设备的增多,映射关系的维护也是一个麻烦的事情,而且IP漂移的情况也不容忽视,管理起来也十分费劲。

既然无线网络不可靠,那么我们就考虑使用有线网络。传统手机一般都内置了 USB 网络共享功能,也就是手机通过USB和PC连接后,PC就可以使用手机的流量。但我们显然需要的应当是相反的功能,希望手机能够通过USB使用PC的流量出口。而这就是 Gnirehtet 项目解决的痛点。

Gnirehtet

GnirehtetRomain Vimont (rom1v)于2017年发起的开源项目,有 Rust 和 Java 的双重实现。这位法国老哥曾供职于 Genymobile 公司做设备监控和群控相关的工作,现在在 VideoLabs 做 VLC 相关的开发。除了 Gnirehtet 这个项目,他的 scrcpy 项目也非常棒( 50k+ star),目前我们也在内部系统中集成并魔改了这个项目用于设备投屏监控以及远程操作。

这个项目一个最大的坑就是他的读法,由于 rom1v 取 tethering 逆序之意,因此从音节上看可读性并不好。因此我私下一直都只叫他VPN项目 :)

架构图

Gnirehtet 项目主要分为三块:Apk(部署在手机端),RelayServer(部署在主机端),CommandLine。

  • Apk 端会实现 Android 系统的 VpnService 接口。用户授权后,系统的所有流量都会以IP报文的形式传给 Apk。
  • RelayServer 会与Apk建立一个长链接以获得IP报文。获得IP包后,根据 RFC-793RFC-768 标准分别解出 TCP 和 UDP 报文的目的IP和内容,然后自行与目的IP建立连接,再进行数据转发(其实就是实现了NAT)。
  • CommandLine 用于进行初始化、Apk和RelayServer的启停、和一些辅助操作。

流程分析

初始化

  1. CommandLine 启动 RelayServer,RelayServer 默认开启 31416 TCP端口,用于监听 Apk 的连接。
  2. CommandLine 通过实现 track-devices 协议,与 adb 的 5037 端口通信,实时获得设备上下线信息。
  3. CommandLine 通过 adb reverse ,将本地的 31416 TCP端口映射到手机的 localabstract:gnirehtet 的Unix域端口,方便 Apk 端访问。
  4. CommandLine 通过 adb 向 手机设备中安装 APK。
  5. CommandLine 通过 adb shell 的 am start 指令拉起 Apk 的 activity。
  6. Apk 拉起后,首次启动时需要获得用户的开启VPN的授权。
  7. Apk 服务的 VpnService 服务被拉起,通过 localabstract:gnirehtet 与 RelayServer 建立好长连接。

初始化完成后,手机端的通知栏会出现VPN选项。

同时,手机的 ifconfig 中也会看到多了一个 tun0 的通道:

$ adb shell
cereus:/ $ ifconfig
lo        Link encap:UNSPEC
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope: Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:125860 errors:0 dropped:0 overruns:0 frame:0
          TX packets:125860 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1
          RX bytes:2585242418 TX bytes:2585242418
 
tun0      Link encap:UNSPEC
          inet addr:10.0.0.2  P-t-P:10.0.0.2  Mask:255.255.255.255
          inet6 addr: fe80::76d3:d75a:4a1b:a574/64 Scope: Link
          UP POINTOPOINT RUNNING  MTU:16384  Metric:1
          RX packets:336 errors:0 dropped:0 overruns:0 frame:0
          TX packets:412 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:500
          RX bytes:130951 TX bytes:71517

运行时

  1. 系统将所有的流量发送给 Apk。
  2. Apk 将 IP 报文发送给 RelayServer。
  3. RelayServer 解析 IP报文头,区分出 TCP 和 UDP 请求,并将其余类型的报文丢弃(例如ICMP等)。
  4. RelayServer 根据从 IP 报文解析出的 IPv4 地址,以及从 TCP 或 UDP 中解析出的端口,与实际的目的地址进行数据收发。

坑点处理

由于 Gnihretet 这个项目应该没被系统性地使用过,因此该项目在大规模应用时会出现很多问题。截至 v2.5 版本,依然有很多坑或bug。以下问题的解决方案中有一部分已经提给 rom1v 进行修复,但是截至目前还没有发布新的 Release。

命令阻塞问题

在 CommandLine 向手机安装 Apk 的时候,有概率会出现命令卡在 `Checking gnirehtet client... 之后,这是因为在 com.genymobile.gnirehtet.Main 在用 dumpsys package com.genymobile.gnirehtet 检测是否安装 Apk 时,在 fork 子进程的时候,未及时将新子进程写在缓冲区的数据读出,从而主进程 wait 子进程结束、而子进程 wait 缓冲区 flush, 导致死锁。

解决方案就是主进程保证先读出子进程的写缓冲区,再 wait 子进程结束。

         List<String> command = createAdbCommand(serial, "shell", "dumpsys", "package", "com.genymobile.gnirehtet");
         Log.d(TAG, "Execute: " + command);
         Process process = new ProcessBuilder(command).start();
-        int exitCode = process.waitFor();
-        if (exitCode != 0) {
-            throw new CommandExecutionException(command, exitCode);
-        }
-        Scanner scanner = new Scanner(process.getInputStream());
-        // read the versionCode of the installed package
-        Pattern pattern = Pattern.compile("^    versionCode=(\\p{Digit}+).*");
-        while (scanner.hasNextLine()) {
-            Matcher matcher = pattern.matcher(scanner.nextLine());
-            if (matcher.matches()) {
-                String installedVersionCode = matcher.group(1);
-                return !REQUIRED_APK_VERSION_CODE.equals(installedVersionCode);
+        try {
+            Scanner scanner = new Scanner(process.getInputStream());
+            // read the versionCode of the installed package
+            Pattern pattern = Pattern.compile("^    versionCode=(\\p{Digit}+).*");
+            while (scanner.hasNextLine()) {
+                Matcher matcher = pattern.matcher(scanner.nextLine());
+                if (matcher.matches()) {
+                    String installedVersionCode = matcher.group(1);
+                    return !REQUIRED_APK_VERSION_CODE.equals(installedVersionCode);
+                }
+            }
+        } finally {
+            int exitCode = process.waitFor();
+            if (exitCode != 0) {
+                // Overwrite any pending exception, the command just failed
+                throw new CommandExecutionException(command, exitCode);
             }
         }

内存不足问题

由于 Gnirehtet 需要维护并模拟所有手机到外网的长链接,因此在 RelayServer 内部维护了很多 StreamBuffer 和 DatagramBuffer 等的缓冲区用于进行数据拷贝。这就导致当连接数和设备数比较大时,很容易出现 OOM 等问题。

解决方案如下:

  1. 减小 com.genymobile.gnirehtet.relay.TCPConnection 中 clientToNetwork 的缓冲区大小,默认是IP报文长度的8倍。
  2. 减小 com.genymobile.gnirehtet.relay.UDPConnection 中 clientToNetwork 的缓冲区大小,默认是IP报文长度的4倍。
  3. 减小 com.genymobile.gnirehtet.relay.Client 中 networkToClient 的缓冲区大小,默认是IP报文长度的16倍。
  4. 在启动 RelayServer 时把 JVM 堆大小适当增大。
  5. 后续可以考虑将缓冲区的逻辑进行改造,其实没有必要维护这么多空缓冲区,可以做一些共享缓冲区的逻辑。

连接过多问题

当设备连接时间变长后,如果有些连接回收的不及时,Router 维护的 Connection 表就会出现如下问题:

  1. Router 的 Connection 表是数组形式存储,查找和删除性能会随着连接数变长而变差。rom1v 认为这里连接数不多,List 的性能要高于 Map,但是生产用起来才发现由于回收不及时,这里的连接数经常是上千的量级。
  2. 很多老的 Connection 没法及时得到释放,导致他们占用的缓冲区也没法及时释放,从而加剧了内存不足的问题。

解决方案如下:

  1. 将 Router 的 Connection 表改造成 ConcurrentHashMap。
  2. 对 Connection 进行 LRU 淘汰,对每个 Client 限制最大的连接数。在一个新连接被建立时,如果当前连接数超过最大连接数,则close 掉最久没被使用过的一批连接。

无法监听设备上下线

前文提到,Gnirehtet 的 RelayServer 是通过 adb 的 track-devices 协议从 adb 获取设备的上下线信息的。相关逻辑在 com.genymobile.gnirehtet.AdbMonitor 中。

但是 rom1v 并没有很好处理好异常情况。当大量设备同时批量上下线时,AdbMonitor 维护的状态就会有问题,并且接受 track-devices 信息的地方无法从异常解析中恢复,这就导致后续设备上下线的信息都被丢弃了。

解决方案:

 static String readPacket(ByteBuffer input) {
     if (input.remaining() < LENGTH_FIELD_SIZE) {
         Log.i(TAG, "No field size found");
         return null;
     }
     // each packet contains 4 bytes representing the String length in hexa, followed by a list of device states, one per line;
     if (input.remaining() < length) {
         // not enough data
         input.rewind();
-        return null;
+        throw new RuntimeException("Not Enough data");
     }
     input.get(BUFFER, 0, length);
     return new String(BUFFER, 0, length, StandardCharsets.UTF_8);
 }

客户端宕机问题

由于安卓系统的特性,正在跑的 VPN 客户端总是会因为各种原因被Kill 。无论是系统内存不足、还是用户主动清理后台、还是因为手机的省电策略,客户端都有可能挂掉。

简单的解决方法是手动在 ”最近活动“ 中将 Gnirehtet 的客户端用小锁锁住。这样虽然也不能完全保证不被杀,但是生存的概率还是大了很多。

如果嫌手动搞麻烦,也可以用 adb 工具直接设置 settings:

$ adb shell settings get system locked_apps
[{"u":0,"pkgs":[]},{"u":-100,"pkgs":["com.jeejen.family.miui"]}]
$ adb shell settings put system locked_apps '"[{\"u\":0,\"pkgs\":[\"com.genymobile.gnirehtet\"]},{\"u\":-100,\"pkgs\":[\"com.jeejen.family.miui\"]}]"'
$ adb reboot

重启后,就会发现客户端一样加了锁。

业务优化

以下是我们在生产环境使用时,出于性能提升或审计需要实现的一些功能。虽然不是必须的功能,但也算是一种不错的实践。

桌面icon展示

默认的 gnirehtet.apk 在桌面时没有图标的,因此有时候比较难判断是否安装过,也不方便手动启动。通过修改 AndroidManifest.xml 文件,将android:icon=”@null" 修改为一个指定图片即可。

DNS缓存

Gnirehtet 的 Apk 中将 DNS 服务器指定为 8.8.8.8 ,当然 Gnirehtet 也提供了指定 DNS server 的参数,但是在流量较大的情况下,如果能使用到本地的 DNS 缓存服务就更好了。

做法如下:

  1. 在 Linux 主机上开启 DNS 缓存服务(一般默认都是开启的),服务开启在了 127.0.0.53#53 。
  2. 修改 RelayServer,在 com.genymobile.gnirehtet.relay.AbstractConnection  中,仿照 主机 127.0.0.1 映射 10.0.2.2 的逻辑,将 主机的 127.0.0.53 映射到 10.0.2.53 。
  3. 在启动 Gnirehtet 的 RelayServer 时,带上 -d 10.0.2.53  参数即可。

最后看一下本地缓存命中率:

$ systemd-resolve --statistics
DNSSEC supported by current servers: no
 
Transactions
Current Transactions: 0
  Total Transactions: 40246262
 
Cache
  Current Cache Size: 823
          Cache Hits: 31190533
        Cache Misses: 9319409
 
DNSSEC Verdicts
              Secure: 0
            Insecure: 0
               Bogus: 0
       Indeterminate: 0

在我们的量级下、默认缓存配置一般都能缓存 3/4 左右的请求。虽然实际使用体感上看不出啥变化,但从理论上应该还是有点用的 :)

网络检测

由于各种原因,Gnirehtet 有跪的可能,因此需要在主机上检测手机网络到底与主机通不通。但是由于 Gnirehtet 会丢弃 ICMP 协议,因此 ping 是 ping 不到东西的。

因此我们在所有设备的 /data/local/tmp/ 下安装了 busybox ,使用 nc 命令与尝试与主机的某个端口通信。如果 socket 能连接上,则说明 vpn 工作正常。

$ adb shell /data/local/tmp/busybox nc -vzw1 10.0.2.2 5037
10.0.2.2 (10.0.2.2:5037) open

-v 参数保证有回显, -z 参数表示只检测连通性不处理IO,-w1 表示最多等待 1s 超时。

10.0.2.2 是在 Gnirehtet 的 RelayServer 中硬编码的、在手机中代指主机的IP地址。

5037 是 adb 的端口,这个这个可以改成主机上任意一个可用的端口。只是借来探测连通信,不会发送实际数据。

设备号透传

出于审计(抓包统计)需要,我们总是想知道,某一个流量是来自哪一个手机设备。在 WIFI 网络方案中,我们(虽然愚蠢但是)可以手动维护设备ID与网络IP的关系。但是在 Gnirehtet 方案中,连接在同一主机的设备是公用同一出口IP的,无法进行区分。

为了解决这个问题,我们借鉴并扩展了 HAProxy 的 proxy-protocol 协议,仿照 py-proxy-protocol 的实现,在 TCP 建立连接后发送一小段数据包用于透传设备序列号。具体分为以下步骤:

  1. Apk 需要申请 android.permission.READ_PHONE_STATE 权限,读取设备序列号。(申请方式随 Android 版本而不同)。
  2. Apk 在与 RelayServer 建立连接后,需要将序列号透传给 RelayServer。
  3. RelayServer 在与外部主机(这里是我们自己的代理主机)建立连接时,透传一段 proxy-protocol 协议,并带上序列号。
  4. 代理主机解析扩展的 proxy-protocol 协议,获取序列号,这样就将每一个连接和具体的设备号一一对应上了。

异常恢复

无论如何,总会有一些未知的 bug 或者无法恢复的情况,因此我们也做了VPN相关的兜底策略:

  1. 根据上面的网络检测功能,如果发现设备网络不通,则自动尝试重启手机上的 VPN 客户端。
  2. 如果多次重启VPN客户端后,网络依然不通,则重启手机。

这样的步骤下来,基本上能解决 99% 的问题了 :)

参考资料

https://github.com/Genymobile/gnirehtet/blob/master/DEVELOP.md

https://developer.android.com/guide/topics/connectivity/vpn

https://android.googlesource.com/platform/system/adb/+/refs/heads/master/SERVICES.TXT