在上一章 身份 (Identity) 中,我们了解了每个 ZeroTier 节点 (Node) 都拥有一本无法伪造的"数字护照",这保证了网络中每个成员的身份都是真实可信的。
现在,设想一个场景:你的笔记本电脑(节点A)和公司的文件服务器(节点B)都已经通过了身份验证,并加入了同一个虚拟网络 (Network)。你想要从服务器上下载一个文件。这意味着节点A需要向节点B发送请求,并且节点B需要将文件数据回传给节点A。
这个过程引出了几个新的问题:
- 节点A如何记录节点B的所有联系方式(比如它可能同时有办公室的有线IP和家里的Wi-Fi IP)?
- 它如何知道哪条联系路径是当前最快的?
- 它们之间通信使用的加密密钥是什么?
为了高效地管理与网络中其他每一个节点的连接,ZeroTier 引入了 对等节点 (Peer)
的概念。
什么是对等节点 (Peer)?
Peer
对象代表了网络中另一个你可以直接与之通信的 ZeroTier 节点 (Node)。你可以把它想象成通讯录里的一张**"联系人名片"**。
每当你需要与另一个节点通信时,你的 ZeroTier 客户端就会为那个节点创建一个 Peer
对象。这张"名片"上记录了关于对方的所有关键信息:
- 身份信息 (Who): 这张名片首先记录了对方的身份 (Identity),包括它独一无二的 ZeroTier 地址。这确保了你总是在和正确的人通话。
- 联系路径 (How): 它维护一个动态更新的列表,包含了所有已知的、可以联系到对方的物理网络路径 (Path)。这就像一个联系人名下有多个电话号码(家庭、工作、手机),ZeroTier 会自动尝试并找出最有效的一个。
- 连接状态 (Status): 名片上还实时标注了连接质量,比如"信号强度"(延迟)、"最后通话时间"(上次收到数据包的时间)等。这帮助 ZeroTier 做出更智能的路由决策。
- 安全密钥 (Secret) : 最重要的是,这张名片还包含了一个共享的对称加密密钥。这个密钥是根据你和对方的身份 (Identity通过加密算法(密钥协商)生成的,只有你们两个知道。所有你们之间的通信都会用这个密钥加密,确保了对话的私密性。
一个节点 (Node)会在其内部的拓扑 (Topology)管理器中,为它认识的每一个其他节点都维护一个 Peer
对象。这构成了一张完整的网络关系图,使得数据可以在任意两个节点之间高效、安全地流动。
Peer
对象的核心职责
Peer
对象是处理点对点通信的专家。它的核心职责可以归纳为以下几点:
- 路径管理: 维护和管理通往另一个节点的所有可用物理路径 (Path)。它会定期测试这些路径,清理失效的,并学习新的路径(例如,当对方的网络环境变化时)。
- 路径选择 : 当需要发送数据时,
Peer
对象会调用getAppropriatePath()
方法,根据延迟、稳定性等因素,从所有可用路径中选择一条最佳路径来发送数据。 - 连接维持 : 通过
doPingAndKeepalive()
方法,Peer
会定期向对方发送"心跳包"(HELLO 或 ECHO 报文),以保持连接活跃(尤其是在有 NAT 或防火墙的环境中),并确认对方仍然在线。 - 安全上下文 : 持有与该特定
Peer
通信所需的共享加密密钥。所有进出该Peer
的数据包都将使用这个密钥进行加密和解密。
代码中的 Peer
Peer
类的定义可以在 node/Peer.hpp
文件中找到。它包含了管理与另一个节点连接所需的所有状态和方法。
cpp
// 文件: node/Peer.hpp
class Peer
{
private:
Identity _id; // 对等节点的身份
uint8_t _key[ZT_SYMMETRIC_KEY_SIZE]; // 共享的对称加密密钥
// 一个 PeerPath 结构体数组,用来存储所有已知的路径
struct _PeerPath {
int64_t lr; // 最后收到数据包的时间
SharedPtr<Path> p; // 指向 Path 对象的指针
// ...
} _paths[ZT_MAX_PEER_NETWORK_PATHS];
Mutex _paths_m; // 保护路径列表的锁
int64_t _lastReceive; // 最后一次收到对方消息的时间
public:
// 构造函数:需要本节点和我方节点的身份来生成共享密钥
Peer(const RuntimeEnvironment *renv, const Identity &myIdentity, const Identity &peerIdentity);
// 获取对方的 ZeroTier 地址
inline const Address &address() const { return _id.address(); }
// 获取对方的完整身份信息
inline const Identity &identity() const { return _id; }
// 获取发送数据到此 Peer 的最佳路径
SharedPtr<Path> getAppropriatePath(int64_t now, bool includeExpired, ...);
// 通过最佳路径发送数据
inline bool sendDirect(void *tPtr, const void *data, unsigned int len, int64_t now, bool force);
// 当收到来自此 Peer 的数据包时,调用此函数更新状态
void received(void *tPtr, const SharedPtr<Path> &path, ...);
// ... 其他方法,如 doPingAndKeepalive, introduce 等
};
_id
: 一个Identity
对象,存储了远端对等节点的完整身份信息。_key
: 一个字节数组,存储了通过密钥协商算法生成的256位共享密钥。这个过程在Peer
的构造函数中完成。_paths
: 一个结构体数组,每一项都包含一个指向物理路径 (Path)对象的智能指针和该路径的最新状态。_lastReceive
: 一个时间戳,记录了最后一次从这个Peer
收到数据的时间,用于判断它是否还"活着"。getAppropriatePath()
: 这是Peer
的智能核心之一,负责在多个可用路径中做出选择。sendDirect()
: 一个便捷的函数,它会自动调用getAppropriatePath()
并通过选定的路径发送数据。received()
: 另一个核心函数,当收到数据时,它会更新_lastReceive
时间戳和对应路径的状态。更重要的是,它也是 ZeroTier 学习新路径的关键。
Peer
的工作流程:发现与通信
想象一下,你的笔记本电脑(节点A)第一次尝试 ping
公司的服务器(节点B)。初始时,A 和 B 都只知道如何联系 ZeroTier 的根服务器(我们称之为"行星服务器 Planet")。它们之间是如何建立直接连接的呢?
这是一个简化的流程图,展示了路径发现和直接通信的过程:
- 初次联系 (通过中继): 节点A不知道节点B的物理地址,所以它把数据包发给行星服务器,请求中继。
- 交换名片 : 行星服务器将数据包转发给节点B。此时,A和B都知道了对方的存在,并交换了身份 (Identity)。它们各自为对方创建了一个
Peer
对象。 - 路径学习 : A和B会通过中继的报文,互相告知自己所知的公网地址。例如,A会告诉B:"你可以尝试在
203.0.113.10:12345
找到我"。节点B的Peer
对象收到这个信息后,就会创建一个新的物理路径 (Path)对象并添加到自己的_paths
列表中。 - 直接连接尝试 (P2P打洞) :
Peer
对象的doPingAndKeepalive()
方法会定期检查所有已知的路径。当它发现一个新的、未经测试的路径时,就会尝试直接向该路径发送一个ECHO
(探测)包。 - 建立直连 : 如果这个
ECHO
包成功穿透了双方的NAT和防火墙,节点B就能收到它,并直接回复一个确认包。当节点A收到这个回复时,它就知道这条直接路径是可用的。Peer
对象会标记此路径为活跃状态,并记录下延迟等信息。 - 优化通信 : 从此刻起,当A的
Peer
对象调用getAppropriatePath()
时,它会发现这条新建立的直接路径延迟远低于通过行星服务器中继的路径,于是会优先选择它进行通信。
深入代码:路径学习的实现
Peer::received()
方法是路径学习魔法发生的地方。让我们看看它的一段简化逻辑,位于 node/Peer.cpp
中。
cpp
// 文件: node/Peer.cpp (received 函数的简化逻辑)
void Peer::received(
void *tPtr,
const SharedPtr<Path> &path, // 数据包从哪个路径来
const unsigned int hops, // 经过了多少次中继 (0表示直连)
// ... 其他参数
) {
_lastReceive = RR->node->now(); // 更新最后联系时间
// 如果 hops == 0,说明这是一个直接连接的数据包
if (hops == 0) {
bool havePath = false;
// 1. 检查这个路径是否已经是已知的
{
Mutex::Lock _l(_paths_m);
for(unsigned int i=0; i<ZT_MAX_PEER_NETWORK_PATHS; ++i) {
if (_paths[i].p == path) {
_paths[i].lr = RR->node->now(); // 更新路径的活跃时间
havePath = true;
break;
}
}
}
// 2. 如果是新路径,并且我们允许使用它
if (!havePath && RR->node->shouldUsePathForZeroTierTraffic(...)) {
// 就把它添加到一个空闲的槽位里
Mutex::Lock _l(_paths_m);
// ... (找到一个空位或者替换一个最老的路径)
_paths[replacePath].lr = RR->node->now();
_paths[replacePath].p = path; // 添加新路径!
// ...
}
}
// ...
}
这段代码的核心逻辑是:
- 当收到一个直连 数据包(
hops == 0
)时,Peer
对象会检查这个数据包的来源物理路径 (Path)是否已经在它的联系人列表_paths
中。 - 如果在,就更新该路径的"最后联系时间",表示它仍然活跃。
- 如果不在 ,
Peer
就会认为自己发现了一条新的、可用的联系方式。它会在_paths
数组中找一个空位,将这个新的Path
对象存进去。 - 下一次当
getAppropriatePath()
被调用时,这个新路径就会成为备选之一,如果它的质量(如延迟)够好,就会被用来发送数据。
通过这种方式,Peer
对象就像一个聪明的联系人管家,不断地学习和优化与对方的通信方式,努力在复杂的网络环境中找到最高效的沟通桥梁。
总结
在本章中,我们深入了解了 对等节点 (Peer)
,它是 ZeroTier 中管理节点间一对一关系的"联系人名片"。
Peer
对象封装了与另一个节点 (Node)通信所需的一切:它的身份 (Identity)、所有已知的物理路径 (Path)、连接状态以及用于加密的共享密钥。- 它的核心职责是管理和选择最佳通信路径 ,并维持连接的活跃状态。
- 通过
received()
方法中的路径学习 机制,Peer
能够动态发现节点间的直接连接(P2P),从而实现高效的低延迟通信。