实时通信 — WebSocket、gRPC 流式调用与 WebRTC
从字节开始实现 WebSocket 服务器
目标
只用标准库实现 RFC 6455 的握手、帧、掩码、控制帧和关闭码,并与真实的库客户端对接,确认是否遵守了规则。
为什么重要
WebSocket 的故障记录中往往只留下一行关闭码。如果不知道 1002、1006 和 1009 分别代表什么,不知道是谁违反了什么规则才会出现那个码,就读不懂这一行。只在代理后面才断开连接,或者只在大消息时才断开的问题,归根结底也是这些字节的问题。把库原本代劳的事情亲手做一遍,之后就能读懂库的错误消息在说什么了。
步骤
- 计算握手密钥——在 /root/rt/ws/wsproto.py 中创建 accept_key(key)。在客户端发送的 Sec-WebSocket-Key 字符串后面附上 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,用 SHA-1 求哈希,并返回把这 20 字节用 base64 编码得到的字符串。
- 构造帧——在 /root/rt/ws/wsproto.py 中添加 encode_frame(opcode, payload, fin=True, mask_key=None)。第一个字节是 FIN 位和 opcode,第二个字节是 MASK 位和长度。长度不超过 125 时直接写入,不超过 65535 时写 126 加后面 2 字节,更大时写 127 加后面 8 字节,均为大端序。如果 mask_key 传入 4 个字节,就置位 MASK 位,在长度之后写入这 4 个字节,然后把正文的第 i 个字节与 mask_key[i % 4] 做 XOR 后附上。
- 读取帧并识别违反规则的情形——在 /root/rt/ws/wsproto.py 中添加 ProtocolError(code) 异常和 decode_frame(buf)。如果 buf 中一个帧还没有完全到达,就返回 None,如果已经完整到达,就返回(fin bool、opcode int、去掉掩码后的正文 bytes、masked bool、已使用的字节数)。如果 RSV 位被置位,或者 opcode 未定义(3 到 7、11 到 15),或者控制帧(8、9、10)的正文超过 125 字节,或者 FIN 被清除,就抛出 ProtocolError(1002)。ProtocolError 必须通过 code 属性持有关闭码。
- 把 HTTP 请求变成 101——在 /root/rt/ws/wsproto.py 中添加 handshake_response(request)。request 是到空行为止的 HTTP 请求 bytes。如果是 GET,Upgrade 是 websocket,Connection 中有 Upgrade 这个 token,Sec-WebSocket-Version 是 13,并且有 Sec-WebSocket-Key,就返回包含 "HTTP/1.1 101 Switching Protocols" 以及 Upgrade、Connection、Sec-WebSocket-Accept 头的响应 bytes。只有版本不同时,返回 426 并附带 Sec-WebSocket-Version: 13 头,其他缺陷则返回 400。头名称以及 websocket、upgrade 的值不区分大小写。
- 启动服务器并回显——在 /root/rt/ws/wsproto.py 中添加 serve(host, port, max_size=1048576)。每个连接用一个线程进行握手,如果不是 101,则发送响应后关闭。此后读取帧,把文本、二进制消息以相同的 opcode 原样返回。被分片的消息(FIN 0 和延续帧 0)要攒起来合并成一条返回,插在片段之间的 ping 则立即以相同正文的 pong 应答。服务器发送的帧不做掩码。
- 用合适的关闭码关闭违反规则的一方——修改 serve 以处理关闭。如果对方的 close 帧中带有码,就用同一个码,没有就用空正文返回 close,然后关闭 TCP。未做掩码的客户端帧用 1002,不是 UTF-8 的文本消息用 1007,攒起来的消息超过 max_size 字节时用 1009 发送 close 并关闭。如果 decode_frame 抛出 ProtocolError,就用它的 code 关闭。
- 与真实的客户端对接——没有额外需要构建的东西。评分器会用 websockets 库的客户端连接到你的服务器,发送韩文文本、70000 字节的二进制(使用 8 字节长度的大小)和 ping,并以 1000 关闭。只有在库没有因协议违规而断开连接,并且收到了所有回显、pong 和关闭码 1000 时,才算通过。
参考
- 工作文件夹是 /root/rt/ws。请先用 mkdir -p /root/rt/ws 创建。
- 想自己启动服务器的话,请使用 /opt/rt-lab/bin/python -c "import sys; sys.path.insert(0, '/root/rt/ws'); import wsproto; wsproto.serve('127.0.0.1', 9001)" 这条命令。评分器会另行选择一个空闲端口来启动。
- serve 是不会结束的函数。评分器会把服务器以单独的进程启动,评分结束后再关闭。
- 有两个常见错误:连服务器帧也做了掩码,以及假定一次 recv 就会收到一个帧,从而漏掉分片到达的帧。
- Python 必须用 /opt/rt-lab/bin/python 运行。本实验的库只装在那个虚拟环境里,如果直接用 python3 运行,就会出现 ModuleNotFoundError。像 alias rpy=/opt/rt-lab/bin/python 这样简写一下会比较方便。
- 实验 Pod 的对外连接被封锁。所有通信都发生在同一个 Pod 内的 127.0.0.1 上,不需要安装或下载。
- 评分器会以单独的进程加载你的代码,并实际建立连接。示例文件只是函数框架,原样保留是通不过的。前面步骤中已经完成的函数不要删除。
- 实验会话结束后,/root 中的文件不会保留。需要的代码请在结束之前另行保存。
计算握手密钥
在 /root/rt/ws/wsproto.py 中创建 accept_key(key)。在客户端发送的 Sec-WebSocket-Key 字符串后面附上 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,用 SHA-1 求哈希,并返回把这 20 字节用 base64 编码得到的字符串。
RFC 6455 第 1.3 节的示例密钥 dGhlIHNhbXBsZSBub25jZQ== 应当变成 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。关键在于不把密钥用 base64 解码,而是按字符串原样拼接。
构造帧
在 /root/rt/ws/wsproto.py 中添加 encode_frame(opcode, payload, fin=True, mask_key=None)。第一个字节是 FIN 位和 opcode,第二个字节是 MASK 位和长度。长度不超过 125 时直接写入,不超过 65535 时写 126 加后面 2 字节,更大时写 127 加后面 8 字节,均为大端序。如果 mask_key 传入 4 个字节,就置位 MASK 位,在长度之后写入这 4 个字节,然后把正文的第 i 个字节与 mask_key[i % 4] 做 XOR 后附上。
掩码不是加密。掩码密钥原样放在帧里一起发送。目的是防止那种让中间缓存把 WebSocket 字节误认为 HTTP 响应的攻击(RFC 6455 第 10.3 节)。
读取帧并识别违反规则的情形
在 /root/rt/ws/wsproto.py 中添加 ProtocolError(code) 异常和 decode_frame(buf)。如果 buf 中一个帧还没有完全到达,就返回 None,如果已经完整到达,就返回(fin bool、opcode int、去掉掩码后的正文 bytes、masked bool、已使用的字节数)。如果 RSV 位被置位,或者 opcode 未定义(3 到 7、11 到 15),或者控制帧(8、9、10)的正文超过 125 字节,或者 FIN 被清除,就抛出 ProtocolError(1002)。ProtocolError 必须通过 code 属性持有关闭码。
因为是从流中读取,所以不能保证一次就收到完整的帧。按 头部 2 字节 → 扩展长度 → 掩码密钥 → 正文 的顺序,只要某一部分的字节不足,就返回 None。必须返回已使用的字节数,调用方才能接着读取下一个帧。
把 HTTP 请求变成 101
在 /root/rt/ws/wsproto.py 中添加 handshake_response(request)。request 是到空行为止的 HTTP 请求 bytes。如果是 GET,Upgrade 是 websocket,Connection 中有 Upgrade 这个 token,Sec-WebSocket-Version 是 13,并且有 Sec-WebSocket-Key,就返回包含 "HTTP/1.1 101 Switching Protocols" 以及 Upgrade、Connection、Sec-WebSocket-Accept 头的响应 bytes。只有版本不同时,返回 426 并附带 Sec-WebSocket-Version: 13 头,其他缺陷则返回 400。头名称以及 websocket、upgrade 的值不区分大小写。
Connection 头中可能有多个 token,比如 "keep-alive, Upgrade"。请按逗号拆开逐个检查。如果在 426 中写明支持的版本,客户端就知道该用什么版本重试。
启动服务器并回显
在 /root/rt/ws/wsproto.py 中添加 serve(host, port, max_size=1048576)。每个连接用一个线程进行握手,如果不是 101,则发送响应后关闭。此后读取帧,把文本、二进制消息以相同的 opcode 原样返回。被分片的消息(FIN 0 和延续帧 0)要攒起来合并成一条返回,插在片段之间的 ping 则立即以相同正文的 pong 应答。服务器发送的帧不做掩码。
控制帧可以插入到被分片的消息中间(RFC 6455 第 5.4 节)。请把攒片段的缓冲区和控制帧的处理分开。如果服务器做了掩码,遵守规则的客户端必须断开连接。
用合适的关闭码关闭违反规则的一方
修改 serve 以处理关闭。如果对方的 close 帧中带有码,就用同一个码,没有就用空正文返回 close,然后关闭 TCP。未做掩码的客户端帧用 1002,不是 UTF-8 的文本消息用 1007,攒起来的消息超过 max_size 字节时用 1009 发送 close 并关闭。如果 decode_frame 抛出 ProtocolError,就用它的 code 关闭。
关闭码是留给对方的唯一诊断信息。1006 不是用来发送的码,而是表示“没有 close 帧就断开了”,由接收方起的名字。只要有关闭的理由,就一定要先发送 close 帧。
与真实的客户端对接
没有额外需要构建的东西。评分器会用 websockets 库的客户端连接到你的服务器,发送韩文文本、70000 字节的二进制(使用 8 字节长度的大小)和 ping,并以 1000 关闭。只有在库没有因协议违规而断开连接,并且收到了所有回显、pong 和关闭码 1000 时,才算通过。
手工构建的实现往往只能通过手工构建的测试。库会严格检查服务器帧的 MASK 位、长度字段的最小编码以及关闭顺序。失败时,消息中会给出库留下的原因。