• Skip to primary navigation
  • Skip to main content
  • Skip to primary sidebar

陈文管的博客

分享有价值的内容

  • Android
  • Affiliate
  • SEO
  • 前后端
  • 网站建设
  • 自动化
  • 开发资源
  • 关于

Gnirehtet终端设备共享PC网络实践

2022年3月17日发布 | 最近更新于 2023年8月28日

目前的生产测试环境中,群控测试系统的基础架构是一个服务端对应N个PC Slave节点,每个PC Slave节点上连接着多台设备,这些设备有手机和车机,设备的网络连接方式是通过设备的WI-FI功能连接一个WI-FI信号实现,这种网络连接方式存在以下问题。

  • 当设备的WI-FI模块出问题或着路由的WI-FI信号中断的时候容易影响测试。
  • QA所处的区域并没有可连接的WI-FI信号或WI-FI信号很弱,不足以支持正常场景下的操作。
  • 特别是当生产环境的网络不好的时候,一旦出现较大的波动,就容易出现大面积测试过程中网络中断的问题。

基于以上对网络稳定性的需求,需要使用更稳妥可靠的网络连接方案,无线网络不可靠,那么我们就考虑使用有线网络。

在初始调研设备共享PC网络的实现方案中有3种实现方式:

  • ShadowSocks实现方案:ATX 让手机通过 USB 数据项使用 PC 的网络上网 Reverse tetherining
  • NAT实现方案:Android通过USB使用ubuntu的ipv4网络
  • Gnirehtet 方案

综合考虑生产应用的灵活性,和接入的成本,选择了Gnirehtet的方案。

目前的Android系统设备,不管是手机还是车载终端,一般都内置了 USB 网络共享功能,也就是手机通过USB和PC连接后,PC就可以使用手机的网络。但我们需要相反的功能,希望手机能够通过USB使用PC端的有线网络,这就是开源项目 Gnirehtet 解决的场景需求。

一、Gnirehtet方案介绍

1. 限制范围

Gnirehtet方案的实现是基于adb reverse 端口的方式来实现的,adb reverse是在Android 5.0版本上才引入的,因此,此方案只能应用在 >= Android 5.0的版本上,adb 需要>=1.0.36。

对于Android 5.0以下的版本的虚拟机可以用busybox的方式尝试下,但在真机上测试无效:

adb shell busybox nc -ll -p {guest port} -e busybox nc {host IP} {host port}

“guest port”是模拟器中的端口,“host”是运行模拟器的PC,既然是虚拟机其实也没必要使用这种方式,虚拟机运行之后直接使用的是PC上的网络。

2. Gnirehtet服务端部署

此实现方案无需Root权限。服务端可以部署在GNU/Linux, Windows 和 Mac 系统上。目前这个方案支持基于IPv4的TCP和UDP协议的共享转发,其他协议的数据都会丢弃掉,暂不支持IPv6。

服务端有两个实现版本,一个版本是用Java,另外一个版本是用Rust。一般在Window下使用Java版本,Mac 和 Linux上使用Rust版本。

运行包括两个部分gnirehtet可执行文件服务端,Gnirehtet.apk 客户端,使用./gnirehtet relay部署服务端之后,用./gnirehtet start <serial>来安装启动对应序列号的客户端。也可以用./gnirehtet autorun来启动服务端,之后会对连接PC的所有设备自动开启网络共享。

Gnirehtet.apk 客户端其实就是一个继承VpnService的VPN应用,在用户授权VPN权限后,系统的所有流量都会以IP报文的形式传给 Apk。服务端会与Apk建立一个长链接以获得IP报文。获得IP包后,根据 RFC-793 和 RFC-768 标准分别解出 TCP 和 UDP 报文的目的IP和内容,然后自行与目的IP建立连接,再进行数据转发(其实就是实现了NAT)。

Gnirehtet网络共享路由

二、Gnirehtet整合应用

这边需要把Gnirehtet实现整合到项目操作流程中,改动包括两部分,一部分是Gnirehtet.apk 客户端,代码不多,直接整个包模块移植整合到现有客户端中,另外一个是服务端,根据实际的需求改造,去掉了启动的时候检测Gnirehtet.apk包是否安装的逻辑,“com.genymobile.gnirehtet”替换成整合之后的包名参数。修改完之后进入到relay-rust目录,编译服务端,使用以下命令:

cd relay-rust 
cargo build --release 

其他的编译配置参考GitHub上的开发文档Gnirehtet DEVELOP.md

这边需要注意的是,在Mac上编译的服务端只能用在Mac上,不能用在Linux上,如果是要应用在Linux上,把修改之后的代码上传或拷贝到Linux环境之后编译。Windows 服务端也需要在Linux上去配置编译,这边列下编译时候需要用到的几个命令(Mac环境下):

//进入到工程目录
cd /Users/chenwenguan/Documents/ARC/Project/gnirehtet-master/
// 压缩打包relay-rust服务端
zip -q -r relay-rust.zip relay-rust
// 上传压缩文件到Linux地址目录
scp -P 22 /Users/chenwenguan/Documents/ARC/Project/gnirehtet-master/relay-rust.zip arc@s13.btos.cn:gnirehtet/
// 解压服务端
unzip -o relay-rust.zip
// 进入到解压之后的relay-rust目录
cd relay-rust
// 编译Linux服务端版本
cargo build --release
// 编译Windows服务端版本,当然执行之前需要根据Gnirehtet开发文档配置下环境
cargo build --release --target=x86_64-pc-windows-gnu
// 下载编译之后的服务端到本地
scp -r arc@s13.btos.cn:gnirehtet/relay-rust/target/release/gnirehtet /Users/chenwenguan/Downloads/

1. 启动客户端VPN授权弹窗自动化点击确认实现

在开启客户端共享PC网络之后,Android客户端会弹出一个VPN授权确认弹窗,只需要点击一次,除非在设置里面取消了VPN授权,后续无需再设置,但在自动化测试场景下,批量设备触发总不能手动去点击确认,需要一个自动确认的实现机制。

Gnirehtet VPN授权弹窗

这边给出接入无障碍服务实现的自动化授权弹窗点击,接入无障碍服务之后,可以监控当前界面弹出的弹窗,实现授权弹窗自动点击确认,无需人工操作。在确认授权之后,通知栏会显示一个钥匙🔑的小图标。

public class VPNAuthService extends AccessibilityService {
    public static boolean started = false;
    @Override
    public void onCreate() {
        super.onCreate();
        started = true;
    }
    @Override
    public void onAccessibilityEvent(AccessibilityEvent accessibilityEvent) {
        if(accessibilityEvent.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED){
            CharSequence pkg = accessibilityEvent.getPackageName();
            CharSequence cls = accessibilityEvent.getClassName();
            if(pkg != null && pkg.equals("com.android.vpndialogs")
                && cls != null && cls.equals("android.app.Dialog")){
                AccessibilityNodeInfo root = getRootInActiveWindow();
                if(root != null){
                    List<AccessibilityNodeInfo> messages = root.findAccessibilityNodeInfosByText("VPN");
                    if(messages.size()>0){
                        for(AccessibilityNodeInfo info:messages){
                            String msg = info.getText().toString();
                            //这边根据实际整合的APK来修改判断条件,也可以用info.getViewIdResourceName()来判断,值为com.android.vpndialogs:id/warning
                            if(msg.contains("ARC") && msg.contains("VPN")){
                                List<AccessibilityNodeInfo> confirm = root.findAccessibilityNodeInfosByText("确定");
                                if(confirm.size() > 0){
                                    for(AccessibilityNodeInfo i : confirm){
                                        i.performAction(AccessibilityNodeInfo.ACTION_CLICK);
                                    }
                                }
                                break;
                            }
                        }
                    }
                }
            }
        }
    }
    @Override
    public void onDestroy() {
        started = false;
        super.onDestroy();
    }
}

AndroidManifest.xml里面的配置实现如下:

<service
  android:name=".VPNAuthService"
  android:description="@string/vpn_auth_service_description"
  android:label="@string/vpn_auth_service_label"
  android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
  <intent-filter>
    <action android:name="android.accessibilityservice.AccessibilityService"/>
  </intent-filter>

  <meta-data
    android:name="android.accessibilityservice"
    android:resource="@xml/vpn_accessibility_service_config"/>
</service>

vpn_accessibility_service_config.xml 放在res/xml/目录下,根据实际需求调整配置accessibilityFlags和accessibilityEventTypes参数,配置如下:

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
  android:description="@string/vpn_auth_service_description"
  android:accessibilityEventTypes="typeWindowStateChanged"
  android:accessibilityFlags="flagReportViewIds"
  android:accessibilityFeedbackType="feedbackSpoken"
  android:notificationTimeout="100"
  android:canRetrieveWindowContent="true"/>

接着是在整合之后的GnirehtetActivity.java类的handleIntent函数中新增如下代码,确保启动VPN授权之前已经开启了无障碍服务。

private void handleIntent(Intent intent) {
    String action = intent.getAction();
    Log.d(TAG, "Received request " + action);
    boolean finish = true;
    if (ACTION_GNIREHTET_START.equals(action)) {
        if(!VPNAuthService.started){
            //-----add start
            Settings.Secure.putString(ArcApplication.getApplication().getContentResolver(), "enabled_accessibility_services", "\"\"");
            Settings.Secure.putString(ArcApplication.getApplication().getContentResolver(), "enabled_accessibility_services", "com.autonavi.arc.jarvis/jp.co.cyberagent.stf.MaskAccessibilityService");
            Settings.Secure.putInt(ArcApplication.getApplication().getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED, 1);
            //-----add end
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        VpnConfiguration config = createConfig(intent);
        finish = startGnirehtet(config);
    } else if (ACTION_GNIREHTET_STOP.equals(action)) {
        stopGnirehtet();
    }
    if (finish) {
        finish();
    }
}

最后一步是授予整合之后的APK写安全设置的权限:

adb shell pm grant <package> android.permission.WRITE_SECURE_SETTINGS

2. 阉割掉VPN授权弹窗的兼容处理

在测试过程中发现一些车载设备把VPN授权弹窗所在的包整个移除掉了,也就是移除了“com.android.vpndialogs”。所以暂时先做功能是否支持的检测判断,即用adb命令去获取这个包名信息,如果输出是空的说明是阉割了这个包。

adb shell pm list package com.android.vpndialogs

另外参考系统VPN授权弹窗ConfirmDialog逻辑实现,使用反射的方式调用系统IConnectivityManager的接口来进行VPN授权。测试可以调用对应的逻辑接口,但是无法绕过CONTROL_VPN权限的授权,CONTROL_VPN权限只有系统应用才可以获取到,即使有系统签名文件使用预装的方式安装到系统路径,还是没法获取到CONTROL_VPN权限。这块内容再调研下,有结论后续再更新。

另外不同的Android版本开启VPN授权的接口有一些区别:

  • Android 2.3 没有VPN功能,因缺少3.0的在线源码资源,无法确认3.0版本是否具备VPN功能,4.0以下的版本都屏蔽VPN功能开启。
  • Android 4.0-4.4版本,只需要调用prepareVpn函数即可。
  • Android 5.0-5.1版本开始,增加了setVpnPackageAuthorization的函数调用。
  • Android 6.0 开始之后的版本,prepareVpn和setVpnPackageAuthorization函数都增加了一个参数。

VPN授权弹窗点击确认按钮的实现逻辑如下(Android 6.0之后的版本):

@Override
public void onClick(DialogInterface dialog, int which) {
    try {
        if (mService.prepareVpn(null, mPackage, UserHandle.myUserId())) {
            // Authorize this app to initiate VPN connections in the future without user
            // intervention.
            mService.setVpnPackageAuthorization(mPackage, UserHandle.myUserId(), true);
            setResult(RESULT_OK);
        }
    } catch (Exception e) {
        Log.e(TAG, "onClick", e);
    }
}

对应的反射方式调用VPN授权接口的实现代码如下:

/**
 * 通过反射的方式操作IConnectivityManager, 打开VPN授权
 *
 * @param vpnPackage 要打开VPN功能的应用包名参数
 * @return
 */
private boolean openVPNAuth(String vpnPackage) {
    try {
        /**
         * 应用需要有android.permission.CONTROL_VPN权限,但是这个权限只授权给系统级应用,第三方应用无法拿到此权限,操作接口会有权限异常。
         * 另一方面,如果是系统级应用也就没必要通过反射的方式去操作。
         */
        Method prepareVpnMethod, setVpnPackageAuthMethod;
        Class<?> serviceManagerClass = Class.forName("android.os.ServiceManager");
        Method getServiceMethod = serviceManagerClass.getMethod("getService", new Class[]{String.class});
        IBinder serviceManager = (IBinder)getServiceMethod.invoke(null, new Object[]{Context.CONNECTIVITY_SERVICE});
        Class<?> stubClass = Class.forName("android.net.IConnectivityManager$Stub");
        Method asInterfaceMethod = stubClass.getMethod("asInterface", new Class[]{IBinder.class});
        Object IConnectivityManager = asInterfaceMethod.invoke(null, serviceManager);
        Class<?> userHandleClass = Class.forName("android.os.UserHandle");
        Method myUserIdMethod = userHandleClass.getMethod("myUserId");
        myUserIdMethod.setAccessible(true);
        int userId = (int)myUserIdMethod.invoke(null, null);
        Class<?> IConnectivityManagerClass = Class.forName(IConnectivityManager.getClass().getName());
        // Android 2.3 没有VPN功能,因缺少3.0的在线源码资源,无法确认3.0版本是否具备VPN功能,4.0以下的版本一起屏蔽掉。
        if (Build.VERSION.SDK_INT < 14){
            return false;
            // 4.0-4.4版本
        } else if (Build.VERSION.SDK_INT >= 14 && Build.VERSION.SDK_INT < 21){
            prepareVpnMethod = IConnectivityManagerClass.getMethod("prepareVpn", String.class, String.class);
            prepareVpnMethod.setAccessible(true);
            boolean prepareSuccess = (boolean)prepareVpnMethod.invoke(IConnectivityManager, null, vpnPackage);
            return prepareSuccess;
            // 5.0-5.1版本
        } else if (Build.VERSION.SDK_INT >= 21 && Build.VERSION.SDK_INT < 23){
            prepareVpnMethod = IConnectivityManagerClass.getMethod("prepareVpn", String.class, String.class);
            prepareVpnMethod.setAccessible(true);
            setVpnPackageAuthMethod = IConnectivityManagerClass.getMethod("setVpnPackageAuthorization", boolean.class);
            setVpnPackageAuthMethod.setAccessible(true);
            boolean prepareSuccess = (boolean)prepareVpnMethod.invoke(IConnectivityManager, null, vpnPackage);
            if(prepareSuccess){
                setVpnPackageAuthMethod.invoke(IConnectivityManager, true);
                return true;
            }
            // 6.0 开始之后的版本
        } else if (Build.VERSION.SDK_INT >= 23){
            prepareVpnMethod = IConnectivityManagerClass.getMethod("prepareVpn", String.class, String.class, int.class);
            prepareVpnMethod.setAccessible(true);
            setVpnPackageAuthMethod = IConnectivityManagerClass.getMethod("setVpnPackageAuthorization", String.class, int.class, boolean.class);
            setVpnPackageAuthMethod.setAccessible(true);
            boolean prepareSuccess = (boolean)prepareVpnMethod.invoke(IConnectivityManager, null, vpnPackage, userId);
            if(prepareSuccess){
                setVpnPackageAuthMethod.invoke(IConnectivityManager, vpnPackage, userId, true);
                return true;
            }
        }
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    } catch (SecurityException e) {
        e.printStackTrace();
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    } catch (IllegalArgumentException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    }
    return false;
}

调用之后输出的异常堆栈如下:

java.lang.SecurityException: Unauthorized Caller: uid 10055 does not have android.permission.CONTROL_VPN.
    at android.app.ContextImpl.enforce(ContextImpl.java:2105)
    at android.app.ContextImpl.enforceCallingPermission(ContextImpl.java:2125)
    at com.android.server.connectivity.Vpn.enforceControlPermission(Vpn.java:757)
    at com.android.server.connectivity.Vpn.prepare(Vpn.java:248)
    at com.android.server.ConnectivityService.prepareVpn(ConnectivityService.java:3126)
    at android.net.IConnectivityManager$Stub.onTransact(IConnectivityManager.java:485)
    at android.os.Binder.execTransact(Binder.java:451)
java.lang.reflect.InvocationTargetException
    at java.lang.reflect.Method.invoke(Native Method)
    at java.lang.reflect.Method.invoke(Method.java:372)
    at fake.preload.arc.com.myapplication.MainActivity.openVPNAuth(MainActivity.java:72)
    at fake.preload.arc.com.myapplication.MainActivity.onCreate(MainActivity.java:19)
    at android.app.Activity.performCreate(Activity.java:6100)
    at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1112)
    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2481)
    at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2614)
    at android.app.ActivityThread.access$800(ActivityThread.java:178)
    at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1470)
    at android.os.Handler.dispatchMessage(Handler.java:111)
    at android.os.Looper.loop(Looper.java:194)
    at android.app.ActivityThread.main(ActivityThread.java:5643)
    at java.lang.reflect.Method.invoke(Native Method)
    at java.lang.reflect.Method.invoke(Method.java:372)
    at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:982)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:777)
Caused by: java.lang.SecurityException: Unauthorized Caller: uid 10055 does not have android.permission.CONTROL_VPN.
    at android.os.Parcel.readException(Parcel.java:1546)
    at android.os.Parcel.readException(Parcel.java:1499)
    at android.net.IConnectivityManager$Stub$Proxy.prepareVpn(IConnectivityManager.java:1749)
    ... 17 more
在VPN.java类里面的prepare函数实现可以看到调用了权限检查操作,这边无法规避掉。
// Check if the caller is authorized.
enforceControlPermission();
// 这边校验的就是CONTROL_VPN权限
private void enforceControlPermission() {
    mContext.enforceCallingPermission(Manifest.permission.CONTROL_VPN, "Unauthorized Caller");
}

3. 服务端内网网段安全过滤配置

考虑到共享PC网络给设备之后,为了避免第三方应用或程序访问内网IP导致安全方面的问题,在Rust服务端增加内网网段安全过滤的判断配置。

在router.rs 的connection函数实现中增加请求IP地址的过滤,这边增加黑名单网段和白名单IP地址的配置,需要屏蔽的网段和放行的IP在对应的清单列表中增加参数,配置的时候一定要增加转义字符 “\\”。

// 判断请求的IP地址是否在屏蔽的黑名单网段中
fn isInRange(&self, ip: u32, cidr: &str) -> bool {
    let cidrIpsSplit:Vec<&str>=cidr.split("\\/").collect();
    let cidrType = cidrIpsSplit[1].parse::<i32>().unwrap();
    let mask:u32 = 0xFFFFFFFF << (32 - cidrType);
    let cidrIps:Vec<&str>= cidrIpsSplit[0].split("\\.").collect();
    let cidrIpAddr = (cidrIps[0].parse::<u32>().unwrap()) << 24 | (cidrIps[1].parse::<u32>().unwrap()) << 16
            | (cidrIps[2].parse::<u32>().unwrap()) << 8 | (cidrIps[3].parse::<u32>().unwrap());
    return (ip & mask) == (cidrIpAddr & mask);
}
// 白名单网址配置判断
fn isInWhiteList(&self, ip: u32) -> bool {
    let white_list = vec![
        "11\\.238\\.117\\.5",
        "11\\.239\\.149\\.62",
    ];
    for item in &white_list {
        let ips:Vec<&str>= item.split("\\.").collect();
        let ipAddr = (ips[0].parse::<u32>().unwrap()) << 24 | (ips[1].parse::<u32>().unwrap()) << 16
             | (ips[2].parse::<u32>().unwrap()) << 8 | (ips[3].parse::<u32>().unwrap());
        if ipAddr == ip {
            return true;
        }
    }
    false
}
fn connection(
    &mut self,
    selector: &mut Selector,
    ipv4_packet: &Ipv4Packet,
) -> io::Result<usize> {
    let (ipv4_header_data, transport_header_data) = ipv4_packet.headers_data();
    let transport_header_data = transport_header_data.expect("No transport");
    let id = ConnectionId::from_headers(ipv4_header_data, transport_header_data);
   // --------- add start 内网网段黑名单和白名单判断过滤
    let destination_ip = ipv4_header_data.destination();
    let black_list = vec![
        "0\\.0\\.0\\.0\\/32",
        "127\\.0\\.0\\.1\\/8",
        "10\\.0\\.0\\.0\\/8",
        "11\\.0\\.0\\.0\\/8",
        "30\\.0\\.0\\.0\\/8",
        "100\\.64\\.0\\.0\\/10",
        "172\\.16\\.0\\.0\\/12",
        "192\\.168\\.0\\.0\\/16",
    ];
    let notInWhite = !self.isInWhiteList(destination_ip);
    for item in &black_list {
        if self.isInRange(destination_ip, item) && notInWhite {
            cx_info!(target: TAG, id, " in black list return");
            return Err(io::Error::new(io::ErrorKind::Other,
                        format!("IP in black list return \"{}\"", &id),));
        }
    }
   // --------- add end
    let index = match self.find_index(&id) {
        Some(index) => index,
        None => {
            let connection =
                Self::create_connection(selector, id, self.client.clone(), ipv4_packet)?;
            let index = self.connections.len();
            self.connections.push(connection);
            index
        }
    };
    Ok(index)
}

IP地址后面斜杠加具体数字是一种用CIDR(无类别域间路由选择,Classless and Subnet AddressExtensions and Supernetting)的形式表示的一个网段,或者说子网。

确定一个子网需要知道主机地址和子网掩码,但用CIDR的形式,可以简单得到两个数值。例如:192.168.0.0/24”就表示,这个网段的IP地址从192.168.0.1开始,到192.168.0.254结束(192.168.0.0和192.168.0.255有特殊含义,不能用作IP地址);子网掩码是255.255.255.0。

另外,如果接入的设备是通过adb forward 转发连接到PC节点上,不是以USB的方式连接,需要在设备连接的PC上重新起个共享网络服务端,节点共享的网络无法共享给通过adb forward转发连接的设备。

最后,最好加一个网络连接是否断开的心跳检测机制,保证共享网络连接在异常情况下可以正常恢复,在测试过程中发现,共享网络客户端在系统内存不足的时候会被系统杀死,或者因为其他一些系统方面的问题导致连接断开,需要心跳检测机制保证连接的稳定性。

在开启网络共享之后手机所有网络都是走VPN通道,WI-FI连接是断开的,此时Ping也是Ping不通(使用ICMP协议),可以参考第三部分的文章,用 busybox 使用 nc 命令与尝试与主机的某个端口通信来做检测。

三、其他应用资料参考

Gnirehtet生产环境实践

扩展阅读:

Android模拟定位实现详解

Android 性能监控之CPU监控

Android 性能监控之内存监控

转载请注明出处:陈文管的博客 – Gnirehtet终端设备共享PC网络实践

扫码或搜索:文呓

博客公众号

微信公众号 扫一扫关注

文章目录

  • 一、Gnirehtet方案介绍
    • 1. 限制范围
    • 2. Gnirehtet服务端部署
  • 二、Gnirehtet整合应用
    • 1. 启动客户端VPN授权弹窗自动化点击确认实现
    • 2. 阉割掉VPN授权弹窗的兼容处理
    • 3. 服务端内网网段安全过滤配置
  • 三、其他应用资料参考
博客公众号

闽ICP备18001825号-1 · Copyright © 2025 · Powered by chenwenguan.com