注意
本文档适用于 Ceph 的开发版本。
msgr2 协议 (msgr2.0 和 msgr2.1)
这是对由 SimpleMessenger 实现的传统 Ceph 在线协议的修订。它解决了性能和安全问题。
目标
相较于原始协议,此次协议修订有几个目标:
灵活的握手。原始协议没有足够灵活的协议协商机制来允许使用非必需功能。
加密。我们将在有线传输中引入加密。
性能。我们希望提供协议功能(例如,填充),以在可能的情况下避免在快速路径中进行计算和内存拷贝。
签名。我们将允许对流量进行签名(但不一定加密)。此功能尚未实现。
定义
client (C):发起 (TCP) 连接的一方
server (S):接受 (TCP) 连接的一方
connection:两个进程之间的一个 (TCP) 连接实例。
entity:一个 ceph 实体实例,例如 'osd.0'。每个实体都有一个或多个唯一的 entity_addr_t,这取决于 'nonce' 字段,该字段通常是 pid 或随机值。
session:两个实体之间的有状态会话,其中消息交换是有序且无损的。如果发生中断(TCP 连接断开),一个会话可能跨越多个连接。
frame:对等体之间发送的离散消息。每帧包含一个标签(类型代码)、有效载荷以及(如果启用了签名或加密)一些其他字段。结构见下文。
tag:与帧相关联的类型代码。标签决定了有效载荷的结构。
阶段
一个连接有四个不同的阶段:
横幅 (banner)
认证帧交换
消息流握手帧交换
消息帧交换
帧格式
交换横幅后,所有后续通信都以帧的形式进行。帧的确切格式取决于连接模式(msgr2.0-crc、msgr2.0-secure、msgr2.1-crc 或 msgr2.1-secure)。所有连接都以 crc 模式开始(msgr2.0-crc 或 msgr2.1-crc,取决于横幅中的 peer_supported_features)。
每个帧都有一个 32 字节的前导码
__u8 tag
__u8 number of segments
{
__le32 segment length
__le16 segment alignment
} * 4
__u8 flags
reserved (1 byte)
__le32 preamble crc
一个空帧有一个空段。一个非空帧可以有 1 到 4 个段,除最后一个段外,所有段都可以为空。
如果段少于四个,则未使用的(尾随)段长度和段对齐字段归零。
### 当前支持的标志
FRAME_EARLY_DATA_COMPRESSED (参见 压缩后帧格式)
保留字节归零。
前导码校验和是 CRC32-C。它覆盖自身之前的所有内容(28 字节),并且无论连接模式如何都会计算和验证(即,即使帧被加密)。
### msgr2.0-crc 模式
msgr2.0-crc 帧的形式如下:
preamble (32 bytes)
{
segment payload
} * number of segments
epilogue (17 bytes)
其中 epilogue 是:
__u8 late_flags
{
__le32 segment crc
} * 4
late_flags 用于帧中止。发送方在传输前导码和第一个段后,可以用零填充剩余的段并设置一个标志来指示接收方必须丢弃该帧。这允许发送方避免在正在发送的帧被撤销(即从信使中取出)时进行额外的缓冲:有效载荷缓冲区可以立即解钉并交还给用户,而无需进行拷贝或阻塞直到整个帧传输完毕。目前这只被内核客户端使用,参见 ceph_msg_revoke()。
段校验和是 CRC32-C。对于“已使用”的空段,它设置为 (__le32)-1。对于未使用的(尾随)段,它归零。
crc 仅用于防止位错误。不提供真实性保证,这与 msgr1 不同,msgr1 试图通过使用会话密钥对段长度和 crc 进行可选签名来提供一些真实性保证。
问题
作为引入适用于控制帧和消息帧的具有可变段数的通用帧结构的一部分,msgr2.0 将消息帧的第一个段(ceph_msg_header2)的 crc 移到了 epilogue 中。
结果是,在从网络中读取整个帧之前,无法安全地解释 ceph_msg_header2。这是 msgr1 的倒退,因为为了将有效载荷直接分散到用户提供的缓冲区中,从而避免在接收消息帧时进行额外的缓冲和拷贝,ceph_msg_header2 必须提前可用——它存储了用户缓冲区所依赖的事务 ID。实现必须在放弃此优化或对未经验证的段采取行动之间做出选择。
late_flags 没有被任何 crc 覆盖。因为它存储了中止标志,所以单个位翻转可能导致已完成的帧被丢弃(导致发送方等待回复时挂起)或者更糟糕的是,导致中止的帧被分发,其中包含垃圾段有效载荷。
msgr1 就是这种情况,并被延续到了 msgr2.0。
### msgr2.1-crc 模式
与 msgr2.0-crc 的区别
第一个段的 crc 存储在第一个段的末尾,而不是 epilogue 中。epilogue 最多存储三个 crc,而不是四个。
如果第一个段为空,则不生成 (__le32)-1 crc。
仅当帧有多个段(即第二个到第四个段中至少有一个非空)时才生成 epilogue。基本原理:如果帧只有一个段,则无法中止,并且 epilogue 中没有 crc 可存储。
未校验和的 late_flags 被 late_status 取代,late_status 通过为每个标志使用一个 4 位半字节和两个海明距离 = 4 且不全是零或一的代码字来内置位错误检测。当然,这是以只有一个保留标志为代价的。
一些示例帧
一个 0+0+0+0 帧(空,没有 epilogue)
preamble (32 bytes)
一个 20+0+0+0 帧(没有 epilogue)
preamble (32 bytes) segment1 payload (20 bytes) __le32 segment1 crc
一个 0+70+0+0 帧
preamble (32 bytes) segment2 payload (70 bytes) epilogue (13 bytes)
一个 20+70+0+350 帧
preamble (32 bytes) segment1 payload (20 bytes) __le32 segment1 crc segment2 payload (70 bytes) segment4 payload (350 bytes) epilogue (13 bytes)
其中 epilogue 是:
__u8 late_status
{
__le32 segment crc
} * 3
Hello
TAG_HELLO: 客户端->服务器 and 服务器->客户端
__u8 entity_type entity_addr_t peer_socket_address
我们立即共享我们的实体类型和对等体的地址(这对于检测我们的有效 IP 地址很有用,尤其是在存在 NAT 的情况下)。
认证
TAG_AUTH_REQUEST: 客户端->服务器
__le32 method; // CEPH_AUTH_{NONE, CEPHX, ...} __le32 num_preferred_modes; list<__le32> mode // CEPH_CON_MODE_* method specific payload
TAG_AUTH_BAD_METHOD 服务器 -> 客户端: 拒绝客户端选择的认证方法
__le32 method __le32 negative error result code __le32 num_methods list<__le32> allowed_methods // CEPH_AUTH_{NONE, CEPHX, ...} __le32 num_modes list<__le32> allowed_modes // CEPH_CON_MODE_*
返回尝试的认证方法、错误代码(如果方法不受支持,则为 -EOPNOTSUPP)以及允许的认证方法列表。
TAG_AUTH_REPLY_MORE: 服务器->客户端
__le32 len; method specific payload
TAG_AUTH_REQUEST_MORE: 客户端->服务器
__le32 len; method specific payload
TAG_AUTH_DONE: (服务器->客户端)
__le64 global_id __le32 connection mode // CEPH_CON_MODE_* method specific payload
服务器是决定认证完成以及最终连接模式的一方。
当客户端使用允许的认证方法时,认证阶段交互示例
当客户端第一次尝试使用被禁止的认证方法时,认证阶段交互示例
认证后帧格式
根据 TAG_AUTH_DONE 协商的连接模式,连接要么保持 crc 模式,要么切换到相应的安全模式(msgr2.0-secure 或 msgr2.1-secure)。
### msgr2.0-secure 模式
msgr2.0-secure 帧的形式如下:
{
preamble (32 bytes)
{
segment payload
zero padding (out to 16 bytes)
} * number of segments
epilogue (16 bytes)
} ^ AES-128-GCM cipher
auth tag (16 bytes)
其中 epilogue 是:
__u8 late_flags
zero padding (15 bytes)
late_flags 的含义与 msgr2.0-crc 模式相同。
每个段和 epilogue 都用零填充到 16 字节。从技术上讲,GCM 不需要任何填充,因为计数器模式(GCM 中的 C)本质上将分组密码变成了流密码。但是,如果总输入长度不是 16 字节的倍数,则会内部发生一些隐式零填充,因为 GCM 用于生成身份验证标签的 GHASH 函数仅适用于 16 字节块。
问题
发送方使用单个 nonce 加密整个帧并生成单个身份验证标签。因为段长度存储在前导码中,所以接收方别无选择,只能解密和解释前导码而无需验证身份验证标签——否则它甚至无法知道要从网络中读取多少才能获取身份验证标签!这创建了一个解密预言机,结合计数器模式的延展性,可能导致敏感信息泄露。
此问题也延伸到消息帧的第一个段。与 msgr2.0-crc 模式一样,在从网络中读取整个帧之前,无法安全地解释 ceph_msg_header2。
使用带有 4 字节计数器字段和 8 字节固定字段的确定性 nonce 构造。初始值取自连接密钥——在认证阶段生成的随机字节字符串。由于计数器字段只有四个字节长,它可能会在一天内回绕并重复,导致 GCM nonce 重用,从而可能导致连接的真实性和机密性完全丧失。通过在计数器重复之前断开连接来解决此问题 (CVE-2020-1759)。
### msgr2.1-secure 模式
与 msgr2.0-secure 的区别
前导码、第一个段和帧的其余部分分别加密,使用单独的 nonce 并生成单独的身份验证标签。这消除了未经验证的明文使用,并使 msgr2.1-secure 模式接近 msgr2.1-crc 模式,允许实现以类似的方式接收消息帧(几乎没有缓冲,相同的分散/收集逻辑等)。
为了减少每帧的加/解密操作次数,前导码增加了一个固定大小的内联缓冲区(48 字节),第一个段会完全或部分内联到其中。前导码身份验证标签同时覆盖前导码和内联缓冲区,因此如果第一个段足够小可以完全内联,则它在一次解密操作后即可用。
与 msgr2.1-crc 模式一样,仅当帧有多个段时才生成 epilogue。基本原理更强,因为它需要额外的加/解密操作。
为了与 msgr2.1-crc 模式保持一致,late_flags 被 late_status 取代(在安全模式下,内置的位错误检测并不是必需的)。
根据 NIST GCM 建议,使用带有 4 字节固定字段和 8 字节计数器字段的确定性 nonce 构造。一个 8 字节的计数器字段不应重复,但为 msgr2.0-secure 模式设置的 nonce 重用保护仍然存在。
初始值与 msgr2.0-secure 模式相同。
与 msgr2.0-secure 模式一样,每个段都用零填充到 16 字节。如果第一个段完全内联,则其填充进入内联缓冲区。否则,填充在剩余部分。推论是内联缓冲区以 16 字节块的形式消耗。
内联缓冲区的未使用部分归零。
一些示例帧
一个 0+0+0+0 帧(空,没有内联,没有 epilogue)
{ preamble (32 bytes) zero padding (48 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes)
一个 20+0+0+0 帧(第一个段完全内联,没有 epilogue)
{ preamble (32 bytes) segment1 payload (20 bytes) zero padding (28 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes)
一个 0+70+0+0 帧(没有内联)
{ preamble (32 bytes) zero padding (48 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes) { segment2 payload (70 bytes) zero padding (10 bytes) epilogue (16 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes)
一个 20+70+0+350 帧(第一个段完全内联)
{ preamble (32 bytes) segment1 payload (20 bytes) zero padding (28 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes) { segment2 payload (70 bytes) zero padding (10 bytes) segment4 payload (350 bytes) zero padding (2 bytes) epilogue (16 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes)
一个 105+0+0+0 帧(第一个段部分内联,没有 epilogue)
{ preamble (32 bytes) segment1 payload (48 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes) { segment1 payload remainder (57 bytes) zero padding (7 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes)
一个 105+70+0+350 帧(第一个段部分内联)
{ preamble (32 bytes) segment1 payload (48 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes) { segment1 payload remainder (57 bytes) zero padding (7 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes) { segment2 payload (70 bytes) zero padding (10 bytes) segment4 payload (350 bytes) zero padding (2 bytes) epilogue (16 bytes) } ^ AES-128-GCM cipher auth tag (16 bytes)
其中 epilogue 是:
__u8 late_status
zero padding (15 bytes)
late_status 的含义与 msgr2.1-crc 模式相同。
压缩
压缩握手是使用基于 msgr2 功能的握手实现的。在此阶段,客户端将向服务器指示是否可以将在线压缩用于消息传输,以及支持的压缩方法列表。如果客户端和服务器都启用了在线压缩,服务器将根据客户端的请求和自己的偏好选择一种压缩方法。握手完成后,双方都设置了压缩处理程序(如果需要)。
TAG_COMPRESSION_REQUEST (客户端->服务器): 声明压缩能力和要求
bool is_compress std::vector<uint32_t> preferred_methods
如果客户端确定双方都支持压缩功能,它将发起握手。
is_compress 标志指示客户端的配置是否使用压缩。
preferred_methods 是客户端支持的压缩算法列表。
TAG_COMPRESSION_DONE (服务器->客户端) : 确定压缩设置
bool is_compress uint32_t method
服务器根据配置确定压缩是否可能。
如果可能,它将选择客户端也支持的优先级最高的压缩方法。
如果不存在,它将确定双方之间的会话将在没有压缩的情况下处理。
# msgr2.x-secure 模式
将压缩与加密结合会引入安全隐患。除非管理员特别配置,否则在使用安全模式时将无法进行压缩。
压缩后帧格式
根据 TAG_COMPRESSION_DONE 协商的连接模式,连接能够接受/发送压缩帧或将所有帧作为解压缩处理。
# msgr2.x-force 模式
如果满足压缩要求(例如,帧大小),通过连接发送的所有后续帧都将被压缩。
对于压缩帧,发送对等体将启用 FRAME_EARLY_DATA_COMPRESSED 标志,从而允许接收对等体检测并解压缩该帧。
# msgr2.x-none 模式
FRAME_EARLY_DATA_COMPRESSED 标志将在前导码中禁用。
消息流握手
在此阶段,对等体相互识别并(如果需要)重新连接到已建立的会话。
TAG_CLIENT_IDENT (客户端->服务器): 识别我们自己
__le32 num_addrs entity_addrvec_t*num_addrs entity addrs entity_addr_t target entity addr __le64 gid (numeric part of osd.0, client.123456, ...) __le64 global_seq __le64 features supported (CEPH_FEATURE_* bitmask) __le64 features required (CEPH_FEATURE_* bitmask) __le64 flags (CEPH_MSG_CONNECT_* bitmask) __le64 cookie
客户端将首先发送,服务器将回复相同的内容。如果这是一个新会话,客户端和服务器可以继续进行消息交换。
target addr 是客户端尝试连接的目标,以便服务器端可以关闭连接,如果客户端正在与错误的守护程序通信。
type.gid (entity_name_t) 在此处设置,通过将 hello 帧中共享的 type 与此处的 gid 结合起来。这意味着我们不需要在每条消息的头中包含它。这也意味着我们不能发送“来自”其他 entity_name_t 的消息。当前的实现将此设置在 _send_message 等的顶部,因此这不应破坏任何现有功能。实现可能希望根据经过认证的凭据允许的内容来屏蔽它。
cookie 是用于识别会话的客户端 cookie,可用于重新连接到现有会话。
我们从 msgr1 中删除了 'protocol_version' 字段
TAG_IDENT_MISSING_FEATURES (服务器->客户端): 抱怨 TAG_IDENT 缺少功能
__le64 features we require that the peer didn't advertise
TAG_SERVER_IDENT (服务器->客户端): 接受客户端身份并识别服务器
__le32 num_addrs entity_addrvec_t*num_addrs entity addrs __le64 gid (numeric part of osd.0, client.123456, ...) __le64 global_seq __le64 features supported (CEPH_FEATURE_* bitmask) __le64 features required (CEPH_FEATURE_* bitmask) __le64 flags (CEPH_MSG_CONNECT_* bitmask) __le64 cookie
如果客户端稍后断开连接并想要重新连接并恢复会话,它可以使用服务器 cookie。
TAG_RECONNECT (客户端->服务器): 重新连接到已建立的会话
__le32 num_addrs entity_addr_t * num_addrs __le64 client_cookie __le64 server_cookie __le64 global_seq __le64 connect_seq __le64 msg_seq (the last msg seq received)
TAG_RECONNECT_OK (服务器->客户端): 确认重新连接尝试
__le64 msg_seq (last msg seq received)
一旦客户端收到此消息,客户端就可以继续进行消息交换。
一旦服务器发送此消息,服务器就可以继续进行消息交换。
TAG_RECONNECT_RETRY_SESSION (仅服务器): 由于过时的 connect_seq 导致重新连接失败
TAG_RECONNECT_RETRY_GLOBAL (仅服务器): 由于过时的 global_seq 导致重新连接失败
TAG_RECONNECT_WAIT (仅服务器): 由于连接竞争导致重新连接失败。
表示服务器已经连接到客户端,并且该方向应该赢得竞争。客户端应该等待该连接完成。
TAG_RESET_SESSION (仅服务器): 要求客户端重置会话
__u8 full
full 标志指示对等体是否应该执行完全重置,即丢弃消息队列。
故障场景示例
第一个客户端的 client_ident 消息丢失,然后客户端重新连接。
服务器的 server_ident 消息丢失,然后客户端重新连接。
服务器的 server_ident 消息丢失,然后服务器重新连接。
会话建立后连接失败,然后客户端重新连接。
会话建立后连接失败,因为服务器重置,然后客户端重新连接。
RC* 意味着重置会话的 full 标志取决于 connection.policy.resetcheck。
会话建立后连接失败,因为客户端重置,然后客户端重新连接。
消息交换
一旦会话建立,我们就可以交换消息。
TAG_MSG: 一条消息
ceph_msg_header2 front middle data_pre_padding data
- ceph_msg_header2 从 ceph_msg_header 修改而来
包含一个 ack_seq。这在大多数情况下避免了对 TAG_ACK 消息的需求。
删除 src 字段,我们现在从消息流握手 (TAG_IDENT) 获取该字段。
指定 data_pre_padding 长度,可用于调整数据有效载荷的对齐方式。(注意:这有用吗?)
TAG_ACK: 确认收到消息
__le64 seq
这仅用于有状态会话。
TAG_KEEPALIVE2: 检查连接活跃性
ceph_timespec stamp
时间戳是发送方本地时间。
TAG_KEEPALIVE2_ACK: 回复 keepalive2
ceph_timestamp stamp
时间戳来自我们正在响应的 TAG_KEEPALIVE2。
TAG_CLOSE: 终止连接
指示应终止连接。这等同于挂断或重置(即,应触发 ms_handle_reset)。这不是严格必需或有用的,因为我们可以直接断开 TCP 连接。
协议交互示例 (WIP)
客户端状态机
服务器端状态机