6.1 WebSocket 握手、掩码与分片
浏览器、小程序和大量移动端 SDK 都只能发起 HTTP 请求。它们要建立长连接,唯一被广泛支持的入口就是 WebSocket,于是「MMO 网关的第一跳」往往不是自定义二进制协议,而是一次 HTTP Upgrade。
多数团队会直接引 gorilla/websocket 或 nhooyr.io/websocket,这没有错。但库把协议细节全部盖住了,一旦遇到「浏览器连不上」「大包被截断」「过代理后立刻断开」这类问题,排查就无从下手。本节不引任何第三方库,用标准库把 RFC 6455 里网关真正需要的那一小块手写出来——握手、帧格式、掩码、分片、控制帧——让协议本身露出来。
本章与站内既有内容的分工
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/posts/game/server/practice/ | 网关选型、协议栈取舍与线上踩坑 | 不选型,只把 RFC 6455 手写出来 |
/posts/game/server/ | 长连接架构、部署形态与容量规划 | 不聊部署,只聊握手与帧 |
一句话:别人讲「该不该用 WebSocket、用哪个库」,本节讲「不引库时协议到底长什么样」。
握手:一个 GUID、一次 SHA-1、一次 Base64
握手简单得可疑。客户端在 HTTP 头里给一个随机的 Sec-WebSocket-Key,服务端把它和一个固定 GUID 拼接、做一次 SHA-1、再 Base64,塞进 Sec-WebSocket-Accept 返回,同时把状态码写成 101 Switching Protocols。
这不是加密,也不是认证——它唯一的作用是证明对面真的懂 WebSocket,而不是一个把请求原样转发的代理。GUID 是公开常量,任何人都能算,所以它挡不住攻击者,只挡得住误配的中间设备。下面这段摘自 L1 的 internal/ws/handshake.go(完整文件 80 行,其余是请求行与头部的解析校验):
// snippet: internal/ws/handshake.go(节选:AcceptKey 与 101 响应)
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
// AcceptKey returns the Sec-WebSocket-Accept value for a client key:
// key + GUID, SHA-1, then Base64.
func AcceptKey(clientKey string) string {
h := sha1.New()
_, _ = io.WriteString(h, clientKey+wsGUID)
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
// ……中间是请求行解析、Upgrade / Connection / Version 校验……
resp := "HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: " + AcceptKey(key) + "\r\n\r\n"
if _, err := io.WriteString(w, resp); err != nil {
return "", fmt.Errorf("ws: write handshake response: %w", err)
}
return path, nil
测试里钉死了 RFC 6455 §1.3 给出的公开向量:键 dGhlIHNhbXBsZSBub25jZQ== 必须算出 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。这条错了,所有浏览器都会在握手阶段拒绝,而错误信息只有一句含糊的「连接失败」。
帧:长度三档、掩码与分片重组
握手完成后,同一条 TCP 连接上跑的就不再是 HTTP,而是一串帧。帧头固定两字节:第一字节高位是 FIN(是否为本消息最后一帧),低四位是 opcode;第二字节最高位是 MASK(载荷是否被掩码),低七位是载荷长度。长度有三个编码档位:
| payload len 字段 | 实际长度 | 后随字节 |
|---|---|---|
| 0–125 | 0–125 | 无 |
| 126 | 126–65535 | 2 字节大端 |
| 127 | 65536–2^63−1 | 8 字节大端(最高位必须为 0) |
RFC 6455 §5.1 规定了一件反直觉的事:客户端发往服务端的每一帧都必须掩码,服务端发往客户端的每一帧都不得掩码。掩码不是加密,密钥就明文跟在帧头后面;它的目的是防止中间代理缓存被污染的帧头,从而堵死一类缓存投毒攻击。算法本身只是用 4 字节密钥与载荷逐字节 XOR,而 XOR 是对合运算,同一个函数既能掩码也能解掩码。
opcode 分两类:0x1/0x2 是文本与二进制数据帧,0x0 是续帧;0x8/0x9/0xA(close/ping/pong)是控制帧。控制帧有两条硬规矩:不可分片,且载荷不得超过 125 字节。下面这段是 L1,go test 可以直接跑:
// file: internal/ws/frame.go
// tier: L1
// verify: go test ./internal/ws/ -run 'TestMask|TestFrame|TestReadMessage|TestControl|TestOversized|TestContinuation|TestUnmasked' -v
package ws
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
)
// Opcodes from RFC 6455 §5.2. 0x3-0x7 and 0xB-0xF stay reserved.
const (
OpContinuation byte = 0x0
OpText byte = 0x1
OpBinary byte = 0x2
OpClose byte = 0x8
OpPing byte = 0x9
OpPong byte = 0xA
)
// Frame errors the connection loop maps onto a close code.
var (
ErrReservedBits = errors.New("ws: reserved bits set")
ErrFragmentedCtl = errors.New("ws: control frame must not be fragmented")
ErrControlTooLarge = errors.New("ws: control frame payload exceeds 125 bytes")
ErrUnexpectedCont = errors.New("ws: continuation frame without a start")
ErrInterleavedFrame = errors.New("ws: data frame interleaved inside a fragmented message")
ErrPayloadTooLarge = errors.New("ws: payload exceeds configured maximum")
ErrBadOpcode = errors.New("ws: unknown opcode")
ErrControlInterleave = errors.New("ws: control frame interleaved inside a fragmented message")
ErrUnmaskedClientFrame = errors.New("ws: client frame was not masked")
)
func isControl(op byte) bool { return op&0x08 != 0 }
// Frame is one wire-level frame. Payload is already unmasked when ReadFrame
// returns it; Masked records what the sender did on the wire.
type Frame struct {
Fin bool
Opcode byte
Masked bool
Payload []byte
}
// Mask applies the 4-byte masking key in place. XOR is an involution, so the
// same function masks and unmasks.
func Mask(key [4]byte, b []byte) {
for i := range b {
b[i] ^= key[i&3]
}
}
// ReadFrame reads exactly one frame. maxPayload caps the declared length;
// 0 means no cap. The masking key is applied whenever the MASK bit is set.
func ReadFrame(r io.Reader, maxPayload uint64) (*Frame, error) {
var head [2]byte
if _, err := io.ReadFull(r, head[:]); err != nil {
return nil, err
}
f := &Frame{Fin: head[0]&0x80 != 0, Opcode: head[0] & 0x0F}
if head[0]&0x70 != 0 {
return nil, ErrReservedBits
}
f.Masked = head[1]&0x80 != 0
length := uint64(head[1] & 0x7F)
switch length {
case 126:
var ext [2]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, err
}
length = uint64(binary.BigEndian.Uint16(ext[:]))
case 127:
var ext [8]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, err
}
length = binary.BigEndian.Uint64(ext[:])
if length>>63 != 0 {
return nil, errors.New("ws: 64-bit payload length has the high bit set")
}
}
if maxPayload > 0 && length > maxPayload {
return nil, fmt.Errorf("%w: %d > %d", ErrPayloadTooLarge, length, maxPayload)
}
var key [4]byte
if f.Masked {
if _, err := io.ReadFull(r, key[:]); err != nil {
return nil, err
}
}
f.Payload = make([]byte, length)
if _, err := io.ReadFull(r, f.Payload); err != nil {
return nil, err
}
if f.Masked {
Mask(key, f.Payload)
}
return f, nil
}
// WriteFrame serialises one frame. When masked is true a fresh random masking
// key is generated, as the RFC requires of clients.
func WriteFrame(w io.Writer, fin bool, opcode byte, payload []byte, masked bool) error {
head := make([]byte, 0, 14)
b0 := opcode & 0x0F
if fin {
b0 |= 0x80
}
head = append(head, b0)
maskBit := byte(0)
if masked {
maskBit = 0x80
}
n := len(payload)
switch {
case n < 126:
head = append(head, maskBit|byte(n))
case n <= 0xFFFF:
head = append(head, maskBit|126, byte(n>>8), byte(n))
default:
head = append(head, maskBit|127)
var ext [8]byte
binary.BigEndian.PutUint64(ext[:], uint64(n))
head = append(head, ext[:]...)
}
var key [4]byte
if masked {
if _, err := rand.Read(key[:]); err != nil {
return err
}
head = append(head, key[:]...)
}
if _, err := w.Write(head); err != nil {
return err
}
if n == 0 {
return nil
}
if !masked {
_, err := w.Write(payload)
return err
}
buf := make([]byte, n) // copy: masking must not mutate the caller's slice
copy(buf, payload)
Mask(key, buf)
_, err := w.Write(buf)
return err
}
// ReadMessage reassembles one complete data message. Control frames are never
// buffered: they come back immediately so ping/pong/close can be answered even
// in the middle of a fragmented data message.
func ReadMessage(r io.Reader, maxPayload uint64) (byte, []byte, error) {
return ReadMessageMasked(r, maxPayload, false)
}
// ReadMessageMasked is ReadMessage with an explicit masking policy. Servers
// pass requireMask=true: RFC 6455 §5.1 makes an unmasked client frame a
// protocol error.
func ReadMessageMasked(r io.Reader, maxPayload uint64, requireMask bool) (opcode byte, payload []byte, err error) {
var (
msgOp byte
buf []byte
)
for {
f, err := ReadFrame(r, maxPayload)
if err != nil {
return 0, nil, err
}
if requireMask && !f.Masked {
return 0, nil, ErrUnmaskedClientFrame
}
if isControl(f.Opcode) {
if !f.Fin {
return 0, nil, ErrFragmentedCtl
}
if len(f.Payload) > 125 {
return 0, nil, ErrControlTooLarge
}
if msgOp != 0 {
return 0, nil, ErrControlInterleave
}
return f.Opcode, f.Payload, nil
}
switch f.Opcode {
case OpContinuation:
if msgOp == 0 {
return 0, nil, ErrUnexpectedCont
}
buf = append(buf, f.Payload...)
case OpText, OpBinary:
if msgOp != 0 {
return 0, nil, ErrInterleavedFrame
}
msgOp = f.Opcode
buf = append(buf, f.Payload...)
default:
return 0, nil, fmt.Errorf("%w: 0x%x", ErrBadOpcode, f.Opcode)
}
if maxPayload > 0 && uint64(len(buf)) > maxPayload {
return 0, nil, fmt.Errorf("%w: %d > %d", ErrPayloadTooLarge, len(buf), maxPayload)
}
if f.Fin {
return msgOp, buf, nil
}
}
}
测试覆盖了五个必须成立的点:掩码 XOR 往返、三种长度编码(5 / 300 / 70000 字节)、分片重组(daw + n-re + alm 拼回 dawn-realm)、FIN=0 的 ping 被拒、超过 125 字节的控制帧被拒,以及超过上限的数据帧在分配缓冲区之前就被拒——最后一条是抗 DoS 的关键,帧头里声明一个 2^62 的长度不该让服务端先去 make 那么大的切片。
本节小结
手写 RFC 6455 的收益不在于省掉一个依赖,而在于三点变得可见:握手只是 SHA-1 加 Base64,不是认证;掩码只是 XOR,不是加密;分片与控制帧的边界规则才是真正需要小心的地方。下一节把这条连接放进真实进程里,看它如何在沉默中死去。
阅读导航:上一节:目录 · 下一节:6.2 心跳与超时检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。