坑边闲话:笔者位于国内某高校的 OpenWrt 路由器 WAN 口能拿到公网 IPv6 地址,但 ISP 就是不发 PD,内网设备的 IPv6 因此长期缺席。三年前笔者曾试过 NDP Relay, 可惜没用;最近升级到 OpenWrt 25.12 后同样的配置突然就通了。顺藤摸瓜查了 odhcpd 的提交历史,发现一个四行的 patch 在背后默默解决了这个问题。

1. 问题背景·

1.1 ISP 不下发 PD 时的困境·

IPv6 的设计初衷是消灭 NAT,让每台设备都持有全球可路由地址。在实践中,家庭和宿舍网络通常通过 DHCPv6 前缀委托(Prefix Delegation,PD)拿到一段前缀(通常是 /56/48),路由器再把这段前缀分割成多个 /64 下发给内网。

但部分 ISP(尤其是高校校园网)只为路由器的 WAN 口分配一个 /64 地址(通过 SLAAC),而不提供 PD。由于 /64 是 SLAAC 的最小单元,路由器无法从中再「切割」出子前缀分配给 LAN. 内网设备因此只能拿到 ULA(Unique Local Address,类似 IPv4 私有地址)或干脆没有 IPv6。

1.2 NDP Relay 的思路·

在这一约束下,一种可行的做法是让路由器充当 NDP 中继(NDP Relay):路由器本身是上游 /64 内的一个节点,同时代理内网设备在这同一个 /64 内「占用」地址。上游路由器在 NDP 层面看到的就好像这些内网设备都直接接在校园网链路上。

OpenWrt 的 odhcpd 内置了这一功能,通过 UCI 配置即可启用。然而在 OpenWrt 25.12 之前,这套配置在许多环境下静默失效:内网设备拿不到全局 IPv6,或偶尔能用随后又断。根源在于 odhcpd 的一个逻辑缺陷,直到 2025 年 10 月的 commit f0d8553 才被修复。

要理解这个 bug,需要先从 NDP 协议本身讲起。

2. IPv6 邻居发现协议 NDP·

2.1 NS 与 NA:地址解析的基本单元·

IPv4 使用 ARP 解决「IP 地址对应哪个 MAC 地址」的问题。IPv6 废弃了 ARP, 以 NDP(Neighbor Discovery Protocol,RFC 4861)取而代之。NDP 的核心报文有两种:

  • Neighbor Solicitation(NS,ICMPv6 type 135):「谁持有地址 X?请告诉我你的 MAC.」
  • Neighbor Advertisement(NA,ICMPv6 type 136):「我持有地址 X,我的 MAC 是 Y.」

下图展示一次完整的邻居解析过程:

sequenceDiagram
    participant S as Sender
    participant T as Target (addr: 2400::abc)

    S->>T: NS (ICMPv6 type 135)<br/>src: 2400::111, dst: ff02::1:ff00:abc<br/>Target: 2400::abc
    T->>S: NA (ICMPv6 type 136)<br/>src: 2400::abc, dst: 2400::111<br/>Target: 2400::abc, Link-layer addr: aa:bb:cc:dd:ee:ff
    Note over S: Neighbor cache updated:<br/>2400::abc -> aa:bb:cc:dd:ee:ff

2.2 请求节点组播地址·

NS 并非广播,而是发往请求节点组播地址(solicited-node multicast address):ff02::1:ff 加上目标地址的低 24 位。例如,查询 2400::a1b2:c3d4 时,NS 发往 ff02::1:ffb2:c3d4。只有低 24 位匹配的节点才会加入该组播组并处理 NS,大幅减少了广播开销。

这一细节在后面理解 NDP Relay 的代理行为时会用到。

3. NDP Relay: odhcpd 的中间人机制·

3.1 跨链路的邻居代理·

正常的 NDP 只在同一链路(link)内有效。路由器的 WAN 口和 LAN 口是两条不同的链路,上游路由器发出的 NS 到了 WAN 口就停下来了,内网设备根本收不到。

NDP Relay 的核心思路是:让 odhcpd 站在 WAN 和 LAN 之间,把上游的 NS 转发给 LAN,再把 LAN 的 NA 转回给上游,同时在内核中创建代理邻居表项(proxy neighbor entry),使内核知道「某个 /64 地址在 LAN 侧」。

网络拓扑大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
 Campus Network (/64: 2400:dd01:103a:4008::/64)
Upstream Router: fe80::1
|
| eth1 (WAN)
| 2400:dd01:103a:4008::router/64 <-- router's own WAN addr
[ OpenWrt / odhcpd ]
| br-lan
+-----+-----+
| |
PC1 PC2
2400:dd01:103a:4008::pc1/64 (SLAAC, same /64 as WAN)
2400:dd01:103a:4008::pc2/64

3.2 正常工作流程·

当 NDP Relay 正常工作时,流程如下:

sequenceDiagram
    participant UR as Upstream Router
    participant OW as OpenWrt (odhcpd)
    participant LD as LAN Device (2400::pc1)

    UR->>OW: NS: who has 2400::pc1?
    Note over OW: relay mode: forward NS to LAN
    OW->>LD: NS (relayed): who has 2400::pc1?
    LD->>OW: NA: I have it, MAC: xx:xx:xx
    Note over OW: add proxy neighbor entry in kernel:<br/>2400::pc1 reachable via br-lan
    OW->>UR: NA (relayed): 2400::pc1 is here
    Note over UR: Neighbor cache: 2400::pc1 -> OpenWrt WAN MAC
    UR->>OW: Traffic to 2400::pc1
    OW->>LD: forwarded

整个过程的关键是上游路由器主动发来了 NS,odhcpd 才有机会转发并建立代理表项。

3.3 回程流量的真实路径·

proxy neighbor entry 建立后,回程流量的投递过程值得细看,其中有一个细节容易被忽略:内核回 NA 时用的是 OpenWrt 自己的 WAN MAC,而不是 PC1 的 MAC. ISP 路由器从始至终不知道 PC1 的真实 MAC: 在它眼里,2400::pc1 就是 OpenWrt 这台机器持有的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Internet           ISP Router             OpenWrt                  PC1
| | | |
| | outbound: fine | |
|<-- src:2400::pc1 --|<--- forwarded -----|<-------- out ---------|
| | | |
| [return traffic] | | |
|--> dst:2400::pc1 ->| | |
| | cache miss | |
| |-NS: who has 2400::pc1? ------------------>|
| | | (kernel proxy: hit) |
| |<-NA: OpenWrt WAN MAC (NOT PC1's MAC) ------|
| | | |
| |--> to OpenWrt WAN MAC |
| | [L2 delivery done] |
| | | L3 route lookup: |
| | | 2400::pc1 via br-lan |
| | |-----> L2 to PC1 ----->|

常见误区

很多人看到 NA 中有 MAC 地址,就以为 ISP 路由器拿到了「PC1 的 MAC」, 之后经二层转发直达 PC1. 实际上不是这样的。

proxy 的设计就是让 OpenWrt 替 PC1 接收链路层帧,NA 中填的是 OpenWrt 自己的 WAN MAC, ISP 路由器对 PC1 的存在一无所知。ISP 路由器把帧发给 OpenWrt 之后,由 OpenWrt 做三层路由:查路由表找到 2400::pc1 对应 br-lan 接口,再在 LAN 侧单独完成二层投递(使用 PC1 的真实 MAC)。

二层边界在 OpenWrt 处发生了一次切割:ISP 路由器负责 ISP 链路的二层投递,OpenWrt 负责 LAN 侧的二层投递,两段二层之间是 OpenWrt 的三层路由。

4. 旧版 odhcpd 的静默死锁·

4.1 死锁的结构性原因·

问题就出在这里。上游路由器发出 NS 的前提是:它已经知道某个地址需要解析(例如,收到了以该地址为目的地的数据包,需要先查 MAC)。

但对于刚通过 SLAAC 配置好地址的内网设备来说:

  • 上游路由器从未见过这个地址,从不发 NS
  • odhcpd 没收到 NS,就不会转发,也不会建代理表项
  • 没有代理表项,上游就不知道这个地址在 OpenWrt 后面
  • 上游永远不发 NS

这是一个经典的引导死锁(bootstrap deadlock):双方都在等对方先动,谁也不会先动。

sequenceDiagram
    participant UR as Upstream Router
    participant OW as OpenWrt (odhcpd, old)
    participant LD as LAN Device

    LD->>LD: SLAAC: configured 2400::pc1 (new address)

    Note over UR: No entry for 2400::pc1 in neighbor cache.
    Note over UR: No traffic destined to 2400::pc1.
    Note over UR: Therefore: no NS sent.

    Note over OW: No NS received from upstream.
    Note over OW: No proxy entry built.

    Note over UR, LD: Deadlock. Traffic to 2400::pc1 is silently dropped.

4.2 ping6 为何能临时有效·

许多用户发现,在内网设备上执行 ping6 <上游网关> 之后,IPv6 连通性会短暂恢复。这个现象看似奇怪,实际上有清晰的解释。

当内网设备发出 ping6 时,需要先通过 NDP 解析上游网关的 MAC 地址。这会产生一个 NS 报文,其源地址就是内网设备的全局 IPv6 地址2400::pc1). odhcpd 将这个 NS 从 LAN 中继到 WAN,上游路由器收到后:

  1. 解析了 NS 的内容,知道谁在问自己
  2. 更重要的是,它看到了源地址 2400::pc1,一个来自本链路的数据包

上游路由器随即将 2400::pc1 写入自己的邻居缓存(状态为 REACHABLE),此后发往 2400::pc1 的流量就能正常路由过来,不再需要 odhcpd 的代理表项作为前提。

然而邻居缓存条目是有生命周期的。REACHABLE 状态默认约 30 秒后转为 STALE, 若持续无流量则经 DELAYPROBE 阶段,最终变为 FAILED. 一旦条目失效,连通性再次中断,直到下一次手动 ping6.

这个现象本质上是在用手工方式替代 odhcpd 本应自动完成的工作:向上游路由器「通报」内网设备地址的存在。

5. commit f0d8553 解析·

5.1 问题的具体位置·

内网路由器 odhcpd 在收到 NS 后,由 handle_solicit() 函数处理。这个函数需要判断:收到的 NS 是来自上游(需要转发给 LAN),还是来自 LAN(需要转发给上游),还是自己发出去又回环回来的(需要忽略)。

判断「是否是自己发的」的方法很直接:比较 NS 报文的源 MAC 地址与本机 MAC 地址是否一致。旧代码如下:

1
2
3
/* src/ndp.c, before f0d8553 */
if (!memcmp(ll->sll_addr, mac, sizeof(mac)))
return; /* self-sent, ignore */

逻辑无懈可击:自己发的包,当然忽略。

odhcpd 存在一条内部探测机制:当它需要确认某个 /64 地址是否在 LAN 侧时,会从 master 接口(WAN)主动发出 NS. 这个 NS 通过 AF_PACKET socket 又被 odhcpd 自己接收到,触发了上面那行 return,探测就此终止,从未传递给 LAN。

5.2 修复的逻辑·

commit f0d8553 的改动极为精简,4 行插入、2 行删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
--- a/src/ndp.c
+++ b/src/ndp.c
@@ -355,8 +355,9 @@ static void handle_solicit(void *addr, void *data, size_t len,
uint8_t mac[6];
odhcpd_get_mac(iface, mac);

- if (!memcmp(ll->sll_addr, mac, sizeof(mac)))
- return;
+ bool is_self_sent = !memcmp(ll->sll_addr, mac, sizeof(mac));
+ if (is_self_sent && !iface->master)
+ return;

@@ -368,7 +369,7 @@ static void handle_solicit(void *addr, void *data, size_t len,
/* Don't forward DAD NS or self-addressed NS */
- if (IN6_IS_ADDR_UNSPECIFIED(&ip6->ip6_src))
+ if (IN6_IS_ADDR_UNSPECIFIED(&ip6->ip6_src) || is_self_sent)
goto out;

修改分两处:

第一处:引入 is_self_sent 变量,将原先「自己发的就直接返回」改为「自己发的且不是 master 接口才返回」。这一条件使得从 WAN(master 接口)发出并回环的 NS 得以继续向下执行,进而触发对 LAN 侧设备的探测。

第二处:在生成 NA 响应之前追加 || is_self_sent 条件。这是必要的防护:如果 odhcpd 对自己发出的 NS 作出 NA 响应,会在邻居缓存中写入一条虚假表项(将自己的地址指向自己),产生错误的路由行为。

两处修改合在一起,使 handle_solicit() 在 self-sent + master 这条路径上的行为发生了质变,如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
odhcpd                    WAN (eth1)                  LAN (br-lan)
| | |
|-- NS (target:2400::pc1) ->| (sent by odhcpd itself) |
| | |
|<-- same NS loops back ----| (AF_PACKET receives it) |
| | |
| handle_solicit() called: | |
| is_self_sent? YES | |
| iface->master? YES | |
| | |
| [OLD] return immediately --X LAN never probed |
| | |
| [NEW] continue... | |
|-- NS (target:2400::pc1) --|----------> |
| | LAN device replies |
|<---------------------- NA (I have it, MAC:xx:xx:xx) ---|
| | |
| create proxy entry: | |
| ip neigh add proxy | |
| 2400::pc1 dev eth1 | |

5.3 修复后的工作流·

修复后,odhcpd 能够主动打破死锁:

sequenceDiagram
    participant UR as Upstream Router
    participant OW as OpenWrt (odhcpd, new)
    participant LD as LAN Device (2400::pc1)

    LD->>LD: SLAAC: configured 2400::pc1

    Note over OW: Probe: send NS on WAN for 2400::pc1
    OW->>OW: NS loops back (self-sent, master iface)
    Note over OW: is_self_sent=true, iface->master=true<br/>Continue processing (do NOT return)

    OW->>LD: NS on LAN: who has 2400::pc1?
    LD->>OW: NA: I have it
    Note over OW: Proxy neighbor entry created:<br/>2400::pc1 reachable via br-lan

    UR->>OW: NS: who has 2400::pc1?
    OW->>UR: NA: 2400::pc1 is here (proxy)
    Note over UR: Neighbor cache established. Traffic flows.

odhcpd 不再被动等待上游发来 NS,而是主动探测、主动建立代理表项,上游路由器一旦有流量就能立刻得到响应。

6. OpenWrt 25.12+ 配置方法·

commit f0d8553 于 2025 年 10 月合入,OpenWrt 25.12 是首个包含该修复的正式版本。在此之前的版本(包括 22.03、23.05、24.10)均不包含该修复,NDP Relay 在没有上游主动发 NS 的环境下无法自主工作。

在 OpenWrt 25.12+ 上启用 NDP Relay 的配置如下。如果路由器存在 wan6 对应的 DHCP 配置(通常有 ignore='1' 需先删除),或者需要新建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 如果已有 wan6 条目且带 ignore,先删掉 ignore:
uci delete dhcp.wan6.ignore

# 若 wan6 条目不存在,则新建:
uci set dhcp.wan6=dhcp
uci set dhcp.wan6.interface='wan6'

# 将 wan6 设为 NDP relay 的 master(上游侧)
uci set dhcp.wan6.ndp='relay'
uci set dhcp.wan6.ra='relay'
uci set dhcp.wan6.master='1'

# 将 LAN 设为 NDP relay 的 slave(下游侧)
uci set dhcp.lan.ndp='relay'
uci set dhcp.lan.ra='relay'

uci commit dhcp
/etc/init.d/odhcpd restart

配置生效后,LAN 设备会通过中继的 RA(Router Advertisement)得到 WAN 侧的 /64 前缀,进而 SLAAC 生成全局 IPv6 地址。可以用以下命令确认:

1
2
3
# 在 LAN 设备上
ip -6 addr show | grep "scope global"
# 应能看到与 WAN 同前缀的 2400::xxxx/64 地址

几点注意:

  • 此配置要求 ISP 给路由器分配了全局 /64(SLAAC),WAN 口有全局 IPv6 地址。如果 WAN 口只有 ULA 或 link-local,则无效。
  • LAN 设备获得的是与路由器 WAN 口同一个 /64 内的地址,从上游视角看它们「直接在校园网链路上」,无 NAT。OpenWrt 的 fw4 防火墙默认对 WAN 区域的 forward 执行 REJECT,可阻止外部主动连入 LAN 设备。
  • 升级到 OpenWrt 25.12 之前,此配置无论写得多正确,都会在没有上游主动 NS 的网络中静默失效,原因正是本文描述的引导死锁。这不是配置错误,是 odhcpd 的历史 bug.