切换深浅色
第 4 章 网络协议是如何设计出来的
接收方已经能够从 TCP 流里恢复一段完整字节,仍然不能回答三个问题:这段字节要求它做什么,这个要求在当前阶段是否合法,以及接下来哪些字节应交给另一个解释器。协议设计把字节排列和双方行为合成一份契约。它既是一种文件格式,也是一种有时间顺序的协作规则。
4.1 Header、Payload、Frame 与 Record:名称必须带着层次阅读
“去掉头部,把负载交给上层”听起来简单,实际必须先确定正在讨论哪一层。一个 TCP 段的负载,可以包含某个安全协议记录的一部分;这个记录解码后,又可能包含多个应用帧。不存在一个适用于整条链路的唯一“头部大小”。
**Header(头部)**是本层用来描述、控制或解释其数据单元的字段集合,例如类型、长度、序号或目标地址。**Payload(负载)**是本层所承载的内容。二者是相对于协议层的职责区分,并不意味着所有协议都只有“连续头部+连续负载”两块。有的协议在尾部放校验信息,有的在内部插入扩展,有的消息只有控制字段而没有应用正文。
第 3 章的两字节长度前缀,就是一个最小的教学头部。若后面为五字节 HELLO,在该层总长是七字节。但若另一层再包三字节控制信息,外层负载就是这完整七字节,不是只有五字节文字。计算开销时必须说明是以最终用户内容、内层编码结果还是外层传输内容为分母。
**Frame(帧)**通常指协议自行定界、可以单独识别类型和处理规则的数据单元。网络语境里的“帧”既可以指链路层帧,也可以指 HTTP/2 或 WebSocket 的应用层帧。名称相同不代表层次相同。**Record(记录)**也表示按本协议规则定界的一段数据,安全传输协议常用这个名称。不能仅凭 frame 与 record 两个词判断某单元是否加密、是否可靠、是否恰好包含一个完整消息。
可以用一段完全由本书定义的嵌套格式说明。外层记录头是“类型一字节+负载长度两字节”;内层帧头是“命令一字节+正文长度一字节”。所有长度均为无符号数,多字节长度使用大端序。下面的数值只演示层次,不是 TLS、HTTP/2 或 VLESS 的报文:
text
外层:20 00 05 | 01 03 61 62 63
└──内层帧──┘
内层: 01 03 | 61 62 63
a b c| 层次与范围 | 头部 | 负载 | 本层总长 |
|---|---|---|---|
外层记录 [0,8) | 20 00 05 | 五字节内层编码 | 8 字节 |
内层帧,外层偏移 [3,8) | 01 03 | 三字节文本 | 5 字节 |
| 文本解释 | 无额外头部 | UTF-8 的 abc | 3 字节 |
外层读到 00 05 后只知道接下来五字节属于这一记录,并不知道 01 是什么命令。内层解析器获得这五字节后,才依据自己的语法把 03 解释为三字节正文长度。外层的 20 与内层的 01 都是类型性字段,却分别属于不同的数值命名空间;交换两者会改变含义。
这也说明“完整”的相对性。外层记录完整,不保证内层帧完整。例如外层长度为四,而内层仍宣称正文三字节,外层虽然能收齐,内层却缺一字节。如果上层允许帧跨记录,就保留内层状态继续读取下一记录;如果上层要求一个记录恰好承载完整帧,就应报错。这两种设计均有可能,必须由组合规则决定。
实际标准提供了更清楚的例子。HTTP/2 用固定九字节帧头承载长度、类型、标志和流编号;一个字段块可以分散到 HEADERS 与 CONTINUATION 帧。TLS 1.3 则分别为握手消息和记录定义格式,握手消息与记录不要求一一对应。本节只借此确认“消息边界不等于承载单元边界”;完整格式在第 11、16 章展开。HTTP/2 §4、TLS 1.3 §5。
在数据路径里,这些边界往往交错:应用先产生一条消息,编码器把它分到若干记录,Socket 把编码结果写入 TCP,TCP 再自行分段。接收方沿相反方向恢复;它不应把某次 Socket 读取结束当成记录结束,也不应把记录结束当成应用请求结束。协议的精确对象是各层字节范围与状态,而不是抓包界面里看起来整齐的一行。
4.2 版本、命令与状态:同一个数值的意义由位置决定
有了长度,接收方可以知道一条消息在哪里结束;要执行它,还需要知道该用哪套规则、执行哪类操作,以及当前阶段是否允许这类操作。这分别对应版本、命令和状态。把这三者混成一个“类型字段”,会使协议很难扩展,也容易在错误阶段执行正确格式的消息。
版本解决的是解释规则是否相同。假设某教学协议版本 1 在固定前缀后放两字节端口,版本 2 却在同一位置插入四字节选项。旧接收端若忽略版本直接继续,可能把选项前两字节当端口,随后所有字段错位。这时消息未必触发长度错误,甚至可能产生语法合法但含义完全不同的请求。版本检查必须发生在依赖该版本的结构被解释之前。
版本字段本身也要有共同的读取约定。若连版本放在哪里、占几个字节都随版本改变,接收方就缺少选择解析器的起点。常见方法是先定义稳定的最小前缀,在其中放版本;另一种方法是在进入具体消息格式之前,通过双方都理解的协商格式选定版本。
“声明版本”与“协商版本”并不相同。声明是发送者说“以下按版本 1 解释”,接收者只能接受或拒绝;协商则让一方报告支持集合,另一方选择共同版本,并定义没有交集时怎样失败。协商不会天然安全:如果主动攻击者能改写双方看到的支持集合,就可能迫使双方采用较弱机制。密码学章节会解释如何把协商内容绑定到认证结果;这里先记住,字段存在与字段可信是两件事。
命令解决的是请求执行什么操作。在一个已选定版本里,01 可以表示建立连接,02 可以表示查询状态。命令不是负载长度,也不是执行结果。请求中 01 的含义,不会因为响应里 01 表示失败就产生冲突:请求和响应处于不同的语义环境。真正危险的是规范没有说清当前方向和阶段,导致两个实现为同一位置选择了不同的命名空间。
状态解决的是现在允许做什么。例如双方可能约定:先选择认证方法,再验证身份,最后请求连接。即使某条连接请求的版本、命令和地址都合法,在尚未认证时收到它也必须按状态规则拒绝,而不能因为字段齐全就建立目标连接。反过来,已经进入透明转发阶段后,目标应用的数据可能恰好以 01 开始;此时它是目标流中的普通字节,不应重新解释成代理命令。
下面用人为定义的值区分三个层面,数值不对应任何现成协议:
| 所在位置与方向 | 字节值 | 在本例中的意义 |
|---|---|---|
| 最小前缀的版本字段 | 01 | 按版本 1 语法解释 |
| 已认证请求的命令字段 | 01 | 请求建立目标连接 |
| 服务器响应的状态字段 | 00 | 请求成功 |
| 服务器响应的状态字段 | 01 | 请求被策略拒绝 |
| 转发阶段的首个负载字节 | 01 | 无代理层意义,交给目标应用 |
这里的“状态字段”是一次操作的结果码,而“连接状态”是接收端正在处于哪个阶段。二者相关但不同:收到成功结果码,客户端才能从等待响应状态转入转发状态;客户端处于等待响应状态,本身并不证明服务器已经成功。
真实的 SOCKS5 很适合说明命名空间的必要性。它的方法选择响应和连接请求都以版本 05 开始,但后面的字节分别表示认证方法和命令;用户名密码子协议又有自己的版本值。不能把整个会话中出现的每一个“版本”都要求为 05,也不能忽略当前子协议阶段。这里的具体格式将在第 6 章逐字节展开,依据是 RFC 1928 §3—4。
读一份规范时,可以把句子“字段 X 为 1”改写为更完整的问题:哪个版本、哪个方向、哪种消息、哪个状态下的 X?只有这些条件确定,数值才获得确定含义。
4.3 身份、地址与端口:谁提出请求,与请求去哪里
代理请求里常同时出现用户标识、目标域名、目标端口和本地监听地址。它们都可能被写成字符串,却回答不同问题。身份用于选择用户或授权规则;目标用于决定这次转发要连接哪个服务;本地监听地址则决定代理在哪里接收请求。如果把用户 UUID 当作地址,把监听端口当作目标端口,报文仍可能编码成功,但通信路径已经改变。
先区分标识和认证凭据。标识用来区分主体,例如账户名、编号或 UUID。认证用于让对方相信请求者具备某项资格。一个系统可以公开账户名,再要求密码或签名证明;也可以把一串不可猜测的标识本身当作持有者凭据。在后一种设计里,拿到这串值就可能足以冒用身份,它因使用方式而成为秘密。不能只凭“UUID”这个名称判断它是否应该保密。
UUID 的标准二进制宽度为 128 位,也就是 16 字节;常见连字符文本是其表示形式,不应未经协议说明就把全部文本字符原样发到线上。UUID 标准规定格式与生成方案,不规定某个代理怎样把 UUID 用于身份验证,更不保证传输中的 UUID 自动保密。UUID 的不同版本也不能与代理协议版本混淆。RFC 9562 §4。
目标地址则需要一种可以在字节中区分类型的表示。IPv4 地址有四字节;IPv6 地址有十六字节;域名是变长名称,不能仅凭“看起来像 IP”决定其类型。常见设计先放地址类型,再放该类型要求的内容。若域名使用长度前缀,还必须指定长度计算的是编码后字节、采用什么编码,以及是否允许国际化域名的 Unicode 形式。不同协议对此有各自规定,本节不把“所有域名都是任意 UTF-8”当成通用规则。
下面是本书定义的三种教学地址编码。类型值特意与后文真实代理格式分开;域名仅允许不含结尾零字节的 ASCII 名称,长度一字节,端口始终放在地址之后、使用两字节大端序:
| 类型 | 后续地址结构 | 教学值 | 地址与端口编码 |
|---|---|---|---|
11 | 四字节 IPv4 | 192.0.2.1:443 | 11 c0 00 02 01 01 bb |
12 | 十六字节 IPv6 | [2001:db8::1]:443 | 12 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01 01 bb |
13 | 一字节长度 N,再接 N 字节名称 | example.com:443 | 13 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d 01 bb |
三种总长分别是 7、19、15 字节。01 bb 均表示十进制 443。IPv6 文本中的 :: 只是一种省略零段的显示规则,不会作为两个冒号字符写进十六字节地址。example.com 有十一字节,前面的 0b 因而不是名称正文。以上地址选用文档示例范围,避免把教学报文误认为真实公网服务。
一个重要的工程后果是:**把域名交给代理,与先在本机得到 IP 再交给代理,不是等价输入。**前者允许代理在其环境中解析域名并选择地址;后者给代理一个已经选好的数值地址。两端的 DNS、可达性、IPv4/IPv6 偏好和内容分发节点可能不同。即使最终都连接 443 端口,建立的网络连接也未必到同一地址。
端口也不能独立说明应用身份。443 是常用服务约定,不是对 HTTPS 的证明。目标服务可能在 8443 上提供 HTTPS,也可能在 443 上运行别的协议。代理请求里写了某个域名,仅表示请求连接该目标;网站是否真由这个名称的合法主体提供,还需要上层身份验证,后文 TLS 证书会处理这个问题。
同理,身份验证成功不代表目标一定被允许。正确的处理顺序通常包含独立判断:请求来自哪个身份、命令是否合法、目标地址是否合规、解析后实际地址是否允许、连接是否成功。按名称做过一次策略判断,不能自动代替解析后的地址检查。具体访问控制策略属于实现设计,但规范必须清楚说明身份、请求目标和授权决定之间的关系。
4.4 长度、扩展与未知字段:能够跳过,不代表可以忽略
协议发布后,最初没有想到的能力会不断出现:额外的转发选项、不同身份材料、诊断标记、新地址类型。直接把新字段插入原有固定位置,会使旧解析器误读后续内容;完全依靠升级版本,又可能让一个很小的附加能力阻止两端通信。扩展设计要解决的就是:怎样让双方明确知道多出的字节在哪里,以及不理解它时还能否继续。
长度是提供结构隔离的工具。设一个扩展区长 E,每项扩展由类型 T、长度 L 和 L 字节值组成。即使不知道 T 的含义,只要扩展区总范围已知、L 的计数方式明确,就可能找到下一项。但必须先验证剩余字节足以容纳完整的类型、长度和字段值;未知类型绝不构成越过扩展区边界的许可。
前缀的数值宽度给出可表示上限,实现还可以规定更小的允许上限。两字节无符号数能表示 65535,不代表服务器就必须为每次请求接受 65535 字节扩展。允许上限可以按消息、字段、连接和总服务分别限制。尤其是嵌套结构:单字段上限、字段项数上限和整个扩展区上限约束不同资源,只有其中一个不能约束所有消耗。
未知字段有两种非常不同的含义。一种是补充信息,例如纯诊断标签,不理解它仍能按原语义执行。另一种会改变必须遵守的语义,例如“该操作只允许在某项条件成立时执行”。若旧接收方跳过后一种字段,虽然消息仍可定界,却执行了发送者没有授权的行为。因此扩展规范常需要区分可忽略与不可忽略的扩展,或者通过能力协商确保双方理解后才发送。
用一套本书定义的 TLV 说明这种规则。T 和 L 都各占一字节;T 的最高位表示“必须理解”,其余七位标识扩展种类;L 只计算值的字节数,最外层另有一字节 E 计算整个扩展区。此处最高位规则仅属于教学格式,不是 TLV 通用标准:
text
+E +---T L Value---+ +T L+
06 02 02 41 42 85 0006 后恰好六字节。第一项类型 02,值长二字节,内容为 ASCII AB,共四字节;第二项类型 85,值长零,共二字节。零长度不等于无字段:类型仍在,其出现本身可以有含义。
假设接收方不认识任何一项:它可以跳过 02,因为此项未要求必须理解;遇到 85,虽然能够准确找到字段末尾,也必须拒绝整个请求,因为它不理解被标为必需的第 5 类语义。反过来,如果接收方认识第 5 类,就应按第 5 类的规则检查零长度是否允许,而不能因为长度为零一律认为合法。
扩展还需要规定顺序和重复。假设两项都声明“目标地址”:是禁止重复、首项有效、末项有效,还是组成地址集合?若入口按首项检查策略、出口按末项建立连接,攻击者就可能利用同一报文的两种解释绕过限制。重复语义必须在整条处理链保持一致,不能留给每个库自行选择。
结构兼容、语义兼容与部署兼容因此是三个不同问题。旧端能够跳过字节,是结构兼容;跳过后仍满足双方要求,才是语义兼容;中间设备、已有库和实际配置也接受这种通信,才可能在部署中工作。RFC 6709 讨论扩展与互操作问题,强调新机制不能只在设计双方的理想实现中成立。RFC 6709。
对解析不确定性的处理,也不能简单归纳为“越宽容越好”。未定义字段、越界长度、未知版本、允许忽略的扩展和未知必需能力,各有不同规则。容忍应该是协议明确授予的空间,而不是遇到无法解释的字节就继续猜。一个可靠实现的能力首先是明确拒绝自己不能安全解释的输入,其次才是支持更多形式。
4.5 状态机:合法字节也可能是非法事件
一份字段表说明数据怎样摆放,却不能独立说明何时可以发送。假设服务器只有收到认证成功后才能接受连接请求。攻击者直接发送一个完整连接请求,其字段可能全部合法,但它出现的时机不合法。服务器需要把“我已经收到了什么、已经承诺了什么”作为解释下一字节的条件。这就是协议状态机的职责。
状态机可以由四部分描述:当前状态、输入事件、满足的条件、执行的动作与下一状态。状态不一定是单个数字;实际实现可能同时保存“正在等哪一字段”“身份是否确认”“目标连接是否建立”“哪个方向已结束”。图中的一个状态往往是这些事实的摘要。
把一个简化的代理连接过程写成服务器视角,可以得到以下规则。它是教学行为模型,不是某个真实代理的完整规范:
| 当前状态 | 事件与条件 | 动作 | 下一状态 |
|---|---|---|---|
| 等请求 | 最小前缀未齐 | 有界保留已到字节 | 等请求 |
| 等请求 | 请求完整且版本、身份、目标均允许 | 开始建立目标连接 | 正在连接 |
| 等请求 | 请求完整但不合法或不允许 | 按协议决定是否发送失败结果并释放资源 | 关闭 |
| 正在连接 | 目标连接成功 | 发送成功响应 | 转发 |
| 正在连接 | 失败或总时限到期 | 发送可表达的失败结果并释放资源 | 关闭 |
| 转发 | 任一方向有数据 | 按该方向转送 | 转发 |
| 转发 | 一个方向正常结束 | 传递该方向结束,继续另一方向 | 单向剩余 |
| 单向剩余 | 另一方向正常结束 | 关闭两个端点 | 关闭 |
| 非关闭状态 | 不可恢复错误 | 终止关联操作并释放端点 | 关闭 |
这里“发送成功响应”不等于响应字节已经被对方应用读取。实现仍可能正在排出响应缓冲区,但它必须确保同一方向上的成功响应排在后续目标数据之前。客户端的解析器先消费成功响应,再把剩余字节交给目标协议;不能在读超前时丢失目标数据。
正在绘制图表…
查看图表原文
stateDiagram-v2
[*] --> Waiting
Waiting --> Connecting: 完整且允许的请求
Waiting --> Closed: 非法请求或超时
Connecting --> Relaying: 连接成功并排入成功响应
Connecting --> Closed: 失败或取消
Relaying --> HalfClosed: 一个发送方向结束
Relaying --> Closed: 不可恢复错误
HalfClosed --> Closed: 另一方向结束或错误
Closed --> [*]
图中的一次状态转移可能跨多个系统调用。目标连接建立本身要等待网络,响应写出也可能短写;因此实现不能把一次函数调用返回与一次完整协议阶段简单等同。相反,一次读取可能触发多个转移:请求头读齐、策略通过、目标已经可用、然后缓冲区还留有待转发字节。
并发事件还会引入资源生命周期问题。连接正在建立时,客户端可能已经断开。若服务器只记着“连接成功就开始转发”,迟到的连接成功事件就可能留下一个无人使用的目标 Socket。正确行为必须检查这次请求是否仍有效;已经取消就释放迟到结果。状态机因而不只决定解释,还决定谁负责关闭连接、归还缓存和取消等待。
超时也应作为事件明确进入状态机。头部始终未齐,可以是请求读取超时;目标尚未连接,可以是连接超时;长期没有有效业务数据,可以是空闲超时。这些计时器的触发条件不同,不能用一个“每读到一字节就永远重置”的规则代替所有限制。极慢地持续发送头部可能绕过单次读取超时,却仍应受整个握手阶段的总时限与资源预算约束。
协议发生错误时,“丢掉几个字节重新找头”也不是通用修复。二进制流中的普通负载可能恰好等于头部标记;没有专门的重新同步规则,就无法证明从候选位置继续解释是安全的。对于一次请求后进入透明转发的代理,关闭当前连接往往比猜测恢复更明确。带多路复用的协议则可能只关闭一条逻辑流,但那需要先有可靠的流边界及相应错误作用域。
从状态机还能分清确认范围。TCP 连接建立,只确认传输连接已建立;代理成功响应,确认代理承诺的操作已经成功;目标网站的业务成功响应,才确认相应业务结果。后一层不能由前一层的状态提前代替。这一点将贯穿 SOCKS5、CONNECT、TLS 握手以及整条代理链。
4.6 一次完整解释:从字段范围走到转发状态
下面把本章已经解释的字段组合成一次教学会话,观察接收方究竟在哪一步知道了什么。它用于展示解析依赖,不是建议部署的新代理协议,也不是现有代理的抓包。为了不把尚未介绍的密码学藏在例子里,本例只使用公开账户标签,不提供身份认证、保密或防篡改能力。服务器是否允许该请求,由独立的可信入口和策略决定,不能由这个标签自证。
请求按以下顺序编码。所有长度均计算字节数,不含其自身;整数无符号,多字节数大端。请求只出现一次,解析完毕后客户端发送方向切换成原始目标流。
| 顺序 | 字段 | 宽度与解释 |
|---|---|---|
| 1 | V | 一字节,仅接受版本 01 |
| 2 | C | 一字节,仅接受连接命令 01 |
| 3 | N 与 U | 一字节标签长度 N,后接 N 字节 ASCII 标签;N 为 1—32 |
| 4 | 目标地址与端口 | 采用 4.3 的教学编码;域名长度为 1—253,端口必须非零 |
| 5 | E 与扩展区 | 一字节 E,后接 E 字节;区内采用 4.4 的 TLV 规则 |
| 6 | 后续流 | 从上一个字段末尾开始,不再按此请求格式解释 |
本例扩展种类 2 表示不影响连接语义的诊断标签,允许零至十六字节 ASCII,禁止重复;未知非必需项跳过,未知必需项拒绝。请求总长允许上限是 512 字节;实际解析同时检查字段约束和总上限。由于字段宽度受限,总上限主要防止多个可变字段在同一请求里累加过大。它不会改变字段自身上限。
标签为 ann,目标为 example.com:443,诊断标签为 AB 的完整请求是:
text
01 01 03 61 6e 6e 13 0b 65 78 61 6d 70 6c 65 2e
63 6f 6d 01 bb 04 02 02 41 42| 流偏移,半开区间 | 字段 | 解释 |
|---|---|---|
[0,1) | 01 | 版本 1 |
[1,2) | 01 | 建立连接 |
[2,3) | 03 | 标签长三字节 |
[3,6) | 61 6e 6e | ann |
[6,7) | 13 | 域名地址类型 |
[7,8) | 0b | 域名十一字节 |
[8,19) | 65 78 61 6d 70 6c 65 2e 63 6f 6d | example.com |
[19,21) | 01 bb | 端口 443 |
[21,22) | 04 | 扩展区四字节 |
[22,24) | 02 02 | 类型 2、值长二字节 |
[24,26) | 41 42 | AB |
首轮假设只读到六字节。接收方已经知道版本、命令和完整标签,但尚未看到地址类型,不能开始连接任何目标。第二轮读到偏移 21 之前,目标已完整,却还缺扩展区长度;不能因为已经有域名和端口就绕过可能改变处理条件的扩展。第三轮收齐扩展后,才完成整个请求的语法和语义检查。
这也解释为什么必须先完整检查扩展再执行不可撤销的动作。若把上面的可忽略诊断项替换成未知必需项,服务器必须拒绝;如果看到端口就抢先连接,拒绝已经不能取消目标端收到过连接这一事实。真实协议可以明确允许推测性动作,但那必须是清楚的设计取舍,不能是解析顺序意外造成的副作用。
若第三轮一次读到了剩余请求字节和 16 03 01 三个附加字节,请求解析器消费总计 26 字节,把三个剩余字节交给转发阶段。它不能把 16 当作第二个版本,也不能因为请求格式没有“正文总长”就等待连接关闭。这里的终止条件是完整解析 E 字节扩展,不是整个 TCP 方向结束。附加字节在本层没有含义,本例不靠它们猜目标协议。
服务器解析域名、检查实际目标地址、建立目标连接;成功以后,按本例约定回复三字节 01 00 00。这分别是版本、成功状态、零长度响应扩展。失败也使用三字节基本响应,非零状态表示本例定义的失败,且不进入转发。客户端只有在收到完整成功响应后才把服务器方向后续字节交给目标应用。
正在绘制图表…
查看图表原文
sequenceDiagram
participant C as 客户端
participant P as 代理
participant D as 目标服务
C->>P: 26 字节请求,可分次到达
P->>P: 完整解析、策略与地址检查
P->>D: 建立目标连接
D-->>P: 连接建立
P-->>C: 01 00 00
C->>P: 原始目标流
P->>D: 转送原始目标流
D-->>P: 目标返回字节
P-->>C: 成功响应之后的目标返回字节
实际实现可以缓存客户端提前送来的有限数据,或者通过不继续读取产生背压;本例不允许在成功响应前把它发送给目标。无论选择哪种缓存策略,请求边界和响应边界都必须保持准确。输入上限、等待时间和未完成状态同样适用,不能因“进入代理转发”就允许无限缓存。
这个例子的关键不在字段数量,而在每一项都有明确的权限边界:标签不证明身份,地址不证明网站身份,字段合法不代表策略允许,目标连接成功不代表目标业务成功,知道头部结束也不意味着已读缓冲区可以清空。后面的真实协议会使用不同字段和安全机制,但仍要回答这些同样的问题。
4.7 兼容性与开销:先说明计算范围
两个实现“都支持这个协议”,仍可能无法通信。它们可能支持不同版本、不同认证方式或不同扩展;也可能协议编码一致,但一方只允许特定地址类型。兼容性需要共同的最小能力、准确的协商结果和一致的错误规则,不能靠软件名称推断。
当一方发出未知版本时,旧实现按原规则拒绝,是一种可预测的失败,并不必然表示设计错误。更难发现的问题是静默误解释:双方都认为自己成功,实际选择的认证方式或目标却不同。因此字段变更不仅要考虑“对方能否继续读”,还要考虑“继续读是否仍具有原来的意义”。版本协商也不等于允许无条件重试旧版;若安全能力的降低没有明确策略与认证绑定,自动回退可能把可用性问题变成安全问题。
性能讨论首先需要统一计量对象。设用户目标流上行 U 字节,下行 D 字节;本例请求 Hq 字节,响应 Hr 字节,后续每个数据记录另有头部 h 字节,记录数为 n,填充总量为 P。仅在这一应用层计量时:
其中哪些项存在取决于真实格式。若控制握手之后直接转送原始流,就没有本层的逐记录头,不能虚构 n 和 h;若有加密标签或其他尾部,还必须单独计入。本式也未计算 IP、TCP、链路封装、重传、确认及另一层安全握手。
沿用 4.6,假设只交换一个 100 字节请求内容和一个 900 字节返回内容,没有额外记录:
| 项目 | 字节数 |
|---|---|
| 目标应用内容 U+D | 1000 |
| 教学代理请求 Hq | 26 |
| 教学代理响应 Hr | 3 |
| 本层总传输 | 1029 |
如果“开销率”定义为额外字节除以目标内容,结果是 29/1000=2.9%;若定义为额外字节占本层总传输比例,结果是 29/1029≈2.82%。两个数字并不矛盾,分母不同。比较资料若没有定义分母,就不能把它们直接排成名次。
再假设目标内容为 1 MiB,即 1,048,576 字节,仍只需相同 29 字节控制信息,其相对开销约为 0.00277%。一次固定请求头对长连接的比例迅速降低,但对每条短连接仍需重新支付。若为了复用连接而添加逐流编号、调度和队列,固定成本可能减少,持续成本却增加;要结合请求数量、连接寿命和复用结构判断。
时间成本与字节成本又不同。RTT(往返时间)表示某条路径上的信息发送到对端,再有返回信息抵达所需的时间。握手阶段若存在必须等待的依赖,就不能靠头部压缩消除。例如客户端必须先得到方法选择,再发送凭据,认证成功后才能发连接请求,这些等待沿着同一路径相继发生。允许把某些首个数据合并发送可以减少等待,但需要协议明确规定提前数据的接受、缓存、拒绝和重试语义。
以第 4.6 的教学模型为例,假设客户端到代理的单程传播约为 d,代理完整验证耗时 p,代理到目标连接建立耗时 c。忽略排队、序列化和其它握手,从客户端开始发送请求到收到完整成功响应约为:
c 自身可能包含 DNS、失败地址回退和目标 TCP 建连;本式没有把这些工作误算为客户端到代理的一次往返。如果在代理连接之外还有安全握手、HTTP 握手或前置代理,每一步又有自己的路径和依赖。这里是条件性时间模型,不是某种产品的性能测试结果。
吞吐量还取决于处理路径。短头部可能节省少量网络字节,却需要更多字段分支、动态分配或复制;较大记录可能摊薄头部,却增加首批数据等待和每连接缓存。一个实现甚至可以在不改变任何线上字节的情况下,通过减少系统调用或改变缓冲策略显著改变表现。协议格式和软件实现应该分别归因。
因此,精确的性能句子应带条件:在什么连接模式、什么负载、什么计量层、哪些复用和安全功能启用的前提下,少了多少字节、减少了哪一次等待,或移除了哪段处理。没有这些限定,“轻量”“零开销”“零延迟”都不足以解释实际网络行为。
4.8 从字段表走向规范:怎样判断一个结论凭什么成立
读真实协议时,最容易发生的错误是把三类陈述混成一类:标准要求这样做,某个实现确实这样做,以及作者认为这样做更好。三者都可能有价值,但只有在明确来源之后,才能决定结论的适用范围。
规范中的义务词是一种精确工具。RFC 2119 定义 MUST、SHOULD、MAY 等用语;RFC 8174 澄清 BCP 14 的特殊含义适用于相应的大写形式,而且文档应声明采用该约定。MUST 表达必须遵守的要求;SHOULD 允许在充分理解后果的特殊情况下偏离;MAY 允许一种选择,不能据此假定所有对端都实现。普通叙述句同样可以规定规范要求,不能只靠搜索大写单词判断整份文档的约束。RFC 2119、RFC 8174。
例如某扩展“可以发送”与接收端“可以忽略”不是同一条规则。前者约束发送者的行为范围,后者决定接收者必须怎样处理未知输入;规范可能要求即便自己不生成该扩展,也必须正确识别其边界。若只读配置文档里“支持某选项”几个字,就容易漏掉这一层互操作要求。
源码则要回答另一组问题:读取的是哪一个提交,哪些分支控制行为,调用者是否已经处理过某些字节,错误会在哪里结束连接?某函数只读目标地址,不代表线上请求从地址开始;版本、身份或安全封装可能已在调用它之前处理。某函数分配一块缓冲区,也不能证明整个数据路径只复制一次;还要追踪缓冲区从何处来、被哪个包装器消费、怎样写到下游。
固定实现结论应该可以定位到实际调用路径。写“这个协议永远不会缓存正文”过于宽泛;写“在某提交、某模式和某条读取路径下,正文按某种有界队列处理”才允许读者核验。版本升级可能改变默认值、路径选择或状态机,却不一定改变协议名。后面分析 Xray 时,固定 SHA 是限制结论范围的坐标,并不是对该版本全部功能都已审查的声明。
配置文件处在第三个层面。它是对实现行为的输入,不是线上格式。配置中的域名可能只用于服务器身份验证,也可能控制外层路由,还可能是最终目标;同一个字符串出现两次并不保证两处字节具有同一职责。理解配置需要把参数沿解析、构建、握手、读写路径追踪到实际行为,而不能把字段名直接当作安全结论。
可以把一条经常出现的说法拆开:“它是二进制协议,所以既快又安全。”二进制仅说明编码方式,头部也许短,也许复杂;速度需要分析额外字节、等待和处理路径;安全需要定义攻击者能力、身份验证和密码学保护。三个结论分别需要不同证据,不能由第一个自动推得后两个。
本书因此对例子作明确区分。确定性构造报文用于解释偏移与状态,不证明实际软件接受它;标准报文用于解释规范,不代表软件完整实现所有选项;固定源码用于解释一版软件,不给未经测量的吞吐排名;实际观测必须带环境和条件,也不能单凭一次观测反推通用契约。接下来的代理章节就按这些边界,逐项连接“请求长什么样”和“服务器真正做了什么”。
第 4 章的分层与消息边界依据分别见 RFC 9113 §4 和 RFC 8446 §5;扩展设计参考 RFC 6709 §4。教学格式、状态机和算术模型由本文明确给出,不把它们登记为已部署协议。