页边批注 · A Note in the Margin

不再手动同步:用自建 Headscale + Syncthing 搭一套多机分布式互联与同步 mesh

我有好几台机器在同时干活:公司一台台式、公司一台 Mac、家里一台、外加一台自己的云服务器。同一个工作目录在多台之间来回改,长期靠一个手写的 sync-all 脚本同步——但它不好用:它是手动的、按需跑的,只要哪次忘了”先拉再改”,两边就分叉,然后就是无穷无尽的手工 merge。

折腾到最后我想明白一件事:问题的根子不是”机器多”,而是”没有一个永远在线、永远可达的唯一真相源”。 sync-all 难用,不是脚本写得烂,而是”按需手动”这个模式本身留了一个会分叉的窗口。

这篇讲我怎么用 自建 Headscale(Tailscale 控制面)+ Syncthing 把这件事一次性解决掉:两台机器再也不用想”同步”这回事,改完存盘几秒就到对面;顺带任意两台之间能直接 SSH。全程自托管、无主从、加新机器一条命令。也会老实把踩的坑和没解决的取舍单列出来。

环境说明:文中所有域名/IP/密钥都是占位符。我的几台机器里有 WSL、macOS,云服务器在国内——所以”控制面在 GFW 后面稳不稳”是我必须考虑的硬约束,这一点很影响选型。


一、先否掉几个”看起来更简单”的方案

在动手前我认真比过几条路,它们都不对:

  • 「只在一台上集中干活」(两端都 SSH 进同一台)。最干净,但我每台机器有各自的角色和本地工具链,集中不现实。
  • 「让某个进程直接遥控家里那台,替我同步」。这是最脆弱的:家用机常关机、动态 IP、藏在 NAT 后,公司网经常根本够不到它;而且它等于多引入一个写入方,是多一个冲突源,不是少。
  • 「老实用 git」。大多数子项目本来就是 git repo,但 git 解决不了”草稿、散文件、非版本化资产”的实时同步,而且它还是要人记得 commit/pull——又回到”手动窗口”。

真正要的是两件正交的能力:(1) 任意机器之间稳定可达(含远程 SSH);(2) 工作目录实时双向同步、且离线机器上线后能自动追上。 这两件事分别由两层独立的 mesh 解决。


二、总体架构:两层互相独立的 mesh

              云服务器(公网 IP)—— 唯一常在线的锚点
   ┌──────────────────────────────────────────────────────────┐
   │  Headscale  控制面(自建,替代 login.tailscale.com)        │ ← 连通层大脑
   │  + 内嵌 DERP 中继 + STUN(NAT 穿透)                        │
   │  Syncthing  常在线枢纽 + 介绍人(GUI 仅在 tailnet 内可达)  │ ← 同步层枢纽
   └──────────────────────────────────────────────────────────┘
        ▲ 各节点只是「出站」连一个公网 IP,与 GFW 无关
   ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
   │ 公司台式      │◄─►│ 家里电脑      │◄─►│ Mac          │  每台:
   │ 100.64.0.1   │   │ 100.64.0.4   │   │ 100.64.0.3   │  tailscale 节点
   └──────────────┘   └──────────────┘   └──────────────┘  + syncthing 节点
       每台拿到稳定 100.64.x 地址;Syncthing 跑在 tailnet 上,不碰国际发现服务器
  • 连通层 = Tailscale 客户端 + 自建 Headscale。 每个节点加入后拿到一个固定的 100.64.x.x 地址和一个 MagicDNS 名,无论在哪个网络、NAT 后面都能互相直连(打不通就走 DERP 中继兜底)。Tailscale 是真 mesh:控制面只在「建立连接」时参与,它挂了,已经建好的点对点连接照常跑。
  • 同步层 = Syncthing。 P2P、无中心。但我额外让那台常在线的云服务器当枢纽:它持有一份同步副本,这样即使”家里和公司从不同时开机”也能收敛(各自上线时跟枢纽对齐);它还兼介绍人(introducer),新节点只要连上枢纽就自动认识全网其它节点。

两层都是”加节点 = 一条命令”,这正好满足”以后随时扩机器”。


三、两个关键选型决定

为什么自建 Headscale,而不是直接用官方 Tailscale? 官方 Tailscale 的控制面在境外(login.tailscale.com / controlplane.tailscale.com)。我的节点都在国内,控制面在 GFW 后面时通时不通,作为每天依赖的基础设施太不踏实。Headscale 是 Tailscale 控制面的开源自托管实现(单 Go 二进制 + SQLite),我把它放在自己那台有公网 IP 的国内云服务器上,所有节点只是”出站连一个域内公网 IP”——和 GFW 彻底无关。它还能开内嵌 DERP 中继,连中继都不必依赖官方的境外节点。

为什么 Syncthing 要跑在 tailnet 上? Syncthing 自带全球发现 + 中继服务器,但那些也在境外、同样会被限速。既然已经有了 tailnet,我直接给每个 Syncthing 节点配上对端的 100.64.x 静态地址、关掉全球发现和中继,流量全走自己的 tailnet。又稳又不依赖任何境外基础设施。


四、搭建要点(命令为示意,去掉了我自己的域名/IP)

1) 云服务器上的 Headscale——走一个子域 + 你现有的反向代理 + ACME 证书,控制面监听本地、由反代转发(Tailscale 客户端要求 HTTPS 且需放行 WebSocket 升级):

# /etc/headscale/config.yaml(节选)
server_url: https://hs.example.com
listen_addr: 127.0.0.1:8080
derp:
  server:
    enabled: true                 # 内嵌 DERP,不依赖境外中继
    region_id: 999
    stun_listen_addr: "0.0.0.0:3478"
  urls: []                        # 清空官方(境外)DERP map
dns:
  magic_dns: true
  base_domain: mesh.internal

反代关键是 WebSocket(否则控制连接 / DERP 长连接建不起来):

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;   # map 出来的变量
    proxy_read_timeout 86400s;                          # 长轮询
}

2) 节点接入——装 Tailscale,用 preauth key 接到自建控制面:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server=https://hs.example.com \
     --authkey=hskey-xxxxxxxx --accept-dns=false

--accept-dns=false 是有意的:桌面/WSL 自己管 /etc/resolv.conf,别让 Tailscale 去抢,免得搞坏本机 DNS;节点间用 100.64.x 地址就够了。

3) Syncthing——云服务器当枢纽常驻,叶子节点各跑一个;通过 REST API 把对端设备 + 共享文件夹配好,并把监听地址绑到自己的 tailnet IP、关掉全球发现/中继。叶子节点把”枢纽”这台标成 introducer: true,新节点连上枢纽就自动学到其它节点。

4) 同步范围——一份 .stignore 决定同步什么。我的原则是只同步代码/草稿/脚本/配置,排除一切”重而可再生”的东西:node_modules、虚拟环境、构建产物、缓存、*.mp4/*.glb 等重媒体、模型权重,以及 .git(仓库走它自己的 git remote,不靠 Syncthing 同步 .git,避免索引冲突)。这一刀下去,我的同步集从几 GB 缩到几百 MB。


五、踩过的坑(这节才是干货)

坑 1:CGNAT 网段和云厂商内网撞车。 Tailscale 用 100.64.0.0/10 这个 CGNAT 段。某些云厂商(我遇到的是一类国内云实例)的内网服务(apt 镜像、元数据)正好用 100.100.x.x——它落在 100.64.0.0/10 里!装上 Tailscale 后,它把云内网路由给吞了,apt 直接超时。修法是给云内网网段加一条更具体的旁路路由(优先级高于 /10),让它走真实网关:

ip route replace 100.100.0.0/16 via <真实网关> dev eth0
# 再用 systemd oneshot 持久化,排在 tailscaled 之后

坑 2:WSL 上的 Tailscale。 现代 WSL2 有 systemd 和 /dev/net/tun,Tailscale 能当正常服务跑。但它的 connmark 防火墙规则在 WSL 内核上会报 unknown option "--restore-mark"(缺内核模块)。节点只是普通客户端、不做出口/子网路由的话,直接 --netfilter-mode=off 让它别碰 iptables 最干净(必要时再 iptables-legacy)。

坑 3:Syncthing 的 #include 死锁(这个最隐蔽)。 为了让忽略规则全网一致、改一处就生效,我让每个节点的 .stignore 只写一行 #include .stglobalignore,真正的规则放在会被同步的 .stglobalignore 里。听起来很优雅——但新节点入网时还没同步到 .stglobalignore,#include 一个不存在的文件会导致忽略规则解析失败,文件夹直接卡在 sync-waiting 永远不拉取,而它要拉的东西里就包含那个 .stglobalignore。死锁。修法:节点入网脚本里先内嵌写一份 .stglobalignore 本地兜底,再 include;之后同步过来的同名文件内容一致、不会冲突。

坑 4:跨版本能互通,但要心里有数。 我的几台分别是 Syncthing v1.18(发行版自带的旧版)和 v2.1(官方 tarball)。BEP 协议跨大版本兼容,实测 v1.18 ↔ v2.1 正常建连同步。能用,但要装新机时尽量统一版本省心。

坑 5:第一个冲突,居然是忽略文件自己。 全网合并跑完,真实工作文件 0 冲突,唯一一个 .sync-conflict.stglobalignore——因为我和”另一台上的我”几乎同时改了它。这反而是个好示范:冲突时 Syncthing 不覆盖,把另一版留成带时间戳的副本,让你手动并。


六、性能与冲突语义:撑不撑得住频繁改?

撑得住,前提是别让它同步该排除的东西。

  • 不是每次保存都同步:Syncthing 走文件系统事件监听(inotify/FSEvents)+ ~10 秒防抖,连续保存会攒一起发,不是逐键。再加一个低频全量扫描兜底。
  • 增量只传变化的块(block 级去重):改一个大文件只传变动的那几 KB,不重传整文件。
  • 我这套(约六千文件 / 几百 MB 代码+文档)是 Syncthing 的舒适区——它能扛十万级文件、几百 GB。空闲内存几十 MB,只在扫描/算 hash 时短暂吃 CPU。
  • 真正撑不住的是:海量碎写的生成目录(node_modules/构建/缓存)、正在写的数据库 / SQLite WAL / 实时日志。这些务必在 .stignore 里排掉,否则它会疯狂 rescan + 算 hash + 制造一堆版本。

冲突怎么解?

  • 两台各自独立改了同一文件 → 一份按修改时间较新的留原名,另一份重命名成 名字.sync-conflict-日期-时间-设备.后缀两份都在,不覆盖、不丢。
  • 冲突副本也会同步到所有节点,你在任意机器都能处理;每文件默认最多留 10 个副本。
  • Syncthing 不做内容三方合并(不像 git)。代码场景最顺的解法就是用 git:.sync-conflict 文件就是另一版本,diff 一下挑/合,然后删掉它。
  • 删除会传播(一台删,别处跟着删)。所以我额外在常在线的枢纽上开了 staggered 版本控制(留 30 天):任何文件被改写/删除前,旧版进枢纽的 .stversions,30 天内可取回——等于白送一个带时间机器的备份节点。叶子节点不开,省空间。

七、安全模型:一台被黑会不会全套连根挂?

这是我自己最在意的问题,老实说结论:会有影响,但范围有限,而且能进一步收窄。

真实暴露面:云服务器只对公网开 443/80(反代,Headscale 控制面在其后、要合法 preauth key 才能进网)、223478(STUN 反射,低危)。Syncthing 的数据口和管理口只绑在 tailnet 地址上,公网根本没监听——同步内容不在互联网上。

真实风险:病根是”共享凭据”(preauth key + 枢纽 API key)。最坏情况下,某台叶子被黑 → 拿到这些凭据 → 能加流氓节点、能改/删同步目录并扩散。但它不会自动变成”每台机器 root”:各节点的 WireGuard 私钥只在本机,控制面被黑也解不开已建立的点对点流量;拿不到各机的 SSH 凭据也登不进别的节点。也就是说 blast radius 是”那个同步目录 + 重新拉个节点进来”,不是你整个数字资产。

于是我做了几层收窄:

  • preauth key 短期化:加机时才发、有效期几十小时,加完立刻作废。不留长效可复用 key。
  • 叶子不留长效凭据:入网脚本注册成功后,自动把本地凭据文件里的一次性密钥抹空。某台叶子失窃也拿不到入网权/枢纽控制权。
  • Headscale ACL:节点间只放行必要端口(SSH + Syncthing + 枢纽注册),其余拒,限制被黑节点的横向移动。
  • 枢纽 API key 可随时轮换。

比起原来”明文 SSH key 到处放 + 手动 rsync”的老办法,这套在堵掉共享凭据后是明显更安全的,不会更差。


八、加一台新机器:一条命令

我把上面所有步骤(装 Tailscale → 入网 → 装 Syncthing → 绑 tailnet/关国际发现/加枢纽/写忽略规则 → 自助注册到枢纽 → 抹掉一次性密钥)封成一个 mesh-join 脚本,Linux/WSL/macOS 通吃、幂等。新机器:

# 从已入网节点取脚本 + 一次性凭据,然后:
bash mesh-join <节点名>

实测在一台 macOS 上一键跑通(它还同时跑着别的 TUN 代理,共存无碍),并通过”介绍人”自动和其它节点直连——验证了”分布式 + 随时加节点”这个目标。


九、还差什么 / 取舍(老实说)

  • 枢纽是单点真相:它挂了,新建连接和”非同时在线的收敛”会受影响(已建的 P2P 同步不受影响),且它承担中继流量。对个人规模够用;真要更稳得再加一个枢纽/中继。版本回收让”枢纽挂了丢数据”的风险可控。
  • DERP 走自建单点:两台都在 NAT 后、打不通直连时,中继流量全过那台云服务器,受它带宽限。同 LAN 的两台会直连,不吃这个亏。
  • ACL 与”自由互联”的张力:我收紧了节点间端口,代价是想临时访问某台上的别的服务(比如本地 dev server)得先开端口。安全和便利的常规取舍。
  • .git 不同步:换来的是稳定(不撞 .git 索引冲突),代价是仓库状态得靠各自的 git remote 收敛——对本来就有远端的项目无所谓。

收尾

这套方案的核心判断是:多机同步的难点不在”找个同步工具”,而在”先有一个永远在线、永远可达、且自托管不受制于人的网络底座”。 先用自建 Headscale 把”任意机器互相可达”这件事坐实,Syncthing 这种 P2P 工具才能稳稳地跑在上面;再用一个常在线的枢纽兼介绍人,把”非同时在线”和”随时加节点”两个现实问题一起解决。

搭完之后最大的感受是:“同步”这个动作从我的脑子里消失了——我不再”做同步”,我只是在不同机器上改文件,它们自己会一致。这正是基础设施该有的样子:你感觉不到它,直到你想加第五台机器,发现也只是一条命令。