IPv6 NDP Relay 原理与 odhcpd 修复解析
坑边闲话:笔者位于国内某高校的 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 | Campus Network (/64: 2400:dd01:103a:4008::/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 | Internet ISP Router OpenWrt PC1 |
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,上游路由器收到后:
- 解析了 NS 的内容,知道谁在问自己
- 更重要的是,它看到了源地址
2400::pc1,一个来自本链路的数据包
上游路由器随即将 2400::pc1 写入自己的邻居缓存(状态为 REACHABLE),此后发往 2400::pc1 的流量就能正常路由过来,不再需要 odhcpd 的代理表项作为前提。
然而邻居缓存条目是有生命周期的。REACHABLE 状态默认约 30 秒后转为 STALE, 若持续无流量则经 DELAY、PROBE 阶段,最终变为 FAILED. 一旦条目失效,连通性再次中断,直到下一次手动 ping6.
这个现象本质上是在用手工方式替代 odhcpd 本应自动完成的工作:向上游路由器「通报」内网设备地址的存在。
5. commit f0d8553 解析·
5.1 问题的具体位置·
内网路由器 odhcpd 在收到 NS 后,由 handle_solicit() 函数处理。这个函数需要判断:收到的 NS 是来自上游(需要转发给 LAN),还是来自 LAN(需要转发给上游),还是自己发出去又回环回来的(需要忽略)。
判断「是否是自己发的」的方法很直接:比较 NS 报文的源 MAC 地址与本机 MAC 地址是否一致。旧代码如下:
1 | /* src/ndp.c, before f0d8553 */ |
逻辑无懈可击:自己发的包,当然忽略。
但 odhcpd 存在一条内部探测机制:当它需要确认某个 /64 地址是否在 LAN 侧时,会从 master 接口(WAN)主动发出 NS. 这个 NS 通过 AF_PACKET socket 又被 odhcpd 自己接收到,触发了上面那行 return,探测就此终止,从未传递给 LAN。
5.2 修复的逻辑·
commit f0d8553 的改动极为精简,4 行插入、2 行删除:
1 | --- a/src/ndp.c |
修改分两处:
第一处:引入 is_self_sent 变量,将原先「自己发的就直接返回」改为「自己发的且不是 master 接口才返回」。这一条件使得从 WAN(master 接口)发出并回环的 NS 得以继续向下执行,进而触发对 LAN 侧设备的探测。
第二处:在生成 NA 响应之前追加 || is_self_sent 条件。这是必要的防护:如果 odhcpd 对自己发出的 NS 作出 NA 响应,会在邻居缓存中写入一条虚假表项(将自己的地址指向自己),产生错误的路由行为。
两处修改合在一起,使 handle_solicit() 在 self-sent + master 这条路径上的行为发生了质变,如下图所示:
1 | odhcpd WAN (eth1) LAN (br-lan) |
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 | # 如果已有 wan6 条目且带 ignore,先删掉 ignore: |
配置生效后,LAN 设备会通过中继的 RA(Router Advertisement)得到 WAN 侧的 /64 前缀,进而 SLAAC 生成全局 IPv6 地址。可以用以下命令确认:
1 | # 在 LAN 设备上 |
几点注意:
- 此配置要求 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.










