跳到内容
观澜涉川 · 札记EN

Tailscale 因 DHCPv6 地址导致无法直连排查记录

· 约 3 分钟阅读

在手机上通过 tailscale 访问 Armbian 设备,始终无法建立 Direct Connection。最终定位到根因是 DHCPv6 分配的 /128 地址被内核选为默认源地址,导致 IPv6 回包无法送达。在 Netplan 中添加 ethernets 段并指定 ipv6-address-generation: stable-privacy 后解决。

问题

  • 设备:Armbian ARM 盒子(ab01)
  • 网络管理:Netplan + NetworkManager
  • 问题:从手机访问 ab01 时,Tailscale 连接始终走 DERP 中继(欧洲节点,~450ms),tailscale netcheck 显示 IPv6: no, unavailable in OS
  • 对比:同局域网的 Linux x86桌面电脑(w01)却能正常直连

排查过程

确认 IPv6 地址和路由都正常

$ ip -6 addr show eth0 | grep inet6
inet6 2408:xxxx:xxxx:xxxx::1001/128 scope global dynamic noprefixroute
inet6 2408:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/64 scope global dynamic noprefixroute
inet6 fe80::xxxx:xxxx:xxxx:xxxx/64 scope link noprefixroute
$ ip -6 route show default
default via fe80::xxxx:xxxx:xxxx:xxxx dev eth0 proto ra metric 100 pref medium

设备同时拿到 /128(DHCPv6)和 /64(SLAAC)两个公网地址。路由和防火墙均无异常。

定位到“能发不能收”

先用 nc -6 -u 测试 UDP 发送:

$ nc -6 -u -z -w 3 2400:3200::1 53 && echo "UDP OK" || echo "UDP FAIL"
Connection to 2400:3200::1 53 port [udp/domain] succeeded!
UDP OK

但这只能证明“包发出去了”,不能证明“能收到回包”。UDP 是无连接协议,-z 只是尝试发送,没有握手确认。

于是用一个 Python 脚本做更完整的测试:向 IPv6 的公共 DNS 服务器(2400:3200::1)发送一个 DNS 查询包,然后等待接收服务器返回的结果。如果收到回应,说明 IPv6 收发都正常;如果超时,说明回包进不来。

$ python3 -c "
import socket
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.settimeout(5)
query = b'\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05baidu\x03com\x00\x00\x01\x00\x01'
try:
s.sendto(query, ('2400:3200::1', 53))
data, addr = s.recvfrom(512)
print('收到回应,长度:', len(data))
except Exception as e:
print('未收到回应:', e)
finally:
s.close()
"
未收到回应: timed out

结果是超时,定位到问题是“能发不能收”。

找到根因:源地址选错

$ python3 -c "
import socket
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.connect(('2400:3200::1', 53))
print('本地地址:', s.getsockname())
s.close()
"
本地地址: ('2408:xxxx:xxxx:xxxx::1001', 49906, 0, 0)

内核默认选了 /128 的 DHCPv6 地址作为源地址。该地址没有对应的 on-link 路由,回包到达光猫后无法送回设备。

强制绑定 SLAAC 的 /64 地址发送同样的 DNS 查询,立刻收到回应,确认根因。

$ python3 -c "
import socket
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.settimeout(5)
s.bind(('2408:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx', 0))
query = b'\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05baidu\x03com\x00\x00\x01\x00\x01'
try:
s.sendto(query, ('2400:3200::1', 53))
data, addr = s.recvfrom(512)
print('收到回应,长度:', len(data))
except Exception as e:
print('未收到回应:', e)
finally:
s.close()
"
收到回应,长度: 91

解决方案

确认系统用的是 Netplan:

$ ls /etc/netplan/
00-default-use-network-manager.yaml

原始文件只有:

network:
version: 2
renderer: NetworkManager

在文件中添加 ethernets 段,为 eth0 指定 IPv6 配置:

network:
version: 2
renderer: NetworkManager
ethernets:
eth0:
dhcp4: true
dhcp6: false
accept-ra: true
ipv6-address-generation: stable-privacy

应用配置:

netplan apply

ipv6-address-generation: stable-privacy 明确告诉 NetworkManager 使用 RFC7217 方式生成 SLAAC 地址的接口标识符。这个参数能被 Netplan 正确识别并翻译给 NetworkManager,从而完整触发 SLAAC 流程,绕过了 accept-ra 翻译丢失的问题。

验证

ip -6 addr show eth0 | grep inet6 # 确认 IPv6 地址存在
tailscale netcheck # 确认 IPv6: yes
tailscale ping ab01 # 确认直连建立

重启设备,再确认一次。

发现

手机流量可以走 IPv6 直连

排查中发现:用手机流量连接 ab01 时,有时能建立 IPv6 直连,有时却走中继。原因是:

  • 手机流量通常有公网 IPv6:4G/5G 网络普遍下发 IPv6 地址,且没有 NAT 转换。如果 ab01 也有可用的公网 IPv6,双方直接通过 IPv6 互通,不需要打洞。
  • ab01 缺少公网 IPv6 时:手机只能靠 IPv4 打洞。但手机流量的 IPv4 通常处于严格的 CGNAT 后面,打洞成功率低,所以回退到中继。
  • 同局域网内设备表现不同:w01 能直连而 ab01 不能,正是因为 w01 有公网 IPv6,而 ab01 当时没有。

不对称直连是正常现象

Tailscale 的每条连接都是双向独立建立的。两端各自尝试用自己能用的最佳路径去连对方,最终可能形成一条“不对称”的连接路径(一端走 IPv4,另一端走 IPv6)。只要至少一个方向能直连,整体连接就不会走中继。

总结

Tailscale 的 ipv6os=false 不一定代表系统没有 IPv6,也可能是源地址选择错误导致 UDP 回包收不到。排查时重点验证 UDP 的接收能力,而不只是发送。

在 Armbian + Netplan + NetworkManager 环境下,accept-ra: true 可能无法正确触发 SLAAC。在 Netplan 中添加 ethernets 段并指定 ipv6-address-generation: stable-privacy,可以显式引导 NetworkManager 走 SLAAC 流程,是持久有效的解决方案。