“暗度陈仓”

从一个 VPN demo 认识 Linux TUN 接口

Posted by kayoch1n's blog on January 31, 2024

/dev/net/tun 是个特殊的文件,它是个字符设备(character special)(/dev/urandom 也是个字符设备),可按照字节流读取or写入 IP packets(L3)。上一篇笔记提到,通过 fd 写入 tun 接口的数据,会被视为操作系统从 tun 接口接收到的 IP packets。与之对应,当 IP packets 离开 netfilter 时,如果 OUT 是一个 tun 接口,进程就能从 tun fd 读取到该数据。Linux 对 socket 和 tun fd 的处理方式是不同的:

  • 写入 socket 的数据,会被装上协议的 header,然后才进入 netfilter;
  • 写入 tun fd 的数据,如果是有效的 IPv4/IPv6 格式,就会直接进入 netfilter。

简单来说,tun 接口给予了使用者直接写入/读取 netfilter 数据的便利。这篇笔记将从一个用 Python 实现的简单 VPN demo 出发,记录 Linux tun 接口的工作方式。

Device allocation

打开 /dev/net/tun 并通过关联上一个名字之后,内核就会创建一个虚拟的网络接口,这个接口并无对应的物理网卡。默认情况下,同一时刻只能有一个进程打开 /dev/net/tun 并关联上同样的名称,比如说 tun0,此时如果另一个进程尝试打开/dev/net/tun 并关联tun0时,系统就会报告一个错误 EBUSY(device or resource busy)。当进程结束后,tun0就会被删除。

凡事有例外。有一种使用场景是,先用ip命令创建、配置并启动 tun0,然后在程序中用 API 打开并读写 tun0。因为命令本身是一个单独的进程, tun0 如果是被进程独占的话结束之后就会被删除,这种使用场景就无从实现了。那实际上 iproute2 是怎么做的呢?可以用 strace ip tuntap add dev tun0 mode tun观察一下

// ...
open("/dev/net/tun", O_RDWR)            = 4
ioctl(4, TUNSETIFF, 0x7fffefed4140)     = 0
ioctl(4, TUNSETPERSIST, 0x1)            = 0
close(4)                                = 0
// ...

第三个 syscall 用了一个叫做 TUNSETPERSIST 的request code,根据网上的资料,这个值可以使虚拟接口在进程退出之后避免被操作系统删除。

See resolved macros

用 Python 创建 tun 接口需要使用一些宏作为参数,但是这些宏在 CPython 中并未定义,得用点代码把这些值打出来:

void dump_hex(void* mem, size_t size) {
    printf("dump %lu bytes starting from %p:\n", size, mem);
    const char* ptr = (const char*)mem;
    for (size_t i = 0; i < size; i++)
    {
        printf("%02X", ptr[i]);
    }
    printf("\n");
}

顺便提一个找出头文件在文件系统中实际位置的方法,方便查找本地的头文件源代码:

gcc cmem.cc -H -fsyntax-only 2>&1 | grep tun.h

VPN demo

tun 虚拟接口常被用来实现 VPN。这个 VPN demo 是我基于这篇文章、用 Python 改写而成,Python 版的源代码在这里。这个 demo 涉及到的 IP 地址如下:

  • VPN client:
    • eth0 192.168.96.2/24
    • tun0 10.8.0.2/16
  • VPN server:
    • eth0 192.168.96.3/24
    • tun0 10.8.0.1/16
  • 网关 192.168.96.1

Docker container

因为涉及到修改 netfilter 和路由表,我怕把主机搞坏,所以用容器模拟一下;而且容器可以默认用户是root,敲命令可以不需要 sudo,用完之后整个容器删掉即可。话说自己以前傻乎乎的还以为容器就是虚拟机,现在了解到容器其实是一个用户态进程。可以用 docker inspect 命令查看容器对应的进程的 pid

docker inspect setsuna | grep -i PID

执行 iptables 修改 netfilter 需要 root 特权,容器在默认情况下无法使用。根据 man capabilities 的描述,传统上 Unix 将进程分成特权进程(euid=0, root/superuser)和非特权进程,通过 euid 和 egid 等方式检查权限;而 Linux 则是将root的特权划分成更小的单元,称为 capability ,可以为进程单独设置一个或多个 capabilities。为了能够修改 netfilter ,需要在 docker-compose.yml 中加入名为 NET_ADMIN 的 capabilities;此外,还需要映射 /dev/net/tun 设备,否则 demo 打开该设备会失败:

version: "3.8"
services:
  shizuku:
    # ... 其他字段
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun

最后,需要在宿主机上面打开IPv4转发。如果你要观察日志的话,还得允许 netfilter 输出所有 namespace 的日志

sudo sysctl net.ipv4.ip_forward=1
sudo sysctl net.netfilter.nf_log_all_netns=1 # 容器内没法使用 dmesg

P.S. 有的资料提到 docker 允许单独为容器指定部分内核参数,这个说法恐怕会造成迷惑。我本地(ubuntu 18.04,docker 24)试了一下发现,在宿主机 IPv4 转发关闭的情况下,容器尽管使用了 sysctls: [net.ipv4.ip_forward=1] 也还是不能转发IPv4。而且这跟宿主机的 user 是否为 root 无关,哪怕容器本身是用 sudo 拉起来的也一样。不过,反过来就不一样了,在宿主机打开该开关的情况下,容器使用了 net.ipv4.ip_forward=0 雀食能禁止 IPv4 转发,猜测 docker 只能关闭内核参数,而不能主动打开被宿主机关闭的内核参数。

Client

client 的发包和收包过程大致是:

  1. netfilter 将所有进程产生的 IPv4 packets 路由到 tun0
  2. client.py 从 tun0 读取 IPv4 packets,加密之后写入 UDP socket;
  3. client.py 从 UDP socket 接收到数据,解密之后写入 tun0
  4. netfilter 将 reply dispatch 到对应的进程。

vpn demo client

在 netfilter 中,同一个 packet 先后遍历了两次 source localhost(1 and 3) 以及两次 destination localhost(14 and 16)。

Route all traffics to tun0

首先,client 通过 iproute2 修改默认的 main 路由表配置,将所有 IPv4 packet 都路由到 tun0 接口:

ip route add 0/1 dev tun0
ip route add 128/1 dev tun0

这里 0/1128/1CIDR 的表示方式:0/1 表示选择第一个bit是0的所有 IPv4 地址,128/1 表示选择第一个bit是1的所有IPv4。两者组合在一起等同于默认路由 0.0.0.0/0,使用这两个路由组合可以避免跟已有的默认路由产生冲突。一般来说操作系统在用CIDR的时候会采取最长掩码匹配策略,这两个CIDR的 subnet mask 长度都是1,因此比默认路由(长度为0)的优先级高。

上述两条路由起作用的时机是 Local Process 之后的第一次 routing decision,在 OUTPUT chain 之前;这次 routing decision 将 IPv4 packet 的源地址设定为 tun0 的 IPv4 地址,就像下面的 ping -c1 119.29.29.29 产生的日志一样:

[Jan 31 21:15] IN= OUT=tun0 SRC=10.8.0.2 DST=119.29.29.29 LEN=84 TOS=0x00 PREC=0x00 TTL=64 ID=61307 DF PROTO=ICMP TYPE=8 CODE=0 ID=41 SEQ=1

可见在 raw OUTPUT 之前,源地址已经是 tun0 的 IPv4 地址 10.8.0.2

日志:iptables -I OUTPUT -t raw -d 119.29.29.29 -j LOG

“Forward” IPv4 packets from tun0 to eth0

当 IPv4 packet 被路由到 tun0 之后,client 就能从 tun0 fd 读取到完整的 IPv4 packet,而不仅仅是 payload data。这一点非常重要:对于server来说,保留了 IPv4 目的地址,server 才能将 packet 转发到真正的目的地。client 将 IPv4 packet 作为 UDP 的 payload 原封不动地发送给 server。在实际应用中这部分逻辑可能更复杂,client 可以将 payload 加密,而且传输协议也不限于 UDP。为简单起见,这里的 demo 就是直接发明文。以 ping 为例,下图是最终 client 发送给 server 的 UDP datagram

UDP datagram

可见在 ICMP msg 连带 IPv4 header 都作为 UDP 的 payload 出现

另外,为了避免这个 UDP datagram 受到前面添加的两条路由的影响、又跑到 tun0 造成死循环,client 的路由表需要再加一个发往目的地 server 使用默认网关的路由:

ip route add 192.168.96.3 via 192.168.96.1

Server

server 的发包和收包过程大致如下:

  1. server.py 从 UDP socket 中接收到加密的数据,将其解密后写入 tun0
  2. netfilter 从 tun0 收到 IPv4 packets,将其转发到 eth0 ,MASQUERADE 修改源地址,发往真正的目的地;
  3. netfilter 从 eth0 收到 reply,MASQUERADE 矫正目的地址,转发到 tun0
  4. server.py 从 tun0 中读取到 IPv4 packets,将其加密后往 UDP socket 发送。

vpn demo client

在 netfilter 中,同一个 packet 先后遍历 destination localhost(5)、两次 forwarded packets(7 and 10) 以及一次 source localhost(12)。

MASQUERADE

相比 client,server 不需要设置路由,但是要设置 iptables。server 从 UDP socket 中读取的 IPv4 packet 源地址仍然是 client tun0 的 10.8.0.2。为了能正常收到 reply,server 设置了一条针对源地址 10.8.0.2/16 使用 MASQUERADE 的规则:

  • 在 forward incoming IPv4 packets 的 POSTROUTING chain,IPv4 packet 的源地址如果是 10.8.0.2/16 就会被修改为 192.168.96.3,也就是 server eth0 的地址;
  • 在 forward reply 的过程中,reply 的目的地址会从 192.168.96.3 被修改为 10.8.0.2。这可能是 nat PREROUTING 利用 conntrack 实现的。
  • client tun0 和 server tun0 的 IPv4 地址需要在同一个子网,否则 MASQUERADE 不会起作用,原因见 上一篇关于 iptables 的笔记

More on iptables

client 和 server 都会设置以下两条规则:

iptables -I FORWARD 1 -i tun0 -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -I FORWARD 1 -o tun0 -j ACCEPT

这两条规则都指定了顺序为1,目的是要让 FORWARD chain 确保 VPN 的 traffic 不会被 DROP 掉。虽然 chain 的默认 policy 可能是 ACCEPT,但是如果单纯依赖这一点而不用规则显式 ACCEPT 的话,就有可能出现 VPN traffic 被别的规则 DROP 掉的问题。而且,考虑到将来可能的用途,包括 LOG、修改默认 policy or precedence,显式 ACCEPT 也是一个好的习惯。

最后,client 上还有一条使用了 MASQUERADE 的规则

iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE

这条规则可以让 client 起到 VPN 网关 的作用,让同一个子网下的其他主机不需要各自启动 VPN client 即可使用该 VPN 的能力,其他主机只要设置路由将 traffic 甩到 client 主机。client 本身已经开启 IPv4 转发,而 MASQUERADE 会将转发的 IPv4 的源地址修改为 tun0 的 10.8.0.2,reply 的时候又能自动修正目的地址~

ip route add 0/1 via 192.168.96.2
ip route add 128/1 via 192.168.96.2

在第三台主机 192.168.96.3 上设置路由。其中,192.168.96.2 是 client 的 IPv4 地址。

Conclusion

IPv4 packets 从 client 的 tun0 的进入,从 server 的 tun0 出来,其内容在这期间没有经过任何修改,就像进入了一个隧道一样,进去的时候是啥样,出来的时候也是那个鬼样,方向反过来也是一样。不得不佩服协议分层确实是一个精妙的设计!这个 IP 隧道对于所有运行于 IPv4 之上的应用程序来说是透明的,应用程序无需修改or重启即可使用隧道,同时也能保有上层协议的全部能力(e.g., TCP keepalive)。最后来一个 vpn demo 的全景图~

vpn demo client

Reference