使用 TUN 的模式
TUN 是内核提供的三层虚拟网络设备,由软件实现来替代真实的硬件,相当于在系统网络栈的三层(网络层)位置开了一个口子,将符合条件(路由匹配)的三层数据包交由相应的用户空间软件来处理,用户空间软件也可以通过TUN设备向系统网络栈注入数据包。可以说,TUN设备是用户空间软件和系统网络栈之间的一个通道。
TAP 是二层(以太网)虚拟网络设备,处理的是以太帧,更加底层可以拿到更多信息,但不在本文的讨论范围。
我们想要利用TUN来做一些事情,实际上就是要编写一个用户态程序,拿到 TUN 设备句柄,向其写入序列化的IP数据包,从中读取数据并还原成IP数据包进行处理,必要时需要取出其payload继续解析为相应传输层协议。
通常使用 TUN 技术的是 VPN 和代理程序,然而这两类程序在对待 TUN 中传递的 IP 数据包时通常有不同的行为:
-
VPN 通常做 网络层 的封装:将拿到的 IP 包进行加密和封装,然后通过某个连接传输到另一个网络中,在解封装和解密后,将 IP 包发送到该网络。在这个过程中,对 IP 包本身的修改是非常小的,不会涉及到整体结构的变动,通常仅会修改一下源 IP 和目标 IP ,做一下 NAT。
-
代理程序 通常是 传输层 的代理:在从 TUN 设备拿到 IP 包后,需要继续解析其 payload,还原出 TCP 或者 UDP 结构,然后加密和封装传输层 (TCP或UDP) 的 payload。网络层的 IP 和传输层的端口信息通常会作为该连接的元数据进行处理,使用额外的加密和封装手段。
简单来说,VPN 不需要解析 IP 包的 payload,而代理程序需要解析出传输层信息并处理,特别是像 TCP 这样复杂的协议,对其处理更是需要非常小心和严谨。对于代理程序这样的需求,如果我们使用 TUN 技术,通常有两种模式:在用户态实现网络栈,或者直接利用操作系统网络栈实现。
用户态网络栈
第一种选择是在用户态实现网络栈,这是不小的工程啊,特别是实现 TCP 协议,因为其协议非常复杂,实现起来有很多细节需要注意,所以自己实现非常容易犯错。所以我们一般会直接找现成的实现来用,现有不少比较成熟且高效的实现,我相信肯定比我自己写的要好几个数量级。
网络栈实现
-
如果使用 C 语言, lwIP 是一个非常不错的选择,由瑞典计算机科学研究所科学院开源,这是一个轻量级的 TCP/IP 栈实现,在占用超少内存的情况下,实现了完整的 TCP,被广泛应用到嵌入式设备中,稳定性有保证。同时,lwIP 有很多其他语言的绑定,包括 go 和 rust,这使我们在使用其他语言开发时也可以选择 lwIP 作为用户态网络栈实现。
-
如果选择使用 Go 语言开发 TUN 的用户态程序(其实这也是大多数人的选择),可以选择 Google 开源的 gVisor 中的实现,gVisor项目目的是为容器提供自己的应用程序内核,其中 tcpip 的实现有 Google 背书,质量有保证。
-
如果选择使用 Rust 进行开发,我们的选择就会困难一点,并没有一个饱经风霜、经过时间检验的实现,在广泛对比之后我推荐 smoltcp ,这是为裸机实时系统开发的独立的、事件驱动的 TCP/IP 栈,其设计目标是简单和健壮,应该可以信任吧。
-
当然,我觉得还有一个可以期待的实现,就是 Google 为 Fuchsia 操作系统开发的 Netstack3 ,之前是由 Go 实现的,不过现在 Google 用 Rust 重新实现了一个新的,谷歌背书,可以期待。
使用流程
在看完可供选择的实现后,我们来看一下在用户空间实现的网络栈如何使用。虽然不同在不同实现下,各个库有不同的编程接口和使用方法,但基本的思路都是一致的,这里我们便仅讨论基本使用流程。
基本思路
从原理上来讲,用户态网络栈就是要不断通过协议解析,从 IPv4 数据包中不断解析出 TCP 流中的载荷数据;将传输层载荷通过不断的协议封装,拿到最终的 IPv4 数据包。
从 TUN 往外读
从 TUN 设备所对应的句柄中读出了一段字节序列,便是需要处理的IP数据包,一般是 IPv4 协议,不过还是需要先根据字节序列的第一个字节进行判断。
如果判断为 IPv4 包,就将整个字节序列扔到 IPv4 的 Packet Parser 实现中,还原出 IPv4 数据包结构。根据 IPv4 Header 中的 protocol 字段,判断 payload 应该使用哪个上层协议解析。 rfc791
一般仅需要处理 ICMP、TCP、UDP 这三种协议,拿 TCP 为例,只需要将 IPv4 的 payload 扔到 TCP 的 Parser 中,即可取出我们想要的传输层载荷。(实际情况当然没有说的这么简单)
向 TUN 写数据
写的过程其实就是读的过程反过来,拿到的是某个传输层协议的 payload,就拿UDP为例,根据该数据报的元信息,构建出完整的 UDP Header,然后将 payload 内容拼接进去。
接下来构建 IPv4 Header,然后将 UDP 报文拼接进 IPv4 payload 中。在拿到 IPv4 数据包后,即可序列化为字节序列,写入 TUN 句柄了。
实际使用
上面的读、写过程看起来简单,但实际需要考虑的东西非常多,包括但不限于分片、丢包、重传、流量控制等等,TCP 作为一个极其复杂的传输层协议,有巨多情况需要考虑,很明显用上面的基本思路是非常繁琐并且难以使用的。
众多用户态网络栈肯定考虑到了这一点,实现都提供了非常友好且直接的接口,可以直接创建一个 TCP/IP 网络栈实例,拿到两个句柄,一端负责读取和写入网络层 IP 数据包,另一端负责接收和写入传输层载荷,中间的复杂转换关系和特殊情况都被内部屏蔽掉了。
操作系统网络栈
根据我们的需求,实际就是在 IPv4 和 TCP payload 之间进行转换,而操作系统的网络栈正好就有这个功能,我们无法简单的直接使用操作系统的网络栈代码,但是可以想办法复用操作系统网络栈提供的功能。TUN 在网络层已经打开了一个口子,还需要在传输层也打开一个口子,其实可以利用操作系统提供的 socket。
我们使用操作系统提供的 Socket 创建一个传输层的 Listener,将某个 IPv4 数据包的目标 IP 和目标端口修改为我们监听的 IP 和端口,然后通过 TUN 将该 IPv4 数据包注入到操作系统的网络栈中,操作系统就会自动的进行相应的解析,并将所需要的传输层 payload 通过前面创建的 Socket 发送给 Listener,由此便利用操作系统网络栈完成了 “往外读” 的操作。
对于“向里写”的操作,只需要向刚刚创建的传输层连接句柄写入即可,操作系统的网络栈同样会进行相应的封包,最后形成 IPv4 数据包。很明显,需要考虑反向的数据包,当向传输层连接的句柄中写入数据、操作系统的网络栈封包时,源 IP 和源端口会被视为新的目标 IP 和目标端口,因为我们需要使返回的 IPv4 数据包能够被 TUN 接口捕获到,在上面步骤中就不能只修改目标 IP 和目标端口,同时还要修改源 IP 和源端口,源 IP 应该限制为 TUN 网段中的 IP。
工作流程
在利用操作系统网络栈时,通常是以下步骤,这里拿 TCP 协议举例。
在我们的例子中, TUN网络的配置为
198.10.0.1/16
,主机IP为
198.10.0.1
,代理客户端监听
198.10.0.1:1313
,App想要访问
google.com:80
,自定义的DNS服务返回
google.com
的 Fake IP
198.10.2.2
。
1. Proxy 创建 TCP Socket Listener
这里首先要在系统网络栈的传输层开个口子,创建一个 TCP Socket Listener,监听
198.10.0.1:1313
2. 某 App 发起连接
当某需要代理的App发起连接,访问
google.com:80
,我们通过自定义的 DNS 服务返回一个 Fake IP (
198.10.2.2
),使流量被路由到 TUN 设备上。
当然这里也可以不使用 Fake IP 方式来捕获流量,通过配置路由规则或者流量重定向也可以将流量导向 TUN 设备,不过 Fake IP 是最常用的方法,所以这里以此举例。
3. 将 TUN 读取到的 IPv4 解析为 TCP 载荷数据
TUN 设备捕获到流量,也就是 IPv4 数据包,在读取出来后,需要利用系统网络栈解析出 TCP 载荷数据。
这一步,需要将读取到的IPv4数据包进行修改,也就是我们上面说的 源IP、源端口,目标IP和目标端口,还有相应的 checksum 也需要重新计算。修改的目的是让 IPv4 数据包通过 TUN 注入到操作系统网络栈后,能够被正确路由并通过一开始监听的TCP Socket将最里层的 TCP payload 返还给我们。
这里为了方便,直接将源 IP 和源端口设置为初始的目标 IP 和目标端口,在实际编程时,有更多的设置策略,也就是 NAT 策略。
4. 代理客户端请求代理服务器
此时代理客户端已经拿到了请求的真实 TCP 载荷,并且可以通过获取 TCP 连接的 peer 信息得到在第3步修改的源 IP 和源端口,通过这些信息可以通过查 NAT 表得到 App 真正想要访问的 IP 和 端口(甚至可以通过查 DNS 请求记录拿到域名信息),因此代理客户端可以根据自己的协议进行加密和封装等操作,然后发送给代理服务端,由代理服务端进行真实的请求操作。
5. 将返回数据封包回 IPv4 并写入 TUN
通过代理客户端与代理服务端、代理服务端与谷歌的通信,拿到谷歌真正的返回数据,现在需要重新封装回 IPv4 数据包,还是利用系统网络栈:将数据写入 TCP Socket (
198.10.0.1:1313
) 中,便可以在 TUN 侧拿到封装好的 IPv4,就是这么轻松。
6. App 拿到返回数据
上面的过程便是利用操作系统网络栈完成 IPv4 到 TCP 载荷数据及其反方向转变的过程。通过这种办法,可以充分利用操作系统的实现,都是饱经检验,质量可靠,且满足各种复杂情况。但是也有缺点,数据需要拷贝多次,增加了性能损耗和延迟。
NAT 策略
我这里想说的 NAT 策略不是指常说的那四种 NAT 类型,当然你可以去实现不同的NAT类型来满足各种各样的需求,但那是更深入的话题,不在本文讨论。
在刚刚的流程的第3步中,你应该发现对源 IP 和源端口的修改是有限制的,我们需要将 IP 限定为 TUN 网段,从而使返回的数据包可以重新被 TUN 设备捕获。但是这种限制是非常宽松的,在我们的例子对 TUN 设备网段的配置中,你有 2^16 个 IP 可供选择,每一个 IP 又有 2^16 个端口可供选择。
但是如果你仔细观察,你会发现上面的例子并没有充分利用这些资源,我们仅仅是将 Fake IP 作为源 IP、真实目标端口作为源端口,而这个 IP 的其他端口都被闲置了。同时我也在其他人写的某些程序中发现,他们仅选择一个 IP 设置为源 IP,通过合理的分配该 IP 的端口作为源端口,在这种情况下, TUN 网段中其余的 IP 资源就被浪费了。
以上两种 NAT 策略在个人电脑上没啥问题,但是如果代理客户端运行在网关上,网络中访问的 IP 数量超过网段中 IP 数量上限,或者 hash(ip:port) 数量超过端口总数(2^16),就会难以继续分配 NAT 项。因此我们应该专门编写一个 NAT 管理组件,合理分配 IP 和端口资源,争取做到利用最大化。
防止环路
抛开事实不谈,如果我们想要代理全部流量,就是要通过路由规则将所有流量导向我们的 TUN 设备,这是很直观且朴素的想法,就像下面的命令一样单纯:
|
|