一、背景说明

很多人说“自建 Tailscale 服务端”,严格来说,通常指的是:

  • 使用 Headscale 作为自建控制平面
  • 使用 Tailscale 官方客户端接入 Headscale

也就是说:

  • Tailscale 官方 SaaS 控制面:不是直接自建这一套
  • Headscale:开源的自建兼容控制平面
  • Tailscale 客户端:Windows / macOS / Linux / iOS / Android 都可以接入

这套方案适合:

  • 家庭设备组网
  • 公司内网穿透
  • 多设备互联
  • 远程桌面、SSH、NAS 管理
  • 不想依赖官方控制面

二、环境说明

本文示例环境:

  • 服务端系统:CentOS Stream 10
  • 容器运行时:Podman
  • 反向代理:Nginx
  • 控制面:Headscale
  • 客户端:Tailscale

三、部署目标

最终实现以下效果:

  1. Headscale 运行在容器中
  2. Nginx 提供 HTTPS 入口
  3. 客户端通过 --login-server=https://你的域名 接入
  4. 节点支持长期在线
  5. 支持查看节点、生成预授权 key、后续多设备接入

四、目录规划

建议统一使用如下目录:

/opt/headscale/
├── config/
│   ├── config.yaml
│   └── acl.hujson
└── lib/

创建目录:

sudo mkdir -p /opt/headscale/config
sudo mkdir -p /opt/headscale/lib

五、安装 Podman 和 Nginx

sudo dnf -y install podman nginx
sudo systemctl enable --now nginx

如果系统启用了防火墙,放行端口:

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --permanent --add-port=3478/udp
sudo firewall-cmd --reload

说明:

  • 80/tcp:HTTP
  • 443/tcp:HTTPS
  • 3478/udp:STUN / DERP 相关端口

六、Headscale 配置文件

创建 /opt/headscale/config/config.yaml

server_url: https://你的域名
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

tls_cert_path: ""
tls_key_path: ""

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

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

derp:
  server:
    enabled: true
    region_id: 999
    region_code: "custom"
    region_name: "Headscale DERP"
    verify_clients: true
    stun_listen_addr: "0.0.0.0:3478"
    private_key_path: /var/lib/headscale/derp_server_private.key
    automatically_add_embedded_derp_region: true
    ipv4: 你的公网IPv4
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  paths: []

database:
  type: sqlite
  debug: false
  gorm:
    prepare_stmt: true
    parameterized_queries: true
    skip_err_record_not_found: true
    slow_threshold: 1000
  sqlite:
    path: /var/lib/headscale/db.sqlite
    write_ahead_log: true
    wal_autocheckpoint: 1000

log:
  level: info
  format: json

node:
  expiry: 0

policy:
  mode: file
  path: /etc/headscale/acl.hujson

dns:
  magic_dns: true
  base_domain: tail.example.com
  override_local_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 1.0.0.1
      - 2606:4700:4700::1111
      - 2606:4700:4700::1001
  split: {}
  search_domains: []
  extra_records: []

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

logtail:
  enabled: false

randomize_client_port: false

taildrop:
  enabled: true

关键说明

1. server_url

写成你最终对外提供服务的 HTTPS 地址,例如:

server_url: https://hs.example.com

2. listen_addr

在容器场景下,建议:

listen_addr: 0.0.0.0:8080

不要写成容器内部的 127.0.0.1:8080,否则宿主机端口映射后可能无法访问。

3. node.expiry: 0

这项很关键,表示禁用节点过期
这样设备首次接入后,后续重启不会因为节点过期而要求重新登录。

4. base_domain

必须是一个合法 FQDN,并且不要和 server_url 使用同一个域名。例如:

  • server_url: https://hs.example.com
  • base_domain: tail.example.com

5. policy.path

这里指定 ACL 文件路径,必须和容器挂载路径保持一致。


七、ACL 配置文件

创建 /opt/headscale/config/acl.hujson

{
  "tagOwners": {
    "tag:server": ["root@"],
    "tag:router": ["root@"],
    "tag:exit": ["root@"]
  },
  "acls": [
    {
      "action": "accept",
      "src": ["*"],
      "dst": ["*:*"]
    }
  ],
  "autoApprovers": {
    "routes": {
      "192.168.1.0/24": ["tag:router"]
    },
    "exitNode": ["tag:exit"]
  }
}

说明

  • 这是一个全放通的起步 ACL,适合先验证网络互通
  • tagOwners 中的 root@ 要替换成你实际使用的 Headscale 用户
  • 后续可以再细化权限

八、启动 Headscale 容器

sudo podman run -d \
  --name headscale \
  --restart=always \
  -p 127.0.0.1:8080:8080 \
  -p 3478:3478/udp \
  -v /opt/headscale/config:/etc/headscale:Z \
  -v /opt/headscale/lib:/var/lib/headscale:Z \
  docker.io/headscale/headscale:latest \
  serve

说明:

  • 127.0.0.1:8080:8080:仅暴露给本机,供 Nginx 反代
  • 3478:3478/udp:开放 STUN / DERP

查看运行状态:

podman ps -a
podman logs --tail=50 headscale

本机健康检查:

curl http://127.0.0.1:8080/health

如果正常,返回:

{"status":"pass"}

九、Nginx 反向代理配置

创建 /etc/nginx/conf.d/headscale.conf

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    listen [::]:80;
    server_name 你的域名;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    server_name 你的域名;

    ssl_certificate /etc/letsencrypt/live/你的域名/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/你的域名/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 https;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_read_timeout 3600;
        proxy_send_timeout 3600;
    }
}

为什么一定要加 Upgrade / Connection

Headscale 在反代后需要正确处理 WebSocket / TS2021 相关请求。
如果 Nginx 没有透传 Upgrade 头,客户端可能出现:

  • 登录失败
  • /machine/register 返回 500
  • 日志提示 No Upgrade header in TS2021 request

检查配置并重载:

sudo nginx -t
sudo systemctl reload nginx

十、HTTPS 证书

可以使用 Let’s Encrypt + Certbot。

申请成功后,测试:

curl https://你的域名/health

如果正常,返回:

{"status":"pass"}

这说明:

  • 域名解析正常
  • HTTPS 正常
  • Nginx 反代正常
  • Headscale 已经可公网访问

十一、创建用户

查看现有用户:

podman exec -it headscale headscale users list

创建用户:

podman exec -it headscale headscale users create root

再次查看:

podman exec -it headscale headscale users list

注意:

  • 某些命令用的是数字 ID
  • 不是所有地方都接受用户名字符串

十二、生成预授权 Key

生成可复用、24 小时有效的 key:

podman exec -it headscale headscale preauthkeys create --user 1 --reusable --expiration 24h

说明:

  • --user 1 中的 1 是用户数字 ID,不是用户名
  • --expiration 24h 表示 key 只用于首次注册设备时的有效期
  • 已经接入成功的设备,后续在线主要依赖 node key,不会因为这把 preauth key 过期就失联

十三、客户端接入

1. 首次接入

tailscale up --login-server=https://你的域名 --authkey=你的预授权key

2. 指定设备名

设备名必须符合 DNS label 规则:

  • 不能有空格
  • 不能有中文
  • 不能有特殊字符
  • 推荐全小写

例如:

tailscale up --login-server=https://你的域名 --authkey=你的预授权key --hostname=macbookpro

或者已接入后修改:

tailscale set --hostname=macbookpro

3. 查看客户端状态

tailscale status
tailscale ip
tailscale status --json

十四、服务端查看节点

查看节点列表:

podman exec -it headscale headscale nodes list

示例输出:

ID | Hostname   | Name       | User | IP addresses                  | Connected | Expired
1  | macbookpro | macbookpro | root | 100.64.0.1, fd7a:...::1       | online    | no

常用观察点:

  • Connected: 是否在线
  • Expired: 是否过期
  • IP addresses: 节点分配到的地址
  • User: 节点归属用户

十五、常用命令汇总

Headscale 侧

查看容器

podman ps -a

查看日志

podman logs --tail=50 headscale
podman logs -f headscale

查看健康状态

curl http://127.0.0.1:8080/health
curl https://你的域名/health

配置校验

podman exec -it headscale headscale configtest

用户管理

podman exec -it headscale headscale users list
podman exec -it headscale headscale users create root

预授权 key

podman exec -it headscale headscale preauthkeys create --user 1 --reusable --expiration 24h

节点查看

podman exec -it headscale headscale nodes list

重启容器

podman restart headscale

Nginx 侧

检查配置

nginx -t

重载配置

systemctl reload nginx

查看状态

systemctl status nginx --no-pager

客户端侧

首次接入

tailscale up --login-server=https://你的域名 --authkey=你的预授权key

指定设备名

tailscale set --hostname=macbookpro

查看状态

tailscale status
tailscale ip
tailscale status --json

查看后台日志

tailscale debug daemon-logs

注销

tailscale logout

十六、几个容易踩的坑

1. ACL 文件没有挂载到容器

如果日志报:

open /etc/headscale/acl.hujson: no such file or directory

说明容器里看不到 ACL 文件,要检查目录挂载是否正确。

2. 容器里 listen_addr 配成 127.0.0.1

如果 Headscale 运行在容器里,listen_addr 不应写成容器内部的 loopback,否则主机映射后访问可能失败。

推荐:

listen_addr: 0.0.0.0:8080

3. Nginx 未透传 Upgrade 头

如果日志报:

No Upgrade header in TS2021 request

通常说明 Nginx 没有正确代理 WebSocket/Upgrade 请求。

4. --user 传了用户名而不是数字 ID

有些 Headscale CLI 参数要求的是用户数字 ID,不是用户名。

5. 设备名带空格

下面这种会报错:

tailscale up --hostname="MacBook Pro"

因为空格不是合法 DNS label。

正确示例:

tailscale up --hostname=macbookpro

十七、关于 key 过期

很多人担心:

如果 preauth key 过期了,设备下次重启还能不能连?

答案是:

  • 新设备注册:受 preauth key 有效期限制
  • 已经注册成功的老设备:后续主要依赖 node key
  • 配了 node.expiry: 0 后,老设备一般不会因为节点过期而掉线

也就是说,preauth key 不是每次开机都要重新用的