我和 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 也不再以原来的方式断流。这篇文章记录完整的排障过程、协议原理、临时修复及其副作用,以及未来如何更彻底地解决这个问题。
症状为什么如此迷惑
最典型的一次过程是:
- 手机使用移动数据,通过 IPv6 连接家中 Sunshine。
- Moonlight 开始时画质和延迟完全正常。
- 某一刻画面突然转圈或冻结。
- 模拟鼠标仍然能操作远端电脑约 10 秒。
- 随后会话彻底断开。
测试期间,客户端先后报错 -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() 可以看到:
- master 接口收到 RA 后,把原始
data和len交给转发函数; - 转发函数在同一数据缓冲区中增加 proxy flag;
- Source Link-Layer Address option 被改写为下游接口 MAC;
- 如有需要,RDNSS 地址会被改写;
- 整封 RA 通过 slave 接口重新发送到
ff02::1; - 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。
规则还附加了三层限制:
- 仅匹配从路由器本机发出的报文(
outputhook); - 仅匹配从
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 的生成原因。更理想的方案按优先级包括:
- 使用真正的 DHCPv6-PD 和路由前缀。 如果运营商和主路由允许,把独立前缀委派给 OpenWrt,由 OpenWrt 在 LAN 侧以 server 模式生成自己的 RA,就不需要把上游任意设备的 RA 跨三层边界转发。这是结构上最干净的修复。
- 在上游入口实施 RA Guard。 若能确认 Apple TV 的接口和 MAC,可在交换机、VLAN 边界或 OpenWrt master 接口入口阻止该设备的 RA,使 odhcpd 根本看不到它。优点是不会误伤主路由合法的 Lifetime 0;缺点是会切断 Apple TV 对应的 Thread PIO/RIO,并依赖稳定的二层身份。
- 把 Apple TV 与 relay master 分开。 将 Apple TV 放入独立 VLAN/SSID,或接到 OpenWrt 下游而不是上游 RA 来源链路。这样即使它继续发 Lifetime 0,发送源仍是 Apple TV,不会被中继重塑成 OpenWrt。
odhcpd 本身确实支持在没有可委派前缀时进行 RA、DHCPv6 与 NDP relay,但 relay 拓扑天然比标准的前缀委派多一层状态耦合。odhcpd README
复盘:以后遇到类似问题,先查什么
如果你也碰到“IPv6 大体能用,但长连接偶发断连”,可以按这个顺序排查:
- 在终端连续观察
::/0,不要只看有没有全局 IPv6 地址。 - 在路由器上下游同时抓 RS/RA,检查 Router Lifetime。
- 查找
fd00::/8的 PIO/RIO 及其链路本地下一跳,并把邻居 MAC 对应到 Apple TV、HomePod 或其他 Thread Border Router。 - 对照 odhcpd relay 前后的源地址、Source Link-Layer Address、Router Lifetime、PIO/RIO 和报文长度。
- 查看 OpenWrt 是否只有 source-specific default route,并搜索日志中的
No default route present、ra_lifetime,排除第二种生成机制。 - 用完全绕过二级路由的路径做 A/B 测试。
- 若要过滤,先用计数器精确匹配,确认命中与故障时间相关。
参考资料
- RFC 4861 — Neighbor Discovery for IP version 6
- Apple TV 的神秘 IPv6 RA 数据包
- Apple TV 发出异常 RA 的逐字段抓包与隔离实践
- Apple Support — Set up Apple TV as a home hub
- OpenThread — Thread Border Router 的 PIO/RIO 行为
- OpenWrt odhcpd README
- odhcpd
b14cf98c— relay 转发函数源码 - odhcpd issue #388 — source-specific route 与 Lifetime 0 的另一类问题
- OpenWrt issue #19467 — odhcpd reports no default route
- OpenWrt firewall configuration
- firewall4
/etc/nftables.d/include documentation - Moonlight issue #1457 — control stream disconnect / error -1
留言 / CONVERSATION
欢迎交流,评论将在审核后展示,你的邮箱不会被公开。
正在准备评论区…