Skip to content

接口契约:控制面与数据面 ​

当前状态

契约已经写成 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 握手,附带握手实际走的路径
ControlDatagramsocket 上收到一个控制报文,原样交出,未经认证
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,长度正好 148WireGuard
WireGuard 握手响应02 00 00 00,长度正好 92WireGuard
WireGuard cookie 回复03 00 00 00,长度正好 64WireGuard
WireGuard 数据04 00 00 00,长度至少 32WireGuard
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 由谁改、出了错怎么回滚, 不在这份契约里

下一步:威胁模型 讲这套设计要防什么、靠什么防。

以 MIT 协议发布 · 首页背景由 Paper Shaders 驱动(Apache-2.0)