我的私人家庭网络最早用的是Zerotier,但是发现连接不稳定,即使我的两个节点都有公网IP有时也会连不上,后来换了WireGuard就非常稳定。但最近新买了几个VPS,又有朋友家加入了网络,节点变多,安全规则也更复杂,WireGuard维护起来有点力不从心了,于是下定决心部署Headscale切换到Tailscale网络。

网上关于Headscale和Tailscale的资料不少,但很多都是重复的,而且不少还在用裸机部署Headscale,大段的介绍怎么用systemd配置自动启动,看得我无语。另外,有些资料可能过时或者没有说清楚,甚至Tailscale的官方文档都不一定完全正确。最后,Headscale的实现并没有包含Tailscale的所有功能,很容易踩坑。本文是半记录半教程性质,会介绍我的部署方式和配置,但细节需要读者自己理会并且随机应变。

环境和方案

本文介绍的部署环境和方案如下:

  • Headscale版本0.27.1;选用Headplane作为UI管理界面,版本0.6.1;Docker版本29.1.1。该版本的Headscale要求客户端tailscale至少是1.64.0,目前的tailscale最新版是1.90.9

  • Headscale部署在海外服务器,使用内建的DERP服务;同时在境内再起一台DERP服务器加速连接。主要是因为我的域名没有备案,没法把Headscale直接部署在境内。我尝试过自签证书并且直接使用IP,Ubuntu上可以添加系统CA,没有问题,但我有客户端需要用OpenWrt,死活加不上证书,只能放弃

  • 本文例子中会使用以下参数,请读者根据自己的情况自行调整:

    • Headscale+Headplane服务器

      • Headscale域名hs.example.com
      • IP地址48.1.1.1
      • HTTPS监听在40000
      • 内建DERP的STUN监听在UDP端口41000
      • 其它metricsgrpc端口都忽略
      • 使用Caddy反代Headscale和Headplane。Caddy通过ACME获取证书,使用DNS-01 challenge,域名放在Cloudflare,API key是cloudflare-dns-api-key
      • Headplane对外域名是hp.example.com,复用端口40000,同时我启用了mTLS保证安全,读者可以自行决定需不需要mTLS
    • 境内DERP服务器

      • IP地址47.1.1.1
      • HTTPS监听在40000
      • STUN监听在41000
      • 不需要HTTP端口
      • 不需要开放ICMP(官方文档说需要,但实测不需要也能跑)
      • 使用自签证书

Headscale服务器配置

准备以下配置文件,本文给出的配置文件限于篇幅,会删除官方注释或省略部分内容,可以到官方仓库中找到example查看相应的注释。

docker-compose.yaml

services:
  headscale:
    image: headscale/headscale:v0.27.1
    container_name: headscale
    restart: unless-stopped
    volumes:
      - ./headscale/config.yaml:/etc/headscale/config.yaml
      - ./headscale/derp.yaml:/etc/headscale/derp.yaml
      - ./headscale/data:/var/lib/headscale
      - ./headscale/run:/var/run/headscale
    ports:
      - "41000:41000/udp"
    command: serve
    networks:
      - headscale-net

  headplane:
    image: ghcr.io/tale/headplane:0.6.1
    container_name: headplane
    restart: unless-stopped
    volumes:
      - ./headplane/config.yaml:/etc/headplane/config.yaml
      - ./headscale/config.yaml:/etc/headscale/config.yaml
      - ./headplane/data:/var/lib/headplane
      - /var/run/docker.sock:/var/run/docker.sock:ro  #见下文说明
    networks:
      - headscale-net

  caddy:
    build:
      context: caddy
      dockerfile: Dockerfile
    container_name: caddy
    restart: unless-stopped
    user: 1000:1000
    ports:
      - "40000:40000"
    environment:
      CF_API_TOKEN: cloudflare-dns-api-key
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy/certs:/certs:ro
      - ./caddy/data:/data
      - ./caddy/config:/config
    networks:
      - headscale-net

networks:
  headscale-net:

volumes:
  headscale-run:

headscale/config.yaml

Headscale配置文件,官方示例。如果不需要额外的DERP服务器,可以将derp.paths设为空数组。

server_url: https://hs.example.com:40000
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
  private_key_path: /var/lib/headscale/noise_private.key
prefixes:
  v4: 100.64.0.0/10
  allocation: sequential
derp:
  server:
    enabled: true
    region_id: 999
    region_code: "headscale"
    region_name: "Headscale Embedded DERP"
    verify_clients: true
    stun_listen_addr: "0.0.0.0:41000"
    private_key_path: /var/lib/headscale/derp_server_private.key
    automatically_add_embedded_derp_region: true
    ipv4: 48.1.1.1
  urls: []
  paths: ["/etc/headscale/derp.yaml"]
  auto_update_enabled: true
  update_frequency: 24h
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
# database,log,unix_socket保持默认即可,省略
policy:
  mode: database
dns:
  magic_dns: true
  base_domain: ts.net
  override_local_dns: false
  nameservers:
    global: ["223.5.5.5"]
    split: {}
  search_domains: []
  extra_records: []
# 这个设置为true会使tailscale忽略启动时的--port参数使用随机端口
randomize_client_port: false

headscale/derp.yaml

配置额外的DERP服务器,如果不需要就可以不用,记得在docker-compose.yaml里删掉这个volume。

regions:
  900:
    regionid: 900
    regioncode: china
    regionname: China
    nodes:
      - name: node
        regionid: 900
        hostname: 47.1.1.1
        ipv4: 47.1.1.1
        stunport: 41000
        derpport: 40000
        insecurefortests: true
        stunonly: false
        canport80: false

headplane/config.yaml

Headplane配置文件,官方示例。如果不需要Web UI,也可以不部署。

server:
  host: "0.0.0.0"
  port: 3000
  cookie_secret: "长度为32的随机字符串请自行生成"
  cookie_secure: true
  cookie_max_age: 86400
  data_path: "/var/lib/headplane"
headscale:
  url: "http://headscale:8080"
  public_url: https://hs.example.com:40000
  config_path: "/etc/headscale/config.yaml"
  config_strict: true
integration:
  agent:
    enabled: false
  docker:
    # 见下文说明
    enabled: false
    container_name: "headscale"
    # container_label: "me.tale.headplane.target=headscale"
    # socket: "unix:///var/run/docker.sock"

caddy/Caddyfile

Caddy配置文件,官方文档

{
    https_port 40000
    auto_https disable_redirects
    admin off
}

(cloudflare) {
    dns cloudflare {env.CF_API_TOKEN}
    resolvers 1.1.1.1
}

hs.example.com:40000 {
    tls {
        import cloudflare
    }
    reverse_proxy headscale:8080
}

hp.example.com:40000 {
    tls {
        import cloudflare

        # mTLS客户端验证,如果headplane不暴露在公网可以不用
        client_auth {
            trust_pool file /certs/my_CA.crt
            mode require_and_verify
        }
    }
    reverse_proxy headplane:3000
}

caddy/Dockerfile

这是用来创建caddy镜像的Dockerfile,caddy的dns都是以插件形式提供的,需要自己编译。如果读者使用别的DNS,请自行换成所使用的DNS,caddy官方支持的DNS可以在这里找到。

FROM caddy:builder AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare

FROM caddy:latest

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

部署说明

我这里容器的用户都是1000:1000,因此在启动之前要保证挂载进容器的目录都是1000用户可读写的,建议事先创建,如果文件夹不存在Docker自行创建的话owner是root,会有权限问题。

全部准备好后,运行sudo docker-compose up -d就搞定了。

通过https://hp.example.com:40000/admin/访问Headplane,第一次访问会要求输入Headscale Key,使用以下命令生成:

sudo docker exec headscale headscale apikeys create -e 999d

建议保留这个Key,因为每换一个浏览器都要重新输入,每次都重新生成的话麻烦。

然后在安装了tailscale的客户端上运行:

sudo tailscale up --login-server https://hs.example.com:40000

按步骤操作即可登录。

DERP服务器

部署DERP服务器需要准备好自签证书,将私钥和证书分别命名为47.1.1.1.key47.1.1.1.crt放在certs文件夹内。请自行替换成自己的IP或者域名(备案了的话),这个命名规范是DERP要求的。

然后准备docker-compose.yaml

services:
  derper:
    image: fredliang/derper:latest
    container_name: derper
    restart: unless-stopped
    ports:
      - 41000:41000/udp
      - 40000:40000
    environment:
      DERP_DOMAIN: "47.1.1.1"
      DERP_CERT_MODE: manual
      DERP_CERT_DIR: /certs
      DERP_ADDR: ":40000"
      DERP_STUN: "true"
      DERP_STUN_PORT: "41000"
      DERP_HTTP_PORT: "-1"
      # 见下文说明
      DERP_VERIFY_CLIENTS: "true"
      # DERP_VERIFY_CLIENT_URL: https://hs.example.com:40000/verify
    volumes:
      - ./certs:/certs
      # 见下文说明
      - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock

使用sudo docker-compose up -d运行,需要注意的是,我这个服务器同时也是一个tailnet节点,所以可以用/var/run/tailscale/tailscaled.sock来验证已登录客户端。如果不是的话,考虑用DERP_VERIFY_CLIENT_URL代替,但可能有坑,见下文说明。

在节点上使用tailscale netcheck以及tailscale debug derp-map验证是否获取并且连接到DERP服务器。

踩坑记录

Headplane和Docker兼容性问题

Docker 29版本提高了最低API版本要求,导致很多客户端不兼容,之前Portainer就遇到了这个问题,这次Headplane又遇到了,所以上面配置文件integration.docker.enabled设置为true那么无论如何都是连不上的。好在这个问题已经在这个PR里修复了,等新版本发布应该就能解决。

实现限制

部署过程中发现了一些Headscale没有实现的功能,越是偏门的功能越有可能没有被实现,我遇到的问题有:

  • ACL不支持IP sets,见这个issue,开发人员正在全力开发新的grants功能,估计没空管旧的ACL了,好在可以通过展开形式表达,就是会有重复
  • DNS不支持wildcard,例如*.example.com,见这个issue,不过这似乎是tailscale本身的限制

DERP服务

部署DERP服务的过程中遇到了两个坑,一个是InsecureForTests的问题,这个flag对自签证书的环境至关重要,告知tailscale忽略证书错误。一开始看了这个帖子里说headscale使用本地yaml文件读取DERP配置的的时候,不支持InsecureForTests,必须要用URL形式,完全被带歪了,这篇文章说的才是对的,本地yaml文件完全也支持InsecureForTests

还有就是derper的两个参数--verify-clients--verify-client-url(在上面的docker-compose.yaml中它们通过DERP_VERIFY_CLIENTSDERP_VERIFY_CLIENT_URL环境变量设置),这是为了防止DERP服务器被白嫖。我一开始以为要验证客户端必须访问Headscale,于是就想用--verify-client-url,并且想当然地以为--verify-clients是开关,设置为true时,--verify-client-url才会生效。试了半天才知道这两个参数是互斥值,--verify-clients的意思是从本地的/var/run/tailscale/tailscaled.sock获取用户信息,要想使用--verify-client-url必须先把--verify-clients设置成false。但是我使用--verify-client-url时DERP依旧无法验证客户端,可能是网络关系,毕竟我的Headscale服务器在境外。不过这时我突然意识到验证客户端其实只需要公钥信息,即使有人假冒了公钥,没有私钥的情况下他依旧无法和网络中的其它节点通信,于是用本地的tailscale节点信息验证客户端也就顺理成章了。

阿里云兼容问题

阿里云内网也会用到100.64.0.0/10这个CGNAT网段,例如阿里云的DNS服务器就是100.100.2.136,APT镜像服务器mirrors.cloud.aliyuncs.com也在这个网段,而tailscale为了安全默认会在iptables里插入一条规则,丢弃所有不是从tailscale网卡中进入本机的100.64.0.0/10数据包,规则如下所示:

Chain ts-input (1 references)
 pkts bytes target     prot opt in          out     source               destination
    0     0 ACCEPT     0    --  lo          *       100.64.0.4           0.0.0.0/0
    0     0 RETURN     0    --  !tailscale0 *       100.115.92.0/23      0.0.0.0/0
    0     0 DROP       0    --  !tailscale0 *       100.64.0.0/10        0.0.0.0/0
11231  587K ACCEPT     0    --  tailscale0  *       0.0.0.0/0            0.0.0.0/0
13256 1659K ACCEPT     17   --  *           *       0.0.0.0/0            0.0.0.0/0            udp dpt:41641

这就导致阿里云的各种内部服务全都不可用。网上有人干脆不用阿里云的内部服务,但我会用到阿里云的镜像服务,必须得用,于是干脆禁用tailscale的安全规则,反正看着问题不大:

sudo tailscale up \
    --accept-dns=false \
    --netfilter-mode=nodivert \
    --login-server https://hs.example.com:40000

关键参数是--netfilter-mode=nodivert,tailscale依旧会创建ts-input以及其它规则链,但不会把它插入到INPUT链里,所以它实际上没有生效。

后记

到这里整套服务已经可以正常运行了,只需要在每台设备上登录一下即可,我这里就不再详述了。此外还有配置ACL的过程,在ChatGPT的帮助下,除了踩了点小坑,还是很顺利的。tailscale整体的使用体验非常好,完全解决了WireGuard的痛点。如果DNS功能可以更强大的话,我甚至考虑把DNS也搬到tailnet上,其次还有SSH,service等一大堆高级功能,等headscale支持grants以后,也要再折腾一番。