本文是对 The HTTP crash course nobody asked for 的整理与翻译。
内容结构概览
- 文章背景:作者维护一个 HTTP/S/2 proxy,代理位于客户端和后端之间,因此谁都不能完全信任。
- HTTP/1.1 入门:在 TCP 连接上发送文本协议,请求行、Header、CRLF、空行、Body。
- 用 netcat 手写请求 :
HEAD / HTTP/1.1、Host、Connection: close,直接和服务器对话。 - Host 的意义:HTTP/1.1 要求 Host,用来支持同一 IP 上托管多个站点。
- Content-Length:请求或响应 body 需要说明长度,否则对端无法判断 body 是否完整。
- 持久连接:HTTP/1.1 默认可在同一 TCP 连接上连续发送多个请求。
- chunked transfer encoding:不知道 body 长度时,可以分块发送,每块前面带十六进制长度,最后以 0 长度块结束。
- 代理的困难:代理要防无限 header、过多 header、空闲连接、Slowloris、body 传输、压缩、缓存等问题。
- 代理必须有立场:在"尽量忠实转发"和"严格规范/安全"之间必须做取舍。
- TLS 登场 :HTTP 明文可用
nc看清;HTTPS 是 HTTP over TLS,需要openssl s_client或库来建立 TLS。 - reqwest 高层客户端 :用 Rust
reqwest一行发 HTTP/HTTPS 请求,底层默认依赖 TLS 实现。 - Wireshark 抓包观察:HTTPS 中只能看到 TLS ClientHello、ServerHello 和加密 Application Data;HTTP 明文则能直接解析头和 body。
- hyper 降一层抽象 :
reqwest用 hyper,直接使用 hyper 能看到 body streaming。 - body 不一定一次读完:同一个响应 body 可能一次读完,也可能分多次 buffer 到达。
- hyper 默认不处理 HTTPS :要用
hyper-rustls或 TLS connector 明确接入 TLS。 - KeyLogFile 解密 TLS:用 rustls key log file 配合 Wireshark,可以看到 TLS 里的明文 HTTP。
- HTTP/2 入门:HTTP/2 仍然跑在 TCP 和 TLS 之上,但引入了 stream multiplexing。
- h2 crate :hyper 的很多 HTTP/2 细节在
h2crate 中,手动使用 h2 可以看到SendRequest和Connection的分工。 - ALPN 必不可少 :TLS 连接上要通过 ALPN 告诉服务器自己想说
h2,否则服务器不会知道。 - HTTP/2 多路复用:多个请求可以在一个 TCP 连接上并发,响应 header/body 可能乱序完成。
- 手写 HTTP/2 frame:HTTP/2 连接先发 preface,然后发送 SETTINGS、HEADERS、DATA 等 frame。
- Frame header 结构:HTTP/2 frame 有长度、类型、flags、reserved bit、stream id、payload。
- 强类型建模 frame:不同 frame 类型有不同 flags,用 Rust enum/bitflags 建模能减少混用错误。
- HPACK:HTTP/2 的 HEADERS payload 是 HPACK 编码的 Header Block Fragment。
- HTTP/2 header 规则 :header 名必须小写,
content-length仍可存在,但必须和 DATA frame 总长度一致。 - 流 ID 与 GOAWAY:client initiated stream 使用奇数 ID,stream ID 会耗尽;服务端可用 GOAWAY 指明最后处理的 stream。
- 流控是 bug 富矿:SETTINGS_INITIAL_WINDOW_SIZE、WINDOW_UPDATE、负窗口、SETTINGS ack 都可能产生复杂交互。
- HTTP/2 的攻击面:SETTINGS/PING/WINDOW_UPDATE 滥用、TCP 与 HTTP/2 双层流控、HPACK 压缩相关攻击。
- 作者的最终动机:为了更好观察内部状态,他开始写自己的 H1/H2 Rust 实现,基于 io_uring、rustls/kTLS、固定 buffer pool。
HTTP 是一种非常成功的技术。
成功到什么程度?你每天都在用它,但大多数时候根本不用想它。打开网页、刷文档、看 RSS、下载包、调用 API,很多数据都经过 HTTP 或 HTTPS。对普通用户来说,"不需要想它"就是它的成功标志。
但如果你写的是 HTTP 代理,那就完全不一样了。
普通 HTTP 客户端至少可以相信自己发出去的请求,普通 HTTP 服务端至少可以相信自己生成的响应。可代理不行。代理站在中间,客户端可能乱来,后端可能乱来,网络可能乱来,甚至自己的代码也可能乱来。
作者维护的是一个 HTTP/S/2 proxy。bug 报告通常长这样:
text
好像有东西不太对。
我们也不知道为什么。
我们怀疑是代理。
这非常真实。代理夹在所有东西中间,一旦系统出问题,它天然就像嫌疑人。客户端日志没有,后端日志没有,用户只知道"请求失败了",于是代理就成了第一怀疑对象。
这篇文章就是从这里开始。作者说,他在工作中遇到了一个非常棘手的 HTTP/2 bug。但要讲清那个 bug,必须从头开始。于是我们先从 HTTP/1.1 讲起。
一、先忘掉复杂性:HTTP/1.1 其实就是往 TCP 里写文本
为了理解 HTTP/1.1,可以先把互联网想成"一堆管子"。
每个 peer 有一个 IP 地址。客户端可以主动建立到某个服务器的 TCP 连接,服务器可以监听端口并接受连接。连接建立之后,双方得到一个双向 socket:可以从里面读字节,也可以往里面写字节。
TCP 给我们提供有序、可靠传输。底层有校验和、重传、拥塞控制、流控等机制,但这篇文章先不展开。
在这个基础上,HTTP/1.1 看起来很简单:打开 TCP 连接,写一段文本,服务器回一段文本。
比如用 nc,也就是 netcat,直接连到 neverssl.com 的 80 端口,然后手写 HTTP:
bash
printf 'HEAD / HTTP/1.1\r\nHost: neverssl.com\r\nConnection: close\r\n\r\n' | nc neverssl.com 80
你发出去的大概是:
http
HEAD / HTTP/1.1
Host: neverssl.com
Connection: close
注意每一行实际以 \r\n 结尾,也就是 CRLF:Carriage Return + Line Feed。HTTP 这套东西继承了电传打字机时代的历史包袱,所以换行不是单纯的 \n。
请求头和 body 之间用一个空行隔开,也就是最后的 \r\n\r\n。
服务器会返回类似:
http
HTTP/1.1 200 OK
Date: Tue, 13 Sep 2022 19:10:46 GMT
Server: Apache/2.4.53
Content-Length: 3961
Content-Type: text/html; charset=UTF-8
这里没有 body,因为我们发的是 HEAD 请求。HEAD 和 GET 很像,只是告诉服务器:我只要响应头,不要响应体。
二、请求行、Host、Header 和 Body
HTTP/1.1 请求的第一行叫 request line:
http
HEAD / HTTP/1.1
它由三部分组成:
text
method HEAD
path /
version HTTP/1.1
method 有很多种。最常见的是 GET,意思是"把这个资源给我"。POST 用来提交表单或上传数据。DELETE 用来删除。OPTIONS 常用于 CORS。
第二行:
http
Host: neverssl.com
在 HTTP/1.1 里非常重要。Host 让同一个 IP 地址可以托管多个站点。现代世界里,这几乎是默认情况:CDN、反向代理、虚拟主机、应用网关,全都依赖 Host 来决定请求到底属于哪个站点或应用。
有些服务器不严格检查 Host。你连着 neverssl.com,Host 写成 fasterthanli.me,它可能仍然返回 200。但有些服务器会检查,不匹配就返回 404 或其他错误。
如果请求有 body,比如 POST,就要把 body 放在空行后面:
http
POST / HTTP/1.1
Host: example.org
Content-Length: 27
Take, eat; this is my body.
Content-Length 告诉服务器 body 有多少字节。这样服务器才能判断:客户端是真的发完了,还是连接中途断了。
响应也是类似结构:先是 status line,然后是 header fields,然后空行,然后 body。
http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1256
<!doctype html>
<html>
...
这里有一个容易混淆的小点:HTTP header 和 HTTP headers 不完全是一回事。
HTTP header 可以指整个头部区域:start-line 加上若干 header fields。HTTP headers 通常指那些 Name: Value 字段。口语里经常混用,但真正看 RFC 时要注意。
三、持久连接:一个 TCP 连接可以发多个请求
HTTP/1.1 默认可以复用 TCP 连接。
如果你不发送:
http
Connection: close
服务器可以保持连接打开,等待你继续发下一个请求。
你甚至可以在同一个连接里连续写两个请求:
http
HEAD / HTTP/1.1
Host: example.org
HEAD / HTTP/1.1
Host: example.org
Connection: close
服务器会返回两个响应。最后一个响应带 Connection: close,双方再关闭连接。
这就是 HTTP/1.1 persistent connection。它避免了每个请求都重新建立 TCP 连接的开销。但它也带来复杂性:连接什么时候关闭?前一个响应 body 到哪里结束?下一个响应从哪里开始?如果 body 长度不清楚,就麻烦了。
所以 Content-Length 很重要。
四、chunked transfer encoding:不知道长度也能传 body
如果你正在上传一个动态生成的文件,还不知道最终 body 多大,就不能提前写 Content-Length。
HTTP/1.1 提供了 chunked transfer encoding:
http
POST / HTTP/1.1
Host: example.org
Transfer-Encoding: chunked
4
Help
C
I am chunked
0
每个 chunk 前面先写长度,长度用十六进制表示。I am chunked 是 12 字节,所以长度是 C。最后发送长度为 0 的 chunk,表示结束。
这样服务器仍然能判断 body 是否完整:如果没看到 0 长度 chunk 就断了,那就是中途断开;如果看到 0 长度 chunk,就表示 body 正常结束。
这一点对代理尤其重要。代理不能只是"读到连接关闭为止"。HTTP/1.1 连接可能不关闭,而是继续承载后续请求。你必须根据 Content-Length 或 chunked encoding 判断 body 边界。
五、写 HTTP/1.1 代理时,世界开始变坏
如果你只是写一个客户端,请求某个 URL,拿回响应,事情还比较简单。
如果你写的是代理,复杂度立刻上升。
假设你是 CDN 或 ADN,有很多 edge nodes,接收用户请求,再把请求转发到后端 worker nodes。你监听 80 端口,接受 TCP 连接,然后要解析 HTTP/1.1。
第一道问题是资源限制。攻击者可以:
text
发送无限长 header,让你的 buffer 不断增长,直到 OOM。
发送很多很多 header,占用解析和内存资源。
打开大量 TCP 连接,然后什么都不发,占满文件描述符或内存。
每次只发一个字节,极慢地发送 header 或 body,也就是 Slowloris。
所以代理不能无限等。你要设置 header 最大长度、header 数量上限、空闲 timeout、读 timeout、body 大小限制等。
第二道问题是转发策略。
比如客户端刚发来:
http
GET / HTTP/1.1
Host: fantastic-app.example.org
理论上你已经知道后端应用是谁,可以立刻开始连接后端,并把请求转发过去。但你还没收到完整 header。恶意客户端可能永远不发空行,也可能继续发一堆奇怪 header。为了安全,你很可能要等完整请求头收完、校验完,再决定是否连接后端。
然后还有更多问题:
text
如果 header value 里有 NUL 字节,要拒绝还是转发?
如果客户端写 Content-Length: 0,但后面又发 body,怎么办?
如果没有 Content-Length,但后面发 body,怎么办?
如果后端返回 204 No Content,却带了 Content-Length 和 body,怎么办?
请求 body 要不要完整读完再转发?大 body 怎么办?
响应 body 要不要缓存?缓存压缩版还是未压缩版?
后端返回 chunked,你转给客户端时还保留 chunked 吗?
能不能把非 chunked body 改成 chunked body?
Content-Encoding 是 gzip / brotli 时,代理要不要解压?
给后端发什么 Accept-Encoding?
CDN 是存多份压缩格式,还是按客户端能力现场转码?
这就是作者的观点:HTTP/1.1 代理必须有立场。
HTTP 规范有很多边界和历史包袱。代理要在"忠实复现客户端请求"和"为了安全与规范而改写/拒绝"之间做选择。选择不同,行为就不同,bug 也不同。
作者开玩笑说,让人写 HTTP 代理不属于日内瓦公约保护范围,但也许应该算。
六、TLS:明文 HTTP 该结束了
作者尝试用 HTTP 明文读取自己的文章,结果服务器返回 301,重定向到 HTTPS。
这是好事。互联网通信应该默认加密。哪怕你只是读一篇公开文章,如果只有"敏感通信"才加密,反而等于告诉观察者:哪些通信敏感,哪些不敏感。
命令行里可以用 openssl s_client 代替 nc。nc 只是原始 TCP,openssl s_client 可以帮我们建立 TLS 连接,把 stdin 加密发出去,把收到的内容解密到 stdout。
例如:
bash
printf 'GET /articles/the-http-crash-course-nobody-asked-for HTTP/1.1\r\nHost: fasterthanli.me\r\nConnection: close\r\n\r\n' \
| openssl s_client -verify_quiet -quiet -connect fasterthanli.me:443
这样就能在 TLS 连接上说 HTTP/1.1。
这里有一个关键概念:HTTPS 不是一套完全不同的应用协议。最朴素地说,它就是 HTTP over TLS。HTTP 仍然是 HTTP,只是中间加了一层加密隧道。
当然,真实世界里 TLS 还有证书验证、SNI、ALPN、密钥协商、协议版本、cipher suite 等一堆细节,但对这篇文章来说,先理解"HTTP 明文文本被包进 TLS 加密通道"就够了。
七、用 Rust 发请求:先从 reqwest 开始
命令行很好玩,但作者想继续往 HTTP/2 走,所以开始写 Rust。
第一层是 reqwest。它是 Rust 生态里常用的高层 HTTP client。配上 tokio:
rust
#[tokio::main]
async fn main() {
let response = reqwest::get("http://example.org").await.unwrap();
println!(
"Got HTTP {}, with headers: {:#?}",
response.status(),
response.headers()
);
let body = response.text().await.unwrap();
for line in body.lines().take(10) {
println!("{line}");
}
}
这里很简单:第一次 .await 得到响应 header,第二次 .await 把 body 读成文本。
把 URL 改成 https://example.org,输出基本一样。对应用代码来说,HTTPS 只是 URL scheme 变了。底层 TLS 由库处理。reqwest 默认会通过依赖链使用 TLS 实现,比如 native-tls / openssl,也可以配置 rustls。
这就是高层抽象的好处:你不必每次都手动建立 TCP、TLS、写请求、读响应。
但作者当然不会停在这里。
八、抓包看 HTTP 和 HTTPS 的差异
为了确认发生了什么,作者用 tcpdump 抓包,再用 Wireshark 打开。
HTTPS 抓包里能看到:
text
TCP handshake: SYN, SYN-ACK, ACK
TLS ClientHello
TLS ServerHello
Application Data
ClientHello 里能看到 SNI,也就是 server name extension,里面有 example.org。这能帮助确认抓到的是目标连接。
但后面的 Application Data 是加密的。Wireshark 只能告诉你这是 TLS record,里面是 encrypted application data。它看不到 HTTP header,也看不到 body。
换成 http:// 明文 URL 后,Wireshark 就能直接解析 HTTP 请求和响应:请求行、headers、body,全都清楚。
这非常直观地展示了 TLS 的作用:不是 HTTP 不存在了,而是 HTTP 被加密包裹起来了。
九、降一层:直接使用 hyper
reqwest 底层使用 hyper。所以作者下一步把 reqwest 去掉,直接用 hyper:
rust
let response = hyper::Client::new()
.get("http://example.org".parse().unwrap())
.await
.unwrap();
println!("Got HTTP {}", response.status());
println!("Body: {:?}", response.body());
输出里 body 是:
text
Body(Streaming)
这很重要。HTTP 响应由 header 和 body 组成。你拿到 response header 时,body 可能还在流式到达。hyper 给你一个 body handle,让你自己决定怎么读。
如果用较低层的接口 poll body:
rust
while let Some(buffer) = std::future::poll_fn(|cx| {
Pin::new(&mut body).poll_data(cx)
}).await {
let buffer = buffer.unwrap();
println!("Read {} bytes", buffer.len());
}
有时整个 body 一次读完:
text
Read 1256 bytes
有时分两次:
text
Read 1125 bytes
Read 131 bytes
这很正常。TCP 是字节流,不是消息流。你写了一个 HTTP response body,不代表应用层每次 read 都会读到完整 body。底层 packet、buffer、socket read 都会影响你看到的切片大小。
这就是网络编程中非常重要的直觉:
text
一次 write 不等于一次 read。
一个 HTTP body 不等于一个 TCP packet。
网络给你的是流,你必须自己处理边界。
十、hyper 默认不是"自动会 HTTPS"
接着作者把 hyper URL 改成 https://example.org,程序报错:
text
invalid URL, scheme is not http
这不是说 hyper 不能做 HTTPS,而是说当前配置的 hyper client 只支持普通 HTTP。要支持 HTTPS,需要显式加 TLS connector。
作者使用 hyper-rustls 和 rustls:
rust
let conn = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.https_or_http()
.enable_http1()
.build();
let client = hyper::Client::builder().build::<_, hyper::Body>(conn);
这比 reqwest 啰嗦很多,但好处是控制力更强。
比如我们可以打开 rustls 的 key log 功能:
rust
client_config.key_log = Arc::new(KeyLogFile::new());
然后运行程序时设置:
bash
SSLKEYLOGFILE=/shared/sslkeylogfile cargo run
再把这个 key log file 配给 Wireshark,就能解密 TLS 会话。Wireshark 里不再只是 encrypted Application Data,而能看到里面真实的 HTTP/1.1 请求和响应。
这一步非常有教育意义:TLS 不再是黑盒。你能看到明文 HTTP 在 TLS 里面是什么样子,也能理解抓包工具为什么平时看不到内容。
十一、进入 HTTP/2:它仍然跑在 TCP 和 TLS 之上
下一步是 HTTP/2。
使用 hyper 的时候,可以构建一个只启用 HTTP/2 的 client:
rust
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(client_config)
.https_only()
.enable_http2()
.build();
let client = Client::builder()
.http2_only(true)
.build::<_, hyper::Body>(connector);
抓包后,Wireshark 能解码出 HTTP/2。这里要注意:HTTP/2 仍然使用 TCP,也仍然可以通过 TLS 承载。熟悉的 TCP handshake 和 TLS handshake 仍然存在。
但 HTTP/2 和 HTTP/1.1 的模型差别很大。
HTTP/1.1 在一个连接上可以顺序发多个请求,但响应基本也按顺序回来。HTTP/2 则有 stream multiplexing。多个请求可以在同一个 TCP 连接上并发进行,不同 stream 的 frame 可以交错出现。一个请求的 headers 和 body 可以和另一个请求的 headers 和 body 交织在一起。
作者用 h2 发多个请求,用 strace 验证只有一个到 443 端口的连接,但日志显示多个请求的 header/body 完成顺序可以不同。这就是 HTTP/2 多路复用的直观表现。
十二、直接用 h2:SendRequest 和 Connection
hyper 的 HTTP/2 细节很大一部分在 h2 crate 里。于是作者继续降一层,直接使用 h2。
流程是:
text
手动 DNS 解析
建立 TCP 连接
建立 TLS 会话
把 TLS socket 交给 h2::client::handshake
得到 SendRequest 和 Connection
spawn Connection
用 SendRequest 发送请求
h2::client::handshake(stream).await? 返回两个东西:
text
SendRequest
Connection
SendRequest 用来发请求。Connection 是一个 Future,必须被 poll,整个 HTTP/2 连接才能继续推进。作者通过 tokio::spawn(conn) 把它放到后台任务里跑。
这很符合 HTTP/2 的模型:连接本身有很多内部状态和异步事件,不是"发一个请求等一个响应"这么简单。它要处理 frame 读写、流控、SETTINGS、WINDOW_UPDATE、stream 状态、错误等。
第一次直接用 h2 时,作者遇到了:
text
frame with invalid size
原因是:虽然连接到 443 并建立了 TLS,但服务器不知道客户端想说 HTTP/2。
这就引出 ALPN。
十三、ALPN:告诉服务器我想说 h2
在 TLS 上使用 HTTP/2,客户端需要通过 ALPN,也就是 Application-Layer Protocol Negotiation,告诉服务器自己支持哪些应用层协议。
如果你不设置 ALPN,服务器可能默认以为你要说 HTTP/1.1。结果客户端把 TLS socket 交给 h2 解析,看到的其实是 HTTP/1.1 响应,于是就解析出"frame size 不合法"。
解决方法是设置:
rust
client_config.alpn_protocols = vec![b"h2".to_vec()];
真实世界里通常会同时提供:
text
h2
http/1.1
这样如果服务器支持 HTTP/2,就选 h2;否则回退到 HTTP/1.1。
明文 HTTP/2 还有另一个路径:h2c,也就是 HTTP/2 cleartext。它可以通过 HTTP/1.1 Upgrade 机制从普通明文连接升级到 HTTP/2。但作者这里走的是 TLS + ALPN。
加上 ALPN 后,HTTP/2 请求成功。
十四、手写 HTTP/2:从 preface 和 frame 开始
接下来作者继续往下,不用 h2 crate,自己写 HTTP/2 frame 读写。
HTTP/2 客户端在 ALPN 协商成功后,需要先发送 connection preface:
rust
pub const PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
然后发送 SETTINGS frame。
HTTP/2 的通信单位是 frame。每个 frame 都有一个 9 字节 header:
text
Length 24 bits
Type 8 bits
Flags 8 bits
R 1 bit reserved
Stream ID 31 bits
Payload Length bytes
frame type 包括:
text
DATA
HEADERS
PRIORITY
RST_STREAM
SETTINGS
PUSH_PROMISE
PING
GOAWAY
WINDOW_UPDATE
CONTINUATION
作者先写一个简单的 Frame:
rust
pub struct Frame {
pub frame_type: FrameType,
pub flags: u8,
pub reserved: u8,
pub stream_id: u32,
pub payload: OpaquePayload,
}
然后用 nom 解析 frame header,用 byteorder 写回 header,用 enum-repr 处理 frame type 的数字表示。
一开始 flags 只是 u8。但很快作者把它改成更强类型:不同 frame type 有不同 flags。比如 SETTINGS 有 ACK,HEADERS 有 END_HEADERS、END_STREAM、PADDED、PRIORITY,DATA 有 END_STREAM、PADDED。
如果只是 u8,你很容易把 HEADERS 的 flag 用到 SETTINGS 上。Rust 可以用 enum + bitflags 表达:
rust
pub enum FrameType {
Data(DataFlags),
Headers(HeadersFlags),
Settings(SettingsFlags),
WindowUpdate,
...
}
这样 FrameType 本身携带对应 flags,错误组合更难出现。
作者说,即使是 throwaway code,他也愿意做这些抽象。因为这样心智负担反而更小:类型系统帮你保证"这个 frame 的 flags 属于这个 frame type"。
十五、SETTINGS、ACK、HEADERS 和 HPACK
HTTP/2 连接建立后,客户端发送 preface 和 SETTINGS。服务器也会返回 SETTINGS。收到对方的 SETTINGS 后,如果不是 ACK,就要发送一个带 ACK flag 的 SETTINGS frame 作为确认。
接着要发请求。HTTP/2 里的请求不再是文本:
http
GET / HTTP/1.1
Host: example.org
而是通过 HEADERS frame 表达。请求头里会有一些 pseudo-header:
text
:method GET
:path /
:scheme https
:authority example.org
这些 header 会被 HPACK 编码。HPACK 是 HTTP/2 的头部压缩格式,有自己的 RFC。作者没有手写 HPACK,而是用了 Rust 的 hpack crate。
发送 HEADERS frame 时,选择 stream ID 1。客户端主动创建的 stream ID 必须是奇数,服务器主动创建的 stream ID 是偶数。第一个客户端请求自然是 stream 1。
因为这次没有请求 body,所以 HEADERS frame 可以带:
text
END_HEADERS
END_STREAM
END_HEADERS 表示 header block 到这里结束,不需要 CONTINUATION frame。END_STREAM 表示客户端这一侧不再发送数据,也就是请求 body 结束。
请求发出去后,服务器返回:
text
SETTINGS
WINDOW_UPDATE
SETTINGS ACK
HEADERS
DATA
HEADERS 里包含响应头,比如 :status: 200、content-type、content-length 等。DATA 里是响应 body。如果 DATA 带 END_STREAM,说明响应 body 结束。
到这里,作者已经用自己写的最小 HTTP/2 frame 层,发出了一个真实的 HTTPS HTTP/2 请求,并读到了真实响应。
十六、HTTP/2 的 header 规则和 body 规则
HTTP/2 里,header 名必须小写。这是正确决定。HTTP/1.x 里 header 名大小写不敏感,导致代理和库之间有各种 normalize 问题。HTTP/2 直接规定:构造 HTTP/2 message 时,field names 必须转成 lowercase。
content-length 仍然存在,但语义更严格。
在 HTTP/2 里,body 是 DATA frames。content-length 如果存在,必须等于所有 DATA frame payload 长度之和。否则 message malformed。
也有例外:某些响应定义上没有内容,比如 204 No Content、304 Not Modified、HEAD 请求的响应。它们可以有非零 content-length header,但实际没有 DATA frame 内容。
HTTP/1.1 的 chunked transfer encoding 在 HTTP/2 里不再需要。因为 DATA frame 本身已经是分块结构。请求或响应 body 就是同一 stream 方向上的多个 DATA frame。
这说明 HTTP/2 不是"把 HTTP/1.1 文本换成二进制"这么简单。它重新定义了消息如何分帧、如何多路复用、如何表达流结束。
十七、stream ID、GOAWAY 和重试语义
HTTP/2 每个请求/响应运行在 stream 上。
客户端发起的 stream ID 是奇数,并且必须递增。也就是说,客户端可以发 stream 1、3、5、7......不能倒退,也不能重用旧 ID。
stream ID 是 31 bit,所以理论上会耗尽。客户端耗尽后可以新开 HTTP/2 连接继续请求。服务端如果想优雅关闭连接,可以发送 GOAWAY frame。
GOAWAY 很有意思。它里面包含 last stream ID,表示服务端最后接受处理的是哪个 stream。
假设服务端视角:
text
收到 stream 1
收到 stream 3
收到 stream 5
决定优雅关闭,发送 GOAWAY last_stream_id = 5
而客户端视角可能是:
text
发送 stream 1
发送 stream 3
发送 stream 5
发送 stream 7
发送 stream 9
发送 stream 11
收到 GOAWAY last_stream_id = 5
客户端就知道:1、3、5 可能被处理;7、9、11 没有被服务端接受,应该在别的连接上重试。
HTTP/1.1 很难做到这一点。如果 TCP 连接在响应 header 前断了,客户端不一定知道服务器有没有处理请求。服务器返回 503 或 429 也许表示可以重试,但这依赖具体后端语义。代理还要区分这个错误是后端生成的,还是代理自己生成的。
HTTP/2 的 GOAWAY 给了更清晰的 out-of-band 连接级信号。
十八、HTTP/2 流控:复杂性真正开始
HTTP/2 最容易出 bug 的地方之一是 flow control。
HTTP/2 有连接级 flow control,也有 stream 级 flow control。双方通过 WINDOW_UPDATE frame 增加对方可发送的窗口。还有 SETTINGS_INITIAL_WINDOW_SIZE,用来设定新 stream 的初始窗口大小。
复杂之处在于:SETTINGS 是异步的。
客户端建立连接后可以立刻发请求,不必等服务器 SETTINGS。但服务器 SETTINGS 可能改变初始窗口大小。于是可能出现这种情况:
text
客户端一开始按默认窗口发了 60KB。
服务器随后 SETTINGS_INITIAL_WINDOW_SIZE = 16KB。
客户端收到后要重新计算窗口。
结果窗口变成 -44KB。
是的,窗口可以变成负数。规范要求发送方必须记录这个负窗口,在收到足够 WINDOW_UPDATE 让窗口变正之前,不能继续发送新的受流控 frame。
更麻烦的是,如果一方发送 SETTINGS 改变窗口,对端在 ACK 之前可能还会继续按旧窗口发送数据。发送 SETTINGS 的一方必须准备好接收比新窗口更多的数据。
如果 SETTINGS 永远不被 ACK,规范允许一定时间后发 SETTINGS_TIMEOUT 连接错误。也可以对某个 stream 发 RST_STREAM,比如 FLOW_CONTROL_ERROR。
HTTP/1.1 里,很多事情可以简单地关闭 TCP 连接。HTTP/2 不一样。多个 stream 共享同一个 TCP 连接。一个 stream 出问题,不一定应该杀掉整个连接。你要在 stream 级别处理错误。
这就是 HTTP/2 强大但复杂的地方。
十九、HTTP/2 的新攻击面
HTTP/2 不只是功能更多,攻击面也更多。
攻击者可以:
text
疯狂发送 SETTINGS frame,让对方不断 ACK。
疯狂发送 PING frame,消耗处理资源。
发送很多 WINDOW_UPDATE,每次 increment 很小,诱导对方生成大量小 DATA frame。
在 HTTP/2 层给大量 flow-control credit,但在 TCP 层不读数据,导致对方构造并缓存大量待发送 frame,造成资源耗尽。
此外,HTTP/2 的 header 使用 HPACK 压缩。压缩本身也可能引入侧信道攻击,例如 CRIME 类攻击:攻击者通过构造输入并观察压缩后密文大小变化,推测 secret header 或 cookie 内容。
这不是说 HTTP/2 不好,而是说协议越强大,状态越复杂,安全边界越多。
作者最后还提到一个实际复现:某些场景下,HTTP/1.1 能完成所有请求,而 HTTP/2 在某些请求数和最大 stream 数组合下会卡住,比如 240 个请求、200 个 stream 时卡在 222/240。作者没有在这篇文章里完全解释原因,只是说明这类问题确实存在,而且 h2spec 这种一致性测试只能作为起点,不足以覆盖所有现实交互。
二十、为什么作者想写自己的 H1/H2 实现
文章后记里,作者说研究这篇文章让他既谦卑,又更想自己实现协议。
他正在写一个新的 Rust H1/H2 实现,有非常明确的目标:
text
只支持 Linux
使用 io_uring 做异步 I/O
使用 rustls 做 TLS 握手,然后尝试 kTLS
仔细控制内存使用,使用固定大小 buffer pool
暴露 H1/H2 连接的精确内部状态
这和 hyper/h2 的目标不同,所以不是竞争关系。
hyper 是 Rust 事实标准 HTTP 实现,API 稳定、通用、生态依赖广。它需要服务大量用户,不能轻易暴露内部状态或大改公共 API。
作者想要的是另一个方向:更强可观测性,更精确状态,更可控内存,更贴近代理场景。
他特别提到 io_uring 会改变异步 I/O 接口的设计。提交一个操作后,buffer 就不再属于应用,而暂时属于内核。所以 API 可能像 tokio-uring 那样:把 owned buffer 传进去,完成后再把 buffer 还回来。
这会导致代码经常写成:
rust
let mut buf = get_a_buf();
let res;
(res, buf) = src.read(buf).await;
res?;
如果使用 Vec<u8> 作为 buffer 类型很直接,但不一定最优。Bytes 很常用,hyper 也大量使用它,但 Bytes 是 Send,内部用 atomic reference counting。作者设想如果每个 tokio-uring runtime 是 current-thread 模式,一个线程处理自己的连接,就可能不需要 Arc 这种原子引用计数,普通引用计数就够。
他还提到 Arc<GlobalState> 模式在高流量 hyper 应用中的真实成本。多线程共享全局状态很方便,但原子操作、mutex、跨线程协调都不是免费的。如果连接处理尽量局部化,很多 mutex 可以变成 RefCell,因为同一线程里只需要运行时借用检查,不需要阻塞或切线程。
这些都是作者实现新协议栈时想探索的问题。
二十一、这篇文章真正想讲什么
表面上,这是一篇 HTTP 速成课。它从 HTTP/1.1 讲到 HTTP/2,从 netcat 讲到 hyper,从 TLS 抓包讲到 h2 frame,从 chunked transfer 讲到 flow control。
但更深层,它讲的是"抽象背后到底藏着多少状态"。
你用 reqwest 发请求,代码很短:
rust
reqwest::get("https://example.org").await?
但背后发生了:
text
DNS 查询
TCP 连接
TLS ClientHello / ServerHello
SNI
证书验证
ALPN
HTTP/1.1 或 HTTP/2 选择
请求 header 编码
body streaming
响应 header
响应 body
连接复用
流控
缓存
压缩
错误处理
如果是 HTTP/2,还要加上:
text
connection preface
SETTINGS
SETTINGS ACK
stream ID
HEADERS
HPACK
DATA
WINDOW_UPDATE
RST_STREAM
GOAWAY
PING
frame flags
stream lifecycle
connection-level state
stream-level state
高层库把这些都隐藏起来,这是好事。否则每个应用开发者都要疯掉。
但当你写 proxy、debug 诡异问题、排查性能、做高流量系统时,你又必须知道下面发生了什么。否则遇到 bug 时,只能看见一个抽象的 hyper::Error 或"请求卡住",不知道状态机在哪里停了。
这就是作者写这篇文章的动机:把 HTTP 从"神秘黑盒"变成"虽然复杂,但可以接近"的系统。
二十二、对工程实践的启发
第一,HTTP/1.1 看起来简单,但代理场景很难。
手写一个客户端和写一个代理不是一回事。代理要面对恶意输入、资源耗尽、规范歧义、后端异常、缓存、压缩和传输编码。不能只按 happy path 理解 HTTP。
第二,body 是流,不是一次性字符串。
无论 HTTP/1.1 还是 HTTP/2,body 都可能分多次到达。你不能假设一次 read 得到完整响应,也不能假设 write 和 read 边界一致。
第三,TLS 不只是"加密开关"。
TLS 会影响抓包、调试、协议协商。SNI 帮服务器知道你访问哪个域名,ALPN 帮双方协商 HTTP/2 或 HTTP/1.1。使用 key log file 可以在调试环境里解密 TLS 流量,理解真实传输内容。
第四,HTTP/2 不是 HTTP/1.1 的二进制皮肤。
它引入 stream multiplexing、frame、HPACK、flow control、GOAWAY、RST_STREAM 等机制。它解决了一些 HTTP/1.1 难题,也引入了新状态和新 bug。
第五,协议实现需要可观测性。
当状态机复杂到一定程度,只有高层错误不够。你需要能 dump 内部状态、看到连接和 stream 的生命周期、知道窗口大小、知道哪些 frame 发出/收到、知道哪个 stream 卡住。
第六,Rust 非常适合写这类协议探索。
Rust 的 enum、bitflags、Result、类型系统、所有权模型,都适合把协议状态建模成难以误用的结构。作者多次强调,即使是 throwaway code,把 frame type 和 flags 做成强类型也能减少心智负担。
二十三、总结
这篇文章从一个代理维护者的痛苦开始。HTTP 在普通使用中很成功,因为我们几乎不用想它。但当你维护 HTTP/S/2 proxy 时,任何问题都会被怀疑是代理导致的。代理处在客户端和后端之间,不能完全信任任何一方,还要在规范、兼容性、安全、性能和资源限制之间做取舍。
文章先从 HTTP/1.1 讲起。HTTP/1.1 本质上是在 TCP 连接上发送文本。请求由 request line、headers、空行和可选 body 构成。每行以 CRLF 结束。Host 是 HTTP/1.1 的重要要求,用于同一个 IP 上托管多个站点。Content-Length 用来标明 body 长度,帮助对端判断 body 是否完整。连接默认可以复用,因此 body 边界必须明确。未知 body 长度时,可以使用 chunked transfer encoding,每个 chunk 前面带十六进制长度,最后以 0 长度 chunk 结束。
然后文章转向代理问题。一个 HTTP/1.1 代理要防无限长 header、过多 header、空闲连接、Slowloris、过大 body 等资源耗尽攻击。它还要决定如何处理非法 header、奇怪的 Content-Length、204 却带 body 的响应、chunked 与非 chunked 的转换、gzip/brotli 压缩、缓存格式和 Accept-Encoding。代理必须在"忠实转发"和"严格校验"之间做选择。HTTP/1.1 很简单,但只是在忽略大部分边界条件时简单。
随后 TLS 登场。作者用 openssl s_client 代替 nc,在 TLS 上手写 HTTP/1.1。接着用 Rust 的 reqwest 发 HTTP/HTTPS 请求,再用 tcpdump 和 Wireshark 抓包。明文 HTTP 可以被 Wireshark 直接解析出 header 和 body;HTTPS 只能看到 TLS ClientHello、ServerHello 和 encrypted Application Data。用 rustls 的 KeyLogFile 配合 Wireshark,可以在调试环境中解密 TLS,看到 TLS 里面真正的 HTTP 明文。
接下来文章从 reqwest 降到 hyper。hyper 让我们更清楚看到 header 和 body 的分离:拿到 response 后,body 是 streaming 的。body 可能一次读完,也可能分多次读到。通过 strace 可以看到这和底层 socket read 对应。hyper 默认 HTTP client 不能直接处理 HTTPS,需要加 hyper-rustls 这样的 TLS connector。这样代码更啰嗦,但控制力更强。
进入 HTTP/2 后,文章先用 hyper 发 HTTP/2 请求,再降到 h2 crate。HTTP/2 仍然跑在 TCP 和 TLS 上,但增加了 stream multiplexing。多个请求可以共享一个 TCP 连接,响应 header 和 body 可以乱序完成。直接使用 h2 时,需要建立 TCP、建立 TLS,然后调用 h2::client::handshake 得到 SendRequest 和 Connection。Connection 必须作为 Future 被 poll,否则连接不会推进。第一次失败的原因是没有通过 ALPN 告诉服务器要说 HTTP/2;加上 client_config.alpn_protocols = vec![b"h2".to_vec()] 后,请求成功。
然后作者继续手写 HTTP/2 frame 层。HTTP/2 客户端先发送 connection preface:PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,然后发送 SETTINGS frame。HTTP/2 frame 有 9 字节 header:24-bit length、8-bit type、8-bit flags、1-bit reserved、31-bit stream id,再跟 payload。frame type 包括 DATA、HEADERS、SETTINGS、PING、GOAWAY、WINDOW_UPDATE 等。作者用 Rust enum 和 bitflags 建模 frame type 和 flags,让不同 frame type 的 flags 不容易混用。即使是实验代码,这种强类型抽象也能减少心智负担。
为了发送请求,需要 HEADERS frame。HTTP/2 请求头包含 pseudo-headers,如 :method、:path、:scheme、:authority。HEADERS payload 使用 HPACK 编码,所以作者引入 hpack crate。客户端选择 stream ID 1,因为客户端发起的 stream 必须是奇数。没有请求 body 时,HEADERS frame 可以同时带 END_HEADERS 和 END_STREAM。服务器返回 SETTINGS、WINDOW_UPDATE、SETTINGS ACK、HEADERS 和 DATA。作者解析 HEADERS,解出 :status: 200 等响应头,再读取 DATA frame 得到 HTML body。
文章随后讲 HTTP/2 的细节:所有 header 名必须小写;content-length 仍然存在,但必须等于所有 DATA frame payload 长度之和,除非消息定义上没有内容,比如 204、304 或 HEAD 响应;HTTP/1.1 的 chunked transfer encoding 在 HTTP/2 中不再需要,因为 DATA frame 本身已经是分块。client initiated stream 使用奇数 ID,且必须递增。stream ID 会耗尽,服务端可以发送 GOAWAY,并用 last stream ID 告诉客户端哪些请求已被接受,哪些应该重试。
最复杂的部分是 HTTP/2 flow control。HTTP/2 有连接级和 stream 级流控,WINDOW_UPDATE 增加窗口,SETTINGS_INITIAL_WINDOW_SIZE 可以改变初始窗口。由于 SETTINGS 是异步的,窗口甚至可能变成负数,发送方必须记录负窗口,直到收到 WINDOW_UPDATE 后恢复。SETTINGS 如果不被 ACK,也可能触发 SETTINGS_TIMEOUT。和 HTTP/1.1 不同,HTTP/2 不能遇到单个 stream 问题就粗暴关闭 TCP,因为同一个连接上还有其他 stream。RST_STREAM、GOAWAY、SETTINGS、WINDOW_UPDATE 这些机制让协议更强大,也让状态机更复杂。
文章最后指出,HTTP/2 还有很多攻击面:滥发 SETTINGS 或 PING、发送极小 WINDOW_UPDATE 诱导对方生成大量小 DATA frame、在 HTTP/2 层提供大量流控额度但在 TCP 层不读数据造成资源耗尽,以及 HPACK 压缩带来的侧信道风险。h2spec 这样的 conformance testing tool 有价值,但只能作为起点,真实系统仍然有大量交互 bug 空间。
后记中,作者说他正在写一个新的 Rust H1/H2 实现:只面向 Linux,使用 io_uring,使用 rustls/kTLS,固定大小 buffer pool,并尽量暴露精确内部状态。这个项目不是要取代 hyper,而是为了不同设计目标:更可控的内存、更清楚的连接状态、更容易追查未知问题。作者认为 Rust 很适合做这类协议实验,因为它能用类型系统把 frame、flags、state 和错误路径建模得更难误用。
整篇文章真正想表达的是:HTTP 之所以好用,是因为大量复杂性被库、协议和基础设施隐藏了。但如果你写代理、排查性能和协议 bug,或者想理解系统真实行为,就必须往下走。HTTP/1.1 不是"几行文本"那么简单,HTTP/2 也不是"二进制版 HTTP/1.1"。它们背后是连接、流、frame、flow control、TLS、ALPN、压缩、缓存、安全和状态机。理解这些东西,会让 HTTP 从神秘黑盒变成一个虽然复杂、但可以靠近和实验的系统。