QDP:一种以 QQ 消息为传输介质的通信协议

目录:

QDP(QQ-based Datagram Protocol)是一种基于 QQ 消息的传输协议,可用于通过 QQ 传输任意二进制数据,并自动对数据报进行分片发送。

协议实现见 richardchien/qdp

动机

上面的描述听起来很诡异,但确实是这样。

起因是做 CQHTTP 插件的时候想基于 酷Q 写一个新的 QQ 客户端,然后想到可以通过 QQ 消息发送一些 QQ 自己的客户端不支持的内容,比如 Markdown,再由这个第三方客户端来进行解析和显示,进而意识到其实可以通过 Base64 等编码来使任意二进制数据的传输称为可能。

和朋友讨论的过程中也意识到其实这个东西理论上可以用来做隧道代理(虽然速度会非常非常慢)。

这个想法在脑海里搁置了很久之后,终于闲 xian 来 de 无 dan 事 teng 来设计了一下协议的具体内容并做了简单实现。

思路

首先,QQ 消息只能发送文本内容,而我们想实现任意二进制的传输,因此需要把二进制转换成字符串,既然是概念验证,肯定就选最简单的 Base64 了。

然后,考虑到发送的二进制内容可能非常大,编码之后可能会超出 QQ 单条消息的允许大小,经过简单测试,发现一条消息可以发送 4500 个 ASCII 字符,也就是说,一条消息最大能发送 4500 字节,这里我们记 QQ 消息的 MTU 为 4500 B。

这个 MTU,实际上不能全部拿来传输数据,因为我们要能够确保知道这条消息是 QDP 协议的数据,还需要一些标记手段,这里通过在 Base64 编码之后的消息开头加上魔术前缀来实现,例如 <<<42>>>~,只有消息以这个前缀开头,接收方才会去尝试解包它之后的 Base64 内容。于是消息内容最终长这样:

<<<42>>>~elkAATA5xexIZWxsbywgMTIzIQ==

再考虑 QQ 机器人发送消息的频率不能无限高,实际上这个频率只能很低,否则很容易被封号,经过和相关机器人开发者的讨论,这个频率应该能够达到最高 10 msg/s 左右,但这只是发送频率,实际接收方接收消息的速度会更慢。

另外,QQ 消息有一个特点是,消息之间有可能乱序,甚至有可能丢失,但只要是收到的消息,几乎不可能出错,类比计算机网络中现有的 TCP、UDP 等协议的话,QDP 协议是不需要校验和的,因为只存在乱序和丢包,不存在内容出错。

由于这只是概念验证,于是不打算实现像 TCP 那样的可靠传输,实际上 QQ 消息在一定程度上已经非常可靠了,虽然说有可能出现乱序,但在网络条件比较好的测试环境应该还是可以忽略不计。

UDP 协议使用源 IP、源端口、目标 IP 和目标端口来确定数据报的发送和接收方,但 QQ 上没有 IP,因此这里使用源 QQ、源端口、目标 QQ 和目标端口来确定发送和接收方。于是,模仿 UDP 协议的首部,QDP 的首部需要包含源端口和目标端口。而 QDP 数据报最终发送时,需要经过类似于 IP 分片的处理,也就是把 QDP 数据报当做数据,切成多块之后再各自加首部,形成新的大小在 MTU 允许范围内的数据报,本来想把这一层称为「QIP」,但实现的时候发现没必要这么复杂,所以这个概念就不要了,这个切分之后的分片,我们称之为「QDP 分片」。如果模仿 IP 协议的话,「QDP 分片」的首部需要包含源 QQ、目标 QQ、数据报 ID(用于重组分片)、分片偏移,但这里我们进行简化,因为我们通过 QQ 收发消息,收发消息的时候都能够知道自己和对方的 QQ 号,不像真实的网络那么复杂,因此实际上分片中是不需要放源和目标 QQ 号的。而 IP 的分片偏移在实现上也似乎有点繁琐,因此 QDP 分片直接使用序号了。

根据上一段的思路,最终 QDP 数据报(Packet)和 QDP 分片(Fragment)的协议格式设计如下:

QDP Packet
+------------------+------------------+------------+
|      16 bit      |      16 bit      | var length |
|     Src Port     |     Dst Port     |    Data    |
+------------------+------------------+------------+

QDP Fragment
+------------------+-------+----------+------------+
|     16 bit       | 1 bit |  15 bit  | var length |
|    Packet ID     |  MF   | Sequence |    Data    |
+------------------+-------+----------+------------+

其中,QDP Fragment 的 Data 是整个 QDP Packet 的二进制切分之后的一部分,QDP Packet 的 Data 是实际要传输的二进制内容;Packet ID 是对应一个 QDP Packet 的随机数,用于接收方对可能有多个数据报混杂的分片进行重组;MF 标志位就和 IP 数据报的 MF 一个意思,More Fragment,表示当前分片是不是最后一个分片;Sequence 表示当前分片是原始 QDP Packet 的第几个分片,从 1 开始数。

有了协议格式之后,整体的收发流程就比较明确了。

发送方指定目标 QQ、目标端口和数据,QDP 实现程序将源端口、目标端口和数据放入 QDP Packet,按需进行切分,组成一个或若干个 QDP Fragment,然后依次将每个 QDP Fragment 的内容的二进制进行 Base64 编码,加上魔术前缀 <<<42>>>~,发送给目标 QQ。

接收方收到 QQ 消息,判断是否以魔术前缀开头,如果是,将前缀之后的内容进行 Base64 解码,恢复成 QDP Fragment,判断 MF 标志位,如果不是最后一个分片,暂存该分片,如果是最后一个,则根据 Sequence 进行排序重组,如果出现分片丢失的情况,直接丢弃该数据报。成功重组之后得到 QDP Packet,判断其目标端口是否为当前应用监听的端口,如果是,则放入 Socket 对应的接收队列,等待应用程序接收。

实现

上面思路的最后已经详细说明了协议的具体工作流程,因此实现起来就非常简单了,见 richardchien/qdp

上面给出的实现提供了如下接口(很类似于 Python 内置的 UDP Socket 接口):

qdp.init({
    12345678: 'ws://127.0.0.1:6700'
})

sock = qdp.Socket()
await sock.bind((12345678, 9999))
logging.info('QDP socket created')

while True:
    data, addr = await sock.recvfrom()
    text = data.decode('utf-8')
    logging.info(f'Received from {addr[0]}:{addr[1]}, data: {text}')
    text_send = f'Hello, {text}!'
    await sock.sendto(text_send.encode('utf-8'), addr)
    logging.info(f'Sent to {addr[0]}:{addr[1]}, data: {text_send}')

上面的代码给出了一段使用 QDP Socket 实现服务端的例子。qdp.init() 传入 QQ 号和 CQHTTP 的 WebSocket API 地址的映射;sock.bind((12345678, 9999)) 将 Socket 绑定到了 QQ 12345678 的 9999 端口;sock.recvfrom()sock.sendto() 就是非常正常的接收和发送接口了。

客户端部分的代码基本上和服务端类似,和 UDP 的区别是,即使是客户端,也必须要调用 sock.bind() 方法,来绑定到 QQ 号(因为要连接 CQHTTP),端口可以填 None,内部会从 50000 到 65535 随机选取一个可用端口。

另外,由于发送大量数据时需要分很多片,为了限制消息发送频率,顺便给 CQHTTP 写了一个 API 限速功能,可以通过配置文件限制请求的执行速度,测试时使用了 2 msg/s 和 5 msg/s 两种速度。

总结

以上就是 QDP 协议从理论上的概念到实现的过程了,实现之后简单进行了一下测速,发现每秒实际只能传输 2~3 KB 的数据,可以说非常慢了,基本上没啥用,不过作为概念验证已经算是成功了。

参考资料

评论