《MMO 服务器开发》6.1 WebSocket 握手、掩码与分片

浏览器与小程序只能发起 HTTP 请求,MMO 网关的长连接入口因此多半是 WebSocket。本节不引任何第三方库,用标准库手写 RFC 6455 里网关真正需要的一小块:从 Sec-WebSocket-Key 拼 GUID、SHA-1、Base64 回 101,到帧头的 FIN、opcode、MASK 与 126/127 扩展长度,再到客户端必须掩码的逐字节 XOR 与分片重组。

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–1250–125无
126126–655352 字节大端
12765536–2^63−18 字节大端(最高位必须为 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 心跳与超时检测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标