机房里有一台带 5090 的机器,是我跑实验用的。人在宿舍,机器在机房,中间隔着校园网、两层 NAT, 以及一个我完全够不着的上游。

一开始想得很简单:装好 Tailscale,回宿舍用 SSH 连上,就能干活。 后面分别遇到了桌面会话不一致、NAT 映射受限和 IPv6 前缀未下发的问题。按当时的记录,最后恢复了直连;下面保留影响判断的现象与配置变化。

诊断项 之前 现在
Tailscale 路径 relay DERP(sfo) direct
最近 DERP San Francisco Hong Kong
探测到的 IPv6 no yes
出口诊断(不同字段) MappingVariesByDestIP: true PortMapping: UPnP / NAT-PMP
Tailscale 设备列表,红框标出机房服务器,状态显示 Connected,地址已打码
Tailscale 设备列表中的机房服务器。Connected 表示设备在线,实际是否直连仍需查看连接路径。

阶段 1 - 远程桌面进入了不同会话

服务器上跑着向日葵,我平时用它看 GNOME 桌面,敲代码走 SSH。有一阵子想省事, 直接开 Windows 自带的远程桌面(mstsc / RDP)连过去,然后就出问题了。

症状很迷惑:RDP 登进去看到的桌面,和向日葵里看到的桌面根本不是同一个。 两边各开各的窗口,互相看不见对方。在 RDP 里开的程序,切回向日葵完全找不到;反过来也一样。 严重的时候 RDP 下 GNOME 干脆直接黑屏,只剩个鼠标能动。

向日葵 mstsc / RDP 物理控制台 GNOME 会话 A 人坐在屏幕前看到的那个 RDP 新开会话 GNOME 会话 B 登录时另起的一个 两个逻辑上独立的桌面在抢同一个用户 —— 同时用必然打架

当时第一反应是「xrdp 有 bug」,换了好几个配置项都没用。后来才想明白, 在这台机器的配置下,两种入口使用了不同的 Linux 桌面会话:

  • 向日葵连的是物理控制台那个会话——就像人坐在显示器前一样, 能看到本地显示器正在展示的桌面。
  • RDP(xrdp)登录时会新开一个会话。在 GNOME 下它并不会附到物理控制台上, 而是另外起了一个「第二个图形界面」。

在当时的 xrdp 配置下,两种方式进入的是不同桌面会话。黑屏可能与会话或显示后端有关,单凭黑屏无法确定具体原因,也不能据此认定两种远程桌面方式一定不能并存。

(当时还以为是显卡驱动的问题,差点去重装 NVIDIA 驱动,还好没动手。)

这么解决的

放弃 RDP,把 xrdp 停掉。桌面这一层只留向日葵(看物理控制台), 其余所有事情一律走 SSH。跑训练、改配置和看日志都可以从这个入口完成。

后面查路由表、抓 DHCPv6 包、调路由器 API,全是在 SSH 里做的。 回头看,桌面这一层早点定下来确实省事,否则网络还没查明白,又要先处理黑屏。

阶段 2 - Tailscale 一直经过中继

桌面总算理清了,接着看连接。那阵子跑 tailscale ping,反复看到的都是这副样子:

5090 · tailscale ping <宿舍 Windows>
pong from a12354768 (100.x.x.x) via DERP(sfo) in 359ms
pong from a12354768 (100.x.x.x) via DERP(sfo) in 350ms
pong from a12354768 (100.x.x.x) via DERP(sfo) in 351ms
direct connection not established

每次回包都从中继绕一圈,最后一行才是重点:直连压根没建起来。

三百多毫秒敲 SSH,打字都能感觉到等待。Tailscale 会尝试建立 UDP 直连,成功后走 P2P, 没建立起来时可以靠 DERP 中继维持连接。现在一直停在中继,得往下查直连卡在哪儿。

失败时

  1. 宿舍 Windows
  2. DERP(sfo) 中继
  3. 机房 5090

现在

  1. 宿舍 Windows
  2. 5090(直连)

netcheck 显示映射随目标变化

tailscale netcheck 会打一组 STUN 探测,报告本机对外的网络特征。5090 这边当时是这样的:

5090 · tailscale netcheck
* UDP: true
* IPv4: yes, 183.63.97.253:27197
* IPv6: no, but OS has support
* MappingVariesByDestIP: true
* PortMapping:
* Nearest DERP: San Francisco

MappingVariesByDestIP: true 是关键。它表示出口 NAT 分配给同一台内网设备的公网端口, 会随目标地址的不同而变化。换句话说,对面看到的端口号不固定——这正是 UDP 打洞最难对付的一类 NAT。 这种变化会增加端点预测和打洞的难度,是否能建立直连还要结合两端网络与实际探测结果判断。

再看 5090 自己的地址:192.168.31.151,而出口路由器的 WAN 侧是 10.215.x.x, 一段私有地址。这确认了一个需要继续排查的条件:5090 前面不止一层 NAT。

5090 · eno1 192.168.31.151 小米路由器 RC01 WAN 侧 10.215.x.x ← 私有地址 校园网 / 运营商 CGNAT 我够不着的那一层 公网 IPv4 UPnP 在这一层开了洞 上游这层没有映射 洞就断在这里

示意图:5090 前面有两层 NAT。UPnP 只能作用于我够得着的那一层。

(10.x 的 WAN 地址不是猜的,是从路由器自己的 NAT-PMP 回包里漏出来的。)

先试了最简单的一招:开 UPnP

出口路由器是台小米万兆路由器(RC01)。我远程登进它的后台,把 UPnP 打开了。 Tailscale 立刻就申请到了映射,路由器后台能直接看到:

路由器后台 · UPnP 端口映射列表
协议   内部端口   外部端口   客户端
UDP    41641      41642      192.168.31.151

5090 的 netcheck 也确认拿到了 PortMapping: UPnP, NAT-PMP, PCP。 然后重新打洞——还是 DERP。

为什么没用

UPnP 只在小米路由器这一层开了个洞。而它自己也在 10.215.x.x 后面, 它向上游申请端口映射时,拿到的仍然是一个内网地址。

也就是说,这个洞只打到了第二层 NAT 的内侧,外面那层压根看不到。 仅修改这台路由器,并没有解决上游地址转换的限制。

阶段 3 - 通过管理 API 配置出口路由器

要往下走就得改路由器配置,而人不在现场,所以只能从 5090 上远程操作它。

这台路由器是我自己网络的出口设备,管理密码本来就有。 麻烦在网页后台:远程、跨代理打开,经常加载不全。于是改成用脚本调用它的 API, 还是用原有管理凭据登录,只是省掉了反复等页面的过程。

先看看网页是怎么登录的

小米路由器的 LuCI 后台有一套自己的登录校验。抓页面 JS 能看出来:密码不是明文传的, 而是客户端先生成一个 nonce,再拿它跟密码一起做两轮 SHA-256。

import hashlib, json, random, time, urllib.request, urllib.parse

BASE = "http://192.168.31.1"
KEY  = "<从页面 JS 里抠出来的 32 位 hex>"
DEVICE_ID = "<本机网卡 MAC>"

def sha256(s):
    return hashlib.sha256(s.encode()).hexdigest()

def login(pwd):
    # nonce 由客户端自生成: 0_<MAC>_<unix时间>_<四位随机>
    nonce = "0_%s_%d_%d" % (DEVICE_ID, int(time.time()), random.randint(0, 9999))
    # 两轮 SHA-256: 先 hash(pwd + KEY), 再连上 nonce 一起 hash
    password = sha256(nonce + sha256(pwd + KEY))
    data = urllib.parse.urlencode({
        "username": "admin",
        "password": password,
        "logtype":  2,
        "nonce":    nonce,
    }).encode()
    r = urllib.request.urlopen(urllib.request.Request(
        BASE + "/cgi-bin/luci/api/xqsystem/login",
        data=data, method="POST"))
    return json.loads(r.read())["token"]

几个当时踩过的点:

  • nonce 是客户端自己生成的,不是服务器下发的。格式 0_<clientMAC>_<unix时间>_<四位随机>, 其中 clientMAC 由路由器注入登录页——从 5090 访问时就是 5090 网卡的 MAC。
  • KEY 是一个完整的 32 位 hex。页面里那个字段很容易只看到一半, 抄漏了后面几位就会一直登录失败。
  • 避免重复使用 nonce。当时连续请求遇到了 Invalid nonce,调试时需要检查生成方式和请求时序。这不是密码绕过方法,调用仍需要已有的管理凭据。

token 应放在请求路径中

401 了一晚上

登录成功拿到 token 之后,我照着直觉拼 ?token=<token> 去调别的接口, 结果全是 {"code":401,"msg":"Invalid token"}。

折腾了一阵才发现,真正的页面用的是路径参数而不是查询参数:

/cgi-bin/luci/;stok=<token>/api/xqnetwork/xxx

那个 ;stok= 是嵌在路径里的分号语法,不是 ?。知道这一点之后, 剩下的接口就都能通了——包括后面要用的 IPv6 配置接口。

阶段 4 - 换 IPv6 试试

回到正题。既然 IPv4 那层的 CGNAT 我够不着,那就换条路——让 Tailscale 走 IPv6。 能不能通,取决于出口路由器能不能从上游拿到 IPv6。而这台 RC01 的固件是支持 IPv6 的, 页面里给了好几种模式:

  • Native —— 原生,直接从上游拿地址
  • DHCPv6 —— 通过 DHCPv6 获取
  • PPPoEv6 —— IPv6 也走 PPPoE 拨号
  • 静态 IPv6、Passthrough(透传)、NAT6

这台机器的上行是校园网,IPv4 走 PPPoE。于是挨个试:

路由器 API · 依次切换 ipv6_mode 后读回的状态
Native       → ipv6_mode: native    up: false
PPPoEv6      → ipv6_mode: pppoev6   up: false
Passthrough  → ipv6_mode: pi_relay  up: false
DHCPv6       → ipv6_mode: dhcpv6    up: true   ip6addr: []

前三个直接起不来。DHCPv6 看起来有戏——up: true 了,但那个空的 ip6addr: [] 说明它其实没拿到任何地址,只是链路活着而已。

(顺带一提,切换模式的瞬间路由器会短暂失去响应,poll 的时候会 No route to host 几下,别慌,等它自己缓过来就行。)

最迷惑的一段:日志里明明有公网前缀

我去把路由器的系统日志下载下来翻了一遍(用它自己的日志导出接口拿压缩包,在 5090 上解开看)。 里头确实出现过公网前缀——上游是发了东西下来的:

路由器 syslog · odhcp6c
RA Evaluate options start
PI length:64, valid:2592000, preferred:604800,
          target: 2001:da8:****:****::
PI update PREFIX:2001:da8:****:****::
RA Evaluate options end

但在路由表里,这个前缀只挂在 WAN 口上,LAN 什么都没有:

路由器 · 路由表快照
2001:da8:****:****::/64 dev eth4    proto static
fd00:6969:6969::1        dev br-lan  proto kernel

而 fd00::/64 属于本地地址范围,不能直接在公网路由。同一份日志还给出了前缀下发的线索:

路由器 syslog · odhcpd
A default route is present but there is no public
prefix on lan thus we don't announce a default route!

翻译过来就是:上游确实发了一个 /64 的前缀信息,但它只落在 WAN 口上, 没有被分配下来给 LAN 用。路由器拿不到能往下发的委托前缀(DHCPv6-PD), 所以 LAN 只能发私有前缀,下面的机器自然也拿不到公网 IPv6。

修好前 上游 RA 下发 /64 WAN 拿到公网前缀 LAN 只有 fd00::/64 前缀没有委托下来,LAN 出不去公网 修好之后 上游 RA 下发 /64 WAN 拿到公网 IPv6 NAT6 转换后出网 LAN 仍走 fd00::,由路由器转出公网

示意图:问题不在选哪个模式,而在前缀给了 WAN、没给 LAN。

(为了确认这一点,还在 5090 上抓了一次包,看到路由器发出的 RA 里确实只有 managed / other stateful 标志,没有 PIO。这支持当次通告未向 LAN 提供相应前缀的判断。)

最后起作用的是 NAT6

真正跑起来的是 DHCPv6 + NAT6 这个组合:路由器自己在 WAN 侧拿到了公网 IPv6, LAN 内部仍然用 fd00::/64,由路由器做 IPv6 的地址转换把内网流量送出去。

打开之后,WAN 上出现了一串 2001:da8:****:**** 的地址, 5090 这边的 netcheck 立刻变成 IPv6: yes, 并且探测到了自己对外呈现的 IPv6 端点。

路由器后台同时能看到此前 UPnP 配置产生的 Tailscale NAT-PMP 映射。 5090 也探测到了 IPv6 出网能力;这些状态仍需与实际连接路径分开确认:

5090 · tailscale netcheck(修好之后)
* IPv6: yes, [2001:da8:****:****:****:****:****:****]:54215
* PortMapping: UPnP, NAT-PMP, PCP
* Nearest DERP: Hong Kong

最近 DERP 从旧金山变成香港,说明首选中继节点发生了变化,但不能单独证明实际流量已经直连。按我当时的记录,连接最终转为 direct;本文未附完整的成功输出,复现时应以 tailscale ping 或 tailscale status 显示的实际端点与连接路径为准。

(这台 IPv6 也是远程开的——人始终没走到路由器跟前,全程是从 5090 上跑脚本改的。)

代价与遗留问题

按当时的记录,最后连上了。不过配置里仍然留着几个妥协,不能看到 direct 就把它们忘了:

  • NAT6 意味着 LAN 设备没有独立的公网 IPv6。 内网还是 fd00::/64,地址转换由路由器做。这是「能出网」,不是「每台设备一个公网地址」。
  • 入站访问仍然受限。LAN 没有独立的公网 IPv6,外部主动访问仍得考虑地址转换和防火墙, 不能把这次 Tailscale 直连等同于所有服务都能直接被访问。如果能拿到 DHCPv6-PD 前缀下发给 LAN,会更符合我原本想要的结构。
  • 当时拿到的只有 WAN 侧地址。 记录里上游给了 WAN 侧的 /64,没拿到委托前缀。再往下是上游不支持、需要额外配置,还是有别的条件,得找网络管理员确认,不能只靠切换路由器模式猜。

若要让 LAN 获得可用的委托前缀,下一步需要向网络管理员确认这条线路是否支持 DHCPv6-PD、是否需要绑定设备或特定 VLAN。 这些上游条件无法仅靠反复切换路由器模式确定。

下次排查时先看哪些状态

  • 先确认远程桌面的会话模式。本次向日葵连接物理控制台,xrdp 使用独立会话。我最终保留一种图形入口,其余操作交给 SSH。
  • MappingVariesByDestIP 值得先看一眼。 true 表示探测到的映射随目标变化,是打洞困难的一条线索。但别只看这一行就宣布没救,还得结合两端网络和实际连接结果。
  • 开 UPnP 只解决你够得着的那一层。 上游还有 CGNAT 时,本地映射没法控制那一层。PortMapping 有值不等于能直连,最终还是要看打洞结果。
  • IPv4 卡住了,可以查查 IPv6。 前提是两端有可用的 IPv6 路径、相关流量也能通过。对这次排查来说,换这条路比继续只盯着 IPv4 端口映射更有用。
  • IPv6 的坑经常在「WAN 有、LAN 没有」。 日志里看到公网前缀不等于通了,得确认它不是只挂在 WAN 口上、真的被发到了 LAN。
  • 网页后台不好用时,可以看看已有的管理 API。 这次用脚本能记录参数、比较结果,也能在 SSH 里完成操作。管理凭据和修改前的配置要留好,能调用接口不等于随便改都能恢复。

这次排查最终收敛到几个具体对象:远程桌面进入的会话、地址经过的 NAT,以及 IPv6 前缀的分配位置。 把它们分别查清楚,才能判断下一步该改本机、路由器,还是向上游确认。

文中的三张配图为示意图,按本次排查的真实结构绘制,非截图。 所有公网前缀、MAC 地址、Tailscale 地址及路由凭据均已脱敏。 「远程登进路由器」一节针对的是本人自有并持有管理凭据的网络设备,做法是用脚本调用其 Web API。

建议不要公开自己正在使用的网络设备的配置与凭据,以防被滥用。

—— 记于机房那台 5090 又跑满了一整晚之后