背景

在下面这篇文章里笔者已经写了南京大学的IPv6有十分令人窒息的配置:

简单说来:
  • 使用 DHCPv6 分配单个 /128 地址,没有 DHCPv6-PD,没有 SLAAC
  • 路由器的 Router Advertisement 没有任何前缀信息,因此网络中主机不会有内网链路上路由,即使是同一个链路上的两台主机互通也会走默认路由即路由器上过一圈(虽然多半是用的立派的「华为」网络层交换机)。笔者甚至怀疑学校没有做正确的前缀层叠划分工作;
  • 分配的地址和 MAC 地址绑定,同一 MAC 地址即使更换 DUID 和 IA 也仅能拿到同一个 IPv6 地址,且路由器不会路由 IPv6 地址和 MAC 地址不匹配的包(即不可在路由器中继 RS/RA)

在此之外,甚至有一些楼栋甚至完全没有 IPv6,还用着百兆网。不幸的是,笔者的实验室所在的楼栋正在此列,而更新网络设备或者支持 IPv6 一直没有被提上议事日程,更别说修正上面提到的离谱配置了。

因此,笔者尝试通过隧道从其它有 IPv6 的楼栋接网,最后取得了较好的结果,在此分享出来。由于此方案是为了应对极端特殊的网络配置,笔者并不预期此方案会有太大的参考价值,大家看个乐就好。

目标与方案

使用 IPv4 给没有 IPv6 的网络/设备接通 v6 有无数种方案,但适合我们情况、满足我们目标的方案寥寥无几:

  • 6in4 一路上的防火墙未必支持,6to4 需要专门的 IP 区间,而我们希望无 NAT6 地让下层设备直接拿到学校 DHCPv6 分配的地址;
  • Teredo 仅能处理单台主机,我们需要做 Site-to-Site 的隧道,即我们希望在实验室的路由器上配置后内网里所有机器不单独配置即可获得 IPv6 地址;
  • 各种网络层 VPN 如裸 WireGuard、L2TP/IPsec、OpenVPN 普通模式完全没法处理 DHCPv6 绑死 MAC 地址的现状;
  • 裸 GRETAP,理论上可行但因为学校楼际网络结构未知,不知道 GRETAP 能不能顺利通过,再加上实验室还有一层 NAT;
  • SoftEther 与 OpenVPN 开 TAP 模式可行,本文没有用;
  • WireGuard + GRETAP 为本文所用的方案;

即使使用 GRETAP,笔者也有两种构思:

  • 链路层直接桥接:优点是最原生,配置简单;缺点是难以配置防火墙(Linux 在链路层上的有状态防火墙不太成熟)、同一内网下的两设备互联要走路由器过一下(也就要来回一遍只有百兆的隧道),以及 PMTUd 难以工作(原则上 ICMPv6 Packet Too Big 需要由路由器而非交换机发送);
  • 在链路层转发 DHCPv6 包,但在内网(即实验室网络)拟态一个路由器:优点是这样内网两设备可以跑满千兆,PMTUd 可以工作,以及可以配置复杂的有状态防火墙;缺点是配置十分复杂(可能没现成实现,得手搓防火墙代码,比如需要截取所有 DHCPv6 包并配置成路由,即把路由表当成转发数据库用)、容易出问题;

最后笔者选择了第一个方案即直接桥接两网络链路层,因为实在是没有找到第二个方案的现成实现。同时我们有两点需要注意:

  • 需要使用防火墙等,阻断 IPv6 流量以外的流量;
  • MTU 需要下调——这个问题事实上相当复杂,后面会着重写到;

配置

Step 0: 状况

以下把实验室的网络,即没有原生 IPv6 的网络称为“受网”,把另一栋楼,即有原生 IPv6 的网络称为“供网”。我们需要在受网和供网上各有一台设备(以下称为受网主机和供网主机),特别地,供网侧的设备需要直接连入校园有线网,不能再隔路由器。

例子中供网主机在教学/行政/科研区域,因此有公网 IPv4 地址。但理论上由于在校内,而校内内网地址全部互通,因此只要有校园网 IPv4 地址即可。受网主机置于实验室内网里,有 NAT 阻断传入连接。因此自然让受网主机去连接供网主机。

Step 1: WireGuard 隧道

首先在供受网主机上创建好 WireGuard 公私钥,并记录下来:

wg keygen | tee /etc/wireguard/private.key
chmod 600 /etc/wireguard/private.key
wg pubkey < /etc/wireguard/private.key > /etc/private/public.key

使用 wg-quick,供网主机如下配置(/etc/wireguard/wg0.conf ):

[Interface]
Address = 169.254.11.1/32
ListenPort = 供网主机端口,可任意配置
PrivateKey = 供网私钥
MTU = 1380

[Peer]
PublicKey = 受网公钥
AllowedIPs = 169.254.11.2/32

受网主机如下配置(/etc/wireguard/wg0.conf):

[Interface]
Address = 169.254.11.2/32
PrivateKey = 受网私钥
MTU = 1380

[Peer]
PublicKey = 供网公钥
AllowedIPs = 169.254.11.1/32
Endpoint = 供网主机IP:端口

并放开供网主机防火墙上的对应端口,笔者供网主机防火墙默认放行因此没有配置。其次,启动两台主机上的 WireGuard 并确保能互相 ping 通:

systemctl enable wg-quick@wg0
systemctl start wg-quick@wg0
ping 169.254.11.1 (或者.2)

Step 2: 初步的防火墙与系统配置

由于我们仅处理 IPv6,而不能把两个网络的 IPv4 桥接起来(一个是校园网,一个是更深的内网),因此我们需要在防火墙上禁止 IPv6 包以外的包通过。笔者在受供网主机上均使用的是 nftables 防火墙,且其配置相同:

table bridge filter {
	chain postrouting {
		type filter hook postrouting priority srcnat; policy accept;
		oifname "gre-ipv6" meta protocol != ip6 drop
		iifname "gre-ipv6" meta protocol != ip6 drop
	}
}

由于匹配在 GRETAP 隧道上,我们可以直接丢弃掉非 v6 包,而不需要用 BROUTE 的特殊语义(分流转发与路由,在 nftables 1.0.8 才引入)。

接下来我们需要在供受网主机上均打开 IPv6 的包转发(/etc/sysctl.conf 中找到这行取消注释):

net.ipv6.conf.all.forwarding=1

要即时生效可以用 sysctl -p /etc/sysctl.conf

Step 3: 桥接与 GRETAP 隧道

由于受网和供网主机,一台用的 NetworkManager,一台用的 Netplan,笔者痛苦地写了两种配置( 」゚Д゚)」<(反而完全没有写 systemd-networkd 的配置)。重点在于两台机器的外网接口和 GRETAP 需要桥接起来,并把受供网主机自身的联网设备配置在网桥上。

由于学校的 IPv4 DHCP 与鉴权与 MAC 地址绑定,笔者在第一次启动网桥的时候因为网桥的 MAC 和物理网卡的不同,结果远程连接断了不得不上楼去机器前处理(σ゚д゚)σ,最后还是发生了 DHCP 重新映射的情况。

供网主机使用 NetworkManager 管理网络,因此执行了这些(其中 eno1 为直接接校园网的网卡):

nmcli con add ifname br-ipv6 type bridge con-name br-ipv6
nmcli con add type bridge-slave con-name br-ipv6-outer ifname eno1 master br-ipv6
nmcli con add type ip-tunnel ip-tunnel.mode gretap slave-type bridge con-name br-ipv6-inner ifname gre-ipv6 remote 169.254.11.2 local 169.254.11.1 master br-ipv6

nmcli con mod br-ipv6-inner ethernet.mtu 1280
nmcli con mod br-ipv6 ethernet.cloned-mac-address(原网卡的 MAC 地址)
nmcli con mod br-ipv6 bridge.stp false
nmcli con mod br-ipv6 connection.autoconnect-slaves true

nmcli con down eno1 && nmcli con up br-ipv6

完成后,执行 nmcli 应该看到 br-ipv6 桥上出现 IPv4/v6 地址,而原先的 eno1 不再有 IP,同时出现新的 gre-ipv6 隧道设备。

接下来是受网主机的 Netplan 配置(其中 ens18 为系统网卡):

network:
  ethernets:
    ens18: {}
  tunnels:
    gre-ipv6:
      mode: gretap
      local: 169.254.11.2
      remote: 169.254.11.1
      mtu: 1280
  bridges:
    br-ipv6:
      macaddress: 旧网卡 MAC 地址
      interfaces:
        - ens18
        - gre-ipv6
      dhcp4: true
      dhcp6: true
      parameters:
        stp: false
  version: 2

使用 netplan apply 应用即可。此时在受网侧网络找一台主机重启一下网络管理器,应该会出现 IPv6 地址。使用 tcpdumpgre-ipv6 设备上监听 ICMPv6 包,应该可以在受网侧设备接收到供网端的路由器的 ICMPv6 RA 包。但是此时直接打开网页会发现打不开,或奇慢无比。抓包发现出现了较严重的分片情况,且不知道为什么,一个超限的包会被分成大量 20 字节的包,且一秒一个地往外蹦。( ·_ゝ·)

显然,我们需要配置网络的 MTU。虽然前面在各种接口上配置了 MTU,但受端网络的其它设备是无法知道此 MTU 值的,而要正确配置 MTU 比想象中的要复杂得多。

Step 4: MTU

由于隧道的开销,我们需要限制隧道中以太网帧的最大长度,而不能跑到默认的 1500 字节。一个 TCP 报文从 【以太网头+IPv6头+TCP头】(此时 MTU 为 1518 - 18 = 1500 字节,TCP MSS 为 1518 - 18 - 40 - 20 = 1440 字节)变成了【以太网头+IPv4 头+UDP头+WireGuard头+GRE头+以太网头+IPv6头+TCP头】。一般 WireGuard 接口取的典型 MTU 为 1380 字节,我们在此基础上再往下减,1320 字节足矣。但是因为反正只有百兆网,跑到满速开销也可以接受,所以笔者为了保险使用了比较保守的 1280 字节 MTU,这也是 IPv6 在不进行链路层碎片化时允许的最低 MTU。此时的 TCP MSS 为 1220 字节。

同时,虽然 IPv6 需要限制 MTU,但受网侧主机的 IPv4 流量并不流经隧道,因此应当继续使用 1500 的正常 MTU,因此修改所有主机的网卡 MTU 也不是可行的方案。

最开始笔者试图配置防火墙,利用 IPv6 的 PMTUd 机制在过大的包通过防火墙的时候发送 ICMPv6 Packet Too Big 报文。然而我们的整个转发隧道事实上工作在链路层,并没有网络层成分,而按理说 ICMPv6 Packet Too Big 报文是路径上的路由器发的,交换机没法发(否则连 Source IPv6 地址都没得填)。要利用 PMTUd 就不得不在隧道路径上拟态一个路由器,或者 *真的搞出来* 一个路由器。甚至 nftables 没法在链路层用 ICMPv6 包 reject 一个数据包。

第二个方案是在防火墙上进行 TCP MSS Clamping,配置大致如下(在任意一侧):

(在前面的 nftables 的 bridge filter 表 postrouting 链中)
oifname "gre-ipv6" tcp flags syn tcp option maxseg size set 1220
iifname "gre-ipv6" tcp flags syn tcp option maxseg size set 1220

但这样 TCP 以外的协议也不能正常工作,如 UDP/QUIC,同时总感觉不太优雅。重新认真读了 ICMPv6 和 IPv6 NDP 的 RFC 后,笔者发现了 Router Advertisement 报文有一个可选的 Option 5 可以配置网络 MTU。似乎我们只需要配置这个选项,所有的主机就都可以正确限制 MTU。因此第一反应自然是直接在供网主机防火墙里配置:

nft rule bridge filter postrouting oifname "gre-ipv6" icmpv6 type 134 icmpv6 mtu set 1280 meta nftrace set 1

然而这样在旧版 nftables 里虽然能添加,但并不会起作用,在新版 nftables 里就直接不允许添加进规则表了。笔者最后的方法是,把所有的 Router Advertisement 消息发到用户态进行编辑。为此,我们需要用到 Netfilters Queueing 功能,并写一个程序在用户态使用 netlink 拉取数据包并编辑。虽然要写程序,显得略复杂,但这已经远比写一个内核态 Netfilters Hook 要简单太多了。

笔者写了一个 Rust 程序来编辑数据包——年轻人的第一个 Rust 程序……感觉各种 Lifetime 管理还是挺坐牢的,而其中最最难受的就是一个对象同时只允许存在一个可写引用的设定ー´)д´) `д´) 程序使用了 nfqueue 和内核 Netfilter 通信,用 pnet 解析/生成网络包(比较的难用)。过程还是比较曲折的,例如没仔细读 ICMPv6 的 RFC 造成浪费了很多时间在 checksum 上。

最后整个代码比想象中的要长一些,主要加了点离线用 PCAP 文件调试的代码。代码在 https://github.com/cqjjjzr/ramtu_clamp ,其中也包含了编辑安装配置教程。

以下是笔者在供网侧编译安装 ramtu_clamp 的命令,读者应视自己的发行版调节:

sudo apt install libnetfilter-queue-dev
git clone https://github.com/cqjjjzr/ramtu_clamp.git
cd ramtu_clamp
cargo build --release
sudo cp ./target/release/ramtu_clamp /opt
sudo chmod +x /opt/ramtu_clamp

同时需要在供网侧 systemd 中添加服务单元文件(/etc/systemd/system/ramtu_clamp.service):

[Unit]
Description=IPv6 NDP-RA MTU Clamp

[Service]
ExecStart=/opt/ramtu_clamp --queue 1 --new-mtu 1280

[Install]
WantedBy=multi-user.target

注意其中的 queuenew-mtu 参数,前者需要与后面防火墙上的队列编号匹配,后者为新的 MTU。保存并启用/启动服务:

systemctl enable ramtu_clamp
systemctl start ramtu_clamp

接下来在供网主机的 nftables 上加上这样一条规则:

(在 nftables 的 bridge filter 表 postrouting 链中)
oifname "gre-ipv6" icmpv6 type nd-router-advert queue num 1

启动后,使用 tcudump 在受网主机的 GRETAP 网卡上监听 IPv6 RA 包,检查所有包都有值为 1280 的 MTU,则证明配置成功。

此时,可以在受网侧另一台主机上重启网络管理器以获取 IPv6 地址,并进行测速,观察到基本跑满线速,用 tcpdump 观察不到碎片化现象,则说明 MTU 配置正确。

总结

最后,整个方案可以总结为:为了让一个本没有 IPv6 接入的网络接入另一地方的非标准 IPv6 网络,使用 WireGuard + GRETAP 打通链路层隧道、直接桥接两边网络,并使用防火墙过滤非 IPv6 流量、自定义 Netfilters Queue 编辑 ICMPv6 Router Advertisement 包通告钳制后的 MTU。

事实上,此方案并没有配置防火墙以保护内网设备。由于整个隧道工作在链路层,防火墙也需要在网桥上过滤包,而 nftables 的网桥上有状态防火墙并不十分成熟,因此笔者也懒得配置了,直接把暴露内网服务的服务器的 IPv6 关了,或者在各主机上单独设置了防火墙——按理说各机器就不应该依赖网络的防火墙,而应该自己配置自己的防火墙(;´ヮ`)7

这篇文章本不应当诞生,而其诞生完全应归咎于部分网络技术人员的错误配置。这也折射出了许多网络工作人员在 IPv6 实现中还有极严重的 IPv4 思维。典型现象包括一机一 IP(事实上一个子网至少应该有 /64。即使使用 RFC 7217 之类的兼容小于 /64 的子网,也绝不应用 /128)、全靠有状态 DHCPv6 管理客户端状态,也不正确利用 DHCPv6-PD 和前缀资源配置层叠划分前缀的机制。这显示他们完全是在按 IPv4 的旧思想做事。

更有甚者,目前的「不在 Router Advertisement 报文上配置前缀(即使使用 DHCPv6 有状态配置 IPv6 地址,也可以配置“禁止用于SLAAC”的前缀,用来正确配置局域网 On-Link 路由)造成局域网流量全部经过网络层交换机/路由器」这样极不专业的行为,使校园网 IPv6 的效率倒退到连 IPv4 都不如。除了为了行为管理(等于审计,等于窃听,因为这是内网流量,不是出口)的需要外,笔者只能将现状归咎于相关人员懒于学习新的技术、惰于培养新的思维

Tips

笔者在调试此方案中用到的一些 tcpdump 命令碎片:

最常用的命令:
tcpdump -v -nn -i gre-ipv6 "icmp6 and ip6[40] == 134" # 查看所有 RA 包
tcpdump -nn -i gre-ipv6 -w test.pcap "ip6" # 保存所有 IPv6 包
tcpdump -nn -e -v -i gre-itxia "(icmp6 and ip6[40] == 134) or (ip6 and udp and (port 546 or port 547))" # 查看所有 RA 包与 DHCPv6 包,最后调试无法获取 IPv6 地址原因时用到

常用参数:
啰嗦/特别啰嗦:-v/-vv
显示 MAC 地址:-e
不把 IP 地址和端口反查成域名/服务名:-nn
写入 PCAP 文件:-w test.pcap

几个 tcpdump 过滤器:
ICMPv6 Router Advertisement 包:"icmp6 and ip6[40] == 134"
DHCPv6 包:"ip6 and udp and (port 546 or port 547)"
IPv6 TCP SYN 包(用来检查 TCP MSS):"ip6 and tcp and (ip6[53]&2!=0)"

不想被自己的惰性打败。