用 Headscale + Tailscale 自建虚拟组网:让散落在互联网上的上百台机器人「像在同一个局域网」一样调试

关键词:Headscale、Tailscale、WireGuard、DERP、NAT 穿透、P2P、CGNAT、frp、NetBird、机器人远程调试


一、背景:ToDesk 调试机器人,到底难在哪

我们公司在全国各地部署了大量移动机器人 / 工控机(Ubuntu + ROS 2 为主),它们的网络环境是典型的最坏情况

  • 有的插 4G/5G 物联网卡(运营商 CGNAT,没有公网 IP);
  • 有的接客户办公室 Wi-Fi(防火墙严格、只放 80/443);
  • 有的临时用工程师手机热点(双层 NAT);
  • 客户不可能给我们做端口映射,更不可能开 VPN 专线。

早期我们的调试方式是在机器人上装 ToDesk / 向日葵,工程师远程桌面进去操作。痛点非常具体:

痛点说明
只能「看屏幕」本质是像素流,不是网络。想 scp 一个 20MB 的 rosbag 出来、想用本地 Foxglove/RViz 连远端 DDS,做不到
无法多协议SSH、RTSP 摄像头、Web 后台、ROS 2 DDS、gdb 远程调试、Jupyter,全都得手动折腾
无法自动化CI 想批量给 50 台机器人推固件、跑巡检脚本,ToDesk 没法脚本化
机器人没人时进不去无人值守设备、无头(headless)设备体验极差
安全与审计第三方云中转,账号密码满天飞,无法做「谁在什么时候访问了哪台机器」的 ACL 与审计
带宽与画质现场 4G 上行小,桌面流卡顿,调参靠猜

我们真正需要的不是「远程桌面」,而是三层网络的可达性:让办公室里的开发机、CI Runner、工程师笔记本和机器人处在同一个逻辑二/三层网络里,ping 100.64.0.12 就能通,然后所有现成工具(ssh、scp、rsync、curl、ros2 topic list、gdbserver、VS Code Remote)全部原样可用。

这就是虚拟组网 / Overlay Mesh VPN要解决的问题。


二、方案总览:Tailscale 客户端 + 自建 Headscale + 自建 DERP(且不依赖任何域名/DNS)

最终落地的架构非常简单,只需要一台带公网 IP 的小云主机(2C2G 足够)

                    ┌──────────────────────────────────────────┐
                    │        公网云主机 (只有 IP,无域名)        │
                    │                                          │
                    │   Headscale  (控制面 / Coordination)      │
                    │     - 节点注册、公钥分发、NetMap 下发      │
                    │     - ACL 策略、路由审批                   │
                    │                                          │
                    │   Embedded DERP + STUN (中继面)           │
                    │     - udp/3478  STUN 探测反射地址          │
                    │     - tcp/443|8080 DERP 加密中继           │
                    └───────▲──────────────────▲───────────────┘
                            │ 控制信令(长连接)  │ 控制信令
                            │ + DISCO 探测      │ + DISCO 探测
              ┌─────────────┘                  └─────────────┐
              │                                              │
    ┌─────────┴─────────┐                          ┌─────────┴─────────┐
    │  开发机 / 笔记本    │                          │  机器人 A (4G)     │
    │  tailscaled        │                          │  tailscaled        │
    │  100.64.0.2        │◀════ WireGuard P2P ═════▶│  100.64.0.11       │
    └───────────────────┘   直连成功后不经过服务器    └───────────────────┘
                                                     │ --advertise-routes
                                                     ▼
                                              机器人车内网 192.168.8.0/24
                                              (相机/雷达/PLC/交换机)

三个平面(这是理解一切差异的钥匙):

平面组件是否承载业务流量说明
控制面 Control PlaneHeadscale❌ 不承载只交换公钥、端点候选、ACL、路由表(NetMap)
数据面 Data PlaneWireGuard(各节点之间)尽可能 P2P 直连
中继面 Relay PlaneDERP + STUN⚠️ 兜底才用打洞失败时中转已加密的密文

核心思想一句话:控制面负责「介绍双方认识」,数据面是「两人自己私聊」,中继面是「实在联系不上时的传话人,而且传话人听不懂内容」。

2.1 为什么不用官方 Tailscale 云?

  • 控制面 controlplane.tailscale.com 与官方 DERP 节点在国内访问不稳定,握手慢、偶发失败;
  • 上百台机器人的设备数量与计费;
  • 客户合规要求:网络拓扑、设备标识、密钥编排不能出境;
  • 需要自定义 DERP 落在国内机房,兜底链路才可用。

Headscale 是 Tailscale 控制服务器的开源自托管实现(BSD-3),复用官方未修改的 Tailscale 客户端,这点非常关键——机器人端我们用的是官方二进制,稳定性、跨平台(x86_64 / aarch64 / OpenWrt)、长期维护都有保障。


三、技术方案细节:一次连接是怎么建立起来的

3.1 数据面:WireGuard 做了什么、没做什么

WireGuard 本身极其精简:Noise_IK 握手 + ChaCha20-Poly1305 + 一张静态的 AllowedIPs → Peer Endpoint 表。它没有

  • 服务发现
  • NAT 穿透
  • 密钥分发与轮换编排
  • 访问控制策略

也就是说,裸 WireGuard 要求你事先知道对端的 IP:Port。而机器人在 4G 网络下,公网映射地址随时变化,人工维护配置是不可能的。

Headscale/Tailscale 干的活,就是把这张表变成动态的、自动收敛的。

3.2 控制面:NetMap 与端点候选集

节点启动后通过 Noise(ts2021)与 Headscale 建立长连接,周期性/事件驱动地拿到一份 NetMap,其中对每个 peer 包含:

  • WireGuard 公钥、DISCO 公钥
  • overlay 地址(100.64.0.0/10,即 CGNAT 保留段)
  • 端点候选集(Endpoint Candidates)
  • 该 peer 的 home DERP region

候选集由客户端自己收集,大致包括:

C A = { ( i p l a n , p ) } ⏟ 本地网卡   ∪   { ( i p s t u n , p s t u n ) } ⏟ STUN 反射地址   ∪   { ( i p v 6 , p ) } ⏟ IPv6 全局地址   ∪   { ( i p p m p , p p m p ) } ⏟ UPnP/NAT-PMP/PCP 映射 C_A=\underbrace{\{(ip_{lan},p)\}}_{\text{本地网卡}}\ \cup\ \underbrace{\{(ip_{stun},p_{stun})\}}_{\text{STUN 反射地址}}\ \cup\ \underbrace{\{(ip_{v6},p)\}}_{\text{IPv6 全局地址}}\ \cup\ \underbrace{\{(ip_{pmp},p_{pmp})\}}_{\text{UPnP/NAT-PMP/PCP 映射}} CA=本地网卡 {(iplan,p)}  STUN 反射地址 {(ipstun,pstun)}  IPv6 全局地址 {(ipv6,p)}  UPnP/NAT-PMP/PCP 映射 {(ippmp,ppmp)}

3.3 连接建立时序:所有连接都从 DERP 开始

这是很多人误解的点。Tailscale 的路径升级过程是:

t0   A 要访问 B
     ├─ A 立刻通过 DERP 把首包发给 B(连接零等待可用,只是慢)
     └─ 同时通过 DERP 交换 DISCO 消息(各自的候选端点、nonce)
t0+  双方对 |C_A| × |C_B| 条候选路径并行发 DISCO ping("赛马")
t1   某条 UDP 路径 pong 回来 → 记录 RTT
t2   按 RTT 最优选路,WireGuard 流量切到直连 UDP(无缝,不断连)
t∞   后台持续复探;网络变化(切 Wi-Fi、换基站)时重新赛马、重新升级/降级

选路目标函数就是最小 RTT:

path ∗ = arg ⁡ min ⁡ ( a , b ) ∈ C A × C B   R T T ( a , b ) \text{path}^{*}=\arg\min_{(a,b)\in C_A\times C_B}\ \mathrm{RTT}(a,b) path=arg(a,b)CA×CBmin RTT(a,b)

由于三角不等式,直连几乎总优于中继:

R T T relay = R T T A → D + R T T D → B   ≥   R T T A → B = R T T direct \mathrm{RTT}_{\text{relay}}=\mathrm{RTT}_{A\to D}+\mathrm{RTT}_{D\to B}\ \ge\ \mathrm{RTT}_{A\to B}=\mathrm{RTT}_{\text{direct}} RTTrelay=RTTAD+RTTDB  RTTAB=RTTdirect

我们线上实测的典型数字(工程师在杭州办公室 → 机器人在苏州厂区,DERP 在香港):

路径RTTiperf3 吞吐
直连 P2P28 ms46 Mbps(受 4G 上行限制)
DERP 中继71 ms18 Mbps(且服务器出网带宽被瓜分)

tailscale status 一眼看出来当前是哪种

$ tailscale status
100.64.0.11   robot-a   robot   linux   active; direct 113.x.x.x:41641, tx 1.2M rx 8.9M
100.64.0.17   robot-b   robot   linux   active; relay "sh", tx 40k rx 88k
                                                 ↑ 这个就是走中继了

3.4 「没有域名 / 没有 DNS」怎么落地

这是我们方案里最有实操价值的一段。Headscale 官方文档默认假设你有 FQDN + Let’s Encrypt。我们只有一个公网 IP,做法如下。

先澄清一个常见误解:
MagicDNSrobot-a.your-net.ts.net 这种名字)根本不需要公网 DNS。它是 tailscaled 在本机起的 100.100.100.100 解析器,域名与 A 记录由 Headscale 通过 NetMap 直接下发。所以「没有 DNS」完全不影响 MagicDNS 主机名可用。

真正受影响的只有两处:控制面 URL 的证书DERP 的 TLS

方案 A(我们采用):自建 CA,签发 SAN 含 IP 的证书
# 1. 生成 CA
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
  -keyout ca.key -out ca.crt -subj "/CN=RobotNet Internal CA"

# 2. 关键:SAN 必须写 IP,而不是 DNS 名
cat > san.cnf <<'EOF'
[req]
distinguished_name=dn
req_extensions=ext
[dn]
CN=203.0.113.10
[ext]
subjectAltName=IP:203.0.113.10
extendedKeyUsage=serverAuth
EOF

openssl req -newkey rsa:2048 -nodes -keyout hs.key -out hs.csr -config san.cnf
openssl x509 -req -in hs.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out hs.crt -days 3650 -sha256 -extfile san.cnf -extensions ext

Headscale 配置:

# /etc/headscale/config.yaml
server_url: https://203.0.113.10:443
listen_addr: 0.0.0.0:443
metrics_listen_addr: 127.0.0.1:9090

tls_cert_path: /etc/headscale/hs.crt
tls_key_path:  /etc/headscale/hs.key

prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48

derp:
  server:
    enabled: true
    region_id: 900
    region_code: "cn-sh"
    region_name: "Self-hosted Shanghai"
    stun_listen_addr: "0.0.0.0:3478"
    # 显式声明公网地址,显著提升穿透与稳定性
    ipv4: 203.0.113.10
    # ipv6: 2001:db8::10
    automatically_add_embedded_derp_region: true
  # 关掉官方 DERP map,国内访问它只会拖慢握手
  urls: []
  paths: []
  auto_update_enabled: true
  update_frequency: 24h

dns:
  magic_dns: true
  base_domain: robotnet.internal   # 不需要真实注册,纯内部
  nameservers:
    global:
      - 223.5.5.5

然后ca.crt 打进机器人系统镜像的信任库(这一步对我们反而是好事:机器人是我们自己出厂的设备,装 CA 天经地义,而且顺手实现了「只有我们出厂的镜像才能加入网络」的弱绑定):

sudo cp ca.crt /usr/local/share/ca-certificates/robotnet-ca.crt
sudo update-ca-certificates

Windows/macOS 工程师电脑同理导入到系统钥匙串/受信任的根证书颁发机构。
Tailscale 客户端使用系统证书池,装好 CA 后 tailscale up --login-server=https://203.0.113.10 直接通。

方案 B(更省事,但要理解安全边界):控制面走 HTTP
server_url: http://203.0.113.10:8080
listen_addr: 0.0.0.0:8080
tls_cert_path: ""
tls_key_path: ""

为什么这不是灾难?因为 Tailscale 客户端与控制面之间跑的是 ts2021 Noise 协议,在 HTTP 之上还有一层端到端的 Noise 加密与双向密钥认证;HTTPS 在这里主要提供的是「服务端身份的 PKI 锚点」和抗中间人劫持的第一跳信任。:首次注册时你缺少了服务端身份校验,存在理论上的 MITM 劫持注册风险,同时嵌入式 DERP 的 WebSocket 也会退化成明文 WS 承载(内容仍是 WireGuard 密文,但握手元数据暴露)。

结论:内网/PoC 用 B,生产用 A。 我们生产用 A。

顺带避坑:反向代理必须支持 WebSocket

如果你在 Headscale 前面套了 Nginx/Caddy,务必开 WebSocket 升级,否则 DERP 与新版客户端会连不上;同时 udp/3478(STUN)无法被 HTTP 反代,必须直接放行到主机。

location / {
  proxy_pass http://127.0.0.1:8080;
  proxy_http_version 1.1;
  proxy_set_header Upgrade    $http_upgrade;
  proxy_set_header Connection $connection_upgrade;   # 关键
  proxy_set_header Host       $host;
  proxy_set_header X-Real-IP  $remote_addr;
  proxy_buffering off;
  proxy_read_timeout 3600s;
}

3.5 机器人侧的落地细节

批量注册用 PreAuthKey,不要用交互式登录:

# 服务端:给 robots 用户签发一个可复用、90 天有效、自动打 tag 的 key
headscale users create robots
headscale preauthkeys create --user robots --reusable --expiration 90d

机器人首次开机脚本:

tailscale up \
  --login-server=https://203.0.113.10 \
  --authkey="${TS_AUTHKEY}" \
  --hostname="robot-$(cat /etc/robot-sn)" \
  --advertise-tags=tag:robot \
  --advertise-routes=192.168.8.0/24 \
  --accept-routes=false \
  --accept-dns=false \
  --ssh

几个参数的实战理由:

  • --advertise-routes=192.168.8.0/24:把机器人车内网(相机、激光雷达、PLC、下位机)整段暴露进 overlay。这是杀手级特性——那些不能装客户端的嵌入式设备,通过机器人做 Subnet Router 一并可达。服务端还需 headscale nodes approve-routes 审批。
  • --accept-routes=false / --accept-dns=false:机器人是被访问方,绝不接受下发路由和 DNS,避免污染车上原有网络与 ROS 通信。
  • --advertise-tags=tag:robot:机器人不绑「人」的身份,绑设备 tag,密钥永不过期、便于 ACL。
  • --ssh:用 Tailscale SSH 接管认证,工程师不用发 SSH key,且可审计。
  • 无 TUN 设备的极端环境(老内核、受限容器):加 --tun=userspace-networking,配合 tailscale nc / SOCKS5 也能用。

ACL 策略(Headscale 的 policy.json / HuJSON):

{
  "tagOwners": { "tag:robot": ["group:devops"] },
  "groups": {
    "group:dev":    ["alice@", "bob@"],
    "group:devops": ["carol@"]
  },
  "acls": [
    {
      "action": "accept",
      "src": ["group:dev"],
      "dst": ["tag:robot:22", "tag:robot:8080", "tag:robot:554",
              "192.168.8.0/24:80,554"]
    },
    { "action": "accept", "src": ["group:devops"], "dst": ["tag:robot:*"] },
    // 机器人之间互相不可见 —— 横向移动收敛,非常重要
    { "action": "accept", "src": ["tag:robot"], "dst": ["tag:robot:0"] }
  ],
  "ssh": [
    { "action": "check", "src": ["group:dev"], "dst": ["tag:robot"],
      "users": ["root", "robot"], "checkPeriod": "12h" }
  ]
}

默认拒绝 + 白名单 + 机器人之间零可见性,这是 ToDesk 时代根本给不了的东西。

3.6 MTU 与吞吐:一个必须算清的账

WireGuard 在 IPv4/UDP 上的封装开销:

H wg = 20 ⏟ outer IPv4 + 8 ⏟ UDP + 16 ⏟ WG header + 16 ⏟ Poly1305 tag = 60  bytes H_{\text{wg}}=\underbrace{20}_{\text{outer IPv4}}+\underbrace{8}_{\text{UDP}}+\underbrace{16}_{\text{WG header}}+\underbrace{16}_{\text{Poly1305 tag}}=60\ \text{bytes} Hwg=outer IPv4 20+UDP 8+WG header 16+Poly1305 tag 16=60 bytes

Tailscale 出于跨 4G/隧道/PPPoE 的保守考虑,默认 overlay MTU 取 1280。有效载荷效率:

η = L L + H wg \eta=\frac{L}{L+H_{\text{wg}}} η=L+HwgL

  • 大包 L = 1280 L=1280 L=1280 η ≈ 95.5 % \eta \approx 95.5\% η95.5%
  • 小包(ROS 2 DDS 心跳、SSH 交互) L = 64 L=64 L=64 η ≈ 51.6 % \eta \approx 51.6\% η51.6%

工程含义:跑大文件(rosbag 回传)几乎无损;但如果你在 overlay 上跑高频小包(比如 1kHz 的控制回路、DDS 大量 discovery 报文),开销占比会飙升,CPU 中断和 pps 才是瓶颈,不是带宽。所以我们的原则是:

overlay 只用于调试、观测、升级、文件传输;实时控制闭环永远留在车内网。

另外记得处理 MSS clamping,避免 PMTU 黑洞导致「ssh 能连上但 ls -R 卡死」的经典现象:

iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --clamp-mss-to-pmtu

四、NAT 穿透原理:什么时候能 P2P,什么时候不能

4.1 NAT 的两个正交维度(RFC 4787)

很多人只知道「对称/非对称 NAT」,其实要拆成两个独立维度:

① 映射行为 Mapping Behavior(决定「外部看到我的地址是否稳定」)

设内部端点 ( i p i , p i ) (ip_i,p_i) (ipi,pi) 发往外部 ( i p d , p d ) (ip_d,p_d) (ipd,pd),NAT 分配外部映射 M M M

  • EIM(Endpoint-Independent Mapping,端点无关)
    M = f ( i p i , p i ) M=f(ip_i,p_i) M=f(ipi,pi)
    无论发给谁,外部映射相同。这是能打洞的前提。 常见于家用路由器(俗称 Full-cone / Restricted cone)。
  • ADM(Address-Dependent Mapping)
    M = f ( i p i , p i , i p d ) M=f(ip_i,p_i,ip_d) M=f(ipi,pi,ipd)
  • APDM(Address-and-Port-Dependent Mapping,即对称 NAT
    M = f ( i p i , p i , i p d , p d ) M=f(ip_i,p_i,ip_d,p_d) M=f(ipi,pi,ipd,pd)
    发给不同目标就是不同外部端口 → STUN 探测到的端口对 peer 毫无意义

② 过滤行为 Filtering Behavior(决定「谁能给我回包」)

  • EIF(Endpoint-Independent Filtering):映射建立后任何人都能进
  • ADF:只有你发过包的那个 IP 能进
  • APDF(Address-and-Port-Dependent Filtering):只有你发过包的那个 IP:Port 能进(最严)

4.2 打洞可行性判据

同时打洞(simultaneous open)的本质:双方同时向对方的预测端点发 UDP 包,各自的 NAT 因为看到「出方向流量」而建立映射并放行反向包。

用逻辑式表达直连可行条件(近似):

D i r e c t ( A , B )   ⟺   ( E I M ( A )   ∨   E I M ( B ) )   ∧   ¬ ( A P D M ( A ) ∧ A P D M ( B ) ) \mathrm{Direct}(A,B)\ \Longleftrightarrow\ \big(\mathrm{EIM}(A)\ \lor\ \mathrm{EIM}(B)\big)\ \land\ \neg\big(\mathrm{APDM}(A)\land \mathrm{APDM}(B)\big) Direct(A,B)  (EIM(A)  EIM(B))  ¬(APDM(A)APDM(B))

直观理解:只要有一方的外部端点是可预测的,另一方就能把包打到那里,从而完成同时开放。两边都是对称 NAT(APDM)时,双方都不知道该往哪个端口打 → 常规打洞失败。

再叠加时间维度:NAT 映射有生存期 T n a t T_{nat} Tnat,双方探测必须落在同一个时间窗内:

∣ t A − t B ∣ < min ⁡ ( T n a t A ,   T n a t B ) |t_A-t_B|<\min\big(T^{A}_{nat},\,T^{B}_{nat}\big) tAtB<min(TnatA,TnatB)

这正是 DERP 侧信道的价值:它让控制面能协调双方「数三下一起发」,把 ∣ t A − t B ∣ |t_A-t_B| tAtB 压到毫秒级。

4.3 对称 NAT 下的端口预测:生日悖论

如果非要在双边对称 NAT 下硬打,唯一办法是暴力猜端口。假设 NAT 从 N N N 个端口中随机分配,双方各发 k k k 个探测包,则至少命中一次的概率(生日悖论形式):

P hit = 1 − ( 1 − 1 N ) k 2 ≈ 1 − e − k 2 / N P_{\text{hit}}=1-\left(1-\frac{1}{N}\right)^{k^{2}}\approx 1-e^{-k^{2}/N} Phit=1(1N1)k21ek2/N

N = 64512 N=64512 N=64512(49152–65535 之外的可用高位端口范围量级):

每侧探测包数 k k k命中概率 P hit P_{\text{hit}} Phit
64 ≈ 6.1 % \approx 6.1\% 6.1%
128 ≈ 22.3 % \approx 22.3\% 22.3%
256 ≈ 63.9 % \approx 63.9\% 63.9%
400 ≈ 91.7 % \approx 91.7\% 91.7%

这就是为什么「暴力打洞」在实验室能演示、在生产不可靠:它需要发出 k 2 k^2 k2 量级的探测流量( k = 256 k=256 k=256 时约 6.5 万次尝试),在 4G 网络上会触发运营商限速甚至封端,且映射还在不断老化。所以 Tailscale 的策略是理性的:有限次赛马,失败就走 DERP,并在后台持续复探等网络变好。 官方内部指标显示典型环境下直连成功率 > 90 % >90\% >90%


五、为什么「手机热点」几乎一定要走中继?

这是团队最常问的问题。答案是多个不利因素在手机热点场景下同时叠加

5.1 双层甚至三层 NAT

机器人  192.168.43.x
   │  ① 手机 Tethering NAT(Android/iOS 自己实现的 NAT,通常 APDM 且端口随机)
   ▼
手机   10.x.x.x / 100.64.x.x  (运营商分配的私有地址)
   │  ② 运营商 CGNAT(LSN,共享公网 IP,NAT44)
   ▼
公网   111.x.x.x:随机端口     (这个端口甚至不是固定的)
   │  ③ 部分省份还有 NAT 池 / 多出口
   ▼
Internet

有效外部映射是多层复合函数:

M eff = ( g CGNAT ∘ g tether ) ( i p i , p i , i p d , p d ) M_{\text{eff}}=\big(g_{\text{CGNAT}}\circ g_{\text{tether}}\big)(ip_i,p_i,ip_d,p_d) Meff=(gCGNATgtether)(ipi,pi,ipd,pd)

只要链路上任意一层是 APDM,整个复合映射就是 APDM。 手机热点这两层通常是 APDM。于是 4.2 节的判据直接判死:

A P D M ( A ) ∧ A P D M ( B )   ⇒   ¬ D i r e c t ( A , B ) \mathrm{APDM}(A)\land \mathrm{APDM}(B)\ \Rightarrow\ \neg\mathrm{Direct}(A,B) APDM(A)APDM(B)  ¬Direct(A,B)

5.2 STUN 探测到的地址「拿到手就过期/失效」

STUN 的工作原理是:客户端把包发给 STUN 服务器,服务器把「我看到你来自 X:Y」告诉你。但在 APDM 下:

M ( … , i p STUN , 3478 ) ⏟ STUN 告诉你的   ≠   M ( … , i p B , p B ) ⏟ 对端实际需要的 \underbrace{M(\dots,ip_{\text{STUN}},3478)}_{\text{STUN 告诉你的}}\ \neq\ \underbrace{M(\dots,ip_{B},p_{B})}_{\text{对端实际需要的}} STUN 告诉你的 M(,ipSTUN,3478) = 对端实际需要的 M(,ipB,pB)

这不是「探测不到」,而是「探测到了但那是给 STUN 服务器专用的门,peer 走不了」。 所以你会看到 tailscale netcheck 输出:

$ tailscale netcheck
Report:
  * UDP: true
  * IPv4: yes, 111.x.x.x:38271
  * IPv6: no, but OS has support
  * MappingVariesByDestIP: true      ← 罪魁祸首:APDM / 对称 NAT
  * PortMapping:                     ← UPnP/PMP/PCP 全都没有
  * Nearest DERP: Self-hosted Shanghai
  * DERP latency: cn-sh: 31.2ms

MappingVariesByDestIP: true 基本就是「今天你注定走中继」的判决书。

5.3 端口映射协议全线失效

在家用路由器上,Tailscale 还能通过 UPnP IGD / NAT-PMP / PCP 主动向路由器申请一个稳定的外部端口,把 APDM 变成事实上的 EIM。但:

  • 手机热点不实现 UPnP/NAT-PMP 服务;
  • 运营商 CGNAT 绝对不可能让你申请端口映射(你连它的管理面都碰不到)。

所以「手动做端口转发」这条 Plan B 也没了。这正是 CGNAT 时代的核心困境。

5.4 IPv6 在 Tethering 下也常被削弱

前面说 IPv6 是救星,但手机热点场景下经常失效:

  • 部分 Android/iOS 的热点对下游设备做 NAT66 或只下发 ULA,不做前缀委派(Prefix Delegation),机器人拿不到 GUA;
  • 有的机型热点干脆只提供 IPv4;
  • 运营商侧 v6 入向策略不一致。

5.5 移动网络的 NAT 映射老化窗口极短 + 手机省电

移动核心网的 UDP NAT 映射空闲超时常低至 30 s,甚至有 15 s 的实测案例。维持一条洞需要:

T keepalive < T n a t min ⁡ T_{\text{keepalive}}<T^{\min}_{nat} Tkeepalive<Tnatmin

WireGuard 的 persistent-keepalive 与 Tailscale DISCO 心跳大约 25  s 25\ \text{s} 25 s 量级,本就贴着红线;再叠加手机的 Doze/省电模式对后台 UDP 的抑制、基站切换导致 NAT 表项迁移(外部 IP 直接变了),即使侥幸打通也会频繁掉洞、反复降级到中继

5.6 实践结论与应对

场景建议
手机热点调试接受走中继,把 DERP 部署在离机器人近的国内机房;DERP 只中转密文,安全性不受影响
长期驻场机器人优先争取有线/Wi-Fi + 公网 IPv6,或用支持 IPv6 的 4G 路由器代替手机热点
需要大带宽(回传 rosbag)把现场一台有较好网络的工控机设为 Subnet Router / Peer Relay(Tailscale ≥1.86 支持把自己网内节点当高吞吐中继),避免打满云主机带宽
排障口诀tailscale netcheckMappingVariesByDestIPPortMapping,再 tailscale ping <peer> 看它是否从 via DERP 升级成 direct
$ tailscale ping robot-a
pong from robot-a (100.64.0.11) via DERP(cn-sh) in 68ms
pong from robot-a (100.64.0.11) via DERP(cn-sh) in 64ms
pong from robot-a (100.64.0.11) via 113.x.x.x:41641 in 27ms   ← 升级成功

六、和 frp / frpc 的区别:不是同一层的东西

我们最早就是用 frp 做的,所以这块体会很深。根本差异:frp 是「服务暴露」(Service Exposure),Headscale/Tailscale 是「网络组建」(Network Overlay)。

6.1 架构对比

frp(隧道/反向代理模型):所有流量都必须经过 frps
   工程师 ──TCP──▶ frps(公网VPS):6001 ──隧道──▶ frpc(机器人) ──▶ 127.0.0.1:22
                    ▲ 永久瓶颈 + 单点 + 带宽账单

Headscale/Tailscale(Overlay Mesh 模型):服务器只在控制面
   工程师 ══════ WireGuard 直连(90%+)══════▶ 机器人
      └── 控制面信令 ──▶ Headscale ◀── 控制面信令 ──┘
          (打洞失败时才把 DERP 拉进数据面)

6.2 复杂度的数量级差异

frp 需要为每台设备的每个端口手工分配一个服务端端口并写配置:

C frp = ∑ i = 1 N m i ( 隧道条目数,且服务端端口必须全局唯一 ) C_{\text{frp}}=\sum_{i=1}^{N} m_i\quad(\text{隧道条目数,且服务端端口必须全局唯一}) Cfrp=i=1Nmi(隧道条目数,且服务端端口必须全局唯一)

我们 137 台机器人,每台至少要 SSH(22)、Web(8080)、RTSP(554)、DDS 若干、gdbserver、Jupyter…… 取 m i = 6 m_i=6 mi=6,就是 822 条隧道配置 + 822 个服务端端口。而且端口和设备的映射关系要靠一个 Excel 台账维护,换机器就得改配置、重启 frps。

Overlay 方案:

C mesh = N ( 每台设备一次 enroll,之后寻址靠固定 overlay IP ) C_{\text{mesh}}=N\quad(\text{每台设备一次 enroll,之后寻址靠固定 overlay IP}) Cmesh=N(每台设备一次 enroll,之后寻址靠固定 overlay IP)

且逻辑可达关系是全网状的 ( N 2 ) = N ( N − 1 ) 2 \binom{N}{2}=\frac{N(N-1)}{2} (2N)=2N(N1) 条,但按需惰性建立(lazy connection),不会一开机就建 137 条隧道。

6.3 带宽与成本

frps 必须承载所有业务流量的双向

B frps = ∑ i = 1 N ( b i ↑ + b i ↓ ) B_{\text{frps}}=\sum_{i=1}^{N}\big(b^{\uparrow}_i+b^{\downarrow}_i\big) Bfrps=i=1N(bi+bi)

3 个工程师各回传一个 500MB rosbag,云主机 5 Mbps 带宽就直接跪了。而 Headscale 控制面的流量是常数级心跳 + NetMap 增量,我们 137 台设备的控制面日均出网流量 < 200 MB。DERP 只在少数场景介入。

B headscale ≈ N ⋅ b heartbeat ≪ B frps B_{\text{headscale}}\approx N\cdot b_{\text{heartbeat}} \ll B_{\text{frps}} BheadscaleNbheartbeatBfrps

6.4 能力对比表

维度frpHeadscale + Tailscale
抽象层次L4/L7 端口与请求转发L3 虚拟三层网络
数据路径恒经服务器P2P 直连优先,中继兜底
P2P 支持xtcp(STUN 打洞),但需手动为每对配置、无自动升级/降级、跨平台与稳定性一般内建、自动、透明、持续复探
配置规模 O ( ∑ m i ) O(\sum m_i) O(mi),端口台账地狱 O ( N ) O(N) O(N),固定 overlay IP
双向可达单向(只能「外→内」访问已暴露端口)双向,机器人也能主动上报/推数据
UDP / 组播支持 UDP 但每端口一条;DDS/组播基本不可用原生 IP,UDP 正常;组播需 Subnet Router 侧处理
ROS 2 / DDS❌ 几乎不可行(动态多端口、双向、需对端可路由 IP)ROS_DOMAIN_ID + 单播 discovery 可用
整段内网可达❌ 需为每个设备再套一层--advertise-routes 一行
加密需自行开启 TLS/transport.tls默认端到端 WireGuard,中继看不到明文
身份与 ACLtoken/端口级,粒度粗身份/Tag/端口级 ACL + SSH 审计
IP 漫游机器人换网要重连隧道无缝,路径自动重新赛马
适用场景给不能装客户端的第三方暴露某个 HTTP 服务自有设备舰队的运维与调试

我们现在的结论:frp 没有被淘汰,它被降级为一个专用工具——只用来把某个内部 Web 后台临时开放给不方便装客户端的客户,其余全部走 overlay。


七、和 NetBird 的区别:同一层,不同哲学

NetBird 和 Headscale 解决的是同一个问题(自托管 WireGuard Mesh + 中心化控制面),我们也做过一轮 PoC,最后选了 Headscale。差异如下。

7.1 架构差异

Headscale + TailscaleNetBird
客户端官方 Tailscale 客户端(BSD-3,未修改)自研客户端 netbird(BSD-3)
控制面Headscale(复刻 Tailscale 协议,BSD-3)management(AGPLv3 since 0.53)
信令复用 DERP 长连接传 DISCO独立 signal 服务(gRPC)
穿透栈自研 DISCO + STUN + 端点赛马标准 ICE(基于 pion) + STUN
中继DERP(HTTPS/WSS over 443)自研 relay(WebSocket / QUIC,自动选优)+ 兼容 coturn/TURN
WireGuard 实现userspacewireguard-goLinux 默认 内核态,也支持 userspace
管理界面无官方 UI(CLI + YAML),社区有 headscale-ui / Headplane官方 Web Dashboard,开箱即用
身份Tag / User / PreAuthKey深度 OIDC/SAML 集成(自带 Zitadel),SSO+MFA
部署组件数1 个二进制 + 1 个 SQLite/PGmanagement + signal + relay + dashboard + IdP(多容器)
组织模型单 tailnet多账户/多网络

7.2 为什么我们选了 Headscale(结合「机器人 + 无域名」这个具体约束)

  1. 客户端就是官方客户端,这在嵌入式场景是决定性优势。
    我们要把它塞进 Jetson(aarch64)、x86 工控机、甚至几台 OpenWrt 网关的出厂镜像里。Tailscale 官方提供全平台静态二进制、OpenWrt/Alpine 包、Docker 镜像、以及 --tun=userspace-networking(无 TUN 环境救命选项)。机器人固件五年不一定升级,我押注生态更大、更新更勤、被验证最多的那个客户端。

  2. 部署面积小 = 故障面积小。
    Headscale 是一个 Go 二进制 + 一个数据库文件,DERP 和 STUN 都内嵌。整个控制面的备份就是 cp headscale.sqlite。NetBird 完整自托管要跑 management / signal / relay / dashboard 4 个服务,标准姿势还要一个 Zitadel 做 IdP——在「只有一个 IP、没有域名」的约束下,多服务多端口的 TLS 与回调地址配置痛苦度指数上升(OIDC 回调对纯 IP 尤其不友好)。

  3. 无域名友好度。 见第 3.4 节,Headscale 只有 1 处证书要处理(server_url + 内嵌 DERP 复用同一监听)。NetBird 的 dashboard/OIDC/relay 各自都要可信 URL。

  4. CLI/GitOps 优先。 我们的 ACL 是 HuJSON 文件进 Git、CI 里跑测试、headscale policy set 下发,天然符合基础设施即代码。NetBird 是 UI 优先(虽然有 API),对我们反而是负担。

7.3 什么情况下我会选 NetBird

诚实地讲,NetBird 在这些点上更强,如果你的场景匹配,应该选它:

  • 要吞吐:Linux 内核态 WireGuard 相比 userspace 少了上下文切换和内存拷贝,单流吞吐优势明显(本文第三方评测中两者在数据中心间都能跑到 1.2–1.4 Gbps,但 NetBird 在同等 CPU 下更省)。如果你要在 overlay 上传大量视频流,这很重要。
  • 要 Web UI 和 SSO:需要把访问权限交给非工程师同事管理、要 SSO/MFA/审计报表、要「策略可视化图谱」。
  • 要全栈开源且被审计:Headscale 是对 Tailscale 闭源控制面协议的第三方复刻,理论上存在被上游协议变更打破的风险(历史上确实出现过客户端新版本与旧 Headscale 不兼容,必须锁定客户端版本区间)。NetBird 客户端与服务端同源同版本,无此风险。
  • 多租户:Headscale 是单 tailnet 设计;要给多个客户各自隔离的网络,NetBird 更合适。

7.4 一个必须提前建立的运维纪律

无论选哪个,客户端版本必须锁定。我们的做法:

# 机器人镜像里固定版本,并禁用自动升级
apt-mark hold tailscale
tailscale version   # 1.8x.y,与 Headscale vX.Y 的兼容矩阵登记在内部 Wiki

Headscale 官方文档明确列出了每个版本支持的 Tailscale 客户端范围,升级顺序永远是:先升 Headscale,验证,再分批升客户端(灰度 5 台 → 20 台 → 全量)。踩过一次「客户端自动更新后全体掉线」的坑就懂了。



八、踩坑清单

  1. udp/3478 必须在云主机安全组放行,否则 STUN 全废,直连率暴跌到接近 0,而现象只是「有点慢」,极难察觉。
  2. 反向代理不开 WebSocket → DERP 与新客户端连不上,日志报 derphttp: connect: unexpected status 400
  3. 忘记 headscale nodes approve-routes--advertise-routes 声明了但整段内网就是不通。
  4. --accept-routes=true 打在机器人上 → 机器人把办公室路由吸进来,车内网 IP 冲突,现场直接失联(我们真炸过一台,要人跑现场)。
  5. 100.64.0.0/10 与运营商 CGNAT 撞段:Tailscale 选这个段本身是为了少撞家用 192.168/10.x,但它正好和 4G CGNAT 同段。若机器人的 WAN 地址是 100.64.x.x,务必检查路由优先级,必要时用 prefixes.v4 换到 100.100.0.0/16 之外的其它子段并配合策略路由。
  6. 自签 CA 的有效期与时间同步:机器人断电久了 RTC 漂移,证书「尚未生效」导致 enroll 失败。开机脚本先 chronyd -q 对时再 tailscale up
  7. MTU 黑洞:能 ssh 上但 git clone / scp 卡死 → 上 MSS clamping。
  8. DERP 单点:DERP 挂了,已建立的直连不受影响,但新连接和掉洞重连全废。至少部署两个 region(不同机房),写进 derp.yaml
  9. NAT 类型自检脚本化:把 tailscale netcheck --format=json 采集进监控,把 MappingVariesByDestIPdirect/relay 比例做成看板,比工程师口头反馈「今天有点慢」有用。

久、总结

  • Overlay Mesh VPN 与反向代理不是竞品,是不同抽象层:frp 暴露「服务」,Headscale/Tailscale 组建「网络」。机器人舰队运维要的是后者。
  • 一句话记住控制面/数据面分离:Headscale 只做介绍人, ∼ 90 % \sim 90\% 90% 的流量根本不经过你的服务器,所以一台 2C2G 小云主机能撑起上百台设备。
  • 能否 P2P 的判据是 NAT 的映射行为,而不是「有没有公网 IP」
    D i r e c t ( A , B ) ⟺ ( E I M ( A ) ∨ E I M ( B ) ) ∧ ¬ ( A P D M ( A ) ∧ A P D M ( B ) ) \mathrm{Direct}(A,B)\Longleftrightarrow\big(\mathrm{EIM}(A)\lor\mathrm{EIM}(B)\big)\land\neg\big(\mathrm{APDM}(A)\land\mathrm{APDM}(B)\big) Direct(A,B)(EIM(A)EIM(B))¬(APDM(A)APDM(B))
  • 手机热点打不通是必然,不是 bug:Tethering NAT ⊕ 运营商 CGNAT 双层 APDM + 无 UPnP + 30s 短映射老化 + 省电抑制,五个因素叠加,直接命中判据的否定分支。接受中继,并把 DERP 放到离设备近的机房。
  • 提升直连率的最高性价比动作是打通 IPv6,而不是折腾打洞参数。
  • 没有域名完全可以做生产:自建 CA + SAN 含 IP 的证书 + CA 预置进出厂镜像,同时 MagicDNS 本来就不依赖公网 DNS。
  • Headscale vs NetBird:要「官方客户端 + 极简部署 + GitOps」选 Headscale;要「内核态吞吐 + Web UI + SSO + 多租户 + 全栈同源开源」选 NetBird。

参考资料

  • Tailscale, How NAT traversal works — https://tailscale.com/blog/how-nat-traversal-works
  • Tailscale, How Tailscale is improving NAT traversal (part 1) — https://tailscale.com/blog/nat-traversal-improvements-pt-1
  • Tailscale Docs, DERP servers / Peer Relays — https://tailscale.com/kb/1232/derp-servers ・ https://tailscale.com/docs/features/peer-relay
  • Headscale Docs, DERP / Reverse proxy — https://headscale.net/stable/ref/derp/
  • RFC 4787(NAT UDP 行为要求)、RFC 5389(STUN)、RFC 6598(100.64.0.0/10
  • NetBird 官方文档与架构说明 — https://netbird.io

Learn more:

  1. Solving the NAT Traversal Nightmare
  2. Tailscale Peer Relays · Tailscale Docs
  3. How Tailscale is improving NAT traversal (part 1)
  4. DERP servers · Tailscale Docs
  5. wikipedia.org
  6. How NAT traversal works
  7. NetBird vs Tailscale vs WireGuard: Compared
  8. Cloudflare Mesh vs NetBird vs Tailscale
  9. NetBird vs Headscale vs Tailscale
  10. NetBird vs Tailscale vs Headscale (2026): A Mesh VPN Comparison for Private Cloud Access
  11. What Is Headscale? Self-hosted Tailscale
  12. Tailscale vs. NetBird
  13. headscale/docs/reverse-proxy.md at v0.22.3 · juanfont/headscale · GitHub
  14. Reverse proxy
  15. Reverse proxy
  16. Reverse proxy
  17. Headscale
  18. headscale/docs/ref/derp.md at main · juanfont/headscale · GitHub
  19. The Ultimate Guide to Remote Access 2026: Tailscale vs. Cloudflare vs. Netmaker vs. FRP & More
  20. Reverse Proxy vs WireGuard vs Tailscale – Zima Store Online
  21. Tailscale + Reverse Proxy vs VPN-Only Access – Zima Store Online
  22. Cloudflare Mesh vs NetBird vs Tailscale
  23. Cloudflare WARP vs Tailscale vs. Pangolin vs. Headscale — practical comparison (who/where to use, where not to) · GitHub
  24. Tailscale vs Reverse Proxy vs VPN: How Should You Access Self-Hosted Services Remotely?
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐