Headscale + Headplane 三端多活组网折腾实录(异地办公 + 多重安全)

从”家里想直连公司内网”这个朴素需求出发,一路做到 hk_main / fn-NAS / DSM 三端 Headscale 完全独立多活 + 自建 DERP + Headplane 管理面板 + Authelia 2FA 前置鉴权 + ACL autoApprovers + DB 单向同步的完整方案。

本文是笔记性质,尽量把过程中的坑、判断、命令、边界情况都记下来,方便日后翻阅。

一、背景与目标

在家和公司之间需要一条稳定、低延迟、可控的私网通道:

  • 异地办公:家里的 Mac 要能像在公司局域网一样访问 gitlab.corp.example10.20.30.40 这类内网资源。
  • 完全自主:不依赖 Tailscale 官方控制面,也不依赖 Tailscale 官方 DERP —— 数据面、控制面、管理面都在自己手里。
  • 多重安全保障:控制面本身要有认证、管理面要有 2FA、tailnet 之间要有 ACL 授权、路由 auto-approve 要受 user 约束。
  • 多活容灾:任何一台服务器(香港 VPS / 家里 NAS / 群晖)挂了,都能秒切到另一台,不影响使用。带宽差异大(香港带宽小、家里 IPv6 大带宽),要能自由选。

技术栈的选择:Headscale(Tailscale 控制面的开源实现,纯 Go,兼容官方 tailscale 客户端)+ Headplane(Headscale 的第三方 Web 管理面板,替代常见但已落后的 goodieshq/headscale-admin)+ Authelia(SSO + 2FA,保护 Web UI)+ s-ui / Clash(客户端出站分流,保证 Headscale/DERP 流量走直连)。

二、总体架构

三台机器各自跑一份 Headscale + Headplane,DB 由 hk_main 单向同步到 DSM 和 fn-NAS,客户端通过 switch_headscale.sh 秒切 profile。

hk_main (香港 VPS) fn-NAS (家里飞牛) DSM-6400 (家里群晖)
控制面 URL https://headscale.example.com https://headscale.home.example.net:4433 https://headscale.nas.example.net:4433
DERP region 901 hkmain 902 fnnas (共用 hk_main 中继)
DERP HTTPS 端口 443 (haproxy → nginx → headscale) 4433
STUN UDP 公网端口 3478 3578 (Lucky 转发到内网 3478)
管理面板 Headplane 0.7.1 /admin/ 同左 同左
Authelia 2FA auth.example.com auth.home.example.net:4433 auth.nas.example.net:4433
依赖 Tailscale 官方 DERP
DB 是否是主 ✅ 主 ⬅ 单向同步自 hk ⬅ 单向同步自 hk

关键设计约束

  • 三端 DB 完全一致(同一份 users / nodes / api_keys / routes / policy),只有 cookie_secret 和证书这类”机器身份”独立。
  • 三端只共享同一把 Headplane 登录 API key,人不用背三串密码。
  • 三端各自发布只包含自己 DERP region 的 DERPMap,互不依赖 —— 挂一台不影响另外两台。
  • 客户端 profile 之间共享 machine key,切服务端只是切”谁记账”,路由批准状态跟 DB 走。

三、多重安全体系

从外向内一共四层:

3.1 第一层:域名/IP 层的最小暴露面

  • 控制面 URL 全部走 HTTPS + TLS,前面是 haproxy / nginx,后端 headscale 只监听 127.0.0.1 或容器内部。
  • 4433 这类高位端口 + IPv6 通道进一步降低被自动化扫描的概率。
  • STUN UDP 3478/3578 是必须开的,但只承载加密的 STUN 探测和 DERP 数据面,本身不接受任何鉴权凭据。

3.2 第二层:Authelia 2FA 前置管理面

Headplane 管理面挂在 nginx 的 /admin/ 下面,nginx 在这个 location 上通过 auth_request 转到 Authelia。关键是只在 /admin/ 上加,其他 location 全部放行:

# 只有浏览器访问的 /admin/ 走 Authelia
location /admin/ {
    include /etc/nginx/authelia-headscale.conf;   # auth_request → Authelia
    proxy_pass http://headplane:3000;
}

# 客户端所有 API 路径完全不受影响
location = /health         { proxy_pass http://headscale:8080; }
location /api/v1/           { proxy_pass http://headscale:8080; }   # Bearer token
location /ts2021            { proxy_pass http://headscale:8080; }   # Noise
location /derp              { proxy_pass http://headscale:8080; }
location /machine/          { proxy_pass http://headscale:8080; }
location /register/         { proxy_pass http://headscale:8080; }   # tailscale up 授权

判断依据:tailscale 守护进程是机器对机器通信,不理解浏览器的 302,一旦被 Authelia 拦立刻断线。而管理面板是浏览器场景,上 2FA 天然合适。

踩过的坑:/admin/ 返回 403 而不是期望的 401 → 302,一开始怀疑规则写错。真正原因是 configuration.yml 更新后 Authelia 容器没重启,进程里的规则表还是旧版本,请求匹配不到任何规则,落到 default_policy: deny 就返回 403。以后凡是改 authelia/config/configuration.yml 都必须 docker restart authelia

3.3 第三层:Headscale 自身的 API Key + Bearer

  • Headscale CLI / Headplane / 自动化脚本都通过 API Key(形如 hskey-api-xxx.yyy...)访问 /api/v1/*

  • API Key 有过期时间,可以随时 headscale apikeys expire 撤销。

  • 生成方式:

    docker exec headscale headscale apikeys create -e 87660h  # 10 年
  • 注意:API Key 存在 SQLite DB 里,通过后面的 DB 同步机制自动分发到三端。Headplane 用的就是这把 key。

3.4 第四层:ACL + autoApprovers

Headscale 0.29 的策略用 HuJSON,示例:

{
  "acls": [
    { "action": "accept", "src": ["*"], "dst": ["*:*"] }
  ],
  "autoApprovers": {
    "routes": {
      "10.0.0.0/8":     ["alice@"],
      "172.16.0.0/12":  ["alice@"],
      "192.168.0.0/16": ["alice@"]
    }
  }
}

要点:

  • autoApprovers.routes 的 owner 必须是 typed entity(0.29 新校验):alice@(user)/ group:admin / tag:server 之一。从 0.23 升级过来直接写 "alice" 会启动失败,报 policy load failed
  • 只对该 user 拥有的节点生效:以后加家人账号 family@ 就得追加,或者干脆改成 group:admin 更灵活。
  • ACL 主体规则 action:accept src:* dst:*:* 是”全放行”,家庭多活场景够用,如果要做部门级隔离再往里加。

四、Headplane 替换过时的 headscale-admin

历史包袱:项目起初用的是 goodieshq/headscale-admin(社区版),随着 Headscale 升级到 0.29 后 API Key prefix 显示行为变了,admin UI 直接白屏。有人尝试通过在容器里挂一份 fork 的 admin-web/ 静态资源做 JS shim 兼容,短期能跑,但:

  1. admin-web/ 不在 git 里,谁改的、改了啥全靠猜;
  2. _app/immutable/ 目录被强行改名做浏览器缓存 bust,跟真实 JS 逻辑修改混一起看不清 diff;
  3. 上游一发新版就散架,docker compose pull 直接覆盖挂载点。

正确解:换 ghcr.io/tale/headplane:0.7.1 —— 主动维护、官方声明支持 headscale 0.29,走标准的 docker-integration 模式(通过容器 label me.tale.headplane.target=headscale 找 headscale + docker.sock 触发命令)。

4.1 docker-compose.yml 片段

services:
  headscale:
    image: headscale/headscale:0.29.3
    labels:
      me.tale.headplane.target: headscale   # ← Headplane 靠这个 label 定位
    volumes:
      - ./data:/var/lib/headscale
      - ./config:/etc/headscale
    ports:
      - "127.0.0.1:8080:8080"
      - "3478:3478/udp"                     # STUN

  headplane:
    image: ghcr.io/tale/headplane:0.7.1
    depends_on: [headscale]
    volumes:
      - ./headplane/config.yaml:/etc/headplane/config.yaml:ro
      - /var/run/docker.sock:/var/run/docker.sock  # 让 headplane 能 reload headscale
    ports:
      - "127.0.0.1:3000:3000"

4.2 headplane 的 config.yaml

server:
  host: "0.0.0.0"
  port: 3000
  cookie_secret: "<32 字节 base64,每台机器独立>"
  cookie_secure: true

headscale:
  url: "http://headscale:8080"              # 容器内直连
  config_path: "/etc/headscale/config.yaml" # headplane 会通过它读元数据

integration:
  docker:
    enabled: true
    container_name: headscale
    socket: "unix:///var/run/docker.sock"

oidc:
  disable_api_key_login: false              # 允许用 API Key 登录

登录时输入的就是前面生成的 hskey-api-xxx... 完整值。三端 DB 一致,同一把 key 三端通用。

4.3 secrets 落在 git 的取舍

headplane/config.yaml 明文含 cookie_secret + api_key。这个仓库里 db.sqlitenoise_private.key 也都是明文进 git 的,所以按同一惯例处理(仓库私有)。如果仓库有公开风险,一定要改用 cookie_secret_path + docker secrets / env 变量注入

五、自建 DERP:从”依赖官方”到”三端完全独立”

5.1 为什么要自建

之前 tailscale 在家里 ping 公司 IP 延迟 800ms+,公司机 ping 家里资源也慢。tailscale netcheck 输出:

Nearest DERP: San Francisco (202.8ms)
tshttpproxy: using proxy "http://127.0.0.1:7890" for URL: "https://controlplane.tailscale.com/"

真相是 ClashX 系统代理把 tailscale 拉 DERPMap 的请求也代理走了,从美国 VPS 出去测延迟 —— 于是 tailscale 判定 SFO 最近,所有中继流量绕地球一圈。

治标(让流量走直连)+ 治本(自建 DERP)一起上。

5.2 客户端流量分流(治标)

改 s-ui 的 subClashExt,在 rules 最前面插入直连规则:

rules:
  # 让 tailscale 相关流量绕过代理
  - PROCESS-NAME,tailscaled,DIRECT
  - PROCESS-NAME,tailscale,DIRECT
  - DOMAIN-SUFFIX,tailscale.com,DIRECT
  - DOMAIN,controlplane.tailscale.com,DIRECT
  - DOMAIN-SUFFIX,derp.tailscale.net,DIRECT

  # 自家域名直连
  - DOMAIN-SUFFIX,example.com,DIRECT
  - DOMAIN-SUFFIX,example.net,DIRECT
  - DOMAIN-SUFFIX,corp.example,DIRECT
  - DOMAIN-SUFFIX,team.example,DIRECT
  - DOMAIN-SUFFIX,svc.example.io,DIRECT

  # tailnet 内 IP 直连
  - IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
  - IP-CIDR,fd7a:115c:a1e0::/48,DIRECT,no-resolve

  # ...其余规则
  - MATCH,🐟 漏网之鱼

陷阱:Clash rules 按顺序匹配,**MATCH 之后写的任何规则永远不会命中**。之前的 direct 规则被写在 MATCH 后面导致完全失效。这类坏顺序问题定期扫一下。

5.3 自建 DERP 的配置(治本)

Headscale 内建 embedded DERP,只要在 config.yaml 里打开:

derp:
  server:
    enabled: true
    region_id: 901                  # hk_main = 901, fn-NAS = 902
    region_code: "hkmain"
    region_name: "hk_main (HK)"
    stun_listen_addr: "0.0.0.0:3478"
    private_key_path: /var/lib/headscale/derp_server_private.key
    automatically_add_embedded_derp_region: true

  urls: []                          # 关掉 tailscale 官方 DERP
  paths: []                         # 不用外部 derp.yaml
  auto_update_enabled: false
  update_frequency: 24h

fn-NAS 侧的差异只在 region_id/code/name 三处,STUN 端口都是内部 3478,但 Lucky 外部转发的公网端口不同:

  • hk_main:直接暴露 UDP 3478(阿里云 ECS)
  • fn-NAS:Lucky 里外部 UDP 3578 转发到内网 UDP 3478(因为 fn-NAS 本机 3478 已被占用)

踩过的坑 1derp.paths 期望的是 YAML 格式,不是 JSON。而且 YAML 的 map key 必须是 int(901: 不能写 "901":),否则 headscale 直接报错。

踩过的坑 2:家里是纯 IPv6 公网,hk_main 只有 IPv4,两台之间没共同 IP 族,v4 ↔ v6 之间无法互 STUN。但这不影响真实使用 —— tailscale 客户端本身能拿到两个 region 的 DERPMap,各自打洞。

踩过的坑 3:Lucky 转发规则的传输类型必须勾对。STUN 只需要 UDP,家里 v6 公网就勾 udp6;有 v4 公网时勾 udp4tcp4/tcp6 对 STUN 无意义。

5.4 端口清单(异地办公场景照抄)

服务 端口 协议 说明
Headscale HTTP 8080 TCP 容器内,nginx 反代
Nginx HTTPS 443 / 4433 TCP 对外,控制面 + DERP HTTPS 中继
DERP STUN 3478 / 3578 UDP 打洞探测,必须放行
Authelia 内部 TCP 走 nginx auth_request,不对外
Headplane 3000 TCP 容器内,nginx /admin/ 反代

阿里云安全组 / 家里路由器 / Lucky 三处都要放行。iptables 加 UDP 3478 白名单要注意:如果原策略是 ACCEPT,加入规则可能触发 fw-setup.sh 把 policy 改成 DROP 影响其他服务,先 iptables -L INPUT 看 policy 再动。

六、DB 单向同步:三端多活的核心

三端多活并不意味着”随便哪台都能改”—— 那样会有分裂脑。约定:hk_main 是唯一写入端,DSM 和 fn-NAS 只读镜像。

同步脚本 scripts/sync_headscale_hk_to_fn.shscripts/sync_headscale_hk_to_dsm.sh 结构一致:

1. 在 hk_main 上用 sqlite3 .backup 做热快照到 tar.gz
2. scp 到本机中转
3. scp 到目标机
4. 目标机 docker stop headscale
5. mv 旧 db.sqlite → 备份目录
6. tar 解压到目标位置
7. 清理 db.sqlite-wal / db.sqlite-shm(这两个是 WAL 模式的残留,不清会有 checksum 不一致警告)
8. docker start headscale
9. headscale nodes list 校验节点数与源端一致

关键点:

  • **必须用 sqlite3 .backup 而不是直接 cp**:热备份是原子的,避免拷到写一半的页。
  • DSM 的 SSH 环境比较特殊:非交互式 SSH 的 PATH 里没 docker,必须用绝对路径 /usr/local/bin/docker
  • DSM 端口是 9000 不是 22DSM_SSH=root@nas.example.net DSM_PORT=9000
  • sync 之后 cookie_secret 不受影响:cookie_secret 在 headplane/config.yaml 里各机独立,DB 不涉及。API key 因为在 DB 里,所以自动统一。

日常工作流:

# 在 hk_main 的 Headplane UI / 或 CLI 做变更(加用户、改 ACL、批准 route)
docker exec headscale headscale users create alice
docker exec headscale headscale policy set -f /etc/headscale/acl.hujson

# 一键推到备端
./scripts/sync_headscale_hk_to_fn.sh
./scripts/sync_headscale_hk_to_dsm.sh

七、客户端 profile 秒切:switch_headscale.sh

tailscale 客户端支持多 profile,每个 profile 对应一个 login-server。首次登录后 profile 常驻本地,之后切换只需一条命令(几乎零延迟)。

7.1 首次登录(一台机器只要做一次)

# 香港 profile
sudo tailscale login \
  --login-server https://headscale.example.com \
  --nickname hs-hk \
  --accept-routes --accept-dns \
  --advertise-routes=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16  # 公司机才需要

# fn-NAS profile
sudo tailscale login \
  --login-server https://headscale.home.example.net:4433 \
  --nickname hs-fn \
  --accept-routes --accept-dns

# DSM profile
sudo tailscale login \
  --login-server https://headscale.nas.example.net:4433 \
  --nickname hs-dsm \
  --accept-routes --accept-dns

登录会打印一个 /register/<key> 授权 URL,浏览器打开后(如果 /register/ 被 Authelia 保护,会先跳 2FA)在 Headplane 里点批准就好。

7.2 日常切换

bash scripts/switch_headscale.sh hk        # 切香港控制面
bash scripts/switch_headscale.sh fn        # 切飞牛 NAS
bash scripts/switch_headscale.sh dsm       # 切群晖
bash scripts/switch_headscale.sh status    # 查看当前

脚本内部就是 tailscale switch <profile-id>,加上:

  • 自动从 ~/.config/switch-headscale.confADVERTISE_ROUTEStailscale set --advertise-routes ...(家里 Mac 不设置就默认不宣告,公司 Mac 设为公司内网段)
  • 校验 profile 存在性;不存在时打印首登指引
  • 优先使用 /opt/homebrew/bin/tailscale(避开 Mac App Store 版留下的 /usr/local/bin/tailscale stub —— 那个 stub 指向 /Applications/Tailscale.app,只装 brew 版会执行报 No such file,静默出空输出,脚本里的 python 一 json.load(空) 就崩)

7.3 profile 之间 machine key 是共享的

重要认知:同一台机器切不同 profile,tailscale 的 machine key 不变(存在 /Library/Tailscale/tailscaled.state 里)。所以:

  • 换服务端不用重新在 UI 里批准这台机器
  • 之前批准过的 routes 状态 (Enabled=true) 跟 machine key 走,切回来立刻可用
  • 但是 prefs(含 AdvertiseRoutes)是每 profile 独立的 —— 这就是为什么早期版本脚本漏掉 --advertise-routes 会导致每次切 profile 都要重批准

八、如何添加”新设备”

以家里刚买的一台 Mac mini 为例,全流程:

8.1 装 tailscale 客户端

brew install --cask tailscale                 # macOS
# 或 Linux: curl -fsSL https://tailscale.com/install.sh | sh

8.2 首登三个 profile

按 §7.1 跑三次(hk / fn / dsm),每次浏览器点授权即可。

8.3 配置默认 profile & 路由宣告(可选)

mkdir -p ~/.config
cat > ~/.config/switch-headscale.conf <<'EOF'
# 家里机器不宣告任何路由
ADVERTISE_ROUTES=""
EOF

公司 Mac 则:

ADVERTISE_ROUTES="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"

8.4 因为 ACL 有 autoApprovers,路由无需人工批准

只要该 machine 归属 alice@ user 并宣告的是 10/8 172.16/12 192.168/16 之一,Headscale 会自动 Enabled=true。切换 profile 后如果 route is not available on node,检查两处:

  • tailscale statusAdvertiseRoutes 是否有值 —— 无则说明 profile 首登时忘加,重跑 switch_headscale.sh 让它自动补 tailscale set --advertise-routes
  • Headplane / headscale routes listAdvertised 是否 true

8.5 首登时最常见的坑

  • 卡在 login 无输出:多半是 login-server 网络不通。另开终端 curl -v --max-time 5 https://headscale.xxx/health,返回 {"status":"pass"} 才说明能到。
  • profile 里出现 default 或其他非 hs-* 昵称:老 profile 遗留,tailscale switch --list 找到后 tailscale switch remove <id> 干掉,避免脚本误 adopt。

九、如何添加”新用户”

以给家人加账号 family 为例:

9.1 建 user

# 只在 hk_main 上做
docker exec headscale headscale users create family
docker exec headscale headscale users list

9.2 更新 ACL 让 family 也享受 autoApprovers(可选)

hk_main/data/headscale/config/acl.hujson

{
  "acls": [
    { "action": "accept", "src": ["*"], "dst": ["*:*"] }
  ],
  "autoApprovers": {
    "routes": {
      "10.0.0.0/8":     ["alice@", "family@"],
      "172.16.0.0/12":  ["alice@", "family@"],
      "192.168.0.0/16": ["alice@", "family@"]
    }
  }
}

或者建 group:

"groups": {
  "group:admin": ["alice@", "family@"]
},
"autoApprovers": {
  "routes": {
    "10.0.0.0/8": ["group:admin"],
    ...
  }
}

推送 policy:

docker exec headscale headscale policy set -f /etc/headscale/acl.hujson

9.3 家人在自己机器上登录

  • 生成 preauth key(免浏览器授权):

    docker exec headscale headscale preauthkeys create --user family --expiration 24h --reusable
  • 家人执行:

    sudo tailscale login \
      --login-server https://headscale.example.com \
      --nickname hs-hk \
      --authkey <上一步生成的 key> \
      --accept-routes --accept-dns

9.4 把变更同步到备端

./scripts/sync_headscale_hk_to_fn.sh
./scripts/sync_headscale_hk_to_dsm.sh

十、多活切换的实际处理

10.1 平时:三端并行,客户端选一个用

三端各自跑,各自的控制面 URL 都可访问。客户端只连当前 profile 指向的那一台,其他两台是”热备”。DB 每次做变更后手动 sync 一次(或加 cron 定期 sync)。

10.2 主机 hk_main 故障,切到 fn-NAS

bash scripts/switch_headscale.sh fn

machine key 不变,Enabled=true 状态在 fn-NAS 的 DB 里也有(因为 sync 过),路由立刻可用。唯一影响:故障期间对 headscale 的写操作(加机器、改 ACL)不能做,等 hk_main 恢复再统一在主上做。

10.3 家里断电,只剩 hk_main 和 DSM 可用

DSM 独立部署在群晖上,走公网 DDNS nas.example.net:4433。fn-NAS 断电不影响 DSM。切 dsm profile 即可继续。

10.4 手动”提升”备端为主

如果 hk_main 长时间不能恢复,需要允许 DSM 或 fn-NAS 接受写入:

  1. 停掉从 hk 的 sync 脚本(如果有 cron)
  2. 在新主上正常做 CLI 操作
  3. 事后按新主重建 sync 方向(把 sync_headscale_hk_to_*.sh 改成 sync_headscale_dsm_to_*.sh
  4. hk_main 恢复后作为备端,从新主 sync 回来一次

日常没必要走这步,等主端恢复通常比重建同步方向快。

10.5 DERP 中继的”多活”

因为 hk_main 和 fn-NAS 各自跑一份 DERP,客户端切到哪个 profile 就用哪个 region 的 DERPMap:

  • hs-hk → 只用 region 901 (hkmain)
  • hs-fn → 只用 region 902 (fnnas)

即使某一端的 DERP 挂了,切另一个 profile 就能继续中继(当然 P2P 通常也能打通,DERP 只是兜底)。

十一、常用命令速查

# ── Headscale 状态 ──
docker exec headscale headscale nodes list
docker exec headscale headscale users list
docker exec headscale headscale routes list
docker exec headscale headscale apikeys list

# ── 生成 preauth key / API key ──
docker exec headscale headscale preauthkeys create --user alice --expiration 24h
docker exec headscale headscale apikeys create -e 87660h

# ── 手动批准 route(正常有 autoApprovers 不需要) ──
docker exec headscale headscale routes enable -r <route_id>

# ── 修改并加载 ACL ──
vi hk_main/data/headscale/config/acl.hujson
docker exec headscale headscale policy set -f /etc/headscale/acl.hujson

# ── 客户端 ──
tailscale switch --list
tailscale status
tailscale netcheck | head -20             # 看 Nearest DERP 是否是自建
tailscale ping --verbose <peer-name>      # 看是 direct 还是 DERP 中继

# ── 三端 DB 同步 ──
./scripts/sync_headscale_hk_to_fn.sh
./scripts/sync_headscale_hk_to_dsm.sh

# ── Headplane 升级(hk → fn/dsm 中转) ──
./scripts/upgrade_headplane.sh 0.7.2      # DRY_RUN=1 演练

十二、踩坑合集

  1. 0.23 → 0.29 升级autoApprovers.routes 的 owner 必须是 typed entity (alice@ / group: / tag:),光写 "alice" 会启动失败。
  2. **ephemeral_node_inactivity_timeout**:0.29 迁到 node.ephemeral.inactivity_timeout,老写法只 WARN 不阻塞,但升级时一并改掉更清爽。
  3. DERP paths 是 YAML 不是 JSON,且 map key 必须 int。
  4. Lucky UDP 转发的传输类型:家里 v6 公网只勾 udp6;有 v4 公网加 udp4tcp4/tcp6 对 STUN 无意义。
  5. **iptables policy 是 ACCEPT 时不要乱加 -A INPUT**:某些自动化脚本会顺带把 policy 改成 DROP,会破坏其他服务。
  6. 切 profile 后路由消失:脚本必须在 switch 后 tailscale set --advertise-routes,因为 prefs 是每 profile 独立的。
  7. Mac 上 /usr/local/bin/tailscale 是 App Store stub:只装 brew 版时它会静默失败,脚本要显式用 /opt/homebrew/bin/tailscale
  8. Authelia 改了配置不重启:请求匹配不到规则会返回 403 而不是 401。docker restart authelia 是必做步骤。
  9. goodieshq/headscale-admin v0.28.0 只支持 headscale ≤0.25:0.29 上直接白屏,换 Headplane。
  10. ClashX 代理污染 tailscaletshttpproxy: using proxy ... 出现在 netcheck 里说明流量被系统代理拦了。要么让 tailscale 走 PROCESS-NAME DIRECT,要么调整 rules 里的 domain suffix。

十三、未来可以做的事

  • cron 定时跑 sync_headscale_hk_to_*.sh(现在是手动,容易忘)
  • Headplane 的 cookie_secretapi_key 改成 docker secrets 注入,脱离 git 明文
  • headplane 0.7.x → 0.8.x 升级路径试跑
  • 给 fn-NAS 的 DERP 加 IPv4 出口(家里 v6 only 场景下 v4-only 客户端只能走 hk region)
  • 加一台海外备份控制面(比如 vmiss-us),做 3+1 多活

十四、小结

从”想让家里 Mac ping 通公司 IP”这个 30 分钟需求,一路挖到最后大概花了三个夜晚,收获是把 控制面 / 管理面 / 数据面 / 客户端流量分流 / 多端 DB 同步 这一整套解耦清楚。核心心得:

  • 控制面(Headscale)、管理面(Headplane)、数据面(DERP)尽量各自独立部署,只在必要处共享(DB / API key)。
  • **多活的关键不是”双主”,是”单写多读 + 秒切”**。真需要故障切主,脚本改一下方向即可。
  • 每一层安全都只做一件事:Authelia 管浏览器 UI 的身份、Headscale API Key 管机器 API、ACL autoApprovers 管路由授权、DERP 只转发加密流量。
  • 踩坑一定要写下来。三个月后再遇到同样错误,能省两小时。

(本文将持续更新。)