接口契约:控制面与数据面
当前状态
契约已经写成 Rust trait 和类型,放在 crates/meshora-dataplane, 并且有了第一个实现:M1 正在写的 boringtun 数据面(crates/meshora-wg)。控制面那一侧还没写,契约还会改。
架构一页把系统分成了控制面和数据面两层。这一页把两层之间的那条缝写死: 谁负责什么、能互相说什么、哪些规矩不能破。
边界在哪
一句话:控制面决定"和谁、走哪条路",数据面负责"加密、发出去"。
| 负责 | 由什么构成 | |
|---|---|---|
| 数据面 | 加密、收发、把报文交给中继 | WireGuard(boringtun 的 Tunn)、UDP socket、虚拟网卡 |
| 控制面 | 其余全部 | 身份、发现、端点探测、穿透协调、链路探测、选路、ACL |
有两处划分需要说明:
- 中继服务是自研的,但"把报文交给中继"这一步归数据面。 它在每个报文的热路径上, 放到控制面就意味着每个报文都要跨一次边界。
- 打洞和探测归控制面,但它们发出的报文要借数据面的 socket。 原因见下面的第 4 条。
boringtun 的 Tunn 是一个自己不做 I/O 的状态机:把报文喂给它,它告诉你该往外发什么。 socket 归谁、报文走哪条路,都由 Meshora 自己决定 —— 这正是这份契约要规定的。
数据面提供什么
控制面用四个方法驱动数据面:
| 方法 | 做什么 |
|---|---|
apply(peers) | 用一份完整的 peer 集合替换当前配置 |
set_path(peer, path) | 切换某个 peer 的发送路径:直连某个地址,或者经某个中继 |
send_control(to, datagram) | 从 WireGuard 用的同一个 socket 发出一个控制报文 |
send_control_via(path, peer, datagram) | 沿指定路径把控制报文发给某个 peer:直连同上,经中继则交给中继转发。控制面靠它探测中继那条路 |
status() | 每个 peer 的当前路径、最近一次握手的时间、收发字节数 |
数据面用事件把消息交回控制面:
| 事件 | 什么时候发生 |
|---|---|
HandshakeCompleted | 和某个 peer 完成了一次 WireGuard 握手,附带握手实际走的路径 |
ControlDatagram | socket 上收到一个控制报文,原样交出,未经认证 |
RelayedControl | 经中继收到一个控制报文,附带中继认证过的发送方和经过的中继(回应要走同一个);内容同样未经认证 |
接口是同步的,不绑定任何 async 运行时。本机私钥和事件出口都在构造数据面时交进去,不经过这些方法。
五条不变量
契约的价值不在方法签名,而在这几条规矩。每一条都有它要防的东西。
1. 私钥不出数据面
接口里没有任何读私钥的方法。私钥在构造时交进去,之后就拿不出来了。
控制面也要用这把私钥(和控制服务握手,见身份认证), 但它从密钥存储里拿,不从数据面拿。能读出私钥的地方越少越好。
2. apply 是声明式、原子的
控制面每次交出的是"现在应该是什么样",而不是"加一个、删一个":集合里没有的 peer 被删除, 新出现的被加入。整份配置要么全部生效,要么保持原样,不会停在改了一半的状态。
仍在集合里的 peer 保留路径和会话。 路径是运行时状态,不是配置 —— 改个 keepalive 不该让连接断一下。
集合在构造时就要过三道检查:同一个 peer 不能出现两次;网段必须是规范写法 (10.0.0.1/24 这种主机位非零的写法多半是笔误,直接拒绝);同一个网段只能分给一个 peer。
最后这条和 wg set 的行为不同:WireGuard 遇到重复的网段,会把它从原来的 peer 身上静默挪走。 这里选择拒绝 —— 静默挪走意味着一条错误配置就能把别人的流量引过来。
前缀长度不同的重叠仍然允许(10.0.0.0/8 给 A、10.1.0.0/16 给 B),按最长前缀匹配。 出口节点拿的 0.0.0.0/0 就是靠这条规则和其余路由共存的。
3. 发送路径只由控制面决定
原版 WireGuard 会漫游:从一个新地址收到对端的合法报文,就把发送地址切过去。 Meshora 的数据面不这么做,发送路径只能通过 set_path 改变。原因有两个:
- 安全。 漫游意味着"谁能送来一个合法报文,谁就能改发送地址"。路径上的攻击者可以扣下一个报文, 换个源地址再放出去,把流量引到它指定的地方 —— 读不到内容,但足以让流量绕道或者中断。
- 一致。 控制面的选路一直在测量各条路径。数据面要是自己也会切,两边就会互相打架。
对收到报文的回应(握手响应、cookie 回复)从哪条路来就从哪条路回,不受这条限制; 数据面主动发出的报文一律走 set_path 设定的路径。新加入的 peer 在设定路径之前,发给它的报文直接丢弃。
对端从一个新地址发起的握手仍然会完成,数据面在 HandshakeCompleted 里报告握手实际走的路径。 要不要切过去,由控制面验证之后决定。
4. 控制报文和 WireGuard 共用一个 socket
打洞是让 NAT 为"本机某个端口 → 对端"建一条映射。这条映射只属于那一个本地端口: 探测和打洞要是从另一个 socket 发出去,凿出来的洞 WireGuard 用不上。
所以控制面的探测、打洞报文必须经 send_control 从数据面的 socket 发出; 同一个 socket 收到的报文也要先分流,见下一节。
send_control 只接受以 Meshora 魔数开头的报文。控制面不能借这个口子,发出会被对端当成 WireGuard 报文的东西。
5. 控制报文未经认证
数据面只看报文开头的魔数,不做任何认证:来源地址可以伪造,内容可以是任意字节。 控制面必须先验证,再相信它,见威胁模型里的 R3。
也因为这是未经认证的外部输入,控制面处理不过来时可以丢掉它们(UDP 本来就不保证送达), 但握手完成的事件不能丢。事件出口不能阻塞:它在数据面的收发循环里被调用,卡一下所有流量都跟着停。
数据面自己也可以在交出之前就丢:现在的实现每秒最多交出 1000 条。谁都能往这个端口发控制报文, 事件通道要是无界的,控制面一卡住(比如正在给协调服务写消息),它们就会无限堆积。
共享 socket 上的分流
同一个 UDP socket 上收到的报文,按开头的字节分给不同的处理方:
| 报文 | 判定 | 交给 |
|---|---|---|
| WireGuard 握手发起 | 前 4 字节是 01 00 00 00,长度正好 148 | WireGuard |
| WireGuard 握手响应 | 02 00 00 00,长度正好 92 | WireGuard |
| WireGuard cookie 回复 | 03 00 00 00,长度正好 64 | WireGuard |
| WireGuard 数据 | 04 00 00 00,长度至少 32 | WireGuard |
| Meshora 控制报文 | 以 MSHR 开头 | 控制面 |
| 其他 | —— | 丢弃 |
WireGuard 部分的判定和 boringtun 自己解析报文的规则一致。有一个容易踩的坑: 不能要求数据报文的长度是 16 的倍数。 WireGuard 规范要求把明文填充到 16 的倍数,但填充不会超过 MTU, 贴着 MTU 的报文填不满;boringtun 更是干脆不填充。加上这条检查,被当成垃圾丢掉的会是大量正常报文。
魔数的选择只有两条约束:按 WireGuard 的读法(前 4 字节当小端整数)不能落在 1 到 4; 首字节不能是 STUN 用的 0x00~0x03,给以后改用标准 STUN 留一条路。两条都在代码里用编译期断言钉住了。
crate 划分
| crate | 职责 | 现在 |
|---|---|---|
meshora-types | 共享词汇:节点身份 NodeKey 和它的私钥、路径 Path、控制报文的魔数。不做网络 I/O | 已建 |
meshora-dataplane | 这份契约:DataPlane trait、它收发的类型、共享 socket 上的分流规则。不依赖 boringtun | 已建 |
meshora-wg | 数据面的实现:基于 boringtun 的、不做 I/O 的核心引擎,加上跑在 tokio 上的 UDP 驱动 | 已建 |
meshora-tun | 虚拟网卡:Windows 用 wintun,Linux 用 tun,macOS 用 utun。平台相关的 unsafe 代码集中在这里 | 已建:Linux 实测过,Windows 在 CI 的虚拟机上实测过,macOS 还没有 |
meshora-proto | 节点、协调服务、中继之间的线协议:编解码、Noise 连接、节点间控制报文 | 已建 |
meshora-control | 节点侧控制面:注册与认证、发现、端点探测、穿透、链路探测、选路 | M1 |
meshora-coord | 协调服务(可自建):注册、密钥分发、打洞对时 | M1 |
meshora-relay | 中继服务(可自建):转发密文 | M1 |
meshorad | 节点守护进程:把控制面、数据面、虚拟网卡接起来 | M1 |
依赖关系有三条规矩:
- 控制面(
meshora-control)和数据面的实现(meshora-wg)互不依赖,两边都只依赖契约meshora-dataplane,由meshorad接起来。契约单独成一个 crate 正是为了这个: 放在实现里的话,控制面为了用上 trait,得把整个 WireGuard 实现都拖进来 - 协调服务和中继不依赖数据面,也不依赖虚拟网卡:服务端既不需要 WireGuard,也不需要虚拟网卡
meshora-types不依赖任何 Meshora crate,它在依赖图的最底层
表里标"已建"的 crate 都在 workspace 里了。其余的等各自有代码可写时再建 —— 空壳 crate 只会让仓库看起来比实际进度多。
M1 定下来的
- 中继走 TCP + Noise IK,不和这个 UDP socket 复用。节点用自己的节点密钥认证,中继只服务名单里的节点
- 打洞对时不经中继:("请你现在向我发包")走协调服务,它本来就和双方都连着。 经中继发的控制报文只有一种用途:探测中继那条路的延迟和丢包(1.0.0 加的
send_control_via、RelayedControl) - 系统网络配置只做了最少的一步:按协调服务分的地址设好虚拟网卡的地址和 MTU,系统据此把 overlay 网段路由到网卡。别的路由和 DNS 都没碰
托管很多网络时
协调服务可以一个进程托管很多网络(--hub),控制面的契约跟着扩了一点,1.0.0 的节点和网络码照旧能用:
- 控制通道的第一条消息多了两种。 1.0.0 的
Hello原样保留(单网络的协调服务只认它);Join { network, invite }进 hub 里的某个网络;Create { name }新建一个网络,发起者成为网主, 服务器回Created { network, invite },之后和加入一样收Welcome、NetMap - 网络码多一段
/网络ID:公钥@地址:端口/网络ID#邀请码。不带它的就是单网络的协调服务 - 网主的管理请求(
Admin:移出、新建 / 作废 / 换邀请码、改名、解散)走同一条控制通道, 服务器回AdminReply;成员或邀请码一变,服务器给网主推一份Roster。不是网主发的一律拒绝 - 建网分两步:控制面先连上服务器建好网络、拿到网络码,节点再照常用网络码加入。 中间失败(比如建不了虚拟网卡)也不会留下一个没人知道网络码的网络
- 中继知道网络的边界:同进程的中继只在同一个网络的成员之间转发,并且给每个节点限速。数据面的契约不变
还没定的
- 中继要不要伪装成 HTTPS:现在是裸的 TCP,穿不过只放行 443 端口、还要检查 TLS 的网络
- 事件会变多:比如中继连接断了,控制面需要知道。中继的链路质量现在靠经中继的探测知道, 但连接断没断仍然只能从探测没回应推断
- 单独部署的中继认不得凭邀请码加入的成员:多个中继节点这边已经能用了 —— 每个中继都探测, 两边都因此连着每个中继,哪个挂了探测不通,各自换到别的,不需要双方协调。但中继只转发它认得的节点: 和协调服务同进程的中继跟协调服务共用成员表,另起的中继只认启动时给它的名单。 要让它跟上成员表,得有一条中继向协调服务要成员表(或者协调服务给节点签发通行凭证)的路,还没做
- 系统网络配置的边界:出口路由(Gateway)、DNS 由谁改、出了错怎么回滚, 不在这份契约里
下一步:威胁模型 讲这套设计要防什么、靠什么防。