译注:本文翻译自 Tun/Tap interface tutorial。这篇文章写的相当不错,特翻译。另外,为了跟以前博文统一,本文将 interface
统一翻译为设备
;在适当地方,进行意译。
前言:请注意本文所涉及代码只用于展示。如果你想谨慎点,你需要自己提高代码的健壮性和整合其到其他代码中。另外,本文的描述也不是关于本文主题的最终参考,而只是我个人的实验结果。请报告在代码和文章中找到 bug 或者错误,谢谢!
本文所涉及的源代码包链接:simpletun。
更新于 2010 年 07 月 18 日:通过这篇文章,我才了解到 iproute2 的新版本已经可以(最终)创建 tun/tap 设备,尽管对这功能没有(还是这样?)完善文档支持。因此,安装 tunctl (UML 工具)或者 OpenVPN 来只创建 tun 设备就不必要了。下边是 iproute2-2.6.34 关于 tun/tap 帮助文档:
1 | # ip tuntap help |
Tun/Tap 设备是由 Linux (和可能其他类 Unix 操作系统)所提供的特色功能,可用于用户空间网络操作(userspace networking),也即允许用户空间程序直接接触原生网络流量(以太网层或 IP 层),并对它们进行任意处理。本文尝试解释在 Linux 下 tun/tap 设备是如何工作的,并通过一些样例代码来展示如何使用它们。
Tun/Tap 是怎么工作的
Tun/Tap 设备是软件定义的设备,这意味着它们只存在于内核,而且,跟一般网卡设备不一样,它们没有对应的物理设备(所以也没有物理连线连接到它们)。你可以把 tun/tap 设备想像成一个常规的网卡设备,当内核要通过“物理连线”发送数据的时候,将数据发送到跟 tun/tap 设备绑定(有具体流程,见下文)的用户空间的程序。当用户空间程序绑定到 tun/tap 设备,它会得到一个特殊的文件描述符
,通过该描述符可以读取从 tun/tap 设备发出的数据。类似地,用户程序可以向该描述符写数据(必须适当格式化好,见下文),然后这些数据会作为 tun/tap 设备的输入。对于内核而言,这个过程就像是 tun/tap 设备从“物理连线”上收发数据一样。
tap 设备和 tun 设备的区别在于,tap 设备输出(也必须给出)的是完整的以太网帧,而 tun 设备输出(也必须给出)的是原生 IP 数据包(没有内核添加的以太网帧头)。一个设备是起 tun 设备还是起 tap 设备的作用是通过创建时的标签来区分的。
tun/tap 设备可以是临时的
,也即,它由一个同样的程序创建、使用和销毁;当该程序退出时,即使它没有显式地销毁该设备,该设备也会自动消失。另外一个创建 tun/tap 设备的方法是使得它们持久化
(persistent)(译注:除非你自己删除它);在这种情况下,tun/tap 设备由一个特定的工具(如 tunctl
、openvpn --mktun
)来创建,然后用户程序可以依附到该设备;在这个依附过程中,用户程序必须使用创建设备时的类型(tun 或者 tap)来连接到设备,否则依附将会失败。下文我们看看在代码中是如何做到的。
一旦一个 tun/tap 设备创建成功,那它就可以像其他设备一样使用,意味着可以给它添加 IP、分析它的流量、添加防火墙规则、添加指向它的路由表等等。
有这些知识背景,让我们看看怎么使用 tun/tap 设备和可以用它们来做什么。
创建 Tun/Tap 设备
创建一个全新的设备和(重新)依附到一个持久化的设备的代码本质上是一样的,区别在于前者需要 root 用户来创建(好吧,更准确点,是拥有 CAP_NET_ADMIN
权限的用户),而后者只要普通用户满足一些要求就可以做到。让我们从创建一个新的设备开始。
首先,不管你做什么,/dev/net/tun
设备都必须以读/写模式打开。这个设备也称为克隆设备
(clone device),因为它被用作创建 tun/tap 虚拟设备的起点。这个操作(跟其他 open() 调用一样)返回一个文件描述符。但要用该描述符跟设备进行通信,这样还是不够的。
第二步是调用系统调用函数 ioctl()
,传入的参数包括第一步得到的文件描述符、常数 TUNSETIFF
、指向一个描述虚拟设备参数(基本的,包括名字、运行模式——tun 或 tap)的数据结构。作为可变参数,虚拟机设备的名字可以不指定,内核会试着按已存在的类型(tun 或 tap)一样的的设备的下一个设备来挑选名字(如,如果 tap2 已经存在,那内核就会试着分配 tap3,以此类推)。这些操作都需要 root 用户
(或者是拥有 CAP_NET_ADMIN 权限的用户 ——在下文我不会再重复,这里约定当我说“必须由 root 用户执行”时,就有这一层意思)。
如果 ioctl() 函数成功返回,那虚拟设备就已经创建好,而且文件描述符已经关联到该设备。可以使用该描述符进行通信。
到这点,有两件事可以发生。用户程序可以开始马上使用该设备(可能提前给它配置了一个 IP 地址),使用完的时候,退出并销毁掉该设备。另外一个选择是通过其他特殊的 ioctl() 调用把该设备转化为持久化的,程序退出也不销毁该设备,保留给其它程序使用。这是 tunctl
或者 openvpn --mktun
这类程序的做法。例如,这类程序通常也提供设置设备的所有者为非 root 用户或非 root 组的功能,所以当程序以非 root 权限运行并拥有该设备的合理权限时,就可以依附到该设备。
创建虚拟设备的基本代码可见于内核代码树下的 Documentation/networking/tuntap.txt 文档。修改一小点,就可以写一个创建虚拟设备的准函数:
1 |
|
tun_alloc() 函数接受两个参数:
char *dev
包含设备的名字(如 tap0、tap2 等等)。尽管采用能够暗示其类型的名字更好点,任何名字都可以使用。实际中,像 tunX 或者 tapX 的名字很常用。如果 *dev 为\0
,内核会尝试创建第一个请求类型的设备(如 tap0,但如果已有同类型的设备存在,则为 tap1,以此类推)int flags
告诉内核将要创建的设备类型(tun 或者 tap)。基本地,可以用IFF_TUN
来表示 Tun 设备(无以太网帧头),或者IFF_TAP
来表示 Tap 设备 (有以太网帧头) 。另外,另一个标志IFF_NO_PI
能够与这两个基本的标志进行或运算。 该标志告诉内核不要提供数据包信息,它的目的就在于告诉内核数据包是“纯洁的” IP 数据包,没有多余的字节;反之( IFF_NO_PI 没有设置),4 个额外的字节就会被添加到数据包的前边(2 个标志字节和 2 个协议字节)。 IFF_NO_PI 不需要在设备创建和重新连接期间进行匹配。另外需要注意的是,当用 Wireshark 抓包的时候,那 4 个字节不会被显示。
可以利用下边代码来创建一个设备:
1 | char tun_name[IFNAMSIZ]; |
到了这里,就如我们之前所说的,程序可以只用该设备于自己的目的,或者把它设定为持久化(和设置该设备所有者为具体的用户/组)。如果程序做的是前一种,那没有什么好说的;但如果它做的是后一种,这正是后文要说的。
有两个额外的 ioctl() 调用可用,它们经常一起使用。第一个系统调用可以设置(或移除)设备持久化状态;第二个系统调用允许将设备的所有者赋给普通 (非 root) 用户。这两个功能,tunctl
和 openvpn --mktun
(或者其他)都提供。接下来,因为 tunctl
代码更为简单,我们看看。记住,这个代码片段只创建 tap 设备,就像用户模式 Linux (User-mode Linux)用法一样(代码简单编辑和简单化过,以更清晰明了):
1 | ... |
这些额外的 ioctl() 调用还是需要 root 用户来运行,但我们现在所拥有的是一个由某个用户拥有的持久存在的设备,所以以该用户运行的程序能够依附到这个设备上。
就如前边所说的,这证明了(重新)依附到一个已存在的 tun/tap 设备的代码和创建一个新的设备的一样;换句话说,tun_alloc()
函数可以被再次使用。当这样子做的时候,要保证成功,需要完成以下 3 件事情:
设备必须已经存在,而且由同一个想要连接到它的用户拥有(可能需要持久存在)
该用户必须对
/dev/net/tun
有读写权限依附提供的类型必须和创建 tun/tap 设备时的类型一致(如,设备创建时类型为 IFF_TUN,要重新依附也必须使用同样的类型)
当用户要创建的设备名已存在,但他是该设备的所有者,那内核允许带 TUNSETIFF
参数的 ioctl() 调用成功(译注:指 err = ioctl(fd, TUNSETIFF, (void *) &ifr)
)。在这种情况下,没有新设备会被创建,所以一个普通用户可以成功(重新)依附到设备。
所以这些都是要说明当调用 ioctl(TUNSETIFF
) 时会发生什么、内核是如何区别处理新建设备和依附到设备的请求:
如果给定的设备名字之前不存在或没有指定,这意味着用户请求新建一个新的设备,内核因此利用给定的名字(或由内核自己确定)创建一个新的设备。这由 root 用户完成。
如果给定的设备名字已存在,这意味着用户想要连接到该设备。这可由普通用户来完成,只要该用户对克隆设备有适当权限、是该设备的所有者(创建的时候设定)、指定的类型和该设备的类型(创建的时候指定)相匹配
你可以查看内核代码中实现了上述步骤的代码 drivers/net/tun.c
。重要的的函数是 tun_attach()
、tun_net_init()
、tun_set_iff()
、tun_chr_ioctl()
;最后一个函数实现了多种 ioctl() 调用,包括 TUNSETIFF、TUNSETPERSIST、TUNSETOWNER、TUNSETGROUP 和其他。
在任何情况下,非 root 用户是不允许配置设备的(即添加 IP 和置其状态为 UP),这跟对待常规(物理)设备一样。当需要 root 权限时,一些常规方法(suid binary wrapper、sudo 等)可以使用。
下边是可能的使用场景(其中一个我一直在使用):
虚拟设备被创建、持久化、赋予用户和由 root 用户进行配置(如,在机器启动期间用使用初始化脚本,使用 tunctl 或等同的工具)
普通用户依附和脱离(detach)它们拥有的虚拟设备的无数次
虚拟设备由 root 用户销毁,例如,在关机时候的运行销毁脚本,可能使用 tunctl -d 或等同的工具
让我们试试
在这长篇但必需的介绍过后,是时间做点工作了。所以,因为我们要用到的是一个平常的设备,我们把它当做常规设备使用就可以了。就我们的目的而言,使用 tun 和使用 tap 设备没有什么区别;是程序需要知道设备的类型以创建它、依附到它、向它读写数据。让我们创建一个持久化的设备并为其添加一个 IP:
1 | openvpn --mktun --dev tun2 |
让我们对网络进行分析和查看流量:
1 | tshark -i tun2 |
tshark 的输出为空,没有任何流量经过该设备。这就对了:因为我们是在 ping 设备的 IP 地址,操作系统正确决定无需将数据包发送到“物理连线”上,而是由内核直接对这些 ping 进行回复。如果你再想一下,你 ping 另一个设备(如 eth0)也会遇到一模一样的结果,没有数据包会被发送出去。这听起来很明显,但可能是一开始混淆的来源(我就是)。
知道将一个掩码 24 位(X.X.X.X/24)的 IP 地址赋予一个设备将会创建一条通过该 IP 通往该 IP 所在子网的路由,我们修改实验并强迫内核真正从 tun 设备发送点什么东西出去(**注意:下边结果在内核版本小于 2.6.36 的环境得到;大于该数的得到结果不一样,就如评论中解释的。**)
1 | ping 10.0.0.2 |
现在我们终于看到点东西了。内核看到地址(10.0.0.2)不属于本地设备,但找到了一条通过 tun2 到 10.0.0.0/24 网络的路由,所以它就按规则把数据包从 tun2 发送出去。请注意这里 tun 设备和 tap 设备的不同行为:对于 tun 设备,内核发送的是 IP 数据包(原生,没有其余头部——试着用 tshark 或 wireshark 分析它),而对于 tap 设备,内核会为目标 IP 地址广播 ARP 数据包:
1 | pinging 10.0.0.2 now, but through tap2 (tap) |
而且,对于 tap 设备,网络流量由完整的以太网帧组成(同样,你可以用网络分析器进行分析)。注意,tap 设备的 mac 地址由内核在其创建期间自动生成,但可以通过调用 ioctl() 函数传入 SIOCSIFHWADDR
参数修改(再次看看 driver/net/tun.c
中的函数 tun_chr_ioctl()
)。最后,tap 设备的 MTU 被设置为 1500:
1 | ip link show dev tap2 |
当然了,目前为止还没有程序依附到该设备上,所以所有往外发送的数据包都丢失了。让我们更进一步,写一个简单的程序,将其依附到设备,并读取从内核发出的数据包。
一个简单的程序
我们将要写一个程序,将其依附到设备,并读取从内核发出的数据包。记住,如果对克隆设备 /dev/net/tun
有必要的权限、是该设备的所有者、为该设备选择了正确的类型(tun 或 tap),那你可以以普通用户的权限来运行程序,只要设备是可持久化的。下边这个程序实际只是一个框架,或者只是一个框架的开头,因为我们只展示了如何从设备中读取数据和只解释了程序在拿到数据后能做什么。我们假定之前定义的 tun_alloc()
函数在程序中是可用的。下边是代码:
1 | ... |
如果你为 tun77 添加了 IP 地址 10.0.0.1/24,然后运行以上程序,同时 ping 10.0.0.2 (或者任何在 10.0.0.0/24 子网除了 10.0.0.1 外的 IP 地址),你可以从该设备中读取到数据:
1 | openvpn --mktun --dev tun77 --user waldner |
如果你算一下,你会发现,这 84 字节的数据来自:20 个字节来自 IP 头部、8 个来自 ICMP 头部、56 个是 ICMP 的回文消息主体,就如你可以从 ping 命令中看到的一样:
1 | ping 10.0.0.2 |
试着用上边程序发送各种各样的数据包到该设备(同样也试试 tap 类型的),同时证明你读取的数据大小跟设备类型是一致的。每一次调用 read() 返回一个完整的数据包(或者是以太网帧,对于 tap 模式);相似地,如果我们要写,就要为每一次调用 write() 写一整个 IP 数据包( 或者是一整个以太网帧,对于 tap 模式 )。
接下来的两大段是作者的思维拓展,太长,就不译了,有兴趣可以看原文。
隧道
但是对于 tun/tap 设备我们还有另一件事可以做的,就是创建隧道(tunnel)。我们不需要重新实现 TCP/IP,相反,我们可以写一个程序来将数据来回地转播到运行同样程序的远程主机,这个远程程序对称地做着同样的事情。让我们假设,对于上边的程序,除了依附到 tun/tap 设备,还建立了跟远程主机之间的连接。这个远程主机也运行了一个相似的软件(也依附到远程主机的本地 tun/tap 设备),是以服务端模式运行。(实际上,这两个程序是一样的,谁是服务端谁是客户端取决于使用的命令行参数)一旦这两个程序都在运行,网络流量就会往两边流动,因为程序的主要代码在两边做的事情是一样的。这里的网络连接是用 TCP 来实现的,但其他方式也是可以的(如 UDP,或者甚至 ICMP)。你可以从 simpletun 下载到完整代码。
下边是程序的主循环,实现了 tun/tap 设备和隧道之间数据流动的实际工作。为了清晰起见,debug 语句已经移除(你可以在完整源码包中看到)。
1 | ... |
(要查看 read_n()
和 cwrite()
的详细,参考源码,它们所做的应该显而易见。是的,上边关于 select() 的代码并不是 100% 准确的,而且做了一些简单的假设,如 read_n() 和 cwrite() 函数都不会阻塞。就如我说的,这个代码只是为了展示而已。)
下边是上边代码的主逻辑:
程序使用了
select()
函数来保持同时对两个描述符的控制。不管数据从哪一个描述符读进来,都写到另一方的描述符。因为程序使用了 TCP,接收方会接收到一个单独的数据流,使得确定数据包的边界变得困难。所以当一个数据包或以太网帧被写到网络的时候,它的长度(2 个字节)都会附加在真正的数据包的前边。
当数据从 tap_fd 描述符进来,就读取一个完整的数据包或以太网帧,因此在它们前边附加上长度就可以直接写到网络描述符。因为长度的数据类型是短整型,大于 1 个字节,按原生二进制格式写入,所以 ntohs()/htons() 函数被用于屏蔽不同机器大小端不一致的问题。
当数据从网络描述符进来,由于前文提及的技巧,通过读取长度字节就可以知道即将接收的数据包有多长。当读取完数据,就将这些数据写到 tun/tap 设备描述符,最后由内核来接收,就像从“物理连线”接收数据一样。
所以你可以用这个程序来做什么呢?你可以用它来创建一个隧道!首先,在隧道两端的主机创建和配置好所需的 tun/tap 设备,包括给它们添加 IP 地址。在本例中,我用到了两个 tun 设备:tun11,IP 地址 192.168.0.1/24,在本地主机;tun3,IP 地址 192.168.0.2/24,在远程主机。simpletun 这个程序使用 TCP 协议、默认端口号 55555 (你可以通过命令行 -p 命令改变)。远程主机将会把 simpletun 以服务端模式运行,在本地主机以客户端模式运行。现在我们来看看(远程主机 IP 地址为 10.2.3.4):
1 | openvpn --mktun --dev tun3 --user waldner |
当一个像上边一样的隧道被建立起来,从外边只能看到的只是两个同等 simpletun 之间的一条连接(上例中是 TCP)。真正的数据(即更高层应用程序交换的数据,上例中是 ping 或 ssh)永远不会被直接暴露(尽管数据以明文方式传输,见下文)。如果你开启了一个运行了 simpletun 的主机上的 IP 转发功能(译注:可通过 /proc/sys/net/ipv4/ip_forward 查看,StackExchange 上有一个不错的回答),并且创建了到其他主机的必需路由,那你可以通过隧道连接到这些主机。
需要注意的是,如果虚拟设备都是 tap 类型的,透明地把两个地理上隔开的以太网连接起来是可能的,以致这些设备认为它们都在同一个二层网络。要做到这一点,在网关(也即运行了 simpletun 或其它使用了 tap 设备的隧道软件的主机)上,把本地的 LAN 设备和虚拟的 tap 设备桥接起来是有必要的。这种方式,从 LAN 收到的以太网帧也会发送到 tap 设备(因为桥接),然后隧道软件再从该设备读取数据并将它们发送远程对端;在远程对端,另一个网桥会保证接收到的以太网帧会被转发到 LAN。反过来,同样的事情会再次发生。因为我们是在两个 LAN 之间传送以太网帧,这两个 LAN 被有效地桥接在一起。这意味着如果你在伦敦有 10 台机器、在柏林有 50 台机器,你就可以利用 192.168.1.0/24 子网(其他子网也可以,只要能容纳至少 60 个 IP 地址)的 IP 地址创建 60 个以太网络。不过,如果你想建立这样的网络,不要使用 simpletun。
扩展及改进
simpletun 非常简单,可以用多种方式对其进行扩展。首先,采用新的方式连接到对端,如利用 UDP、ICMP;第二,目前数据是以明文方式传递,但这样当数据在程序的缓冲区在被发送前可能被修改,所以可以对这些数据进行加密(类似地在对端进行解密)。
然而,对于该导论目的,本文所用的简单程序应该已经给了你一个如何用 tun/tap 构建隧道的想法。虽然 simpletun 是一个简单的展示,但这确实是很多流行程序使用 tun/tap 工作的方式,如 OpenVPN、Vtun 或 OpenSSH 的 VPN 特性。
最后,把隧道建立在 TCP 连接上是不值得的,因为我们可能会遇到一种所谓的“tcp over tcp”的情形,详情请参考博文 “Why tcp over tcp is a bad idea“ 。注意,OpenVPN 因为此原因默认使用 UDP,因为使用 TCP 会降低性能(尽管有些情况不得不用这个)。