数据面迁移到 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,因此不影响迁移。

必须排期、不能靠发现的四件事

  1. DERP 切换是一次性动作。 两套线格式双向都不兼容:中继和节点必须同时切换。

自托管部署里这是可控的(一个 celium-control --derp 与它自己的节点同时升级), 但停机窗口要提前说清楚,而不是上线时才发现。

  1. ACL 语义审计。 他们的过滤器是有状态的:它不再检查出站方向,nil 表示

全部拒绝(Celium 现在把 nil 当"无限制"),并且对非 SYN 的 TCP、分片和 ICMP 宽松得多。迁移时必须逐条对照,否则会出现"策略看起来一样、实际放行范围不同"。

  1. 用户态模式下的 MagicDNS 尚未验证。 他们 netstack 里的名字解析由

ipn/ipnlocal 驱动,而这正是本方案省略的部分。Celium 现在的做法是:TUN 模式下 在 100.100.100.100 上起解析器并接管宿主 DNS;用户态模式下在进程内解析(代理与 自身查询走它)。这条路径与本迁移无关,需要单独确认在切换后仍然成立。

  1. 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 statuscelium ping、SOCKS5 代理、MagicDNS)需要的 东西:

能力接口实现方式
逐对端状态PeerStatus(key) (PeerStatus, bool)走他们的 Engine.UpdateStatus(*ipnstate.StatusBuilder),即 ipnlocal 生成 tailscale status 用的同一接口;字节数与握手时间来自 WireGuard,当前路径来自 magicsock
pingPing(ctx, key, size) (*PingResult, error)他们的 Engine.Ping(ip, "disco", …)。选它的理由:disco ping 在 magicsock 内部应答,隧道坏掉时也能通,而且回答里带着路径(直连地址或中继区域)——ICMP 能证明"包到了",却答不出"走的哪条路"。他们的 ping 只在收到 pong 时回调、没有超时回调,所以这里在 ctx 预算内自己重发
netcheckNetcheck(ctx) (*netcheck.Report, error)他们的 net/netcheck 客户端 standalone 模式,也就是 tailscale netcheck 走的同一条路径
两种数据面Config.TUNMode + TUNMode() / TUNName() / UserspaceMode()用户态:Tun: niltstun.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.OnNetInfomagicsock 的 SetNetInfoCallback
ACLSetNetworkMap 内部安装 *tailcfg.PacketFilter见第 2 节

local.Backend 的导出 API 保持不变,只有三处类型被迫改名(原来的类型所在包已被删除):

  • Engine() wgengine.EngineEngine() *tsengine.Node
  • Sock() *magicsock.Conn 删除(magicsock 是他们的包,且没有任何调用者)
  • Netcheck() (magicsock.Report, error)(netcheck.Report, error)

第三个类型现在住在新的叶子包 net/netcheck,理由是结构性的:ipn/ipnserver 要把它编码 成 JSON,cli 是刻意保持轻量的瘦客户端;为了一个状态结构把整个数据面(WireGuard + gVisor + 他们的控制客户端)链进 celium 可执行文件是不值得的。因此 cli/netcheck.gocli/cli_test.goipn/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.goTestNilACLAllowsAndEmptyACLDenies:两个真实引擎、同一个

tailnet,按顺序走"无策略 → 通"、"空策略 → 不通"、"只开 echo 端口 → 又通"三步。第三步 是关键:没有它,第二步的失败无法与"引擎在第二步坏了"区分开。

  • tstestTestEmptyPolicyDeniesEverything:同样的三步,但策略经过真实的

controlplane.CompileACL{tag:nobody-has-this} 这种"规则存在但匹配不到任何机器"的 策略编译成 filter)。既有的 TestACLIsEnforced 继续验证"端口级放行/拒绝"。

逐条对照 net/packet(已删除)与他们的 filter。 表里"语义"是指迁移后实际生效的 行为。

#net/packet 原来他们的 filter迁移后的结论
1nil = accept-all,非 nil 空 = default-deny没有 nil 这一档;filter.New(nil) 全拒已消除:在 buildFilter 里补上 nil 档
2出站也检查(CheckOutboundrunOut 无条件 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 与非首片以外的回包靠状态放行放宽且更难解释:一条流一旦建立,策略变化对已建立的流不一定立刻生效
8ICMP/ICMP 错误按规则匹配ICMP 错误与 echo reply 总是接受放宽
9丢弃日志按 1s 限速(denyLogInterval他们有自己的限速日志相同(各自实现)
10按目的端口建索引,宽端口范围落回线性扫描规则集展开为 match 列表实现细节,语义无关

被删除的 net/packet/filter.go 里值得留下的知识,除上表外还有两条:一是规则里的 NetPortRange.Bits 是展示用提示,不影响匹配(他们的解析器在 Bits 非 nil 时会让 整条策略转换失败,所以 tsengine 直接拒绝这种地图而不是产出一个装不上的过滤器); 二是"匹配不到任何机器的规则"不是错误(这正是策略先于机器存在时的样子),但绝不能 静默变成通配。

3. 迁移中发现的行为差异(每条都注明钉住它的测试或测量)

3.1 出站不再被策略检查(保留差异,必须记住)

他们的过滤器对出站包无条件放行。后果不是"ACL 失效",而是执行点从两端变成只有收端: 本机发出的包不会因为策略被丢弃,被拒绝的连接表现为客户端超时而不是立刻失败。 TestACLIsEnforcedTestEmptyPolicyDeniesEverything 仍然通过,因为拒绝发生在对端的 入站过滤上——这正是"测试全绿但语义变宽"的典型情形,所以它被写在这里而不是被测试掩盖。 如果将来需要"本机也不许发",那必须在 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):

旧引擎新引擎
TestMeshTrafficReachesAPeer0.21s3.17s
TestDirectPathIsPreferred0.17s3.17s
TestRelayPathCarriesTraffic2.08s3.16s
整个 ./tstest/36.8s92.8s
TestACLIsEnforced33.1s36.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_UDPConfig.DisableDirectPaths):它是环境变量、进程级,

所以用引用计数(envSwitch)在第一个需要它的节点上打开、最后一个关闭时还原;同时 在交给 magicsock 的地图里剥掉对端的 endpoints,作为不依赖该开关的第二道保险。 限制:同一进程里不能同时有"只走中继"和"允许直连"的节点;Celium 的守护进程一进程 一节点,只有测试会碰到,测试里同一个 cluster 用同一种设置。

  • TS_DEBUG_USE_DERP_HTTP:见 3.6。

3.6 InsecureForTests 的含义不同,以及它带来的限制

Celium 的地图里这个标志只有一种用法:明文 HTTP 的 loopback relay——Celium 旧客户端 据此把 URL 的 scheme 从 https 改成 httpwgengine/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.TUNModeceliumd --tun=auto|tun|userspace 的语义不变。

本环境(非 root 的 macOS)无法起真实 TUN,所以这条路径没有被端到端验证过。失败时 tsengine 的报错会点名前置条件(需要 root / CAP_NET_ADMIN),local 会把它记进 health 并写日志——不会静默降级。

4. 删除清单

.go 行数(非测试 / 测试):

非测试测试合计
wgengine/(含 magicsock/367020355705
derp/226210733335
net/tun/13387392077
net/netstack/7339031636
net/packet/6788831561
disco/257191448
合计8938582414762

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.modgo mod tidy 清理过:golang.zx2c4.com/wireguard(旧引擎的依赖)不再需要, golang.org/x/{net,sys} 降为 indirect,同时补上了 Linux/Windows/Android 交叉编译需要的 间接依赖(iptables/nftables/netns/go-ole 等)。

5. 未能完成 / 已知限制

  1. 真实 TUN 模式没有端到端验证:本环境需要 root。代码完整,失败路径会点名前置条件。
  2. 自签名 TLS relay 无法在 Celium 的地图里表达(见 3.6)。
  3. 一个进程只能有一种 relay 传输、一种直连策略(见 3.5、3.6)。
  4. 出站方向不再有策略(见 3.1)。
  5. 仍然用不了 magicsock.Options.TestOnlyPacketListener(他们的 wgengine.Config 不转发

它),所以 tstest/natlab 风格的工具箱依然不可用——与 spike 的结论一致。

  1. TS_DEBUG_ALWAYS_USE_DERP 仍会死锁整个 wgengine 的关闭(见上面的第 4 条待办事项);

测试统一用 TS_DEBUG_NEVER_DIRECT_UDP