ZeroTier 源码解析 (6) 数据包 (Packet)

在上一章 交换机 (Switch) 中,我们将 Switch 比作一个高效的"邮件分拣中心",它负责指挥所有数据流的走向。我们知道,Switch 处理的每一个项目都是一个独立的、封装好的信息单元。

那么,这些在 ZeroTier 网络中穿梭的"邮件"本身到底是什么样子的呢?它们如何既能携带我们要发送的数据,又能包含路由、加密和控制所需的所有元数据?

这就是 数据包 (Packet) 的用武之地。如果 Switch 是邮政分拣中心,那么 Packet 就是那个带有地址、邮票和内容的标准信封。

什么是数据包 (Packet)?

Packet 是 ZeroTier 网络中通信的基本单位。它就像一个精心设计的数字信封 ,无论是发送文件、网页请求,还是简单的 ping 命令,所有数据最终都必须被装入这种标准格式的信封中才能在网络上传输。

让我们用一个实体信封来比喻 Packet 的结构:

graph TD; Packet["数据包 (Packet)
数字信封"] --> Header["头部 (Header)
信封外部"]; Packet --> Payload["载荷 (Payload)
信封内部的信件"]; Header --> ToFrom["收/发件人地址
(源/目标 ZT 地址)"]; Header --> TrackingID["追踪号码
(唯一的 Packet ID)"]; Header --> Instructions["特殊指令 (Verb)
例如:'账单'、'邀请函'"]; Header --> SecuritySeal["防伪封条 (MAC)
(用于验证信息未被篡改)"]; Payload --> Content["数据内容
(如以太网帧、控制信息等)"];
  • 信封外部(头部 Header) : Packet 的头部包含了所有必要的元数据,用于正确、安全地投递。

    • 收/发件人地址: 明确的源和目标 ZeroTier 地址。
    • 追踪号码 : 一个唯一的 Packet ID,也用作加密的初始向量(IV),确保每次通信都是独一无二的。
    • 特殊指令 (Verb) : 这是 Packet 最具特色的部分。它是一个"动词",明确地告诉接收方这个数据包的意图 是什么。例如,是VERB_FRAME(发送一个普通的数据帧),VERB_HELLO(与一个新节点打招呼并交换身份),还是VERB_ERROR(告知对方发生了错误)。
    • 防伪封条 (MAC): 一个消息认证码,用于确保数据包在传输过程中没有被篡改。
  • 信封内部(载荷 Payload) : 这是 Packet 真正携带的信息。它的内容完全取决于头部的"动词 (Verb)"。如果动词是 VERB_FRAME,载荷就是实际的以太网帧;如果动词是 VERB_ERROR,载荷就是具体的错误代码。

最关键的是,Packet 类自身就封装了处理安全性的所有逻辑。它负责对载荷进行加密、压缩,并生成那个防伪封条。这确保了 ZeroTier 通信的机密性、完整性和效率。

Packet 的核心职责

Packet 作为一个数据结构类,其核心职责是封装和保护数据

  1. 数据封装: 它提供了一个标准的格式,可以将上层应用的数据(如以太网帧)与 ZeroTier 协议所需的元数据(地址、动词等)打包在一起。
  2. 加密与解密 : 当一个数据包准备发送时,Packetarmor() 方法会使用发送方和接收方对等节点 (Peer)之间共享的密钥来加密载荷。相应地,dearmor() 方法在接收端负责解密。
  3. 认证与验证 : armor() 方法在加密的同时,还会计算出一个消息认证码(MAC),就像给信封打上蜡封。dearmor() 方法在收到包后,会首先校验这个"蜡封"是否完好,如果被篡改,则立即丢弃该包。
  4. 压缩与解压 : 为了节省带宽,Packet 还内置了 compress()uncompress() 方法,使用 LZ4 算法对载荷进行可选的压缩。

Packet 的生命周期:一次安全的投递

想象一下,节点A 要发送一条消息给节点B。这个过程在 Packet 层面是如何运作的?

sequenceDiagram participant NodeA as 节点 A participant Packet as Packet 对象 participant NodeB as 节点 B NodeA->>Packet: 创建 Packet (设置目标、源、VERB_FRAME) NodeA->>Packet: 附加要发送的数据 (Payload) NodeA->>Packet: 调用 compress() (可选, 压缩 Payload) NodeA->>Packet: 调用 armor() (加密 Payload, 计算 MAC) NodeA->>NodeB: 通过物理网络发送加密后的 Packet NodeB->>Packet: 接收到二进制数据,创建 Packet 对象 NodeB->>Packet: 调用 dearmor() (验证 MAC, 解密 Payload) alt MAC 验证失败 Packet-->>NodeB: 验证失败,丢弃 else MAC 验证成功 NodeB->>Packet: 调用 uncompress() (如果被压缩过) NodeB->>NodeB: 读取并处理解密后的 Payload end
  1. 打包 : 节点 (Node) A 创建一个 Packet 对象,填上B的地址和自己的地址,并将"动词"设为 VERB_FRAME。然后,它将原始数据(例如一个以太网帧)附加到 Packet 的载荷区。
  2. 压缩 : Node A 调用 Packetcompress() 方法。如果压缩能显著减小体积,Packet 内部就会替换掉原始载荷,并设置一个"已压缩"的标志。
  3. 封装与加密 (Armor) : Node A 调用 armor() 方法。这个方法会使用与B共享的密钥:
    • 加密整个载荷区。
    • 计算整个包(包括头部和加密后的载荷)的 MAC 值,并填入头部。
  4. 发送 : 此时,Packet 已经是一个准备就绪、无法被窃听和篡改的二进制数据块,可以通过互联网发送给节点B。
  5. 解封与验证 (Dearmor) : 节点B收到数据后,用它创建 Packet 对象,并立即调用 dearmor() 方法。这个方法会:
    • 使用与A共享的密钥,重新计算一遍收到的包的 MAC 值。
    • 如果计算出的 MAC 与包头部的 MAC 不符,说明数据在途中被篡改,dearmor() 返回 false,包被丢弃。
    • 如果 MAC 相符,dearmor() 才会继续用密钥解密载荷。
  6. 解压与处理 : 如果 dearmor() 成功,节点B接着调用 uncompress()(如果需要),最后从载荷区读出原始数据进行处理。

代码中的 Packet

Packet 的定义可以在 node/Packet.hpp 中找到。它继承自一个名为 Buffer 的类,本质上是一个可操作的字节缓冲区。

cpp 复制代码
// 文件: node/Packet.hpp

class Packet : public Buffer<ZT_PROTO_MAX_PACKET_LENGTH>
{
public:
    // 动词列表,定义了数据包的意图
    enum Verb {
        VERB_NOP = 0x00,
        VERB_HELLO = 0x01,
        VERB_ERROR = 0x02,
        VERB_OK = 0x03,
        VERB_FRAME = 0x06,
        // ... 其他动词
    };

    // 构造函数:创建一个新的数据包
    Packet(const Address &dest, const Address &source, const Verb v);

    // 获取和设置动词
    inline Verb verb() const;
    inline void setVerb(Verb v);

    // 加密并认证数据包
    void armor(const void *key, bool encryptPayload, const AES aesKeys[2]);

    // 验证并解密数据包
    bool dearmor(const void *key, const AES aesKeys[2]);

    // 压缩和解压缩载荷
    bool compress();
    bool uncompress();

    // ... 其他获取和设置头部字段的方法
};
  • enum Verb: 这个枚举定义了所有可能的"动词",是 Packet 的核心。
  • 构造函数 Packet(...): 创建一个数据包时,必须指定收件人、发件人和意图(动词)。
  • armor() / dearmor(): 这是实现安全通信的两个关键方法。
  • compress() / uncompress(): 提供可选的压缩功能。

深入代码:IncomingPackettryDecode

当一个数据包从网络到达时,它首先被封装成一个 IncomingPacket 对象,这是 Packet 的一个特殊子类。然后,交换机 (Switch) 会调用 IncomingPackettryDecode() 方法来处理它。

tryDecode() 的核心是一个巨大的 switch 语句,它完美地诠释了"动词"驱动的设计思想。

cpp 复制代码
// 文件: IncomingPacket.cpp (tryDecode 函数的简化逻辑)

bool IncomingPacket::tryDecode(const RuntimeEnvironment *RR, void *tPtr, int32_t flowId)
{
    // ... 此处省略了解密(dearmor)和解压(uncompress)的过程 ...
    // 假设数据包已经通过验证并解密

    _authenticated = true;
    const Packet::Verb v = verb(); // 获取数据包的"动词"

    bool r = true;
    switch(v) { // 根据动词的类型,执行不同的处理逻辑
        case Packet::VERB_HELLO:
            // 如果是 HELLO,执行处理 HELLO 的逻辑
            r = _doHELLO(RR, tPtr, true);
            break;

        case Packet::VERB_FRAME:
            // 如果是数据帧,执行处理数据帧的逻辑
            r = _doFRAME(RR, tPtr, peer, flowId);
            break;
        
        case Packet::VERB_ERROR:
            // 如果是错误报告,执行处理错误的逻辑
            r = _doERROR(RR, tPtr, peer);
            break;

        // ... 处理其他所有类型的动词 ...
        
        default: // 忽略未知的动词
            peer->received(...); // 但仍然记录收到了一个包
            break;
    }
    
    // ...
    return r;
}

这段代码清晰地展示了 Switch 是如何委托 IncomingPacket 进行自我处理的:

  1. tryDecode() 内部,首先调用 dearmor()uncompress(),完成解封和准备工作。
  2. 然后,它获取数据包的 verb()
  3. switch 语句根据 verb() 的值,将任务派发给对应的私有方法(例如 _doHELLO, _doFRAME 等)。
  4. 每个 _do... 方法都包含了处理特定类型数据包的所有逻辑,例如,_doFRAME 会将解出的以太网帧注入到操作系统的网络栈中。

这种设计使得代码结构非常清晰,每种数据包的处理逻辑都被整齐地封装在各自的函数里。

深入幕后:armor() 的魔法

armor() 方法是数据包安全的保障。虽然它的实现涉及复杂的密码学操作,但其核心思想可以简化为以下伪代码:

pseudocode 复制代码
// Packet::armor() 的概念性伪代码
function armor(共享密钥, 是否加密内容):
    // 1. 根据共享密钥和数据包的唯一信息(如Packet ID)
    //    生成一个一次性的加密密钥和 MAC 密钥。
    (一次性加密密钥, MAC密钥) = 从(共享密钥, PacketID)派生得出

    // 2. 如果需要,用"一次性加密密钥"加密"信件内容"(Payload)。
    if (是否加密内容):
        this.载荷 = encrypt(this.载荷, with: 一次性加密密钥)

    // 3. 为整个包(包括头部和可能已加密的载荷)
    //    用"MAC密钥"计算一个"防伪封条"(MAC)。
    mac_value = compute_mac(整个数据包的数据, with: MAC密钥)

    // 4. 将"防伪封条"贴在"信封"上(写入MAC字段)。
    this.mac_field = mac_value

这个过程确保了:

  • 机密性: 即使攻击者截获了数据包,没有共享密钥也无法解密载荷。
  • 完整性: 任何对数据包(哪怕一个比特)的修改都会导致 MAC 验证失败,从而被接收方识破和丢弃。
  • 防重放 : 因为 Packet ID 每次都不同,所以派生出的一次性密钥也不同。攻击者无法把旧的数据包重新发一遍来欺骗系统。

总结

在本章中,我们详细了解了 ZeroTier 通信的基本单元------数据包 (Packet)

  • Packet 就像一个数字信封,它将要传输的数据(载荷)和所有必要的元数据(头部)封装在一起。
  • 其头部的核心是一个动词 (Verb) ,它明确定义了这个数据包的意图,驱动着整个节点的处理逻辑。
  • Packet 类自身负责加密、认证和压缩 ,通过 armor()dearmor() 方法,为 ZeroTier 的通信提供了端到端的安全保障。
  • 当一个 IncomingPacket 到达时,它的 tryDecode() 方法会根据"动词"来调度执行相应的处理代码,实现了清晰的逻辑分离。

我们现在知道了节点之间如何通过安全的 Packet 来交换信息。但是,一个节点是如何知道网络中存在哪些其他节点,以及如何联系它们的呢?换句话说,节点是如何构建和维护它对整个虚拟网络的"地图"的?

相关推荐
小马敲马12 分钟前
[4.2-2] NCCL新版本的register如何实现的?
开发语言·c++·人工智能·算法·性能优化·nccl
soilovedogs1 小时前
百度之星2024初赛第二场 BD202411染色
c++·算法·百度之星
啊阿狸不会拉杆2 小时前
《算法导论》第 15 章 - 动态规划
数据结构·c++·算法·排序算法·动态规划·代理模式
2401_858286112 小时前
CD64.【C++ Dev】多态(3): 反汇编剖析单继承下的虚函数表
开发语言·c++·算法·继承·面向对象·虚函数·反汇编
重启的码农4 小时前
ZeroTier 源码解析 (7) 拓扑 (Topology)
c++·网络协议
芥子须弥Office15 小时前
从C++0基础到C++入门 (第二十五节:指针【所占内存空间】)
c语言·开发语言·c++·笔记
啊阿狸不会拉杆15 小时前
《算法导论》第 14 章 - 数据结构的扩张
数据结构·c++·算法·排序算法
Q741_14717 小时前
如何判断一个数是 2 的幂 / 3 的幂 / 4 的幂 / n 的幂 位运算 总结和思考 每日一题 C++的题解与思路
开发语言·c++·算法·leetcode·位运算·总结思考
慕y27418 小时前
Java学习第一百二十二部分——HTTPS
网络协议·学习·https