Celium 架构
Celium 是一个自托管的 mesh 组网软件,功能对齐 Tailscale:给每台机器一个 虚拟 IP,让它们在任意网络环境下互联,走点对点加密隧道,打不通时自动回退到 中继,并且由一套中心化的策略(ACL / DNS / 密钥)统一下发。
┌──────────────────────────────────────────────┐
│ 协调服务器 control plane │
│ 机器鉴权 · 节点注册 · 虚拟 IP 分配 · ACL │
│ DNS 配置 · DERP 拓扑 · netmap 长轮询推送 │
└───────┬──────────────────────────────┬───────┘
│ Noise 加密控制通道 │ 只交换公钥与策略,
│ (HTTPS + Upgrade) │ 永远看不到业务流量
┌──────────┴─────────┐ ┌─────────┴──────────┐
│ 节点 A celiumd │ │ 节点 B celiumd │
│ ┌──────────────┐ │ 直连 UDP │ ┌──────────────┐ │
│ │ WireGuard 设备│◄─┼──────────┼─►│ WireGuard 设备│ │
│ └──────┬───────┘ │ 打洞成功 │ └──────┬───────┘ │
│ │ │ │ │ │
│ ┌─────┴─────┐ │ ┌───────┴─────┐ │ │
│ │ magicsock │ │ │ magicsock │ │ │
│ └─────┬─────┘ │ └───────┬─────┘ │ │
│ TUN / netstack │ │ │ │
└────────────────────┘ └─────────────────────┘
│ │
└──────────► DERP 中继 ◄─────┘
打洞失败时的兜底路径,
中继只转发密文,无法解密
1. 设计原则
- 控制面与数据面彻底分离。 协调服务器只知道公钥、端点、主机名和策略,
没有任何能解密业务流量的密钥。它被攻破的后果是"策略被篡改",而不是 "流量被读取"。
- 节点状态是 netmap 的纯函数。 服务器下发完整的期望状态(谁在线、
什么密钥、走哪个中继、放行什么流量),节点不做增量猜测。这让重连、 策略变更、状态回滚都能用同一套逻辑处理。
- 身份分层,密钥轮换。 机器密钥(Ed25519)代表设备,长期存在;
节点密钥(Curve25519 = WireGuard 密钥)每次启动轮换,由控制面签名成 节点证书,所以对端能离线验证身份,不需要在建连时回查服务器。
- 一切都要在没有 root 的情况下可验证。 TUN 模式是默认交付形态,
但同一套代码有一个 userspace(gVisor netstack)后端,测试与 CI 用它 跑完整链路。这也是 Tailscale 的 --tun=userspace-networking。
- 失败要能解释。 状态输出里必须能看到"这个 peer 为什么慢/不通":
当前走直连还是中继、最后一次握手、字节计数、NAT 类型。
2. 组件与代码结构
| 目录 | 职责 | 对应 Tailscale |
|---|---|---|
types/key | 四类密钥(machine / node / disco / control) | types/key |
types/tailcfg | 控制面协议数据结构 | types/tailcfg |
types/netmap | 运行时网络视图(带索引,不可变) | types/netmap |
types/ipn | Prefs(期望状态)/ Status(观测状态) | types/ipn |
control/controlhttp | Noise IK 握手 + HTTP Upgrade 隧道 | control/controlhttp |
control/controlclient | 客户端状态机:注册、长轮询、应用 netmap | control/controlclient |
controlplane | 协调服务器:注册表、IP 分配、ACL 编译、账号与邀请、管理 API | 闭源控制面 |
derp | DERP 中继协议的服务端与客户端 | derp |
disco | 打洞协议:ping/pong、端点发现、NAT 判定 | disco |
wgengine | WireGuard 设备与 peer 配置 | wgengine |
wgengine/magicsock | 端点管理:直连优先、中继兜底(实现 conn.Bind) | wgengine/magicsock |
net/tun | 真实 TUN 设备(macOS utun / Linux tun)与路由配置 | wgengine/router + tun |
net/netstack | 无 root 的 userspace 数据面(gVisor) | net/netstack |
net/dns | MagicDNS 解析器与宿主 DNS 配置 | net/dns + net/dnscache |
net/packet | ACL 数据包过滤器与 IP 头解析 | wgengine/filter |
net/socks5 | userspace 模式的出口代理 | — |
ipn/local | LocalBackend 状态机 | ipn/localapi + ipn/ipnlocal |
ipn/ipnserver | 本地 API(unix socket 上的 HTTP) | ipn/ipnserver |
ipn/state | 机器密钥 / 节点密钥 / 偏好持久化 | ipn/store |
engine | 数据面迁移到 Tailscale 实现的验证与结论 | — |
tstest | 端到端测试:真实控制面 + 真实中继 + 真实节点 | tstest |
关于「对应 Tailscale」这一列:数据面(WireGuard 设备、打洞、中继、用户态协议栈) 最终直接使用 tailscale.com 的 Go 实现,不再自行重写;控制面与账号体系是 Celium 自己的。迁移的验证、类型翻译表、以及哪些包会被删除,记录在 docs/ENGINE-ADOPTION.md 与 engine/ 中。在这张表里保留对照, 是为了说明每个模块在架构上对应什么,而不是承诺两份实现长期共存。
cli / cmd/celium | 命令行 | cmd/tailscale |
|---|---|---|
cmd/celiumd | 守护进程 | cmd/tailscaled |
cmd/celium-control | 协调服务器进程 | — |
3. 身份与密钥
四种密钥,四种用途,Go 类型不同,编译器保证不会混用:
| 类型 | 算法 | 生命周期 | 用途 |
|---|---|---|---|
key.MachinePrivate/Public | Ed25519 | 设备终身 | 设备身份;控制面用它鉴权;Noise 静态密钥由它派生 |
key.NodePrivate/Public | Curve25519 | 每次 up 轮换 | WireGuard 密钥;控制面签名成节点证书 |
key.DiscoPrivate/Public | Curve25519 | 每次进程启动 | 打洞消息的认证与加密 |
key.ControlPrivate/Public | Ed25519 | 服务器终身 | 签名 netmap 与节点密钥 |
文本形式带前缀,mkey: / nodekey: / discokey: / controlkey:, 避免把密钥贴错字段——这是 mesh VPN 运维中最常见的一类事故。
机器密钥 → Noise 静态密钥:控制通道用 Noise IK,客户端静态密钥不是 另生成一把,而是从机器密钥确定性地派生(SHA-512(seed) 截断并 clamp, 公钥侧走 Edwards→Montgomery 双有理映射)。这样 Noise 握手本身就完成了 机器鉴权,控制面不需要额外一轮签名校验。
节点密钥 → 节点证书:控制面用自己的 control 私钥对节点公钥签名, 签名随 netmap 一起下发。于是任何节点都能离线验证"这个 WireGuard 公钥 确实属于该节点",中继和服务器都无法冒充。
4. 控制协议
传输:HTTPS 上的 HTTP/1.1 Upgrade。客户端向控制面发起
POST /machine/control HTTP/1.1
Upgrade: celium-control-protocol
Connection: Upgrade
服务端回 101 Switching Protocols,之后这条连接变成 Noise IK 加密的 字节流,上面再跑 HTTP(net/http 服务端与客户端各自套一层)。
握手参数与 Tailscale 相同: Noise_IK_25519_ChaChaPoly_BLAKE2s,prologue = celium-control-protocol。 IK 模式让客户端在第一条消息里就加密了自己的静态公钥,同时服务端静态 公钥在客户端是 pin 死的(--control-key),因此中间人无处可藏。
加密流上的接口:
| 端点 | 方向 | 说明 |
|---|---|---|
POST /machine/register | 请求/响应 | RegisterRequest → RegisterResponse,节点加入 tailnet、拿到 node ID 与虚拟 IP |
POST /machine/map | 长轮询流 | 请求 MapRequest,响应是连续的 JSON 对象流(MapResponse),连接保持打开,服务器有变化就推 |
POST /machine/logout | 请求/响应 | 注销节点 |
netmap 推送语义:首个响应是完整快照(NetmapComplete=true),之后是 增量(PeersChanged / PeersRemoved)。节点端只需要实现"把 MapResponse 折叠进当前 NetworkMap",重连就是把折叠器重置。
端点上报(endpoint)走同一个 map 请求:节点每次探测出新的 UDP 端点, 或者 NAT 映射变化,就带着新的 endpoint 列表重开一次 map 请求,服务器更新 注册表并推给相关 peer。
5. 数据面
WireGuard 由 golang.zx2c4.com/wireguard(wireguard-go)提供——用的是 上游实现,不是重写协议,因为密码学协议自己实现是风险不是优势。
IP 包 → TUN/netstack → wireguard-go device → magicsock.Bind → UDP/DERP
magicsock 实现 wireguard-go 的 conn.Bind 接口,因此它位于 WireGuard 与网络之间,可以自由选择"这个包从哪条路出去":
- 每个 peer 维护一组候选端点:局域网地址、STUN 发现到的公网地址、
UPnP/NAT-PMP 映射地址、通过 peer 的本地 API 学到的地址。
- 直连可用就用直连;握手失败或超时就切到该 peer 的 home DERP 中继。
- 一旦直连恢复,立刻切回直连。切换对 WireGuard 透明:Bind 层换掉的是
发送路径,密钥与序号不变。
两个后端:
- TUN(默认,需要 root):macOS 用 utun(
syscall打开
AF_SYSTEM/SYSPROTO_CONTROL 的 com.apple.net.utun_control), Linux 用 /dev/net/tun。配合路由表把 100.64.0.0/10(以及 subnet router / exit node 的 CIDR)指进去。
- netstack(无 root):gVisor 用户态 TCP/IP 栈,进程内直接收发 IP 包,
对外只暴露一个 SOCKS5 代理,把本进程发起的连接接进 mesh。功能等价,但 只覆盖自己发起的连接,用于测试、容器与无特权环境。
虚拟 IP 从 100.64.0.0/10(CGNAT 段)分配,与 Tailscale 同段,好处是 不会与常见的家用/企业网段冲突。
6. disco:打洞与端点发现
UDP 上的轻量协议,消息格式:
magic[6]="CELIUM" senderDiscoPub[32] receiverDiscoPub[32] nonce[24] box(...)
载荷用 disco 共享密钥(X25519(disco 私钥, 对端 disco 公钥))做 NaCl box 加密,因此中继即使转发也无法伪造或篡改端点信息。
消息类型:
| 类型 | 作用 |
|---|---|
ping | 探测对端在本机看到的源地址,同时测量 RTT;携带 txid 与"请从哪个地址回" |
pong | 回显 txid,并报告自己看到的源地址 |
call-me-maybe | 让对端主动向我的一组候选地址发 ping,用于双方都在 NAT 后时同时开洞 |
call-me-maybe-endpoint | 端点变化时通知 |
NAT 判定(netcheck):向 DERP 节点的 STUN 端口发 STUN 请求,比较不同 目标下观察到的映射地址:
- 同一端口 → 易打洞(endpoint-independent mapping)
- 端口随目标变化 → 硬 NAT(symmetric),只能靠中继
NAT 类型直接影响策略:两个硬 NAT 之间的节点不会浪费时间反复打洞, 直接走 DERP。
7. DERP:中继兜底
DERP 是"最后手段"路径,不是 VPN 服务器:它转发的是 WireGuard 密文, 无法解密,也不知道里面是什么协议。
- 客户端通过
wss://(测试时ws://)连接,Upgrade: DERP。 - 帧格式:
magic[4]="DERP"+type[1]+len[4]+ payload。
类型包括 SendPacket、RecvPacket、KeepAlive、NotePreferred、 Ping/Pong、Health、PeerGone、Restarting。
- 每个节点在 netmap 里有一个
HomeDERP区域;控制面按区域延迟指派。 - DERP 之间也互相转发(mesh),因此两个 home 区域不同的节点也能通信。
加密边界要说清楚:客户端与中继之间由 TLS 保护;中继转发的载荷是 WireGuard 密文。因此中继能看到"谁在和谁通信"(这是它完成工作所必需的), 但看不到内容——解密它需要 WireGuard 私钥,而那把密钥只在两端存在。 DERP 协议本身不做端到端加密,因为它运载的东西已经是端到端加密的。
8. MagicDNS
每个节点在 100.100.100.100 上跑一个解析器(Tailscale 同名地址), 把 <host> 与 <host>.<tailnet>.celium. 解析为 peer 的虚拟 IP。 控制面通过 DNSConfig 决定:
- 哪些后缀走 MagicDNS(split DNS),其它后缀透传给原宿主解析器;
- 是否接管宿主 DNS 配置(
CorpDNS); - 额外的静态记录与 DoH resolver。
名字映射规则:<short-host> 唯一时直接可用;重名时按用户/设备模型 消歧(Cellium 里简化为:重名节点只保留 FQDN 可用,并在状态里给出告警)。
9. ACL
策略用 JSON 描述,在控制面编译成 tailcfg.PacketFilter 下发:
{
"groups": { "group:dev": ["alice@example.com"] },
"hosts": { "db": "100.64.0.5" },
"acls": [
{ "action": "accept", "src": ["group:dev"], "dst": ["db:5432"] },
{ "action": "accept", "src": ["tag:server"], "dst": ["tag:server:*"] }
],
"tagOwners": { "tag:server": ["alice@example.com"] }
}
编译时把 group / host / tag 展开成具体前缀,得到一张按源地址排序的规则表。 节点端 net/packet 对每个出站与入站包做匹配:源必须是某个 peer 的虚拟 地址(防伪造),目的端口必须在放行范围内。默认拒绝。
10. 本地后端与 CLI
ipn/local 是一个状态机:
Stopped ──up──► Starting ──netmap 到达──► Running
│ ▲
└──需要登录──► NeedsLogin ┘
CLI 不直接碰网络与密钥,它通过 unix socket 上的本地 HTTP API 与 celiumd 通信(ipn/ipnserver)。这样只有守护进程需要特权,CLI 本身 无权限要求,也避免了两份状态。
主要命令:
celium up [--advertise-routes=...] [--advertise-exit-node] [--accept-routes]
celium down
celium status [--json] [--peers]
celium ping <peer>
celium netcheck
celium login / logout
celium serve / celium derper / celium control(自托管服务端)
11. 威胁模型
| 对手 | 能做到 | 做不到 |
|---|---|---|
| 好奇的网络中间人 | 看到加密流量 | 解密、冒充节点(Noise + WireGuard 密钥都在端上) |
| 被攻破的 DERP 中继 | 拒绝服务、记录通信关系 | 读取内容、伪造端点信息(disco 端到端认证) |
| 被攻破的协调服务器 | 篡改 ACL、下发错误 netmap、拒绝服务 | 解密历史流量、冒充某节点与 peer 握手(节点证书要控制密钥签名,且客户端 pin 控制公钥) |
| 拿到磁盘的本地攻击者 | 读到机器密钥与节点私钥 | 解密历史流量(WireGuard 前向保密) |
12. 验证策略
- 单元测试:密钥编解码、netmap 索引与最长前缀匹配、ACL 编译与匹配、
netmap 增量折叠、disco 消息编解码。
- 端到端:一条
go test起一个真实协调服务器 + 2~3 个真实节点
(netstack 后端),验证
- 节点拿到虚拟 IP 并互相可见;
- 经中继(DERP)能 ping 通;
- 直连 UDP 打通后自动切换到直连;
- ACL 拒绝未授权端口、放行授权端口;
- MagicDNS 名字解析到正确虚拟 IP;
- 中继断开后仍能直连。
- 系统级:真实 TUN 模式需要 root,脚本化在具备权限的机器上跑。