原文链接
WebSocket 协议:RFC 6455。或 rfc6455
SegmentFault:websocket 协议解析 [RFC6455]
WebSocket服务器就是一个承载TCP应用程序的主机,此TCP应用程序可用于监听遵循特定协议的服务器上的任何端口。
WebSocket服务可以用任何支持Berkeley套接字的服务器端编程语言编写,如C(++)或Python,甚至PHP和服务器端JavaScript。
-
本文需要了解的基础知识:
- 1,HTTP如何工作的。
- 2,TCP套接字。(socket)
阅读最新的官方WebSockets规范RFC 6455.第1节和第4-7节对于服务器实现者特别有意思。 第10节讨论安全性,你应该在暴露你的服务器之前仔细阅读它。
WebSocket服务器在本文只是做简单解释。 WebSocket服务器通常是独立的专用服务器(出于负载平衡或其他实际原因),所以您通常会使用反向代理(例如常规HTTP服务器)来检测WebSocket握手,预处理它们并将这些客户端发送给 一个真正的WebSocket服务器。 这意味着您不必使用cookie和身份验证处理程序来扩充服务器代码。。自动翻译看着别扭。
WebSocket 握手
首先,服务器必须要监听从客户端以标准TCP socket发送过来的socket连接。根据主机的平台,这可能已经为你处理。例如,假设你的服务器正在侦听example.com,端口8000,并且您的套接字服务器响应/ chat上的GET请求。
警告:服务器可能会监听它选择的任何端口,但是如果它选择了80或443以外的端口,它可能在防火墙和/或代理服务器上有问题。 端口443上的连接往往会更频繁,但是当然,这需要一个安全的连接(TLS / SSL)。 另外请注意,大多数浏览器(特别是Firefox 8+)不允许从安全页面连接到不安全的WebSocket服务器。
握手是WebSockets中的“Web”(WebSocket=Web+Socket,Socket连接需要握手,WebSocket握手的是Web)。 这是从HTTP到WS的桥梁(通过握手,将协议从http升级为TCP)。 在握手过程中,客户端与服务器端经过协商的,如果有是问题,任何一方都可以在完成前退出。 服务器必须小心了解客户端要求的所有内容,否则会引入安全问题。
1. 客户端握手请求
虽然是要构建WebSocket服务器, 客户端仍然必须启动WebSocket握手过程,所以在服务器端 必须要解析客户端的请求。
客户端会发送一个标准的Http请求 (HTTP 版本必须不低于1.1,http方法必须是GET):
Request URL:ws://www.example.com:8000/chat
Request Method:GET
Status Code:101 Switching Protocols
Host:example.com:8000
Connection:Upgrade
Upgrade:websocket
Sec-WebSocket-Key:jZnFsvHUJVQfW69xu3qQYA==
Sec-WebSocket-Version:13
客户端可以在这里请求扩展和/或子协议,查看详情。header里也会包含常见的User-Agent,Referer,Cookie或验证标头。 可以根据这些header做任何处理(比如做权限判断…); 它们并不直接与WebSocket相关。 忽视它们也是安全的。 在许多常见的设置中,反向代理已经处理了它们。
如果请求头有错误、或服务器端不能识别,服务器会发送一个“400错误的请求”,并立即关闭套接字,通常,会再HTTP Response的body中会携带握手失败的原因,但并不会显示(浏览器不会显示)。如果服务器不识别WebSockets的版本,服务器会返回一个header:Sec-WebSocket-Version
携带服务器能识别的版本,对应本文,如果请求的是v12,服务器不识别,会返回能识别的版本V13。现在,我们来看看最奇怪的标题Sec-WebSocket-Key。
Tip1: 所有的浏览器请求默认都会带上 Origin 头信息。 可以用此Header做安全性验证 (检查是否同源(域);黑/白名单, etc.) ,如果不允许此请求可返回403拒绝访问的代码。 不过要注意的是能发送request请求的不止有浏览器,还可能是其他代理,他们都能伪造一个Origin。 大多数应用程序会拒绝没有此header的请求。
Tip2: request-uri (本文对应/chat
) 在规范中没有定义的含义. 很多人巧妙地使用它来让一个服务器处理多个WebSocket应用程序。比如: example.com/chat
用作多人聊天的应用,在同一台服务器主机上的话 example.com/game
用作多人游戏的应用。
Note: 常规的 HTTP 状态码只能在握手之前使用。握手成功后,必须使用不同的状态码 (在规范的第7.4节中定义)。
2.服务器端 握手响应
服务器接收到上面的请求后, 服务器应该发送一个看起来像这样的非常奇怪的(但仍然是HTTP)响应 (记住:每个 header 都是以 \r\n 结尾,最后一个Header结尾也会追加 \r\n ):
Request URL:ws://www.example.com:8000/chat
Request Method:GET
Status Code:101 Switching Protocols
Connection:Upgrade
Sec-WebSocket-Accept:hl41mB6NkGreZYMvgElDtZ9KpTQ=
Upgrade:WebSocket
另外,服务器可以在这里决定扩展/子协议请求; 详情参见 Miscellaneous 。 Sec-WebSocket-Accept
的值,必须是由服务器从 客户端 请求中的Header Sec-WebSocket-Key
和值计算产生,计算过程(rfc6455第1.3节):
Sec-WebSocket-Accept
的值 拼接258EAFA5-E914-47DA-95CA-C5AB0DC85B11
(一个魔字符串),得出jZnFsvHUJVQfW69xu3qQYA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
- 计算
jZnFsvHUJVQfW69xu3qQYA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
的SHA-1 hash 值。 - 将2中的hash值base64-encoded编码得出结果:
hl41mB6NkGreZYMvgElDtZ9KpTQ=
并返回。
FYI:这个看起来很复杂的过程存在,所以客户端很明显服务器是否支持WebSocket。这很重要,因为如果服务器接受WebSockets连接但将数据解释为HTTP请求,则可能会出现安全问题。 (不太懂。这样做会用在什么地方?rfc6455最后有必要从头到尾看一遍)
经过以上步骤后,连接就建立起来了,就可以数据交互了。
tips:在服务器响应握手之前,可用发送其他header,像 Set-Cookie 验证权限、重定向以及其他状态码。
3.维护客户端连接
这与WebSocket协议无关,单有必要一提:服务器有必要跟踪客户的套接字,以避免多次与已完成握手的客户端再次握手。 同一个客户端IP地址可以尝试连接多次,如果服务器不维护客户端的连接列表,可能会遭到 拒绝服务攻击1,2。
数据帧交换
web客户端和服务器端可以随时相互发送消息–这就是WebSocket的方便之处,但是提取消息的过程就比较麻烦了。尽管所有帧都遵循相同的特定格式,但从客户端到服务器的数据都是使用异或加密(XOR)的(使用32位密钥),详情在rfc6455 第5节。
1 .数据帧格式
每个数据帧格式 (从Client<==>Server) 如下:
帧格式:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
每个数据帧供32位(4字节)
名称 | 长度 | 注释 |
---|---|---|
FIN | 1bit | 标明这一帧是否是整个消息体的最后一帧,0则接续listen更多的部分 |
RSV1 RSV2 RSV3 | 1bit | 保留位,必须为0,如果不为0,则标记为连接失败 |
opcode | 4bit | 操作位,定义这一帧的类型 |
Mask | 1bit | 标明承载的内容是否需要用掩码进行异或 |
Masking-key | 0 or 4bytes | 掩码异或运算用的key |
Payload length | 7bit or 7 +16bit or 7 + 64bit | 承载体的长度(后续会解释为什么会有3种长度) |
1. 第4-7位(opcode)操作码
在websocket中,我们定义了几种操作类型,也就是表明了数据包的行为,数据包大体可分为两种,一种是字符数据包 (string),一种是字节数据包 (byte) 不同的数据包使用不同的opcode来传输,opcode定义如下:
操作码 | 说明 |
---|---|
%x0 | 标明这一个数据包是上一个数据包的延续,它是一个延长帧 (continuation frame) |
%x1 | 标明这个数据包是一个字符帧 (text frame) |
%x2 | 标明这个数据包是一个字节帧 (binary frame) |
%x3-7 | 保留值,供未来的非控制帧使用 |
%x8 | 标明这个数据包是用来告诉对方,我方需要关闭连接 |
%x9 | 标明这个数据包是一个心跳请求 (ping) |
%xA | 标明这个数据包是一个心跳响应 (pong) |
%xB-F | 保留至,供未来的控制帧使用 |
2. 第8位(MASK )表明是否被编码,也即:承载的内容是否需要用掩码进行异或 提取数据。客户端到服务器的消息必须要加密,所以服务器期望值应该是1(实际上,规范的第5.1节指出,如果客户端发送了未使用掩码(MASK)的消息,则服务器必须与客户端断开连接)。
将帧发送回客户端时,请勿mask it,也不要设置mask位。 我们稍后会解释masking。 注意:即使使用安全套接字,您也必须mask消息。RSV1-3可以忽略,它们用于扩展。(不明所以)
3. 解码Payload长度
Payload Length
位占用了可选的7bit或者7 + 16bit 或者 7 + 64bit。
读取Payload数据,必须要指定读到那里为止。因此获知负载数据长度很重要。这个过程稍微有点复杂,要以下这些步骤:
1. 读取9-15位 (包括9和15位本身),并转换为无符号整数。如果值小于或等于125,这个值就是长度;如果是 126,请转到步骤 2。如果它是 127,请转到步骤 3。
2. 读取接下来的 16 位并转换为无符号整数,并作为长度。
3. 读取接下来的 64 位并转换为无符号整数,并作为长度(**最高有效位必须为0**)。
4. 读取并 Unmasking Data。
我们需要使用掩码对payload的每一个字节进行异运算(解码),客户端到服务器掩码 rfc6455 5.3节
八位位组:即字节
变换数据的八位位组i (”transformed-octet-i”)是原始数据的八位位组i(”original-octet-i”)异或(XOR)i取模4位置的 掩码键的八位位组(”masking-key-octet-j”):
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
/// <summary>
/// 根据payloadData、mask 解码
/// </summary>
/// <param name="payloadData">payloadData:字节数组。承载的数据</param>
/// <param name="mask">掩码 4字节:字节数组。掩码</param>
/// <returns></returns>
private byte[] GetDecodedPayloadData(byte[] payloadData, byte[] mask)
{
byte[] DecodedPayloadData = new byte[payloadData.Length];
for (var i = 0; i < payloadData.Length; i++)
{
DecodedPayloadData[i] = (byte)(payloadData[i] ^ mask[i % 4]);
}
return DecodedPayloadData;
}
可通过Encoding.UTF8.GetString(DecodedPayloadData);
取得请求中的数据。
5. 报文段
FIN和opcode字段一起工作,将消息分成不同的帧。 这被称为报文段。 报文段仅在操作码0x0至0x2上可用。