4

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

 1 year ago
source link: https://www.chenwenguan.com/gnirehtet-device-share-pc-net-practice/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

2022年3月17日 | 最近更新于 下午8:03

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

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

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

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

综合考虑生产应用的灵活性,和接入的成本,选择了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/LinuxWindows 和 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 [email protected]: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 [email protected]: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网络实践

扫码或搜索:文呓

博客公众号

微信公众号 扫一扫关注


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK