这个博客所依赖的一切都活在一张 overlay 网络之后:Git 服务、部署机、构建这些页面的那些 机器。如果你想看拓扑,这里有一篇关于那条流水线的实战记录。 真正让我好奇的是拨号的另一头——一个节点可以小到什么程度,还能算这张 overlay 里 一等成员?不是”用自制协议把 IP 隧道过去”的设备,而是被原版 EasyTier 节点列出、 ping 通、并且替它转发流量的那种节点。
ESPTier 就是我的答案:用纯 C 复刻 EasyTier 真正的线协议,让同一份可移植核心既跑在 Linux 上,也跑在 ESP32-C3 上。这是一份实战记录——线协议真正要求了什么,ESP32-C3 上的 嵌入式组网哪些地方让人觉得”作弊”(是好的那种),哪些不觉得,以及我在哪里刻意停手。
这里的嵌入式组网,为什么必须是真线协议而不是仿制品
这个项目最省事的做法,是自创一个小型 UDP 协议,然后管它叫”overlay”。我以前干过,
而它通不过唯一重要的检验:原版项目的节点会不会把你当邻居?所以目标被冻结得毫不
光鲜——EasyTier v2.6.4,commit 8428a89d2dabc94c97d370ec607c6ca142473626。
更老的 2.4.5 节点明确不在范围内;我宁愿对一个受支持的版本诚实,也不想对好几个含糊。
代码的形状来自一条约束:协议那一半不能知道自己跑在哪颗芯片上。components/etcore
是无 OS 依赖的可移植 C11——没有 socket、没有 time、没有 rand、没有 printf。
它只通过两个窄接口触达外部世界:et_transport(connect / send / recv / close,
面向 TCP 字节流)与 et_platform(et_now_ms / et_fill_random / et_log)。同一份
源码既能作为主机静态库编进 CMake,也能作为 ESP-IDF 组件编进 ESP-IDF v5.4。
Linux 那侧拿到的是 POSIX socket 和一块 TUN 设备;ESP32-C3 这侧拿到的是 lwIP socket、
esp_timer、esp_random 和 ESP_LOG。
这个切分不是为了架构洁癖。它意味着协议行为在两个目标上逐字节一致,于是某个在主机 单测里复现出来的握手 bug,在设备上就是同一个 bug——而主机测试一秒就能跑完,不必 等一轮烧录。
帧就是契约:逐字节的 EasyTier TCP 线协议
每条消息就是一个 TCP 帧:
frame = [ u32 LE outer_len ][ PeerManagerHeader 16B ][ payload ]
没有 magic 前缀。所有多字节整数都是小端,protobuf varint 也是。16 字节的
PeerManagerHeader 里依次是 from_peer_id(u32)、to_peer_id(u32)、
packet_type(u8)、flags(u8)、forward_counter(u8)、reserved(u8)
和 len(u32)。
整件事系于一条很容易搞错的不变式:outer_len 数的是线上真实字节,而头里的
len 字段是明文载荷长度。未加密时两者相等;加密时线上长度是 16 + 明文 + 28,
多出来的 28 字节是 AEAD 尾巴(下面细说)。解码时,payload 切片按真实帧长选取,
绝不按 hdr.len 读。一个”撒谎的 len”就是一枚随时会炸的内存安全漏洞——而在 MCU 上
没有 MMU 兜你。
len 上限是 2000,所以一帧最多 2004 字节——这决定了静态接收缓冲区的大小,也就决定了
单条 peer 的 RAM 硬上限。本实现会用到的 packet_type:Data=1、HandShake=2、
Ping=4、Pong=5、RpcReq=8、RpcResp=9。flags 里有 ENCRYPTED=0x01、
LATENCY_FIRST=0x02、EXIT_NODE=0x04、NO_PROXY=0x08、COMPRESSED=0x10、
NOT_SEND_TO_TUN=0x40。
握手与 SipHash-1-3 身份
握手刻意做得简单,而且不加密。主动拨号的一侧先发 HandshakeRequest:一个
magic 常量(接收方甚至不校验它)、一个运行时随机的 my_peer_id、version=1、
network_name,以及一个必须恰好 32 字节的 network_secret_digest。对端回发
自己的请求。我们只校验 packet_type==2 和 len(digest)==32,然后从中取出对方的
peer_id。
身份本身来自 SipHash-1-3(c=1, d=3)配 (0,0) 密钥,这与 Rust 的 DefaultHasher
逐字节等价。先写入 network_name 再写入 network_secret,把 finish().to_be_bytes()
折回 hasher,就得到一个 32 字节 digest;对密钥单独做同样的构造,就得到加密用的 16 字节
key128。有个细节很容易踩:finish() 不消费也不重置状态,后续 write() 会继续
累积。要是这里搞错,你的 digest 会看起来很像样、但其实是错的——这是最糟的那种错。
还有一个行为细节值得单独点出,因为它是”能互通”与”正确”之间的分界:如果对端返回的 digest 与我们不一致,连接会进入终态 dead、永不重试。密钥错误不是瞬时故障;对着一个 已经拒绝你密钥的节点反复猛敲,只是失礼。
AES-128-GCM 与那 28 字节尾巴
载荷用 AES-128-GCM 加密,密钥由密钥派生而来、是网络级共享的,不是逐会话的。
只加密载荷——16 字节的头留在明文。随载荷带上一个随机 12 字节 nonce,AAD 为空,
帧尾变成 tag(16) || nonce(12),tag 在前,并置上对应的 ENCRYPTED 标志。
对受限目标来说有两点要紧。其一,nonce 来自注入的 RNG 回调;RNG 一旦失败,我们会
拒绝发出这个包,而不是降级成一个可预测的 nonce。其二,HandShake、Ping、
Pong 永不加密,而 Data、RpcReq、RpcResp 永远加密。AES-GCM 底层是 vendored
进来、主机与设备共用的,所以这最吓人的部分只有一份实现。
路由同步,才是你能被 ping 通的原因
握手成功只给你一条活的 TCP 连接,仅此而已。要被寻址,你还得同步路由表,而真实 EasyTier 的假设正是在这里咬人。
同步走 RPC 层,用 OspfRouteRpc、method_index=1,domain_name 设为网络名。
信封是 RpcPacket,以加密的 RpcReq/RpcResp 承载。一颗地雷:compression_info
必须为 None,而且不得暴露 accepted_algo=Zstd——否则对端会很乐意给你发
Zstd 压缩的 RPC body,而你解不开。宣告一项自己没实现的能力,就是一场自找的故障。
请求里带着你的 peer_id、一个稳定的 session_id、is_initiator=true,以及一串
RoutePeerInfo。关键约束是:EasyTier 解析 IP→peer 只走路由表,而且它会跳过
任何 version 为 0 的 peer。所以除非你广播一条 version >= 1 的、属于你自己的
RoutePeerInfo,否则你根本不可能被 ping 到——没有第二条路。此外还得在 90 秒内刷新,
不然这条 peer 会被老化淘汰(REMOVE_UNREACHABLE_PEER_INFO_AFTER)。我们把自身地址
以大端 4 字节整数广播(10.9.9.9 即 0x0A090909)、24 位前缀,以及一个
"2.6.4-esptier" 的 easytier_version 字符串——这样在 peer 列表里一眼就能看出哪个
节点是那个异类。
此外还有一张全局邻接矩阵 conn_bitmap,每个 (源, 目的) 对映一个位,按每字节 LSB
优先打包。一条花了我一阵子才内化的规则:你必须广播整张已知矩阵,而不只是自己那一行。
链式拓扑只有在每个节点都替邻居转发它们的行时才会收敛。
转发:成为一个中转跳
路由一通,中继这件事的核心就变成了”别做多余的事”。to_peer_id 是最终目的地,
不是下一跳。forward_counter 从 1 起始、逐跳自增,超过 7 就丢帧——这是给一张没有全局
拓扑视图的网络准备的廉价环路护栏。中转节点不解密也不重新加密:载荷逐字节搬运,
头字段原样不动。
下一跳走”反向路径”——即我们学到这条路由的那条连接——而不是一次 Dijkstra 计算。这是
一个刻意的简化,对我们针对的树状拓扑够用。而 HandShake、Ping、Pong 会被中转节点
完全丢弃,因为它们是逐连接语义,不是端到端的。最后这条规则由一个怪癖兜住:EasyTier 在
用 Pong 回应 Ping 时只交换 packet_type,to_peer_id 仍指向对端自己的 id。
把这三类包一律放在连接层处理、永不放进转发路径,一切就对上了。
从主机仿真到真机:M0 到 M6
我按编号里程碑来建,全部合进了 master,每一条都是可被证伪的断言:
- M0 —— 仓库骨架、主机构建、能跑
idf.py build的 ESP32-C3 骨架,外加 SipHash-1-3、protobuf 原语和黄金向量生成器。 - M1 ——
PeerManagerHeader/TCP 分帧、HandshakeRequest,以及由 fake transport 驱动的 peer 状态机。 - M2 —— 真实握手加 AES-128-GCM;主机仿真加入真实网络,并靠 Ping/Pong 保活稳定
保持
UP。 - M3 —— 双向的最小
SyncRouteInfo;我们出现在easytier-cli peer里,并能被 真实节点 ping 通。 - M4 —— 裸 IPv4 数据面:ICMP echo 应答与 UDP 回显,跨节点往返成功。
- M5 —— ESP32-C3 接入真实 Wi-Fi,在真机上复现 M3/M4。
- M6 —— 多直连 peer、全局邻接矩阵、按目的转发,于是真实节点能经由我们的中转 节点路由。
跑起来:先主机,再开发板
主机路径不需要硬件、也不需要特权,如果你想亲眼看它工作,我会从这里开始:
cmake -S . -B build && cmake --build build
scripts/testnet.sh up
build/apps/host_sim/host_sim --network-name et_test --network-secret et-test-secret \
--peer 127.0.0.1:11110 --my-ipv4 10.9.9.9/24 --hostname nodeA
# 另一个 shell 里:
easytier-cli -p 127.0.0.1:15988 peer
easytier-cli peer 的输出里应该出现 10.9.9.9/24 nodeA,version=2.6.4-esptier。
注意端口纪律:这里全程只用测试端口(listener 11110、RPC 15988)。同一台机器
上可能已有生产 overlay 在监听 11010/15888,脚本会拒绝启动,而不是去踩它。
在开发板上,同一份核心在 ESP-IDF v5.4 下编译:
scripts/install_idf.sh
. ~/dev/tools/esp/esp-idf/export.sh
cd apps/esp32
idf.py set-target esp32c3
idf.py -p /dev/ttyACM0 flash monitor
设备作为 Wi-Fi STA 主动拨向对端主机,所以它需要一个 2.4GHz 的 AP(ESP32-C3 只支持
2.4GHz),并且主机防火墙要放行入站的 11110/tcp。一次成功的启动会打印
WiFi connected, got IP ...,随后是 peer <id> UP。再往后,scripts/e2e_m5.sh 会
起一个真实节点、烧录开发板、以有界超时抓取串口日志,并同时断言设备出现在真实节点的
peer 列表里、以及 UDP/ICMP 往返成功。
它在芯片上的代价,以及我刻意不做的事
轮询任务跑在 24KB 栈上。这个数字不是随手定的:M6 的同步路径会把路由请求编码、RPC
信封和 conn_bitmap 暂存缓冲嵌在 peer TX 帧缓冲之下,16KB 开始不够用了。核心最多支持
4 个直连 peer,每个约 4KB 接收缓冲,且运行时不做内存增长。当前设备固件只接一个 peer
(slot 0);多 peer 与转发是在主机侧验证的,而 ESP32-C3 没有 PSRAM 可以借。
我也想同样清楚地说明边界。没有 NAT 打洞、中继发现、公共服务器发现。选路是反向
路径,不是完整 OSPF/Dijkstra。secure mode、Noise 握手和 credential 都未实现——只有
传统路径。非 TCP 隧道(UDP、WS、WG、QUIC)、LAN 广播发现、IPv6、压缩、ACL、
exit-node、Magic DNS 一概没有,2.4.5 互通也在范围外。开发主机上还没有 tcpdump,
所以 M4/M6 的中转是用路由表的 next_hop_ipv4 加上应用层计数(UDP_REPLY/ICMP_REPLY)
来证明的,而非字节级 .pcap。如果你要线上证据,可以通过项目的抓帧代理把 RPC 流抓成
fixture——这正是握手与路由编码器测试的做法。
这份清单很长,但这正是重点。让一颗受限 MCU 成为真实 mesh VPN 的正牌成员,不在于把 EasyTier 整个重写一遍,而在于精确地实现那个可互通子集、用里程碑逐条证明每个断言, 并知道——且说出——子集在哪里结束。这跟这套栈的其余部分 做的是同一桩买卖:挑一个小面、把它自动化、对边界保持诚实。