代理技术简介
今天来水一篇文章,简单介绍一下代理的基础知识吧。
需求
所谓代理,其实就是代理执行,在网络通信里指的是使用目标ip来进行流量的代发,我们可以在本地配置代理,也可以对浏览器进行特别的代理,这些大多用处不大。我们代理的目的一般有流量拦截和加密通信。流量拦截可以检测本地发出去的包和过滤网络输入的包,这就是俗称的抓包,抓包是基于本地代理实现的,但是需要管理员权限,可以侵犯其它进程的网络数据怎么想都是要权限的吧,对于桌面系统获得权限就完事了,但我们的手机就有点麻烦了,所以接下来我们会以安卓手机为例。不过先说说加密通信吧,我们平时访问网页的时候,所有的信息都会是明文(http),很容易被某墙丢包,就算使用(https)也要传入域名信息,也容易被某墙丢包,所以我们与我们的代理外服通信的时候如果不进行加密的话,是越不过某墙的,因此本地代理还有一个用处就是抓取流量以后进行包装加密,防止被中间人攻击者发现而进行恶意篡改,这样我们就能成功与代理外服通信了。其实关于代理,我们只是讲本地代理的东西,服务器代理嘛,成本高就不说了,而且思想也差不多,而且我们以安卓为主。
学习项目
安卓实现主动代理的基本手段是使用系统提供的vpn服务,安卓的防火墙软件,加速器软件,抓包软件等都是基于此来实现的,其基本用处是拦截所有发送至互联网的数据,其核心类为android.net.VpnService。我们从github上,发现一个简单的项目LocalVpn,它没什么特别的功能就是基于vpn服务实现了一个转发器,我们接下来就以此来深入了解一下VpnService的用法吧。
项目结构
使用此服务的基础权限是android.permission.INTERNET
,即与网络相关的权限。然后此项目,有一个主界面和一个基本服务

主界面运行效果如下

就一个按钮,点一下即可启用我们的服务,显然像这种不断运行拦截流量的要通过Service来实现。

这里注册了一个广播,它与此主界面绑定,用于Service与主界面的通信,如果Service的状态改变,就会传信息到主界面通知它改变按钮的状态。

由上述官方文档可知,首先我们要调用静态方法VpnService.prepare(this)
来确定是否有权限启动VPN,有的话就会返回请求启动VPN的intent,这个intent主要给用户来确定是否启动vpn。然后在onActivityResult
里面确定返回结果,一切正常的话则启动我们的LocalVPNService服务。LocalVPNService继承于VpnService,用于实现流量拦截的核心类,他们都是Service的子类,可以一直运行在后台。然后就是通过VpnService.Builder来构建vpn接口的文件描述符,读此描述符读写来完成流量转发。我们能否直接使用VpnService而不继承呢?重实现的功能角度和适配上有些不太现实。我们来通览一下这个项目的基本结构吧
1 | ByteBufferPool.java LocalVPNService.java TCPInput.java UDPOutput.java |
UDP,和TCP是分别用来分析两种常见协议TCP/UDP的类,Packet是发送包的Java抽象,ByteBufferPool就是一段反复利用的内存而已,LocalVPNService是主要服务,LocalVPN是主界面,TCB主要用于完成TCP握手,LRUCache只是一个类似k-v的数据结构。由这些我们多少可以知道,vpn是运行在ip协议上的,官方文档确实有这么一句话The interface is running on Internet Protocol (IP), so packets are always started with IP headers.
。所以使用的时候还是得小心点,不止UDP/TCP,很多协议都运行在ip协议之上,不过嘛,我们抓包就只要这两个协议就差不多够了,其它的可以直接放行,比如常用的socket就是基于UDP协议的。
多线程
我们先来看一下最开始的创建函数

首先使isRunning为true表示运行,然后在setupVPN初始化我们的vpnservice,至于那些参数基本都是拿来判断是否通过vpn的,比如

这个就是表示可以通过vpn路由的地址族,至于像setSession这类设置名字的就没什么好说的了。哦,还有一个setConfigureIntent可以讲一讲,它可以得到一个PendingIntent对象,这个主要是系统配置传入的intent,如下

注意到每个软件的最右边有一个设置一样的按钮,点击它就可以触发PendingIntent,不用的话就是系统自带的了,本案例里没有用到,我们就这样一笔带过吧。接下来对数据的处理值得我们好好研究一下。
在executorService = Executors.newFixedThreadPool(5);
里,我们创建了一个容量为5的线程池,5个线程分别处理不同事务,其中VPNRunnable是与vpn直接对接的线程,UDPInput和UDPOutput处理UDP相关事务,TCPInput和TCPOutput处理TCP相关事务,这里我们可能会疑惑为什么UDP和TCP需要特别拿来处理,其实我猜大概是用来分担线程压力的,另一方面的话,这部分也是我们想重点偷窥拦截的对象,所以单独来处理也不为过。对于tcp和udp,我们发现它使用了Selector和ConcurrentLinkedQueue,具体我们不关心,我们可以容易知道这是实现nio(无阻塞io)的一种基本方法,为什么会需要这个?其实也与我们的目的相关,比如tcp是有握手过程的,对于每个tcp连接,我们可以通过nio来回避多线程阻塞的麻烦。至于nio嘛,简单讲其实就是一个线程对一个事务队列进行循环执行,自己去补吧。最后注册了一个我们之前提到的广播,至于一些清理操作,没啥好说的。我们去看看主线程吧

首先依据文件描述符创建相应的文件输入输出流,这里是linux万物皆文件的思想,对于网络接口我们也视为一个文件,发送数据即写入,接受数据即读取。虽说它转化为了FileChannel对象,但并没有用到特有的传输方法transferTo和transferFrom,所有与原始的输入输出流差别不大,只不过数据的载体是byteBuffer罢了。然后循环保活线程,如果曾发出过数据,那么从ByteBufferPool取出ByteBuffer数据,否则清空数据,至于数据哪来的,当然是其它线程给的,我们后面会看的。然后是read,根据文档Each read from the descriptor retrieves an outgoing packet which was routed to the interface.
意思就是拦截其它程序发出去的包,并保存到bufferToNetwork,接下来构造Packet对象来封装我们的包,至于Packet的实现属于计算机网络系统的基础内容了,总之就是解析IP协议啦。接下来就是依据TCP和UDP协议分别将数据送入相应队列,至于其它类型没有任何处理,不过ip协议上真的就这两部分吗?确实有其它的协议,如ICMP和IGMP协议,我最喜欢下面这张模型图了

这样容易看出,除了TCP和UDP外的其它依赖IP协议的基本都处理不到,所以不管其实也无所谓了。接下来从networkToDeviceQueue队列了取得接收回来的数据,然后write,同样我们来看官方文档Each write to the descriptor injects an incoming packet just like it was received from the interface.
,这里就是将接受的数据回传给应用。最后稍微让线程休息一下,防止耗电过度,至此我们的主线程结束了。到这里我们可能会发现了一个比较神奇的问题,我们拦截的都是IP协议上的包,但我们的程序好像只能发送TCP和UDP的包,比如通过Socket发送TCP的包,有没有觉得头脑炸裂,竟然不能简单的read和write来实现转发,还必须自己解析包来模拟实现。
TCP转发
我们先从较为复杂的TCP协议看起吧,一样先直接看主线程吧,你问为什么不看看其它的函数,如构造方法?这不都是想要什么传什么嘛,也没什么好说的

这里有两个队列inputQueue和outputQueue,对于TCPOutput来说,inputQueue就是设备发向网络的数据队列,在之前的那个线程里的deviceToNetworkTCPQueue.offer(packet);
里面传入了数据,我们在这里取出来,一旦成功取出将进入下一阶段。Packet里的backingBuffer即是除了报头以外的数据,也就是传输的核心内容,后面则是取出一些报头的基本内容如目标ip和端口之类的。TCB是tcp连接的正体封装,主要是用来区别下面的一些握手过程,它们会分别调用process*来处理,RST是通信终结标志,所以调用的是closeCleanly。这里我们需要注意,握手是相对与ip协议那一端,而我们在发出数据这一方是不需要握手的,我们可以直接使用Socket来实现数据传输。我们继续看吧

我们看到vpnService.protect(outputChannel.socket());
方法,这主要为了保证我们程序向外发送的数据不会被vpn拦截,不然会发生无限循环,这挺好理解的吧。outputChannel.connect(new InetSocketAddress(destinationAddress, destinationPort));
我们与数据发送的最后目标连接,tcb.selectionKey = outputChannel.register(selector, SelectionKey.OP_CONNECT, tcb);
往我们的selector里面注册事件,我们注意到currentPacket.updateTCPBuffer(responseBuffer,)
之类的方法,其实主要是为了让其它应用接受包里面的ip和端口之类的看起来确实是自己发出数据的回应,最后outputQueue.offer(responseBuffer);
将我们收到的修改包传入接受队列outputQueue。那么啥时候发数据呢?实际在标志位为ACK的时候表示我们正在相互传输数据,我们来看processACK,我们再次说明一下,这里的ACK是相对于发出包的其它应用,而与远程目标的连接并没有这个概念

从下面这段代码
1 | try { |
我们可以知道,应用只把payloadBuffer的数据发送到远端,即我们之前的backingBuffer,也就是说不包含报头信息,就像我们之前说的那样。数据接收的话在TCPInput里,我们去看看吧

你和我说看不懂这些操作,去复习nio的基本内容吧,nio的基本操作是遍历select,寻找我们需要的操作来处理,而操作的依据是我们获取的SelectionKey,这里主要处理连接和读取,我们去看一下如何读取的

我们看到语句readBytes = inputChannel.read(receiveBuffer);
,它将接收的数据存入receiveBuffer,结果的话最终放入outputQueue队列。我们发现两个TCP都有将数据包放入output队列,似乎有点反直觉,但其实,往其它应用传入数据就如我们之前所说还包含握手过程,TCPOutput传入握手包,TCPInput则传入实际的数据包。
总结
这样好像结束了?UDP的话其实更加简单粗暴,直接扔包和接包就行了,连对接都不需要。然后好像就没内容了,可是怎么觉得什么都没有讲的感觉,所以嘛,我才会在开头说这是水文章。其实这个应用的大部分实现都在于协议包的解析,如从IP包中解析出TCP包和UDP包,然后是通过nio来与目标完成通信的过程,TCP使用Socket,UDP使用Datagram,也就是说大部分内容都是对协议的理解,感觉枯燥又无聊。哎呀,不说了,我们直接画个总结图结束我们的文章吧

这应该是今年最后的一篇文章了,以这样的形式结束,感觉也不错。