rfc7540-HTTP2学习:HTTP/2 协议详解(续)—— RFC 7540 深入篇
本文覆盖:流优先级与依赖树、流量控制细节、各帧格式定义、错误处理、HTTP 消息映射
1. 流优先级(Stream Priority)
1.1 为什么需要优先级?
一条 TCP 连接上可能同时跑几十条流:有的是关键 CSS(阻塞渲染),有的是懒加载图片(无关紧要)。优先级机制让客户端告诉服务器:“先把资源给重要的流,次要的流稍等”。
重要:优先级只是建议,服务器可以忽略。它不保证执行顺序,只是影响资源分配倾向。
1.2 依赖树(Dependency Tree)
HTTP/2 用一棵树来表达流与流之间的依赖关系:
- 根节点:虚拟的流 0(stream 0x0),所有流默认都依赖它
- 父流(parent):被依赖的流
- 子流(dependent):依赖父流的流,只有父流处理完(或阻塞)才能分到资源
默认依赖创建
现在新建流 D,默认依赖 A(非独占):
B、C、D 是平级的,顺序不固定。
独占依赖(Exclusive Dependency)
如果流 D 以独占方式依赖 A,则 D 插入 A 和 B、C 之间,B、C 变成 D 的子节点:
1.3 权重(Weight)
同一父节点下的子流按权重按比例分配资源,权重范围 [ 1 , 256 ] [1, 256] [1,256]。
流 B 获得的资源比例 = w B w B + w C \text{流 B 获得的资源比例} = \frac{w_B}{w_B + w_C} 流 B 获得的资源比例=wB+wCwB
例如:B 权重 4,C 权重 12,则:
B 的比例 = 4 4 + 12 = 1 4 , C 的比例 = 12 16 = 3 4 \text{B 的比例} = \frac{4}{4 + 12} = \frac{1}{4}, \quad \text{C 的比例} = \frac{12}{16} = \frac{3}{4} B 的比例=4+124=41,C 的比例=1612=43
即 C 获得的资源是 B 的三倍。
1.4 重优先级(Reprioritization)的复杂示例
RFC 7540 给出了一个较复杂的重排例子,原始树为:
x
|
A
├── B
└── C
├── D
│ └── F
└── E
现在把 A 改成依赖 D(A 成为 D 的子节点)。由于 D 是 A 的后代,先把 D 提升到 A 原来的位置,再把 A 挂到 D 下:
非独占模式(A 和 F 都是 D 的子节点):
x
|
D
├── F
└── A
├── B
└── C
└── E
独占模式(A 成为 D 唯一的子节点,F 变成 A 的子节点):
x
|
D
└── A
├── B
├── C
│ └── E
└── F
1.5 流关闭后的权重继承
当一个流从依赖树中移除时,它的子流不会凭空消失,而是上移成为被移除流的父流的子节点,并按比例重新分配权重。
例如,A 和 B 共享父节点,C 和 D 依赖 A:
- 若 A 被移除,C 和 D 上移,和 B 一起成为 A 原父节点的子节点
- C 和 D 获得的权重 = 原来 A 的权重按 C:D 的权重比例分配
1.6 默认优先级
| 情况 | 父流 | 默认权重 |
|---|---|---|
| 普通新建流 | 流 0(根) | 16 |
| 服务器推送的流 | 关联的请求流 | 16 |
2. 错误处理
HTTP/2 将错误分为两类,处理方式完全不同。
2.1 连接错误(Connection Error)
影响整个连接,所有流都不能继续。
处理步骤:
- 发送
GOAWAY帧(携带出错时最后成功处理的流 ID 和错误码) - 关闭 TCP 连接
2.2 流错误(Stream Error)
只影响单条流,其他流正常继续。
处理方式:向出问题的流发送 RST_STREAM 帧,该流关闭,连接保持。
注意:不能对 RST_STREAM 回复 RST_STREAM,否则会造成无限循环。
2.3 错误码全表
| 错误码 | 值 | 含义 |
|---|---|---|
NO_ERROR |
0x0 | 无错误(如正常关闭连接) |
PROTOCOL_ERROR |
0x1 | 协议违规(通用) |
INTERNAL_ERROR |
0x2 | 端点内部错误 |
FLOW_CONTROL_ERROR |
0x3 | 违反流量控制规则 |
SETTINGS_TIMEOUT |
0x4 | SETTINGS 未及时得到 ACK |
STREAM_CLOSED |
0x5 | 流已关闭后仍收到帧 |
FRAME_SIZE_ERROR |
0x6 | 帧大小不合法 |
REFUSED_STREAM |
0x7 | 流在处理前被拒绝(可安全重试) |
CANCEL |
0x8 | 流不再需要,主动取消 |
COMPRESSION_ERROR |
0x9 | 头部压缩上下文损坏 |
CONNECT_ERROR |
0xa | CONNECT 请求的连接异常 |
ENHANCE_YOUR_CALM |
0xb | 对方行为产生过多负载(限流) |
INADEQUATE_SECURITY |
0xc | TLS 安全性不满足要求 |
HTTP_1_1_REQUIRED |
0xd | 要求使用 HTTP/1.1 |
3. 帧格式详解
3.1 DATA 帧(type=0x0)
承载请求或响应的正文数据。
+---------------+
|Pad Length? (8)| <- 仅当 PADDED 标志置位时存在
+---------------+---------+
| Data (*) | <- 实际数据
+-------------------------+
| Padding (*) | <- 填充字节(全为 0)
+-------------------------+
标志位:
| 标志 | 值 | 含义 |
|---|---|---|
END_STREAM |
0x1 | 这是本流最后一帧(触发半关闭或关闭) |
PADDED |
0x8 | 存在 Pad Length 字段和填充 |
规则:
- DATA 帧必须关联到某条流(stream_id 不能为 0)
- DATA 帧受流量控制约束(包括 Pad Length 和 Padding 字节都计入窗口)
- 只能在流处于
open或half-closed (remote)状态时发送
为什么有 Padding? 填充可以混淆真实数据大小,防止流量分析攻击(侧信道攻击)。
3.2 HEADERS 帧(type=0x1)
最重要的帧,用于开启一条流并携带头部块(HTTP 请求/响应头)。
+---------------+
|Pad Length? (8)|
+-+-------------+---------+
|E| Stream Dependency? | <- 仅当 PRIORITY 标志存在
+-+---+-------------------+
| Weight? (8) | <- 仅当 PRIORITY 标志存在
+-+---+-------------------+
| Header Block Fragment | <- HPACK 压缩后的头部数据
+-------------------------+
| Padding (*) |
+-------------------------+
标志位:
| 标志 | 值 | 含义 |
|---|---|---|
END_STREAM |
0x1 | 流将在此帧(或随后的 CONTINUATION)之后关闭 |
END_HEADERS |
0x4 | 头部块传输完毕,不会跟 CONTINUATION 帧 |
PADDED |
0x8 | 存在填充 |
PRIORITY |
0x20 | 存在优先级字段(E、Stream Dependency、Weight) |
细节:头部字段名必须是小写,含大写字母的请求或响应视为畸形(malformed)。
3.3 PRIORITY 帧(type=0x2)
单独调整某条流的优先级,载荷固定 5 字节:
+-+------------------------------+
|E| Stream Dependency (31 位) |
+-+----------+-------------------+
| Weight (8) |
+------------+
E:1 位,是否独占依赖Stream Dependency:依赖的父流 IDWeight:权重值(存储时减 1,读取时加 1,实际范围 [ 1 , 256 ] [1, 256] [1,256])
可以在流的任何状态下发送,包括 idle 和 closed 状态。
3.4 RST_STREAM 帧(type=0x3)
立即终止一条流,载荷固定 4 字节:
+-------------------------------+
| Error Code (32 位) |
+-------------------------------+
- 不能对 idle 状态的流发 RST_STREAM
- 不能用 RST_STREAM 回复 RST_STREAM(防无限循环)
- 发出后必须准备好接收对方在 RST_STREAM 之前已经发出的帧
3.5 SETTINGS 帧(type=0x4)
协商连接参数,每个参数是一个 6 字节的键值对:
+-------------------------------+
| Identifier (16 位) |
+-------------------------------+-------------------------------+
| Value (32 位) |
+---------------------------------------------------------------+
六个标准参数:
| 参数 | ID | 初始值 | 含义 |
|---|---|---|---|
SETTINGS_HEADER_TABLE_SIZE |
0x1 | 4096 字节 | HPACK 动态表最大大小 |
SETTINGS_ENABLE_PUSH |
0x2 | 1(开启) | 是否允许服务器推送 |
SETTINGS_MAX_CONCURRENT_STREAMS |
0x3 | 无限制 | 对方最多可开多少条并发流 |
SETTINGS_INITIAL_WINDOW_SIZE |
0x4 | 65535 字节 | 新流的初始流量控制窗口 |
SETTINGS_MAX_FRAME_SIZE |
0x5 | 16384 字节( 2 14 2^{14} 214) | 愿意接收的最大帧载荷 |
SETTINGS_MAX_HEADER_LIST_SIZE |
0x6 | 无限制 | 愿意接受的最大头部列表大小 |
SETTINGS 的 ACK 机制:收到 SETTINGS 帧后必须立即回复一个带 ACK 标志(0x1)的空 SETTINGS 帧。如果发送方等待 ACK 超时,可发送 SETTINGS_TIMEOUT 错误。
客户端 服务器
| |
|-- SETTINGS (MAX_STREAMS=100) ---->|
|<-- SETTINGS (ACK) ----------------| <- 必须回复
| |
重要约束:
- SETTINGS 必须在 stream_id=0 上发送(连接级别)
- 载荷长度必须是 6 的倍数(否则 FRAME_SIZE_ERROR)
- 收到 SETTINGS 后必须按顺序处理每个参数,不能跳过
3.6 PUSH_PROMISE 帧(type=0x5)
服务器主动推送前的"预告通知",在已有开放流上发送:
+---------------+
|Pad Length? (8)|
+-+-------------+---------+
|R| Promised Stream ID | <- 31 位,即将被推送的新流 ID
+-+------------------------+
| Header Block Fragment | <- 合成的请求头(如 :path: /style.css)
+-------------------------+
| Padding (*) |
+-------------------------+
Promised Stream ID必须是偶数(服务器发起)且当前处于 idle 状态- 只能在
open或half-closed (remote)的流上发送 - 若客户端设置了
SETTINGS_ENABLE_PUSH=0,服务器不能发此帧 - 客户端可以用
RST_STREAM拒绝接受推送
3.7 PING 帧(type=0x6)
心跳检测,载荷固定 8 字节(任意内容):
+-------------------------------+
| Opaque Data (64 位) |
+-------------------------------+
- 收到没有
ACK标志的 PING,必须回复一个带ACK且载荷完全相同的 PING - 用途:检测连接是否存活;测量 RTT(从发出到收到 ACK 的时间)
- 必须在 stream_id=0 上发送
3.8 GOAWAY 帧(type=0x7)
优雅关闭连接,告知对方最后处理到哪条流:
+-+-----------------------------+
|R| Last-Stream-ID (31 位) |
+-+-----------------------------+
| Error Code (32 位) |
+-------------------------------+
| Additional Debug Data (*) | <- 可选调试信息
+-------------------------------+
两阶段优雅关闭(RFC 7540 推荐的服务器维护方案):
阶段 1:服务器发 GOAWAY,last_stream_id = 2^31-1(最大值)
-> 告知客户端"即将关闭,别发新请求了"
等待一个 RTT(让在途的新请求到达)
阶段 2:服务器发第二个 GOAWAY,last_stream_id = 实际最后处理的流
-> 明确告知哪些流已被处理,哪些需要客户端在新连接上重试
这样可以避免客户端丢失正在发送中的请求。
3.9 WINDOW_UPDATE 帧(type=0x8)
流量控制的核心帧,归还发送方的发送配额:
+-+-----------------------------+
|R| Window Size Increment (31) | <- 增量,范围 [1, 2^31-1]
+-+-----------------------------+
- stream_id=0:更新连接级别窗口
- stream_id=N:更新流 N 的窗口
- 增量为 0 是错误(
PROTOCOL_ERROR) - 窗口累加后不得超过 2 31 − 1 2^{31}-1 231−1,否则
FLOW_CONTROL_ERROR
3.10 CONTINUATION 帧(type=0x9)
头部块的延续帧,载荷只有一个字段:
+-------------------------------+
| Header Block Fragment (*) |
+-------------------------------+
- 只有一个标志:
END_HEADERS(0x4) - 前一帧必须是同一条流上的
HEADERS、PUSH_PROMISE或不带END_HEADERS的CONTINUATION - CONTINUATION 帧中间不能插入其他流的任何帧
4. 流量控制深入
4.1 初始窗口大小与 SETTINGS 的交互
连接建立时,所有流的初始窗口大小为 65535 65535 65535 字节。通过 SETTINGS 可以改变新流的初始窗口,但这会影响已存在的活跃流。
新窗口 = 旧窗口 + ( 新 SETTINGS_INITIAL_WINDOW_SIZE − 旧初始值 ) \text{新窗口} = \text{旧窗口} + (\text{新 SETTINGS\_INITIAL\_WINDOW\_SIZE} - \text{旧初始值}) 新窗口=旧窗口+(新 SETTINGS_INITIAL_WINDOW_SIZE−旧初始值)
例如:客户端连接建立后立即发送了 60 KB 数据,然后服务器发来 SETTINGS_INITIAL_WINDOW_SIZE=16384:
客户端剩余窗口 = 65535 − 60 × 1024 + ( 16384 − 65535 ) = − 44031 字节(负数!) \text{客户端剩余窗口} = 65535 - 60 \times 1024 + (16384 - 65535) = -44031 \text{ 字节(负数!)} 客户端剩余窗口=65535−60×1024+(16384−65535)=−44031 字节(负数!)
窗口变成负数后,客户端必须停止发送,等待 WINDOW_UPDATE 把窗口拉回正数。
4.2 死锁风险
流量控制实现不当时可能发生死锁:
场景:接收方没有及时读取 TCP 缓冲区
客户端发 DATA -> TCP 缓冲区满 -> 发送阻塞
接收方想发 WINDOW_UPDATE -> 但 WINDOW_UPDATE 也被 TCP 阻塞
-> 双方都在等对方 -> 死锁!
解决方案:接收方必须及时从 TCP receive buffer 读数据,不能积压。
4.3 禁用流量控制的方法
想"禁用"流量控制(用于代理、高吞吐场景):将接收窗口设置为最大值 2 31 − 1 2^{31}-1 231−1,并在每次收到数据后立刻发 WINDOW_UPDATE 归还全部额度,这样发送方永远不会被阻塞。
最大窗口大小 = 2 31 − 1 = 2,147,483,647 字节(约 2 GB) \text{最大窗口大小} = 2^{31} - 1 = 2{,}147{,}483{,}647 \text{ 字节(约 2 GB)} 最大窗口大小=231−1=2,147,483,647 字节(约 2 GB)
5. HTTP 消息映射
5.1 请求的帧序列
一个完整的 HTTP/2 请求由以下帧组成:
必须有:
HEADERS [+ CONTINUATION*] <- 包含请求头(:method, :path 等)
可选:
DATA [多个] <- 请求正文(POST/PUT 时)
最后一帧带 END_STREAM 标志
对于 GET 请求(没有正文):
客户端 -> 服务器:
HEADERS (END_HEADERS, END_STREAM) <- 一帧搞定,同时关闭请求端
对于 POST 请求(有正文):
客户端 -> 服务器:
HEADERS (END_HEADERS) <- 只有头部
DATA <- 正文片段 1
DATA (END_STREAM) <- 正文片段 2,且标记结束
5.2 响应的帧序列
服务器 -> 客户端:
[HEADERS (1xx 信息响应)]* <- 可选,0 个或多个 1xx 响应
HEADERS (END_HEADERS) <- 最终响应头(:status 必须存在)
[DATA]* <- 响应正文
[HEADERS (END_HEADERS, END_STREAM)] <- 可选的 Trailer 头
5.3 伪头部字段(Pseudo-Header Fields)
HTTP/2 用以 : 开头的伪头部替代 HTTP/1.1 的请求行和状态行:
请求伪头部(必须在所有普通头部之前):
| 伪头部 | 对应 HTTP/1.1 | 示例 |
|---|---|---|
:method |
请求方法 | GET、POST |
:scheme |
URI 方案 | https、http |
:authority |
Host 头部 | example.com:443 |
:path |
请求路径+查询 | /index.html?q=1 |
响应伪头部:
| 伪头部 | 含义 |
|---|---|
:status |
HTTP 状态码(如 200、404) |
对应关系:
HTTP/1.1 请求行:GET /index.html HTTP/1.1
Host: example.com
HTTP/2 等价:
:method: GET
:scheme: https
:authority: example.com
:path: /index.html
──────────────────────────────────────────
HTTP/1.1 状态行:HTTP/1.1 200 OK
HTTP/2 等价:
:status: 200
HTTP/2 不传输 HTTP 版本号和响应原因短语(如 “OK”、“Not Found”),这些信息被去掉了。
5.4 Cookie 的特殊处理
HTTP/1.1 中 Cookie 是单个头部,所有 cookie 用 ; 拼在一起:
cookie: a=b; c=d; e=f
HTTP/2 允许把它拆开成多个 Cookie 头部(对 HPACK 压缩更友好):
cookie: a=b
cookie: c=d
cookie: e=f
当需要把 HTTP/2 请求转发给 HTTP/1.1 服务器时,必须把多个 Cookie 头用 "; " 重新拼接回来。
5.5 禁止的 HTTP/1.1 头部
HTTP/2 禁止在消息中出现连接相关的头部,包括:
ConnectionKeep-AliveProxy-ConnectionTransfer-Encoding(HTTP/2 不支持 chunked 编码,DATA 帧本身就能分片)Upgrade
唯一例外:TE头部可以存在,但其值只能是trailers。
6. 协议扩展机制
HTTP/2 设计了扩展点,允许在不修改核心协议的情况下添加新功能:
| 扩展点 | 说明 |
|---|---|
| 新帧类型 | 未知帧类型必须忽略并丢弃(不报错) |
| 新 SETTINGS 参数 | 未知参数必须忽略 |
| 新错误码 | 未知错误码等同于 INTERNAL_ERROR |
关键约束:
- 改变现有协议语义的扩展,必须先协商(通过 SETTINGS 或其他机制)才能使用
- 扩展帧不能出现在头部块的中间(HEADERS/CONTINUATION 序列之间),否则是
PROTOCOL_ERROR - 扩展的初始默认值必须是"禁用"状态(opt-in 而非 opt-out)
7. C++ 完整模拟实现
下面的代码模拟了本文涵盖的核心内容:优先级依赖树、GOAWAY 优雅关闭、SETTINGS 参数协商、各种帧的构造与解析。
// http2_advanced.cpp
// 编译:g++ -std=c++17 -o http2_advanced http2_advanced.cpp
// 覆盖:优先级依赖树、SETTINGS协商、GOAWAY优雅关闭、帧格式构造
#include <cstdint>
#include <cstring>
#include <iostream>
#include <iomanip>
#include <map>
#include <vector>
#include <string>
#include <stdexcept>
#include <algorithm>
#include <cassert>
// ─────────────────────────────────────────────
// 帧类型与标志常量
// ─────────────────────────────────────────────
static constexpr uint8_t FRAME_DATA = 0x0;
static constexpr uint8_t FRAME_HEADERS = 0x1;
static constexpr uint8_t FRAME_PRIORITY = 0x2;
static constexpr uint8_t FRAME_RST_STREAM = 0x3;
static constexpr uint8_t FRAME_SETTINGS = 0x4;
static constexpr uint8_t FRAME_PUSH_PROMISE = 0x5;
static constexpr uint8_t FRAME_PING = 0x6;
static constexpr uint8_t FRAME_GOAWAY = 0x7;
static constexpr uint8_t FRAME_WINDOW_UPDATE = 0x8;
static constexpr uint8_t FRAME_CONTINUATION = 0x9;
static constexpr uint8_t FLAG_END_STREAM = 0x1;
static constexpr uint8_t FLAG_END_HEADERS = 0x4;
static constexpr uint8_t FLAG_PADDED = 0x8;
static constexpr uint8_t FLAG_PRIORITY = 0x20;
static constexpr uint8_t FLAG_ACK = 0x1;
// ─────────────────────────────────────────────
// 错误码
// ─────────────────────────────────────────────
static constexpr uint32_t ERR_NO_ERROR = 0x0;
static constexpr uint32_t ERR_PROTOCOL_ERROR = 0x1;
static constexpr uint32_t ERR_FLOW_CONTROL_ERROR = 0x3;
static constexpr uint32_t ERR_SETTINGS_TIMEOUT = 0x4;
static constexpr uint32_t ERR_REFUSED_STREAM = 0x7;
static constexpr uint32_t ERR_CANCEL = 0x8;
// ─────────────────────────────────────────────
// SETTINGS 参数 ID
// ─────────────────────────────────────────────
static constexpr uint16_t SETTINGS_HEADER_TABLE_SIZE = 0x1;
static constexpr uint16_t SETTINGS_ENABLE_PUSH = 0x2;
static constexpr uint16_t SETTINGS_MAX_CONCURRENT_STREAMS = 0x3;
static constexpr uint16_t SETTINGS_INITIAL_WINDOW_SIZE = 0x4;
static constexpr uint16_t SETTINGS_MAX_FRAME_SIZE = 0x5;
static constexpr uint16_t SETTINGS_MAX_HEADER_LIST_SIZE = 0x6;
// ─────────────────────────────────────────────
// HTTP/2 连接参数(SETTINGS)
// ─────────────────────────────────────────────
struct Http2Settings {
uint32_t header_table_size = 4096; // HPACK 动态表大小
bool enable_push = true; // 是否允许服务器推送
uint32_t max_concurrent_streams = UINT32_MAX; // 最大并发流(初始无限制)
int32_t initial_window_size = 65535; // 流的初始窗口
uint32_t max_frame_size = 16384; // 最大帧载荷(2^14)
uint32_t max_header_list_size = UINT32_MAX; // 最大头部列表大小
// 序列化为 SETTINGS 帧的载荷(每个参数 6 字节)
std::vector<uint8_t> serialize() const {
std::vector<uint8_t> buf;
auto push16 = [&](uint16_t v) {
buf.push_back((v >> 8) & 0xFF);
buf.push_back(v & 0xFF);
};
auto push32 = [&](uint32_t v) {
buf.push_back((v >> 24) & 0xFF);
buf.push_back((v >> 16) & 0xFF);
buf.push_back((v >> 8) & 0xFF);
buf.push_back(v & 0xFF);
};
// 序列化每个参数(ID=2字节 + Value=4字节)
push16(SETTINGS_HEADER_TABLE_SIZE); push32(header_table_size);
push16(SETTINGS_ENABLE_PUSH); push32(enable_push ? 1 : 0);
push16(SETTINGS_INITIAL_WINDOW_SIZE); push32(static_cast<uint32_t>(initial_window_size));
push16(SETTINGS_MAX_FRAME_SIZE); push32(max_frame_size);
return buf;
}
void print() const {
std::cout << " HEADER_TABLE_SIZE = " << header_table_size << "\n";
std::cout << " ENABLE_PUSH = " << (enable_push ? "true" : "false") << "\n";
std::cout << " MAX_CONCURRENT_STREAMS = ";
if (max_concurrent_streams == UINT32_MAX) std::cout << "unlimited\n";
else std::cout << max_concurrent_streams << "\n";
std::cout << " INITIAL_WINDOW_SIZE = " << initial_window_size << "\n";
std::cout << " MAX_FRAME_SIZE = " << max_frame_size << "\n";
}
};
// ─────────────────────────────────────────────
// 优先级信息
// ─────────────────────────────────────────────
struct PriorityInfo {
uint32_t parent_stream_id = 0; // 依赖的父流 ID(0 = 依赖根节点)
uint8_t weight = 16; // 权重,范围 [1, 256]
bool exclusive = false; // 是否是独占依赖
};
// ─────────────────────────────────────────────
// 优先级依赖树
// 管理所有流的父子关系和权重
// ─────────────────────────────────────────────
class PriorityTree {
public:
// 节点:存储每条流的优先级信息
struct Node {
uint32_t stream_id;
PriorityInfo priority;
std::vector<uint32_t> children; // 子流 ID 列表
};
// 初始化:根节点是流 0
PriorityTree() {
nodes_[0] = Node{0, PriorityInfo{0, 16, false}, {}};
}
// 添加新流或设置优先级
// exclusive=false:普通依赖(成为 parent 的子节点之一)
// exclusive=true:独占依赖(插入到 parent 和其原有子节点之间)
void set_priority(uint32_t stream_id, uint32_t parent_id,
uint8_t weight, bool exclusive)
{
// 确保父节点存在(可能是 idle 流,此时用默认优先级创建)
if (nodes_.find(parent_id) == nodes_.end()) {
nodes_[parent_id] = Node{parent_id, PriorityInfo{0, 16, false}, {}};
}
// 如果流已存在,先从原父节点摘除
if (nodes_.find(stream_id) != nodes_.end()) {
uint32_t old_parent = nodes_[stream_id].priority.parent_stream_id;
auto& old_siblings = nodes_[old_parent].children;
old_siblings.erase(
std::remove(old_siblings.begin(), old_siblings.end(), stream_id),
old_siblings.end()
);
}
// 创建/更新节点
PriorityInfo info{parent_id, weight, exclusive};
if (nodes_.find(stream_id) == nodes_.end()) {
nodes_[stream_id] = Node{stream_id, info, {}};
} else {
nodes_[stream_id].priority = info;
}
if (exclusive) {
// 独占模式:把 parent 原有的所有子节点转移给 stream_id
auto& parent_children = nodes_[parent_id].children;
for (uint32_t child : parent_children) {
if (child != stream_id) {
nodes_[child].priority.parent_stream_id = stream_id;
nodes_[stream_id].children.push_back(child);
}
}
parent_children.clear();
parent_children.push_back(stream_id);
} else {
// 普通模式:直接加入父节点的子列表
nodes_[parent_id].children.push_back(stream_id);
}
}
// 打印树结构(递归)
void print() const {
std::cout << "\n=== 优先级依赖树 ===\n";
print_node(0, 0);
std::cout << "====================\n\n";
}
private:
std::map<uint32_t, Node> nodes_;
void print_node(uint32_t id, int depth) const {
auto it = nodes_.find(id);
if (it == nodes_.end()) return;
const Node& n = it->second;
// 缩进显示层级
for (int i = 0; i < depth; ++i) std::cout << " ";
if (depth > 0) std::cout << "|-- ";
if (id == 0) {
std::cout << "[Root: stream 0]\n";
} else {
std::cout << "stream " << id
<< " (weight=" << static_cast<int>(n.priority.weight)
<< (n.priority.exclusive ? ", exclusive" : "")
<< ")\n";
}
for (uint32_t child : n.children) {
print_node(child, depth + 1);
}
}
};
// ─────────────────────────────────────────────
// GOAWAY 帧构造器
// ─────────────────────────────────────────────
struct GoAwayFrame {
uint32_t last_stream_id; // 最后处理的流 ID
uint32_t error_code; // 关闭原因
std::string debug_data; // 可选调试信息
// 序列化为字节流(不含帧头)
std::vector<uint8_t> serialize_payload() const {
std::vector<uint8_t> buf;
auto push32 = [&](uint32_t v) {
buf.push_back((v >> 24) & 0xFF);
buf.push_back((v >> 16) & 0xFF);
buf.push_back((v >> 8) & 0xFF);
buf.push_back(v & 0xFF);
};
// Last-Stream-ID:最高位保留为 0
push32(last_stream_id & 0x7FFFFFFF);
push32(error_code);
// 追加调试信息
for (char c : debug_data) buf.push_back(static_cast<uint8_t>(c));
return buf;
}
void print() const {
const char* err_names[] = {
"NO_ERROR", "PROTOCOL_ERROR", "INTERNAL_ERROR",
"FLOW_CONTROL_ERROR", "SETTINGS_TIMEOUT", "STREAM_CLOSED",
"FRAME_SIZE_ERROR", "REFUSED_STREAM", "CANCEL",
"COMPRESSION_ERROR", "CONNECT_ERROR", "ENHANCE_YOUR_CALM",
"INADEQUATE_SECURITY", "HTTP_1_1_REQUIRED"
};
std::cout << "[GOAWAY] last_stream=" << last_stream_id
<< " error=";
if (error_code <= 0xd) std::cout << err_names[error_code];
else std::cout << "0x" << std::hex << error_code << std::dec;
if (!debug_data.empty())
std::cout << " debug=\"" << debug_data << "\"";
std::cout << "\n";
}
};
// ─────────────────────────────────────────────
// PING 帧的 RTT 测量模拟
// ─────────────────────────────────────────────
struct PingFrame {
uint8_t opaque_data[8]; // 8 字节不透明数据
// 构造一个 PING 请求(填入随机标识符)
static PingFrame make_request(uint64_t token) {
PingFrame f;
for (int i = 7; i >= 0; --i) {
f.opaque_data[i] = token & 0xFF;
token >>= 8;
}
return f;
}
// 验证 PING 回复的载荷是否和请求相同
bool matches(const PingFrame& reply) const {
return std::memcmp(opaque_data, reply.opaque_data, 8) == 0;
}
void print(bool is_ack) const {
std::cout << "[" << (is_ack ? "PING ACK" : "PING") << "] data=0x";
for (int i = 0; i < 8; ++i)
std::cout << std::hex << std::setw(2) << std::setfill('0')
<< static_cast<int>(opaque_data[i]);
std::cout << std::dec << std::setfill(' ') << "\n";
}
};
// ─────────────────────────────────────────────
// 伪头部验证器
// 检查 HTTP/2 请求/响应的头部字段是否合法
// ─────────────────────────────────────────────
struct Http2Headers {
std::map<std::string, std::string> fields;
void add(const std::string& name, const std::string& value) {
fields[name] = value;
}
// 验证请求头:必须包含 :method, :scheme, :path
bool validate_request(std::string& error) const {
// 所有头部名必须是小写
for (const auto& [k, v] : fields) {
for (char c : k) {
if (c >= 'A' && c <= 'Z') {
error = "头部字段名包含大写字母:" + k;
return false;
}
}
}
// 必须有三个伪头部(CONNECT 方法除外)
if (fields.count(":method") == 0) {
error = "缺少 :method 伪头部";
return false;
}
auto method = fields.at(":method");
if (method != "CONNECT") {
if (fields.count(":scheme") == 0) {
error = "缺少 :scheme 伪头部";
return false;
}
if (fields.count(":path") == 0) {
error = "缺少 :path 伪头部";
return false;
}
// :path 不能为空(http/https URI)
if (fields.at(":path").empty()) {
error = ":path 不能为空";
return false;
}
}
// 禁止出现连接相关头部
static const std::vector<std::string> forbidden = {
"connection", "keep-alive", "proxy-connection",
"transfer-encoding", "upgrade"
};
for (const auto& f : forbidden) {
if (fields.count(f)) {
error = "禁止使用连接相关头部:" + f;
return false;
}
}
return true;
}
// 验证响应头:必须包含 :status
bool validate_response(std::string& error) const {
if (fields.count(":status") == 0) {
error = "缺少 :status 伪头部";
return false;
}
return true;
}
void print() const {
for (const auto& [k, v] : fields)
std::cout << " " << k << ": " << v << "\n";
}
};
// ─────────────────────────────────────────────
// 主函数:演示各模块
// ─────────────────────────────────────────────
int main() {
std::cout << "=== HTTP/2 高级特性演示 ===\n\n";
// ── 1. SETTINGS 协商 ──────────────────────
std::cout << "【1】SETTINGS 参数协商\n";
Http2Settings client_settings;
client_settings.max_concurrent_streams = 100;
client_settings.initial_window_size = 131072; // 128 KB(提高吞吐)
client_settings.enable_push = false; // 客户端不接受推送
std::cout << "客户端 SETTINGS:\n";
client_settings.print();
auto payload = client_settings.serialize();
std::cout << "序列化载荷大小:" << payload.size() << " 字节\n";
std::cout << "(每个参数 6 字节,共 " << payload.size() / 6 << " 个参数)\n\n";
// ── 2. 优先级依赖树 ────────────────────────
std::cout << "【2】优先级依赖树演示\n";
PriorityTree tree;
// 建立初始树:A(1) 依赖根,B(3) C(5) 依赖 A
tree.set_priority(1, 0, 16, false); // 流1 依赖根,权重16
tree.set_priority(3, 1, 4, false); // 流3 依赖流1,权重4
tree.set_priority(5, 1, 12, false); // 流5 依赖流1,权重12
std::cout << "初始树(B=流3, C=流5 依赖 A=流1):";
tree.print();
// 权重比例计算
std::cout << "流3 和 流5 的资源比例:\n";
double ratio_3 = 4.0 / (4 + 12);
double ratio_5 = 12.0 / (4 + 12);
std::cout << " 流3 获得 " << ratio_3 * 100 << "% ("
<< "1/" << static_cast<int>(1.0/ratio_3) << ")\n";
std::cout << " 流5 获得 " << ratio_5 * 100 << "% ("
<< "3/" << static_cast<int>(1.0/ratio_5) << ")\n\n";
// 新建流7,以独占方式依赖流1
std::cout << "新建流7,以【独占】方式依赖流1:";
tree.set_priority(7, 1, 16, true);
tree.print();
// ── 3. GOAWAY 两阶段优雅关闭 ──────────────
std::cout << "【3】GOAWAY 两阶段优雅关闭\n";
// 阶段1:告知客户端即将关闭,但还不知道具体最后处理哪个流
GoAwayFrame goaway1;
goaway1.last_stream_id = (1u << 31) - 1; // 2^31-1,表示"还没决定"
goaway1.error_code = ERR_NO_ERROR;
goaway1.debug_data = "server maintenance";
std::cout << "阶段1(通知即将关闭):";
goaway1.print();
std::cout << "(等待一个 RTT,让在途请求到达)\n";
// 阶段2:发送实际的最后流 ID
GoAwayFrame goaway2;
goaway2.last_stream_id = 7; // 实际最后处理的流是 7
goaway2.error_code = ERR_NO_ERROR;
goaway2.debug_data = "";
std::cout << "阶段2(确认最后流 ID):";
goaway2.print();
std::cout << "-> 流ID > 7 的请求可以在新连接上安全重试\n\n";
// ── 4. PING RTT 测量 ──────────────────────
std::cout << "【4】PING 心跳检测\n";
PingFrame ping_req = PingFrame::make_request(0xDEADBEEF12345678ULL);
ping_req.print(false);
// 模拟对方回复(载荷必须完全相同)
PingFrame ping_ack = ping_req;
ping_ack.print(true);
if (ping_req.matches(ping_ack)) {
std::cout << "PING/ACK 载荷匹配,RTT 测量有效\n\n";
}
// ── 5. HTTP/2 请求头验证 ──────────────────
std::cout << "【5】HTTP/2 请求头合法性验证\n";
// 合法请求
Http2Headers valid_req;
valid_req.add(":method", "POST");
valid_req.add(":scheme", "https");
valid_req.add(":authority", "api.example.com");
valid_req.add(":path", "/v1/data");
valid_req.add("content-type", "application/json");
valid_req.add("content-length", "128");
std::string err;
std::cout << "合法 POST 请求头:\n";
valid_req.print();
bool ok = valid_req.validate_request(err);
std::cout << "验证结果:" << (ok ? "通过" : "失败:" + err) << "\n\n";
// 非法请求:使用了禁止的头部
Http2Headers invalid_req;
invalid_req.add(":method", "GET");
invalid_req.add(":scheme", "https");
invalid_req.add(":path", "/");
invalid_req.add("connection", "keep-alive"); // 禁止!
std::cout << "非法请求头(含 Connection 头):\n";
invalid_req.print();
ok = invalid_req.validate_request(err);
std::cout << "验证结果:" << (ok ? "通过" : "失败:" + err) << "\n\n";
// 非法请求:缺少 :path
Http2Headers missing_path;
missing_path.add(":method", "GET");
missing_path.add(":scheme", "https");
std::cout << "非法请求头(缺少 :path):\n";
missing_path.print();
ok = missing_path.validate_request(err);
std::cout << "验证结果:" << (ok ? "通过" : "失败:" + err) << "\n\n";
// ── 6. Cookie 拆分与重组 ──────────────────
std::cout << "【6】Cookie 拆分(HTTP/2 中对 HPACK 更友好)\n";
std::string http1_cookie = "a=b; c=d; e=f";
std::cout << "HTTP/1.1 格式:cookie: " << http1_cookie << "\n";
// 拆分成多个独立的 cookie 字段
std::vector<std::string> http2_cookies = {"a=b", "c=d", "e=f"};
std::cout << "HTTP/2 格式:\n";
for (const auto& c : http2_cookies)
std::cout << " cookie: " << c << "\n";
// 重新拼接(转发给 HTTP/1.1 时)
std::string rejoined;
for (size_t i = 0; i < http2_cookies.size(); ++i) {
if (i > 0) rejoined += "; ";
rejoined += http2_cookies[i];
}
std::cout << "重新拼接(转发给 HTTP/1.1):cookie: " << rejoined << "\n";
assert(rejoined == http1_cookie);
std::cout << "拼接结果与原始 HTTP/1.1 格式一致\n\n";
std::cout << "=== 演示完毕 ===\n";
return 0;
}
7.1 预期运行输出
=== HTTP/2 高级特性演示 ===
【1】SETTINGS 参数协商
客户端 SETTINGS:
HEADER_TABLE_SIZE = 4096
ENABLE_PUSH = false
MAX_CONCURRENT_STREAMS = 100
INITIAL_WINDOW_SIZE = 131072
MAX_FRAME_SIZE = 16384
序列化载荷大小:24 字节
(每个参数 6 字节,共 4 个参数)
【2】优先级依赖树演示
初始树(B=流3, C=流5 依赖 A=流1):
=== 优先级依赖树 ===
[Root: stream 0]
|-- stream 1 (weight=16)
|-- stream 3 (weight=4)
|-- stream 5 (weight=12)
====================
流3 和 流5 的资源比例:
流3 获得 25% (1/4)
流5 获得 75% (1/1)
新建流7,以【独占】方式依赖流1:
=== 优先级依赖树 ===
[Root: stream 0]
|-- stream 1 (weight=16)
|-- stream 7 (weight=16, exclusive)
|-- stream 3 (weight=4)
|-- stream 5 (weight=12)
====================
【3】GOAWAY 两阶段优雅关闭
阶段1(通知即将关闭):[GOAWAY] last_stream=2147483647 error=NO_ERROR debug="server maintenance"
(等待一个 RTT,让在途请求到达)
阶段2(确认最后流 ID):[GOAWAY] last_stream=7 error=NO_ERROR
-> 流ID > 7 的请求可以在新连接上安全重试
【4】PING 心跳检测
[PING] data=0xdeadbeef12345678
[PING ACK] data=0xdeadbeef12345678
PING/ACK 载荷匹配,RTT 测量有效
【5】HTTP/2 请求头合法性验证
合法 POST 请求头:
:authority: api.example.com
:method: POST
:path: /v1/data
:scheme: https
content-length: 128
content-type: application/json
验证结果:通过
非法请求头(含 Connection 头):
:method: GET
:path: /
:scheme: https
connection: keep-alive
验证结果:失败:禁止使用连接相关头部:connection
非法请求头(缺少 :path):
:method: GET
:scheme: https
验证结果:失败:缺少 :path 伪头部
【6】Cookie 拆分(HTTP/2 中对 HPACK 更友好)
HTTP/1.1 格式:cookie: a=b; c=d; e=f
HTTP/2 格式:
cookie: a=b
cookie: c=d
cookie: e=f
重新拼接(转发给 HTTP/1.1):cookie: a=b; c=d; e=f
拼接结果与原始 HTTP/1.1 格式一致
=== 演示完毕 ===
8. 总结
8.1 各帧用途速查
8.2 流量控制窗口大小范围
1 ≤ WINDOW_UPDATE 增量 ≤ 2 31 − 1 1 \leq \text{WINDOW\_UPDATE 增量} \leq 2^{31} - 1 1≤WINDOW_UPDATE 增量≤231−1
0 ≤ 窗口当前值 ≤ 2 31 − 1 0 \leq \text{窗口当前值} \leq 2^{31} - 1 0≤窗口当前值≤231−1
窗口超过 2 31 − 1 2^{31}-1 231−1 时必须报 FLOW_CONTROL_ERROR;窗口可以变成负数(当 SETTINGS 改小初始窗口时),此时发送方必须停止发送。
HTTP/2 精髓 = 流 ⏟ 多路复用 + 帧 ⏟ 二进制 + HPACK ⏟ 头部压缩 + 优先级树 ⏟ 资源调度 + GOAWAY ⏟ 优雅关闭 \boxed{ \text{HTTP/2 精髓} = \underbrace{\text{流}}_{\text{多路复用}} + \underbrace{\text{帧}}_{\text{二进制}} + \underbrace{\text{HPACK}}_{\text{头部压缩}} + \underbrace{\text{优先级树}}_{\text{资源调度}} + \underbrace{\text{GOAWAY}}_{\text{优雅关闭}} } HTTP/2 精髓=多路复用
流+二进制
帧+头部压缩
HPACK+资源调度
优先级树+优雅关闭
GOAWAY
HTTP/2 RFC 7540 深度中文学习笔记
涵盖内容:第 56–96 页(第 8–12 章 + 附录 A)
本笔记面向完全零基础读者,所有概念均从零开始解释。
目录
- 整体知识地图
- 第 8 章:HTTP/2 中的请求与响应语义
- 第 9 章:附加 HTTP 要求与注意事项
- 第 10 章:安全考虑
- 第 11 章:IANA 注册
- 附录 A:TLS 1.2 密码套件黑名单
- 综合 C++ 实战代码示例
整体知识地图
第 8 章:HTTP/2 中的请求与响应语义
8.1.2.6 格式错误的请求与响应
什么是"格式错误"?
想象你发了一封信,信封格式完全正确,但里面装的是一张全是乱码的纸,或者漏掉了必须写的收件人姓名。
HTTP/2 里的"格式错误(Malformed)"就是这个意思——帧序列本身合法,但逻辑内容违反规则。
格式错误的情形包括:
情形1:出现了多余的帧(不该有 DATA 帧却有了)
情形2:出现了被明令禁止的头字段
情形3:缺少必须存在的头字段(如 :method、:path 等伪头)
情形4:头字段名中包含大写字母(HTTP/2 要求全小写)
情形5:content-length 的值 ≠ 所有 DATA 帧 payload 长度之和
content-length 校验规则
content-length = ∑ i = 1 n DATA_frame_payload [ i ] \text{content-length} = \sum_{i=1}^{n} \text{DATA\_frame\_payload}[i] content-length=i=1∑nDATA_frame_payload[i]
若二者不等,整个请求/响应被视为格式错误。
唯一例外:某些响应(例如 204 No Content、304 Not Modified)本身就没有消息体,即使 content-length 非零,也不算错误——因为协议规定它们没有 DATA 帧,content-length 此时只是一个元数据标注。
处理规则
中间节点(代理): 不得转发格式错误的报文
检测到格式错误时: 必须当作"流错误"处理,错误码 = PROTOCOL_ERROR
服务器收到格式错误请求时: 可以先发一个 HTTP 响应,再关闭或重置流
客户端收到格式错误响应时: 绝对不能接受(must not accept)
为什么要这么严格?这是为了防御针对 HTTP 解析器的攻击。过于宽松的解析会暴露实现漏洞。
8.1.3 请求/响应示例
这一节用具体例子说明 HTTP/1.1 的报文如何映射到 HTTP/2 的帧序列。
关键概念:伪头字段(Pseudo-Header Fields)
HTTP/2 用以 : 开头的特殊字段来传递 HTTP/1.1 的"请求行"和"状态行"信息:
| 伪头字段 | 对应 HTTP/1.1 含义 | 示例值 |
|---|---|---|
:method |
请求方法 | GET / POST |
:scheme |
URI scheme | http / https |
:path |
请求路径 | /resource |
:authority |
Host 头 | example.org |
:status |
状态码(响应用) | 200 / 304 |
示例一:GET 请求(无消息体)
HTTP/1.1 写法:
GET /resource HTTP/1.1
Host: example.org
Accept: image/jpeg
HTTP/2 帧序列:
HEADERS 帧
标志位: END_STREAM(无后续 DATA 帧)
END_HEADERS(无后续 CONTINUATION 帧)
内容:
:method = GET
:scheme = https
:path = /resource
host = example.org
accept = image/jpeg
为什么 END_STREAM 在 HEADERS 帧上? 因为 GET 没有消息体,流在发完头部之后就结束了,不需要 DATA 帧。
示例二:304 Not Modified 响应(无消息体)
HTTP/1.1 写法:
HTTP/1.1 304 Not Modified
ETag: "xyzzy"
Expires: Thu, 23 Jan ...
HTTP/2 帧序列:
HEADERS 帧
标志位: END_STREAM
END_HEADERS
内容:
:status = 304
etag = "xyzzy"
expires = Thu, 23 Jan ...
示例三:POST 请求(有消息体)
POST /resource HTTP/1.1
Host: example.org
Content-Type: image/jpeg
Content-Length: 123
{binary data}
映射到 HTTP/2:
HEADERS 帧
标志位: -END_STREAM(有后续 DATA)
-END_HEADERS(有后续 CONTINUATION)
内容:
:method = POST
:path = /resource
:scheme = https
CONTINUATION 帧
标志位: +END_HEADERS(头部在此结束)
内容:
content-type = image/jpeg
host = example.org
content-length = 123
DATA 帧
标志位: +END_STREAM(流在此结束)
内容: {binary data}
帧序列流程图
示例四:含 100 Continue + Trailer 的完整交互
这是最复杂的案例。客户端请求包含 Expect: 100-continue,服务器先回一个"先别急"的信号,再回正式响应,最后还有尾部头(Trailer)。
客户端 --> 服务器: HEADERS(请求头,带 Expect: 100-continue)
服务器 --> 客户端: HEADERS(:status=100 Continue,-END_STREAM,+END_HEADERS)
客户端 --> 服务器: DATA(正式消息体,-END_STREAM)
服务器 --> 客户端: HEADERS(:status=200 OK,-END_STREAM,+END_HEADERS)
服务器 --> 客户端: DATA(响应体,-END_STREAM)
服务器 --> 客户端: HEADERS(Trailer 尾部头,+END_STREAM,+END_HEADERS)
例如: foo = bar
Trailer(尾部头) 是什么?它是紧跟在 DATA 帧之后发送的附加头字段,携带 END_STREAM 标志,表示流正式结束。在 HTTP/1.1 的 Chunked 编码中也有这个概念。
8.1.4 请求可靠性机制
问题背景
HTTP/1.1 的一个老难题:当网络连接在发送请求后突然断掉,客户端无法知道服务器是否已经开始处理请求。
对于幂等操作(GET、DELETE)可以重试;但对于非幂等操作(POST——比如"支付"),重试就可能导致"重复支付"!
HTTP/2 提供的两种保障机制
机制一:GOAWAY 帧的 Last-Stream-ID
GOAWAY 帧里包含一个"我处理到了哪个流编号"的字段。
GOAWAY 帧
Last-Stream-ID = N
含义:
流编号 <= N 的请求:可能已处理,不要重试(除非确认)
流编号 > N 的请求:绝对没处理,可以安全重试
流编号示意:
Stream 1 [已处理] --|
Stream 3 [已处理] --| Last-Stream-ID = 5
Stream 5 [已处理] --|
Stream 7 [未处理] --> 安全重试
Stream 9 [未处理] --> 安全重试
机制二:RST_STREAM 帧 + REFUSED_STREAM 错误码
服务器用 RST_STREAM(错误码 = REFUSED_STREAM)重置一个流,意思是:
“这个流我根本没有开始处理,你可以放心重试。”
规则:
- 服务器说"没处理"之前,必须确实没有处理。一旦把帧传给应用层,就不能用 REFUSED_STREAM 了。
- GOAWAY 的 Last-Stream-ID 也必须 >= REFUSED_STREAM 所在的流编号,保持一致性。
PING 帧的辅助作用:PING 可以让客户端探测连接是否还活着,防止"连接已经被中间节点(NAT/负载均衡)悄悄丢弃"的情况。
8.2 服务器推送(Server Push)
什么是服务器推送?
传统 HTTP:浏览器请求 HTML → 解析 HTML 发现需要 CSS/JS/图片 → 再发请求 → 等待……
HTTP/2 服务器推送:服务器发现"你请求了这个 HTML,你肯定也需要这个 CSS",主动把 CSS 推过去,不等你问。
传统流程:
客户端 --[GET /index.html]--> 服务器
客户端 <--[HTML 内容]--------- 服务器
客户端 --[GET /style.css]---> 服务器 (浏览器解析 HTML 后才知道需要)
客户端 <--[CSS 内容]---------- 服务器
推送流程:
客户端 --[GET /index.html]---> 服务器
客户端 <--[PUSH_PROMISE: /style.css]-- 服务器 (提前告知)
客户端 <--[HTML 内容]----------- 服务器
客户端 <--[CSS 内容(推送)]----- 服务器 (不用再请求)
PUSH_PROMISE 帧
服务器推送的"预告",包含:
- 完整的请求头部(假装客户端发了这个请求)
- Promised Stream ID:用于后续传输推送内容的流编号(必须是偶数,因为服务器发起的流用偶数编号)
时序要求:PUSH_PROMISE 必须在引用该资源的 DATA 帧之前发送。
推送的约束条件
推送的"请求"必须满足:
| 约束 | 说明 |
|---|---|
| 可缓存 | 必须是 cacheable 方法(通常是 GET) |
| 安全方法 | 必须是 safe 方法(不改变服务器状态) |
| 无请求体 | 推送不能有 request body |
| 权威性 | :authority 必须是服务器有权威的域名 |
客户端的控制权:
- 可以发送
SETTINGS_ENABLE_PUSH = 0完全禁止推送 - 可以发送
RST_STREAM(CANCEL 或 REFUSED_STREAM)拒绝特定推送 - 可以用
SETTINGS_MAX_CONCURRENT_STREAMS限制并发推送数量
重要规则: - 客户端不能主动推送。如果服务器收到 PUSH_PROMISE 帧,这是连接级别错误(PROTOCOL_ERROR)。
- 推送流的状态变化:
- 服务器发 PUSH_PROMISE → 流进入
reserved (local) - 客户端视角 → 流进入
reserved (remote) - 服务器发送推送内容的 HEADERS → 流变为
half-closed (remote)(服务器视角) - 服务器发 END_STREAM → 流变为
closed - 客户端永远不发 END_STREAM(因为客户端没有推送这个概念)
- 服务器发 PUSH_PROMISE → 流进入
推送内容的缓存
可缓存的推送内容可以由客户端缓存,且被视为已经在源服务器验证过(即使有 no-cache 指令,只要推送流还开着就算有效)。
8.3 CONNECT 方法
CONNECT 的作用
CONNECT 是一个特殊的 HTTP 方法,用来建立隧道(Tunnel)。最典型的用法是:HTTPS 代理。
你的浏览器 ---[CONNECT example.com:443]--> 代理服务器
代理服务器 ---[建立 TCP 连接]----------> example.com:443
代理服务器 <--[TCP 连接成功]-------------- example.com:443
你的浏览器 <--[200 Connection Established]-- 代理服务器
|-- 从此之后,所有数据都通过代理"透传" --|
你的浏览器 ---[TLS ClientHello + 加密 HTTP]-> 代理服务器 --> example.com
代理只是"搬运数据",看不懂里面的 TLS 加密内容。
HTTP/2 中 CONNECT 的帧格式
HTTP/2 中 CONNECT 用单条流承载整个隧道,映射规则:
| 字段 | 值 | 说明 |
|---|---|---|
:method |
CONNECT |
固定值 |
:scheme |
必须省略 | 不同于普通请求 |
:path |
必须省略 | 不同于普通请求 |
:authority |
host:port |
目标服务器地址,如 example.com:443 |
若缺少 :authority 或保留了 :scheme/:path,视为格式错误。
CONNECT 流的数据传输
HEADERS(CONNECT 请求)
↓
DATA 帧(客户端发的数据 = 代理转发给 TCP 服务器)
DATA 帧(代理从 TCP 服务器收到的数据 = 发给客户端)
↓
DATA + END_STREAM(等价于 TCP FIN)
TCP 层与 HTTP/2 帧的对应关系:
| TCP 行为 | HTTP/2 对应 |
|---|---|
| FIN(正常关闭) | DATA 帧 + END_STREAM 标志 |
| RST(异常重置) | RST_STREAM 帧,错误码 CONNECT_ERROR |
| 收到 TCP 的 FIN | 发 DATA + END_STREAM 给对端 |
| 收到 TCP 的 RST | 发 RST_STREAM 给对端 |
注意:CONNECT 流上只允许 DATA 帧和流管理帧(RST_STREAM、WINDOW_UPDATE、PRIORITY)。其他帧类型出现在 CONNECT 流上是错误。
第 9 章:附加 HTTP 要求与注意事项
9.1 连接管理
连接持久化
HTTP/2 的连接是长连接,设计上应该:
- 客户端不在不需要时主动断开
- 服务器尽量保持连接,但空闲时可以关闭
- 关闭前应该先发 GOAWAY 帧,让双方优雅地知道连接要结束了
连接数量限制
客户端对同一个 host:port 应该只维护一条 HTTP/2 连接。可以开多条的例外情况:
- 流 ID 快耗尽时(每条连接最多约 2 31 − 1 2^{31}-1 231−1 条流)
- 需要刷新 TLS 密钥材料
- 连接遇到错误需要替换
用相同的 SNI(Server Name Indication)和 TLS 客户端证书时,不应重复建立连接。
9.1.1 连接复用
同一条连接可以复用给不同的 URI 源(Origin),条件是:
对于 HTTPS URI:
- 服务器证书必须对目标主机名有效(包括通配符证书)
- 例如:证书
*.example.com可以让a.example.com和b.example.com共用一条连接
潜在风险:TLS 终端(中间的反向代理)可能根据 SNI 决定把请求路由给哪台服务器,连接复用可能导致请求被错误路由。
9.1.2 421 Misdirected Request 状态码
当服务器不认为自己对该请求有权威性时,返回 421。
客户端用 a.example.com 的连接发了 b.example.com 的请求
↓
服务器发现自己没有 b.example.com 的权威
↓
服务器返回 421 Misdirected Request
↓
客户端可以新建连接重试(即使是非幂等方法也可以)
421 的特性:
- 默认可缓存
- 不能由代理生成(只能由原始服务器生成)
9.2 TLS 特性要求
HTTP/2 over TLS 必须使用 TLS 1.2 或更高版本。
9.2.1 TLS 1.2 的额外限制
| 限制项 | 要求 | 原因 |
|---|---|---|
| TLS 压缩 | 必须禁用 | 压缩导致 CRIME 类信息泄露攻击 |
| TLS 重协商 | 必须禁用 | 安全漏洞,且可能造成连接长时间不可用 |
| DHE 密钥长度 | 至少 2048 位,客户端必须接受最大 4096 位 | 防止密钥暴力破解 |
| ECDHE 密钥长度 | 至少 224 位 | 防止密钥暴力破解 |
为什么禁止 TLS 压缩?
TLS 压缩与应用层内容共享压缩上下文。攻击者若能控制部分请求内容,可以通过观察压缩后的密文长度来推断秘密数据(CRIME 攻击):
攻击思路(CRIME 攻击):
目标:猜测 Cookie 的值
方法:
构造 "Cookie: session=A..." 并看压缩后长度
构造 "Cookie: session=B..." 并看压缩后长度
若某个猜测与真实 Cookie 重复内容更多,压缩比更高 → 长度更短
反复缩小范围,最终还原 Cookie
为什么禁止重协商?
TLS 重协商允许在已有连接上重新协商安全参数。HTTP/2 中若允许重协商,可以用来针对特定资源触发重新认证,有安全隐患。不过有一个例外:连接刚建立时(连接前言之前)可以通过重协商提交客户端证书。
9.2.2 TLS 1.2 密码套件要求
HTTP/2 必须支持(且优先使用):
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
使用 P-256 椭圆曲线。这条密码套件提供:
- 椭圆曲线 Diffie-Hellman 密钥交换(前向保密)
- RSA 身份验证
- AES-128-GCM 对称加密(认证加密,防篡改)
- SHA-256 消息摘要
附录 A 中有长达数页的黑名单——不推荐使用的密码套件,以下小节会解释原因。
第 10 章:安全考虑
10.1 服务器权威性(Server Authority)
HTTP/2 沿用 HTTP/1.1 的权威性定义:
http://URI:通过 DNS 解析确认 IP 地址属于同一主机https://URI:TLS 证书中的域名必须匹配,且验证通过
这与第 8 章中服务器推送、连接复用等功能密切相关——只有有权威性的服务器才能推送资源。
10.2 跨协议攻击(Cross-Protocol Attacks)
攻击场景:攻击者诱导客户端向某服务器发起 HTTP/2 请求,但该服务器实际上期望的是另一种协议(如 SMTP、FTP 等)。
防御措施:
TLS + ALPN(Application-Layer Protocol Negotiation)是最强防御。ALPN 在 TLS 握手阶段就确定了应用协议,若服务器不支持 HTTP/2,握手就会失败。
明文 HTTP/2(h2c)的防御很弱:连接前言里虽然有一个特殊字符串干扰 HTTP/1.1 解析器,但对其他协议没有保护。
10.3 中间人封装攻击(Intermediary Encapsulation Attacks)
问题:HTTP/2 的头部编码允许表达某些在 HTTP/1.1 中非法的字符。若中间代理把 HTTP/2 请求翻译为 HTTP/1.1,可能产生安全漏洞。
两个具体危险字符:
CR (ASCII 0x0d, \r) 回车
LF (ASCII 0x0a, \n) 换行
NUL (ASCII 0x00) 空字符
若头字段值中包含 \r\n,翻译为 HTTP/1.1 时可能被解释为头部行的分隔符,从而注入额外头字段(头注入攻击)。
规则:任何包含非法字符的请求/响应必须被视为格式错误(PROTOCOL_ERROR)。
10.4 推送响应的缓存安全
多租户场景下的隐患:
情形:
example.com 服务多个租户:tenant_A 和 tenant_B
每个租户控制 URI 空间的一部分
攻击:
tenant_A 通过推送,把 /tenant_B/resource 的内容替换成自己的内容
结果:其他用户缓存了 tenant_A 伪造的内容
防御规则:
服务器必须确保租户只能推送自己名下的资源
非权威性来源的推送不得使用或缓存
10.5 拒绝服务(DoS)考虑
HTTP/2 比 HTTP/1.1 需要维护更多状态,因此更容易遭受 DoS 攻击。
10.5.1 头部块大小限制
问题:头部没有硬性大小上限。超大头部会:
- 强迫端点缓冲整个头块(因为路由关键字段可能在末尾)
- 耗尽内存
应对: SETTINGS_MAX_HEADER_LIST_SIZE:建议对端限制头大小(但只是建议,不是强制)- 服务器可返回
431 Request Header Fields Too Large
潜在的滥用方式
| 攻击手法 | 效果 |
|---|---|
| 频繁发送无意义 SETTINGS 帧变更 | 浪费对端处理资源 |
| 大量小的空 DATA / CONTINUATION 帧 | 浪费帧头解析资源 |
| WINDOW_UPDATE / PRIORITY 帧泛滥 | 无效流量 |
| 大量 PUSH_PROMISE 帧 | 消耗 reserved 状态流的内存;超限可报 ENHANCE_YOUR_CALM |
服务器推送的 DoS 防御:客户端应该限制 reserved (remote) 状态的流数量,超过限制可以用错误码 ENHANCE_YOUR_CALM 处理。
10.5.2 CONNECT 的资源放大
CONNECT 的特殊风险:
建立一条 HTTP/2 流(开销小)
↓
代理需要建立一条 TCP 连接(开销大)
↓
即使 HTTP/2 流关闭,TCP 连接仍处于 TIME_WAIT 状态(内核资源)
所以 SETTINGS_MAX_CONCURRENT_STREAMS 单独不足以限制 CONNECT 的资源消耗,还需要专门的速率限制。
10.6 压缩的安全使用
BREACH 攻击:HTTP 响应压缩的变体 CRIME 攻击。
原理(BREACH / CRIME):
同一个压缩上下文中包含:
① 攻击者控制的明文(如 URL 参数)
② 秘密数据(如 CSRF Token)
两者内容若有重叠,压缩比更高 → 密文更短
观察密文长度 → 推断秘密数据
规则:
- 不得将攻击者可控数据和机密数据放在同一个压缩上下文中
- 来源不确定时不得压缩
- TLS 通用流压缩必须禁用(见 9.2)
10.7 填充(Padding)的安全作用
Padding 是什么? HTTP/2 帧可以在实际数据后面附加随机字节,使帧大小看起来不同。
Padding 的局限性:
好处:让攻击者难以通过帧大小推断数据内容
局限:
① 随机 Padding 但分布可预测 → 保护效果很弱
② 固定大小 Padding → 数据跨越固定边界时仍泄露信息
③ 不如限制/禁用压缩有效
结论:Padding 是辅助手段,不是核心防御
中间代理可以:
- 保留 DATA 帧的 Padding
- 去掉 HEADERS 和 PUSH_PROMISE 帧的 Padding(允许)
- 修改 Padding 大小(允许,若有更好的保护理由)
10.8 隐私考虑
HTTP/2 的某些特性可以被用来对客户端进行指纹识别(Fingerprinting):
| 可观察特征 | 指纹信息 |
|---|---|
| SETTINGS 参数值 | 客户端实现类型/版本 |
| 流控窗口管理方式 | 实现特征 |
| 流优先级分配 | 浏览器类型 |
| 对 PING/SETTINGS 的响应延迟 | 网络特征 |
连接复用的隐私影响:
- 单条 TCP 连接 → 同一网站内的操作可被关联
- 跨域复用 → 不同网站之间的操作可被关联追踪
第 11 章:IANA 注册
IANA(互联网号码分配机构)负责维护 HTTP/2 用到的各种注册表。
11.1 HTTP/2 标识字符串
HTTP/2 用 ALPN 来协商协议:
| 标识字符串 | 十六进制序列 | 用途 |
|---|---|---|
h2 |
0x68 0x32 |
HTTP/2 over TLS |
h2c |
0x68 0x32 0x63 |
HTTP/2 over 明文 TCP |
11.2 帧类型注册表
| 帧类型 | 代码 | 章节 |
|---|---|---|
| DATA | 0x0 | 6.1 |
| HEADERS | 0x1 | 6.2 |
| PRIORITY | 0x2 | 6.3 |
| RST_STREAM | 0x3 | 6.4 |
| SETTINGS | 0x4 | 6.5 |
| PUSH_PROMISE | 0x5 | 6.6 |
| PING | 0x6 | 6.7 |
| GOAWAY | 0x7 | 6.8 |
| WINDOW_UPDATE | 0x8 | 6.9 |
| CONTINUATION | 0x9 | 6.10 |
11.3 SETTINGS 参数注册表
| 名称 | 代码 | 初始值 | 含义 |
|---|---|---|---|
| HEADER_TABLE_SIZE | 0x1 | 4096 | HPACK 头压缩表大小(字节) |
| ENABLE_PUSH | 0x2 | 1 | 是否启用服务器推送(0=禁用) |
| MAX_CONCURRENT_STREAMS | 0x3 | 无限制 | 最大并发流数 |
| INITIAL_WINDOW_SIZE | 0x4 | 65535 | 初始流控窗口大小(字节) |
| MAX_FRAME_SIZE | 0x5 | 16384 | 单帧最大载荷(字节) |
| MAX_HEADER_LIST_SIZE | 0x6 | 无限制 | 头字段列表最大大小(建议值) |
11.4 错误码注册表
| 名称 | 代码 | 含义 |
|---|---|---|
| NO_ERROR | 0x0 | 优雅关闭,无错误 |
| PROTOCOL_ERROR | 0x1 | 协议错误(通用) |
| INTERNAL_ERROR | 0x2 | 实现内部错误 |
| FLOW_CONTROL_ERROR | 0x3 | 违反流量控制限制 |
| SETTINGS_TIMEOUT | 0x4 | SETTINGS ACK 超时 |
| STREAM_CLOSED | 0x5 | 已关闭的流收到帧 |
| FRAME_SIZE_ERROR | 0x6 | 帧大小不合法 |
| REFUSED_STREAM | 0x7 | 流未被处理,可重试 |
| CANCEL | 0x8 | 流被取消 |
| COMPRESSION_ERROR | 0x9 | 头压缩状态不一致 |
| CONNECT_ERROR | 0xa | CONNECT 隧道的 TCP 错误 |
| ENHANCE_YOUR_CALM | 0xb | 对端行为超出处理能力(DoS 防御) |
| INADEQUATE_SECURITY | 0xc | TLS 安全参数不满足要求 |
| HTTP_1_1_REQUIRED | 0xd | 此请求需要用 HTTP/1.1 |
其他注册项
- HTTP2-Settings 头字段:HTTP/1.1 Upgrade 机制使用,携带 SETTINGS 参数的 Base64URL 编码
- PRI 方法:HTTP/2 连接前言使用(
PRI * HTTP/2.0),防止被 HTTP/1.1 解析器当作正常请求 - 421 状态码:Misdirected Request
附录 A:TLS 1.2 密码套件黑名单
为什么要有黑名单?
不是所有密码套件都安全。以下类别的密码套件应该避免与 HTTP/2 一起使用:
类别一:无加密
TLS_NULL_WITH_NULL_NULL -- 完全没有加密
TLS_*_WITH_NULL_* -- 加密算法为 NULL
类别二:RC4 流密码
TLS_*_WITH_RC4_*
原因:RC4 已知有多个严重漏洞(BEAST、RC4 偏差攻击)
类别三:弱出口级密钥
TLS_*_EXPORT_*
原因:这些套件在 1990 年代为了满足美国出口限制而人为削弱密钥长度(40/56 位),现代计算机可以暴力破解
类别四:DES/3DES
TLS_*_WITH_DES_*
TLS_*_WITH_3DES_*
原因:DES 密钥只有 56 位,早已被破解;3DES 有 Sweet32 攻击(生日攻击)
类别五:CBC 模式而非 GCM
TLS_*_WITH_AES_*_CBC_*
原因:CBC 模式容易受到 BEAST、POODLE 等填充预言攻击
GCM(Galois/Counter Mode)是认证加密,更安全
类别六:无前向保密(Non-PFS)
TLS_RSA_WITH_*(静态 RSA 密钥交换)
TLS_DH_*(非 DHE,即静态 DH)
原因:无前向保密,历史流量在私钥泄露后可被解密
推荐的安全密码套件(白名单典范):
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
ECDHE: 椭圆曲线 Diffie-Hellman(前向保密)
RSA: 身份验证
AES-128-GCM: 认证加密
SHA256: 消息摘要
密钥交换算法比较:
综合 C++ 实战代码示例
以下代码模拟 HTTP/2 帧序列的验证逻辑,展示本章多个核心概念的实现思路。
/*
* http2_concepts.cpp
*
* 模拟 HTTP/2 的以下概念(从零实现简化版本):
* 1. 帧类型和错误码定义(第 11 章注册表)
* 2. 格式错误检测(8.1.2.6)
* 3. 服务器推送约束检查(8.2)
* 4. GOAWAY 可重试流判断(8.1.4)
* 5. TLS 密码套件安全检查(9.2.2)
*
* 编译:g++ -std=c++17 http2_concepts.cpp -o http2_concepts
*/
#include <cstdint>
#include <iostream>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
// ============================================================
// 第 11 章:帧类型枚举(来自 IANA 帧类型注册表)
// ============================================================
enum class FrameType : uint8_t {
DATA = 0x0, // 数据帧:承载 HTTP 消息体
HEADERS = 0x1, // 头部帧:承载 HTTP 头字段
PRIORITY = 0x2, // 优先级帧
RST_STREAM = 0x3, // 重置流帧
SETTINGS = 0x4, // 设置帧
PUSH_PROMISE = 0x5, // 推送预告帧
PING = 0x6, // 心跳帧
GOAWAY = 0x7, // 离开帧
WINDOW_UPDATE = 0x8, // 流量控制更新帧
CONTINUATION = 0x9, // 头部续传帧
};
// ============================================================
// 第 11 章:错误码枚举(来自 IANA 错误码注册表)
// ============================================================
enum class Http2ErrorCode : uint32_t {
NO_ERROR = 0x0, // 无错误(优雅关闭时使用)
PROTOCOL_ERROR = 0x1, // 协议错误(格式错误时使用)
INTERNAL_ERROR = 0x2, // 内部实现错误
FLOW_CONTROL_ERROR = 0x3, // 流量控制违规
SETTINGS_TIMEOUT = 0x4, // SETTINGS ACK 超时
STREAM_CLOSED = 0x5, // 向已关闭的流发送帧
FRAME_SIZE_ERROR = 0x6, // 帧大小错误
REFUSED_STREAM = 0x7, // 流未被处理(可安全重试)
CANCEL = 0x8, // 流被取消
COMPRESSION_ERROR = 0x9, // 头压缩状态不一致
CONNECT_ERROR = 0xa, // CONNECT 方法 TCP 错误
ENHANCE_YOUR_CALM = 0xb, // 对端行为超出容忍(DoS 防御)
INADEQUATE_SECURITY = 0xc, // TLS 安全参数不足
HTTP_1_1_REQUIRED = 0xd, // 此请求需要 HTTP/1.1
};
// ============================================================
// 简化的 HTTP/2 头字段结构
// ============================================================
struct HeaderField {
std::string name; // 字段名,HTTP/2 要求全小写
std::string value; // 字段值
HeaderField(std::string n, std::string v)
: name(std::move(n)), value(std::move(v)) {}
};
// ============================================================
// 8.1.2.6:格式错误检测器
// ============================================================
class MalformedRequestDetector {
public:
// 必须存在的伪头字段(对于普通请求)
// 这些是 HTTP/2 的"请求行"等价物
static const std::set<std::string> kRequiredPseudoHeaders;
// 检查头字段名是否合法
// HTTP/2 规定:头字段名必须全部小写(大写是格式错误)
static bool has_uppercase_header_name(const std::vector<HeaderField>& headers) {
for (const auto& h : headers) {
// 跳过伪头字段(以 : 开头的)
if (!h.name.empty() && h.name[0] == ':') {
continue;
}
for (char c : h.name) {
if (c >= 'A' && c <= 'Z') {
return true; // 发现大写字母,格式错误
}
}
}
return false;
}
// 检查是否缺少必要的伪头字段
static bool missing_required_pseudo_headers(const std::vector<HeaderField>& headers) {
std::set<std::string> found;
for (const auto& h : headers) {
if (!h.name.empty() && h.name[0] == ':') {
found.insert(h.name);
}
}
// 检查所有必须存在的伪头是否都找到了
for (const auto& required : kRequiredPseudoHeaders) {
if (found.find(required) == found.end()) {
return true; // 缺少必要伪头字段
}
}
return false;
}
// 检查头字段值中是否包含非法字符(\r \n \0)
// 防御中间人封装攻击(10.3)
static bool has_illegal_header_value_chars(const std::vector<HeaderField>& headers) {
for (const auto& h : headers) {
for (char c : h.value) {
// CR(0x0d)、LF(0x0a)、NUL(0x00) 是非法字符
if (c == '\r' || c == '\n' || c == '\0') {
return true;
}
}
}
return false;
}
// 综合格式错误检查
// 返回错误描述,若无错误返回空字符串
static std::string check(
const std::vector<HeaderField>& headers,
int64_t content_length_value, // -1 表示没有 content-length 头
int64_t actual_data_length // 实际收到的 DATA 帧数据总长
) {
// 检查1:大写字母
if (has_uppercase_header_name(headers)) {
return "格式错误:头字段名含大写字母 (PROTOCOL_ERROR)";
}
// 检查2:缺少必要伪头
if (missing_required_pseudo_headers(headers)) {
return "格式错误:缺少必要的伪头字段 (PROTOCOL_ERROR)";
}
// 检查3:非法字符
if (has_illegal_header_value_chars(headers)) {
return "格式错误:头字段值含非法字符 CR/LF/NUL (PROTOCOL_ERROR)";
}
// 检查4:content-length 与实际数据长度不匹配
// 公式: content-length == sum(DATA_frame_payload[i])
if (content_length_value >= 0 && content_length_value != actual_data_length) {
return "格式错误:content-length (" +
std::to_string(content_length_value) +
") 与实际数据长度 (" +
std::to_string(actual_data_length) +
") 不符 (PROTOCOL_ERROR)";
}
return ""; // 没有格式错误
}
};
// 普通请求(非 CONNECT)必须包含这三个伪头
const std::set<std::string> MalformedRequestDetector::kRequiredPseudoHeaders = {
":method",
":scheme",
":path"
};
// ============================================================
// 8.2:服务器推送约束检查器
// ============================================================
class ServerPushValidator {
public:
// 推送的"虚拟请求"必须满足的条件
// 对应 RFC 7540 8.2 节的规则
// 检查1:方法必须是安全且可缓存的
// 安全方法:不改变服务器状态(GET、HEAD、OPTIONS)
// 可缓存方法:GET、HEAD(POST 通常不可缓存)
static bool is_safe_and_cacheable_method(const std::string& method) {
// HTTP/2 Server Push 只允许安全且可缓存的方法
// 实践中几乎都是 GET
return (method == "GET" || method == "HEAD");
}
// 检查2:没有请求体
// 有请求体的资源不能被推送(无法确定用什么请求体)
static bool has_no_request_body(const std::vector<HeaderField>& headers) {
for (const auto& h : headers) {
if (h.name == "content-length") {
// 如果有 content-length,必须为 0
try {
return std::stoi(h.value) == 0;
} catch (...) {
return false;
}
}
}
return true; // 没有 content-length 头,意味着没有请求体
}
// 检查3::authority 必须存在(服务器权威性)
static bool has_authority(const std::vector<HeaderField>& headers) {
for (const auto& h : headers) {
if (h.name == ":authority" && !h.value.empty()) {
return true;
}
}
return false;
}
// 综合验证推送请求是否合法
static std::string validate_push_request(const std::vector<HeaderField>& headers) {
// 找出 :method 的值
std::string method;
for (const auto& h : headers) {
if (h.name == ":method") {
method = h.value;
}
}
if (!is_safe_and_cacheable_method(method)) {
return "推送错误:方法 '" + method + "' 不是安全/可缓存方法 (PROTOCOL_ERROR)";
}
if (!has_no_request_body(headers)) {
return "推送错误:推送请求不能有请求体 (PROTOCOL_ERROR)";
}
if (!has_authority(headers)) {
return "推送错误:缺少 :authority 伪头 (PROTOCOL_ERROR)";
}
return ""; // 推送请求合法
}
};
// ============================================================
// 8.1.4:基于 GOAWAY 的可重试流判断
// ============================================================
class RetryabilityChecker {
public:
// 判断某条流的请求在收到 GOAWAY 后是否可以安全重试
//
// 参数:
// stream_id - 要检查的流 ID
// goaway_last_id - GOAWAY 帧中的 Last-Stream-ID
//
// 规则:
// 流 ID > Last-Stream-ID → 服务器保证没处理 → 可以重试
// 流 ID <= Last-Stream-ID → 可能已处理 → 不确定,不能自动重试
static bool is_safe_to_retry_after_goaway(uint32_t stream_id,
uint32_t goaway_last_id) {
// 大于 Last-Stream-ID 的流:服务器保证没有处理
return stream_id > goaway_last_id;
}
// 判断 REFUSED_STREAM 的流是否可以重试
// REFUSED_STREAM 表示服务器明确承诺"我没有处理这个流"
// 所以永远可以重试(包括非幂等方法如 POST)
static bool is_safe_to_retry_refused_stream() {
// REFUSED_STREAM 意味着流从未被处理 → 总是安全重试
return true;
}
};
// ============================================================
// 9.2.2:TLS 密码套件安全性检查(黑名单检查)
// ============================================================
class TlsCipherSuiteChecker {
public:
// 不安全密码套件黑名单(精选示例,真实黑名单见附录 A)
// 这里只列举几个典型的黑名单成员用于演示
static const std::set<std::string> kBlacklist;
// HTTP/2 必须支持的最低安全密码套件
static const std::string kRequiredCipherSuite;
// 检查某个密码套件是否在黑名单中
static bool is_blacklisted(const std::string& cipher_suite) {
return kBlacklist.find(cipher_suite) != kBlacklist.end();
}
// 检查协商结果是否满足 HTTP/2 最低安全要求
static std::string evaluate_negotiated_cipher(const std::string& cipher_suite) {
if (is_blacklisted(cipher_suite)) {
return "警告:密码套件 '" + cipher_suite +
"' 在黑名单中,可能引发 INADEQUATE_SECURITY 错误";
}
// 检查是否是推荐的安全套件
if (cipher_suite == kRequiredCipherSuite) {
return "密码套件安全:使用了推荐的 ECDHE + AES-GCM + SHA256";
}
// 不在黑名单,也不是标准推荐套件
return "密码套件可接受:不在黑名单中,但建议使用 " + kRequiredCipherSuite;
}
};
// 精选典型黑名单条目(附录 A 有完整列表)
const std::set<std::string> TlsCipherSuiteChecker::kBlacklist = {
"TLS_NULL_WITH_NULL_NULL", // 无任何加密
"TLS_RSA_WITH_RC4_128_SHA", // RC4 已被攻破
"TLS_RSA_WITH_DES_CBC_SHA", // DES 56 位,可暴力破解
"TLS_RSA_WITH_3DES_EDE_CBC_SHA", // 3DES 有 Sweet32 攻击
"TLS_RSA_WITH_AES_128_CBC_SHA", // 无前向保密(静态 RSA)
"TLS_RSA_WITH_AES_128_CBC_SHA256", // 无前向保密(静态 RSA)
"TLS_RSA_WITH_AES_128_GCM_SHA256", // GCM 好但无前向保密
};
// HTTP/2 必须支持的密码套件(9.2.2 节要求)
const std::string TlsCipherSuiteChecker::kRequiredCipherSuite =
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256";
// ============================================================
// 演示主程序
// ============================================================
int main() {
std::cout << "========================================\n";
std::cout << "HTTP/2 RFC 7540 概念演示程序\n";
std::cout << "========================================\n\n";
// --- 演示1:格式错误检测 ---
std::cout << "[演示1] 格式错误检测(8.1.2.6)\n";
std::cout << "------------------------------\n";
// 测试案例A:正常请求
{
std::vector<HeaderField> normal_headers = {
{":method", "GET"},
{":scheme", "https"},
{":path", "/resource"},
{"host", "example.org"}, // 全小写,正确
{"accept", "image/jpeg"},
};
auto err = MalformedRequestDetector::check(normal_headers, -1, 0);
std::cout << "正常 GET 请求: "
<< (err.empty() ? "格式正确" : err) << "\n";
}
// 测试案例B:头字段名含大写
{
std::vector<HeaderField> bad_headers = {
{":method", "GET"},
{":scheme", "https"},
{":path", "/resource"},
{"Host", "example.org"}, // 大写 H,HTTP/2 中非法
};
auto err = MalformedRequestDetector::check(bad_headers, -1, 0);
std::cout << "大写头字段名: "
<< (err.empty() ? "格式正确" : err) << "\n";
}
// 测试案例C:content-length 不匹配
{
std::vector<HeaderField> post_headers = {
{":method", "POST"},
{":scheme", "https"},
{":path", "/upload"},
{"content-length", "100"}, // 声称 100 字节
};
// 但实际只收到了 80 字节的 DATA 帧数据
auto err = MalformedRequestDetector::check(post_headers, 100, 80);
std::cout << "content-length 不匹配: "
<< (err.empty() ? "格式正确" : err) << "\n";
}
// 测试案例D:头字段值含 \r\n(注入攻击)
{
std::vector<HeaderField> inject_headers = {
{":method", "GET"},
{":scheme", "https"},
{":path", "/"},
{"custom", "value\r\nX-Injected: evil"}, // 注入攻击
};
auto err = MalformedRequestDetector::check(inject_headers, -1, 0);
std::cout << "头注入攻击检测: "
<< (err.empty() ? "格式正确" : err) << "\n";
}
std::cout << "\n";
// --- 演示2:服务器推送验证 ---
std::cout << "[演示2] 服务器推送约束检查(8.2)\n";
std::cout << "------------------------------\n";
// 测试A:合法推送(GET 方法,无请求体,有 authority)
{
std::vector<HeaderField> push_ok = {
{":method", "GET"},
{":scheme", "https"},
{":path", "/style.css"},
{":authority", "example.com"},
};
auto err = ServerPushValidator::validate_push_request(push_ok);
std::cout << "合法推送请求: "
<< (err.empty() ? "推送请求合法" : err) << "\n";
}
// 测试B:POST 推送(非法:POST 不安全且通常不可缓存)
{
std::vector<HeaderField> push_bad = {
{":method", "POST"},
{":scheme", "https"},
{":path", "/api/data"},
{":authority", "example.com"},
};
auto err = ServerPushValidator::validate_push_request(push_bad);
std::cout << "非法推送(POST 方法): "
<< (err.empty() ? "推送请求合法" : err) << "\n";
}
std::cout << "\n";
// --- 演示3:GOAWAY 可重试判断 ---
std::cout << "[演示3] GOAWAY 可重试流判断(8.1.4)\n";
std::cout << "------------------------------\n";
{
uint32_t last_id = 5; // 服务器处理到了流 5
std::cout << "GOAWAY Last-Stream-ID = " << last_id << "\n";
for (uint32_t id : {1u, 3u, 5u, 7u, 9u}) {
bool safe = RetryabilityChecker::is_safe_to_retry_after_goaway(id, last_id);
std::cout << " Stream " << id << ": "
<< (safe ? "可以安全重试" : "不确定是否已处理,不应自动重试")
<< "\n";
}
}
std::cout << "\n";
// --- 演示4:TLS 密码套件安全性检查 ---
std::cout << "[演示4] TLS 密码套件黑名单检查(9.2.2 + 附录A)\n";
std::cout << "------------------------------\n";
std::vector<std::string> test_suites = {
"TLS_NULL_WITH_NULL_NULL", // 极度不安全
"TLS_RSA_WITH_RC4_128_SHA", // RC4 黑名单
"TLS_RSA_WITH_AES_128_CBC_SHA", // 黑名单(无前向保密)
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",// 推荐!
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",// 可接受(不在黑名单)
};
for (const auto& suite : test_suites) {
auto result = TlsCipherSuiteChecker::evaluate_negotiated_cipher(suite);
std::cout << " [" << suite.substr(0, 30) << "...]\n"
<< " -> " << result << "\n";
}
std::cout << "\n========================================\n";
std::cout << "演示完毕\n";
std::cout << "========================================\n";
return 0;
}
知识总结脑图
核心规则速查表
| 主题 | 关键规则 |
|---|---|
| 头字段名大小写 | HTTP/2 必须全小写;大写 = 格式错误 = PROTOCOL_ERROR |
| content-length 校验 | 必须等于所有 DATA 帧长度之和;否则 = 格式错误 |
| 服务器推送方法 | 只能 GET/HEAD(安全且可缓存) |
| 客户端推送 | 绝对禁止(服务器收到 PUSH_PROMISE = 连接级错误) |
| CONNECT 伪头 | 只有 :method=CONNECT 和 :authority;省略 :scheme :path |
| TLS 版本 | 最低 TLS 1.2 |
| TLS 压缩 | 必须禁用(防 CRIME) |
| TLS 重协商 | 必须禁用(连接前言前除外) |
| 必须支持的密码套件 | TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 |
| 头字段值非法字符 | CR/LF/NUL 是非法字符(防注入攻击) |
| GOAWAY 可重试 | 流 ID > Last-Stream-ID 才可重试 |
| REFUSED_STREAM | 任何方法(包括 POST)都可重试 |
| 连接数 | SHOULD 对同一 host:port 只维护一条连接 |
| Padding | 辅助防御,不能替代限制压缩 |
DNS RFC 1034 深度中文学习笔记
域名系统——概念与设施(Domain Names: Concepts and Facilities)
面向零基础读者,所有概念从零解释,配合图示、代码与类比。
目录
整体知识地图
第1章 备忘录状态
RFC 1034 是 DNS 的概念性介绍文档,详细的实现规范在其配套文档 RFC-1035 中。
本 RFC 废弃了早期的 RFC-882、RFC-883 和 RFC-973。
DNS 是官方协议的一个子集,同时保留了扩展性——研究者可以不断提出新的数据类型和查询类型实验。读者需注意:文档中的示例数值仅用于教学,不代表真实的当前数据。
第2章 背景与设计目标
2.1 域名系统的历史由来
旧时代的痛苦:HOSTS.TXT
在 DNS 出现之前,整个互联网的主机名与 IP 地址的对应关系,全部写在一个叫 HOSTS.TXT 的文件里,存放于 NIC(网络信息中心)。
每台主机想知道 example.com 是哪个 IP,就得下载这个文件。
问题非常严重:
带宽消耗问题:设有 N N N 台主机,每次 NIC 更新文件,所有主机都要来下载,总流量正比于 N 2 N^2 N2。当主机数量爆炸性增长时,NIC 的出口带宽完全撑不住。
总下载流量 ∝ N 2 \text{总下载流量} \propto N^2 总下载流量∝N2
管理中心化问题:本地组织想改个主机名,必须通知 NIC,等 NIC 更新 HOSTS.TXT 后,全网才能看到变化,周期可能是数天。
结构缺失问题:HOSTS.TXT 是平铺结构,没有层次,无法表达"这台机器属于某组织某部门"这样的关系。
DNS 的解决思路
一句话总结:把一个集中式的大文件,变成一棵分布式的树,让树的每个分支由对应的组织自己维护。
HOSTS.TXT(旧): DNS(新):
┌──────────────────┐ 分布式树形空间
│ host1 = 1.2.3.4 │ --> .(根)
│ host2 = 5.6.7.8 │ ├── edu
│ host3 = 9.0.0.1 │ │ └── mit
│ ...(数千行) │ └── com
└──────────────────┘ └── example
所有人都要下这个文件 每层由对应组织管理
2.2 DNS 设计目标
RFC 明确列出了七条设计目标,逐一理解:
目标1:一致的命名空间
名字不应该嵌入网络地址或路由信息。名字就是名字,与网络拓扑无关。
(旧做法:有时用 IP 地址的一部分来命名主机,这很脆弱)
目标2:分布式维护
数据库太大,且更新频繁,不能由一个中心节点维护。必须分散到各组织,配合本地缓存提升性能。
目标3:数据控制权归源头
谁的数据谁做主。数据的拥有者决定缓存时效(TTL)、更新频率等。
目标4:通用性,支持多种应用
DNS 不只服务于 HTTP,还要支持邮件(MX 记录)、主机查找、任意未来应用。所有数据按**类型(type)打标签,查询可以精确到某类型。
目标5:支持不同协议族
同一个名字可能在不同协议体系里有不同格式的地址。DNS 用类(class)**字段来区分(例如 IN 表示 Internet,CH 表示 Chaos 网络)。
目标6:独立于传输层
DNS 消息可以走 UDP(轻量快速),也可以走 TCP(可靠,适合大数据或区域传输)。
目标7:适配各种主机能力
个人 PC 和大型分时主机都能使用 DNS,只是方式不同(PC 可能使用存根解析器)。
2.3 使用假设
DNS 的设计基于以下几条现实假设:
数据量增长模型:
- 初期:数据量与主机数成正比
- 未来:随着邮箱等信息加入,将与用户数成正比
数据变化频率:大部分数据(主机地址、邮箱绑定)变化很慢,但系统需支持少量快速变化的数据(最快可到秒/分钟级)。
管理边界:通常与组织结构对应。每个组织的域必须有至少两台冗余名称服务器。
可信服务器:客户端可以指定信任的服务器,不强制接受外部引用。
最终一致性:DNS 不保证所有副本同时更新,而是通过 TTL 控制副本的有效期,采用"分发+超时刷新"模型。
查询方式: - 递归查询(Recursive):服务器代替客户端追问到底,最终返回答案。
- 迭代查询(Iterative):服务器告诉客户端"去问别人",客户端自己去追。
RFC 规定:迭代查询必须实现,递归查询是可选功能。
2.4 DNS 的三大组成部分
| 组件 | 职责 | 类比 |
|---|---|---|
| 域名空间 + 资源记录 | 定义树形命名结构和数据格式 | 图书馆的目录分类体系 |
| 名称服务器(Name Server) | 存储并回答关于域名树的查询 | 图书馆的馆员 |
| 解析器(Resolver) | 代表用户向名称服务器发起查询 | 帮你找书的助理 |
三个视角的 DNS:
- 用户视角:整个 DNS 是一棵单一的大树,通过本地解析器访问任何节点。
- 解析器视角:DNS 由无数台名称服务器组成,每台持有部分数据,数据对解析器来说是"基本静态"的。
- 名称服务器视角:DNS 由一系列**区域(Zone)**构成,服务器管理自己负责的区域,定期从主服务器刷新数据,同时并发处理查询请求。
第3章 域名空间与资源记录
3.1 名称空间规范与术语
树形结构
域名空间是一棵有根的树(Rooted Tree)。每个节点(含叶子节点)可以挂载一组资源记录(RR)。
.(根,空标签)
|
┌───────────┼───────────┐
MIL EDU ARPA
| | |
┌────┴────┐ | ┌────┴────┐
BRL NOSC DARPA MIT IN-ADDR SRI-NIC
| |
| ┌─┴──┐
| LCS ACHILLES
ISI |
| XX
┌────┴────┐
A C VAXA VENERA
标签(Label)规则
每个节点有一个标签(Label):
- 长度:0 到 63 个八位字节(octet)
- 同一父节点下的兄弟节点,标签不能相同(大小写不敏感)
- 根节点的标签是空字符串(长度为 0)
域名的构成
从某节点沿路径向上到根,依次拼接各标签,用 . 分隔,即构成该节点的域名(Domain Name)。
例如:节点 XX,路径为 XX → LCS → MIT → EDU → 根,域名为 XX.LCS.MIT.EDU.(末尾的 . 表示根)。
内部表示(程序内部使用):
域名 "www.example.com." 的内部二进制表示:
┌──────┬───┬───┬───┬──────────┬───┬───────┬───┬───┐
│ 3 │ w │ w │ w │ 7 │ e │ x ... │ 3 │com│
│(长度)│ │ │ │(长度) │ │ │ │ │
└──────┴───┴───┴───┴──────────┴───┴───────┴───┴───┘
最后以 长度=0 的字节结束(代表根)
完整域名 vs 相对域名
| 类型 | 特征 | 示例 |
|---|---|---|
| 绝对域名 | 末尾有 .,从根开始 |
poneria.ISI.EDU. |
| 相对域名 | 末尾无 .,需结合本地域补全 |
poneria(在 ISI.EDU 域内) |
总长度限制:域名的所有标签字节数之和(含长度字节)不超过 255 字节。
∑ i ( label_length i + 1 ) ≤ 255 \sum_{i} (\text{label\_length}_i + 1) \leq 255 i∑(label_lengthi+1)≤255
子域关系
A 是 B 的子域,当且仅当 A 的域名以 B 的域名结尾。
例如:A.B.C.D 是 B.C.D、C.D、D 和 .(根)的子域。
3.2 管理指南
DNS 技术规范不强制规定树的结构,组织可以自由选择如何划分子域。但有一些通用建议:
- 计划未来会拆分为多个区域的域,应在顶部提供分支,方便将来无需重命名即可拆分。
- 标签名避免使用特殊字符、前导数字——这会破坏旧软件的兼容性。
3.3 技术指南
要让 DNS 为某类对象(如主机、邮箱)提供命名服务,需要定义两件事:
- 映射规则:如何把对象名转换为域名(如何查询)
- RR 类型和数据格式:如何描述该对象
主机示例:主机名本身就是合法域名,A 记录存储 IP 地址,PTR 记录(在IN-ADDR.ARPA下)实现反向查找(IP→主机名)。
邮箱示例:HOSTMASTER@SRI-NIC.ARPA映射为域名HOSTMASTER.SRI-NIC.ARPA.
规则:<local-part>变成单个标签(内部的.不分割),<mail-domain>按.分割。
3.4 示例名称空间(图示)
下图是文档中反复引用的示例树(部分,非完整互联网):
3.5 首选名称语法
DNS 允许任意二进制标签,但为了与旧系统(如 HOSTS.TXT、email)兼容,推荐使用以下语法:
<domain> ::= <subdomain> | " "(根)
<subdomain> ::= <label> | <subdomain> "." <label>
<label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
<ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
<let-dig-hyp>::= <let-dig> | "-"
<let-dig> ::= <letter> | <digit>
总结规则:
- 以字母开头
- 以字母或数字结尾
- 中间可含字母、数字、连字符(
-) - 不区分大小写
- 单个标签不超过 63 个字符
合法示例:A.ISI.EDU、XX.LCS.MIT.EDU、SRI-NIC.ARPA
3.6 资源记录(Resource Records,RR)
资源记录是 DNS 数据的基本单位。一条 RR 包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| owner | 域名 | 该 RR 所属的域名节点 |
| type | 16 位整数 | 资源类型(A/NS/CNAME/MX/SOA/PTR/HINFO 等) |
| class | 16 位整数 | 协议族(IN=Internet, CH=Chaos) |
| TTL | 32 位整数(秒) | 该记录可被缓存的最长时间 |
| RDATA | 可变长度 | 具体数据,含义由 type 和 class 决定 |
常用类型详解:
| 类型 | RDATA 格式 | 用途 |
|---|---|---|
| A | 32 位 IPv4 地址 | 主机地址(IN 类) |
| NS | 域名(名称服务器主机名) | 指定区域的权威名称服务器 |
| CNAME | 域名(规范名称) | 为另一个名字创建别名 |
| MX | 16 位优先级 + 域名(邮件主机名) | 邮件交换,数值越小优先级越高 |
| SOA | 多个字段(见下) | 区域管理参数,每个区域只有一条 |
| PTR | 域名 | 反向查找(IP→名称) |
| HINFO | CPU 类型字符串 + OS 字符串 | 主机硬件信息 |
SOA 记录的字段(区域起始记录):
SOA 记录格式示例:
. IN SOA SRI-NIC.ARPA. HOSTMASTER.SRI-NIC.ARPA. (
870611 ; serial - 序列号,版本标识
1800 ; refresh - 辅助服务器多少秒后检查更新(30分钟)
300 ; retry - 检查失败后重试间隔(5分钟)
604800 ; expire - 辅助服务器失联多久后丢弃数据(1周)
86400 ; minimum - 所有RR的最小TTL(1天)
)
字段解释:
主域名服务器 = SRI-NIC.ARPA.
负责人邮箱 = HOSTMASTER@SRI-NIC.ARPA(. 代替 @)
TTL 的含义
TTL 告诉缓存方:这条记录最多可以存放多长时间,到期后必须重新查询。
缓存生命周期示意:
时间轴:
0s ──[获取 RR,TTL=86400]──► 86400s ──[必须丢弃,重新查询]──►
TTL 建议:
- 典型主机记录:几天(86400s = 1天)
- 预计要改变的记录:提前降低 TTL,改完再恢复
- 零 TTL(TTL=0):禁止缓存
3.6.1 RR 文本表示
在区域主文件(Master File)中,RR 的文本格式为:
owner TTL class type RDATA
简化规则:
owner为空 → 与上一条 RR 相同class和TTL可省略(沿用默认或前一条)- 类型助记符总是在最后(type mnemonic)
示例:
ISI.EDU. MX 10 VENERA.ISI.EDU.
MX 10 VAXA.ISI.EDU.
VENERA.ISI.EDU. A 128.9.0.32
A 10.1.0.52
VAXA.ISI.EDU. A 10.2.0.27
A 128.9.0.33
这里有 6 条 RR,分属 3 个域名节点,每个节点有 2 条 RR。
不同 class 的示例:
XX.LCS.MIT.EDU. IN A 10.0.0.44 ; Internet 地址
CH A MIT.EDU. 2420 ; Chaos 地址
3.6.2 别名与规范名称(CNAME)
问题:同一资源有多个名字(例如 C.ISI.EDU 和 USC-ISIC.ARPA 是同一台主机)。
CNAME 记录解决这个问题:
USC-ISIC.ARPA IN CNAME C.ISI.EDU. ← USC-ISIC.ARPA 是别名
C.ISI.EDU. IN A 10.0.0.52 ← C.ISI.EDU 是规范名
CNAME 的特殊行为:
- 某节点有 CNAME 记录时,不应有其他 RR(保证别名和规范名不会数据不一致)
- 查询某名称时若找到 CNAME,自动以规范名重新查询(除非查询类型本身是 CNAME)
- 链式 CNAME 应当被追踪,循环 CNAME 应报错
反向查找应指向规范名:
; 正确:PTR 指向规范名
52.0.0.10.IN-ADDR.ARPA. IN PTR C.ISI.EDU.
; 错误:PTR 指向别名(会造成额外间接层)
52.0.0.10.IN-ADDR.ARPA. IN PTR USC-ISIC.ARPA.
3.7 查询(Queries)
查询是发给名称服务器的消息,包含:
- QNAME:目标域名
- QTYPE:查询的资源类型
- QCLASS:查询的协议类
DNS 消息结构(4 个 Section):
+──────────────────────────────────+
│ Header(头部):opcode、标志位等 │
+──────────────────────────────────+
│ Question(问题区):QNAME/QTYPE │
+──────────────────────────────────+
│ Answer(回答区):直接回答查询的RR │
+──────────────────────────────────+
│ Authority(权威区):权威服务器RR │
+──────────────────────────────────+
│ Additional(附加区):可能有用的RR │
+──────────────────────────────────+
QTYPE 的特殊值:
| QTYPE | 含义 |
|---|---|
| A / MX / NS 等 | 精确匹配某类型 |
| AXFR | 区域传输(只在 TCP 上,用于同步完整区域数据) |
| MAILB | 匹配所有邮箱相关 RR(MB、MG 等) |
* |
匹配所有 RR 类型 |
QCLASS 的特殊值:
- 具体类(IN、CH):精确匹配
*:匹配所有 class(但这类查询永远无法权威,因为服务器不一定知道所有 class)
3.7.1 标准查询示例
邮件程序查询 ISI.EDU 的邮件交换记录:
查询:QNAME=ISI.EDU., QTYPE=MX, QCLASS=IN
回答区:
ISI.EDU. MX 10 VENERA.ISI.EDU.
ISI.EDU. MX 10 VAXA.ISI.EDU.
附加区(服务器主动提供,猜测你之后会需要):
VAXA.ISI.EDU. A 10.2.0.27
VAXA.ISI.EDU. A 128.9.0.33
VENERA.ISI.EDU. A 10.1.0.52
VENERA.ISI.EDU. A 128.9.0.32
3.7.2 反向查询(可选)
将资源映射回域名的反向查找。实现可选,但所有服务器必须能理解反向查询消息(哪怕只返回"未实现"错误)。
注意:反向查询不能保证完整性和唯一性(DNS 按名称组织,不按资源),返回结果不应缓存。
正确的 IP→主机名反向查找方式是用 IN-ADDR.ARPA 域(PTR 记录),而非反向查询。
第4章 名称服务器
4.1 简介
名称服务器是 DNS 数据库的仓库。核心职责:
用本地数据回答查询——要么给出答案,要么告诉客户端去哪里找答案。
关键特性:
- 每个区域至少要有两台名称服务器(冗余)
- 服务器的回答来自权威数据(zone data)或缓存数据(cached data),响应头中会标明来源
- 服务器知道自己对哪些区域有权威性
4.2 数据库如何划分为区域
两种分割方式
1. 按 class 分割:不同协议族(IN、CH)的数据完全独立,平行存在。
2. 按名称空间"切割"(cuts):在相邻节点之间画一刀,把整棵树切成若干连通子图,每个子图就是一个区域(Zone)。
区域切割示意(竖线代表切割点):
.(根)[区域A]
/ \
MIL EDU [区域A]
| |
----- ----- ← 切割点
BRL MIT [区域B] ISI [区域C]
|
LCS
每个区域都是连通的,有且只有一个"顶点节点"(离根最近的节点)。区域名称通常就是该顶点的域名。
区域数据的四个部分
胶水记录(Glue RRs) 解决"自举问题":
问题场景:
父区域的 NS 记录说:LCS.MIT.EDU 的服务器是 XX.LCS.MIT.EDU.
但要知道 XX.LCS.MIT.EDU 的 IP,就要查询 LCS.MIT.EDU 区域…
而我们还没连上那个区域!→ 死循环!
解决方案:Glue RRs
父区域额外携带 XX.LCS.MIT.EDU 的 A 记录(地址)
这些 A 记录不是权威的,只用于"引路"
有了 IP,才能去访问子区域服务器,才能获得权威数据
区域委托(Delegation)
当组织想管理自己的子域时:
- 联系父域管理员,协商委托
- 在父域中添加 NS 记录(指向新区域的服务器)和胶水记录(服务器的 IP)
- 新区域在自己的 SOA 中声明权威性
| 角色 | 操作 |
|---|---|
| 父区域管理员 | 添加 NS 记录 + Glue A 记录到父区域 |
| 新子域管理员 | 建立主文件,配置名称服务器,SOA 声明权威 |
| 两者需保持一致 | NS 记录两侧(父/子)内容必须相同 |
4.3 名称服务器内部机制
4.3.1 查询与响应
名称服务器有两种工作模式:
非递归模式(必须实现):
- 只用本地数据回答
- 可以返回:答案、名称错误(NE)、或对更近的服务器的引用(Referral)
递归模式(可选): - 服务器扮演客户端,代为向其他服务器查询,最终返回答案或错误(不会返回引用)
- 不是所有客户端都能使用递归服务,服务器可以限制
两个协商位:
| 位标志 | 所在方 | 含义 |
|---|---|---|
| RD(递归期望) | 查询方 | 我希望你提供递归服务 |
| RA(递归可用) | 响应方 | 我能提供递归服务(不代表本次使用了) |
客户端通过检查响应中 RA 和 RD 均为 1,来确认本次确实使用了递归模式。
递归响应的三种情况:
- 答案(可能附带 CNAME 链)
- 名称错误(名字不存在)
- 临时错误
非递归响应的情况: - 权威名称错误
- 临时错误
- 答案 + 来源标记(zone 还是 cache)
- 对更近服务器的引用
4.3.2 查询处理算法
名称服务器回答标准查询的 6 步算法:
步骤3 详解——通配符(Wildcard)处理:
通配符 RR 的 owner 形如 *.X.COM,规则:
- 匹配
X.COM下任意名称的查询(如Z.X.COM、Y.X.COM) - 但不匹配
X.COM本身 - 若
B.X.COM有显式 RR,则通配符对B.X.COM及其子域(A.B.X.COM)失效 - 通配符不跨越区域委托边界
通配符示例:
X.COM MX 10 A.X.COM ; X.COM 本身的邮件交换
*.X.COM MX 10 A.X.COM ; X.COM 下任意名称的邮件交换
A.X.COM A 1.2.3.4 ; A.X.COM 有显式记录
A.X.COM MX 10 A.X.COM ; A.X.COM 的显式邮件交换(抑制通配符)
*.A.X.COM MX 10 A.X.COM ; A.X.COM 子域的邮件交换
结果:
Z.X.COM的 MX 查询 → 匹配*.X.COM,返回A.X.COMA.X.COM的 MX 查询 → 匹配显式记录,返回A.X.COM(通配符被压制)XX.COM的 MX 查询 → 不匹配(XX.COM 不属于 X.COM)
4.3.4 否定响应缓存(可选)
名称错误(名字不存在)也可以被缓存,以避免对同一不存在名字反复查询。
机制:服务器在权威响应的附加区附上 SOA 记录,SOA 中的 MINIMUM 字段指定该否定结果可缓存多久。
4.3.5 区域维护与传输
主-辅结构
辅助服务器的刷新流程
辅助服务器通过 SOA 的 SERIAL 字段检测主服务器数据是否有更新。SERIAL 使用序列空间算术(避免32位整数溢出的绕回问题)。
SOA 参数:
SERIAL = 区域版本号,每次修改必须增大
REFRESH = 每隔多少秒向主服务器检查一次(如 1800s = 30分钟)
RETRY = 检查失败后的重试间隔(如 300s = 5分钟)
EXPIRE = 如果 EXPIRE 秒内始终无法联系主服务器,丢弃本地副本(如 604800s = 1周)
MINIMUM = 该区域所有 RR 的默认最小 TTL(如 86400s = 1天)
辅助服务器工作流程:
加载区域后等待 REFRESH 秒
↓
查询主服务器的 SOA,比较 SERIAL
├── SERIAL 未变 → 数据有效,再等 REFRESH 秒
├── SERIAL 变大 → 发起 AXFR 请求,下载完整区域
└── 查询失败 → 每 RETRY 秒重试,超过 EXPIRE → 丢弃区域
AXFR 传输(区域传输)必须使用 TCP(因为数据量大,需要可靠传输)。第一条和最后一条消息必须包含区域顶点节点的数据。
第5章 解析器
5.1 简介
解析器是用户程序与 DNS 之间的中间件,位于用户的本地机器上,负责:
- 接受用户程序的查询请求(通常是系统调用或库函数)
- 向名称服务器发起 DNS 查询
- 管理缓存,减少网络请求
解析器的最重要目标:让大多数查询从缓存中直接回答,减少网络延迟和服务器负载。
共享缓存(多进程/多机器共用)比各自独立的缓存高效得多。
5.2 客户端-解析器接口
三个典型功能
功能1:主机名 → IP 地址
用户调用:gethostbyname("www.example.com")
↓
解析器发起 QTYPE=A 的 DNS 查询
↓
返回 32 位 IPv4 地址列表
建议返回多个地址(主机可能有多个 IP),但为了兼容旧 API,也可以只返回一个。
功能2:IP 地址 → 主机名
IP 地址字节序倒置,拼上 IN-ADDR.ARPA 后缀,发起 PTR 查询:
查询 IP 1.2.3.4 对应的主机名:
将 1.2.3.4 倒转 → 4.3.2.1
拼接后缀 → 4.3.2.1.IN-ADDR.ARPA.
发起 QTYPE=PTR 查询
返回主机名字符串
为什么要倒转字节序?
普通域名树从左(最具体)到右(最通用):host.domain.tld
IP 地址从左(最通用,网络前缀)到右(最具体,主机号):192.168.1.5
为了让 IP 也能在同一棵树里用同样的方式组织(更通用的在树的高层),必须把字节序倒转:5.1.168.192.IN-ADDR.ARPA.
功能3:通用查找
直接指定 QNAME、QTYPE、QCLASS,返回所有匹配 RR(原始格式,含 TTL)。
可能的返回结果
| 结果类型 | 含义 |
|---|---|
| 一条或多条 RR | 成功找到请求的数据 |
| 名称错误(NE) | 名字不存在(如拼写错误) |
| 数据未找到错误 | 名字存在,但没有对应类型的 RR(如查邮件服务器却只有A记录) |
| 临时错误 | 网络问题,不是数据不存在,不应报告为"不存在" |
注意:前两种错误(NE 和数据未找到)在 API 层面可以合并,但底层不应混淆——否则会导致无效的重复查询。
5.3 解析器内部机制
5.3.1 存根解析器(Stub Resolver)
最简单的解析器实现:把所有工作外包给一台支持递归的名称服务器。
本地机器 外部递归服务器
┌──────────┐ ┌─────────────┐
│ 应用程序 │─── DNS 查询(RD=1)→│ 递归服务器 │─── 追问各级服务器
│ Stub │←── 答案 ─────────│ │
└──────────┘ └─────────────┘
优点:本地几乎不需要逻辑,适合资源受限的 PC
缺点:依赖外部服务器,TCP 可能有负担,重传策略难以优化
存根解析器需要一个配置文件,列出递归服务器的 IP。
5.3.2 解析器数据结构
完整解析器维护以下关键数据结构:
| 数据结构 | 含义 |
|---|---|
| SNAME | 当前搜索的目标域名 |
| STYPE | 查询类型(QTYPE) |
| SCLASS | 查询类(QCLASS) |
| SLIST | 候选名称服务器列表(含地址、匹配距离、历史成功率),动态更新 |
| SBELT | 安全带(Safety Belt):从配置文件初始化,存放兜底服务器(通常2个根+2个本地) |
| CACHE | 缓存:存储历史响应结果,存储绝对过期时间而非剩余 TTL |
SLIST 的匹配计数(match count):
匹配计数 = 从根向下,SNAME 与候选服务器所管辖区域的公共标签数。
数值越大,说明候选服务器越"接近"目标。
match_count ( S ) = 从根往下,SNAME 与服务器区域的公共标签数 \text{match\_count}(S) = \text{从根往下,SNAME 与服务器区域的公共标签数} match_count(S)=从根往下,SNAME 与服务器区域的公共标签数
SBELT 的 match count 初始化为 − 1 -1 −1(最不"接近",兜底使用)。
5.3.3 解析器核心算法
4 步循环:
步骤2 的优先级策略:
- 避免无限循环(绑定工作量上限)
- 尽量得到答案
- 避免不必要的传输
- 尽快得到答案
SLIST 中的服务器可以按优先级排序:本地网络上的服务器优先,同时参考历史响应时间统计。
第6章 综合场景演示
6.1 区域文件示例
根区域(部分)
. IN SOA SRI-NIC.ARPA. HOSTMASTER.SRI-NIC.ARPA. (
870611 ; serial
1800 ; refresh 每30分钟检查更新
300 ; retry 失败后5分钟重试
604800 ; expire 1周后丢弃
86400 ; minimum 最小TTL=1天
)
NS A.ISI.EDU. ; 根服务器1
NS C.ISI.EDU. ; 根服务器2
NS SRI-NIC.ARPA. ; 根服务器3
MIL. 86400 NS SRI-NIC.ARPA. ; MIL 委托给这两台
86400 NS A.ISI.EDU.
EDU. 86400 NS SRI-NIC.ARPA. ; EDU 委托给这两台
86400 NS C.ISI.EDU.
SRI-NIC.ARPA. A 26.0.0.73 ; Glue:根服务器的地址
A 10.0.0.51
MX 0 SRI-NIC.ARPA.
HINFO DEC-2060 TOPS20
USC-ISIC.ARPA. CNAME C.ISI.EDU. ; 别名记录
; 反向记录(PTR)
73.0.0.26.IN-ADDR.ARPA. PTR SRI-NIC.ARPA.
52.0.0.10.IN-ADDR.ARPA. PTR C.ISI.EDU.
EDU 区域(部分)
EDU. IN SOA SRI-NIC.ARPA. HOSTMASTER.SRI-NIC.ARPA. (
870729
1800
300
604800
86400
)
NS SRI-NIC.ARPA.
NS C.ISI.EDU.
ISI 172800 NS VAXA.ISI ; ISI.EDU 委托(相对名,基于 EDU 原点)
172800 NS A.ISI
172800 NS VENERA.ISI.EDU.
VAXA.ISI 172800 A 10.2.0.27 ; Glue:ISI 服务器地址
172800 A 128.9.0.33
MIT.EDU. 43200 NS XX.LCS.MIT.EDU. ; MIT.EDU 委托
43200 NS ACHILLES.MIT.EDU.
XX.LCS.MIT.EDU. 43200 A 10.0.0.44 ; Glue
ACHILLES.MIT.EDU. 43200 A 18.72.0.8
注意:区域文件中可以混用相对名和绝对名。有末尾 . 是绝对名;无末尾 . 是相对名,会被补上区域原点(origin)。
6.2 标准查询示例详解
示例1:QNAME=SRI-NIC.ARPA, QTYPE=A(权威回答)
查询(发给 C.ISI.EDU):
Header: OPCODE=SQUERY
Question: QNAME=SRI-NIC.ARPA., QCLASS=IN, QTYPE=A
响应(来自 C.ISI.EDU,权威数据):
Header: OPCODE=SQUERY, RESPONSE, AA ← AA=1 表示权威回答
Question: QNAME=SRI-NIC.ARPA., QCLASS=IN, QTYPE=A
Answer: SRI-NIC.ARPA. 86400 IN A 26.0.0.73
SRI-NIC.ARPA. 86400 IN A 10.0.0.51
若发给非权威服务器(从缓存回答):
Header: OPCODE=SQUERY, RESPONSE ← AA=0 表示非权威(来自缓存)
Answer: SRI-NIC.ARPA. 1777 IN A 10.0.0.51 ← TTL 已衰减(不再是86400)
AA 位和 TTL 是区分权威/非权威的两个关键信号。
示例2:QTYPE=*(通配类型查询)
权威服务器回应 QTYPE=* 的 SRI-NIC.ARPA 查询:
Answer:
SRI-NIC.ARPA. 86400 IN A 26.0.0.73
A 10.0.0.51
MX 0 SRI-NIC.ARPA.
HINFO DEC-2060 TOPS20
不同的非权威服务器可能只缓存了不同类型的数据(一台缓存了 A,另一台缓存了 HINFO),这正说明了 QTYPE=* 的缓存语义不完整性。
示例3:QTYPE=MX(附加区的作用)
查询:QNAME=SRI-NIC.ARPA., QTYPE=MX
答案区:
SRI-NIC.ARPA. 86400 IN MX 0 SRI-NIC.ARPA.
附加区(服务器主动提供,因为使用 MX 必然需要地址):
SRI-NIC.ARPA. 86400 IN A 26.0.0.73
SRI-NIC.ARPA. 86400 IN A 10.0.0.51
示例4:名称不存在(NXDOMAIN)
查询:QNAME=SIR-NIC.ARPA.(拼错了!)
响应:
Header: OPCODE=SQUERY, RESPONSE, AA, RCODE=NE ← NE=名称错误
Authority: . SOA SRI-NIC.ARPA. HOSTMASTER.SRI-NIC.ARPA.
870611 1800 300 604800 86400
Authority 区中的 SOA 是"否定缓存信息"——告诉解析器,在 MINIMUM(86400s)内,这个名字不存在,不需要再查了。
示例5:引用(Referral)
查询:QNAME=BRL.MIL(发给 C.ISI.EDU,但它只管 EDU 和 ARPA)
响应(不是权威回答,是引用):
Header: OPCODE=SQUERY, RESPONSE ← 无 AA
Answer: 空
Authority: MIL. 86400 IN NS SRI-NIC.ARPA.
MIL. 86400 IN NS A.ISI.EDU.
Additional: A.ISI.EDU. A 26.3.0.103 ← Glue:告诉你去哪里找
SRI-NIC.ARPA. A 26.0.0.73
SRI-NIC.ARPA. A 10.0.0.51
C.ISI.EDU 说:“我不管 MIL,你去问 SRI-NIC.ARPA 或 A.ISI.EDU,它们的地址我给你了。”
示例6:CNAME 处理
查询:QNAME=USC-ISIC.ARPA, QTYPE=A(发给 A.ISI.EDU)
响应:
Answer:
USC-ISIC.ARPA. 86400 IN CNAME C.ISI.EDU. ← 先返回 CNAME
C.ISI.EDU. 86400 IN A 10.0.0.52 ← 再返回最终答案
AA 位表示 USC-ISIC.ARPA 这条 CNAME 是权威的,但不代表 C.ISI.EDU 的 A 记录也是权威的。
(A.ISI.EDU 碰巧同时对这两个域有权威,所以能一次返回全部)
若发给 C.ISI.EDU(它不一定有 C.ISI.EDU 的 A 记录在缓存里):
Answer: USC-ISIC.ARPA. 86400 IN CNAME C.ISI.EDU. ← 只有 CNAME
Authority: ISI.EDU. 172800 IN NS VAXA.ISI.EDU. ← 告诉你去哪找 C.ISI.EDU
NS A.ISI.EDU.
NS VENERA.ISI.EDU.
Additional: 各服务器地址...
6.3 解析器实战追踪
场景:冷启动后查询 ISI.EDU 的 MX 记录
SBELT(配置文件中的兜底服务器):
Match count = -1
SRI-NIC.ARPA. 26.0.0.73 10.0.0.51
A.ISI.EDU. 26.3.0.103
第一轮:缓存为空,向 SBELT 中的服务器发查询。
SRI-NIC.ARPA 返回委托(更近的引用):
Authority: ISI.EDU. NS VAXA.ISI.EDU.
NS A.ISI.EDU.
NS VENERA.ISI.EDU.
Additional: 各服务器地址
解析器更新 SLIST(match count 从 -1 提升到 3,因为 ISI.EDU 有3个标签匹配):
Match count = 3
A.ISI.EDU. 26.3.0.103
VAXA.ISI.EDU. 10.2.0.27 128.9.0.33
VENERA.ISI.EDU. 10.1.0.52 128.9.0.32
第二轮:向 SLIST 发查询,最终从某台 ISI.EDU 的权威服务器得到:
Answer:
ISI.EDU. MX 10 VENERA.ISI.EDU.
ISI.EDU. MX 20 VAXA.ISI.EDU.
Additional:
VAXA.ISI.EDU. A 10.2.0.27 / A 128.9.0.33
VENERA.ISI.EDU. A 10.1.0.52 / A 128.9.0.32
结果缓存,返回客户端。
C++ 实战代码
以下代码模拟 DNS 的核心数据结构与查询逻辑,包含:
- 资源记录(RR)结构
- 区域(Zone)管理
- 简化的名称服务器查询算法
- 简化的解析器缓存与查询流程
/*
* dns_simulator.cpp
*
* 模拟 DNS RFC 1034 的核心概念(教学用简化版本)
* 涵盖:
* - 资源记录(RR)数据结构
* - 区域(Zone)加载与查询
* - TTL 缓存管理
* - 迭代查询流程(Iterative Resolution)
* - CNAME 追踪
*
* 编译:g++ -std=c++17 dns_simulator.cpp -o dns_simulator
*/
#include <chrono>
#include <cstdint>
#include <iostream>
#include <map>
#include <optional>
#include <sstream>
#include <string>
#include <unordered_map>
#include <vector>
#include <algorithm>
#include <stdexcept>
// ============================================================
// 工具函数:域名标准化(转小写,保证末尾有 .)
// ============================================================
static std::string normalize_name(const std::string& name) {
std::string result = name;
// 转换为小写(DNS 不区分大小写)
std::transform(result.begin(), result.end(), result.begin(),
[](unsigned char c) { return std::tolower(c); });
// 保证末尾是 .(绝对域名)
if (result.empty() || result.back() != '.') {
result += '.';
}
return result;
}
// ============================================================
// 资源记录类型枚举(来自 RFC 1034 Section 3.6)
// ============================================================
enum class RRType {
A, // 主机地址(IPv4)
NS, // 名称服务器
CNAME, // 规范名称(别名)
MX, // 邮件交换
SOA, // 区域起始
PTR, // 反向查找指针
HINFO, // 主机信息
ANY, // 通配类型(* 查询)
};
// 类型到字符串的映射,便于打印
static std::string rr_type_str(RRType t) {
switch (t) {
case RRType::A: return "A";
case RRType::NS: return "NS";
case RRType::CNAME: return "CNAME";
case RRType::MX: return "MX";
case RRType::SOA: return "SOA";
case RRType::PTR: return "PTR";
case RRType::HINFO: return "HINFO";
case RRType::ANY: return "*";
default: return "UNKNOWN";
}
}
// ============================================================
// 资源记录(RR)结构体
// 对应 RFC 1034 Section 3.6:owner / type / class / TTL / RDATA
// ============================================================
struct ResourceRecord {
std::string owner; // 所属域名(已标准化,末尾带 .)
RRType type; // 资源类型
std::string rr_class; // 协议类("IN" 或 "CH")
uint32_t ttl; // 存活时间(秒)
std::string rdata; // 资源数据(文本表示,简化)
// 用于缓存:记录何时写入缓存(单调时钟时间点)
// 实际比较时用 (当前时间 - cached_at) 与 ttl 比较
std::chrono::steady_clock::time_point cached_at;
bool is_authoritative = false; // 是否来自权威数据(zone 数据)
void print(const std::string& prefix = "") const {
std::cout << prefix
<< owner << " "
<< ttl << " "
<< rr_class << " "
<< rr_type_str(type) << " "
<< rdata << "\n";
}
};
// ============================================================
// DNS 消息结构(简化版)
// 对应 RFC 1034 Section 3.7:四个 Section
// ============================================================
struct DnsResponse {
bool authoritative = false; // AA 位
bool is_referral = false; // 是引用还是答案
std::string qname; // 查询名
RRType qtype; // 查询类型
std::vector<ResourceRecord> answer; // 回答区
std::vector<ResourceRecord> authority; // 权威区(委托信息)
std::vector<ResourceRecord> additional; // 附加区(辅助信息)
// 判断是否得到了有效答案
bool has_answer() const { return !answer.empty(); }
void print() const {
std::cout << " [响应] AA=" << authoritative
<< " Referral=" << is_referral << "\n";
if (!answer.empty()) {
std::cout << " [Answer]\n";
for (const auto& rr : answer) rr.print(" ");
}
if (!authority.empty()) {
std::cout << " [Authority]\n";
for (const auto& rr : authority) rr.print(" ");
}
if (!additional.empty()) {
std::cout << " [Additional]\n";
for (const auto& rr : additional) rr.print(" ");
}
}
};
// ============================================================
// 区域(Zone)类
// 对应 RFC 1034 Section 4.2:区域是权威数据的基本单元
// ============================================================
class Zone {
public:
std::string zone_name; // 区域顶点名称(如 "isi.edu.")
uint32_t min_ttl = 86400; // SOA MINIMUM
// zone_name → [list of RRs]
// 使用 map 保证顺序稳定,便于调试
std::map<std::string, std::vector<ResourceRecord>> records;
// 向区域中添加一条资源记录
void add_record(const ResourceRecord& rr) {
std::string key = normalize_name(rr.owner);
records[key].push_back(rr);
}
// 在区域内查找匹配 QNAME 和 QTYPE 的 RR
// 返回 DnsResponse(权威数据)
DnsResponse query(const std::string& qname_raw, RRType qtype) const {
DnsResponse resp;
resp.qname = qname_raw;
resp.qtype = qtype;
std::string qname = normalize_name(qname_raw);
// 精确查找 QNAME 对应的所有 RR
auto it = records.find(qname);
if (it == records.end()) {
// 名字不存在,尝试查找通配符 *.parent_domain
// 简化实现:只检查直接通配符
std::string parent = get_parent(qname);
std::string wildcard = "*." + parent;
auto wit = records.find(wildcard);
if (wit != records.end()) {
// 通配符命中,复制 RR 但将 owner 替换为 QNAME
for (const auto& rr : wit->second) {
if (qtype == RRType::ANY || rr.type == qtype) {
ResourceRecord synth = rr;
synth.owner = qname; // 合成 RR:owner 改为查询名
resp.answer.push_back(synth);
}
}
if (!resp.answer.empty()) {
resp.authoritative = true;
return resp;
}
}
// 真的不存在:名称错误(NXDOMAIN),返回权威标记
resp.authoritative = true;
return resp;
}
const auto& node_rrs = it->second;
// 检查是否有 CNAME(且查询类型不是 CNAME)
// 若有,返回 CNAME(由调用者决定是否追踪)
for (const auto& rr : node_rrs) {
if (rr.type == RRType::CNAME && qtype != RRType::CNAME) {
resp.answer.push_back(rr);
resp.authoritative = true;
// 标记:需要以 CNAME 的规范名重新查询
return resp;
}
}
// 正常匹配:收集所有符合 QTYPE 的 RR
for (const auto& rr : node_rrs) {
if (qtype == RRType::ANY || rr.type == qtype) {
resp.answer.push_back(rr);
}
}
if (!resp.answer.empty()) {
resp.authoritative = true;
} else {
// 名字存在但无对应类型(数据未找到)
resp.authoritative = true; // 权威确认"不存在该类型"
}
return resp;
}
// 检查 QNAME 是否属于本区域(用于判断是否需要委托)
bool is_authoritative_for(const std::string& qname_raw) const {
std::string qname = normalize_name(qname_raw);
std::string zname = normalize_name(zone_name);
// qname 以 zname 结尾,说明在本区域权威范围内
if (qname.size() < zname.size()) return false;
return qname.substr(qname.size() - zname.size()) == zname;
}
private:
// 获取一个域名的父域(去掉第一个标签)
static std::string get_parent(const std::string& name) {
size_t dot = name.find('.');
if (dot == std::string::npos || dot + 1 >= name.size()) {
return "."; // 父域是根
}
return name.substr(dot + 1);
}
};
// ============================================================
// 简单缓存(对应 RFC 1034 Section 5.3.2)
// 存储 TTL 已转换为绝对过期时间的 RR
// ============================================================
class DnsCache {
public:
// 缓存 key = normalized_name + ":" + rr_type
using Key = std::string;
struct CacheEntry {
std::vector<ResourceRecord> records;
std::chrono::steady_clock::time_point expires_at; // 绝对过期时间
};
// 向缓存写入一批 RR
void store(const std::vector<ResourceRecord>& rrs) {
for (const auto& rr : rrs) {
if (rr.ttl == 0) continue; // TTL=0 不缓存
std::string key = make_key(rr.owner, rr.type);
auto now = std::chrono::steady_clock::now();
auto& entry = cache_[key];
// 简化:覆盖同类型的旧缓存
entry.records.clear();
entry.records.push_back(rr);
// 存储绝对过期时间(当前时间 + TTL秒)
entry.expires_at = now + std::chrono::seconds(rr.ttl);
}
}
// 从缓存查找(自动过滤过期条目)
std::vector<ResourceRecord> lookup(const std::string& name,
RRType type) const {
std::string key = make_key(normalize_name(name), type);
auto it = cache_.find(key);
if (it == cache_.end()) return {};
auto now = std::chrono::steady_clock::now();
if (now > it->second.expires_at) {
// 已过期(只读操作,不修改缓存;过期条目将在下次写入时覆盖)
return {};
}
// 计算剩余 TTL(返回给调用者,反映缓存老化)
auto remaining_secs = std::chrono::duration_cast<std::chrono::seconds>(
it->second.expires_at - now).count();
std::vector<ResourceRecord> result = it->second.records;
for (auto& rr : result) {
// 更新 TTL 为剩余存活时间(这就是缓存中 TTL 与权威值不同的原因)
rr.ttl = static_cast<uint32_t>(
std::max<long long>(0, remaining_secs));
rr.is_authoritative = false; // 缓存数据非权威
}
return result;
}
private:
mutable std::unordered_map<Key, CacheEntry> cache_;
static std::string make_key(const std::string& name, RRType type) {
return name + ":" + rr_type_str(type);
}
};
// ============================================================
// 简化的名称服务器(Name Server)
// 持有若干 Zone,可回答查询
// ============================================================
class NameServer {
public:
std::string server_name; // 服务器自身的名称(如 "c.isi.edu.")
void add_zone(const Zone& z) {
zones_.push_back(z);
}
// 处理一次 DNS 查询(非递归模式)
// 返回权威答案或引用(Referral)
DnsResponse handle_query(const std::string& qname, RRType qtype) const {
std::cout << " [" << server_name << "] 收到查询: "
<< qname << " / " << rr_type_str(qtype) << "\n";
std::string norm_qname = normalize_name(qname);
// 找到与 QNAME 最匹配(最接近)的权威区域
const Zone* best_zone = nullptr;
size_t best_len = 0;
for (const auto& z : zones_) {
std::string zn = normalize_name(z.zone_name);
// 检查 QNAME 是否以 zone_name 结尾
if (norm_qname.size() >= zn.size() &&
norm_qname.substr(norm_qname.size() - zn.size()) == zn) {
if (zn.size() > best_len) {
best_len = zn.size();
best_zone = &z;
}
}
}
if (!best_zone) {
// 本服务器无任何相关区域,返回空(调用方需用引用继续)
DnsResponse resp;
resp.qname = qname;
resp.qtype = qtype;
resp.authoritative = false;
std::cout << " -> 无权威区域,无法处理\n";
return resp;
}
std::cout << " -> 使用区域: " << best_zone->zone_name << "\n";
DnsResponse resp = best_zone->query(qname, qtype);
if (!resp.has_answer()) {
std::cout << " -> 未找到记录(NXDOMAIN 或数据不存在)\n";
} else {
std::cout << " -> 找到 " << resp.answer.size() << " 条记录\n";
}
return resp;
}
private:
std::vector<Zone> zones_;
};
// ============================================================
// 简化的解析器(Resolver)
// 对应 RFC 1034 Section 5.3.3 的 4 步算法
// ============================================================
class Resolver {
public:
DnsCache cache;
// 配置已知的名称服务器(简化:直接存引用)
void add_server(const NameServer* ns) {
servers_.push_back(ns);
}
// 主查询接口:解析 QNAME/QTYPE
// 返回最终 RR 列表(空表示失败)
std::vector<ResourceRecord> resolve(const std::string& qname,
RRType qtype,
int max_cname_depth = 5) {
std::cout << "\n=== 解析器查询: " << qname
<< " / " << rr_type_str(qtype) << " ===\n";
std::string current_name = qname;
// 最多追踪 max_cname_depth 层 CNAME,防止循环
for (int depth = 0; depth <= max_cname_depth; ++depth) {
// 步骤1:检查缓存
{
auto cached = cache.lookup(current_name, qtype);
if (!cached.empty()) {
std::cout << " [缓存命中] " << current_name
<< " TTL=" << cached[0].ttl << "s 剩余\n";
return cached;
}
}
// 步骤2~4:向已知服务器查询
std::vector<ResourceRecord> result;
bool found = false;
for (const auto* ns : servers_) {
DnsResponse resp = ns->handle_query(current_name, qtype);
if (!resp.has_answer()) continue;
// 检查响应中是否有 CNAME
if (resp.answer.size() == 1 &&
resp.answer[0].type == RRType::CNAME &&
qtype != RRType::CNAME) {
// 追踪 CNAME:将 current_name 换成规范名
std::string canonical = resp.answer[0].rdata;
std::cout << " [CNAME] " << current_name
<< " -> " << canonical << "\n";
cache.store(resp.answer); // 缓存 CNAME 本身
current_name = canonical;
found = true;
break; // 以新名字重新查询
}
// 得到正常答案
cache.store(resp.answer); // 缓存结果
cache.store(resp.additional); // 缓存附加区数据
result = resp.answer;
found = true;
break;
}
if (!found) {
std::cout << " [失败] 所有服务器都未能回答 " << current_name << "\n";
return {};
}
if (!result.empty()) {
return result; // 成功
}
// result 为空但 found=true 说明正在追踪 CNAME,继续循环
}
std::cout << " [失败] CNAME 链过长(超过 "
<< max_cname_depth << " 层)\n";
return {};
}
// 反向查找辅助:IP 地址转 IN-ADDR.ARPA 域名
// RFC 1034 Section 5.2.1 功能2
static std::string ip_to_arpa(const std::string& ip) {
// 将 "1.2.3.4" 转为 "4.3.2.1.IN-ADDR.ARPA."
std::vector<std::string> octets;
std::stringstream ss(ip);
std::string octet;
while (std::getline(ss, octet, '.')) {
octets.push_back(octet);
}
if (octets.size() != 4) {
throw std::invalid_argument("无效的 IPv4 地址: " + ip);
}
// 倒转字节序
return octets[3] + "." + octets[2] + "." +
octets[1] + "." + octets[0] + ".IN-ADDR.ARPA.";
}
private:
std::vector<const NameServer*> servers_;
};
// ============================================================
// 演示:构建示例 DNS 域(基于 RFC 1034 Section 6 的示例)
// ============================================================
// 构造一个简化的 ISI.EDU 区域
static Zone build_isi_edu_zone() {
Zone z;
z.zone_name = "isi.edu.";
z.min_ttl = 86400;
auto add = [&](const std::string& owner, RRType type,
uint32_t ttl, const std::string& rdata) {
ResourceRecord rr;
rr.owner = normalize_name(owner);
rr.type = type;
rr.rr_class = "IN";
rr.ttl = ttl;
rr.rdata = rdata;
rr.is_authoritative = true;
z.add_record(rr);
};
// ISI.EDU 的 MX 记录(RFC 1034 示例中的 ISI.EDU MX 数据)
add("ISI.EDU", RRType::MX, 86400, "10 VENERA.ISI.EDU.");
add("ISI.EDU", RRType::MX, 86400, "20 VAXA.ISI.EDU.");
// 各主机的 A 记录
add("VENERA.ISI.EDU", RRType::A, 86400, "10.1.0.52");
add("VENERA.ISI.EDU", RRType::A, 86400, "128.9.0.32");
add("VAXA.ISI.EDU", RRType::A, 86400, "10.2.0.27");
add("VAXA.ISI.EDU", RRType::A, 86400, "128.9.0.33");
add("A.ISI.EDU", RRType::A, 86400, "26.3.0.103");
add("C.ISI.EDU", RRType::A, 86400, "10.0.0.52");
// CNAME 别名(USC-ISIC.ARPA → C.ISI.EDU)
add("USC-ISIC.ARPA", RRType::CNAME, 86400, "C.ISI.EDU.");
return z;
}
// 构造根区域(简化)
static Zone build_root_zone() {
Zone z;
z.zone_name = ".";
z.min_ttl = 86400;
auto add = [&](const std::string& owner, RRType type,
uint32_t ttl, const std::string& rdata) {
ResourceRecord rr;
rr.owner = normalize_name(owner);
rr.type = type;
rr.rr_class = "IN";
rr.ttl = ttl;
rr.rdata = rdata;
rr.is_authoritative = true;
z.add_record(rr);
};
// 根区的 NS 记录(委托给 SRI-NIC.ARPA)
add("SRI-NIC.ARPA", RRType::A, 86400, "26.0.0.73");
add("SRI-NIC.ARPA", RRType::A, 86400, "10.0.0.51");
add("SRI-NIC.ARPA", RRType::MX, 86400, "0 SRI-NIC.ARPA.");
add("ACC.ARPA", RRType::A, 86400, "26.6.0.65");
// PTR 反向记录
add("73.0.0.26.IN-ADDR.ARPA", RRType::PTR, 86400, "SRI-NIC.ARPA.");
add("65.0.6.26.IN-ADDR.ARPA", RRType::PTR, 86400, "ACC.ARPA.");
return z;
}
// ============================================================
// 主函数:演示各种 DNS 查询场景
// ============================================================
int main() {
std::cout << "=================================================\n";
std::cout << "DNS RFC 1034 模拟器演示\n";
std::cout << "=================================================\n\n";
// --- 构建名称服务器 ---
NameServer c_isi_edu;
c_isi_edu.server_name = "c.isi.edu.";
c_isi_edu.add_zone(build_root_zone());
c_isi_edu.add_zone(build_isi_edu_zone());
// --- 构建解析器,使用 C.ISI.EDU 作为上游服务器 ---
Resolver resolver;
resolver.add_server(&c_isi_edu);
// ── 演示1:查询 ISI.EDU 的 MX 记录 ──────────────────────
std::cout << "【演示1】查询 ISI.EDU 的邮件交换记录(MX)\n";
{
auto rrs = resolver.resolve("ISI.EDU", RRType::MX);
std::cout << " 最终结果:\n";
for (const auto& rr : rrs) rr.print(" ");
}
// ── 演示2:第二次查询,应该命中缓存 ─────────────────────
std::cout << "\n【演示2】再次查询 ISI.EDU MX(应命中缓存)\n";
{
auto rrs = resolver.resolve("ISI.EDU", RRType::MX);
std::cout << " 最终结果(来自缓存):\n";
for (const auto& rr : rrs) rr.print(" ");
}
// ── 演示3:查询 VENERA.ISI.EDU 的 A 记录 ─────────────────
std::cout << "\n【演示3】查询 VENERA.ISI.EDU 的 IP 地址(A 记录)\n";
{
auto rrs = resolver.resolve("VENERA.ISI.EDU", RRType::A);
std::cout << " 最终结果:\n";
for (const auto& rr : rrs) rr.print(" ");
}
// ── 演示4:CNAME 追踪 USC-ISIC.ARPA → C.ISI.EDU ─────────
std::cout << "\n【演示4】查询 USC-ISIC.ARPA 的地址(CNAME 追踪)\n";
{
auto rrs = resolver.resolve("USC-ISIC.ARPA", RRType::A);
std::cout << " 最终结果:\n";
for (const auto& rr : rrs) rr.print(" ");
}
// ── 演示5:直接查询 CNAME 类型(不追踪)─────────────────
std::cout << "\n【演示5】直接查询 USC-ISIC.ARPA 的 CNAME 记录\n";
{
auto rrs = resolver.resolve("USC-ISIC.ARPA", RRType::CNAME);
std::cout << " 最终结果(CNAME 本身):\n";
for (const auto& rr : rrs) rr.print(" ");
}
// ── 演示6:反向查找(IP → 主机名)───────────────────────
std::cout << "\n【演示6】反向查找 IP 26.6.0.65 对应的主机名\n";
{
std::string arpa_name = Resolver::ip_to_arpa("26.6.0.65");
std::cout << " IP 转 ARPA 域名: " << arpa_name << "\n";
auto rrs = resolver.resolve(arpa_name, RRType::PTR);
std::cout << " 最终结果:\n";
for (const auto& rr : rrs) rr.print(" ");
}
// ── 演示7:查询不存在的名字(NXDOMAIN)───────────────────
std::cout << "\n【演示7】查询不存在的名字 SIR-NIC.ARPA(拼写错误)\n";
{
auto rrs = resolver.resolve("SIR-NIC.ARPA", RRType::A);
if (rrs.empty()) {
std::cout << " 结果: 名称不存在(NXDOMAIN)\n";
}
}
// ── 演示8:ip_to_arpa 工具函数验证 ───────────────────────
std::cout << "\n【演示8】ip_to_arpa 工具函数测试\n";
{
auto test_ip = [](const std::string& ip) {
std::cout << " " << ip << " -> "
<< Resolver::ip_to_arpa(ip) << "\n";
};
test_ip("1.2.3.4");
test_ip("10.0.0.44");
test_ip("128.9.0.32");
}
std::cout << "\n=================================================\n";
std::cout << "演示完毕\n";
std::cout << "=================================================\n";
return 0;
}
核心速查表
| 概念 | 关键要点 |
|---|---|
| 域名树 | 有根的树,节点有 0~63 字节标签,路径从叶到根构成域名 |
| 绝对域名 | 末尾有 .,从根开始;无末尾 . 是相对名(需补全原点) |
| 域名大小写 | 存储保留大小写,比较不区分大小写 |
| 域名总长度 | 所有标签字节 + 长度字节之和 ≤ 255 \leq 255 ≤255 |
| 标签字符限制 | 字母开头,字母/数字结尾,中间可含 -,最长 63 字节 |
| RR 基本结构 | owner / type / class / TTL / RDATA |
| TTL 含义 | 缓存中该记录的最长存活时间(秒);0 表示不缓存 |
| CNAME 规则 | CNAME 节点不能有其他 RR;查询自动追踪;循环应报错 |
| 区域(Zone) | 连通的命名空间片段,有唯一顶点,可被独立管理和传输 |
| Glue 记录 | 子区域服务器在父区域中的 A 记录,解决自举问题 |
| SOA 作用 | 区域管理参数(SERIAL/REFRESH/RETRY/EXPIRE/MINIMUM) |
| 区域传输(AXFR) | 辅助服务器从主服务器下载完整区域,必须用 TCP |
| 递归 vs 迭代 | 递归:服务器代追;迭代:服务器给引用,客户端自追 |
| RA 位 | 服务器声明"我支持递归",不代表本次使用了递归 |
| RD 位 | 客户端声明"我要递归服务" |
| 反向查找 | IP 字节倒转 + .IN-ADDR.ARPA.,发 PTR 查询 |
| SLIST | 解析器的候选服务器列表,按匹配计数(接近度)排序 |
| SBELT | 解析器的"安全带",match count=-1,兜底服务器列表 |
| 否定缓存 | 响应附加 SOA,告知解析器 MINIMUM 秒内不必再查该不存在的名字 |
| 通配符 RR | *.X 匹配 X 下任何子名(至少一个完整标签);显式 RR 压制通配符 |
| AA 位 | 权威回答标志;无 AA = 缓存数据(TTL 已衰减) |
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)