有赞移动 App 一键切换网关实践

概述

为了满足多项目并发对环境的需求,有赞提供了四种测试环境。随着业务的快速迭代,提升切换环境这个步骤的效率,对整个开发、测试回归、运营产品验收等环节都会带来很大的收益。

背景

在 App 之前的测试开发流程中,我们需要经历以下几个步骤才能连接到有赞的测试环境中。

  • 需要有一台装好 Charles、Hosts 切换软件的 PC
  • 在 PC 上切换 hosts 配置,将线上的域名解析到指定环境的 ip
  • 到有赞网关的设置界面设置好需要机器、ServiceChain 等路由信息
  • 在移动设备的 Wi-Fi 设置中将 HTTP 代理设置到 PC 上
  • 需要安装 Charles 提供的 root ca 证书
  • 如果网络环境存在 Nat 转换,客户端还需要在头中加上 X-Forwarded-For 字段,来标记用户的唯一性。需要使用 Charles 的 map 功能,操作比较繁琐

在整个流程中,可能存在以下几个问题

  • 对于产品、运营同学来讲,整个操作流程学习成本较大
  • 整条网络链路上的各个节点都可能存在 DNS 缓存,导致 PC 上切换 hosts 不能及时生效
  • 测试机流动性较大,经常需要连接到不同开发同学的 Charles 代理服务器上,由于 Charles 在不同的机器上会生成不同的 root ca 证书,所以每次都需要再次安装证书

所以我们急切的需要一个可以一键切换测试环境的方案,并且它应该具备以下特点。

  • [1] 操作简单,可以用 widget 点击 button 一键切换通用的测试环境
  • [2] 不依 PC 的开发环境,在脱离 Charles 的环境也要可以使用
  • [3] 不依赖 hosts 切换软件,且切换 hosts 的时候不能有 DNS 缓存问题。
  • [4] 只安装一次/不安装证书
  • [5] 不需要到 Wi-Fi 设置中填上代理,因为此步骤比较繁琐,没有一键开关,经常会有忘记关闭代理导致网络异常的问题
  • [6] 可以抓包、监控、mock 请求
  • [7] 对 App 代码没有侵入性
  • [8] 可以代理「自定义协议」的 TCP 请求,如我们多客服 IM 项目的自定义 TCP 协议。

ZanProxy

ZanProxy 是有赞开源的一款 Node.js 编写的 HTTP 代理服务器。它拥有强大的自定义规则功能,支持以下特性

  • 支持 HTTP(s)、WebSocket 代理
  • 支持请求 map、转发
  • 支持自定义 DNS 解析
  • 支持自定义插件,可定制代理行为

经评估,ZanProxy 完美满足我们对于规则的处理,可以满足 [3] [4] [6] [8] 的需求,可以自定义 DNS,可以加 header、只需要安装一次证书。但是美中不足的是,我们还是需要连接到 ZanProxy 代理,不能脱离代理环境独立运行(ZanProxy 需要在开发的 PC 中安装 Node),让运营、产品同学安装 Node.js 也相对操作门槛也较高。

解决方案

改造网络请求库

我们可以对网络请求库进行改造,比如 okhttp 支持自定义 DNS、修改 request response、自定义信任证书。我们可以写出一个设置的界面,来定义网络库的行为,使其满足我们切换环境的需求。但是这种方式有两种缺点,一是对代码的侵入性太大,开发过程中写个用到网络请求的小 demo 、SDK 想进行测试的话,还需要接入这个网络请求调试库,成本太高。且 webview 支持有限。二是线上的包不能带上这个功能,但是往往运营产品拿到的包都是正式包。不能满足 [7] [8] 的需求

ZanProxy & HttpProxy

为了实现 [2] 的需求,我们可以将 ZanProxy 部署在内网的服务器中,移动端连接到公用的 ZanProxy 代理,这样就可以摆脱 Charles 来独立运行。不过目前 ZanProxy 尚未支持多用户的功能,只能每个环境部署一台,且这个环境只能有一种单独的规则。那么我们需要对 ZanProxy 进行改造,使其支持多用户。这样一来又会衍生出新的问题,就是后端服务怎么标识一个用户?

如果我们只用系统自带的 HTTP 代理,可以用 HTTP 代理服务中的 Username/Passwd 鉴权协议,来标识这个用户,但是这样操作也会很繁琐,不能满足 [1][5] 的需求。

ZanProxy + VpnService

至此,我们有俩个问题没有解决掉:

  1. 有没有一种不需要设置 Wi-FI 的 HTTP 代理就可以将所有 App 的流量都能获取到的办法呢?
  2. HTTP 代理不能转发自定义协议的 TCP 连接,有没有方法可以将 1 中拿到的所有流量都转发到 ZanProxy 服务器中去?借助 ZanProxy 强大的规则功能,也避免了 Android iOS 要写两套代码的问题。

我们注意到,Android 提供的 VpnService、iOS 的 NetworkExtension 可以将手机的所有 L3 网络层的流量通过一个文件描述符的方式回调给客户端进行处理。从这个文件描述符中可以读出一个一个的 IP 数据包,客户端可以自行处理这些包,选择丢弃、转发、篡改等都可以。

IPV4 Packet

一个 TCP 连接的 IP 数据包的格式如上图所示,一个 IP 数据包包含了「IP Header」以及它的「payload」,如果是 TCP 连接,这个「payload」便是 TCP 的数据包。所以去掉「IP Header 」之后我们就可以拿到 L4 传输层的协议。同理,如果 L4 层的协议是 TCP 的话,我们也可以根据 TCP Packet 的规则,解析出抛去三次握手、四次挥手等一些协议信息后的真正传输的「TCP payload」。UDP、ICMP 等其它传输层协议同理。

由于整个 L3 L4 协议解析过于复杂,我们使用了 badvpn 提供的 tun2socks 来实现我们的需求。它可以将 L3 层的 IP 数据包转化成应用层的 socks5 协议

socks5 协议是工作在应用层一种代理协议,socks 使用握手协议来尽可能地进行数据包的透明转发,而常规代理比如 HTTP 代理可能会对包做一些改变。此外,socks 代理不仅可以转发 TCP 连接,还可以转发 UDP 流量和反向代理。

简单来说,socks 协议本质是一条 TCP 连接,它通过一些握手协议、鉴权,可以连接到远端的代理服务器,在连接 & 鉴权成功之后,它就开始转发真正的数据包,而不管包里面是什么协议、是什么内容。统一当成 "bytes" 来处理,它仅仅会在连接的时候告诉远端代理服务器这条连接的传输层协议是什么 (如 TCP UDP)、它的真正目的地是什么(目标服务器地址)。然后远端代理服务器拿到包之后,解析出它的真正目的地,用它定义的传输层协议来连接到真正的远端服务器,然后将自己转换成一个「管道」的角色,进行无脑转发。所以理论上它支持所有协议,满足了我们 [8] 中代理 IM 自定义协议的需求。

启动 tun2socks 需要传入能读到 IP Packet 的 fd,以及需要连接的 socks5 server、port。我们将 tun2socks 移植到了移动平台,以动态链接库的形式打包到 App 里。在运行的时候,传入 VpnService 提供的文件描述符,以及 ZanProxy 服务的地址。tun2socks 就可以帮我们把这些流量转发到 ZanProxy 服务中去。

badvpn-tun2socks --netif-netmask 255.255.255.0,  
                 --socks-server-addr ${socks5_remote_addr}:${socks5_remote_port}
                 --tunfd ${fd}
                 --tunmtu 1500
                 --loglevel 3

所以我们可以利用 VpnService + ZanProxy 的方式,通过 socks 协议来实现我们的需求。整体架构图如下 架构图

为了满足第二条「不依赖 PC 的开发环境」,我们将 ZanProxy 服务部署在办公网络内。并将 ZanProxy 进行改造,使其 socks 代理、多用户管理。多用户方案我们进行了多方面的评估,最后决定使用 socks 的 Username/Password Authentication 来标识连接的身份。为了方便用户使用,避免输入用户名带来的繁琐流程,我们采用设备的「UUID」作为 Username。

整个「分用户进行不同规则代理」的流程如下:

  1. 我们在 App 端开发了一套 UI,用户可以点击 button 来切换「此设备」的规则,来决定连接到哪个环境。ZanProxy 提供了设置 qa/pre/线上环境的接口,在客户端请求设置环境接口的接口后,后端服务将会以「UUID -> Rule」的形式记录下此 UUID 对应的规则。
  2. 客户端在开启 VpnServive 之后,tun2socks 会将流量以 socks 协议转发到后端服务。我们以 UUID 作为 socks 握手协议的 Username
  3. 后端服务在收到客户端的 socks 连接请求后,解析出 Username,也就是客户端的 UUID,去「UUID -> Rule」的表里找到对应的规则,然后拿出规则进行相应的解析、代理。

ACL

ACL 全称 Access Control List(访问控制表)。我们的服务是部署在办公环境下,走的是办公网络,那么如果所有的流量都从这台机器经过的话,对于服务器的网络压力还是很大的。这样一来需要客户端过滤掉一些不需要的网络包,来减轻服务器的压力。我们大部分的请求都是以 youzan.com 结尾,所以我们只需要将 host 匹配到 *.youzan.com 的请求发送到代理服务器,而其他的所有包都直接连接,不做任何处理。
以 Android VpnService 为例,在翻阅官方文档之后,找到了这么一个接口。

/**
* Convenience method to add a network route to the VPN interface
 * using a numeric address string. See {@link InetAddress} for the
 * definitions of numeric address formats.
 *
 * Adding a route implicitly allows traffic from that address family
 * (i.e., IPv4 or IPv6) to be routed over the VPN. @see #allowFamily
 *
 * @throws IllegalArgumentException if the route is invalid.
 * @see #addRoute(InetAddress, int)
 */
public Builder addRoute(String address, int prefixLength) {  
    return addRoute(InetAddress.parseNumericAddress(address), prefixLength);
}

在 VpnService 启动的时候可以填上一个路由地址,这个地址由 address 和 prefixLength 组成,标识一个 ip 段。只有落在这个网段内的 ip 才会被发送到 VPN 的虚拟网卡上。那怎么保证我们想要转发的请求的 ip 落在这个网段中呢?我们注意到,VpnService 还提供了一个 addDnsServer 的接口,它允许我们自定义 DNS 服务器,这样就可以提供一个 DNS 服务器给客户端,返回一个虚假的 ip (可以用内网 ip 段),然后客户端在 addRoute 中设置上这个虚假 ip 段。

  • 服务端定义 DNS Server,在检测到域名需要被代理之后,返回一个 198.18.0.0/16 网段的 ip,并且保证 ip 不会重复。
server.on('message', (domain, send, proxy) => {  
    let cachedIp = this.dnsHostIpCache[domain];
    if (cachedIp) {
        send(cachedIp);
        return;
    }
    let proxyEnabled = this.profileService.socksProxyEnabled('root', domain);
    // 如果是需要代理的域名,返回一个虚假的 ip 并缓存,否则直接请求上游 DNS 服务器。
    if (proxyEnabled) {
        while (true) {
            let ip = randomIpv4('198.18.{token}.{token}');
            if (!this.dnsIpHostCache[ip]) {
                this.dnsIpHostCache[ip] = domain;
                this.dnsHostIpCache[domain] = ip;
                send(ip);
                return;
            }
        }
    } else {
        proxy(DEFAULT_DNS_SERVER))
    }
});
  • 客户端在启动的时候填上需要代理的 ip 段
  val builder = this.Builder()
                .setConfigureIntent(configureIntent)
                .addDnsServer("172.17.2.4")
                .addRoute("198.18.0.0", 16)
                .setSession("有赞移动")
                .setMtu(VPN_MTU)
                .addAddress(PRIVATE_VLAN.format(Locale.ENGLISH, "1"), 24)
  • client 请求不需要走代理的服务时,server DNS 服务会返回正常的 ip。
  • client 请求需要走代理的服务时,server DNS 服务会返回 198.18.x.x 的 ip,并且在内存中记录好 [域名 -> faked ip] 的映射。
  • client 在 DNS 解析返回 198.18.x.x 的 ip 后,开始发起 socks 连接请求,由于设置了 198.18.0.0/16 的路由,所以流量会被发送到 server 服务上。
  • server 拿到 198.18.x.x 的 socks 请求后,便知晓这个请求是需要被代理的请求,然后从 [域名 -> faked ip] 的映射表中反查出 ip 对应的域名,再去上游 DNS 服务器去查询真正的 ip 地址,然后开始做对应的规则解析、代理。

这样一来,就可以实现只让指定域名走代理的 ACL 服务,大大减轻了服务器的压力。 另外这边需要注意一下,在实现自定义 DNS 服务时,不仅要实现 udp server,还需要实现一个相同端口的 TCP server,因为 DNS 请求在返回的包大于 512 个字节之后,会自动切到相同端口的 TCP 服务去请求,一般的 DNS packet 不容易超过 512 字节,但是有些比如 CDN 厂商的域名,它为了实现负载均衡,会返回很多地区的 ip,导致返回的 packet 比较大,很容易超过 512 字节。如果不实现 TCP server 会造成客户端 DNS 解析失败。这里可以用 nginx 将 53 TCP 端口转发到上游 DNS 服务器。

总结

最后我们总结一下 App 在发起一个 Http 请求后会经历哪些事情。这里以 https://carmen.youzan.com/xxx.xxx.xxx/get/1.0.0 为例。 1. 用户开启了 VpnService,选择了 qa 环境。后端服务会记录下「UUID -> Rule (qa)」的映射。
2. 应用 App 发起了https://carmen.youzan.com/xxx.xxx.xxx/get/1.0.0 的请求,层层往下,到了网络层变成了一个或多个 IP Packet。(VpnService/NetworkExtension)将 IP Packet 丢给我们应用层去处理。我们在应用层开启了 tun2socks,tun2socks 拿到了 IP Packet 之后,开始层层剥皮(处理 L3 L4层的协议),最后将应用层的数据通过 socks 请求转发到后端服务,并且在 socks 的握手包里塞入此设备的 UUID。
3. 服务端在拿到 socks 请求后,取出握手包里的 UUID,去查询「UUID -> Rule」,查出此次连接是需要发往 qa 服务器。将这次请求的规则设置为 qa 的规则,然后进行 ZanProxy 代理访问,最后返回给客户端,整个请求完成。

应用

最后我们产出了「有赞移动助手」的一个内部 App,里面集成了我们的环境规则。并且实现了以下特性

  • 界面友好, 一键切换环境
  • 脱离 PC 环境独立运行
  • 不需要在 Wi-Fi 设置中填上 HTTP 代理,摆脱了繁琐的代理设置。
  • 只安装一次证书 (ZanProxy 的证书)。
  • 不仅可以用 ZanProxy 的监控窗抓包,还可以将 socks 流量转发到 Charles 上,来实现功能强大、界面友好的请求监控、mock。
  • 单独的一个 App,对我们的业务 App 代码没有任何侵入性。
  • 支持 IM socket、websocket 的转发 (socks 支持任何协议的 TCP 连接)

总结

至此就介绍完了有赞切换环境的整个方案,其实在开发的过程中还有很多小的细节,由于篇幅限制没有介绍。欢迎大家来一起沟通。除了切换环境之外,利用好 ZanProxy 强大的规则代理功能,还可以开发出很多有趣的功能。比如对某些请求进行 mock,将线上的一些请求打到我们的开发环境。比如 mock 一些请求,来复现线上用户的问题。整个方案的落地遇到了不少找不到参考方案的问题,好在经过慢慢的打磨,加上一些大胆的尝试,这些问题都被慢慢克服。希望以上的方案可以给正在遇到同样问题的同学一些参考。

欢迎关注我们的公众号