我和 OpenWrt 的 IPv6 已经纠缠两年了。

过去每一次,我都是信心满满地配置 IPv6,遇到那些难以解释的故障后又铩羽而归,最后只能避其锋芒,把 IPv6 一关了之。说真的,我已经记不清自己重复过多少次这个过程了。

但这一次,我不想再绕开它。我下定决心,无论问题藏在哪里,都要把它彻底揪出来。终于,这场持续了两年的拉锯,似乎迎来了一个皆大欢喜的结局。

先说结论

问题并不在于设备没有拿到 IPv6 地址,而是 Windows 的 IPv6 默认路由会被间歇性撤销。

我先后排除了 MTU、防火墙、设备性能、透明代理和 Sunshine 配置,最后在 ICMPv6 Router Advertisement(RA)里找到了决定性证据:OpenWrt 会间歇性向 LAN 发出 Router Lifetime 为 0 的 RA,等价于通知下游设备“不要再把我当 IPv6 默认路由器”。

继续向上追查,链条又延伸了一步:Apple TV 在主路由 LAN 上以 Thread 边界路由器的身份通告一条 ULA 路由;我所用版本的 odhcpd relay 会保留上游 RA 的 Router Lifetime,却在下游改用 OpenWrt 的接口身份重新发包。于是,Apple TV 原本表示“我不是默认路由器”的 Lifetime 0,到了二级 LAN,却变成了“OpenWrt 不是默认路由器”。

部署修复规则后,针对这类 RA 的 nftables 计数器从 3 增长到 97,而 Windows 的 IPv6 默认路由没有再消失,Moonlight 也不再以原来的方式断流。这篇文章记录完整的排障过程、协议原理、临时修复及其副作用,以及未来如何更彻底地解决这个问题。

症状为什么如此迷惑

最典型的一次过程是:

  1. 手机使用移动数据,通过 IPv6 连接家中 Sunshine。
  2. Moonlight 开始时画质和延迟完全正常。
  3. 某一刻画面突然转圈或冻结。
  4. 模拟鼠标仍然能操作远端电脑约 10 秒。
  5. 随后会话彻底断开。

测试期间,客户端先后报错 -1 和 -106。弹窗显示:

Timeout type: enet peer timeout disconnect /
unexpected control stream disconnect.
The established control stream was dropped by ENet
or the host/network unexpectedly.

不同 Moonlight 平台或版本对底层断流的错误呈现并不完全相同,所以错误编号只能描述结果,不能直接指向原因。Moonlight 的公开 issue 中也能看到 -1、视频/音频丢失、控制流意外断开和 ENet peer disconnected 同时出现,但那只能证明这类报错是“网络路径或对端控制流消失”的外在表现,不能证明与本文拥有相同根因。Moonlight issue #1457

同一时期还有一些更不显眼的伴随症状:

  • 部分网站首开很慢;
  • B 站、抖音的评论区偶尔刷不出来;
  • IPv6 测试有时成功,有时像是卡在连接阶段;
  • Moonlight 偶尔短暂卡顿后自行恢复,也有时会彻底断线。

最后一项需要区分:运营商前缀变更或瞬时抖动,也可能让既有 IPv6 会话短暂停顿,但通常能够恢复;本文关注的故障有稳定得多的特征——默认路由被撤回、画面持续转圈、控制流延迟死亡,最终出现 -1 / -106。

网络拓扑与关键对照

flowchart LR
    ISP["运营商 IPv6"] --> Main["主路由 / PPPoE"]
    Main --> OW["OpenWrt"]
    OW --> PC["PC / Mellanox 网卡<br/>Sunshine"]
    Main --> ATV["Apple TV<br/>接主路由 LAN,业务指向 OpenWrt"]
    Main -. "I226-V 2.5G 直连<br/>对照路径" .-> PC
    Phone["手机 / 移动数据<br/>Moonlight"] --> Internet["IPv6 Internet"] --> PC

几个重要条件:

  • 主路由负责拨号,OpenWrt 是 PVE 中的二级软路由,性能不存在瓶颈。
  • OpenWrt 的 wan6 接口通过中继把上游 IPv6 延伸到下游。
  • PC 的 Mellanox 10G 网卡走 OpenWrt;Intel I226-V 2.5G 网卡直连主路由,用作对照。
  • I226-V 直连主路由时,Moonlight 从未出现同类断流。
  • Apple TV 接在主路由 LAN,但把部分业务指向 OpenWrt,以实现其部分功能。

文中的公网地址均已替换为文档前缀 2001:db8::/32,链路本地地址、MAC、域名和设备凭据也已匿名化。

真正有效的 A/B 测试,是在同一台 PC、同一套 Sunshine 上,仅切换出口网卡:

  • 连接 OpenWrt 的 Mellanox 网卡:可以复现;
  • I226-V 直连主路由:长期稳定。

这个结果把排障范围缩小到了 OpenWrt 的 IPv6 中继路径,而不是 Moonlight 应用本身。

排除过的方向

这类问题很容易把人带向几个常见答案:

  • MTU / PMTUD:已检查 MTU 钳制,故障仍能复现。
  • OpenWrt 防火墙:常规转发和入站规则无法解释这种周期性消失。
  • 软路由性能:PVE 和 OpenWrt 的 CPU、内存、吞吐均有充足余量。
  • 透明代理:开关透明代理均能复现,IPv6 TCP/UDP 代理本身也能正常工作。
  • Sunshine 监听方式与端口:IPv4 + IPv6、默认绑定和端口均工作正常。
  • DDNS:域名能够更新到当前 IPv6;而且连接建立后,DDNS 不会参与既有会话的数据转发。

突破口:有 IPv6 地址,不等于有 IPv6 默认路由

IPv6 主机可以同时处于以下状态:

  • 全局 IPv6 地址仍然存在;
  • /64 前缀仍然有效;
  • 邻居发现仍然工作;
  • 局域网内 IPv6 仍可通信;
  • 但公网所需的默认路由 ::/0 已经消失。

原因在 RA 的 Router Lifetime 字段。

按照 RFC 4861,Router Lifetime 是一个 16 位秒数,用于声明发送者还能作为默认路由器多长时间。它只决定默认路由器资格,并不会使同一封 RA 里的前缀等其他信息失效。若主机已经把某个设备列为默认路由器,又收到它发来的 Lifetime 0,相关默认路由器条目应被立即超时移除。

证据链

1. 在 br-lan 上抓到 Lifetime 0 的 RA

OpenWrt 上使用:

tcpdump -ni br-lan -vv 'icmp6 and ip6[40] == 134'

其中 ICMPv6 Type 134 就是 Router Advertisement。故障窗口内,LAN 确实收到了 Router Lifetime 为 0 的 RA。

若要同时观察 Router Solicitation(Type 133)和 RA,可用:

tcpdump -ni any -vv 'icmp6 and (ip6[40] == 133 or ip6[40] == 134)'

更严格的复现应同时在 master 和 slave 两侧保存 pcap,并以时间、PIO/RIO、Lifetime 和报文长度关联同一封 RA:

tcpdump -ni eth1 -s0 -w /tmp/ra-upstream.pcap \
    'icmp6 and ip6[40] == 134'

tcpdump -ni br-lan -s0 -w /tmp/ra-downstream.pcap \
    'icmp6 and ip6[40] == 134'

本案没有保留下这组同时抓取的原始 pcap,因此不能声称对某一封报文完成了逐字节配对;但 Apple TV ULA 路由、对应 MAC/下一跳、下游 Lifetime 0、特定版本 relay 源码以及修复计数器,已经构成相互独立且方向一致的证据链。

2. OpenWrt 学到了由 Apple TV 通告的 ULA 路由

故障现场的 OpenWrt 路由表中,有一条并不属于家庭公网前缀的 ULA /64:

fd00:1111:2222::/64 from 2001:db8:100:200::/64 \
    via fe80::APPLE-TV dev eth1 proto static metric 512 valid 1466

fe80::APPLE-TV dev eth1 lladdr <APPLE-TV-MAC> REACHABLE

关键在于:这条 ULA 路由的下一跳链路本地地址与 MAC,和主路由中登记的 Apple TV 完全一致;剩余有效期约 1466 秒,又落在 Thread Border Router 常见的 1800 秒通告周期内。

它证明了 Apple TV 不只是“发起了某次 RS 的普通终端”,而是在主路由 LAN 上承担 IPv6 路由角色并发布特定 ULA 路由。

这与我检索到的博文高度吻合:《Apple TV 的神秘 IPv6 RA 数据包》通过逐台拔线确认 Apple TV 会让同网段设备获得额外 ULA;它引用的另一份逐字段抓包记录则直接展示了 Apple 厂商 MAC 发出的 RA:Router Lifetime 为 0,同时携带有效期 1800 秒的 ULA PIO 和 RIO。

因此,OpenWrt 路由表里看到的 valid 1466 是 RIO 从 1800 秒开始倒计时的剩余值;本文的 nftables 规则匹配的则是 RA 固定头部的 Router Lifetime 0。

这并不必然是 Apple TV 的“错误行为”。Apple 官方确认部分 Apple TV 4K 可以充当 Thread-enabled home hub;OpenThread 的官方说明也指出,Thread Border Router 会在 Wi-Fi/以太网基础设施链路上通过 RA 的 PIO 和 RIO 发布前缀与路由。Apple Home Hub 文档、OpenThread Border Router 说明

参考文章在 2025 年更新称,某次 tvOS 更新后,其环境中不再出现额外 ULA 地址。这个观察不能直接推广到所有型号和拓扑:仅仅“不再生成第二个 SLAAC 地址”也不能证明设备完全不发 RA,因为只含 RIO 的 RA 仍可通告 Thread 路由。现场路由表证明,至少在本次环境里,这条 ULA route advertisement 仍然活跃。

3. Lifetime 0 在 Apple TV 原来的链路里其实合理

按照 RFC 4861,Router Lifetime 只决定 RA 发送者是否应进入 Default Router List,不会让同一封 RA 中的 PIO、RIO 等其他信息失效。

所以 Apple TV 可以同时表达两件事:

  • Router Lifetime=0:不要把 Apple TV 当成通往整个 IPv6 Internet 的默认路由器;
  • RIO lifetime=1800:特定 Thread/ULA /64 可以经由 Apple TV 到达。

在一个普通的扁平 LAN 中,主机看到 RA 的发送源是 Apple TV。Apple TV 本来就不是主机的默认网关,Lifetime 0 通常不会撤掉真正主路由的 ::/0。问题出在这封 RA 跨过了 relay 边界。

4. 所用 odhcpd relay 保留 Lifetime,却替换发送身份

现场版本为 odhcpd 2025.10.02~b14cf98c-r2。审计其对应提交的 forward_router_advertisement() 可以看到:

  1. master 接口收到 RA 后,把原始 data 和 len 交给转发函数;
  2. 转发函数在同一数据缓冲区中增加 proxy flag;
  3. Source Link-Layer Address option 被改写为下游接口 MAC;
  4. 如有需要,RDNSS 地址会被改写;
  5. 整封 RA 通过 slave 接口重新发送到 ff02::1;
  6. Router Lifetime 字段没有被重新计算或改写。

这构成了这次故障中最关键的“源身份错位”:

上游主路由 LAN
Apple TV (fe80::APPLE-TV)
RA: Router Lifetime = 0,RIO = fdxx::/64, 1800s
        │
        │ odhcpd RA relay:保留 Lifetime,改写链路层身份并从 slave 重发
        ▼
下游 OpenWrt LAN
OpenWrt (fe80::OPENWRT)
RA: Router Lifetime = 0,RIO = fdxx::/64, 1800s
        │
        ▼
Windows:把 fe80::OPENWRT 从 Default Router List 移除

上游 RA 的语义本来是“Apple TV 不是默认路由器”;经过中继后,主机看到的语义却成了“OpenWrt 不是默认路由器”。而 OpenWrt 恰恰是该二级 LAN 的真实默认网关。

这也解释了一个此前很反常的 A/B 结果:I226-V 直连主路由时,PC 与 Apple TV 处在同一个原始二层,依然可能收到 Apple TV 的 Lifetime 0 RA,但发送者仍是 Apple TV,不会撤掉主路由;Mellanox 走 OpenWrt 下游时,同一语义经 relay 换成 OpenWrt 身份,才会击中正在使用的默认路由。

odhcpd 的 README 明确说明 relay 用于在 routed、non-bridged interfaces 之间转发 RA;这类实现无法保留上游路由器的链路本地源地址,因此中继控制报文时必须特别警惕身份与语义的耦合。odhcpd README

5. 丢弃规则不仅命中,而且持续命中

部署精确过滤后,查看计数器时为:

packets 3 bytes 240

稳定测试一段时间后变成:

packets 97 bytes 7760

也就是说,又有 94 封“撤销默认路由”的 RA 试图从 OpenWrt 发往 LAN,却被规则拦住了。与此同时,Windows 默认路由仍然健在,Moonlight 没有再发生此前那种断流。

6. Windows 默认路由的 Lifetime 能持续刷新

修复后,我尝试每 5 秒采样一次 Windows IPv6 默认路由。Lifetime 会在约 41–58 秒之间变化,并不断被正常 RA 刷新,没有归零,也没有再从路由表消失。

日常手工检查可以使用:

route print -6
netsh interface ipv6 show route level=verbose

一个简单的连续监控脚本:

1..15 | ForEach-Object {
  $txt = netsh interface ipv6 show route level=verbose | Out-String
  [regex]::Matches(
    $txt,
    'Destination Prefix:\s+::/0.*?Interface Index:\s+(\d+).*?Gateway/Interface Name:\s+(\S+).*?ValidLifeTime\s+(\S+)',
    [Text.RegularExpressions.RegexOptions]::Singleline
  ) | ForEach-Object {
    "{0} if={1} gw={2} lifetime={3}" -f (Get-Date -Format HH:mm:ss),
      $_.Groups[1].Value, $_.Groups[2].Value, $_.Groups[3].Value
  }
  Start-Sleep 5
}

一个相似但不是主因的方向

OpenWrt 的路由表同时只有 source-specific default route:

default from 2001:db8:100:200::93b via fe80::1 dev eth1 proto static metric 512
default from 2001:db8:100:200::/64 via fe80::1 dev eth1 proto static metric 512

OpenWrt 社区确实存在另一类 Lifetime 0 问题:路由转发可用,但 odhcpd 的 server-mode 默认路由检查没有正确接受源相关默认路由,于是记录 No default route present 并生成 Lifetime 0。odhcpd issue #388、OpenWrt issue #19467

最初我曾把它当成主根因。审计 relay 源码并找到 Apple TV 的 ULA RIO 后,这个判断应当降级:本案的 RA 走的是 relay 转发函数,该函数并不执行 server-mode 的 calc_ra_lifetime(),而是直接保留上游 Router Lifetime。因此,Apple TV → relay 的证据链更贴合现象。

不过,source-specific default route 仍是这套网络的独立脆弱点。未来若切换到 RA server/hybrid,或者 odhcpd 在其他状态下自行生成 RA,它仍可能造成相似症状,排查时不应完全忽略。

为什么画面先死,鼠标还能动约 10 秒

Moonlight 与 Sunshine 的连接不是一条单独的“视频连接”,而是由视频、音频、输入和控制等多路状态组成。默认 IPv6 路由突然被撤销后:

  • 高频、持续的媒体数据最先暴露路径中断,画面立即冻结,开始尝试重连;
  • 已建立连接的本地状态、缓冲区及各通道检测周期不同;
  • 输入动作在短时间内仍可能被客户端接受,甚至送达;
  • ENet/control stream 要等心跳、重传或 peer timeout 达到阈值,才最终宣告断开。

精确止血:只丢弃发往 LAN 的 Lifetime 0 RA

修复不能简单地“屏蔽 Apple TV”,而应当针对下游真正有害的语义做精确匹配。

为什么不按 Apple TV 的地址拦截

  • IPv4 地址与 IPv6 RA 匹配无关;
  • Apple TV 的 IPv6 隐私地址与链路本地地址可能变化,切换以太网/Wi-Fi 或启用私有 Wi-Fi 地址时,二层身份也未必固定;
  • 报文经过 odhcpd relay 后,下游看到的是 OpenWrt 的链路身份,原始 Apple TV 身份已经不可用于 nftables output 匹配;
  • 并非 Apple TV 的所有 RA 都有害,真正会撤掉下游默认网关的是 Router Lifetime 精确等于 0 的那部分。

因此,我选择只阻止 OpenWrt 从 br-lan 发出的 Lifetime 0 RA,不会阻断正常的正 Lifetime RA 或全部 ICMPv6。代价是同一封 RA 携带的 Thread 路由信息也会一起被拦截,后文会单独说明。

部署规则

OpenWrt firewall4 会把 /etc/nftables.d/*.nft 作为 nftables 片段纳入 table inet fw4。这一机制可见于 OpenWrt 防火墙配置文档 和 firewall4 nftables.d 说明。

创建文件:

/etc/nftables.d/20-ra-relay-zero-lifetime-guard.nft

内容如下:

# Prevent relayed zero-lifetime Router Advertisements from withdrawing the
# OpenWrt LAN IPv6 default route. Other RA and ICMPv6 traffic is unaffected.
chain ra_relay_zero_lifetime_guard {
    type filter hook output priority -1; policy accept;
    oifname "br-lan" meta l4proto ipv6-icmp icmpv6 type nd-router-advert @th,48,16 0x0000 counter drop comment "ra-relay-zero-lifetime-guard"
}

@th,48,16 的含义是:从传输层头部起第 48 bit 开始,读取 16 bit。RA 固定头部在 Router Lifetime 之前依次是 Type、Code、Checksum、Cur Hop Limit 和 Flags,合计正好 48 bit;因此这里读取到的就是 Router Lifetime。

规则还附加了三层限制:

  • 仅匹配从路由器本机发出的报文(output hook);
  • 仅匹配从 br-lan 发出的 RA;
  • 仅匹配 Router Lifetime 精确等于 0。

正常正 Lifetime 的 RA、RS、NS、NA、Packet Too Big、DHCPv6 及其他 ICMPv6 均不会被这条规则拦截。不要用“屏蔽 ICMPv6”代替它,那会破坏邻居发现、路径 MTU 发现等 IPv6 基础功能。

部署前应先做 OpenWrt 配置备份,并在可恢复的维护窗口执行:

fw4 check
/etc/init.d/firewall restart
nft -a list chain inet fw4 ra_relay_zero_lifetime_guard

重启防火墙可能短暂影响现有连接。若 fw4 check 不通过,不要重启防火墙;先修正语法或从控制台撤销该文件。

之后重点观察规则计数器:

nft -a list chain inet fw4 ra_relay_zero_lifetime_guard

以及 OpenWrt 的 IPv6 路由和 odhcpd 日志:

ip -6 route show table all
logread | grep -Ei 'odhcpd|ra_lifetime|default route'

这条规则的副作用

Lifetime 0 本来是合法的协议语义:路由器准备停止转发时,可以用它通知主机立即改选默认路由器。这条规则会把合法退出和错误退出一并丢弃。

在这套拓扑里,主要副作用是:如果上游真的断网,而 OpenWrt 发出 Lifetime 0,下游不会立即撤销 OpenWrt 默认路由;它会保留到最近一次正 Lifetime RA 的剩余时间耗尽。按本次网络观测,这个窗口大约不超过一分钟,其间可能形成短暂黑洞。

还有一个此前容易忽略的副作用:nftables 丢弃的是整封 Lifetime 0 RA,而不只是把 Router Lifetime 字段改成正值。因此,随该 RA 携带的 Apple TV Thread ULA PIO/RIO 也不会抵达二级 LAN。普通公网 IPv6 和 Moonlight 不依赖这条路由,但如果下游设备需要直接访问 Apple TV 后面的 Thread 网络,这项能力可能受影响。

因此,它是一条针对已验证故障的外科式兜底规则,不是所有 OpenWrt 用户都应该复制的“IPv6 优化”。以下场景尤其需要谨慎:

  • LAN 内有多个 IPv6 默认路由器,需要快速切换;
  • 路由器经常主动退出或进行高可用选主;
  • br-lan 不是实际下游接口;
  • 上游还有其他真实路由器会合法发送 Lifetime 0,但经 relay 后在下游无法再区分原始来源;
  • 尚未抓包确认默认路由消失与故障相关。

更理想的长期方案

这条 nftables 规则解决了用户体验和默认路由稳定性,但没有消除 Lifetime 0 的生成原因。更理想的方案按优先级包括:

  1. 使用真正的 DHCPv6-PD 和路由前缀。 如果运营商和主路由允许,把独立前缀委派给 OpenWrt,由 OpenWrt 在 LAN 侧以 server 模式生成自己的 RA,就不需要把上游任意设备的 RA 跨三层边界转发。这是结构上最干净的修复。
  2. 在上游入口实施 RA Guard。 若能确认 Apple TV 的接口和 MAC,可在交换机、VLAN 边界或 OpenWrt master 接口入口阻止该设备的 RA,使 odhcpd 根本看不到它。优点是不会误伤主路由合法的 Lifetime 0;缺点是会切断 Apple TV 对应的 Thread PIO/RIO,并依赖稳定的二层身份。
  3. 把 Apple TV 与 relay master 分开。 将 Apple TV 放入独立 VLAN/SSID,或接到 OpenWrt 下游而不是上游 RA 来源链路。这样即使它继续发 Lifetime 0,发送源仍是 Apple TV,不会被中继重塑成 OpenWrt。

odhcpd 本身确实支持在没有可委派前缀时进行 RA、DHCPv6 与 NDP relay,但 relay 拓扑天然比标准的前缀委派多一层状态耦合。odhcpd README

复盘:以后遇到类似问题,先查什么

如果你也碰到“IPv6 大体能用,但长连接偶发断连”,可以按这个顺序排查:

  1. 在终端连续观察 ::/0,不要只看有没有全局 IPv6 地址。
  2. 在路由器上下游同时抓 RS/RA,检查 Router Lifetime。
  3. 查找 fd00::/8 的 PIO/RIO 及其链路本地下一跳,并把邻居 MAC 对应到 Apple TV、HomePod 或其他 Thread Border Router。
  4. 对照 odhcpd relay 前后的源地址、Source Link-Layer Address、Router Lifetime、PIO/RIO 和报文长度。
  5. 查看 OpenWrt 是否只有 source-specific default route,并搜索日志中的 No default route present、ra_lifetime,排除第二种生成机制。
  6. 用完全绕过二级路由的路径做 A/B 测试。
  7. 若要过滤,先用计数器精确匹配,确认命中与故障时间相关。

参考资料