跳到主要内容

连接握手

本文主要内容为 WebSocket 建立连接开始握手的内容,主要包含了客户端和服务端握手的内容,以及双方如何处理相关字段和逻辑。

4.1 客户端要求

为了建立一个 WebSocket 连接,客户端需要建立一个连接并且发送一个在本节中定义的握手协议。连接最初状态为CONNECTING。客户端需要提供一个第三章讨论过的主机(host)、端口(port)、资源名称(resource name)和安全标记(secure)字段以及可被使用的一个协议(protocol)和扩展(extensions)列表。另外,如果客户端是一个 Web 浏览器,还需要提供源(origin)字段。

客户端在一个受控制的环境内运行,如使用特定运营商的手机浏览器,可能会断开连接切换到其他的运营商。在这种情况下,我们需要考虑包括手机软件和相关运营商在内的指定客户端。

当客户端通过一系列的配置字段(主机(host)、端口(port)、资源名称(resource name)和安全标记(secure))以及一个可被使用的协议(protocol)和扩展(extensions)列表来建立一个 WebSocket 连接,它一定会通过发送一个握手协议,并且受到一个服务端的握手响应来建立一条连接。建立连接具体需要哪些东西,在开始握手的时候会发送哪些字段,如何处理解读服务端的的响应都会在这一部分得到解答。在下面的内容中,我们会使用到第三章定义的一些术语如主机(host)和安全(secure)字段。

  1. WebSocket 的 URI 部分传递的字段(主机(host)、端口(port)、资源名称(resource name)和安全标记(secure))必须是在第三章 WebSocket URIs 部分指定过的有效字段,如果任意部分是无效字段,那么客户端一定会在接下来的步骤中关闭连接。

  2. 如果客户端有一条通过远端主机(IP 地址)定义的主机和端口定义的已经建立连接的 WebSocket 连接,即使这个远端主机被定义为了其他的名字,这个客户端也必须等到当前的这条连接建立成功或者失败才能建立连接。客户端最多有一条连接可以处于CONNECTING状态。如果多个连接尝试同时与一个相同的 IP 地址建立连接,客户端必须把他们进行排序,所以只能有一个连接执行下面的步骤。

    如果客户端不能够确定远程主机的 IP 地址(例如所有的请求都通过一个自己执行 DNS 查询的代理),那么客户端必须基于此假设每一个主机名都对应着不同的远端主机,因此客户端应该限制同时连接的总数目在一个比较合理的小数目上(例如:客户端可能允许同时跟a.example.comb.example.com这两个地址建立连接,但是如果同时和主机建立三十个连接,这可能是不允许的)。例如:在 Web 浏览器环境下,客户端需要考虑在用户打开的多个 tab 页中设置一个同时建立连接的数目限制。

    注意:这个限制使得脚本仅仅通过创建大量的 WebSocket 连接来进行拒绝服务攻击变得更难了。服务端可以在关闭连接前就停止攻击,从而进一步减小负载,这样会减少客户端的重连率。

    注意:客户端可以与单个主机建立的 WebSocket 连接数量是没有限制的。当建立的连接过多时,服务端可以拒绝和主机/IP 地址建立的连接,同时服务端在负载过高时也可以主动断开占用资源的连接。

  3. 使用代理:如果客户端在使用 WebSocket 协议来连接特定的主机和端口时使用了配置的代理,那么客户端应该连接到那个代理并且通过这个代理去和指定的主机和端口建立一个 TCP 连接。

    例如:如果客户端使用了全局的 HTTP 代理,那么如果尝试和 example.com 的 80 端口建立连接,那么久可能会发送下面的字段给代理服务器:

    CONNECT example.com:80 HTTP/1.1
    Host: example.com
    如果有密码字段的话,那么可能如下所示:

    CONNECT example.com:80 HTTP/1.1
    Host: example.com
    Proxy-authorization: Basic ZWRuYW1vZGU6bm9jYXBlcyE=

    如果客户端没有配置代理,那么就应该会和给定的主机和端口直接建立一条 TCP 连接。

    注意:如果可以,实现不暴露明显界面的来给 WebSocket 选择与其他代理分开的代理推荐使用 SOCKS5(RFC1928)代理供 WebSocket 连接,如果不行的话,使用配置了 HTTPS 连接的代理优于使用 HTTP 连接的代理。

    为了自动配置脚本,传递参数的 URI 必须包含定义在第三节 WebSocket URI 中的主机(host)、端口(port)、资源名称(resource name)和安全(secure)字段。

    注意:WebSocket 协议可以根据定义的规范配置到代理自动配置脚本("ws"代表非加密连接,"wss"代表加密连接)。

  4. 如果连接没有被打开,或者由于直连失败或者代理返回了一个错误,那么客户端必须断开 WebSocket 连接,并且停止重试连接。

  5. 如果安全(secure)字段存在,客户端必须在连接建立以后、发送握手数据之前进行 TLS 握手。如果 TLS 握手失败(比如服务端正数没有验证通过),那么客户端必须断开 WebSocket 连接。否则,所有后续的在此频道上面的数据通信都必须在加密的通道中传输。

    客户端在 TLS 握手时必须使用服务器名称指示扩展(SNI,Server Name Indication)。

一旦到服务端的连接被建立了(包括通过一个代理或者通过一个 TLS 加密通道),客户端必须发送一个开始握手的数据包给服务端。这个数据包由一个 HTTP 升级请求构成,包含一系列必须的和可选的 header 字段。握手的具体要求如下所示:

  1. 握手必须是一个在RFC2616指定的有效的 HTTP 请求。

  2. 这个请求方法必须是 GET,而且 HTTP 的版本至少需要 1.1。

    例如:如果 WebSocket 的 URI 是"ws://example.com/chat",那么发送的请求头第一行就应该是"GET /chat HTTP/1.1"。

  3. 请求的"Request-URI"部分必须与第三章中定义的资源名称(resource name)匹配,或者必须是一个 http/https 绝对路径的 URI,当解析 URI 时,有一个资源名称(resource name)、主机(host)和端口(port)与相对应的 ws/wss 匹配。

  4. 请求必须包含一个Hostheader 字段,它包含了一个主机(host)字段加上一个紧跟在":"之后的端口(port)字段(如果端口不存在则使用默认端口)。

  5. 这个请求必须包含一个Upgradeheader 字段,它的值必须包含"websocket"。

  6. 请求必须包含一个Connectionheader 字段,它的值必须包含"Upgrade"。

  7. 请求必须包含一个名为Sec-WebSocket-Key的 header 字段。这个 header 字段的值必须是由一个随机生成的 16 字节的随机数通过 base64(见RFC4648 的第四章)编码得到的。每一个连接都必须随机的选择随机数。

    注意:例如,如果随机选择的值的字节顺序为 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0a 0x0b 0x0c 0x0d 0x0e 0x0f 0x10,那么 header 字段的值就应该是"AQIDBAUGBwgJCgsMDQ4PEC=="。

  8. 如果这个请求来自一个浏览器,那么请求必须包含一个Originheader 字段。如果请求是来自一个非浏览器客户端,那么当该客户端这个字段的语义能够与示例中的匹配时,这个请求也可能包含这个字段。这个 header 字段的值为建立连接的源代码的源地址 ASCII 序列化后的结果。通过RFC6454可以知道如何构造这个值。

    例如,如果在 www.example.com 域下面的代码尝试与 ww2.example.com 这个地址建立连接,那么这个 header 字段的值就应该是 http://www.example.com

  9. 这个请求必须包含一个名为Sec-WebSocket-Version的字段。这个 header 字段的值必须为 13。

    注意:尽管这个文档草案的版本(09,10,11 和 12)都已经发布(这些协议大部分是编辑上的修改和澄清,而不是对无线协议的修改),9,10,11,12 这四个值不被认为是有效的Sec-WebSocket-Version的值。这些值被 IANA 保留,但是没有被用到过,以后也不会被使用。

  10. 这个请求可能会包含一个名为Sec-WebSocket-Protocol的 header 字段。如果存在这个字段,那么这个值包含了一个或者多个客户端希望使用的用逗号分隔的根据权重排序的子协议。这些子协议的值必须是一个非空字符串,字符的范围是 U+0021 到 U+007E,但是不包含其中的定义在RFC2616中的分隔符,并且每个协议必须是一个唯一的字符串。ABNF 的这个 header 字段的值是在RFC2616定义了构造方法和规则的 1#token。

  11. 这个请求可能包含一个名为Sec-WebSocket-Extensions字段。如果存在这个字段,这个值表示客户端期望使用的协议级别的扩展。这个 header 字段的具体内容和格式具体见 9.1 节。

  12. 这个请求可能还会包含其他的文档中定义的 header 字段,如 cookie(RFC6265)或者认证相关的 header 字段如Authorization字段(RFC2616)。

一旦客户端的握手请求发送出去,那么客户端必须在发送后续数据前等待服务端的响应。客户端必须通过以下的规则验证服务端的请求:

  1. 如果客户端收到的服务端返回状态码不是 101,客户端需要处理每个 HTTP 请求的响应。特别的是,客户端需要在收到 401 状态码的时候可能需要进行验证;服务端可能会通过 3xx 的状态码来将客户端进行重定向(但是客户端不要求遵守这些)等。否则,遵循下面的步骤。
  2. 如果客户端收到的响应缺少一个Upgradeheader 字段或者Upgradeheader 字段包含一个不是"websocket"的值(该值不区分大小写),那么客户端必须关闭连接。
  3. 如果客户端收到的响应缺少一个Connectionheader 字段或者Connectionheader 字段不包含"Upgrade"的值(该值不区分大小写),那么客户端必须关闭连接。
  4. 如果客户端收到的Sec-WebSocket-Acceptheader 字段或者Sec-WebSocket-Acceptheader 字段不等于通过Sec-WebSocket-Key字段的值(作为一个字符串,而不是 base64 解码后)和"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"串联起来,忽略所有前后空格进行 base64 SHA-1 编码的值,那么客户端必须关闭连接。
  5. 如果客户端收到的响应包含一个Sec-WebSocket-Extensionsheader 字段,并且这个字段使用的 extension 值在客户端的握手请求里面不存在(即服务端使用了一个客户端请求中不存在的值),那么客户端必须关闭连接。(解析这个 header 字段来确定使用哪个扩展在 9.1 节中有讨论。)
  6. 如果客户端收到的响应包含一个Sec-WebSocket-Protocolheader 字段,并且这个字段包含了一个没有在客户端握手中出现的子协议(即服务端使用了一个客户端请求中子协议字段不存在的值),那么客户端必须关闭连接。

如果客服务端的响应没有符合定义在这一节和 4.2.2 节中的服务端握手响应定义的要求,那么客户端也会断开连接。

请注意,根据RFC2616,所有的 header 字段名称在 HTTP 请求和 HTTP 请求响应中都是不区分大小写的。

如果服务端的响应通过了上述的验证过程,那么 WebSocket 就已经建立连接了,并且 WebSocket 的连接状态也到了OPEN状态。使用的扩展被定义为一个字符串(可能为空),它是在服务端响应握手时候提供的Sec-WebSocket-Extensions字段的值,如果这个 header 字段在握手响应中不存在,那么就是一个空值。使用的子协议值是在服务端响应握手中提供的Sec-WebSocket-protocol字段的值,如果服务端响应握手时没有这个 header 字段,那么这个值也为空。另外,如果服务端握手响应时设置了任何 cookie 的 header 字段(定义在RFC6265),这些 cookie 被称为在服务端响应握手时设置的 cookie(Cookies Set During the Server's Opening Handshake)。

4.2 服务端要求

服务端可以将连接的管理挂载到其他的网络代理上,如负载均衡器或者反向代理。在这种情况下,这篇规范对于服务端的目标是包含从第一个设备从建立到断开连接的 TCP 连接周期到服务端接受请求,发送响应的所有服务器的基础设施部分。

示例:一个数据中心可能有一个响应 WebSocket 握手请求的服务器,但是它将收到的数据帧都通过连接传递给另一个服务器来处理。在本文档中,"服务端(server)"包含这两者。

4.2.1 解析客户端的握手协议

当客户端开始一个 WebSocket 连接时,他会发送一个开始握手协议。为了获得必要的信息来保证服务端的握手响应,服务端必须解析这个客户端这部分的握手协议。

客户端的握手协议包含以下几部分。当服务的收到一个握手请求,发现客户端并没有发送一个符合以下内容的握手协议(注意在RFC2616中的每一项,header 字段的顺序是不重要的),包括但不限于在握手协议中有不合法的 ANBF 语法,服务端必须立即停止处理客户端的握手请求并且在响应中返回一个表示错误的 HTTP 错误码(如 400 Bad Request)。

  1. 一个 HTTP/1.1 或者跟高版本的 GET 请求,包含一个在第三章定义的应该被解析为资源名称(resource name)"Request-URI"字段(或者包含资源名称(resource name)的 HTTP/HTTPS 绝对路径)。
  2. 包含服务端权限的Hostheader 字段。
  3. 不区分大小写的值为"websocket"的Upgradeheader 字段。
  4. 不区分大小写的值为"Upgrade"的Connectionheader 字段。
  5. 值为 base64 编码(见RFC4648 的第四章)后长度为 16 字节的Sec-WebSocket-Keyheader 字段。
  6. 值为 13 的Sec-WebSocket-Versionheader 值。
  7. 可选的Originheader 字段。所有的浏览器都会发送这个字段。缺少此字段的连接不应该认为是来自浏览器。
  8. 可选的Sec-WebSocket-Protocolheader 字段,对应的值为客户端支持的子协议,根据权重进行排序。
  9. 可选的Sec-WebSocket-Extensionsheader 字段,对应的值为客户端可以使用的扩展。这个字段具体内容会在第 9.1 节再进行讨论。
  10. 可选的其他字段,如使用 cookie 或者服务器请求认证的字段。不识别的 header 字段会依据RFC2616中内容被忽略。

4.2.2 发送服务端握手响应请求

当客户端和服务端建立了一个 WebSocket 连接,服务端也必须完成接受连接的下面说明的步骤,并且发送一个服务端握手响应。

  1. 如果是一条建立在 HTTPS(HTTPS+TLS)端口的连接,通过这个链接完成 TLS 握手过程。如果这次握手失败(例如,客户端在"server_name"扩展中制定了主机名,但是服务端没有这个主机),那么关闭这条连接;否则,后续这个连接的所有的数据传递(包括服务端握手响应)都必须使用一个加密的通道。

  2. 服务端可以选择额外的客户端认证,例如,通过返回 401 状态码和在RFC2616说明的相对应的WWW-Authenticateheader 字段。

  3. 服务端可能通过使用 3xx 的状态码(见RFC2616)来重定向客户端。注意这个步骤可以发生在上面说到的认证之前、之后或者和认证一起。

  4. 构造以下信息:

    • 源(origin

    Originheader 字段在客户端的握手请求中表示建立连接的脚本属于哪一个源。这个源信息被序列化为 ASCII,并且转换为小写。服务端可以使用这个信息来作为判断是否接受这个链接的部分参考内容。如果服务端没有过滤源,那么他会接受任意源的连接。如果服务端没有接受这个连接,那么它必须返回一个对应的 HTTP 错误码(如 403 Forbidden)并且终端这一节描述的 WebSocket 握手过程。更多详情可以阅读第十章。

    • 关键值(key

    Sec-WebSocket-Keyheader 字段在客户端的握手请求中表示一个长度为 16 字节的 base64 编码的值。这个编码后的值是用于服务端握手的创建过程,用来表示接受了这个连接。服务端没有必要对Sec-WebSocket-Key值进行解码。

    • 版本(version

    Sec-WebSocket-Versionheader 字段在客户端握手请求中表示了客户端建立连接使用的 WebSocket 协议版本。如果这个版本和服务端的版本没有匹配上,那么服务端必须中断本章说的 WebSocket 连接,并且发送一个对应的 HTTP 错误码(例如 426 Upgrade Required),同时返回一个Sec-WebSocket-Versionheader 字段用来标识服务端能够识别的版本号。

    • 资源名称(resource name

    服务端提供的服务标识符。如果这个服务端提供多种服务,那么这个值应该是来自客户端握手请求中的 GET 方法中的"Request-URI"字段。如果请求的服务不支持,那么服务端必须发送一个相对应的 HTTP 错误码(例如 404 Not Found)并且中断 WebSocket 连接。

    • 子协议(subprotocol

    服务端准备使用的代表子协议的单个值或者为空。这个值必须选择客户端握手协议中由Sec-WebSocket-Protocol字段中提供的值,服务端会在这个连接中使用此值(任意)。如果客户端握手协议中没有包含这个字段或者服务端不支持客户端请求中提供的任意一个子协议,那么这个值只能为空。没有此 header 值就表明该值为空(这意味着服务端可以不选择客户端传递的任意一个子协议,禁止在响应请求中添加一个Sec-WebSocket-Protocol字段)。空字符串与空值不同,并且空值对于此字段来说是一个不合法值。ABNF 对于整个字段的定义和构造规则可以见RFC2616

    • 扩展(extensions

    表示一个服务端准备使用的协议级扩展列表(可能为空)。如果服务端支持多种扩展,那么这个值必须是客户端握手中已有的数值,是从Sec-WebSocket-Extensions字段中取一到多个值。该字段不存在时则表示此值为空。空字符串与空值不同。客户端没有列举的扩展禁止被 使用。应该选择哪些值和如何进行解析可以见 9.1 节。

  5. 如果服务端选择接受一条连接,他必须发送一个如下说明的有效的 HTTP 请求来进行相应。

    1. RFC2616中说明的一样,状态码为 101 的状态行。比如看上去像这种的:"HTTP/1.1 101 Switching Protocols"。

    2. RFC2616中说明的一样,值为"websocket"的Upgradeheader 字段。

    3. 值为"Upgrade"的Connectionheader 字段。

    4. 一个Sec-WebSocket-Acceptheader 字段。这个值由第 4.2.2 节的第 4 步提到的 key 来进行构造,通过和字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"拼接在一起进行 SHA-1 哈希运算,得到一个 20 字节的值,然后对这 20 字节进行 base64 编码。 ABNF 对这个字段定义如下:

      Sec-WebSocket-Accept = base64-value-non-empty
      base64-value-non-empty = (1*base64-data [ base64-padding ]) | base64-padding
      base64-data = 4base64-character
      base64-padding = (2base64-character "==") | (3base64-character "=")
      base64-character = ALPHA | DIGIT | "+" | "/"

    注意:作为示例,如果客户端握手时发送的Sec-WebSocket-Keyheader 字段的值为"dGhlIHNhbXBsZSBub25jZQ==",那么服务端会把"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"拼接到后面得到"dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11"。然后服务端回对这个字符串进行 SHA-1 哈希操作,得到 0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 0x46 0x06 0xcf 0x38 0x59 0x45 0xb2 0xbe 0xc4 0xea。对这个值进行 base64 编码,得到结果为"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=",然后通过Sec-WebSocket-Accept字段返回这个结果。

    1. 可选的Sec-WebSocket-Protocol字段,值为定义在第 4.2.2 节第 4 点中的子协议中。
    2. 可选的Sec-WebSocket-Extensions字段,值为定义在 4.2.2 节第 4 点中的扩展中。

这样服务端握手响应就完成了。如果服务端完成了上述步骤时也没有关闭中断 WebSocket 连接,那么服务端会考虑建立这个 WebSocket 连接并且将 WebSocket 连接状态置为OPEN。在此刻,服务端就可以开始发送(和接收)数据了。

4.3 收集握手中使用的新的 ABNF 的 header 字段

这一节使用在RFC2616 第 2.1 节定义的 ABNF 语法和规则,包括隐含的LWS 规则(implied LWS rule)。

请注意本节中使用了一下 ABNF 规定。一些规则名称对应一些 header 字段。这样的规则表示对应的 header 字段的值,例如Sec-WebSocket-Key的 ABNF 描述了Sec-WebSocket-Keyheader 字段的值的语法。在名字中带有"-Client"后缀的 ABNF 规则只适用于客户端发送给服务端的请求;而名字中带有"-Server"后缀的 ABNF 规则则只适用于服务端给客户端发送的请求响应。例如 ABNF 规则Sec-WebSocket-Protocol-Client表示客户端发送给服务端的请求中的Sec-WebSocket-Protocol字段的值。

以下的新的 header 字段可以在客户端向服务端发送握手请求时使用:

Sec-WebSocket-Key = base64-value-non-empty
Sec-WebSocket-Extensions = extension-list
Sec-WebSocket-Protocol-Client = 1#token
Sec-WebSocket-Version-Client = version

base64-value-non-empty = (1*base64-data [ base64-padding ]) | base64-padding
base64-data = 4base64-character
base64-padding = (2base64-character "==") | (3base64-character "=")
base64-character = ALPHA | DIGIT | "+" | "/"
extension-list = 1#extension
extension = extension-token *( ";" extension-param )
extension-token = registered-token
registered-token = token
extension-param = token [ "=" (token | quoted-string) ]
;当使用带引号的字符串语法变体时,在引号转义后面的值必须和ABNF"标记(token)"一致。
NZDIGIT = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
version = DIGIT | (NZDIGIT DIGIT) | ("1" DIGIT DIGIT) | ("2" DIGIT DIGIT)
; 范围是从0-255,没有前导0

以下的新的 header 字段可以在服务端向客户端发送握手响应请求时使用:

Sec-WebSocket-Extensions = extension-list
Sec-WebSocket-Accept = base64-value-non-empty
Sec-WebSocket-Protocol-Server = token
Sec-WebSocket-Version-Server = 1#version

4.4 支持多版本 WebSocket 协议

这一节提供了一些关于在客户端和服务端间支持多版本的 WebSocket 的协议的指导。

使用 WebSocket 版本标记字段(Sec-WebSocket-Versionheader 字段),客户端可以在最初请求时选择 WebSocket 协议的版本号(客户端不必要支持最新的版本)。如果服务端支持请求的版本并且我收到消息是有效的,那么服务端会接受这个版本。如果服务端不支持客户端请求的版本,那么服务端必须返回一个Sec-WebSocket-Versionheader 字段(或者多个Sec-WebSocket-Versionheader 字段)包含服务端支持的所有版本。在这种情况下,如果客户端支持其中任意一个版本,它可以选择一个新的版本值重新发起握手请求。

下面的示例演示了如何进行上面所述的版本协商:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
...
Sec-WebSocket-Version: 25

服务端的响应可能如下所示:

HTTP/1.1 400 Bad Request
...
Sec-WebSocket-Version: 13, 8, 7

注意服务端发送的最后的请求响应也可能是这个样子:

HTTP/1.1 400 Bad Request
...
Sec-WebSocket-Version: 13
Sec-WebSocket-Version: 8, 7

客户端选择了版本 13,重新进行握手:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
...
Sec-WebSocket-Version: 13