数据面迁移到 Tailscale 引擎
结论
可行,且只采用数据面。 验证代码在 engine/tsengine(约 700 行),证明可以用 Celium 自己的协调服务器驱动 tailscale.com 的引擎,不需要他们的控制面、不需要登录、 不需要 profile 存储、也不需要 ipn.LocalBackend。详细证据见 ENGINE-ADOPTION.md。
已验证的两条路径(go test ./engine/...,无需 root):
- 直连:两个引擎、各自的节点密钥/disco 密钥/UDP 端口、用户态协议栈;网络地图里
完全没有中继区域,所以回声只可能走直连。反向对照:拨一个不属于任何节点的地址 必须失败,它确实失败。
- 中继:真实的
tailscale.com/derp/derpserver跑在 httptest 上,并用他们的
TS_DEBUG_NEVER_DIRECT_UDP 关掉直连。这是一个 A/B:地图里没有中继时回声失败, 加上一个区域后同样的回声成功,且 16 个包全部从中继区域 1 转发。
密钥兼容性也经过了断言而非推断:从 magicsock 里读回 DiscoPublicKey(),与传入的 Celium disco 私钥推导出的公钥比对相等。
替换清单
| 现在 | 迁移后 | 依据 |
|---|---|---|
wgengine/(自研 WireGuard 数据面) | engine/tsengine | 他们的 wgengine 被完整验证 |
wgengine/magicsock/(端点管理、直连/中继选路) | 他们的 wgengine/magicsock | 直连与中继两条路径都跑通 |
derp/(自研中继协议) | tailscale.com/derp/derpserver + 他们的客户端 | 两套 DERP 线格式互不兼容,必须整体切换 |
net/netstack/(用户态数据面) | 他们的 wgengine/netstack | 无需 root,darwin 上验证通过 |
net/tun/(真实 TUN 设备与路由) | 他们的 wgengine/router + tun | 同上 |
net/packet/(ACL 匹配器) | 他们的 wgengine/filter | 迁移后它就是死代码,可立即删除 |
disco/(自研打洞信令) | 暂留 | 他们的 magicsock 自带发现;确认无引用后再删 |
net/dns/(MagicDNS) | 保留 | 见下面的未决问题 |
control/、controlplane/、types/、ipn/、cli/、cmd/ | 保留 | 这些是 Celium 自己的控制面与账号体系 |
协调服务器可以保留自己的密钥类型和 JSON 协议不变:节点密钥与 disco 密钥在两边 是字节兼容的(都是 X25519 [32]byte,同样的 clamp、同样的 nodekey:/privkey:/ discokey: 文本形式)。机器密钥不兼容(Celium 是 Ed25519,Tailscale 是 X25519), 但数据面上没有任何代码读 tailcfg.Node.Machine,因此不影响迁移。
必须排期、不能靠发现的四件事
- DERP 切换是一次性动作。 两套线格式双向都不兼容:中继和节点必须同时切换。
自托管部署里这是可控的(一个 celium-control --derp 与它自己的节点同时升级), 但停机窗口要提前说清楚,而不是上线时才发现。
- ACL 语义审计。 他们的过滤器是有状态的:它不再检查出站方向,
nil表示
全部拒绝(Celium 现在把 nil 当"无限制"),并且对非 SYN 的 TCP、分片和 ICMP 宽松得多。迁移时必须逐条对照,否则会出现"策略看起来一样、实际放行范围不同"。
- 用户态模式下的 MagicDNS 尚未验证。 他们 netstack 里的名字解析由
ipn/ipnlocal 驱动,而这正是本方案省略的部分。Celium 现在的做法是:TUN 模式下 在 100.100.100.100 上起解析器并接管宿主 DNS;用户态模式下在进程内解析(代理与 自身查询走它)。这条路径与本迁移无关,需要单独确认在切换后仍然成立。
TS_DEBUG_ALWAYS_USE_DERP会让完整wgengine的关闭死锁(magicsock 的
rebind() 缺少 closed 检查,会装上永不关闭的连接)。验证时改用 TS_DEBUG_NEVER_DIRECT_UDP 达到同样效果。若将来需要这个调试开关,要先修上游。
未采用整体方案的原因
整体采用意味着把一套 Noise 握手、只支持 HTTP/2 的隧道、zstd 分帧的地图流,以及 persist/tsdial/eventbus 的依赖图一起搬进来——换取的却是一个 Celium 已经拥有的 控制面。而 magicsock.Conn 从控制面需要的只有 SetDERPMap + SetNetworkMap, 正是这个窄接口让"只换数据面"成立。
迁移的验收标准
不是"编译通过",而是现有的端到端套件全部通过——它已经在验证真实行为:
- 两个真实节点之间的 TCP 流量穿过隧道
- 直连打洞成功后自动从中继切到直连
- 纯中继路径可用
- ACL 拒绝真的拒绝、放行真的放行
- 一个节点下线后对端立刻标记离线
- 守护进程先启动、之后
up的路径
迁移过程中这些测试必须保持绿色;如果某一条在迁移后失败,那就是语义差异,必须查清 并记录下来,而不是调整测试去迁就实现。
迁移执行记录
上面是计划与证据。以下是已经执行的迁移:Celium 不再运行自己的数据面,ipn/local 现在驱动 engine/tsengine,而 engine/tsengine 驱动 tailscale.com。
1. 迁移后的数据面接口
tsengine.Node 是 Celium 与 Tailscale 引擎之间的全部界面。它在 spike 的基础上补齐了 ipn/local(以及 CLI、celium status、celium ping、SOCKS5 代理、MagicDNS)需要的 东西:
| 能力 | 接口 | 实现方式 |
|---|---|---|
| 逐对端状态 | PeerStatus(key) (PeerStatus, bool) | 走他们的 Engine.UpdateStatus(*ipnstate.StatusBuilder),即 ipnlocal 生成 tailscale status 用的同一接口;字节数与握手时间来自 WireGuard,当前路径来自 magicsock |
| ping | Ping(ctx, key, size) (*PingResult, error) | 他们的 Engine.Ping(ip, "disco", …)。选它的理由:disco ping 在 magicsock 内部应答,隧道坏掉时也能通,而且回答里带着路径(直连地址或中继区域)——ICMP 能证明"包到了",却答不出"走的哪条路"。他们的 ping 只在收到 pong 时回调、没有超时回调,所以这里在 ctx 预算内自己重发 |
| netcheck | Netcheck(ctx) (*netcheck.Report, error) | 他们的 net/netcheck 客户端 standalone 模式,也就是 tailscale netcheck 走的同一条路径 |
| 两种数据面 | Config.TUNMode + TUNMode() / TUNName() / UserspaceMode() | 用户态:Tun: nil(tstun.NewFake)+ wgengine/netstack;TUN:tstun.New + router.New(osrouter hook)+ 真实设备 |
| 用户态拨号与监听 | DialContextTCP / ListenTCP / HandleTCPFlow | 他们的 netstack;TUN 模式下这三个都返回明确错误(内核自己已经能到 tailnet) |
| 地址 | Addresses() / Addrs() | 取自最近一次网络地图 |
| 本机可被拨到的地址 | Endpoints() / EndpointTypes() / Config.OnEndpoints | 他们的引擎通过 SetStatusCallback 推送(wgengine.Status.LocalAddrs),变化时回调 Celium |
| 连通性摘要 | NetInfo() / DERPLatency() / Config.OnNetInfo | magicsock 的 SetNetInfoCallback |
| ACL | SetNetworkMap 内部安装 *tailcfg.PacketFilter | 见第 2 节 |
local.Backend 的导出 API 保持不变,只有三处类型被迫改名(原来的类型所在包已被删除):
Engine() wgengine.Engine→Engine() *tsengine.NodeSock() *magicsock.Conn删除(magicsock 是他们的包,且没有任何调用者)Netcheck() (magicsock.Report, error)→(netcheck.Report, error)
第三个类型现在住在新的叶子包 net/netcheck,理由是结构性的:ipn/ipnserver 要把它编码 成 JSON,cli 是刻意保持轻量的瘦客户端;为了一个状态结构把整个数据面(WireGuard + gVisor + 他们的控制客户端)链进 celium 可执行文件是不值得的。因此 cli/netcheck.go、cli/cli_test.go、ipn/ipnserver/{ipnserver,client,ipnserver_test}.go 各改了少量行——只改类型名,没有改行为。
2. 迁移后生效的 ACL 语义,以及哪几条与他们不同
决定:Celium 的语义保持不变,由 tsengine.buildFilter 在边界上适配。
nil*PacketFilter = 没有限制。他们的filter.New(nil, …)是全部拒绝,把 nil 直接
递进去会让每一个新 tailnet 静默锁死。因此 nil 被物化成一条显式的 allow-all 规则 (等价于他们的 tailcfg.FilterAllowAll),并且和真实策略走同一条转换路径。
- 非 nil 空 filter = 全部拒绝,原样递给
filter.MatchesFromFilterRules→ 0 条 match →
全拒。
钉住它的测试:
engine/tsengine/acl_test.go的TestNilACLAllowsAndEmptyACLDenies:两个真实引擎、同一个
tailnet,按顺序走"无策略 → 通"、"空策略 → 不通"、"只开 echo 端口 → 又通"三步。第三步 是关键:没有它,第二步的失败无法与"引擎在第二步坏了"区分开。
tstest的TestEmptyPolicyDeniesEverything:同样的三步,但策略经过真实的
controlplane.CompileACL({tag:nobody-has-this} 这种"规则存在但匹配不到任何机器"的 策略编译成空 filter)。既有的 TestACLIsEnforced 继续验证"端口级放行/拒绝"。
逐条对照 net/packet(已删除)与他们的 filter。 表里"语义"是指迁移后实际生效的 行为。
| # | net/packet 原来 | 他们的 filter | 迁移后的结论 |
|---|---|---|---|
| 1 | nil = accept-all,非 nil 空 = default-deny | 没有 nil 这一档;filter.New(nil) 全拒 | 已消除:在 buildFilter 里补上 nil 档 |
| 2 | 出站也检查(CheckOutbound) | runOut 无条件 Accept,只记 flow state | 放宽:出站不再被策略检查。见下面第 3.1 条 |
| 3 | 非首片分片:只有"覆盖全部端口"的规则才匹配,否则丢弃(fail closed) | IPProto == Fragment 直接 Accept | 放宽:分片第二片起不再被检查 |
| 4 | 入站包的源地址必须等于 WireGuard 认定的对端地址 | 同样按 WireGuard 给出的源检查 allowed IPs | 相同 |
| 5 | 目的地址是本机的流量一律接受(自己的地址之间不该被 ACL 管) | 同样有 localNets 豁免 | 相同 |
| 6 | 无法解析的包一律丢弃(fail closed) | 同样丢弃 | 相同 |
| 7 | 无状态:每条规则按方向匹配 | 有状态:512 条 flowtrack LRU,非 SYN TCP 与非首片以外的回包靠状态放行 | 放宽且更难解释:一条流一旦建立,策略变化对已建立的流不一定立刻生效 |
| 8 | ICMP/ICMP 错误按规则匹配 | ICMP 错误与 echo reply 总是接受 | 放宽 |
| 9 | 丢弃日志按 1s 限速(denyLogInterval) | 他们有自己的限速日志 | 相同(各自实现) |
| 10 | 按目的端口建索引,宽端口范围落回线性扫描 | 规则集展开为 match 列表 | 实现细节,语义无关 |
被删除的 net/packet/filter.go 里值得留下的知识,除上表外还有两条:一是规则里的 NetPortRange.Bits 是展示用提示,不影响匹配(他们的解析器在 Bits 非 nil 时会让 整条策略转换失败,所以 tsengine 直接拒绝这种地图而不是产出一个装不上的过滤器); 二是"匹配不到任何机器的规则"不是错误(这正是策略先于机器存在时的样子),但绝不能 静默变成通配。
3. 迁移中发现的行为差异(每条都注明钉住它的测试或测量)
3.1 出站不再被策略检查(保留差异,必须记住)
他们的过滤器对出站包无条件放行。后果不是"ACL 失效",而是执行点从两端变成只有收端: 本机发出的包不会因为策略被丢弃,被拒绝的连接表现为客户端超时而不是立刻失败。 TestACLIsEnforced 与 TestEmptyPolicyDeniesEverything 仍然通过,因为拒绝发生在对端的 入站过滤上——这正是"测试全绿但语义变宽"的典型情形,所以它被写在这里而不是被测试掩盖。 如果将来需要"本机也不许发",那必须在 Celium 自己的出站路径上补一层,而不是指望他们的 filter。
3.2 对端状态里 Relay 的含义不同
他们的 ipnstate.PeerStatus.Relay 在有中继的地图上总是填对端的 home 区域 (endpoint.populatePeerStatus 无条件设置),哪怕此刻走的是直连。Celium 的 ipn.PeerStatus.Relay 文档是"正在使用的中继"。因此 ipn/local/status.go 只在 CurAddr == "" 时填 Relay,保持 PathType() 的"直连/中继互斥"契约。 钉住它的测试:TestDirectPathIsPreferred(要求直连时 Relay == "")与 TestRelayPathCarriesTraffic(要求中继时 CurAddr == "")。
3.3 首次连接的延迟变长(他们的引擎固有的)
他们的引擎是懒建路径的:向一个对端发第一个包时,如果既没有已验证的直连地址、也还没 学到对端的 home relay,这个包会被丢弃(wg: Failed to send handshake initiation: no UDP or DERP addr),而 wireguard-go 的重握手间隔是 5 秒。Celium 旧引擎会直接把包打到候选 地址上,所以第一个包就通。
实测(同一台机器、同一套 e2e):
| 旧引擎 | 新引擎 | |
|---|---|---|
TestMeshTrafficReachesAPeer | 0.21s | 3.17s |
TestDirectPathIsPreferred | 0.17s | 3.17s |
TestRelayPathCarriesTraffic | 2.08s | 3.16s |
整个 ./tstest/ | 36.8s | 92.8s |
TestACLIsEnforced | 33.1s | 36.5s |
其中大部分差距来自上面这个"第一个包被丢掉、5 秒后重试"的机制。测量中还发现一件事值得 记住:e2e 的 relay 如果不提供 STUN,netcheck 会退回用 HTTPS 给 relay 测延迟,对一个 明文 HTTP 的 relay 来说就是等一次永远不会完成的 TLS 握手——3 秒白等,而且最终什么都测不 到。测试 harness 现在起一个真实的 STUN 监听(celium-control --derp 本来也提供),这既 缩短了测试,也让 harness 更像真实部署。
3.4 他们的 disco ping 没有超时回调
magicsock 只在收到 pong 时调用 Engine.Ping 的回调(endpoint.go:1778 是唯一调用 点),没有回复的 ping 永远不回调。因此 tsengine.Ping 在调用方的 ctx 预算内自己重发 (每 1s 一次),并把第一次回复作为结果。重发不只是补一个缺失的超时:一个刚起来的 tailnet 里,对端的 home relay 与端点都是异步学到的,第一次 ping 完全可能打空。
3.5 两个进程级开关
TS_DEBUG_NEVER_DIRECT_UDP(Config.DisableDirectPaths):它是环境变量、进程级,
所以用引用计数(envSwitch)在第一个需要它的节点上打开、最后一个关闭时还原;同时 在交给 magicsock 的地图里剥掉对端的 endpoints,作为不依赖该开关的第二道保险。 限制:同一进程里不能同时有"只走中继"和"允许直连"的节点;Celium 的守护进程一进程 一节点,只有测试会碰到,测试里同一个 cluster 用同一种设置。
TS_DEBUG_USE_DERP_HTTP:见 3.6。
3.6 InsecureForTests 的含义不同,以及它带来的限制
Celium 的地图里这个标志只有一种用法:明文 HTTP 的 loopback relay——Celium 旧客户端 据此把 URL 的 scheme 从 https 改成 http(wgengine/magicsock/derp.go)。 Tailscale 把它读成"跳过证书校验",而他们的客户端除了进程级的 TS_DEBUG_USE_DERP_HTTP 之外没有"按 relay 用明文"的开关。两者不能同时满足,而地图格式是 Celium 的,所以按 Celium 的语义实现:地图里出现 insecure 节点时,tsengine 打开那个进程级开关。
限制(必须记住):
- 同一进程不能对一个 relay 用 HTTP、对另一个用 HTTPS(Celium 旧客户端可以)。
- 自签名 TLS 的 relay 在 Celium 的 map 里无法表达:它会被当成明文 HTTP relay。
要跑 TLS relay,就用真正的证书(celium-control --tls-cert/--tls-key),此时地图不设 insecure 标志,客户端走 HTTPS。
TestNeedsPlainHTTP 钉住这个判定的两种地图形状,TestEnvSwitchIsRefCounted 钉住开关的 引用计数与还原。
3.7 relay 的切换是一次的,flag 全部保留
cmd/celium-control --derp 不再挂 derp.Server,改为 derp/derpserver(v1.102.3 里 server 在 derp/derpserver,不在 derp/),STUN 改用 net/stunserver, --derp/--derp-region-id/--derp-region-code/--derp-region-name/--derp-stun-port/ --derp-hostname 的语义与校验都没变,main_test.go 未改。
地图与实际提供的服务保持一致:relay 挂在主监听器上(和以前一样),因此 DERPPort 就是主监听端口;STUNPort 是 STUN 实际监听的那个 UDP 端口;只有"明文 HTTP + loopback"才设 InsecureForTests,TLS 部署不设。relay key 仍然是 derp.key、仍然跨重启 保持(节点会 pin 它);它和 Celium 的 node key 是同一套字节,转换在 cmd/celium-control/keys.go。
3.8 端点上报与 netcheck 的驱动方式
删掉了 ipn/local 里 5 分钟一次的 netcheck 循环:端点现在由引擎在自己认为变化时推送 (OnEndpoints),比定时轮询既快又准;连通性摘要同理(OnNetInfo)。
celium netcheck 仍然按需探测,但它用自己的一套 UDP socket(他们的 netcheck 客户端 standalone 模式),不是引擎正在用的那个。诚实地说:在"每个连接分配不同外部端口"的 NAT 上,报告里的公网地址可能和隧道实际用的端口不同,所以"UDP 通"是关于网络的证据,不是关于 隧道当前端口的承诺。报告里的 LocalEndpoints 填的是引擎实际上报的端点,便于对照。
3.9 MagicDNS:约束确认成立
他们的 netstack 没有导出解析器(netstack.Impl 没有 LookupHost),netstack 内部 的名字解析由 ipnlocal 驱动,而 ipnlocal 不在本设计内。因此 local.LookupHost 的顺序是:IP 字面量直接返回 → 网络地图(tailnet 名字,含裸标签补全 后缀)→ Celium 自己的进程内解析器(其余名字,走控制面配置的 resolver 与 split-DNS)。 TestMagicDNSResolvesPeers 通过。两种模式的分工与迁移前一致:TUN 模式在 100.100.100.100 上起解析器并(CorpDNS 打开时)接管宿主 resolver;用户态模式在进程内回答, SOCKS5 代理与 CLI 走它。
3.10 TUN 模式的实现与限制
TUN 模式用他们的 net/tstun(真实设备)+ wgengine/router(需要 _ "wgengine/router/osrouter" 这个空导入来注册 router hook,否则 router.New 在任何平台 都返回"unsupported OS")。TUN 模式下不创建 netstack:内核自己终结数据包。 local.Options.TUNMode 与 celiumd --tun=auto|tun|userspace 的语义不变。
本环境(非 root 的 macOS)无法起真实 TUN,所以这条路径没有被端到端验证过。失败时 tsengine 的报错会点名前置条件(需要 root / CAP_NET_ADMIN),local 会把它记进 health 并写日志——不会静默降级。
4. 删除清单
.go 行数(非测试 / 测试):
| 包 | 非测试 | 测试 | 合计 |
|---|---|---|---|
wgengine/(含 magicsock/) | 3670 | 2035 | 5705 |
derp/ | 2262 | 1073 | 3335 |
net/tun/ | 1338 | 739 | 2077 |
net/netstack/ | 733 | 903 | 1636 |
net/packet/ | 678 | 883 | 1561 |
disco/ | 257 | 191 | 448 |
| 合计 | 8938 | 5824 | 14762 |
disco/ 在迁移后没有任何引用(唯一的引用者 wgengine/magicsock 已删),所以按计划 删除;它的语义知识(disco 共享密钥比他们少一步 HSalsa20,因此两边不互通)保留在 ENGINE-ADOPTION.md 第 3 节,并已随数据面一起被他们的实现取代。
保留:control/、controlplane/、types/、ipn/、cli/、cmd/、net/dns/、 net/socks5/。
新增代码规模:engine/tsengine 非测试 2132 行、测试 1441 行,加上 net/netcheck/report.go 60 行。
go.mod 用 go mod tidy 清理过:golang.zx2c4.com/wireguard(旧引擎的依赖)不再需要, golang.org/x/{net,sys} 降为 indirect,同时补上了 Linux/Windows/Android 交叉编译需要的 间接依赖(iptables/nftables/netns/go-ole 等)。
5. 未能完成 / 已知限制
- 真实 TUN 模式没有端到端验证:本环境需要 root。代码完整,失败路径会点名前置条件。
- 自签名 TLS relay 无法在 Celium 的地图里表达(见 3.6)。
- 一个进程只能有一种 relay 传输、一种直连策略(见 3.5、3.6)。
- 出站方向不再有策略(见 3.1)。
- 仍然用不了
magicsock.Options.TestOnlyPacketListener(他们的wgengine.Config不转发
它),所以 tstest/natlab 风格的工具箱依然不可用——与 spike 的结论一致。
TS_DEBUG_ALWAYS_USE_DERP仍会死锁整个wgengine的关闭(见上面的第 4 条待办事项);
测试统一用 TS_DEBUG_NEVER_DIRECT_UDP。