目前的生产测试环境中,群控测试系统的基础架构是一个服务端对应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.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授权,后续无需再设置,但在自动化测试场景下,批量设备触发总不能手动去点击确认,需要一个自动确认的实现机制。
这边给出接入无障碍服务实现的自动化授权弹窗点击,接入无障碍服务之后,可以监控当前界面弹出的弹窗,实现授权弹窗自动点击确认,无需人工操作。在确认授权之后,通知栏会显示一个钥匙🔑的小图标。
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终端设备共享PC网络实践
扫码或搜索:文呓
微信公众号 扫一扫关注