20260915-headscale-caddy-openwrt-homelab

自建 Headscale:Caddy 反代 + OpenWrt 子网路由打通家宽 Homelab

用官方 Tailscale 当客户端、自建 Headscale 当控制面,把家里的 10.10.0.0/16 挂进 tailnet。控制面跑在阿里云上海 ECS,域名 hola.holderserver.lab,MagicDNS 后缀 ts.holderserver.lab。

目标架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Windows / 手机(任意网络)
│ Tailscale 客户端
│ --login-server https://hola.holderserver.lab
▼
┌─────────────────────────────────────────┐
│ 阿里云 ECS 108.108.108.108 │
│ Caddy :80/:443 ──反代──► Headscale :8080 │
│ Headscale 内嵌 DERP + STUN :3478/udp │
└─────────────────────────────────────────┘
│
▼
OpenWrt(x86 J4125,10.10.1.1/16)
tailscale 子网路由 advertise 10.10.0.0/16
│
▼
家宽设备 10.10.x.x / openwrt.home.lab

角色分工:

角色 机器 作用
控制面 + DERP 大陆 ECS Headscale 0.29.3 + Caddy 签证书、反代、中继
子网路由 家里 OpenWrt 宣告 10.10.0.0/16,转发 tailnet ↔ LAN
日常客户端 Windows 下班/手机热点访问家里服务

要点:

  • Headscale 容器内只听 8080,不要映射到宿主机 8080。宿主机 80/443 给 Caddy,3478/udp 给 STUN。
  • prefixes.v4/v6 是 tailnet 虚拟地址池(100.64.0.0/10 与 fd7a:115c:a1e0::/48),和 VPS 有没有公网 IPv6、家里是不是 10.10.0.0/16 无关。
  • server_url 主机名必须和 MagicDNS base_domain 不同:这里分别是 hola.holderserver.lab 与 ts.holderserver.lab。
  • 0.29.3 要求 Tailscale 客户端 ≥ 1.80。

目录与 Compose

VPS 上目录:

1
2
3
4
5
6
/opt/stacks/headscale/
compose.yml
Caddyfile
config/config.yaml
config/acl.hujson
data/

compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
services:
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- headscale
networks:
- hsnet

headscale:
# Docker Hub 在国内经常超时,走 GHCR 或国内镜像
image: ghcr.io/juanfont/headscale:0.29.3
# 国内加速镜像:ghcr.1ms.run/juanfont/headscale:0.29.3
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
ports:
- "3478:3478/udp"
networks:
- hsnet

networks:
hsnet:

volumes:
caddy-data:
caddy-config:

拉 headscale/headscale:0.26.4 时若出现:

1
Get "https://registry-1.docker.io/v2/": net/http: request canceled

直接换 GHCR 或加速前缀,版本用当时的 release 0.29.3,不要死盯旧 tag。

80 若被系统 Apache 占用:

1
2
3
sudo systemctl stop apache2
sudo systemctl disable apache2
sudo systemctl mask apache2

Headscale 配置

config/config.yaml(0.29 可用字段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
server_url: https://hola.holderserver.lab
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
v6: fd7a:115c:a1e0::/48
allocation: sequential

derp:
server:
enabled: true
region_id: 900
region_code: "aliyun_sh"
region_name: "Aliyun Shanghai"
stun_listen_addr: "0.0.0.0:3478"
private_key_path: /var/lib/headscale/derp_server_private.key
verify_clients: true
ipv4: "108.108.108.108"
# 无公网 IPv6 就不写 ipv6
urls: []
auto_update_enabled: false

dns:
magic_dns: true
base_domain: ts.holderserver.lab
override_local_dns: true
nameservers:
global:
- 223.5.5.5
split:
# 家庭dns 拆分
home.lab:
- 10.10.1.1

database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite

log:
level: info
format: text

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

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

说明:

  • derp.urls: [] 表示不用官方 DERP,打洞失败时流量只走自己的 VPS。
  • region_id / region_code / region_name 只是 DERP 地图里的显示名,和连通性无关;region_id 有节点连上后不要乱改。
  • split.home.lab → 10.10.1.1 用来解析家里自建域名(见文末 DNS)。没有 Homelab 域名可先去掉 split。
  • listen_addr: 0.0.0.0:8080 只绑在 Headscale 容器网栈 里。另一台容器也可以在自己内部听 8080,只要不要都 -p 8080:8080 到同一台宿主机。

默认全通 ACL config/acl.hujson:

1
2
3
4
5
6
7
8
9
10
11
12
{
"groups": {
"group:admin": []
},
"acls": [
{
"action": "accept",
"src": ["*"],
"dst": ["*:*"]
}
]
}

Caddyfile

能稳定跑起来的版本:

1
2
3
4
5
6
hola.holderserver.lab {
encode gzip
reverse_proxy headscale:8080 {
flush_interval -1
}
}

flush_interval -1 减少 /machine/map 长轮询被缓冲。

不要把 read_timeout 直接写在 reverse_proxy 块里。当前 caddy:2 会报:

1
unrecognized subdirective read_timeout

容器随即 Restarting (1),宿主机 443 变成 connection refused,所有客户端立刻 logged out。超时参数必须写在该版本认的 transport http 语法里;不确定就维持上面这份最小配置。

改完:

1
2
3
4
docker compose up -d
docker compose ps
docker compose logs --tail=80 caddy
docker compose logs --tail=80 headscale

Headscale 正常启动会看到 listening and serving HTTP on: 0.0.0.0:8080,以及:

1
WRN listening without TLS but ServerURL does not start with http://

这是预期:TLS 在 Caddy 上终结,Headscale 只对内提供 HTTP。

证书与 80/443 打不通

Caddy 本机回环可以 308 到 HTTPS,但外网 curl http://108.108.108.108/ 超时,Let’s Encrypt 会报:

1
Timeout during connect (likely firewall problem)

分层排查:

  1. UFW 的 INPUT 不够。 装了 ufw-docker 之后,容器映射端口走 FORWARD。下面这种规则看起来开了 80/443,其实拦不到 Docker:

    1
    2
    3
    80/tcp     ALLOW IN     Anywhere
    443/tcp ALLOW IN Anywhere
    8080/tcp ALLOW FWD 特定 IP

    需要:

    1
    2
    3
    4
    5
    6
    7
    8
    sudo ufw-docker allow caddy 80/tcp
    sudo ufw-docker allow caddy 443/tcp
    sudo ufw-docker allow headscale 3478/udp
    # 或
    sudo ufw route allow proto tcp from any to any port 80
    sudo ufw route allow proto tcp from any to any port 443
    sudo ufw route allow proto udp from any to any port 3478
    sudo ufw reload

    ufw status 里应出现 ALLOW FWD 的 80/443/3478。不要去放行 Headscale 的 8080。

  2. 阿里云安全组 入方向 TCP 80、TCP 443、UDP 3478,源 0.0.0.0/0,且规则挂在拥有 108.108.108.108 的那张网卡上。开过「云防火墙」还要再放一刀。

  3. ECS备案 80、443端口需要备案。

成功日志特征:

1
2
served key authentication certificate  challenge=tls-alpn-01
certificate obtained successfully identifier=hola.holderserver.lab

验证:

1
curl -I https://hola.holderserver.lab

curl -I 发的是 HEAD,Headscale 根路径常回 HTTP/2 405 Allow: GET,说明反代和证书都好,不是服务挂了。改用 GET 或打 /health。

浏览器打开该域名多半是空白/404,Headscale 没有管理后台首页。

用户、Key 与 0.29 CLI

1
2
3
docker exec -it headscale headscale users create default
docker exec -it headscale headscale users list
docker exec -it headscale headscale preauthkeys create --user 1 --reusable --expiration 24h

0.29 起 --user 必须是 数字 ID,再写 default 会:

1
strconv.ParseUint: parsing "default": invalid syntax

路由也不再是 headscale routes:

1
2
3
docker exec -it headscale headscale nodes list
docker exec -it headscale headscale nodes list-routes
docker exec -it headscale headscale nodes approve-routes --identifier 1 --routes 10.10.0.0/16

批准后三列都要有网段:

1
2
ID | Hostname | Approved     | Available    | Serving (Primary)
1 | openwrt | 10.10.0.0/16 | 10.10.0.0/16 | 10.10.0.0/16

preauth key 只显示一次。可以手动将key过期

1
2
docker exec -it headscale headscale preauthkeys list --user 1
docker exec -it headscale headscale preauthkeys expire --user 1 <key-id>

OpenWrt 当子网路由

环境:eSir R24.2.2 / 内核 5.15.151 / J4125,LAN:

1
br-lan  inet addr:10.10.1.1  Mask:255.255.0.0

即网段 10.10.0.0/16,宣告时写网段不要写成 10.10.1.1/16。

不要走坏掉的 opkg

/etc/opkg/distfeeds.conf 指向了已经 404 的 snapshots,还混着 18.06.9 的 luci,wget returned 8。定制固件不要强行对官方 24.10 源,直接下官方静态包。

1
2
3
4
5
6
7
cd /tmp
wget https://pkgs.tailscale.com/stable/tailscale_1.92.5_amd64.tgz
tar -xzf tailscale_1.92.5_amd64.tgz
cp tailscale_1.92.5_amd64/tailscale tailscale_1.92.5_amd64/tailscaled /usr/sbin/
chmod +x /usr/sbin/tailscale /usr/sbin/tailscaled
mkdir -p /var/lib/tailscale /var/run/tailscale
tailscale version

需要 ≥ 1.80,这里是 1.92.5。

OpenWrt 没有 systemd,用 procd:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cat > /etc/init.d/tailscale << 'EOF'
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1

start_service() {
mkdir -p /var/run/tailscale /var/lib/tailscale
procd_open_instance
procd_set_param command /usr/sbin/tailscaled
procd_append_param command --state /var/lib/tailscale/tailscaled.state
procd_append_param command --socket /var/run/tailscale/tailscaled.sock
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
EOF
chmod +x /etc/init.d/tailscale
/etc/init.d/tailscale enable
/etc/init.d/tailscale start

接入:

1
2
3
4
5
6
tailscale up \
--login-server https://hola.holderserver.lab \
--authkey 'hskey-auth-...' \
--advertise-routes=10.10.0.0/16 \
--accept-dns \
--snat-subnet-routes

之后重连一般 不必再带 authkey,状态在 tailscaled.state 里。只有 logged out、删过 state、或节点在服务端被删时才需要 key。

zsh 下改防火墙

opwnwrt下 shell在更改为 zsh的情况下,firewall.@zone[-1] 的方括号会被当成通配符:

1
zsh: no matches found: firewall.@zone[-1].name=tailscale

结果 uci add 建出无名 zone,firewall restart 出现 Section @zone[4] has no name。所有带 [ 的参数加单引号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
uci add firewall zone
uci set 'firewall.@zone[-1].name=tailscale'
uci set 'firewall.@zone[-1].input=ACCEPT'
uci set 'firewall.@zone[-1].output=ACCEPT'
uci set 'firewall.@zone[-1].forward=ACCEPT'
uci add_list 'firewall.@zone[-1].device=tailscale0'

uci add firewall forwarding
uci set 'firewall.@forwarding[-1].src=tailscale'
uci set 'firewall.@forwarding[-1].dest=lan'

uci add firewall forwarding
uci set 'firewall.@forwarding[-1].src=lan'
uci set 'firewall.@forwarding[-1].dest=tailscale'

uci commit firewall
/etc/init.d/firewall restart

成功时日志里有:

1
2
3
Zone 'tailscale'
Forward 'tailscale' -> 'lan'
Forward 'lan' -> 'tailscale'

uci commit 写入 /etc/config/firewall,重启还在。sysctl -w net.ipv4.ip_forward=1 只改当前内核,OpenWrt 默认通常已开转发。

这台机还跑着 OpenClash / Passwall。本机访问 hola.holderserver.lab 必须直连,否则会出现 DERP RST、PollNetMap 超时。先:

1
curl -v --max-time 15 https://hola.holderserver.lab/

短请求 200 只说明 TCP/TLS 通,不能排除长轮询被掐。

Windows 客户端

装官方 Tailscale,不要找 Headscale Windows 版。管理员 PowerShell:

1
tailscale up --login-server https://hola.holderserver.lab --authkey "hskey-auth-..." --accept-routes --accept-dns

不要点默认 Log in(那会进官方云)。

验证:

1
2
3
tailscale status
ping 100.64.0.1
ping 10.10.1.1

100.64.0.2 是 Windows 自己 的 tailnet 地址,ping 自己 <1ms 没有诊断意义。

  • 100.64.0.1 / openwrt:路由器在 tailnet 里的地址
  • 10.10.1.1 和家里 NAS 的 10.10.x.x:子网路由

不需要固定公网 IP。Wi-Fi、网线、手机热点、4G/5G 都能用;运营商 NAT 下多半走 DERP(hola.holderserver.lab:443),延迟略高。

每天关机:Tailscale 是 Windows 服务,默认会记住 --login-server。开机等托盘 Connected 即可,不要每次 logout。服务设为 Automatic,并:

1
tailscale set --accept-routes --accept-dns

只有变成 logged out 才重新带 key。早上若不通,先看 OpenWrt tailscale status 和 VPS 上 Caddy 是否还是 Up。

Homelab 域名

远程时客户端 DNS 来自 Headscale(全局 223.5.5.5),不会问家里的 dnsmasq,所以 openwrt.home.lab 解析不了,只能打 IP。

在 dns.nameservers.split 把 home.lab 指到 10.10.1.1,改完 docker compose restart headscale,Windows:

1
2
3
4
tailscale down
tailscale up --login-server https://hola.holderserver.lab --accept-routes --accept-dns
ipconfig /flushdns
nslookup openwrt.home.lab

若 REFUSED,让 dnsmasq 回答来自 tailnet / SNAT 后的查询(--snat-subnet-routes 时源地址常常已经是 10.10.1.1)。必要时:

1
2
3
uci set dhcp.@dnsmasq[0].localservice='0'
uci commit dhcp
/etc/init.d/dnsmasq restart

两套名字:

名字 解析方 结果
openwrt / openwrt.ts.holderserver.lab MagicDNS 100.64.0.1
openwrt.home.lab 家里 DNS(split) 10.10.1.1

主机不多也可以用 dns.extra_records 写死 A 记录。

排障备忘

现象 常见原因
外网 80/443 超时,本机 curl 308 ufw-docker 未 ALLOW FWD / 安全组 / 云防火墙
Caddy Restarting (1),客户端 connection refused Caddyfile 语法错误(如乱写 read_timeout)
OpenWrt PollNetMap / long-poll timed out 反代空闲断开,或 Clash 劫持本机 443
preauthkeys --user default 失败 0.29 要用户 ID
headscale routes unknown 改用 nodes list-routes / approve-routes
OpenWrt opkg snapshot 404 换官方 amd64 静态包
uci no matches found zsh 吃掉 [],参数加引号
ping 通 100.64.0.1 不通 10.10.1.1 路由未批准或防火墙无 tailscale ↔ lan
Windows ping 100.64.0.2 很快 那是本机地址

Caddy 挂掉时 OpenWrt 典型日志:

1
2
Get "https://hola.holderserver.lab/key?v=131": dial tcp 108.108.108.108:443: connect: connection refused
You are logged out.

先修 VPS docker compose ps,再在路由器上 tailscale up。

日常维护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# VPS
docker compose ps
docker compose logs --tail=50 caddy
docker exec -it headscale headscale nodes list
docker exec -it headscale headscale nodes list-routes

# OpenWrt
/etc/init.d/tailscale start
tailscale status

# Windows
tailscale status
ping 100.64.0.1
ping 10.10.1.1

扩容就是再发一条 preauth key,新设备 --login-server https://hola.holderserver.lab。只有要进家宽的客户端才开 --accept-routes。

作者

cSan

发布于

2026-09-15

更新于

2026-09-15

许可协议