Headscale + Tailscale 自建私有网络

目标:官方 Tailscale 免费版有设备数量限制,且控制面在境外,本文以 Headscale 作为自建控制面,Tailscale Client 负责节点接入与 WireGuard 加密通信,Headplane 提供 Web 管理界面,实现自主可控的跨网络组网,且所有设备和数据都由自己掌控。

效果:让分散在不同地点、不同内网、没有公网 IP 的机器,像在同一个局域网里一样互相访问(SSH、HTTP、任意 TCP/UDP),流量全程 WireGuard 端到端加密。


目录

章节 内容
文档信息 架构、参数表、所需条件
第一部分 控制面服务器:Headscale、Tailscale 客户端、Headplane Web 管理界面
第二部分 远程服务器:Tailscale 客户端接入
第三部分 组网连通性验证
第四部分 日常运维、备份、安全、排错、验收、卸载
附录 原生安装命令速览
参考资料 本文内容官方资料来源

文档信息

架构与端口规划

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                  互联网                        本机所在局域网
┌──────────────────────┐ ┌──────────────────────────────────────┐
│ 远程设备 │ │ 控制面服务器 10.1.20.22 │
│ Linux 192.168.0.1 │ │ │
│ │ │ │
│ Docker 容器 tailscale │ ①控制面 │ ┌──────────────────────────────────┐ │
│ --network host │────────▶│ │ Docker 网络 hsnet │ │
│ 宿主机 = tailnet 节点 │ TCP │ │ headscale :8080 控制面 │ │
│ 100.64.0.2 │ 28080 │ │ headplane :3000 Web 管理界面 │ │
│ │ ↓NAT │ │ │ │
└──────────┬───────────┘ 8080 │ └──────────────────────────────────┘ │
│ │ ts-host (--network host) │
│ │ 宿主机自身 = 100.64.0.1 │
│ └──────────────────────────────────────┘
│
└─── ②数据面:WireGuard over UDP,节点之间端到端直连
地址段 100.64.0.0/10,不经过控制面

两条链路必须分清:

① 控制面 ② 数据面
谁和谁 节点 ↔ headscale 节点 ↔ 节点
干什么 注册、下发网络拓扑、交换公钥、协调打洞 你的真实业务流量(SSH、HTTP…)
走什么 TCP 8080(公网经 NAT 变 28080) UDP,端口动态
加密 Noise 协议 WireGuard

关键性质:headscale 挂了,已经建立的节点间直连不受影响,业务照常跑;只有新节点注册和拓扑变更会失败。

三个组件

组件 作用 本方案版本
Headscale 自建控制面,替代 Tailscale 官方协调服务器。管节点注册、分配 100.64.x 地址、下发拓扑 v0.29.4
Tailscale 客户端 跑在每台要入网的机器上,建立 WireGuard 隧道 1.102.5
Headplane 第三方 Web 管理界面(Headscale 官方没有 UI)。看节点、发密钥、管用户 v0.7.1

端口清单

端口 协议 绑定地址 用途 是否需对外
8080 TCP 0.0.0.0 headscale 控制面 跨网络接入时需要(经 NAT 映射)
9090 TCP 127.0.0.1 headscale metrics/debug 否
3000 TCP 0.0.0.0 Headplane Web UI 见 [HeadPlane Web](#部署 Headplane Web 管理界面)
41641 UDP — WireGuard 节点间数据面 打洞失败时需映射
3478 UDP — STUN(启用自建 DERP 时才用) 启用 DERP 时需要
80 / 443 TCP — Let’s Encrypt 签发证书 上 TLS 时需要

第一部分:控制面服务器

前置条件检查

逐项执行,全部满足才能继续。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1.系统版本(需 Ubuntu 20.04+ / Debian 11+)
grep PRETTY_NAME /etc/os-release

# 2.架构
# x86_64 → amd64 ;aarch64 → arm64
uname -m

# 3.不能在容器/受限环境里跑(需要能创建 TUN 设备)
# none / kvm / vmware 都可以;docker / lxc 则本方案不适用
systemd-detect-virt

# 4.TUN 设备(WireGuard 数据面必需)
ls -la /dev/net/tun

# 5.Docker
sudo docker --version

# 6.磁盘(三个镜像合计约 480MB,加上运行数据,预留 2GB 足够)
df -h /

# 7.端口空闲,无输出即为空闲。有输出说明端口被占,先解决冲突或使用其他端口。
sudo ss -tlnp | grep -E ':(8080|9090|3000)'

拉取镜像

国内网络环境下 registry-1.docker.io 不可达。 docker pull headscale/headscale:0.29.4 会直接失败。

所以三个镜像都从国内镜像源拉取,再用 docker tag 打回官方名字。这样后续所有命令都能用标准镜像名,也和 Headplane 的 Docker 集成兼容。

1
2
3
4
5
6
7
8
9
10
11
# headscale
sudo docker pull docker.m.daocloud.io/headscale/headscale:0.29.4
sudo docker tag docker.m.daocloud.io/headscale/headscale:0.29.4 headscale/headscale:0.29.4

# tailscale 客户端
sudo docker pull docker.m.daocloud.io/tailscale/tailscale:stable
sudo docker tag docker.m.daocloud.io/tailscale/tailscale:stable tailscale/tailscale:stable

# headplane
sudo docker pull ghcr.nju.edu.cn/tale/headplane:latest
sudo docker tag ghcr.nju.edu.cn/tale/headplane:latest ghcr.io/tale/headplane:latest

校验版本

1
sudo docker run --rm --entrypoint tailscale tailscale/tailscale:stable version | head -1

版本硬约束:headscale 0.29 会拒绝低于 v1.80 的客户端。 启动日志里能看到 minimum_version=v1.80。客户端版本不够会静默失败,所以这里必须先确认。

创建 Docker 网络与目录

1
2
3
4
5
6
7
# 1.创建网络:所有控制面容器要在同一个网络里,才能用容器名互相访问
sudo docker network create hsnet

# 2.创建目录
sudo mkdir -p /opt/headscale/{config,lib}
sudo mkdir -p /opt/headplane/data
sudo chown -R $USER:$USER /opt/headscale /opt/headplane

部署 Headscale

写配置文件

创建 /opt/headscale/config/config.yaml,内容如下(需把 server_url 换成你的值):

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
cat > /opt/headscale/config/config.yaml << 'EOF'
server_url: http://yourdomain.com:port
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090

noise:
private_key_path: /var/lib/headscale/noise_private.key

unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"

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

database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite

dns:
magic_dns: true
base_domain: example.com
nameservers:
global:
- 223.5.5.5
- 119.29.29.29

policy:
mode: file
path: ""

derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: true
update_frequency: 3h

log:
level: info
format: text
EOF

几个关键项的解释:

配置项 值 为什么
server_url 公网 DDNS + NAT 端口 headscale 对外宣告的地址,会出现在客户端注册 URL 里。必须是所有客户端都能访问到的地址
listen_addr 0.0.0.0:8080 进程实际绑定的地址。容器内必须绑 0.0.0.0,绑 127.0.0.1 的话宿主机发布端口也访问不到
metrics_listen_addr 0.0.0.0:9090 同上,容器内绑回环等于白绑
noise.private_key_path 必填 headscale 0.29 强制要求,缺失直接 Fatal config error 退出
database.type sqlite 小规模足够。多实例部署才需要 postgres
database.sqlite.path /var/lib/headscale/db.sqlite 数据库文件
unix_socket /var/run/headscale/headscale.sock CLI 通信套接字,权限 0770
dns.base_domain example.com 占位值,需修改。决定 MagicDNS 后缀
tls_cert_path / tls_key_path "" 留空则纯 HTTP
acme_email / tls_letsencrypt_hostname "" Let’s Encrypt 自动签发
policy.mode file ACL 策略模式
policy.path "" 空 = 不加载 ACL = 默认全通。这是合法值,不会报错
derp.server.enabled false 内嵌 DERP 中继要求 server_url 是 HTTPS,明文 HTTP 下开启会配置校验失败
derp.urls 官方 derpmap 默认会用 Tailscale 官方公共中继兜底,见 DERP
derp.auto_update_enabled true 每 3h 刷新官方地图

server_url 和 listen_addr 是两个不同的东西,端口号不一样是正常的(8080 vs 28080),因为中间隔了一层 NAT 端口转换。

启动容器

1
2
3
4
5
6
7
8
9
10
11
sudo docker run -d \
--name headscale \
--restart unless-stopped \
--network hsnet \
--read-only \
--tmpfs /var/run/headscale \
-v /opt/headscale/config:/etc/headscale:ro \
-v /opt/headscale/lib:/var/lib/headscale \
-p 0.0.0.0:8080:8080 \
-p 127.0.0.1:9090:9090 \
headscale/headscale:0.29.4 serve

参数说明:

参数 作用
--read-only + --tmpfs /var/run/headscale 容器根文件系统只读,只有 unix socket 目录用 tmpfs。数据落在 bind mount 里,安全
-p 0.0.0.0:8080:8080 控制面端口,必须对外,远端节点才能接入
-p 127.0.0.1:9090:9090 metrics 端口,只绑本机回环,不对外暴露
serve 必须显式传。镜像没有默认 CMD,不传会立刻退出

验证

1
2
3
4
5
# 本地验证
curl -s http://127.0.0.1:8080/health

# 公网验证(需路由器的 NAT 映射),预期:{"status":"pass"}
curl -s --max-time 15 http://yourdomian.com:port/health

检查生成的数据文件:

1
2
3
4
5
6
7
sudo ls -la /opt/headscale/lib/

# 预期输出
-rw-r--r-- 1 root root 4096 db.sqlite
-rw-r--r-- 1 root root 32768 db.sqlite-shm
-rw-r--r-- 1 root root 358472 db.sqlite-wal
-rw------- 1 root root 72 noise_private.key

noise_private.key 是控制面的身份私钥,丢了所有节点都必须重新注册。 备份时必须包含它(见 备份与恢复)。

CLI 使用方式

容器化之后,所有 headscale 管理命令都通过 docker exec 执行:

1
2
3
sudo docker exec headscale headscale version
sudo docker exec headscale headscale users list
sudo docker exec headscale headscale nodes list

镜像是 distroless,没有 shell。 sudo docker exec -it headscale sh 会报错。只能直接执行 headscale 二进制,不能用管道在容器内组合命令。

创建用户与密钥

Headscale 用 user 做租户隔离,每个节点必须归属某个 user。

创建用户

1
2
sudo docker exec headscale headscale users create <user-name>
sudo docker exec headscale headscale users list

执行命令后会生成用户ID,记下这个数字 ID(这里是 1),下一步必填。

生成预授权密钥(preauthkey)

节点接入需要一把密钥。

1
sudo docker exec headscale headscale preauthkeys create --user 1 --expiration 24h --reusable

参数说明:

参数 作用 注意
-u, --user 用户数字 ID,必填 不能传用户名
-e, --expiration 有效期,如 1h / 24h / 72h 默认只有 1 小时,很容易不够用
--reusable 允许多台设备复用同一把密钥 见下方安全权衡
--ephemeral 节点下线后自动清理 适合临时/容器节点

查看与吊销密钥

1
2
3
4
5
6
7
8
9
10
11
# 查看所有密钥信息
sudo docker exec headscale headscale preauthkeys list

# 按 ID 吊销
sudo docker exec headscale headscale preauthkeys expire --id <id>

# 或者直接物理删除密钥
sudo docker exec headscale headscale preauthkeys delete --id <id>

# 3.复查 —— Expiration 列会立刻变成当前时间
sudo docker exec headscale headscale preauthkeys list

Used=true 表示已被消费。注意:--reusable 密钥即使已被使用,Used 仍显示 false(因为它还能继续用)。

吊销密钥不影响已注册的节点。 节点身份持久化在它自己的状态目录/卷里。实测:吊销后重启容器,节点仍然自动上线。

部署 Tailscale 节点容器

由于我们需要让这台物理机能够SSH到组网内的任意一台设备上,所以需要选择 host 模式,让控制面宿主机自身进入 tailnet,成为 tailnet 成员

host 模式节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 单独生成一把一次性密钥
HOSTKEY=$(sudo docker exec headscale headscale preauthkeys create --user 1 --expiration 1h | tail -1 | tr -d '[:space:]')
echo "keylen=${#HOSTKEY}" # 必须是 88

sudo docker run -d \
--name ts-host \
--restart unless-stopped \
--network host \
--cap-add NET_ADMIN \
--cap-add SYS_MODULE \
--device /dev/net/tun \
-e TS_AUTHKEY="$HOSTKEY" \
-e TS_STATE_DIR=/var/lib/tailscale \
-e TS_USERSPACE=false \
-e "TS_EXTRA_ARGS=--login-server=http://10.1.20.22:8080 --hostname=ts-host --accept-routes" \
-v ts-host-state:/var/lib/tailscale \
tailscale/tailscale:stable
参数 为什么必须
--network host 使用宿主机网络
--cap-add NET_ADMIN 创建和配置 TUN 设备需要
--cap-add SYS_MODULE 加载内核模块(配合 TUN)
--device /dev/net/tun 把宿主机 TUN 设备透传进容器。缺了会报 No such device
TS_USERSPACE=false 必须显式设置。默认 true 会启动 tailscaled --tun=userspace-networking,不创建真实的 tailscale0,做不了真正的 WireGuard 隧道
TS_EXTRA_ARGS 放 tailscale up 的参数。--login-server / --hostname / --accept-routes 都属于 tailscale up
--login-server 必须改成宿主机 IP 或公网地址,host 模式下容器共享宿主机网络命名空间,Docker 内嵌 DNS(127.0.0.11)不再可用,容器名解析不了
-v ts-host-state:/var/lib/tailscale 节点身份持久化。有了它,容器重建/重启不需要重新用密钥注册

host 模式让宿主机成为 tailnet 成员,意味着宿主机上所有监听 0.0.0.0 的服务都对全部 tailnet 成员可见。

节点注册成功后立刻吊销:

1
2
3
# 查到刚创建那把密钥的 ID,按 ID 删除密钥
sudo docker exec headscale headscale preauthkeys list
sudo docker exec headscale headscale preauthkeys expire --id <id>

吊销不影响已注册节点 —— 身份已经存在状态卷里了。吊销后重启容器仍自动上线。

验证节点接入

1
2
3
4
5
6
# 内网机器上查看是否有新增节点
sudo docker exec headscale headscale nodes list
# 容器内看状态
sudo docker exec ts-node-a tailscale status
# 查看 tailscale0
ip -br addr show tailscale0

部署 Headplane Web 管理界面

生成 API key

Headplane 用 headscale 的 API key 登录

1
2
# 生成 API key
sudo docker exec headscale headscale apikeys create --expiration 90d

写配置文件

替换其中的 API_KEY

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
COOKIE_SECRET=$(openssl rand -hex 16)     # 32 字符随机串

cat > /opt/headplane/config.yaml << EOF
server:
host: "0.0.0.0"
port: 3000
base_url: "http://10.1.20.22:3000"
cookie_secret: "$COOKIE_SECRET"
cookie_secure: false
cookie_max_age: 86400
data_path: "/var/lib/headplane"

headscale:
url: "http://headscale:8080"
config_path: "/etc/headscale/config.yaml"
api_key: "<API_KEY>"

integration:
agent:
enabled: false
docker:
enabled: true
container_name: "headscale"
proc:
enabled: false
EOF

sudo chown $USER:$USER /opt/headplane/config.yaml
chmod 600 /opt/headplane/config.yaml # 含明文密钥,必须限权

关键项说明:

配置项 说明
base_url 必须和你浏览器地址栏输入的地址完全一致,且不含 /admin 后缀。不一致会导致登录后跳转异常、cookie 失效。如果你打算用 SSH 隧道从 localhost:3000 访问,这里就要写 http://localhost:3000
cookie_secure 明文 HTTP 部署必须 false;上了 TLS 反代后改 true
headscale.url 走 Docker 网络用容器名 http://headscale:8080
integration.docker.enabled true 让 UI 能重启 headscale 容器、改 DNS 配置。代价见 [Docker socket 挂载的风险](#docker socket 挂载的风险)

启动容器

1
2
3
4
5
6
7
8
9
10
sudo docker run -d \
--name headplane \
--restart unless-stopped \
--network hsnet \
-v /opt/headplane/config.yaml:/etc/headplane/config.yaml:ro \
-v /opt/headplane/data:/var/lib/headplane \
-v /opt/headscale/config/config.yaml:/etc/headscale/config.yaml \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 0.0.0.0:3000:3000 \
ghcr.io/tale/headplane:latest

注意 /etc/headscale/config.yaml 这一行没有 :ro —— 是故意挂载为可写的。这样 Headplane 的 Docker 集成才能在 UI 里改 DNS / 网络设置并重启 headscale 生效。如果你不需要这个功能,加 :ro 更安全(代价是 UI 里改配置的按钮会失效,日志会出现 not writable 警告)。

功能可用性

浏览器打开 http://10.1.20.22:3000/admin,在登录框里粘贴上面生成好的 headscale API key。

功能 可用性
节点列表 / 删除 / 打标签 可用
用户管理 可用
预授权密钥管理(含 UI 里生成) 可用
ACL 查看 可用
UI 里改 DNS / 网络设置 可用,需 headscale config 可写挂载
UI 里重启 headscale 容器 可用,需 Docker 集成
浏览器内 SSH 到节点 不可用,需另外部署 Headplane Agent
OIDC 单点登录 不可用,需配置 oidc 段

第二部分:远程设备接入

远端机器要用宿主机级节点(--network host),这样这台机器本身成为 tailnet 成员,它的 sshd 和其他服务才能被访问。

前置检查

在 远程服务器 上执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1 架构
uname -m

# 2 系统版本
grep PRETTY_NAME /etc/os-release

# 3 Docker
sudo docker --version

# 4 TUN 设备
ls -la /dev/net/tun

# 5 控制面可达,预期:{"status":"pass"}
curl -s --max-time 15 http://yourdomain.com:port/health

拉取镜像

1
2
3
4
5
6
# 官方源不可达(000)时:
sudo docker pull docker.m.daocloud.io/tailscale/tailscale:stable
sudo docker tag docker.m.daocloud.io/tailscale/tailscale:stable tailscale/tailscale:stable

# 校验版本必须 >= 1.80
sudo docker run --rm --entrypoint tailscale tailscale/tailscale:stable version | head -1

在控制面生成密钥

回到控制面服务器执行,一台机器一把一次性密钥:

1
sudo docker exec headscale headscale preauthkeys create --user 1 --expiration 72h

一次性密钥用过即废。 中途失败要重试,必须回控制面重新生成一把,不能复用已消费的。

部署

启动容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 替换命令中的 your-auth-key; http://yourdomain.com:port; device-name
sudo docker run -d \
--name tailscale \
--restart unless-stopped \
--network host \
--cap-add NET_ADMIN \
--cap-add SYS_MODULE \
--device /dev/net/tun \
-e TS_AUTHKEY="<your-auth-key>" \
-e TS_STATE_DIR=/var/lib/tailscale \
-e TS_USERSPACE=false \
-e "TS_EXTRA_ARGS=--login-server=<http://yourdomain.com:port> --hostname=<device-name> --accept-routes" \
-v tailscale-state:/var/lib/tailscale \
tailscale/tailscale:stable

--login-server 必须是公网可访问地址。

TS_USERSPACE=false 必须显式设置,否则不会创建真实的 tailscale0。

验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 宿主机拿到 tailnet 地址
ip -br addr show tailscale0

# 路由(注意用 table 52,不是 main 表)
ip route show table 52

# 看到所有节点
sudo docker exec tailscale tailscale status

# ping 控制面侧节点
sudo docker exec tailscale tailscale ping --c 4 100.64.0.1

# NAT 类型
sudo docker exec tailscale tailscale netcheck

# 实际 UDP 端口
sudo ss -ulnp | grep tailscaled

回到控制面确认:

1
2
sudo docker exec headscale headscale nodes list
# 新节点 Connected=online

注册成功后吊销密钥

1
2
3
# 在控制面执行
sudo docker exec headscale headscale preauthkeys list
sudo docker exec headscale headscale preauthkeys expire --id <刚用的那把的 ID>

第三部分:组网连通性验证

SSH 可达性验证

1
2
3
4
5
6
7
8
# 验证内网机器能主动访问远端节点
ping -c 3 100.64.0.2
sudo docker exec ts-host tailscale ping --c 5 100.64.0.2

# 响应如下说明打洞成功,建立了端到端直连
pong from device-name (100.64.0.2) via xxxx:8177 in 21ms
# 打洞失败则一直显示
pong from device-name (100.64.0.2) via DERP(...) in 356ms
1
2
# 响应中能看到 direct 字样也说明打洞成功
sudo docker exec ts-host tailscale status --peers --active

验证反向:tailnet 内其他节点能连到宿主机的 sshd(这条证明入站也通):

在远程主机终端输入

1
sudo docker exec <container-name> tailscale ping --c 5 100.64.0.1

打洞失败时的处理

如果 tailscale ping 始终显示 via DERP(...)、从不出现 direct,说明 NAT 打洞失败。诊断:

1
sudo docker exec ts-host tailscale netcheck

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Report:
* UDP: true
* IPv4: yes, 222.95.176.17:59133
* IPv6: no, unavailable in OS
* MappingVariesByDestIP: false
* PortMapping:
* CaptivePortal: false
* Nearest DERP: San Francisco
* DERP latency:
- sfo: 145.2ms (San Francisco)
- lax: 152.5ms (Los Angeles)
- tok: 219.9ms (Tokyo)
- hkg: 221.6ms (Hong Kong)
- sin: (Singapore) ← 空值 = 探测失败

关注这几个字段:

字段 含义
UDP 必须为 true,否则完全无法打洞,只能走中继
MappingVariesByDestIP false = 端点无关映射(EIM),打洞成功率高;
true = 对称 NAT,打洞基本必败,只能走中继
PortMapping 有 UPnP/NAT-PMP/PCP 会提高成功率,为空也没关系
Nearest DERP 最近的公共中继

按诊断结果处置:

诊断 处置
UDP: false 网络禁 UDP,检查防火墙/运营商策略,这种情况无法直连
MappingVariesByDestIP: false + PortMapping: UPnP 条件最好,通常自动直连成功,无需任何额外配置
PortMapping 为空 + EIM 通常仍能自动直连,无需额外配置
MappingVariesByDestIP: true(对称 NAT) 在路由器上映射 UDP 41641(或 tailscaled 实际端口)到该机器,或改用有公网 IP 的一侧主动连
无 UPnP 手动在路由器映射 UDP 端口

查 tailscaled 实际使用的 UDP 端口:

1
2
sudo ss -ulnp | grep tailscaled
# 实测:UNCONN 0 0 0.0.0.0:41641 *:* users:(("tailscaled",pid=...,fd=19))

第四部分:运维

headscale users list、nodes list、preauthkeys list 这类表格命令,即使 stdout 是管道而不是终端,也照样输出 ANSI 颜色转义码。在使用脚本操作时,可以带上NO_COLOR=1来防止颜色码污染,例如

1
sudo docker exec -e NO_COLOR=1 headscale headscale users list

常用管理命令

1
2
3
4
5
6
7
8
9
10
# 定义个简写,省得每次敲一长串
alias hs='sudo docker exec headscale headscale'

hs nodes list # 所有节点
hs users list # 所有用户
hs preauthkeys list # 所有密钥
hs preauthkeys create --user 1 --expiration 72h # 发一把新密钥
hs preauthkeys expire --id 5 # 按 ID 吊销
hs nodes delete --identifier 3 --force # 删除节点
hs version # 版本

接入时不输入明文密钥

不想用密钥:省略 TS_AUTHKEY,容器日志会打印一个注册 URL:

1
2
To authenticate, visit:
http://puhouddns.imwork.net:28080/register/hskey-authreq-vZZ7wZUoevfg7n5Y-uu85olI

然后在控制面批准(注意 --user 这里传用户名):

1
sudo docker exec headscale headscale nodes register --user testuser --key hskey-authreq-vZZ7wZUoevfg7n5Y-uu85olI

好处:全程不产生任何需要复制粘贴的长期凭据,密钥不会出现在 shell 历史、聊天记录,也不会留在 docker inspect 里。

备份与恢复

对象 位置 丢失后果
noise 私钥 /opt/headscale/lib/noise_private.key 所有节点必须重新注册
数据库 /opt/headscale/lib/db.sqlite(含 -shm / -wal) 节点、用户、密钥数据全丢
headscale 配置 /opt/headscale/config/config.yaml 需重新配置
headplane 配置 /opt/headplane/config.yaml 含 API key 和 cookie secret,丢了所有登录会话失效
节点身份卷 docker 卷 ts-host-state 对应节点需重新注册

安全注意事项

控制面是明文 HTTP

当前 server_url: http://...,控制面流量(注册、下发拓扑、交换公钥)走明文。虽然 Noise 协议加密了实际内容,但还是建议上 TLS,见 [TLS](#上 TLS)。

Headplane 的暴露面

Headplane 绑定 0.0.0.0:3000,局域网内任何人都能打开登录页。API key 泄露 就能 完全控制 headscale。

建议:

  • 生产环境上 TLS 反代 + HTTP Basic Auth 或 OIDC

  • 或者只绑本机回环,通过 SSH 隧道访问:

    • 把 Headscale 部署时的 docker run 里的 -p 0.0.0.0:3000:3000 改成 -p 127.0.0.1:3000:3000
    • 同步把 headplane 配置里的 base_url 改成 http://localhost:3000,否则 cookie 域不匹配,登录后跳转异常。
  • API key 有效期不要设太长(本文用 90 天,生产建议 30 天)

Docker socket 挂载的风险

Headplane 挂载了 /var/run/docker.sock,意味着它可以完全控制宿主机上的所有容器。可以:

  • 启动特权容器挂载宿主机根目录
  • 读取其他容器的敏感数据
  • 停止 / 重启任意服务

建议(按安全强度递增):

  1. API key 设短有效期,监控 Headplane 容器异常行为
  2. 直接关掉 Docker 集成:integration.docker.enabled: false 并去掉 docker.sock 挂载。代价是 UI 里不能重启容器、不能改 DNS,但节点/用户/密钥管理全部照常可用

DERP 中继与自主可控

默认配置 derp.urls: [https://controlplane.tailscale.com/derpmap/default] 会拉取 Tailscale 官方公共中继地图,作为打洞失败时的兜底。

最近的公共中继:

1
2
3
* Nearest DERP: San Francisco
- sfo: 145.2ms - lax: 152.5ms - tok: 219.9ms
- hkg: 221.6ms - fra: 252.8ms - sin: (探测失败)

中国大陆没有官方中继节点,最近的在旧金山。

风险维度 评估
数据内容 安全。WireGuard 端到端加密,DERP 中继只看到密文
元数据 暴露。连接时间、频率、流量大小对 Tailscale 官方可见
可用性 依赖境外基础设施。实测新加坡节点已探测失败,说明可达性确实不稳定。若官方中继全部不可用且打洞失败,节点间就彻底断连

自建 DERP 中继:headscale 支持内嵌 DERP,但要求 server_url 是 HTTPS。所以必须先上 TLS。在/opt/headscale/config/config.yaml中配置:

1
2
3
4
5
6
7
8
9
10
derp:
server:
enabled: true
region_id: 999
region_code: "myderp"
region_name: "My DERP"
stun_listen_addr: "0.0.0.0:3478"
urls: []
paths: []
auto_update_enabled: false

然后重建容器并额外发布 STUN 端口(docker restart 不会改变端口映射):

1
2
sudo docker rm -f headscale
sudo docker run -d --name headscale ... -p 0.0.0.0:8080:8080 -p 3478:3478/udp ... headscale/headscale:0.29.4 serve

路由器上映射 UDP 3478。

明文 HTTP 下开启内嵌 DERP,配置校验会直接报错:Headscale server_url must be https when DERP is enabled

上 TLS

headscale 支持 Let’s Encrypt 自动签发,但只支持 HTTP-01(80 端口)和 TLS-ALPN-01(443 端口)挑战,不支持 DNS-01。

  1. 路由器映射 80→80、443→443
  2. 改配置:
    1
    2
    3
    4
    5
    server_url: https://headscale.example.com
    listen_addr: 0.0.0.0:443
    acme_email: your@email.com
    tls_letsencrypt_hostname: headscale.example.com
    tls_letsencrypt_challenge_type: HTTP-01
  3. 重建容器(不是 restart —— 端口映射变了):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    sudo docker rm -f headscale
    sudo docker run -d
    --name headscale \
    --restart unless-stopped \
    --network hsnet \
    --read-only \
    --tmpfs /var/run/headscale \
    -v /opt/headscale/config:/etc/headscale:ro \
    -v /opt/headscale/lib:/var/lib/headscale \
    -p 0.0.0.0:443:443 -p 127.0.0.1:9090:9090 \
    headscale/headscale:0.29.4 serve
  4. 所有节点强制重新认证:
    1
    sudo docker exec ts-host tailscale up --force-reauth --login-server https://headscale.example.com
  5. 改 Headplane /opt/headplane/config.yaml 配置:
    1
    2
    3
    4
    5
    server:
    base_url: "https://headplane.example.com"
    cookie_secure: true # ← 上 TLS 后改成 true
    headscale:
    url: "https://headscale:443"

Let’s Encrypt 限流:每 3 小时 20 次失败验证、每周 50 张证书。调试时注意别触发。

修改 MagicDNS 后缀

当前 base_domain: example.com 是占位值。

example.com 是真实存在的公网域名。 用它作 MagicDNS 后缀意味着 tailnet 内部对 *.example.com 的解析会被劫持到 tailnet。虽然本方案下 --accept-dns=false、MagicDNS 实际不生效,但如果你将来开启 accept-dns,就会撞上这个冲突。

生产环境建议换成不会与公网冲突的后缀,如 tailnet.internal。修改后所有节点需重新注册。

卸载与清理

控制面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. 停止并删除容器
sudo docker stop headscale headplane ts-host
sudo docker rm headscale headplane ts-host

# 2. 删除节点状态卷
sudo docker volume rm ts-host-state

# 3. 删除数据与配置(含 noise 私钥,谨慎!)
sudo rm -rf /opt/headscale /opt/headplane

# 4. 删除网络
sudo docker network rm hsnet

# 5. 删除镜像(retag 产生了双份标签,两个都要删,否则镜像层不会真正释放)
sudo docker rmi headscale/headscale:0.29.4 \
docker.m.daocloud.io/headscale/headscale:0.29.4 \
tailscale/tailscale:stable \
docker.m.daocloud.io/tailscale/tailscale:stable \
ghcr.io/tale/headplane:latest \
ghcr.nju.edu.cn/tale/headplane:latest

# 6. 清理密钥与备份文件
sudo rm -f /root/hs-apikey.txt /root/headscale-backup-*.tar.gz

删完后 /var/lib/docker/overlay2/ 里可能残留孤儿镜像层(实测约 158 MB)。回收它们需要重启 docker daemon

远端机器 Tailscale 卸载

1
2
3
sudo docker rm -f tailscale
sudo docker volume rm tailscale-state
sudo docker rmi tailscale/tailscale:stable docker.m.daocloud.io/tailscale/tailscale:stable

附录

原生安装Headscale

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 查看最新版本
curl -s https://api.github.com/repos/juanfont/headscale/releases/latest | grep '"tag_name"'

# 下载安装包
mkdir -p /tmp/headscale-install && cd /tmp/headscale-install
wget --output-document=headscale.deb "https://github.com/juanfont/headscale/releases/download/v0.29.4/headscale_0.29.4_linux_amd64.deb"

# 安装
cd /tmp/headscale-install
sudo apt-get install -y ./headscale.deb

# 验证,noise 私钥与 SQLite 数据库会自动生成,无需手工创建。
# 配置文件:/etc/headscale/config.yaml,自己按需调整
headscale version
sudo systemctl status headscale --no-pager

sudo headscale configtest # 预期 exit code 0,无报错输出
curl -s http://127.0.0.1:8080/health
# 预期 {"status":"pass"}
sudo journalctl -u headscale --no-pager | grep -icE 'ERR|FATAL|panic'
# 预期 0
systemctl is-enabled headscale # 预期 enabled
systemctl is-active headscale # 预期 active

原生命令:

1
2
3
4
5
6
7
sudo systemctl restart headscale
sudo headscale nodes list
sudo headscale users create testuser
sudo headscale users list
sudo headscale preauthkeys create --user 1 --expiration 24h
sudo headscale preauthkeys list
sudo headscale preauthkeys expire --user 1 --key <完整密钥>

原生安装 Tailscale

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
# 1.添加官方签名密钥
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg \
| sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null

# 2.添加软件源
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list \
| sudo tee /etc/apt/sources.list.d/tailscale.list >/dev/null

# 3.确认源内容正确
cat /etc/apt/sources.list.d/tailscale.list

# 4.安装
sudo apt-get update
sudo apt-get install -y tailscale

# 5.验证安装
tailscale version | head -2 # 版本号必须 >= 1.80
systemctl is-active tailscaled # active
systemctl is-enabled tailscaled # enabled

# 6.接入节点
sudo tailscale up \
--login-server http://yourdomian.com:port \
--authkey hskey-auth-<新生成的密钥> \
--hostname server-name \
--accept-routes

# 7.验证结果
sudo tailscale status
sudo tailscale ip -4
ip addr show tailscale0
sudo headscale nodes list

原生命令:

1
2
3
4
5
6
7
8
sudo tailscale status              # 查看所有节点
sudo tailscale status --peers # 含连接方式(direct / DERP)与收发字节
sudo tailscale ip -4 # 本机 tailnet IPv4
sudo tailscale ping <对端IP> # 探测连通性与路径
sudo tailscale down # 断开(保留注册状态)
sudo tailscale up ... # 重新连接
sudo tailscale up --force-reauth # 强制重新认证(改 server_url 后需要)
sudo tailscale netcheck # 网络能力诊断:NAT 类型 / UDP / DERP 延迟

原生安装 Headplane

1
2
3
4
5
6
7
8
9
# Adjust as needed and set a custom user if you desire
sudo mkdir -p /var/lib/headplane
sudo chown -R $(whoami):$(whoami) /var/lib/headplane

# You can optionally checkout a specific release tag.
git clone https://github.com/tale/headplane.git
cd headplane
pnpm install
pnpm build

原生命令:

1
2
# You may want to tweak the expiration duration as needed
headscale apikeys create --expiration 90d

参考资料

Headscale

Headplane

Tailscale