odhcpd 中继模式原理、局限以及解决方案

近日我学校终于开始试运行 IPv6 了,而我在配置路由器的过程中发现了一些由于上游配置不规范导致的各种问题(如下游客户端可获取 v6 地址但无法 ping 通外网等),于是就有了本文,希望能够普及相关知识以及帮助遇到类似问题的人。
本文主要针对常见的高校 IPv6 配置,即连入校园网、认证(可选)后路由器仅能获得一个 /64 地址,无 PD 获取。

无状态地址自动配置 (SLAAC)

目前 IPv6 的动态地址分配方式可以分成有状态(stateful)和无状态(stateless)两种,而高校中比较常见的也即其中的无状态地址自动配置(SLAAC)。关于该协议的细节已经有很多文章介绍,且不是本文主题,故仅对其作简单介绍。
SLAAC 协议主要过程如下:

  1. 在客户端会向多播地址 ff02::2 广播 RS(Router Solicitation)信息;
  2. 路由节点在收到 RS 后即会单播回复 RA(Router Advertisement)来告知客户端路由前缀(如 2001:da8:abc:def::/64 );
  3. 客户端收到 RA 获取其所在子网的前缀,并配合 DAD 协议自动生成该前缀下唯一的全局路由地址。

多数高校中配置的 IPv6 环境会通过上述过程让客户端获取一个 /64 的地址(如 2001:da8:abc:def:aa:bb:cc:dd/64 );部分会在其中第 2 步之前对客户端的 MAC 地址进行鉴权,仅对已通过认证的 MAC 地址做 RA 响应。

邻居发现协议(NDP)

NDP 是 IPv6 中类似 ARP 的协议,用来做网络层和链路层的节点发现以及地址翻译。其主要过程与上述 SLAAC 类似,客户端会将目的地的信息放在 NS(Neighbor Solicitation)消息中进行广播或单播,而被查询的地址如果在线则会发送 NA(Neighbor Advertisement)消息告知客户端其链路地址。

odhcpd 的中继模式(relay)及其工作条件

针对单设备仅能获取 /64 非 PD 地址的网络环境,openwrt 默认包含的 odhcpd 提供了中继模式来让内网的客户端能够正确获取 IPv6 地址以及与外界通信。以下内容不包含对 DHCPv6 协议的中继。

中继行为

中继模式中每个 interface 有 master/slave 之分。
对于 RA 中继,master/slave 其会影响 RS/RA 消息的转发方向:

  1. 从 slave interface 收到 RS 消息,odhcpd 会修改其源 MAC 地址为 master interface 再将其从 master interface 转发出去;
  2. 从 master interface 收到的 RA 消息,odhcpd 会修改其中源 MAC 地址为 slave interface 再将其从 slave interface 转发出去;
  3. 从 slave 收到的 RA 以及从 master 收到的 RS 消息会被忽略。

对于 NDP 中继则不区分 master/slave 身份,以下仅以 M,S 两个 interface 做举例:

  1. 从 M 收到 NS 消息,odhcpd 会在 S 发送 icmp-echo 消息来让内核在 S interface 触发目的地址的 NDP 过程,若成功在 S 所在的链路发现了目的地址,则在 M 回复相应的 NA 消息,反之亦然;
  2. 在上述步骤中成功在 M/S 链路被发现的节点地址会被加入路由表,以方便后续通信的路由策略。

更新路由表项的任务交给 NDP 中继的原因是 SLAAC 为无状态地址配置,因此路由器并不能知道所有的节点地址信息,自然也无法进行正确的路由。
从上述中继模式的行为可以知道,对于绝大多数情况,WAN/LAN 口即分别为 master/slave 接口。

工作条件

仔细分析中继模式的行为我们可以发现,对于 LAN 侧的客户端来说,几乎都可以通过中继模式的 RA 通过 SLAAC 协议获取 IPv6 地址,然而要想通过这个地址与外网正常通信,则需要让 NDP 中继建立起正确的指向 LAN 接口的路由表项。下面以 LAN 侧客户端 A 主动 ping Google 为例:

  1. LAN 侧客户端 A (2001:da8:abc:def::A/64)发起了对全局路由地址(如 ipv6.google.com)的 echo-request 请求,该 IPv6 分组会被正常路由到 Google 的服务器;
  2. 服务器 echo-reply 的分组到达 WAN 口的上游,此时上游会在各个节点端口广播目标是 A 地址的 NS 消息,当路由器的 WAN 收到该 NS 时,会按照前文中 NDP 中继的行为进行中继;
  3. odhcpd 成功让内核在 LAN 侧发现了 A,从而在 WAN 侧回复了 NA 请求,并在路由表添加了 A 地址在 LAN 侧的表项;
  4. WAN 口的上游收到了来自 odhcpd 的 NA 消息从而更新了 A 节点的邻居信息,并将来自服务器的 echo-reply 分组发送到 WAN 口;
  5. WAN 口根据步骤 3 中建立的路由表项将发往 A 的分组路由到 LAN 侧并交付。

在经过上述步骤之后,在 WAN 口上游的邻居信息尚未过期之前,由于路由表项的正确建立,A 可以实现与外网的正常通信。由于第一次路由表向尚未建立时需要先进行 NDP 中继,会导致很多人观测到的“中继模式第一次发起请求延迟高”的现象。而如果 WAN 口的上游提前从 A -> Google 的分组中学习了 A 的邻居信息,那么将跳过步骤 2 直接把分组递交给 WAN 口,而此时路由器还没有从 odhcpd 学习到正确的路由表导致该分组超时丢弃,直到 WAN 口上游的邻居信息超时之后才会第一次进行步骤 2 的广播 NS 动作,中继模式才能继续正确工作,导致第一次请求的延时变得更长。
我们可以看到,如果需要让中继模式正常工作打通内外网,上述 5 个步骤必须依次进行且缺一不可。其中步骤 1,3,5 都是在发生在路由器端且可控制的,而步骤 2, 4 却是路由器 WAN 口上游节点的行为,不为用户所控制。

中继模式的局限性以及可能的解决方法

由于如今大多数操作系统都会默认开启 IPv6 隐私扩展,LAN 侧节点的地址会定期改变,而每次新的地址发起连接都会重复上文中继模式工作条件的 5 个步骤,这导致即使中继模式正常工作也会有非预期的延时。此外上游节点的不可控因素也会导致中继模式不正常工作。

案例说明

在中继模式的工作条件中,步骤 2 是比较容易出问题的环节。如上游节点的邻居信息是在路由器接入链路认证后静态绑定,跳过了邻居发现过程就直接把 IPv6 分组递交到了 WAN 口,此时 odhcpd 的 NDP 中继无法学习到正确的路由表也就一直无法让目的地是 LAN 的分组进入 LAN 区域。更有甚者(即我的学校…)在此基础上错误得配置了上游节点的 NDP 行为,使得上游对收到的任何目标是全局路由地址的 NS 信息进行答复,这会直接导致下述行为:

  1. 目的地是 LAN 侧客户端 A 的 IPv6 分组直接到达 WAN 口;
  2. 路由器内核根据现有路由表进行转发,发现该分组属于 WAN 口的 /64 子网,所以在 WAN 口发送 NS 寻找 A 的 MAC 地址;
  3. 错误配置的 WAN 口上游回答了 NA 消息,导致 odhcpd 错误地学习了邻居信息并添加了 A 地址在 WAN 侧的错误路由表项;
  4. 路由器将该 IPv6 分组发回给了 WAN 口上游节点,导致丢包。

至此后续到达 WAN 口的分组会不停重复上述过程,导致 LAN 侧的 A 虽然有 IPv6 地址却无法正常通信。

可能的解决方法

仔细分析上述场景,上游节点直接把目的地址属于 WAN 口的网段(如2001:da8:abc:def::/64)的地址直接交付到了 WAN 口 ,这个行为说明上游节点实际上将整个子网绑定给了路由器 WAN 所在的接口。因此对于路由器来说,所有属于该子网的地址都可以认为是 LAN 侧的地址。以下给出两种该情况的解决方案。

方法1 - 重设路由表

注意,该方法依赖 owipcalc 包来计算子网地址: opkg install owipcalc
我们可以手动在 WAN 口获得 IPv6 地址后添加一条路由表,让整个子网重定向到 LAN 口,这个操作可以通过 OpenWrt 的 hotplug 机制来进行,保存以下脚本放在 /etc/hotplug.d/iface/80-reset-route6 并重启 WAN 接口即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#!/bin/sh

wan_dev="wan6"

[ "$HOTPLUG_TYPE" = "iface" ] || exit 0
[ "$INTERFACE" = "$wan_dev" ] || exit 0

RTMETRIC=127

. /lib/functions/network.sh

network_get_physdev lan_dev lan || exit 0

ifup_cb() {
local _lan_dev="$1"
local _metric="$2"

local wan_subnet
network_get_subnet6 wan_subnet "$wan_dev" || return
_wan_network=$(owipcalc "${wan_subnet}" network)

ip -6 route replace "$_wan_network" dev "$_lan_dev" metric "$_metric"
}

ifdown_cb() {
local _lan_dev="$1"
local _metric="$2"

ip -6 route flush dev "$_lan_dev" metric "$_metric"
}

case "$ACTION" in
ifup)
ifup_cb "$lan_dev" "$RTMETRIC"
;;
ifdown)
ifdown_cb "$lan_dev" "$RTMETRIC"
;;
ifupdate)
ifdown_cb "$lan_dev" "$RTMETRIC"
sleep 1
ifup_cb "$lan_dev" "$RTMETRIC"
;;
*)
;;
esac

exit 0

方法2 - 伪装前缀代理

实际上,上游节点直接绑定端口和上游直接分配了一个 /64 的 PD 效果是一样的。因此我们也可以伪造成获取了一个 PD 前缀来让 netifd 进行后续的配置。该方法的优点是 LAN 接口也可以获得一个全局路由地址,并且可以配合 LAN 侧的 DHCPv6 Server 进行更自由的内网配置。为此我们需要关闭 odhcpd 的中继模式, 编辑 /etc/config/dhcp :

1
2
3
4
5
6
7
8
9
10
#...

config dhcp lan
option ra 'server'
option dhcpv6 'server'
option ndp 'disabled'

config dhcp wan6
option ignore '1'
#...

保存后重启 odhcpd, 然后编辑 /etc/odhcp6c.user 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#!/bin/sh

log() {
logger -t "odhcp6c[fake-ipv6pd]" "$@"
}

reset_envs() {
local entry

local raroutes=""
local userprefix=""
for entry in $RA_ROUTES; do
local route="$entry"
local addr="${entry%%/*}"
entry="${entry#*/}"
local mask="${entry%%,*}"
entry="${entry#*,}"
local gw="${entry%%,*}"
entry="${entry#*,}"
local valid="${entry%%,*}"
entry="${entry#*,}"
local metric="${entry%%,*}"

if [ "$addr" != "::" ]; then
local prefix="$addr/$mask"
log "found ipv6 prefix $prefix"
userprefix="$userprefix $prefix"
continue
fi
log "preserve ra route $route"
raroutes="$raroutes $route"
done

RA_ROUTES="$raroutes"
USERPREFIX="$userprefix"
}

fake_ipv6pd() {
local device="$1"
local action="$2"

[ "$action" != "ra-updated" ] && return
[ -n "$PREFIXES$USERPREFIX" ] && return
[ -z "$ADDRESSES$RA_ADDRESSES" ] && return

reset_envs

[ -n "$ADDRESSES$RA_ADDRESSES$PREFIXES$USERPREFIX" ] && setup_interface "$device"
}

fake_ipv6pd "$@"

保存后运行 /etc/init.d/network restart 让路由器重新配置网络即可。

结论与推广

本文仅讨论了中继模式不正常工作的情况之一并给出了可能的解决方案,希望本文抛砖引玉,能够给遇到类似问题的人提供解决方法或者排查思路。如果有任何问题欢迎在评论区讨论或联系我的邮箱^_^