为什么有了 Tailscale 还要自建 Headscale
上一篇文章里我写过用 Tailscale 把多台 VPS 连成私网,那套方案确实好用:装完客户端、登录、三台机器就像插了同一根网线。但用久了会发现一个绕不过去的问题——控制权不在你手里。Tailscale 的协调服务器(control plane)是官方托管的,你的设备信息、公钥、ACL 规则、登录身份(Google / GitHub / 微软账号)全都要交给它。对于个人站来说这通常无所谓,但如果你是出于「数据自主」才自托管的那类人,这就是心结;更现实的是,一旦官方控制面抽风或者网络受限连不上 login.tailscale.com,你的整个私网就瘫痪了,连设备间直连都做不了。
Headscale 就是来解决这个问题的:它是 Tailscale 控制面的开源、可自托管实现。客户端还是你熟悉的 Tailscale,协议完全兼容,但协调逻辑跑在你自己的服务器上。本文记录我把它跑起来的完整过程,包括 DERP 中继、ACL、以及那些官方文档没写清楚的坑。
理解 Tailscale 的架构,才知道 Headscale 换了什么
要搞懂 Headscale,得先分清 Tailscale 的「控制面」和「数据面」:
- 控制面(Control Plane):负责设备注册、公钥交换、下发 ACL 规则、协调 NAT 打洞。它不转发你的业务流量,只是告诉每台设备「对方在哪、公钥是什么」。
- 数据面(Data Plane):设备之间直接建立 WireGuard 隧道走加密流量。如果 NAT 打洞失败,才退回到 DERP 中继服务器转发(此时流量经过中继,但仍是端到端加密的,中继看不到明文)。
Headscale 替换的是控制面。你把 Tailscale 客户端的控制地址指向自己的 Headscale 服务器(login-server),设备就注册到你的协调服务器上,后面的打洞和加密逻辑完全不变。官方默认提供的 DERP 中继也可以自建,这样连中继都不依赖别人。
一句话:Headscale = 自托管的 Tailscale 控制面 + 兼容官方客户端。
准备工作:域名、服务器与端口
Headscale 的硬性要求不多,但有几条最好提前满足:
- 一台有公网 IP 的服务器:作为控制面,客户端必须能访问到它。最好是固定 IP 的 VPS。
- 一个域名指向它,比如
hs.example.com。因为 Tailscale 客户端强制要求控制地址是 HTTPS(旧版本可以配 http,新版本基本不行了),所以必须有证书。 - 开放端口:443/tcp(控制面 API,走 HTTPS),以及如果自建 DERP 的话需要 3478/udp(STUN)和 443 或 8443 端口的 DERP。纯控制面 + 用官方 DERP 的话,只开 443 就够了。
- 推荐思路:控制面可以套一层 Cloudflare(把 DNS 设为橙云),这样源站 IP 被隐藏、还能白嫖 TLS(但要注意 Cloudflare 代理只支持标准 443,DERP 的 UDP 走不了 CF)。
我的做法是:Headscale 本体通过 Docker 部署在 127.0.0.1:8080,前面用 Nginx 反代并挂 Let's Encrypt 证书,对外只暴露 443。这样和前面几篇文章里的自托管服务风格一致,也方便统一管理。
部署 Headscale:Docker + 配置分离
先建目录和配置文件。/opt/headscale/config/config.yaml 是核心配置,几个关键项必须改:
server_url: https://hs.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
# 私网网段,默认 100.64.0.0/10,除非冲突否则别改
ip_prefixes:
- 100.64.0.0/10
# 数据库:sqlite 单人站最省心
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
# 关键:私钥目录,务必持久化并备份
private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
# DNS 配置,客户端会拿到这个
dns_config:
override_local_dns: true
nameservers:
- 1.1.1.1
- 8.8.8.8
base_domain: hs.example.com
# 预注册密钥策略(下面细说)
ephemeral_node_inactivity_timeout: 30m
然后是用 Docker Compose 启动。注意 Headscale 的老版本和 0.23+ 版本配置项变化较大(比如 ip_prefixes 后来改成了 prefixes),一定要对照你拉取的那个镜像版本对应的官方示例配置来改,照抄旧教程会启动失败。
services:
headscale:
image: headscale/headscale:0.23.0-alpha5
container_name: headscale
restart: unless-stopped
command: serve
ports:
- "127.0.0.1:8080:8080"
volumes:
- "/opt/headscale/config:/etc/headscale"
- "/opt/headscale/data:/var/lib/headscale"
docker compose up -d 之后,docker compose logs -f 里应该看到数据库初始化、监听 8080 的日志。若有配置项报错,通常是版本不匹配,回退到简单配置逐项加。
反向代理与 HTTPS:让客户端连得上
Tailscale 客户端对控制面的证书校验很严格,必须是有効的公共 CA 证书,自签名不行(除非用 --tls-cert-file 也不如直接用 Let's Encrypt 省事)。Nginx 配置:
server {
listen 443 ssl http2;
server_name hs.example.com;
ssl_certificate /etc/letsencrypt/live/hs.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hs.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # 长连接,控制面用得上
proxy_read_timeout 300s;
proxy_buffering off;
}
}
配好后浏览器访问 https://hs.example.com/health 应该返回 {"status":"pass"},这是最直接的验收方式。如果返回 502,检查 Headscale 容器是否真在监听;如果证书报错,确认 Certbot 续期任务正常。
这里有个坑:如果你在 Headscale 前面套了 Cloudflare 橙云,Cloudflare 的 100 秒超时会影响控制面的长轮询(long-poll)。Tailscale 客户端用长连接接收控制面推送,超时后会频繁重连、表现为「节点状态刷新慢」。解决办法是给 / 路径的 proxy_read_timeout 设长一点,或者干脆把 Cloudflare 设为灰云(DNS only)只做解析不透传流量。
注册第一台设备:预授权密钥与用户
Headscale 里「用户(user/namespace)」是逻辑隔离单位,每台设备归属于某个用户。先建一个用户:
docker exec headscale headscale users create admin
接着要生成一个预授权密钥(pre-auth key)。这个 key 的作用是让客户端免去交互式登录(Headscale 没有 OIDC 时,交互式登录会返回一个 URL,需要手动确认,自动化场景很麻烦):
docker exec headscale headscale preauthkeys create --user admin --reusable --expiration 24h
拿到一串 key 后,在客户端机器上执行:
tailscale up --login-server https://hs.example.com --authkey--advertise-routes=192.168.1.0/24
--login-server 是灵魂参数,它把客户端从官方控制面切到你的 Headscale。--authkey 让它跳过交互确认。--advertise-routes 是子网路由,把客户端所在的内网段通告出去(下面详述)。
注册成功后,在服务器上跑 docker exec headscale headscale nodes list 就能看到这台设备。也可以 headscale nodes register --user admin --key 手动注册。
⚠️ 一个高频坑:很多教程让客户端跑 tailscale login,但在自建 Headscale 环境下要统一用 --login-server,否则客户端还是连官方。另外新版本客户端把 tailscale up 的部分参数挪到了配置文件,遇到「unknown flag」就查你客户端版本对应的文档。
子网路由与出口节点
子网路由器(subnet router)是 Headscale 最实用的特性之一:如果你家里有台 NAS 或路由器,跑上 Tailscale 客户端并通告它所在的内网段,那么你在外面任何一台装了 Tailscale 的设备就能直接访问家里的内网 IP,无需端口映射。
两步:先是客户端通告 + 开 IP 转发(net.ipv4.ip_forward=1,写进 /etc/sysctl.d/ 持久化),然后在 Headscale 服务端批准路由:
docker exec headscale headscale routes list # 看到 pending 的路由 docker exec headscale headscale routes enable -r 1 # 按 route id 批准
服务端不批准,客户端通告了也没用,这是数量最多的「配了不生效」来源。
出口节点(exit node)则是把所有公网流量都从某台设备出去,常用于「在家用国外 VPS 的线路」或者「在外面伪装成家里网络」。客户端用 --advertise-exit-node 通告,服务端 headscale routes enable 批准,其它设备用 --exit-node=<IP> 指向它。注意出口节点的带宽会成为所有流量的瓶颈,别用小水管机器硬扛大流量。
ACL:默认拒绝,按需放行
Headscale 的 ACL 策略写在 config.yaml 的 acl 段或者单独的 policy 文件里,语法和 Tailscale 的 HuJSON 兼容。核心原则是默认全拒,显式放行:
{
"acls": [
{ "action": "accept", "src": ["admin"], "dst": ["admin:*"] },
{ "action": "accept", "src": ["group:dev"], "dst": ["tag:web:80,443"] }
],
"tagOwners": {
"tag:web": ["admin"],
"tag:db": ["admin"]
}
}
这里 src 可以用用户名、tag:xxx(给设备打标签)、group:xxx(先定义用户组)。不写 ACL 段时,Headscale 默认允许同一用户下所有设备互通;一旦写了 ACL,就变成「未匹配即拒绝」,务必先确认规则覆盖了你要用的全部通路,否则会把自己锁在外面。
修改 ACL 后需要重启 Headscale 让策略生效,客户端会在下次同步时拿到新规则。排障时用 docker exec headscale headscale policy get 确认规则被正确解析。
自建 DERP 中继(可选但推荐)
即便控制面自建了,如果两台设备 NAT 打洞失败,流量还是会走 Tailscale 官方的 DERP 中继——这等于元数据(谁和谁通信)又漏回官方了。要彻底自主,可以自建 DERP:
DERP 服务器需要 UDP 3478(STUN)、以及 TLS 端口(通常 443 或 8443)。用官方 derper 二进制或社区镜像部署,然后用 Headscale 的 derp 配置段把自己的 DERP 地址告诉客户端:
derp:
urls:
- https://hs.example.com/derp
paths: []
坑在于:DERP 的 UDP 端口走不了 Cloudflare 代理,如果控制面套了 CF,DERP 得用另一个不套 CF 的子域名。另外自建 DERP 也要证书,配置比控制面本身还琐碎,建议先在只有控制面的情况下跑通,再按需中继。
尾巴:六个坑与运维建议
- 控制地址必须 HTTPS 且证书有效:自签名证书客户端不认,直接用 Let's Encrypt。
- 子网路由必须在服务端批准:通告了但一直 pending,客户端表现就是「连不上内网」。
- 配置文件版本不匹配:Headscale 迭代快,旧教程的
ip_prefixes/private_key_path等字段可能已变,照抄会启动失败。 - 套了 Cloudflare 后长轮询超时:控制面连接不稳定、状态刷新慢,考虑灰云或调长 proxy 超时。
- ACL 一写就全拒:没覆盖到的通路默认被拒,改之前先备份配置。
- 私钥目录没持久化:
noise_private.key和private.key丢了,所有设备都要重新注册。务必纳入备份。
最后是运维层面:Headscale 的数据库(SQLite)和两个私钥文件是整系统的命根子,一定要加进你的异地备份流程。日常巡检用 headscale nodes list 看有无掉线设备,headscale routes list 看路由是否正常。永远不要把 headscale 的 API 直接暴露公网——它本身没有用户级认证,暴露等于把整个私网的管理权限送人,正确的做法是会 SSH 进服务器操作,或者加一层认证网关。
Headscale 这类工具的价值,用一句话概括就是:把「私有网络」这件事的最终解释权拿回自己手里。它不完美,更新快、文档碎,但当你真的需要掌控一切时,它是目前最接近那个目标的成熟方案。