使用 TUN 的模式

https://zu1k.com/posts/coding/tun-mode/

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 请求记录拿到域名信息),因此代理客户端可以根据自己的协议进行加密和封装等操作,然后发送给代理服务端,由代理服务端进行真实的请求操作。

Google

5. 将返回数据封包回 IPv4 并写入 TUN

通过代理客户端与代理服务端、代理服务端与谷歌的通信,拿到谷歌真正的返回数据,现在需要重新封装回 IPv4 数据包,还是利用系统网络栈:将数据写入 TCP Socket (198.10.0.1:1313) 中,便可以在 TUN 侧拿到封装好的 IPv4,就是这么轻松。

Kernel

6. App 拿到返回数据

Kernel

上面的过程便是利用操作系统网络栈完成 IPv4 到 TCP 载荷数据及其反方向转变的过程。通过这种办法,可以充分利用操作系统的实现,都是饱经检验,质量可靠,且满足各种复杂情况。但是也有缺点,数据需要拷贝多次,增加了性能损耗和延迟。

我这里想说的 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 设备,这是很直观且朴素的想法,就像下面的命令一样单纯:

1
sudo route add -net 0.0.0.0/0 dev tun0

如果你真的这么写,你就会发现你上不了网了。这是因为出现了环路。

如果稍微思考一下,你就会发现,虽然我们想要代理所有流量,但是代理客户端与代理服务端的流量却是需要跳过的,如果用上面的路由,就会导致代理客户端发出的流量经过路由然后从 TUN 重新回到了代理客户端,这是一个死环,没有流量可以走出去。流量只近不出,来回转圈,你的文件打开数爆炸,操作系统不再给你分配更多的句柄,数据来回拷贝,你的CPU风扇猛转,电脑开始变卡。

这是我们不想看到的,需要采取一些措施避免环路的产生。在实践中有不少方法可以避免这种情况的发生,例如通过合理的配置路由规则,使连接代理服务器的流量可以顺利匹配到外部网络接口。只不过这种方法不够灵活,如果代理服务器 IP 发生变化则需要及时改变路由规则,非常麻烦,所以我们接下来介绍其他的方法。

Fake IP 就是我们上面例子中用到的方法,这是一种限制进入流量的方法。基本思路是自己实现一个 DNS 服务器,对用户的查询返回一个假的 IP 地址,我们可以将返回的 IP 地址限制为 TUN 设备的网络段,这样应用发起的流量其实便是发给 TUN 网络的流量,自然的被路由匹配,而无需像前面那样路由全部的流量,其余的流量包括代理客户端发起的请求便不会被路由,可以保证不产生环路。

当代理客户端需要知道应用真正想要请求的地址时,就通过一些接口向自己实现的 DNS 服务器进行反向查询即可。

通过前面的分析,可以发现产生环路是因为代理客户端本身发出的流量被系统路由到 TUN 设备导致的,因此我们可以想办法让代理客户端本身发起的流量不走 TUN 而是从真实的物理网络接口出去。

在 (类)Unix 系统中,可以对代理客户端的流量打上 fwmark 防火墙标记,然后通过策略路由使带有标记的流量走单独的路由表出去,从而绕过全局的流量捕获。

cgroup

cgroup 是 Linux 内核的功能,可以用来限制、隔离进程的资源,其中 net_cls 子系统可以限制网络的访问。在网络控制层面,可以通过 class ID 确定流量是否属于某个 cgroup,因此可以对来自特定 cgroup 的流量打上 fwmark,使其能够被策略路由控制。

我们可以创建一个用于绕过代理的 cgroup ,对该 cgroup 下进程的流量使用默认的路由规则,而不在该 cgroup 的其余进程的流量都要路由到 TUN 设备进行代理。

TAP 在2层,读取和写入的数据需要是以太帧结构

TUN 在3层,读取和写入的数据需要是IP数据包结构

在给网卡配置IP时,其实是修改内核网络栈中的某些参数,而不是修改网卡。虽然网卡也会有一些可供修改的配置项,但一般情况是通过其他方法进行修改的(驱动程序)。

物理网卡会有 DMA 功能,在启用 DMA 时网卡和网络栈(内存中的缓冲区)的通讯由 DMA 控制器管理,因此性能更高延迟也更低。

在Linux下一切皆文件,/dev/net/tun 是特殊的字符(char)设备文件,通过打开这个文件获得一个文件句柄,然后通过 ioctl() 系统调用对其进行配置。在这里可以选择打开TUN设备还是TAP设备,可以设置设备名称。

详见:Network device allocation

BPF 是一种高级数据包过滤器,可以附加到现有的网络接口,但其本身不提供虚拟网络接口。 TUN/TAP 驱动程序提供虚拟网络接口,可以将 BPF 附加到该接口。

没有评论: