Headscale + Tailscale 自建私有网络
Headscale + Tailscale 自建私有网络
目标:官方 Tailscale 免费版有设备数量限制,且控制面在境外,本文以 Headscale 作为自建控制面,Tailscale Client 负责节点接入与 WireGuard 加密通信,Headplane 提供 Web 管理界面,实现自主可控的跨网络组网,且所有设备和数据都由自己掌控。
效果:让分散在不同地点、不同内网、没有公网 IP 的机器,像在同一个局域网里一样互相访问(SSH、HTTP、任意 TCP/UDP),流量全程 WireGuard 端到端加密。
目录
| 章节 | 内容 |
|---|---|
| 文档信息 | 架构、参数表、所需条件 |
| 第一部分 | 控制面服务器:Headscale、Tailscale 客户端、Headplane Web 管理界面 |
| 第二部分 | 远程服务器:Tailscale 客户端接入 |
| 第三部分 | 组网连通性验证 |
| 第四部分 | 日常运维、备份、安全、排错、验收、卸载 |
| 附录 | 原生安装命令速览 |
| 参考资料 | 本文内容官方资料来源 |
文档信息
架构与端口规划
1 | 互联网 本机所在局域网 |
两条链路必须分清:
| ① 控制面 | ② 数据面 | |
|---|---|---|
| 谁和谁 | 节点 ↔ 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 | # 1.系统版本(需 Ubuntu 20.04+ / Debian 11+) |
拉取镜像
国内网络环境下 registry-1.docker.io 不可达。 docker pull headscale/headscale:0.29.4 会直接失败。
所以三个镜像都从国内镜像源拉取,再用 docker tag 打回官方名字。这样后续所有命令都能用标准镜像名,也和 Headplane 的 Docker 集成兼容。
1 | # headscale |
校验版本
1 | sudo docker run --rm --entrypoint tailscale tailscale/tailscale:stable version | head -1 |
版本硬约束:headscale 0.29 会拒绝低于 v1.80 的客户端。 启动日志里能看到 minimum_version=v1.80。客户端版本不够会静默失败,所以这里必须先确认。
创建 Docker 网络与目录
1 | # 1.创建网络:所有控制面容器要在同一个网络里,才能用容器名互相访问 |
部署 Headscale
写配置文件
创建 /opt/headscale/config/config.yaml,内容如下(需把 server_url 换成你的值):
1 | cat > /opt/headscale/config/config.yaml << '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 | sudo docker run -d \ |
参数说明:
| 参数 | 作用 |
|---|---|
--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 | # 本地验证 |
检查生成的数据文件:
1 | sudo ls -la /opt/headscale/lib/ |
noise_private.key是控制面的身份私钥,丢了所有节点都必须重新注册。 备份时必须包含它(见 备份与恢复)。
CLI 使用方式
容器化之后,所有 headscale 管理命令都通过 docker exec 执行:
1 | sudo docker exec headscale headscale version |
镜像是 distroless,没有 shell。
sudo docker exec -it headscale sh会报错。只能直接执行headscale二进制,不能用管道在容器内组合命令。
创建用户与密钥
Headscale 用 user 做租户隔离,每个节点必须归属某个 user。
创建用户
1 | sudo docker exec headscale headscale users create <user-name> |
执行命令后会生成用户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 | # 查看所有密钥信息 |
Used=true表示已被消费。注意:--reusable密钥即使已被使用,Used仍显示false(因为它还能继续用)。
吊销密钥不影响已注册的节点。 节点身份持久化在它自己的状态目录/卷里。实测:吊销后重启容器,节点仍然自动上线。
部署 Tailscale 节点容器
由于我们需要让这台物理机能够SSH到组网内的任意一台设备上,所以需要选择 host 模式,让控制面宿主机自身进入 tailnet,成为 tailnet 成员
host 模式节点
1 | # 单独生成一把一次性密钥 |
| 参数 | 为什么必须 |
|---|---|
--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 | # 查到刚创建那把密钥的 ID,按 ID 删除密钥 |
吊销不影响已注册节点 —— 身份已经存在状态卷里了。吊销后重启容器仍自动上线。
验证节点接入
1 | # 内网机器上查看是否有新增节点 |
部署 Headplane Web 管理界面
生成 API key
Headplane 用 headscale 的 API key 登录
1 | # 生成 API key |
写配置文件
替换其中的 API_KEY
1 | COOKIE_SECRET=$(openssl rand -hex 16) # 32 字符随机串 |
关键项说明:
| 配置项 | 说明 |
|---|---|
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 | sudo docker run -d \ |
注意
/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 | # 1 架构 |
拉取镜像
1 | # 官方源不可达(000)时: |
在控制面生成密钥
回到控制面服务器执行,一台机器一把一次性密钥:
1 | sudo docker exec headscale headscale preauthkeys create --user 1 --expiration 72h |
一次性密钥用过即废。 中途失败要重试,必须回控制面重新生成一把,不能复用已消费的。
部署
启动容器:
1 | # 替换命令中的 your-auth-key; http://yourdomain.com:port; device-name |
--login-server必须是公网可访问地址。
TS_USERSPACE=false必须显式设置,否则不会创建真实的tailscale0。
验证
1 | # 宿主机拿到 tailnet 地址 |
回到控制面确认:
1 | sudo docker exec headscale headscale nodes list |
注册成功后吊销密钥
1 | # 在控制面执行 |
第三部分:组网连通性验证
SSH 可达性验证
1 | # 验证内网机器能主动访问远端节点 |
1 | # 响应中能看到 direct 字样也说明打洞成功 |
验证反向: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 | Report: |
关注这几个字段:
| 字段 | 含义 |
|---|---|
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 | sudo ss -ulnp | grep tailscaled |
第四部分:运维
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 | # 定义个简写,省得每次敲一长串 |
接入时不输入明文密钥
不想用密钥:省略 TS_AUTHKEY,容器日志会打印一个注册 URL:
1 | To authenticate, visit: |
然后在控制面批准(注意 --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 域不匹配,登录后跳转异常。
- 把 Headscale 部署时的 docker run 里的
API key 有效期不要设太长(本文用 90 天,生产建议 30 天)
Docker socket 挂载的风险
Headplane 挂载了 /var/run/docker.sock,意味着它可以完全控制宿主机上的所有容器。可以:
- 启动特权容器挂载宿主机根目录
- 读取其他容器的敏感数据
- 停止 / 重启任意服务
建议(按安全强度递增):
- API key 设短有效期,监控 Headplane 容器异常行为
- 直接关掉 Docker 集成:
integration.docker.enabled: false并去掉 docker.sock 挂载。代价是 UI 里不能重启容器、不能改 DNS,但节点/用户/密钥管理全部照常可用
DERP 中继与自主可控
默认配置 derp.urls: [https://controlplane.tailscale.com/derpmap/default] 会拉取 Tailscale 官方公共中继地图,作为打洞失败时的兜底。
最近的公共中继:
1 | * Nearest DERP: San Francisco |
中国大陆没有官方中继节点,最近的在旧金山。
| 风险维度 | 评估 |
|---|---|
| 数据内容 | 安全。WireGuard 端到端加密,DERP 中继只看到密文 |
| 元数据 | 暴露。连接时间、频率、流量大小对 Tailscale 官方可见 |
| 可用性 | 依赖境外基础设施。实测新加坡节点已探测失败,说明可达性确实不稳定。若官方中继全部不可用且打洞失败,节点间就彻底断连 |
自建 DERP 中继:headscale 支持内嵌 DERP,但要求 server_url 是 HTTPS。所以必须先上 TLS。在/opt/headscale/config/config.yaml中配置:
1 | derp: |
然后重建容器并额外发布 STUN 端口(docker restart 不会改变端口映射):
1 | sudo docker rm -f headscale |
路由器上映射 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。
- 路由器映射 80→80、443→443
- 改配置:
1
2
3
4
5server_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 - 重建容器(不是 restart —— 端口映射变了):
1
2
3
4
5
6
7
8
9
10
11sudo 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 - 所有节点强制重新认证:
1
sudo docker exec ts-host tailscale up --force-reauth --login-server https://headscale.example.com
- 改 Headplane
/opt/headplane/config.yaml配置:1
2
3
4
5server:
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 | # 1. 停止并删除容器 |
删完后
/var/lib/docker/overlay2/里可能残留孤儿镜像层(实测约 158 MB)。回收它们需要重启 docker daemon
远端机器 Tailscale 卸载
1 | sudo docker rm -f tailscale |
附录
原生安装Headscale
1 | # 查看最新版本 |
原生命令:
1 | sudo systemctl restart headscale |
原生安装 Tailscale
1 | # 1.添加官方签名密钥 |
原生命令:
1 | sudo tailscale status # 查看所有节点 |
原生安装 Headplane
1 | # Adjust as needed and set a custom user if you desire |
原生命令:
1 | # You may want to tweak the expiration duration as needed |
参考资料
Headscale
- 官方安装文档:https://headscale.net/stable/setup/install/official/
- 快速上手:https://headscale.net/stable/usage/getting-started/
- Web UI 集成说明(社区项目清单):https://headscale.net/stable/ref/integration/web-ui/
- DERP 配置:https://headscale.net/stable/ref/derp/
- 反向代理集成:https://headscale.net/stable/ref/integration/reverse-proxy/
- 源码仓库:https://github.com/juanfont/headscale
Headplane
- 文档:https://headplane.net
- Docker 安装:https://headplane.net/install/docker
- 源码仓库:https://github.com/tale/headplane
Tailscale