自建 headscale 打通公司与家庭的 P2P 网络:方案选型、部署与面板
八月 14, 2026 更新: 十月 11, 2026 [网络与安全] #tailscale #cloudflare #wireguard #iptables需求
打通公司工作机(Ubuntu 24.04,位于公司 NAT 后)与家庭机器(Debian 13,位于家宽 NAT 后)的加密互联。已有一台阿里云北京 VPS 可作协商/中继节点。用途:SSH 运维、访问公司内部 GitLab/Jenkins/Wiki、RDP 桌面。
约束条件:
- VPS 为大陆节点,80/443 端口提供服务需 ICP 备案,不能走常规端口;
- 尽量不依赖家庭路由器配置(不强制端口转发);
- 数据面优先点对点,流量不过第三方;
- 维护简单、逻辑清晰。
方案选型
| 方案 | P2P 直连 | VPS 角色 | 维护难度 | 结论 |
|---|---|---|---|---|
| headscale(自托管 Tailscale 控制面) | ✅ 自动打洞,失败走 DERP | 控制面 + 中继兜底 | 低 | 选用 |
| 纯 WireGuard 星型 | ❌ 全走 VPS | 数据中继 | 最低 | 带宽被 VPS 卡死 |
| WG 手动直连 + VPS 兜底 | ⚠️ 部分 | 中继 | 高 | NAT 变化要手动改 |
| OpenVPN | ❌ | 中继 | 高(PKI) | 无 P2P、性能差 |
| tinc / n2n / zerotier | ⚠️ 部分 | 协商/中继 | 中高 | 维护弱/控制面在海外 |
选 headscale 的理由:真 P2P(打洞成功流量不经 VPS,带宽无瓶颈)、DERP 中继兜底(打洞失败仍可用)、控制面一个 Go 二进制 + 官方客户端,维护最清晰。客户端用 Tailscale 官方客户端(Apache 2.0 开源),控制面完全自建,数据不出自己的节点。
架构与原理
公司机 (Ubuntu 24.04) VPS (阿里云北京) home 机 (Debian 13)
┌─────────────────┐ ①元数据 ┌──────────────────────┐ ①元数据 ┌─────────────────┐
│ tailscale │◀──8443──▶ │ headscale 控制面(8443)│◀──8443──▶ │ tailscale │
│ │ │ DERP 中继(同 8443) │ │ │
│ │ │ STUN (3478/udp) │ │ │
└──────┬──────────┘ └──────────────────────┘ └────────┬────────┘
│ ② 直连 UDP(打洞成功,不经 VPS) │
└────────────────────────────────────────────────────────────────────┘
│ ③ DERP 中继 TCP 8443(打洞失败兜底,仍端到端加密) │
三种流量路径:
- 控制面(headscale):只交换元数据——公钥、双方公网 IP:端口、NAT 类型、节点列表。不承载业务流量。
- P2P 直连(打洞成功):两端 UDP 直连,WireGuard 加密,流量完全不经 VPS。
tailscale status显示direct。 - DERP 中继(打洞失败):走 VPS 上的中继,但只转发端到端加密密文,中继无法解密内容。
NAT 打洞为什么有时失败
打洞依赖双方 NAT 的"入站宽容度",成功率取决于最严格的那一端,不是最宽松的:
| NAT 类型 | 入站行为 | 打洞结果 |
|---|---|---|
| Full cone | 任何人发到映射端口都能进 | 几乎必成 |
| Restricted/Port-restricted cone(家用路由器) | 只收"映射记录的源" | 双端同时发包可成 |
| Symmetric(企业防火墙/CGNAT) | 只收"源地址精确匹配",且每次出站换端口 | 大概率失败 → 走 DERP |
典型死局:home 是 cone(能收到公司的包),公司是 symmetric(收不到 home 的回包,因为每次出站端口都变)→ 单向通,打洞失败,自动降级 DERP。本部署实测:公司 NAT 友好,打洞成功,ping 21ms 直连。
DERP 与 STUN 的归属
- STUN(UDP 3478)由 DERP 服务器内置提供(headscale 内置 DERP 自带),客户端靠它探测自己的公网 IP:端口;
- DERP 中继复用 headscale 的 HTTPS 端口 8443(内置 DERP 与主服务共用 TLS 监听),无独立端口;
- headscale 内置 DERP 默认
verify_clients: true——只允许本 tailnet 节点中继,外部客户端直接拒绝(防滥用,零配置)。
部署准备
端口与安全组(备案约束)
VPS 为大陆节点,80/443 需备案,故全部走自定义端口:
| 端口 | 用途 |
|---|---|
| TCP 8443 | headscale 控制面 + 内置 DERP 中继(共用) |
| UDP 3478 | STUN 打洞辅助 |
| TCP 22 | SSH(保留) |
阿里云安全组放行 8443/tcp 3478/udp。部署前实测:VPS 安全组原只放行 22,其余端口全被拦——这是最容易忽略的坑。
域名与证书(Cloudflare DNS + acme.sh)
域名托管在 Cloudflare,用 acme.sh 的 dns_cf 插件做 DNS-01 验证(不依赖 80 端口,正好绕开备案):
# Cloudflare 控制台 → My Profile → API Tokens → Create Token
# 模板 "Edit zone DNS" → Zone 限定你的域名 → 得到 CF_Token / CF_Account_ID
curl https://gitee.com/neilpang/acme.sh/raw/master/install.sh | sh # GitHub 可能不通,用 gitee
export CF_Token="xxx" CF_Account_ID="xxx"
~/.acme.sh/acme.sh --issue --dns dns_cf -d headscale.demo.com
~/.acme.sh/acme.sh --install-cert -d headscale.demo.com \
--key-file ~/p2p/certs/key.pem --fullchain-file ~/p2p/certs/cert.pem \
--reloadcmd "docker restart headscale" # 续期后自动重载容器
自动续期由 acme.sh 安装时写入的 cron 负责(每天检查),--reloadcmd 让续期后自动加载新证书,全链路无人值守。
⚠️ 记录必须 DNS-only(灰云):不要开 Cloudflare 橙色代理,否则 8443 等非标准端口无法穿透。
镜像准备(国内 VPS 拉不动 Docker Hub)
VPS 实测连 Docker Hub 超时(DNS 被污染),但 ghcr.io 可直拉(headplane 镜像就是 VPS 直拉的)。headscale 镜像走本地中转:
# 本地(公司机,有代理):
podman pull docker.io/headscale/headscale:0.29.3
podman save docker.io/headscale/headscale:0.29.3 -o headscale.tar
scp headscale.tar vps:/tmp/
# VPS:
docker load -i /tmp/headscale.tar
VPS 部署(headscale + headplane)
部署目录 ~/p2p/(用户有 docker 组权限,全程无需 root)。
docker-compose.yml
services:
headscale:
image: headscale/headscale:0.29.3
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
- ./certs:/var/lib/headscale/certs:ro
ports:
- "8443:8443/tcp"
- "3478:3478/udp"
headplane:
image: ghcr.io/tale/headplane:latest
container_name: headplane
restart: unless-stopped
depends_on: [headscale]
volumes:
- ./headplane.yaml:/etc/headplane/config.yaml:ro
- ./data/headplane:/var/lib/headplane
- ./config:/etc/headscale:ro
ports:
- "127.0.0.1:3000:3000/tcp"
- "100.64.0.100:3000:3000/tcp" # 更新(2026-08-15):VPS 管理节点加入后双绑定,tailnet 内访问
100.64.0.100是 VPS 节点的固定 tailnet IP(headscale 无 CLI 指定 IP,直接改 sqlitenodes.ipv4实现,详见下节)。若节点重新注册导致 IP 变化,需同步改此绑定与 headplane.yaml 的base_url。
config.yaml 关键项
server_url: https://headscale.demo.com:8443
listen_addr: 0.0.0.0:8443
metrics_listen_addr: 0.0.0.0:9090
prefixes:
v4: 100.64.0.0/10 # tailnet 虚拟网段,与家/公司网段无冲突
derp:
urls: [] # ★ 清空官方 DERP 地图,否则客户端会绕海外
server:
enabled: true
region_code: "ali"
region_name: "Aliyun Beijing"
ipv4: <VPS公网IP>
stun_listen_addr: "0.0.0.0:3478"
# verify_clients 默认 true:仅本 tailnet 节点可中继
database:
type: sqlite
tls_cert_path: /var/lib/headscale/certs/cert.pem
tls_key_path: /var/lib/headscale/certs/key.pem
dns:
magic_dns: false
override_local_dns: false # 关掉 MagicDNS,避免干扰公司 DNS
两个必踩的坑:
derp.urls若留默认(指向 controlplane.tailscale.com),客户端会优先尝试官方海外 DERP——必须清空,只留内置;- headscale 0.29 要求
dns.override_local_dns: true时必须有dns.nameservers.global——不用 MagicDNS 就直接两个都关。
启动验证:curl -k https://headscale.demo.com:8443/health 返回 {"status":"pass"}。
客户端接入(公司机 + home 机 + VPS 管理节点)
更新(2026-08-15):新增 VPS 作为 tailnet 第 3 节点(管理入口),headplane 面板经 tailnet 访问。本节点也按下面流程加入,只是 headplane 部署在本机上。
更新(2026-08-15):新增 home 局域网 Proxmox 宿主(
peter.xiao.com,192.168.2.100,Debian 13)为 tailnet 第 4 节点,headplane 面板中重命名为peter-xiao100(100.64.0.3)。同时把全部节点在 headplane 面板统一重命名:xiao→xiao200、VPSiz2zehkipqcroqwfrvhwj7z→ali。节点重命名完全可以在 headplane 面板(Machines)操作,无需 CLI。
更新(2026-08-16):新增 tailnet 第 5、6 节点并上线公司侧双路由器 HA——公司局域网
xiao99(192.168.7.99,100.64.0.5)通告192.168.4.0/22,与peter-optiplex-7090双通告双审批(客户端按连接分配、故障自动切换);home 局域网peter-megabook-14(192.168.2.147,100.64.0.6)作为普通节点加入。同日踩坑:子网路由器 accept 自吸(xiao99 开--accept-routes后 LAN 内主机 ping 不通它、tailnet 直连却正常,回包被 table 52 吸进 tailscale0),详见「子网路由原理」一节。
安装(官方 apt 源,国内直连可用)
# Ubuntu 用 ubuntu/noble,Debian 用 debian/trixie
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkgs.tailscale.com/stable/<发行版>.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/<发行版>.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list >/dev/null
sudo apt-get update && sudo apt-get install -y tailscale
登录(preauthkey 免浏览器)
# VPS 创建用户 + key(headscale 0.29 的 --user 参数是数字 ID!)
docker-compose exec headscale headscale users create peter
docker-compose exec headscale headscale users list # 拿 ID
docker-compose exec headscale headscale preauthkeys create --user <ID> --expiration 24h
# 客户端:
sudo tailscale up --login-server=https://headscale.demo.com:8443 --authkey=<key>
验证
sudo tailscale status
# 100.64.0.1 company peter linux -
# 100.64.0.2 home peter linux active; direct <home公网IP>:<端口>, tx 5716 rx 4540
sudo tailscale ping --c 3 100.64.0.2
# pong from home via <home公网IP>:61761 in 21ms ← direct,不经 VPS
sudo tailscale netcheck # 确认 Nearest DERP 是自建节点,无海外
实测结果:打洞成功(21ms 直连),DERP 地图只有自建节点。
VPS 管理节点(第 3 节点,2026-08-15 加入)
VPS 也加入 tailnet,作为 headplane 面板所在的管理节点——headscale 控制面容器与 tailscaled 客户端共存无冲突(8443/3478 与客户端数据面 UDP 41641 不撞端口):
# 安装(Ubuntu noble 官方源,需 sudo)
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list >/dev/null
sudo apt-get update && sudo apt-get install -y tailscale
# 登录(--operator=peter 让普通用户免 sudo 执行 tailscale 命令)
sudo tailscale up --login-server=https://headscale.demo.com:8443 --authkey=<key> --operator=peter
tailnet IP 固定为 100.64.0.100(刻意选的易记地址)。headscale 0.29 没有 CLI 指定 IP,固定方式是直接改 sqlite 的 nodes.ipv4(headscale nodes list -o json 确认节点 ID → docker stop headscale → 备份 → 改库 → docker start);若节点重新注册 IP 会变,需同步改 compose 绑定、headplane.yaml 的 base_url 和 @reboot cron 的 grep 模式。headplane 绑定该 IP 供其他节点访问(见下节)。
部署时遇到的 DNS 坑:VPS 的阿里云 VPC DNS(100.100.2.136/138)曾整体不可达,导致 tailscaled 登录控制面失败(DoH 兜底又被大陆屏蔽)。已通过 /etc/hosts 硬编码 headscale.demo.com + systemd-resolved 改用公共 DNS(223.5.5.5/119.29.29.29)修复。更正(2026-08-21):当时判断为"DNS 污染/不可达"不完整——真因是 ts-input 防欺骗规则丢 100.x 回包(CGNAT 网段冲突,与下文备份失败同根因),绕过有效但掩盖了根因。
开机顺序坑:重启后 docker 先于 tailscaled 启动,headplane 绑定的 100.64.0.100:3000 会绑定失败导致容器起不来。兜底:peter 的 @reboot cron 等待 ts0 出现 tailnet IP 后 docker restart headplane;未生效时手动重启容器即可。
home Proxmox 宿主(第 4 节点,2026-08-15 加入)
tailnet 第 4 节点为 home 局域网内的 Proxmox 宿主(peter.xiao.com,192.168.2.100,Debian 13),与 home 机 xiao200(192.168.2.200)同网段。加入方式与 VPS 节点完全相同:
# 安装(debian/trixie 官方源)后登录,同样 --operator=peter 免 sudo
sudo tailscale up --login-server=https://headscale.juzhong.xyz:8443 --authkey=<key> --operator=peter
关键点:
- 节点重命名走面板:headscale 0.29 的 CLI 是
headscale nodes rename -i <id> <新名>(需在 VPS 上 exec),但完全可以在 headplane 面板 Machines 里直接改——本次 4 个节点全部在面板统一重命名(peter-optiplex-7090/xiao200/ali/peter-xiao100)。CLI 与面板等效,均即时下发,无需重启服务端或客户端(客户端下次同步 netmap 即生效)。 - 实测延迟:同网段 xiao200 直连 ~5ms(局域网内部,不出网);VPS 直连 ~4ms;公司机打洞直连 ~25-36ms(首包经 DERP(ali) 后切 direct)。
- 策略路由表:tailnet 路由在 Linux 上不占用主路由表,
ip route看不到100.64.0.0/10属正常——路由在独立table 52,由ip rule的5270: from all lookup 52策略路由承接(优先级低于 fwmark 自保护规则、高于主表),ip route show table 52才能看到。tailscaled 自身流量(连控制面/STUN)打 fwmark0x80000,先查 main/default 表,避免走 tailnet 递归。 - preauth key 与节点有效期无关:
--expiration 24h只限制 key 的注册窗口,节点注册成功后永久有效(nodes list 的 Expiration 为 N/A),除非手动 expire/delete。
公司侧子网路由器 xiao99(第 5 节点,2026-08-16 加入,双路由器 HA)
公司局域网内 Debian 13 主机(192.168.7.99,100.64.0.5)。此前已入网,本次补通告 192.168.4.0/22,与 peter-optiplex-7090 组成双路由器 HA(同一网段双通告双审批,headscale 0.29 node.routes.ha 默认开启,客户端按连接哈希分发、一台挂掉自动切换):
# ① 补通告(已是节点、--operator=peter,免 sudo;tailscale up 需列出全部非默认 flag,含 --login-server)
tailscale up --login-server=https://headscale.juzhong.xyz:8443 --advertise-routes=192.168.4.0/22
# ② VPS 审批(ip_forward Debian 13 默认已是 1)
docker-compose exec headscale headscale nodes approve-routes -i 6 -r 192.168.4.0/22
docker-compose exec headscale headscale nodes list-routes # 两个节点同一网段均为 Approved+Available
查看路由偏好:tailscale status 不显示 advertise/accept,要用 tailscale debug prefs(AdvertiseRoutes/AcceptRoutes 字段)。
勘误(2026-09-12):prefs 里 accept-routes 的字段名不是
AcceptRoutes,而是RouteAll——tailscale debug prefs输出中并没有AcceptRoutes字段,查"是否接受路由"要看RouteAll(宣告仍是AdvertiseRoutes)。tailscale 1.102 实测(新节点与 Proxmox 宿主)。
home 机 peter-megabook-14(第 6 节点,2026-08-16 加入)
home 局域网内 Ubuntu 26.04 笔记本(192.168.2.147,100.64.0.6),普通节点,无子网路由角色。
注册踩坑:首次接入时没带 --login-server,误注册到了官方控制面(login.tailscale.com,登录链接形如 https://login.tailscale.com/a/xxx)——与自建 headscale 是两个完全不同的控制面,节点列表互不可见。修复:先杀掉等待官方认证的 tailscale up 进程,再用 preauthkey 指向自建控制面重新注册即可,tailscale status 立刻显示 tailnet 全部节点。
安全设计:子网访问默认关闭,显式开关
默认仅 tailnet 节点互通(虚拟网段内),任何一侧局域网互访默认关闭,按需开启:
# 开启「公司侧局域网可被 home 访问」三步:
# ① 公司机宣告路由:
sudo tailscale up --login-server=https://headscale.demo.com:8443 --advertise-routes=192.168.4.0/22
# ② home 机接受路由:
sudo tailscale up --login-server=https://headscale.demo.com:8443 --accept-routes
# ③ VPS 审批(0.29.3 语法):
docker-compose exec headscale headscale nodes list-routes
docker-compose exec headscale headscale nodes approve-routes -i <node_id> -r 192.168.4.0/22
# 关闭(任一侧,恢复仅节点互通):
sudo tailscale up --login-server=https://headscale.demo.com:8443
home 侧对称操作(宣告 192.168.2.0/24)。tailscale up 重复执行只更新偏好,不会掉线。公司网段需注意与 home 网段、tailnet CGNAT 段(100.64.0.0/10)无冲突。
子网路由原理:宣告/接受、ip_forward、SNAT 与回程(2026-08-15 实测整理)
子网访问能通依赖三件套,缺一不可:
① 宣告 vs 接受(方向配对):--advertise-routes = 宣告方(我所在的局域网可被 tailnet 访问,需 headscale nodes approve-routes 审批才生效);--accept-routes = 接受方(把对端宣告的子网注入本机路由表)。⚠️ 子网路由器不要 accept(见下方「accept 自吸」),本方案 scripts/*-routes-on.sh 已改为只通告,需要接受对端网段时单独跑 *-accept.sh——同一台机器不要同时跑两个脚本。
② IP 转发(最容易漏):子网路由器必须 net.ipv4.ip_forward = 1,tailscale 不会自动设置(Linux 上;macOS 才自动)。Ubuntu 工作站默认是 0——实测本方案中 home 侧 Proxmox 宿主天然为 1(PVE 桥接需要),但公司机极可能为 0,子网流量会静默不通。已在 scripts/*-routes-on.sh 里加入启动时自动检查并开启(sysctl -w 仅本次生效,持久化需写 /etc/sysctl.d/99-tailscale.conf)。
③ SNAT(默认开启,零配置):--snat-subnet-routes 默认 true,tailscale 自动对子网转发流量做 masquerade。效果:子网内主机看到的源 IP 是子网路由器的局域网 IP(不是 tailnet 的 100.64.0.x),回包只需发给默认网关(即子网路由器),conntrack 对称恢复后送回隧道——子网内主机完全不需要知道 tailnet 路由,回程天然通畅。
home(100.64.0.2) → tailscale0 → 子网路由器解密 → 内核转发 eth0
↑ │ 并发包 SNAT:源 100.64.0.2 → 路由器 LAN IP
└── conntrack 恢复源地址 ← 子网内主机回包给默认网关(路由器)
何时需要手动回程路由:只有关闭 SNAT(--snat-subnet-routes=false,需保留真实源 IP 做日志审计时)才有此需求——此时子网内主机收到源为 100.64.0.x 的包,必须给它加 100.64.0.0/10 → 子网路由器 LAN IP 的静态路由(DHCP/VPC 下发),否则回程丢包。默认 SNAT 开启时无此坑。
排障顺序:① 两端 accept/advertise 是否配对(tailscale debug prefs 查 AdvertiseRoutes/RouteAll;原文此处误写为 AcceptRoutes,勘误见上文「查看路由偏好」);② headscale nodes list-routes 的 Approved 列是否包含该网段;③ 子网路由器 sysctl net.ipv4.ip_forward 是否为 1;④ 目标机防火墙;⑤ 子网路由器是否误开 accept(见下条)。
accept-routes 自吸(2026-08-16 实测 xiao99,全网段断连 20 分钟的元凶):机器自己就在通告的网段内时开 --accept-routes,tailscale 会把本网段路由也装进 table 52(策略路由 5270 lookup 52 优先于 32766 lookup main),于是——本机发往局域网的包(含对 LAN 主机的回包)全部被吸进 tailscale0,tailscaled 视其为不可达丢弃。症状极具迷惑性:局域网内主机 ping 这台机器 100% 丢包,但 tailnet 直连(tailscale ping 100.64.0.x)完全正常——先怀疑隧道/打洞坏了,实际是回包自吸。抓包特征:回包从 tailscale0 Out 出去(ICMP ... udp port xxx unreachable),br0 上只有入包没有回包。修复:tailscale set --accept-routes=false 秒恢复。tailscale 官方文档对 HA 双路由器场景同样警告:"standby 会接受 primary 宣告的同一路由,导致本可直接连接的子网绕道 primary"——双通告的路由器应该只用 --advertise-routes。
宣告网段内普通节点 accept 自吸(2026-10-07 KVM VM 实测,2026-10-10 笔记本复现,附回包消失的机制层解释):xiao99 案例是宣告者本人自吸;后来发现非宣告者的普通节点住进被宣告网段(192.168.2.0/24)再开 accept-routes,同样中招——本网段路由装进 table 52 后,本网段互访的回包被吸进隧道。2026-10-07:宿主上的 KVM VM(192.168.2.156,为经隧道访问公司网段开 accept)宿主 ping/SSH 全超时,但 ARP/IPv6 直连/tailscale IP 正常("半通"最迷惑);VM 反向 ping 宿主却通——去程绕隧道经 subnet router 三层 hairpin、回程走 LAN 的非对称路径(延迟 1.8ms、ping 网关 TTL=63 是绕行铁证)。2026-10-10:home 笔记本(192.168.2.129,100.64.0.6)同型复现,并补齐了最后一块拼图——回包进了隧道后,为什么对端还是没收到:
- 双向对照实验:笔记本主动 ping 宿主通——request 查 table 52 进隧道时,内核按出口设备 tailscale0 自动选源为 100.64.0.6(tailscale IP);宿主 ping 笔记本不通——reply 的源必须沿用 request 的目的地址(192.168.2.129,LAN 地址),ICMP 应答不能改源。
- 宿主 tailscale0 抓包 0 回包:丢包点锁定在发送方 tailscaled——它的 packet filter 有源地址防欺骗检查,只放行"源 = 本节点 tailscale IP"的包,以 LAN IP 为源的回包进 TUN 即被丢弃,根本没封装发出(与后文阿里云 CGNAT 案例实测的 ts-input 入方向 DROP 是同族机制,本例是出方向表现)。
- 推论:"非对称路由在普通链路上常能通"(ICMP socket 不绑接口,回包绕路回来照样交付)在这里不成立——"去程走 LAN / 回程走隧道且回程带 LAN 源地址"的组合必死,这就是"接受了通告路由也照样不通"的原因。
修复(10-07 定型,10-10 复用):保持 accept-routes(VM/笔记本要经隧道访问对端子网,不能关),把本网段查表固定回 main 表——ip rule add to 192.168.2.0/24 lookup main priority 5255(任何 <5270 皆可)。妙处在于两全:在网段内时 main 表直连路由获胜,回包走 WiFi/网线;带笔记本出门后 main 表查不到本网段,自动落回 table 52 走隧道,子网路由功能无损。ip rule 重启即丢,需 systemd oneshot 单元持久化(tailscaled 只管理 5210–5270 自己的段,不会代为恢复)。注意"/23 更短前缀宣告让本地 /24 获胜"仅在 Windows/macOS 有效——Linux 策略路由逐表顺序查找、跨表无最长前缀,main 表的 /24 轮不到被比(tailscale issue #1227)。tailnet 内已修复三例:xiao99(宣告者,关 accept 即可)、KVM VM、笔记本(普通节点,豁免+持久化);完整排查记录与单元模板沉淀在 tailscale-p2p 仓库文档。
双路由器 HA(2026-08-16 实测):同一网段 192.168.4.0/22 由 peter-optiplex-7090 与 xiao99 双通告并逐个审批后,headscale node.routes.ha(默认开启)使客户端同时拿到两条候选路由,按连接(五元组哈希)分发、单台掉线自动切换;headscale nodes list-routes 两个节点均显示 Approved+Available。局限:无法配置优先级(主备),负载近似均分;两台都别开 accept。
与 OpenVPN 路由共存(2026-08-16 实测 peter-xiao100):home 本机已有 OpenVPN 路由 192.168.4.0/22 via 10.8.0.5 dev tun0(main 表),与 tailnet 宣告的公司网段完全重叠。实测结论:不冲突,但 tailscale 会接管——tailnet 子网路由进独立 table 52,策略路由优先级 5270 lookup 52 < 32766 lookup main,accept 后访问 192.168.4.x 全部走 tailscale 隧道(公司机作子网路由器 + SNAT),OpenVPN 那条路由休眠但保留。两条隧道本质是"到同一网段的两个入口",谁的路由表优先级高谁生效。想继续走 OpenVPN(如 443 穿透公司严格 NAT)就不要执行 accept;接管情况用 ip route show table 52 确认。
管理面板(headplane)访问
更正(2026-08-15):本文初稿的"headplane 公网访问"方案(CF Tunnel / Zero Trust)当时并未实际启用——
panel.juzhong.xyz未建 DNS 记录、cloudflared 也未配对应 ingress。实际落地改为经 tailnet 内访问(headplane 不监听公网,只绑127.0.0.1:3000与 tailnet IP100.64.0.100:3000)。原方案对比保留,作为未来公网访问的备选资料。为何不用tailscale serve暴露见文末"踩坑记录"。
headplane 绑定:
# docker-compose.yml
ports:
- "127.0.0.1:3000:3000/tcp"
- "100.64.0.100:3000:3000/tcp"
# headplane.yaml
base_url: "http://100.64.0.100:3000" # tailnet IP,须与绑定一致
cookie_secure: false # tailnet 内走 HTTP;WireGuard 已加密,无需 secure cookie
访问:任意 tailnet 节点浏览器开 http://100.64.0.100:3000/admin,用 headscale API key 登录。安全模型:公网安全组不放行 3000 + 容器不监听 0.0.0.0 + WireGuard 端到端加密 + API key 认证,四层无公网暴露面。
运维速查(常用命令):
cd /home/peter/p2p
docker-compose exec headscale headscale users list # 用户及 ID
docker-compose exec headscale headscale users create <名称> # 新增用户
docker-compose exec headscale headscale nodes list # 节点与状态
docker-compose exec headscale headscale nodes expire -i <id> # 踢下线
docker-compose exec headscale headscale preauthkeys create --user <id> --expiration 24h # 新设备入网 key
docker-compose exec headscale headscale apikeys create # headplane 登录 key(只显示一次)
docker-compose exec headscale headscale nodes list-routes # 子网路由审批(0.29.3 语法;旧 routes list/enable 已移除)
固定节点 IP(headscale 无 CLI,直接改库;先备份,改完重启 headscale 和客户端):
docker exec headscale headscale nodes list -o json | grep -i given_name # 确认节点 ID
docker stop headscale
cp /home/peter/p2p/data/db.sqlite* /tmp/
python3 -c "import sqlite3; d=sqlite3.connect('/home/peter/p2p/data/db.sqlite'); d.execute(\"UPDATE nodes SET ipv4='100.64.0.100' WHERE id=3\"); d.commit()"
docker start headscale
tailscale up --login-server=https://headscale.demo.com:8443 --operator=peter # 重新拉取地图
公网访问备选方案一:纯 Cloudflare Tunnel(未启用,备选)
关键认知:Tunnel(通道)不需要 Zero Trust 组织、不需要信用卡;Access(认证门)才需要。纯 CLI 路线全程只碰 CF 基础 DNS + 隧道:
cloudflared tunnel login # 浏览器授权
cloudflared tunnel create caddy-tunnel # 建隧道
cloudflared tunnel route dns caddy-tunnel panel.demo.com # 加 CNAME
# /etc/cloudflared/config.yml 在 404 兜底前插入:
# - hostname: panel.demo.com
# service: http://localhost:3000
sudo systemctl restart cloudflared
headplane 侧同步改配置并重启(启用公网方案时,与 tailnet 内访问二选一或共存):
server:
base_url: "https://panel.demo.com" # 必须改,否则登录后跳转回 localhost
cookie_secure: true
访问 https://panel.demo.com/admin,用 headscale API key 登录。安全模型:无端口暴露 + TLS + API key 单层认证。
公网访问备选方案二:Zero Trust + Access(未启用,备选,绑卡边缘双因子)
创建 Zero Trust 组织时 CF 要求添加付款方式(风控,Free 计划 50 用户不扣费)。之后:
- Tunnel → Public Hostname 添加
panel.demo.com→ HTTP →localhost:3000; - Access → Applications → Self-hosted → 配置 Email OTP(或 Google)策略。
访问流程:浏览器 → CF 边缘邮箱验证码(未认证请求到不了 VPS)→ 隧道 → headplane API key。双因子 + 边缘审计,代价是绑卡一次 + 依赖 CF 零信任产品线(国内 VPS↔CF 边缘稳定性需实测)。
| 维度 | 方案一 纯隧道 | 方案二 Zero Trust + Access |
|---|---|---|
| 成本 | 0 元,无卡 | 0 元(Free),绑卡验证一次 |
| 认证 | headplane API key 单层 | CF 双因子 + API key 两层 |
| 攻击面 | 无端口,有认证入口 | 认证在边缘,请求到不了 VPS |
| 审计 | 无 | CF 登录审计 |
个人场景方案一已够用;两方案可随时切换,隧道配置完全复用。
废弃 wg-easy
VPS 上原有 wg-easy(WireGuard 管理面板)与新方案并存无冲突,确认废弃后清理:
docker-compose rm -sf wg-easy # 停并删容器
docker volume rm wg-easy_etc_wireguard # 删数据卷(已空)
# 从 docker-compose.yaml 移除服务块(先备份)
残留:旧配置目录(root 属主,需 sudo 删)、安全组 51820/udp 放行(无害可留)。
阿里云内网服务被 tailnet CGNAT 网段冲突阻断(2026-08-21 实测)
2026-08-21,VPS 上阿里云自动备份(hbrclient)持续失败,排查后定位为 tailnet CGNAT 网段与阿里云 VPC 内网服务网段重叠,入向回包被 tailscale 防火墙丢弃。这也是 8-14 部署时"VPC DNS 不可达"坑的真身(见本节末尾关联)。
现象
hbrclient.service(Alibaba Cloud Backup Service)反复重启,日志反复报NoAvailableEndpointss -tn可见发往100.100.105.60:443的连接永远卡在SYN-SENT- VPC 内网 DNS(100.100.2.136)解析一度超时
根因:两个"100 网段"撞车
headscale 默认给 tailnet 分配 100.64.0.0/10(config prefixes.v4)。Tailscale 客户端为防 IP 欺骗,在 ts-input 链装了丢弃规则:
-A ts-input -s 100.64.0.0/10 ! -i tailscale0 -j DROP
而阿里云 VPC 内网服务恰好也落在 100.64.0.0/10 内:
| 地址 | 用途 | 稳定性 |
|---|---|---|
| 100.100.2.136/138 | VPC 内网 DNS | 固定(DHCP 同步推送路由) |
| 100.100.100.200 | ECS metadata(实例身份) | 固定 |
| 100.100.105.60 | HBR 备份 MQTT 通道(post-cn-xxx-internal.mqtt.aliyuncs.com 解析) | 域名稳定,IP 可能轮换 |
机制:出站请求正常(路由走 eth0,table 52 无覆盖路由),但回包源地址落在 CGNAT 段且非来自 tailscale0 → 命中 DROP → TCP 永远建不起连接。
排查要点(实测命令)
ip rule show # 5270: from all lookup 52(优先级高于主表 32766)
ip route show table 52 # 只有 tailnet peer IP,无 100.100.x 覆盖路由 → 路由没问题
ip route get 100.100.2.136 # via <VPC网关> dev eth0 → 出站正常
ss -tn | grep 100\. # SYN-SENT 100.100.105.60:443 ← 实锤
sudo iptables -S ts-input # 看到 -s 100.64.0.0/10 ! -i tailscale0 -j DROP
tail -30 /opt/alibabacloud/hbrclient/logs/hbrclient.log
# "Cannot connect to wss://post-cn-xxx-internal.mqtt.aliyuncs.com:443 ... i/o timeout"
# "Failed to get ECS metadata ... 100.100.100.200:80 i/o timeout"
一句话定位:路由没坏,坏在防火墙对"内网回包"的防欺骗丢弃(先查 ip route get 排除路由,再看 ss 抓 SYN-SENT 定位目标 IP,最后 iptables 确认 DROP 规则)。
修复:放行阿里云内网服务段
sudo iptables -I ts-input 1 -s 100.100.0.0/16 -j ACCEPT
100.100.0.0/16 是阿里云内网服务固定段,一条覆盖 DNS/metadata/MQTT 及后续新增内网 endpoint,换 IP 无需维护。
持久化(必踩的坑)
tailscaled 每次启动会重建 ts-* 链并冲掉自定义规则;iptables-persistent 恢复时机在 tailscaled 之前,同样会被清。需 systemd oneshot(After=tailscaled.service):
# /etc/systemd/system/ts-input-aliyun-allow.service
[Unit]
Description=Allow Aliyun internal 100.100.0.0/16 in ts-input
After=tailscaled.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/iptables -C ts-input -s 100.100.0.0/16 -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I ts-input 1 -s 100.100.0.0/16 -j ACCEPT
[Install]
WantedBy=multi-user.target
验证
timeout 3 bash -c '</dev/tcp/100.100.105.60/443' && echo OK # 之前 SYN-SENT 卡死,现在应连通
curl -m 3 -s http://100.100.100.200/latest/meta-data/instance-id # metadata 恢复
grep -i NoAvailableEndpoint /opt/alibabacloud/hbrclient/logs/hbrclient.log | tail # 不再新增
实测结果(2026-08-21 15:31 起):加规则后 TCP 建连立即 OK、metadata 返回 instance-id、ss 从 SYN-SENT 变 ESTAB;hbrclient 重启后日志不再报 Cannot connect/NoAvailableEndpoint,MQ 注册成功、任务轮询正常,备份链路恢复。
备选方案(记录备查)
| 方案 | 安全性 | 维护量 | 静默失败风险 |
|---|---|---|---|
| 精确 IP 白名单(按日志逐个加) | 最高 | 低,但要盯日志 | 高(阿里云换 IP 即挂) |
| ipset + 内网域名定时刷新(折中) | 高 | 中 | 低 |
| 放行 100.100.0.0/16(当前采用) | 中(有意放宽) | 最低 | 最低 |
ipset 方案:i 一条 iptables 规则引用 ipset 集合,cron 按内网 DNS(dig @100.100.2.136)解析 *-internal.aliyuncs.com 域名刷新集合——必须用内网 DNS 解析,公网 DNS 拿到的是公网 IP,白名单失效;域名清单需随 agent 演进手动增补;ipset 与规则同样需要重启后恢复。
安全说明
放行 /16 使 ts-input 防欺骗对该段失效(tailnet 成员可伪造该段源 IP 直达本机服务)。实际风险低:tailnet 全为自有设备、外部攻击者受阿里云安全组 + vSwitch 源地址校验限制、本机服务无按源 IP 做信任判断者。若未来 tailnet 加入不可信节点,应收窄回精确 IP/ipset 方案。
关联:8-14 部署时的 DNS 坑就是它
部署时"VPC DNS 不可达"当时用 /etc/hosts 硬编码 + 公共 DNS 绕过,判断为"DNS 污染/不可达"。现在看,那次"不可达"正是 ts-input 丢回包(当时 VPS 节点刚加入 tailnet),绕过方案有效但掩盖了根因;本次备份失败把真凶暴露出来,一次解决了两类症状。教训:加入 tailnet 后阿里云内网服务异常,优先怀疑 CGNAT 网段冲突,而不是 DNS 污染。
总结
- 方案:headscale 控制面 + Tailscale 官方客户端,打洞直连优先、DERP 中继兜底,数据面完全自主可控;
- 坑:备案约束 → 自定义端口;官方 DERP 地图不清空会绕海外;0.29 的 preauthkey 用数字用户 ID;国内 VPS 拉 Docker Hub 需本地中转;
- 安全:默认仅节点互通 + 子网显式开关;DERP 默认 verify_clients;面板经 tailnet 内访问(无公网暴露,四层防护);
- 实测:打洞成功 21ms 直连,DERP 只走自建节点,证书续期全自动。
踩坑记录:tailscale serve 在本拓扑下不可用(2026-08-15)
曾尝试用 tailscale serve --bg --http 3000 3000 把 headplane 暴露到 tailnet,实测失败,两个原因:
- 代理端口与 docker-proxy 冲突:serve 的代理监听端口与 headplane 容器已占用的
127.0.0.1:3000冲突(headplane 容器先于 tailscaled 启动),请求全部返回404 page not found(Go 默认 404,19 字节); - 挂载按主机名匹配 Host:serve 挂载 key 是 tailnet 主机名
iz2zehkipqcroqwfrvhwj7z:3000,而本 tailnetmagic_dns: false,其他节点无法解析该主机名、只能用 IP 访问 → Host 匹配失败同样 404。
结论:改用 docker 双绑定(127.0.0.1:3000 + 100.64.0.100:3000)直连,无 serve 的匹配与端口冲突问题;若要恢复 MagicDNS 才能发挥 serve 价值,而 MagicDNS 会干扰公司 DNS(见 config 注释),故弃用 serve。