Tailscale vs ZeroTier:内网组网选型与实战

温馨提醒
总结摘要
从 ZeroTier 迁移到 Headscale 的完整历程,包括选型分析、架构设计、TLS 证书配置、端口被占用的替代方案、阿里云 CGNAT 地址段冲突与 netfilter-mode 修复,以及多次踩坑的实战经验。
从 ZeroTier 迁移到 Headscale 的完整历程,踩坑记录、端口配置、TLS 证书、子网路由等实战经验。

Tailscale/Headscale vs ZeroTier:我的组网方案选型与实战

从 ZeroTier 迁移到 Headscale 的完整历程,踩坑记录、端口配置、TLS 证书、子网路由等实战经验。


为什么选择自建 Headscale 而不是 Tailscale 官方 SaaS

首先要弄清楚一个常见的误解:Tailscale 和 Headscale 不是两个竞品,Headscale 是 Tailscale 控制面的开源替代。

Tailscale 的问题

Tailscale 官方的 SaaS 模式确实方便——下载客户端、一键登录、自动组网。但用了几个月后,几个痛点逐渐暴露:

  1. 免费版限制 3 个用户:个人使用够,但一旦想给家人、朋友、其他设备分配独立账号,立刻就超了。付费版 $6/user/month,10 台设备就是 $60/月,性价比极低

  2. 控制面在别人手里:你的网络拓扑、ACL 策略、DNS 配置、所有节点的连接元数据,全部经过 Tailscale 的协调服务器。虽然 Tailscale 声称不看你流量(WireGuard 端到端加密),但元数据的归属权不是你

  3. DERP 中继不可控:当 NAT 打洞失败时,流量会经过 Tailscale 官方的 DERP 中继服务器。中继节点遍布全球,但延迟不可控、带宽不保证,高峰时段可能很慢

  4. 没有数据主权:合规要求、数据审计、内网完全隔离——这些在 SaaS 模式下都做不到

为什么 Headscale 解决了这些问题

引用自 VPNSmith 的实践总结

“The promise is precise: 90% of Tailscale Premium’s value, for ~5% of the cash cost, with 100% data sovereignty.”

Headscale 提供 Tailscale 高级版 90% 的功能,大约 5% 的现金成本,以及 100% 的数据主权。

维度Tailscale SaaSHeadscale 自建
用户数限制免费 3 人,$6/人/月无限制
控制面归属Tailscale Inc.你自己
元数据隐私经 Tailscale 服务器完全本地
DERP 中继官方节点,延迟不可控自建 DERP,延迟可控
数据主权100%
合规/审计依赖第三方完全自主
客户端完全相同的 Tailscale 客户端完全相同的 Tailscale 客户端

最重要的是:客户端用的一模一样。你的手机、电脑、服务器装的还是官方 Tailscale 客户端,只是把 --login-server 指向你自己的 Headscale 服务器。用户体验完全一致,但控制面 100% 归你。


为什么不是 ZeroTier

ZeroTier 我也用了一年多,功能上其实没问题,但有几个决定性的痛点促使我迁移:

真正的痛点

1. 协议栈太重,性能不如 WireGuard

ZeroTier 用的是自研协议(Layer 2 虚拟以太网),协议栈复杂,封装开销大。而 Tailscale 基于 WireGuard(Layer 3),是 Linux 内核主线支持的协议,极简极快:

WireGuard 核心代码不到 4000 行,而 ZeroTier 的核心协议实现超过 10 万行。简单意味着更少的 bug、更快的审计、更低的延迟。

实际测试中,同样的两台阿里云服务器之间,Tailscale 的吞吐量比 ZeroTier 高约 30-40%。

2. 没有 MagicDNS 等价物

ZeroTier 没有内置 DNS。你需要在每台机器上维护 /etc/hosts 或者自己搭 DNS 服务器。而 Tailscale 的 MagicDNS 让每个节点自动获得 <hostname>.<base_domain> 的域名,零配置:

1
2
3
4
5
# ZeroTier 的做法
$ ssh root@10.147.17.23    # 你得记住这个 IP

# Tailscale 的做法
$ ssh root@myserver.example.net    # 自动解析,不用记 IP

3. Exit Node 支持原始且复杂

ZeroTier 要做"出口网关"需要在路由表上手动配置,而且不支持一键开关。Tailscale 的 Exit Node 是原生功能:tailscale set --exit-node=<node> 一键切换,手机上也能用。

4. ACL 策略不如 Tailscale 灵活

ZeroTier 的规则引擎是自研语法,学习成本高。Tailscale 的 ACL 是类 JSON 的声明式语法,配合 tagged nodes、autoApprovers 等机制,更直观:

1
2
3
4
5
{
  "acls": [
    {"action": "accept", "src": ["tag:server"], "dst": ["tag:db:*"]}
  ]
}

5. 自建控制面成本高

ZeroTier 可以自建(通过 moons + roots),但需要同时维护多个组件,且官方文档对自建一直不太友好。Headscale 一个二进制/Docker 容器搞定全部。


部署架构

服务器

  • 云服务器:公网 IP xxx.xxx.xxx.xxx
  • 容器化部署:Docker
  • 组件
    • headscale/headscale v0.29.2 — 控制面核心
    • headplane — Web 管理面板(可选,仅通过 Tailscale 私网访问)

网络拓扑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
公网 (xxx.xxx.xxx.xxx)
  ├─ 10443/TCP  ── headscale API + DERP 中继
  ├─ 3478/UDP   ── STUN (NAT 穿透)
  └─ Tailnet (100.80.0.0/16)   ← 自定义子网,方便记忆
       ├─ 100.80.0.1  iStoreOS (家)
       ├─ 100.80.0.4  云服务器-A
       └─ 100.80.0.5  云服务器-B
            └─ 子网路由 192.168.1.0/24 → iStoreOS 家里的局域网

Docker Compose 部署

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# docker-compose.yaml
services:
  headscale:
    image: headscale/headscale:latest
    container_name: headscale
    restart: unless-stopped
    volumes:
      - ./data/headscale:/var/lib/headscale
    ports:
      - "10443:10443"    # HTTPS API + DERP 中继
      - "3478:3478/udp"  # STUN
    command: serve

  headplane:
    image: ghcr.io/tale/headplane:latest
    container_name: headplane
    restart: unless-stopped
    volumes:
      - ./data/headplane:/etc/headplane
    ports:
      - "127.0.0.1:3000:3000"  # 仅本地访问,或通过 Tailscale 私网
    depends_on:
      - headscale

⚠️ 官方不支持容器化部署( headscale 官方声明 ),但社区 Docker 镜像 headscale/headscale 维护良好,实测可用。

首次启动步骤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 1. 创建目录结构
mkdir -p data/headscale/tls data/headplane

# 2. 编写 config.yaml(见第八节配置对比)并放到 data/headscale/config/

# 3. 放置 TLS 证书到 data/headscale/tls/

# 4. 启动
docker compose up -d

# 5. 创建用户
docker exec headscale headscale users create <username>

# 6. 生成 Headplane API key
docker exec headscale headscale apikeys create

# 7. 编写 data/headplane/config.yaml(见 Headplane 配置章节)

# 8. 重启 headplane
docker compose restart headplane

节点加入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Linux 客户端
tailscale up --login-server=https://headscale.example.com:10443

# 在 headscale 上注册(通过 Headplane 面板或 CLI)
# 方式一:Headplane Web 面板 → Devices → Approve
# 方式二:CLI
docker exec headscale headscale nodes list
docker exec headscale headscale nodes register --user <username> --key <nodekey>

# 使用预授权密钥(推荐,无需手动批准)
docker exec headscale headscale preauthkeys create --user <username> --reusable
# 客户端:
tailscale up --login-server=https://headscale.example.com:10443 --auth-key=<key>

Headplane 管理面板

Headplane 是 Headscale 的第三方 Web UI。这里记录关键的配置和踩坑。

配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# data/headplane/config.yaml
headscale:
  url: http://headscale:8080      # 容器内通过 Docker 网络访问
  api_key: <your-api-key>         # 由 headscale apikeys create 生成

server:
  host: 0.0.0.0
  port: 3000
  cookie_secret: <random-32-char>   # 随机字符串,openssl rand -hex 16

  disable_api_key_login: false      # 允许 API key 登录

生成 API Key

1
2
docker exec headscale headscale apikeys create
# 输出类似: hskey-api-PR3zh3Os-Bm7-...

踩坑:空白页面

现象:访问 http://headscale.example.com:3000 显示空白,日志报错 <Router basename="/admin/"> is not able to match the URL "/"

原因:Headplane 的 frontend 路由在 /admin/ 前缀下,直接访问根路径 / 会 404。

解决:访问 http://headscale.example.com:3000/admin/(注意末尾的 /admin/)。

现象:页面提示 “Headplane is configured to use secure cookies, but this site is being served over an insecure connection”

原因:Headplane 配置了 cookie_domain 且启用了 secure cookie,但通过 HTTP(非 HTTPS)访问。

解决

  • 如果通过 Tailscale 私网 IP 访问,去掉 cookie_domain 配置
  • 或者通过 Nginx 反代 Headplane 并配置 HTTPS

端口放行:官方文档怎么说

Headscale 官方端口要求

根据 headscale 官方 requirements 文档

端口协议公开用途
443TCPHTTPS 控制面 + DERP 中继(推荐)
3478UDPSTUN(嵌入式 DERP 服务器必需)
80TCP可选Let’s Encrypt HTTP-01 验证(仅使用内置 ACME 时需要)
9090TCPMetrics 端点(仅内网)

Tailscale 官方文档也明确列出了 客户端需要的出站端口

  • TCP to *:443 — 协调服务器 + DERP 中继
  • UDP from :41641 to *:* — WireGuard 直连
  • UDP to *:3478 — STUN(NAT 类型检测)

我的实际端口放行

端口协议公网可达说明
10443TCPheadscale API + DERP 中继(非标准端口,见下文)
3478UDPSTUN
41641UDPWireGuard 直连(客户端侧)
9090TCPMetrics,仅 localhost 监听

验证 DERP 中继

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ tailscale debug derp headscale
{
  "Info": [
    "Region 999 == \"headscale\"",
    "Successfully established a DERP connection",
    "STUN response: xxx.xxx.xxx.xxx:58024"
  ],
  "Errors": [
    "Error connecting over IPv6: no such host"   # 无 IPv6,不影响
  ]
}

443 端口被占用怎么办?非标准端口的配置与影响

问题

根据 headscale 官方文档

“The Tailscale client assumes HTTPS on port 443 in certain situations. Serving headscale either via HTTP or via HTTPS on a port other than 443 is possible but sticking with HTTPS on port 443 is strongly recommended for production setups.” — 参见 issue #2164

但在实际部署中,443 端口经常被其他服务(如 Nginx、Caddy、网站)占用。占用时的解决方案:

方案一:使用非标准端口(我的选择)

如果 443 被 Nginx 等服务占用,可以用其他端口(如 10443),只要在配置里显式指定:

1
2
3
# config.yaml
server_url: https://headscale.example.com:10443
listen_addr: 0.0.0.0:10443

影响

  • 客户端连接正常(server_url 显式指定了端口,客户端会跟随)
  • 少数 Tailscale 客户端在 fast-reconnect 等场景下可能硬编码假定 443(参见 issue #2164 ),但目前 v0.29 实测未发现问题
  • DERP 中继正常(DERP map 会使用 server_url 的端口)

方案二:Nginx SNI 分流

如果 443 上跑着多个 SNI 域名的 HTTPS 服务,可以用 Nginx 的 stream 模块做四层 SNI 分流:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# nginx stream 模块
stream {
    map $ssl_preread_server_name $backend {
        headscale.example.com headscale_backend;
        www.example.com      website_backend;
        default              website_backend;
    }

    upstream headscale_backend {
        server 127.0.0.1:10443;
    }
    upstream website_backend {
        server 127.0.0.1:8443;
    }

    server {
        listen 443;
        proxy_pass $backend;
        ssl_preread on;
    }
}

⚠️ 注意:这是四层 TCP 代理(不做 TLS 终结),而非七层 HTTP 反向代理。headscale 的 WebSocket 协议(tailscale-control-protocol)和 STUN 都能正常工作。

方案三:反向代理(有局限性)

headscale 社区提供了一份 反向代理文档 ,但标注为:

⚠️ Community documentation — not actively maintained by headscale authors. It is not verified by headscale developers.

反向代理的限制:

  • STUN (UDP/3478) 无法代理:必须直连暴露
  • WebSocket 协议特殊:Tailscale 客户端用的是 tailscale-control-protocol(不是标准 WebSocket),Upgrade 头值不同,反代需要额外配置
  • Cloudflare 不支持:Cloudflare 不支持 WebSocket POST 方式升级

如果用 Caddy 做反代,配置示例:

1
2
3
4
5
6
headscale.example.com {
    reverse_proxy 127.0.0.1:8080 {
        header_up True-Client-IP {remote_host}
        header_up X-Real-IP {remote_host}
    }
}

headscale 侧配置:

1
2
3
4
server_url: https://headscale.example.com
listen_addr: 127.0.0.1:8080      # 仅本地监听,TLS 由 Caddy 处理
tls_cert_path: ""
tls_key_path: ""

结论

443 被占用时,用非标准端口(如 10443)直连是最简单可靠的方案,功能无影响。SNI 四层分流是更优雅的方案但需要额外配置。反向代理可行但官方不保证兼容性。


TLS 证书配置:非 443 端口如何设置

方式一:手动指定证书(推荐)

如果已经有证书文件(如从阿里云/腾讯云申请的免费证书,或者 Let’s Encrypt 手动签发的),直接配置:

1
2
3
# config.yaml
tls_cert_path: /var/lib/headscale/tls/cert.pem    # 证书文件(含完整证书链)
tls_key_path: /var/lib/headscale/tls/cert.key      # 私钥文件

⚠️ 重要:根据 headscale TLS 文档

“The certificate should contain the full chain, else some clients, like the Tailscale Android client, will reject it.”

证书必须包含完整证书链(fullchain.pem),否则 Android 客户端会拒绝连接。

如果使用 Docker 部署,需要将证书挂载进容器:

1
2
3
4
5
6
# docker-compose.yaml
services:
  headscale:
    volumes:
      - ./tls/cert.pem:/var/lib/headscale/tls/cert.pem:ro
      - ./tls/cert.key:/var/lib/headscale/tls/cert.key:ro

方式二:Let’s Encrypt 自动签发

Headscale 内置了 ACME 客户端,支持自动签发和续期:

1
2
3
4
5
# config.yaml
tls_letsencrypt_hostname: "headscale.example.com"
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"

⚠️ 注意:

  • HTTP-01 验证:Let’s Encrypt 会连接你的 80 端口验证域名所有权。如果你用非 443 端口(如 10443),listen_addr 是 10443,但 ACME 验证仍然需要 80 端口。如果 80 也被占用,可以改 tls_letsencrypt_listen 到其他端口,然后用防火墙将 80 流量转发过来
  • TLS-ALPN-01 验证:如果 80 不可用,可以改用 TLS-ALPN-01 挑战。但 Let’s Encrypt 只会连接 443 端口做验证,如果 listen_addr 不是 443,需要防火墙转发 443 到实际端口

证书续期

根据 headscale TLS 文档

  • Let’s Encrypt 证书有效期 3 个月
  • Headscale 在证书剩余 30 天以内时才会尝试续期
  • 续期尝试间隔 30-60 分钟随机触发
  • 续期成功/跳过时不产生日志

手动验证证书到期时间

1
2
3
4
5
6
$ openssl s_client -servername headscale.example.com \
    -connect headscale.example.com:10443 2>/dev/null \
    | openssl x509 -noout -dates

notBefore=Feb  8 09:48:26 2026 GMT
notAfter=May   8 09:48:25 2026 GMT

如果是手动证书(方式一),建议用 cron 定时任务替换证书并重启 headscale:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 每月 1 号凌晨 3 点更新证书
# crontab -e
0 3 1 * * /usr/local/bin/renew-headscale-cert.sh

# renew-headscale-cert.sh
#!/bin/bash
# 将新证书拷贝到 headscale 的证书目录
cp /path/to/new/fullchain.pem /opt/headscale/tls/cert.pem
cp /path/to/new/privkey.pem /opt/headscale/tls/cert.key
# 重启 headscale 容器
docker restart headscale

阿里云 CGNAT 地址段冲突:--netfilter-mode 的真正作用

Tailscale 使用 100.64.0.0/10(CGNAT 地址段,RFC6598)作为节点 IP 地址池。阿里云 ECS 在 100.100.x.x 段部署了内部服务(DNS 100.100.2.136/138、元数据 100.100.100.200 等)。

冲突机制

很多人以为冲突是因为 Tailscale 加了 100.64.0.0/10 dev tailscale0 路由,把流量劫持走了。但实际上,即使路由表里没有这条路由,冲突依然存在

真正的根因在 Tailscale 的 --netfilter-mode。根据 Tailscale netfilter modes 官方文档 ,默认 on 模式下,Tailscale 会在 iptables 里加一条规则:

Prevent CGNAT spoofing: Traffic from the CGNAT range (100.64.0.0/10) that does not arrive through the tailscale0 interface is dropped. This prevents attackers from spoofing Tailscale IP addresses on other interfaces.

翻译:凡是来源或目的 IP 落在 100.64.0.0/10 范围内、且不是从 tailscale0 接口进出的包,一律 DROP。

这条规则是为了安全——防止外部攻击者伪造 CGNAT 地址。但副作用是:阿里云 ECS 的内网 DNS(100.100.2.136)和元数据服务(100.100.100.200)也在这个范围内,它们的流量是从 eth0 进来的,不符合 “from tailscale0” 的条件,被 iptables 直接 DROP 了

修复方案

--netfilter-mode 设为 nodivert

1
2
tailscale set --netfilter-mode=nodivert
tailscale up --login-server=https://headscale.example.com:10443

nodivert 的含义:Tailscale 仍然创建 iptables 子链(规则还在),但不把它们挂载到主链的 INPUT/FORWARD 钩子上。这样 CGNAT 反欺骗规则被停用,但规则定义保留,你可以按需手动激活。

影响分析

--netfilter-mode=nodivert 关闭了 Tailscale 自动管理的 4 类 iptables 规则:

规则作用关闭后影响
Accept tailnet traffic放行 tailscale0 接口流量如系统默认策略是 ACCEPT,无影响
Prevent CGNAT spoofingDROP 非 tailscale0 的 100.64.0.0/10 包⬅️ 这就是要关的,关了阿里云内网服务就通了
Accept Tailscale protocol放行 UDP 41641 端口如系统默认策略是 ACCEPT,无影响
Enable routing features子网路由/exit node 的转发和 NAT需手动配置 iptables 转发规则

对 Headscale 控制面:完全不受影响。节点注册、密钥分发、ACL 同步走 HTTPS,和 netfilter 无关。

对 P2P 打洞:不受影响。打洞依赖的是 WireGuard 的 UDP 通信,只要系统默认 INPUT 策略是 ACCEPT(阿里云 ECS 默认是),UDP 41641 的入站包不会被挡。

对 DERP 中继:DERP 的 STUN(UDP 3478)和 HTTPS 中继端口,只要系统默认策略是 ACCEPT 就不受影响。

为什么选 nodivert 而不是 off

off 完全删除所有 Tailscale 的 iptables 规则,后续想恢复得重新执行 tailscale upnodivert 保留了规则定义,只是不激活,方便以后按需启用。

缩小 IP 池(顺手做了)

我还把 IP 地址池从默认的 100.64.0.0/10 缩小到了 100.80.0.0/16

1
2
3
4
# config.yaml
prefixes:
  v4: 100.80.0.0/16    # 方便记忆
  v6: fd7a:115c:a1e0::/48

这纯粹是为了方便记忆——100.80.0.x100.64.x.x 好记。/16 的网段也足够个人使用。

⚠️ 官方提醒(来自 config-example.yaml ):

“Using a SUBSET of these ranges is supported and useful if you want to limit IP allocation to a smaller block. Using ranges OUTSIDE of CGNAT/ULA is NOT supported and will cause undefined behaviour.”

参考


配置对比:我的配置 vs 官方示例

以下是关键配置项的对比,标注了与 headscale v0.29.1 官方示例 的差异:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# ============================================================
# ① server_url — 客户端连接地址
# 官方默认: http://127.0.0.1:8080
# 我的配置: https://headscale.example.com:10443
# 差异: 必须配置为公网可访问的 HTTPS 地址
# ============================================================
server_url: https://headscale.example.com:10443

# ============================================================
# ② listen_addr — 监听地址
# 官方默认: 127.0.0.1:8080
# 官方生产: 0.0.0.0:8080
# 我的配置: 0.0.0.0:10443
# 差异: 直接监听 10443 提供 HTTPS(非标准端口),不经过反代
# ============================================================
listen_addr: 0.0.0.0:10443

# ============================================================
# ③ metrics_listen_addr — 不变
# 官方默认: 127.0.0.1:9090
# 我的配置: 127.0.0.1:9090 (一致)
# ============================================================
metrics_listen_addr: 127.0.0.1:9090

# ============================================================
# ④ prefixes — IP 地址池
# 官方默认: v4: 100.64.0.0/10
# 我的配置: v4: 100.80.0.0/16
# 差异: 缩小到 /16 子网,方便记忆
# 备注: 官方支持缩小范围,但不能超出 CGNAT/ULA 范围
# ============================================================
prefixes:
  v4: 100.80.0.0/16          # 官方是 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential

# ============================================================
# ⑤ derp.server — 内嵌 DERP
# 官方默认: enabled: false
# 我的配置: enabled: true
# 差异: 启用自建 DERP 中继,减少对 Tailscale 官方 DERP 的依赖
# 注意: 启用后必须配置 stun_listen_addr 和 ipv4
# ============================================================
derp:
  server:
    enabled: true                         # 官方默认是 false
    region_id: 999
    region_code: "headscale"
    region_name: "Headscale Embedded DERP"
    stun_listen_addr: "0.0.0.0:3478"
    automatically_add_embedded_derp_region: true
    ipv4: xxx.xxx.xxx.xxx               # 填写你的公网 IP
    private_key_path: /var/lib/headscale/derp_server_private.key

# ============================================================
# ⑥ TLS 证书
# 官方默认: tls_cert_path: "" (不使用 TLS)
# 我的配置: 手动指定证书路径
# 差异: 自签/购买证书,而非 Let's Encrypt
# ============================================================
tls_cert_path: /var/lib/headscale/tls/cert.pem
tls_key_path: /var/lib/headscale/tls/cert.key

# ============================================================
# ⑦ DNS — MagicDNS
# 官方默认: base_domain: example.com
# 我的配置: 自定义域名
# 差异: 使用自己的域名,同时配置国内 DNS 上游
# ============================================================
dns:
  magic_dns: true
  base_domain: example.net               # 官方示例是 example.com
  nameservers:
    global:
      - 223.5.5.5                        # 官方示例是 1.1.1.1,国内用阿里 DNS 更快

# ============================================================
# ⑧ node.expiry — 节点过期
# 官方默认: 0 (永不过期)
# 我的配置: 0 (一致)
# 备注: Tailscale SaaS 默认 180 天,如果你想要类似行为,设为 180d
# ============================================================
node:
  expiry: 0

关键差异总结

配置项官方默认我的配置原因
server_urlhttp://127.0.0.1:8080https://...:10443生产环境必须 HTTPS
listen_addr127.0.0.1:80800.0.0.0:10443443 被占用,使用非标准端口
prefixes.v4100.64.0.0/10100.80.0.0/16方便记忆
derp.server.enabledfalsetrue自建 DERP,降低延迟
tls_cert_path"" (空)手动指定使用自己的证书
dns.nameservers1.1.1.1223.5.5.5国内阿里 DNS 延迟更低

踩坑记录

坑 1:Linux 客户端不会自动接受子网路由

现象:子网路由在 Headscale 端已批准,但 Linux 服务器 ping 不通子网内的 IP。

根因:Tailscale 在 Linux 上默认不启用 --accept-routes。这是 Tailscale 的设计行为,而非 bug:

平台默认接受路由
Windows / macOS / iOS / Android
Linux

Linux 默认不接收路由的原因:安全考虑——Linux 通常是服务器,不应被远程节点自动修改路由表。

修复

1
2
3
4
5
# 推荐方式:tailscale set 持久化(v1.46+)
tailscale set --accept-routes

# 或者 tailscale up 时带参数
tailscale up --login-server=https://headscale.example.com:10443 --accept-routes

坑 2:tailscale up 报错 “requires mentioning all non-default flags”

现象

1
2
3
$ tailscale up --login-server=https://headscale.example.com:10443 --accept-routes
Error: changing settings via 'tailscale up' requires mentioning all
non-default flags. Use --reset or explicitly mention all non-default settings.

原因:之前设置过其他非默认参数(如 --netfilter-mode=nodivert),tailscale up 要求每次列出所有非默认参数。

修复:用 tailscale set 替代 tailscale up 带参数:

1
2
tailscale set --accept-routes --netfilter-mode=nodivert
tailscale up --login-server=https://headscale.example.com:10443

tailscale set 是官方推荐的方式——偏好设置和连接操作分离,配置持久化。

坑 3:路由在 table 52 而不是 main 表

现象ip route show 看不到 Tailscale 路由,但 ping 能通。

原因:Tailscale 使用自定义路由表 52(而非 main 表),避免和系统路由冲突。

1
2
3
4
5
6
7
# 错误
$ ip route show | grep 192.168.1
# (空)

# 正确
$ ip route show table 52
192.168.1.0/24 dev tailscale0    # ✅ 在这里

坑 4:Docker 容器映射了不需要的端口

现象:headscale 的 Docker 镜像默认暴露了 8080 端口,但配置里 listen_addr 是其他端口,8080 成了死端口。

影响:虽然安全组可能阻断了 8080,但在主机上它是暴露的。如果安全组放行,就会有一个无用的端口暴露在公网。建议在 docker-compose.yaml 里只映射实际使用的端口。

坑 5:ZeroTier 和 Tailscale 共存冲突

现象:Windows 上启用 Tailscale 之后,无法访问 ZeroTier 的 192.168.191.0 网段。

原因:两个 VPN 客户端同时运行时,路由表可能互相覆盖。Tailscale 和 ZeroTier 在路由表、DNS、iptables 层面存在竞争。

修复

  • 不建议在同一台机器上同时运行两个 VPN 客户端
  • 如果必须共存,用 tailscale set --exit-node-allow-lan-access 配置 LAN 访问策略
  • 临时方案:tailscale down 停掉 Tailscale 后再访问 ZeroTier 网段

坑 6:DERP 中继延迟高

现象:Windows 连到南京的家中节点,ping 延迟 120ms。

原因:当 NAT 打洞失败时,流量走 DERP 中继。DERP 服务器在阿里云成都,Windows 在南京,家里也在南京——流量绕了成都再回来,延迟自然高。

诊断

1
2
3
4
5
6
7
# 查看当前连接状态
tailscale status
# 如果显示 "relay" 而非 "direct",说明在走 DERP 中继

# 检查是否直连
tailscale ping <node>
# 输出会显示是 direct 还是 relay

优化

  • 确保两端 UDP 41641 不被防火墙拦截
  • 确保两端 STUN (UDP 3478) 可达
  • 如果始终无法直连,考虑在离用户更近的地理位置部署 DERP 服务器

坑 7:server_urlbase_domain 不能同域

现象:headscale 容器反复重启,日志报错:

1
2
Error: initializing: loading configuration: server_url cannot use the same domain
as base_domain in a way that could make the DERP and headscale server unreachable

原因server_url(如 https://headscale.example.com:10443)和 base_domain(如 example.com)如果使用相同的根域名,DERP 和 API 的路由可能冲突。

修复base_domain 必须和 server_url 的域名不同。例如:

1
2
3
4
server_url: https://headscale.example.com:10443
# ...
dns:
  base_domain: example.net    # 不同于 server_url 的 example.com

坑 8:TLS 证书文件名不影响功能

现象:headscale 日志提示 WRN listening without TLS but ServerURL does not start with http://,但证书文件明明存在。

原因:headscale 在配置中找到 tls_cert_pathtls_key_path 后才会启用 TLS。文件名无所谓(cert.pemfullchain.pem 都可以),关键是路径正确且证书包含完整证书链。

验证

1
2
3
4
5
6
7
# 确保证书包含完整链
openssl verify -CAfile /path/to/cert.pem /path/to/cert.pem

# 检查证书和私钥是否匹配
openssl x509 -noout -modulus -in /path/to/cert.pem | md5sum
openssl rsa -noout -modulus -in /path/to/cert.key | md5sum
# 两个 md5 值应该相同

坑 9:headscale 命令不能直接执行

现象:在宿主机上执行 headscale ... 命令报错 command not found

原因:headscale 跑在 Docker 容器里,二进制 headscale 不在宿主机 PATH 中。

修复:所有管理命令通过 docker exec 执行:

1
2
3
4
5
6
# 正确
docker exec headscale headscale users list
docker exec headscale headscale nodes list

# 错误
headscale users list   # command not found

ZeroTier vs Headscale:最终选择

场景建议
个人使用、不想折腾Tailscale 官方 SaaS(免费 3 用户够用)
超过 3 个用户、需要数据主权Headscale 自建
需要 MagicDNS(自动域名)Headscale/Tailscale(ZeroTier 没有)
需要 Exit Node 一键切换Headscale/Tailscale(ZeroTier 配置复杂)
需要 Layer 2 虚拟以太网ZeroTier(Tailscale 只支持 Layer 3)
大量 Linux 服务器组网Headscale(但要记得 --accept-routes
对延迟敏感、需要自控 DERPHeadscale(自建 DERP 延迟可控)

常用命令速查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# === Headscale 管理 ===
docker exec headscale headscale nodes list              # 列出所有节点
docker exec headscale headscale nodes list-routes       # 列出所有路由
docker exec headscale headscale nodes approve-routes -i <ID> -r <CIDR>  # 批准路由
docker exec headscale headscale users list              # 列出用户
docker exec headscale headscale preauthkeys create -u <user> --reusable  # 创建预授权密钥

# === 客户端 ===
tailscale status                                       # 查看连接状态
tailscale set --accept-routes                          # 持久化接受路由
tailscale set --exit-node=<node>                       # 设置出口节点
tailscale debug derp-map                               # 查看 DERP 地图
tailscale debug derp headscale                         # 测试自建 DERP 连通性
tailscale ping <node>                                  # 测试节点直连延迟
ip route show table 52                                 # 查看 Tailscale 路由表

# === 端口检测 ===
nc -zv <IP> 10443                                      # 检查 TCP 端口
nc -zuv <IP> 3478                                      # 检查 UDP 端口

# === 证书检查 ===
openssl s_client -servername headscale.example.com \
  -connect headscale.example.com:10443 2>/dev/null \
  | openssl x509 -noout -dates                         # 检查证书到期时间

参考资料


本文记录了从 ZeroTier 迁移到 Headscale 的完整历程,包括选型分析、架构设计、TLS 证书配置、端口被占用的替代方案、阿里云 CGNAT 地址段冲突与 --netfilter-mode 修复、以及多次踩坑的修复经验。如果你也在做类似的迁移,希望这些经验能帮到你。