当你为站点到站点隧道添加 IPv6 前缀时,数据包并不会按示意图那样行进。本地网关取走整个 IPv6 数据包,将其加密,并在前面放一个外部 IP 头,其地址族由端点地址决定,而不是由内部内容决定。远端网关剥掉这层外部头,把原始数据包转发到自己的 LAN;在那里它又成了普通的 IPv6 数据包。外部隧道与内部流量是两场彼此独立的对话,它们只共享一个密钥、一对端点,别的什么都没有。

两个头部,两个地址族

在 IPsec 隧道模式下,内部 IPv6 数据包成为 ESP 头后面的载荷。承载该 ESP 包的外部头可以是 IPv4 或 IPv6,它遵循 local_addrs 和 remote_addrs。WireGuard 用更少的仪式做同样的事:内部数据包是指向对端端点的 UDP 数据报载荷。两者都不改动内部头,因此远端在解封装后仍能路由它。

站点之间的路径只能看到 UDP 4500(当 IPsec 使用 NAT 穿越时),或默认 WireGuard 监听器的 UDP 51820。途中任何路由器都可以只支持 IPv4,而 IPv6 流量仍然穿过,因为它在这条路径上从不以 IPv6 数据包出现。这给出了清晰的第一层划分:tcpdump -ni eth0 udp port 51820 显示加密路径,tcpdump -ni wg0 ip6 显示内部流量。外部抓包繁忙而内部抓包安静,意味着内核没有把 IPv6 放进隧道;那是路由或策略问题,不是链路状态。

网关在转发时还会递减内部 Hop Limit,因此端点在 IPv6 路径上被算作路由器。外部跳数与内部 Hop Limit 独立变化,而在远端网关死掉的包死在转发环节,不是加密环节。

选择器与 AllowedIPs 就是许可清单

IKEv2 协商 child SA 可以承载哪些内部前缀。在 strongSwan 5.9 及更新版本中,在 swanctl.conf 里写出来是这样:

children {
  net6 {
    local_ts  = 2001:db8:aa::/48
    remote_ts = 2001:db8:bb::/48
    mode = tunnel
    esp_proposals = aes256gcm16-prfsha384-ecp384
    start_action = trap
  }
}

省略 local_ts 和 remote_ts 时,strongSwan 会从它选中的接口子网推导它们。在双栈主机上,这条捷径常常只得到纯 IPv4 的一对:SA 起来了,而 IPv6 在到达 ESP 代码之前就被拒绝。把两个前缀都写出来,SA 才有资格承载它们,start_action = trap 会在第一个匹配的包上把它拉起。

WireGuard 只有一个列表却干两件事。在 peer 块中,AllowedIPs = 2001:db8:bb::/48, fd00:bb::/48 既是发送时安装的路由,也是接收时应用的过滤器:某个已解密数据包的内部源落在该 peer 列表之外,就会被静默丢弃。wg-quick 会为每个条目添加一条路由,因此下面的 peer 块不需要单独的 ip -6 route 命令。

[Interface]
Address = 2001:db8:aa:ff::1/64, fd00:aa:ff::1/64
ListenPort = 51820

[Peer]
PublicKey = <site-b-public-key>
Endpoint = 203.0.113.20:51820
AllowedIPs = 2001:db8:bb::/48, fd00:bb::/48

两份列表必须互为镜像:站点 B 接受站点 A 的前缀作为远端选择器并把它路由回去,只有两个方向一致时隧道才工作。非对称列表会给出最耗时的症状——握手正常、计数只在一侧增长,以及 从 A 到 B 的 ping 成功,而 B 到 A 却什么都到不了。

转发、路由与返回前缀

Linux 不会主动转发 IPv6,这个设置按命名空间而不是按接口生效:

sysctl -w net.ipv6.conf.all.forwarding=1
ip -6 route add 2001:db8:bb::/48 dev wg0
ip -6 route get 2001:db8:bb::10 from 2001:db8:aa::10

第二行就是人们在基于路由的设计里容易忘记的那行。基于策略的 IPsec 会安装匹配这些前缀的 XFRM 策略,但基于路由的配置配合 vti6 或 WireGuard 时,需要一条指向该设备的真实 IPv6 路由。把 sysctl 持久化到 /etc/sysctl.d/ 下;否则重启会静默把转发恢复为关闭。

转发有一个值得知道的副作用:内核随后会忽略路由器通告,除非 net.ipv6.conf.<wan>.accept_ra 等于 2,因此 WAN 地址靠 SLAAC 获得的网关,在你开启转发的那一刻就可能丢掉默认路由。在同一个文件里为该接口设置 accept_ra = 2,路由就能保住。站点 B 的核心也必须从隧道网关学到 2001:db8:aa::/48,可以走静态路由,也可以在隧道上跑路由协议,否则回包会死在 B 自己的网络里。

四步检查,定位断点

按这个顺序做,并在第一个失败的步骤停下,因为后面的每一步都依赖前面的结果。

  1. 选择器:swanctl --list-sas 应该显示 child SA 上的 IPv6 前缀对,或 wg show wg0 allowed-ips 显示远端前缀以及新的 latest-handshakes。
  2. 内核路由:ip -6 route get 2001:db8:bb::10 必须用隧道路由设备回答,而不是 WAN 接口。
  3. 内部抓包:在真实 LAN 主机之间 ping 时运行 tcpdump -ni wg0 ip6。有出无回的包指向 B 的路由或防火墙;完全没有包则指回第 1 或第 2 步。
  4. 目标:在 B 主机上,ss -6 -ltn 确认服务在 IPv6 上监听,本地防火墙计数器显示包是被接受还是被丢弃。

MTU 属于同一轮检查。IPv6 路由器不分片,所以过大的包会被丢弃,Packet Too Big 会回到发送方。如果握手和小 ping 正常,但文件传输卡住,就缩小载荷直到通过:ping -6 -s 1400 -c 3 2001:db8:bb::10。WireGuard 接口默认 MTU 为 1420 字节,IPv4 上的 IPsec 还要再减,所以设置内部 MTU 比指望穿过丢弃 ICMPv6 的防火墙做路径 MTU 发现更可靠。

看起来像防火墙问题的故障模式

最常见的一种根本不是防火墙:服务绑定到 0.0.0.0 而不是 ::,AAAA 记录存在,客户端通过 IPv6 得到快速拒绝,双栈浏览器则回退到 IPv4 把它藏了起来。如果只有部分应用失败,先检查监听地址族,再动规则集。

第二种是 ICMPv6 过滤。Neighbor Discovery 和 Packet Too Big 属于正常运行的一部分,而不是诊断手段;丢弃全部 ICMPv6 的策略会造出一条能连接、能认证、然后在任何大于小包的东西上卡住的隧道。放行路径依赖的类型,并保留计数器,让丢弃可见。

第三种是前缀被加进了隧道,却从未加进拥有它的站点的地址规划,于是没有路由器通告它,也没有主机能用它回应。从一个前缀和一对主机开始,用四步验证双向,然后再加其余部分;坏掉的前缀只会是配置里的一行,而不是一次重设计。唯一值得常开的官方参考是 RFC 8200,它定义了隧道所承载的头部。

相关文章