坑边闲话:笔者位于国内某高校,OpenWrt 路由器的 WAN 口能拿到公网 IPv6 地址,但校园网只发 /64、不给 PD,内网设备的 IPv6 因此长期缺席。多年前笔者就试过用 NDP Relay 补救,可惜当时不稳定、用一阵就断,身边也有好几个人遇到同样的问题。最近升级到 OpenWrt 25.12,同样的配置却突然稳定可用了。顺着 odhcpd 的提交历史,笔者找到一个只有四行的 patch(commit f0d8553),它修掉的逻辑死锁,看起来正是当年的元凶。

然而当笔者试图坐实这条因果时,故事变得复杂了:笔者专门搭了对照中继,从 23.05.5 一直退到当年那个版本时代的 22.03.0(都不含该 patch),结果它们在今天的校园网里照样跑得稳稳当当,连把代理表项全删光都能在一两秒内反应式自愈。版本这条线既已基本排除,最干净的那个对照实验其实已经做不成了:真正变了、又无从还原的,是当年校园网上游的行为。这篇文章因此有两层:一是讲清 NDP Relay 的原理与 f0d8553 修掉的那个真实 bug;二是诚实记录这场调查,并在结尾说明,今天的「能用」未必是这个 commit 的功劳,更可能是环境本身早已改变(园区路由器升级、配置优化等)。

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 才被修复。不过这个缺陷是否就是某个具体环境失效的根因,并不总能下定论,第 7 章会专门回到这个问题。

要理解这个 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 才有机会转发并建立代理表项。然而这是一条「被动等待」的路径:它的前提是上游路由器已经有理由发 NS(即已知道该地址存在)。全新 SLAAC 设备上线时,这一前提并不成立。第 4 章将专门讨论这个引导问题。

3.3 出向流量·

上面展示的是「上游路由器来问,odhcpd 中继回答」的流程。实际使用中,更常见的起点是内网设备主动发起对外的 IPv6 连接。

出向流程本身并不复杂:PC1 需要先解析默认网关的 MAC,然后把数据包交给 OpenWrt,由 OpenWrt 从 WAN 口转发给 ISP 路由器。

sequenceDiagram
    participant LD as LAN Device (PC1, 2400::pc1)
    participant OW as OpenWrt
    participant UR as ISP Router
    participant INT as Internet Server

    LD->>OW: NS on br-lan: who has fe80::1 (gateway)?
    OW->>LD: NA: fe80::1 is here, MAC = OpenWrt LAN MAC

    LD->>OW: IPv6 pkt: src=2400::pc1, dst=server (to OpenWrt LAN MAC)
    Note over OW: L3 routing: forward via eth1
    OW->>UR: IPv6 pkt: src=2400::pc1 (Eth src: OpenWrt WAN MAC)
    UR->>INT: forwarded upstream

出向路径本身畅通。值得注意的是,ISP 路由器在收到这个包时,同时看到了以太帧的源 MAC(OpenWrt WAN MAC)以及 IPv6 的源地址(2400::pc1)。是否利用这一信息更新邻居缓存,取决于 ISP 路由器的具体实现,RFC 4861 对此没有强制规定。这一点将在下一节中进一步说明。

3.4 回程流量·

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

sequenceDiagram
    participant INT as Internet Server
    participant UR as ISP Router
    participant OW as OpenWrt
    participant LD as PC1 (2400::pc1)

    LD->>OW: outbound pkt (src: 2400::pc1)
    OW->>UR: forwarded (Eth src: OpenWrt WAN MAC)
    UR->>INT: upstream

    INT->>UR: return pkt (dst: 2400::pc1)
    Note over UR: neighbor cache miss for 2400::pc1
    UR->>OW: NS: who has 2400::pc1?
    Note over OW: kernel NDP proxy: entry found
    OW->>UR: NA: 2400::pc1 is here, MAC = OpenWrt WAN MAC
    Note over UR: NOT PC1's real MAC.<br/>ISP router only knows OpenWrt.
    UR->>OW: frame to OpenWrt WAN MAC (L2 delivery done)
    Note over OW: L3 route lookup: 2400::pc1 via br-lan
    OW->>LD: forward to PC1 (L2 on br-lan)

常见误区

很多人看到 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 问题的结构性原因·

proxy entry 缺失导致的连通性问题,有时是立即失效,有时是延迟失效,取决于 ISP 路由器的实现。

如果 ISP 路由器在收到出向数据包时,从其源地址中主动学习了邻居条目(某些实现会这样做),那么回程初期的 NDP 查询可能命中这条条目,连通性短暂成立。但该条目未经 NS/NA 机制确认,处于 STALE 状态。NDP 规范要求路由器通过 NUD(Neighbor Unreachability Detection)周期性地重新确认邻居可达性:条目从 STALE 经过 DELAY 阶段后,会进入 PROBE 阶段,向 2400::pc1 发出 NS。

这一步是问题的根本所在:NUD 探测是无法绕过的。只要 proxy entry 未建立,OpenWrt 对这个 NS 就无从响应,条目进入 FAILED, 回程流量中断。

如果 ISP 路由器不做上述学习,则初始回程就直接触发 NS, 立即失败。

两种情形的共同结果是:只要 proxy entry 缺失,NUD 探测必然在某个时刻失败,连通性无法长期维持

sequenceDiagram
    participant UR as ISP Router
    participant OW as OpenWrt (no proxy entry)
    participant LD as LAN Device (2400::pc1)

    LD->>OW: outbound traffic (src: 2400::pc1)
    OW->>UR: forwarded (Eth src: OpenWrt WAN MAC)
    Note over UR: May or may not learn 2400::pc1<br/>from source address (impl-dependent)

    Note over UR: NUD: entry goes STALE, then PROBE
    UR->>OW: NS: is 2400::pc1 still reachable?
    Note over OW: No proxy entry. Cannot respond.
    Note over UR: No reply. Entry -> FAILED.
    Note over UR: Return traffic dropped.

4.2 ping6 为何能临时有效·

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

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

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

上游路由器随即将 2400::pc1 写入自己的邻居缓存。RFC 4861 §7.2.3 规定,节点收到 NS 时应(SHOULD)为其源地址创建邻居条目,初始状态为 STALE: 即「有记录但未经可达性确认」。处于 STALE 状态的条目仍可用于转发,因此回程流量此时能够正常送达。

然而邻居缓存条目是有生命周期的。STALE 条目一旦有流量触发,会经过 DELAY(5 秒)进入 PROBE 阶段,路由器在此阶段发出 NS 重新确认可达性。若此时没有 proxy entry, OpenWrt 无从响应,条目最终变为 FAILED, 连通性中断,直到下一次手动 ping6.

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

5. commit f0d8553 解析·

5.1 问题的触发链·

先看 handle_solicit() 的核心逻辑:收到 NS 后,对每个「另一侧」的 relay 接口调用 ping6(),用 ICMPv6 echo 探测目标地址是否存在于对侧:

1
2
3
4
5
6
/* src/ndp.c — handle_solicit() */
avl_for_each_element(&interfaces, c, avl) {
if (iface != c && c->ndp == MODE_RELAY &&
(ns_is_dad || !c->external))
ping6(&req->nd_ns_target, c);
}

注意:ping6() 发的是 ICMPv6 echo request,不是 NS。

触发链从 PC1 做 SLAAC 时的 DAD(Duplicate Address Detection,重复地址检测) 开始。PC1 配置好新地址后,必须先发一条源地址为 :: 的 NS 确认地址无冲突:这就是 DAD NS。

odhcpd 在 LAN 侧收到这条 DAD NS,因为 ns_is_dad = true,会调用 ping6(2400::pc1, WAN口),尝试在 WAN 侧探测该地址是否已存在。

问题就出在这里:内核要把这个 ping 发出 WAN 口,首先得知道 2400::pc1 在 WAN 链路上的 MAC 地址。由于 WAN 口开启了 proxy_ndp = 1,内核会在 WAN 口上生成一条 NS 来做邻居解析。这条 NS 随即被 odhcpdAF_PACKET socket 捕获,其源 MAC 是 WAN 口自己的 MAC,odhcpd 认定为「自己发的包」。

旧代码在此处直接 return:

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

探测链就此断掉。odhcpd 从未向 LAN 发出 ping,PC1 没有机会回复,proxy entry 无从建立。

完整的因果链如下:

sequenceDiagram
    participant PC1 as PC1
    participant OW as odhcpd
    participant KRN as Linux Kernel
    participant WAN as WAN

    PC1->>OW: DAD NS (src=::, target=2400::pc1)<br/>on br-lan
    Note over OW: ns_is_dad=true<br/>DAD 必须覆盖整个 /64,包括 WAN 侧
    OW->>KRN: ping6(2400::pc1, WAN口)<br/>relay DAD to upstream segment

    Note over KRN: 发 ping 前需解析 2400::pc1 的 MAC<br/>WAN 口开启了 proxy_ndp=1
    KRN->>WAN: NS: who has 2400::pc1?
    WAN->>OW: 同一条 NS 回环 (AF_PACKET)<br/>源 MAC = WAN 口自身 MAC

    Note over OW: handle_solicit()<br/>is_self_sent=YES, iface->master=YES

    rect rgb(255, 200, 200)
        Note over OW,PC1: [OLD] return<br/>链路断开,LAN 侧从未被探测<br/>proxy entry 无从建立
    end

    OW->>PC1: [NEW] ping6(2400::pc1, LAN口)
    PC1->>OW: echo reply
    Note over OW: NEIGH6_ADD 事件触发<br/>proxy entry 建立在 WAN 口

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 这条路径上的行为发生了质变,如下图所示:

sequenceDiagram
    participant OW as odhcpd
    participant WAN as eth1 (WAN)
    participant LAN as br-lan

    OW->>WAN: NS, target: 2400::pc1
    WAN->>OW: same NS loops back (AF_PACKET)
    Note over OW: handle_solicit():<br/>is_self_sent=YES, iface->master=YES

    rect rgb(255, 200, 200)
        Note over OW,LAN: [OLD] return immediately — LAN never probed, proxy entry never built
    end

    OW->>LAN: [NEW] NS: who has 2400::pc1?
    LAN->>OW: NA: I have it, MAC: xx:xx:xx
    Note over OW: 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)均不包含该修复,缺少「主动探测」路径,只能依赖被动反应路径;在没有上游主动发 NS 的环境下,中继无法自行完成引导。第 7 章会用实测重新审视「这是否就是某个具体环境失效的决定性原因」。

在 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 的网络中,此配置可能静默失效,原因正是本文描述的引导死锁,而非配置错误。是否真的触发,取决于具体环境,第 7 章会用一台 23.05.5 的对照中继说明这一点。

7. 一次迟来的对照实验·

第 5、6 章把 commit f0d8553 讲成「连通性恢复的原因」。这条因果听起来顺理成章,笔者却一直没能直接验证它。本章记录一次迟来的对照实验:笔者最终搭出一台纯 23.05.5 的中继来做对照,而它给出的结果,把上面这条因果推翻了一半。

7.1 搭对照中继:23.05.5 与 22.03.0·

最初做不成对照,是因为手头两台路由器都已是 OpenWrt 25.12.4(含补丁),没有备机可降级,降级现网设备又会中断服务。后来笔者专门准备了一台独立测试机补上这一环:

  • 一台运行原版 OpenWrt 23.05.5(r24106)的虚拟机充当中继,WAN 口接入校园 /64,另一网口接一台测试设备(下称 PC),中继配置与第 6 章一致。
  • 翻阅 odhcpd 提交历史可确认:23.05.5 携带的 src/ndp.c 与 f0d8553 之前的代码在功能上完全等同(中间只隔着一个与逻辑无关的拼写修正提交),即这台中继不含第 5 章所述的「主动探测」路径。
  • 为把版本变量探到底,笔者随后又把这台测试机换成更老的 OpenWrt 22.03.0(同样不含 f0d8553,配置同为标准 relay)。22.03.0 正落在笔者当年真正使用的版本时代,是最贴近「案发现场」的一档。

启用后,PC 通过中继转发的 RA 拿到了一个校园 /64 内的全局地址。这里有一个必须先排除的陷阱:PC 本身还有另一条独立的 IPv6 出口,若不约束,测试流量可能从那条路走掉、根本不经过被测中继。笔者通过绑定出口接口、并关掉另一条出口,确保所有测试流量都老老实实穿过这台 23.05.5 中继。

7.2 它就这么通了·

第一项测试很直接:让 PC 经这台 23.05.5 中继访问公网 IPv6。结果是 0% 丢包、RTT 稳定,仅首包有一次引导延迟。23.05.5 的中继,能用。

真正决定性的是「冷态重建」实验。笔者先在中继上把 PC 地址的 proxy 表项、/128 路由、LAN 侧邻居全部删除,让中继侧彻底回到一无所知的冷态;随后从一台位于另一家宽带(不同 ISP)的外部主机,向 PC 的全局地址发起 ping。事件链如下:

1
2
3
4
5
6
7
8
9
10
Time(relative)  Iface   Frame
---------------------------------------------------------------------------
T+0 deleted PC's proxy / route / neighbor on relay (cold)
external host sends echo; upstream delivers to relay WAN MAC
+~1.7s WAN relay -> external: ICMPv6 Destination unreachable (still cold)
+~1.7s WAN relay(self) NS "who has PC" \ inbound arrived but no entry,
+~1.7s LAN relay NS "who has PC"; PC replies NA / kernel resolves both sides
+~1.7s LAN relay ICMP echo (id=0) -> PC <- odhcpd ping6() probe signature
+~2.0s LAN external echo forwarded -> PC; PC replies <- connectivity back
subsequent echoes all succeed

几点是确凿的:

  1. 删除之后,中继对入向包回了一条 ICMPv6 Destination unreachable。这既证明它当时确实是冷的,也反证流量的确打到了这台中继,没有从别处漏走。
  2. 大约 2 秒后连通恢复,靠的全是反应式机制:入向数据触发中继内核去解析 PC,LAN 侧 PC 应答、NEIGH6_ADD 事件让 odhcpd 重建 proxy;LAN 侧那条 id=0 的 echo 正是 odhcpdping6() 探测签名。全程没有 f0d8553 的主动探测路径参与。

也就是说:一台不含 f0d8553 的 23.05.5 中继,被人为删光状态后,仍能在约 2 秒内纯靠反应式路径自愈。

把测试机换成 22.03.0(标准 relay 配置)再做同一组操作,逐帧过程完全一致:入向数据触发内核解析、odhcpdping6() 探测(LAN 侧 seq 0 的 echo 就是签名)重建表项、随后对上游与同段中继代答 NA,约半秒、丢一个包即恢复。把三个横跨 f0d8553 前后的版本放在一起对照:

版本 含 f0d8553 正常连通 删光后冷态自愈
22.03.0(笔者当年的版本时代) 0% 丢包 约 0.5 秒,丢 1 个包
23.05.5 0% 丢包 约 2 秒,丢 1 至 2 个包
25.12.4 0% 丢包 即时

三个版本在今天的校园网里全部能用、都能从冷态自愈。含补丁的 25.12.4 因为多了主动探测路径,重建几乎无感;但「能不能用」这件事,三者并无区别。

7.3 人为注入劣化·

既然正常能用,笔者又试着把它压垮,看能不能逼出记忆中那种「不稳定」。

空闲老化。 停掉对 PC 的流量,用 ip monitor 盯邻居表。PC 的 LAN 侧邻居在 REACHABLESTALEPROBE 之间循环,但始终没有被回收:该中继的邻居表项数远低于内核回收阈值 gc_thresh1(128),低于阈值时内核根本不回收 stale 表项,于是 proxy 表项全程都在,没有出现「表项老化掉、再重建」的抖动。

人为劣化 WAN。tcnetem 在 WAN 口注入延迟与丢包:

WAN 注入劣化 实测影响 proxy 表项
延迟 3s / 6s / 15s 丢包约 0,RTT 等量增大 一直在
丢包 50% / 80% / 95% 实测丢包约等于注入值,无放大 一直在

哪怕单向延迟拉到 15 秒、丢包拉到 95%,连通性也只是成比例地变差,没有出现「NDP 解析崩塌、整段黑洞」式的放大效应,proxy 表项纹丝不动。

根因很清楚:proxy 表项是静态的(NTF_PROXY),且由 LAN 侧的邻居事件维护,这条链路完全不受 WAN 质量影响。 内核收到上游 NS 当场本地应答;延迟只让报文「晚到」而非「不到」;WAN 再烂,也只是按链路质量成比例丢数据,动不了中继机制本身。

7.4 最干净的对照已无法完成·

把上面这些放在一起,可以给出一个更准确也更克制的图景。odhcpd 有两条独立的建表路径:

  • 被动反应路径:上游(或同段其它中继)主动发来针对某地址的 NS,odhcpd 借机探测、建表。这条路径在所有版本都有,前提是「上游有理由发 NS」。
  • 主动探测路径odhcpd 由自己发出又回环的 NS 驱动,主动去 LAN 侧探测、建表。这条路径正是 f0d8553 才打通的,价值在于不依赖上游主动发 NS。

第 4、5 章对失效机理的分析(NUD 探测无法绕过、DAD 自探测回环死锁)在逻辑上依然成立,f0d8553 修掉的 bug 也是真实的。但本章的实测补上了一个关键限定:第 4 章那张「proxy 缺失、上游来探测、无从应答、条目进入 FAILED」的图景,在繁忙的共享段里往往并不致命:那条到来的 NS 本身就会触发 odhcpd 去重建 proxy(正是 7.2 的冷态重建),于是结局是丢几个包后恢复,而非永久中断。在本文这种校园 /64(上游网关与同段多台中继持续 solicit 全段地址)上,被动反应路径单独就足以把中继喂活,f0d8553 的主动探测路径在这里并不承重

那么当年的失效又是怎么回事?笔者必须诚实承认:最干净的那个对照实验,已经做不成了。 它需要同时还原两样东西,而如今只剩一样还能还原:

  • 版本这条线,笔者已经探到底:23.05.5 与 22.03.0(后者正落在当年使用的版本时代)都直接测过,配置标准、删光状态也照样在一两秒内反应式自愈。f0d8553 前后的多个版本今天都能用,「版本太老」这条基本可以排除。
  • 真正还原不了的,是上游环境。校园网的 H3C 设备这些年完全可能改过策略,园区路由器也可能升级、配置也可能被优化过。当年那套上游行为,今天无从复刻。

正因为版本这条线已被排除,能解释「当年失效、如今可用」的,几乎只剩上游环境的变化。因此最老实的说法是:今天的「能用」,未必是 f0d8553 的功劳,更可能是上游环境早已不同。 笔者以及当年同样碰壁的若干人所经历的失效是真实的,但它的根因如今已落在时间的另一侧,无法干净地复现与归因。本文能确定的,是 NDP Relay 的机制、那个真实存在的 bug、以及 f0d8553 对它的修复;本文不能断言的,是这个 commit 就是某一个具体环境在今天得以正常工作的决定性原因。