HTTP 代理
源码文件:19 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/http/mod.rsEtemenanki/protocols/src/http/config.rsEtemenanki/protocols/src/http/protocol.rsEtemenanki/protocols/src/http/core.rsEtemenanki/protocols/src/http/codec.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/client.rsEtemenanki/protocols/tests/unit/http/core.rsEtemenanki/protocols/tests/unit/http/protocol.rsEtemenanki/protocols/tests/unit/http/codec.rsEtemenanki/protocols/tests/pipeline/http.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/outbound/mod.rskatana/src/outbound/mod.rs
HTTP 代理位于 protocols/src/http/。它分为两半,共用同一组线路格式辅助函数:
HttpCore是服务端,一个ProxyCoreDecode,每条连接只服务一个 HTTP/1.x 代理请求:要么是CONNECT隧道,要么是带绝对 URI 的普通请求,由它改写后转发给源站。HttpConnect是客户端,一个ProxyCoreEncodecodec,它向上游代理发送一个CONNECT,等待200,之后原样透传字节。
本页面向需要修改其中任一半的贡献者。阅读前需要了解 服务端协议核心 中的服务端协议核心契约(事件、effect、held 缓冲区和 staging 预留),以及 协议基础 中共享的 Timing、SniffPrefix 和 Passthrough 辅助类型。面向用户的设置见 HTTP 代理指南页。
| 组件 | 负责 | 交给其他部分 |
|---|---|---|
protocol.rs |
查找并解析请求头和响应头,保存固定响应,改写要转发的请求,构造 CONNECT 请求,校验 Proxy-Authorization。 |
所有 I/O。除了未被使用的异步函数 read_head,这些辅助函数都是作用于字节切片的纯函数。 |
HttpCore |
解析第一个请求头并认证,只打开一个出站流,应答 CONNECT,写入固定的错误响应,之后在两个方向上原样中继。 |
拨号、路由、读写 socket,以及针对从不发送任何字节的客户端的看门狗(由运行时和应用负责)。 |
HttpConnect |
写入一个 CONNECT 请求,解析上游的状态行,之后原样 seal 和 open 字节。 |
拨号到上游代理及其外层的 TLS(由客户端运行时和出站的传输层负责)。UDP:HTTP 代理不承载 UDP。 |
HttpServerConfig |
保存账户表、匿名 payload 和 allow_transparent。 |
解析 TOML。应用在 app/src/inbound/mod.rs 中由 HttpInboundSettings 构造它。 |
这些辅助函数移植自 Xray-core 的 HTTP 代理(proxy/http 和 common/protocol/http/headers.go);包裹它们的 sans-I/O 协议核心和 codec 是本 crate 原生实现的。
protocols/src/http/config.rs → HttpServerConfig:
pub struct HttpServerConfig<T> { pub accounts: HashMap<CompactString, (CompactString, Arc<T>)>, pub anonymous: Arc<T>, pub allow_transparent: bool,}| 字段 | 含义 |
|---|---|
accounts |
username -> (password, payload)。空表表示关闭认证。 |
anonymous |
accounts 为空时每个流携带的 payload;否则忽略。 |
allow_transparent |
接受 origin-form 的请求目标(GET /path),并从 Host 取得目的地址。Default 将其设为 false。 |
Clone 是手写实现的,因此 T 不需要 Clone 约束(所有 payload 都在 Arc 之后)。Default 要求 T: Default,因为它用 T::default() 构造匿名 payload。协议核心以 Arc<HttpServerConfig<T>> 的形式接收配置,因此同一监听器的所有连接共享一份配置。
应用一对一地映射其设置:每个 [[inbound.settings.accounts]] 条目(Account { user, pass })成为一个 payload 为 Arc::new(()) 的表项,allow_transparent 从 HttpInboundSettings 复制而来,后者标注了 #[serde(deny_unknown_fields, default)]。
服务端协议核心
Section titled “服务端协议核心”protocols/src/http/core.rs → HttpCore:
pub struct HttpCore<T> { /* private */ }
impl<T> HttpCore<T> { pub const BUF_SIZE: usize = MAX_HEAD;
pub fn new(config: Arc<HttpServerConfig<T>>, sniff: bool, source: Option<IpAddr>) -> Self; pub fn is_established(&self) -> bool;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for HttpCore<T> { type Key = Single; type Target = Flow<T>; type Error = io::Error; type TransportAddr = ();
const STAGING_RESERVE: usize = 256;
fn handle( &mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>, ) -> Result<usize, io::Error>;
fn held(&self) -> &[u8];}私有字段说明了每一部分状态所在的位置:
| 字段 | 类型 | 作用 |
|---|---|---|
config |
Arc<HttpServerConfig<T>> |
账户和 allow_transparent。 |
sniff |
bool |
入站是否开启嗅探(应用传入入站的 sniffing 设置,默认为 true)。只有目标为 IP 字面量的 CONNECT 才会嗅探。 |
source |
Option<IpAddr> |
客户端地址,复制到每个 Flow 中供路由使用。 |
timing |
Timing |
唯一的截止时间,按阶段设置。is_established() 读取它。 |
prefix |
SniffPrefix |
嗅探型 CONNECT 的最初隧道字节,保留到流打开为止。 |
rewritten |
Vec<u8> |
转发的普通请求改写后的 origin-form 请求头。 |
state |
State<T> |
下面的状态机。 |
enum State<T> { Handshake, Sniff(Flow<T>), Relay { relay: Passthrough<Single>, reply: bool, }, Done,}只有 200 尚未发出的 CONNECT 的 reply 才为 true:协议核心在 Connected 时写入它。嗅探型 CONNECT 和普通请求进入 Relay 时都是 reply: false。
held() 在 rewritten 非空时返回它,否则返回嗅探前缀。一条连接上两者至多只会填充一个,因为普通请求从不嗅探,CONNECT 也从不改写。
BUF_SIZE 是基于该协议核心的运行时所需的读缓冲区大小。应用在 app/src/serve.rs 中将其实例化为 drive::<{ HttpCore::<()>::BUF_SIZE }, _, _>,pipeline 测试也使用同一常量。
客户端 codec
Section titled “客户端 codec”protocols/src/http/codec.rs → HttpConnect:
pub struct HttpConnect { /* private: request: Vec<u8> */ }
impl HttpConnect { pub const REQUEST_MAX: usize = 1024;
pub fn new(dest: &Destination, auth: Option<(&str, &str)>) -> Self;}
impl ProxyCoreEncodeHandshake for HttpConnect { type Target = Destination; type Error = io::Error; const STAGING_RESERVE: usize = Self::REQUEST_MAX;
fn start(&mut self, out: &mut Staging<'_>) -> io::Result<Handshake>; fn reply(&mut self, wire: &mut [u8], _: &mut Staging<'_>) -> io::Result<Reply>; fn finish(&mut self, _: &mut Staging<'_>) -> io::Result<()>;}
impl ProxyCoreEncode for HttpConnect { fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> io::Result<usize>; fn open(&mut self, wire: &mut [u8]) -> io::Result<Opened>;}new 预先构造整个请求,因此 codec 唯一的状态就是这个字节向量,start 写入后即将其清空。应用和 katana 都把它包装为 ProxyClient<HTTP_BUF, HttpConnect, NoUdp>;应用部分见 出站,katana 的出站池见 入站与出站。
线路格式辅助函数
Section titled “线路格式辅助函数”protocols/src/http/protocol.rs:
pub struct Header { pub name: String, pub value: Vec<u8>,}
pub struct RequestHead { pub method: String, pub target: String, pub headers: Vec<Header>,}
pub fn find_head_end(buf: &[u8]) -> Option<usize>;pub fn parse_request_head(head: &[u8]) -> io::Result<RequestHead>;pub fn parse_response_status(head: &[u8]) -> io::Result<u16>;pub fn build_connect_request(dest: &Destination, auth: Option<(&str, &str)>) -> Vec<u8>;pub fn header_value<'a>(headers: &'a [Header], name: &str) -> Option<&'a str>;pub fn parse_request_target(target: &str) -> (bool, Option<&str>, &str);pub fn build_forward_request( method: &str, origin_target: &str, host: &str, headers: &[Header],) -> Vec<u8>;pub fn check_proxy_auth<T>( headers: &[Header], accounts: &HashMap<CompactString, (CompactString, Arc<T>)>,) -> Option<(String, Arc<T>)>;pub async fn read_head<R>(reader: &mut R) -> io::Result<Vec<u8>>where R: AsyncRead + Unpin;| 辅助函数 | 行为 |
|---|---|
find_head_end |
返回紧跟在第一个 \r\n\r\n 之后的偏移,找不到则返回 None。每次调用都扫描整个切片。 |
parse_request_head |
用 httparse::Request 解析,至多 MAX_HEADERS(128)个请求头,并复制为拥有所有权的 Header。部分解析视为错误,因此调用方必须传入完整的请求头。 |
parse_response_status |
用 httparse::Response 解析,请求头数量上限相同;只返回状态码。 |
header_value |
返回第一个名称匹配(不区分大小写)的请求头的值,按 UTF-8 解读。非 UTF-8 的值视为不存在。 |
parse_request_target |
把请求目标拆分为 (is_https, authority, origin_form)。http:// 和 https:// 前缀不区分大小写匹配;authority 在第一个 /、? 或 # 处结束;authority 之后没有内容的绝对 URI 得到 origin form /。其他情况返回 (false, None, target)。 |
build_forward_request |
构造普通请求转发时使用的 origin-form 请求头(见 转发的请求头)。 |
check_proxy_auth |
根据账户表校验 Proxy-Authorization: Basic …(见 认证)。 |
read_head |
一个逐字节读取请求头的异步读取器,上限为 MAX_HEAD。工作区中没有任何地方调用它;协议核心改为在运行时的读缓冲区上使用 find_head_end。 |
protocols/src/helpers/address.rs 中的 parse_authority 和 format_authority 在 host[:port] 字符串与 TCP Destination 之间转换。parse_authority(raw, default_port) 会去掉首尾空白,接受带或不带端口的方括号 IPv6 字面量,端口缺省时取 default_port,域名保持不解析,并拒绝不带方括号的 IPv6 字面量、无效或为空的端口以及空主机。无法解析为 IP 地址的主机一律视为域名,方括号内的文本也是如此。
服务端协议核心从不动态构造响应。它从 protocol.rs 的四个常量中选一个写入,这四个常量都能放进 256 字节的 STAGING_RESERVE:
| 常量 | 字节数 | 发送时机 | 之后 |
|---|---|---|---|
CONNECT_ESTABLISHED |
39 | CONNECT 目标已连接;嗅探型 CONNECT 则立即发送 |
Relay(嗅探型 CONNECT 为 Sniff) |
RESP_407 |
106 | 需要凭据,但凭据缺失或错误 | ShutdownTransport,Finish |
RESP_400 |
72 | 普通请求不是 absolute-form,且 allow_transparent 关闭 |
ShutdownTransport,Finish |
RESP_502 |
47 | CONNECT 目标连接失败,且 200 尚未发出 |
ShutdownTransport,Finish |
HTTP/1.1 200 Connection established
HTTP/1.1 407 Proxy Authentication RequiredProxy-Authenticate: Basic realm="proxy"Connection: close
HTTP/1.1 400 Bad RequestProxy-Connection: closeConnection: close
HTTP/1.1 502 Bad GatewayConnection: close每一行都以 \r\n 结尾,每个响应都以一个空行结束,没有 body。
codec 发送的 CONNECT 请求
Section titled “codec 发送的 CONNECT 请求”build_connect_request 用 format_authority 格式化目标(IPv6 加方括号,端口总是存在),并写出:
| 行 | 是否出现 | 内容 |
|---|---|---|
| 请求行 | 总是 | CONNECT <host>:<port> HTTP/1.1 |
Host |
总是 | 同一个 <host>:<port>。 |
Proxy-Authorization |
auth 为 Some 时 |
Basic 后接 user:pass 的标准 base64 编码。 |
Proxy-Connection |
总是 | Keep-Alive |
| 结束符 | 总是 | 一个空行。 |
请求必须能放进 REQUEST_MAX(1024 字节):两次 authority 加一份凭据。start 在写入前检查长度,超出时以 InvalidInput 失败,错误文本为 http: CONNECT request exceeds the codec's reserve。
转发的请求头
Section titled “转发的请求头”对于普通请求,build_forward_request 生成如下请求头,协议核心将其保存在 rewritten 中:
| 部分 | 内容 |
|---|---|
| 请求行 | <method> <origin-form target> HTTP/1.1。方法原样复制;版本始终为 HTTP/1.1。 |
Host |
绝对 URI 中的 authority;URI 中没有 authority 时(透明模式)为客户端的 Host 值。 |
| 其他请求头 | 客户端的每个请求头按原顺序、保持客户端发送时的名称拼写,写成 name: value,下面列出的除外。 |
Connection |
始终为 close,追加在最后。 |
| 结束符 | 一个空行。 |
从客户端请求头中删除的内容:
- 所有
Host请求头(由上面那一行取代); HOP_BY_HOP中的所有名称:proxy-connection、proxy-authenticate、proxy-authorization、te、trailers、transfer-encoding、upgrade、connection、keep-alive;- 客户端
Connection请求头的值中列出的所有名称,按小写比较。
stateDiagram-v2 [*] --> Handshake Handshake --> Handshake: 请求头不完整,consume 0 Handshake --> Relay: CONNECT,Open,reply 待发 Handshake --> Relay: 普通请求,Open 和 ForwardHeld Handshake --> Sniff: 开启嗅探的 CONNECT 到 IP,已写入 200 Sniff --> Relay: 得出 verdict、嗅探超时或客户端 EOF Handshake --> Done: 407、400 或客户端 EOF Relay --> Done: ConnectFailed Done --> [*]
Timing 在旁边按自己的阶段运行:流打开之前是 Handshake,收集前缀期间是 Sniff,从 open 推出 Effect::Open 的那一刻起是 Relay。这就是为什么 is_established() 在 Open 时、出站尚未连接之前就变为 true。Relay 通过 Passthrough 的半关闭记账或空闲截止时间结束,两者都会推出 Effect::Finish,而不改变 state。
在 Handshake 状态下收到 Event::Transport 时,on_transport:
- 对整个未解析区域调用
find_head_end。没有结束符时返回Ok(0),运行时保留这些字节并继续读取;但如果该区域已达到MAX_HEAD,则视为错误。 - 用
parse_request_head只解析data[..end]。end之后的字节(请求 body,或客户端提前发送的隧道字节)留在运行时的缓冲区中。 - 调用
on_head,它先认证,再按方法分支;方法与CONNECT不区分大小写比较。 - 返回
Ok(end),只消费请求头。
CONNECT
Section titled “CONNECT”请求目标是 authority-form,直接交给 parse_authority(&head.target, 443);不参考 Host 请求头。接下来的处理取决于协议核心是否需要嗅探。
协议核心立即打开流,并在出站连接成功前一直欠着 200:
sequenceDiagram
participant C as 客户端
participant R as 服务端运行时
participant H as HttpCore
participant O as 出站
C->>R: CONNECT example.com:443 HTTP/1.1 及请求头
R->>H: Event Transport,完整请求头
H->>R: Effect Open,reply 待发,截止时间 RELAY_IDLE_TIMEOUT
R->>O: 经路由器连接
alt 连接成功
O-->>R: 已连接
R->>H: Event Connected
H->>R: 写入 CONNECT_ESTABLISHED
R-->>C: HTTP/1.1 200 Connection established
C->>R: 隧道字节
R->>H: Event Transport
H->>R: Effect Forward,整个切片
R->>O: 字节,原样
else 连接失败
R->>H: Event ConnectFailed
H->>R: 写入 RESP_502,ShutdownTransport,Finish
R-->>C: HTTP/1.1 502 Bad Gateway
end客户端在看到 200 之前不会在 CONNECT 之后发送任何内容,那样就没有东西可嗅探。因此协议核心在解析请求头的同一次调用中写入 CONNECT_ESTABLISHED,把流暂存在 State::Sniff 中,并设置 SNIFF_TIMEOUT:
sequenceDiagram participant C as 客户端 participant R as 服务端运行时 participant H as HttpCore participant O as 出站 C->>R: CONNECT 192.0.2.10:443 HTTP/1.1 R->>H: Event Transport,完整请求头 H->>R: 写入 CONNECT_ESTABLISHED,截止时间 SNIFF_TIMEOUT R-->>C: HTTP/1.1 200 Connection established C->>R: TLS ClientHello R->>H: Event Transport H->>H: SniffPrefix push,找到域名 H->>R: 带嗅探域名的 Effect Open,前缀的 ForwardHeld R->>O: 连接,然后发送保留的前缀 R->>H: Event Connected Note over H: reply 为 false,不写入任何内容
在以下情况下流也会打开:前缀达到 SNIFF_LIMIT(Verdict::Exhausted),嗅探截止时间已过(Expired::Sniff),或客户端在窗口期内半关闭(TransportEof 先打开流,再将其半关闭)。无论哪种情况,open_sniffed 都会把 SniffPrefix::result() 复制到 Flow::sniffed,并且在保留的前缀非空时由 open 为它推出 Effect::ForwardHeld。嗅探器如何恢复域名见 嗅探。
进入 Relay 后,协议核心就是一个 Passthrough<Single>:每个传输层切片都成为一次针对整个切片的 Effect::Forward,每个出站切片都原样写入发往客户端的方向。
对于其他任何方法,on_head 分别确定目的地址和要转发的 Host:
flowchart TB
target["parse_request_target(head.target)"]
abs{"authority 存在且非空?"}
transparent{"allow_transparent?"}
r400["写入 RESP_400,关闭"]
host{"Host 请求头非空?"}
fromHost["目的地址取自 Host"]
fromUri["目的地址取自 URI authority"]
empty["错误:missing target host"]
open["parse_authority、build_forward_request、open"]
target --> abs
abs -- 是 --> host
abs -- 否 --> transparent
transparent -- 否 --> r400
transparent -- 是 --> host
host -- 是 --> fromHost --> open
host -- 否 --> fromUri
fromUri -- "有 authority" --> open
fromUri -- "无 authority" --> empty
确切的规则:
- 目的地址。
Host请求头非空时优先于 URI authority。https://目标的默认端口为 443,其他为 80。 - 转发的
Host。 URI authority 优先于Host请求头。透明模式下没有 authority,因此转发客户端的Host。 - 转发的请求目标。 URI 的 origin-form 部分(
/path?q=1);透明模式下为原样的请求目标。
协议核心把改写后的请求头保存在 rewritten 中,并调用 open(flow, false, fx),它依次推出 Effect::Open 和 Effect::ForwardHeld { range: 0..held }。运行时在出站连接成功后应用这个 held 转发,因此新的请求头会先于任何 body 字节到达源站:
sequenceDiagram participant C as 客户端 participant R as 服务端运行时 participant H as HttpCore participant O as 源站 C->>R: GET http://example.com/path?q=1 HTTP/1.1,请求头,body R->>H: Event Transport H->>H: build_forward_request 写入 rewritten H->>R: Effect Open,rewritten 的 ForwardHeld,consumed = 仅请求头 R->>O: 连接,然后 GET /path?q=1 HTTP/1.1 ... Connection close R->>H: Event Transport,body 字节 H->>H: 清空 rewritten H->>R: Effect Forward,整个切片 R->>O: body,原样 O-->>R: 响应,然后 EOF R->>H: Event Outbound,然后 OutboundEof H->>R: 写入响应,ShutdownTransport R-->>C: HTTP/1.1 200 OK ...
协议核心每条连接只解析一个请求头。此后客户端发送的所有内容都不经检查地中继到同一个出站,改写时追加的 Connection: close 则要求源站在响应后关闭连接。客户端若想发送第二个请求,必须新开一条连接。协议核心不会为普通请求写入 200:源站自己的响应就是应答。
authenticate 在按方法分支之前运行,因此对 CONNECT 和普通请求同样生效:
accounts表为空时,所有请求都被接受,用户名为"",携带anonymouspayload。- 否则
check_proxy_auth必须返回Some。它读取第一个Proxy-Authorization请求头,去掉Basic或basic前缀(scheme 的其他大小写形式一律拒绝),修剪剩余部分,按标准 base64 解码,要求结果为 UTF-8,在第一个:处拆分(因此密码可以包含冒号,用户名不行),查找用户名并要求存储的密码与之相等。这条链上任何一步失败都得到None。
得到 None 时设置 State::Done 并调用 refuse(RESP_407, fx)。成功时,流携带 NetworkUser,其中 UserAuthorization::UsernamePassword { username, password } 的 password 始终为空:凭据止步于协议核心。对于普通请求,HOP_BY_HOP 还会把 Proxy-Authorization 请求头从转发的请求头中移除。
客户端 codec
Section titled “客户端 codec”sequenceDiagram
participant A as 客户端运行时
participant X as HttpConnect
participant U as 上游代理
A->>X: start
X->>A: 写入 CONNECT 请求,Handshake AwaitReply
A->>U: CONNECT example.com:443 HTTP/1.1
U-->>A: HTTP/1.1 200 ...
A->>X: reply,传入未解析的字节
alt 尚无结束符
X->>A: Reply NeedMore
else 状态码 200
X->>A: Reply Step,consumed = 请求头,next Done
else 其他状态码
X->>A: 错误 ConnectionRefused
end
A->>X: seal 和 open,原样
reply只接受状态码200;其他任何状态码,包括其他2xx,都以ConnectionRefused拒绝,错误文本为proxy responded with status <code>。客户端运行时把 codec 的每个错误包装为InvalidData,但保留其文本,pipeline 测试正是这样在明文一侧的错误中看到407的。consumed只是请求头的长度。上游在响应头之后发送的字节留在客户端运行时的读缓冲区中,成为最初的隧道字节。seal把收到的整个明文切片写入,open把整个线路切片作为一帧返回。finish不写入任何内容:线路本身的 EOF 承载半关闭。
| 不变量 | 由谁保证 | 由哪些测试固定 |
|---|---|---|
| 完整请求头到达之前不消费任何字节。 | find_head_end 为 None 时,on_transport 返回 Ok(0)。 |
connect_to_a_domain_answers_200_once_connected(20 字节前缀消费 0),head_end_is_found_only_once_the_blank_line_arrives |
| 请求头总能放进读缓冲区,更大的请求头在协议核心中失败,而不是卡住。 | BUF_SIZE = MAX_HEAD;区域达到 MAX_HEAD 且没有结束符时,on_transport 在运行时自身的帧大小检查之前报错。 |
an_oversized_head_is_refused |
| 只消费请求头;body 或提前到达的隧道字节留给中继。 | on_transport 返回 end,而不是 data.len()。 |
plain_request_is_rewritten_into_the_held_buffer(consumed == wire.len() - 4) |
| 认证之前不打开任何流,也不向出站发送任何字节。 | on_head 首先调用 authenticate;失败则进入 refuse(RESP_407, …)。 |
missing_or_wrong_credentials_are_a_407,new_server_refuses_bad_credentials_with_407 |
CONNECT 只有在目标连接成功后才收到 200,嗅探的情况除外。 |
State::Relay { reply: true };Event::Connected 写入 CONNECT_ESTABLISHED 并清除该标志。 |
connect_to_a_domain_answers_200_once_connected |
每个 CONNECT 恰好应答一次。 |
嗅探路径在 on_head 中写入 200,并以 reply: false 打开;之后 Connected 不写入任何内容。 |
connect_to_an_ip_with_sniffing_replies_early_and_holds_the_prefix(“no second 200”),new_server_connect_to_an_ip_answers_before_the_first_bytes,new_server_vs_new_client_tcp(第二个 200 会破坏回显的字节) |
200 尚未发出而连接失败的 CONNECT 以 502 应答并关闭。 |
reply 已设置时,Event::ConnectFailed 写入 RESP_502,然后是 ShutdownTransport 和 Finish。 |
connect_refused_answers_502_and_closes |
| 改写后的请求头先于 body 到达源站。 | open 紧接 Open 推出 ForwardHeld;在 held effect 应用之前,运行时不投递任何字节事件。 |
plain_request_is_rewritten_into_the_held_buffer,new_server_forwards_a_plain_request |
| held 缓冲区绝不会在已排队的 held 区间之下被清空。 | rewritten 和 prefix 只在 Relay 字节事件(Transport 或 Outbound)开始时清空,而运行时的 pin 规则会把这类事件推迟到 held effect 应用之后。 |
plain_request_is_rewritten_into_the_held_buffer 检查 body 之后 held() 为空;pin 规则本身属于服务端运行时。 |
| 改写后的请求头不携带代理凭据和逐跳请求头。 | build_forward_request 跳过 HOP_BY_HOP 中的名称、Connection 中列出的名称以及 Host。 |
forward_request_strips_hop_by_hop,plain_request_is_rewritten_into_the_held_buffer |
| 除非开启透明模式,没有绝对 URI 的普通请求会被拒绝。 | on_head 检查 authority.is_none() && !allow_transparent。 |
origin_form_without_transparent_is_a_400 |
| 每个固定响应都能放进 staging 预留。 | STAGING_RESERVE = 256;最大的响应(RESP_407)为 106 字节。空间不足属于协议核心的 bug,表现为 staging_full()。 |
每个写入响应的测试都隐式覆盖了这一点。 |
| codec 的请求绝不超过其声明的预留。 | STAGING_RESERVE = REQUEST_MAX;start 先检查长度。 |
没有测试构造超长请求。 |
只有 200 才会打开客户端隧道,且其后的剩余字节得以保留。 |
reply 匹配 200 并返回 consumed: end。 |
connect_codec_refuses_a_non_200,connect_codec_awaits_a_200_then_passes_through |
失败路径与取消
Section titled “失败路径与取消”handle 返回的每个错误都会经由运行时结束连接,且不向客户端发送响应。固定响应覆盖的是协议核心有有用信息可告知的情况。
| 情况 | 客户端看到的结果 | 机制 |
|---|---|---|
请求头达到 MAX_HEAD 仍无结束符 |
连接关闭 | InvalidData,http head exceeds maximum size |
| 请求头格式错误,或超过 128 个请求头 | 连接关闭 | InvalidData,malformed http request: <httparse error> |
请求头只由空行组成(例如请求前多出的 \r\n\r\n) |
连接关闭 | InvalidData,incomplete http request head |
| authority 无法解析 | 连接关闭 | parse_authority:invalid port、empty authority host、ambiguous authority (bracket IPv6 literals)、malformed IPv6 authority、trailing data after IPv6 authority |
| 凭据缺失或错误 | 407,然后关闭 |
refuse(RESP_407, …),State::Done |
origin-form 请求目标,allow_transparent 关闭 |
400,然后关闭 |
refuse(RESP_400, …),State::Done |
透明模式下既无 Host 也无 authority |
连接关闭 | InvalidData,missing target host |
CONNECT 目标连接失败,200 尚未发出 |
502,然后关闭 |
Event::ConnectFailed 写入 RESP_502 |
嗅探型 CONNECT 或普通请求连接失败 |
连接关闭,无响应 | reply: false 时的 Event::ConnectFailed:ShutdownTransport,Finish |
| 中继期间出站出错 | 已写入的字节发完后连接关闭 | Passthrough::on_outbound_gone |
| 客户端在请求头完整之前关闭 | 连接关闭 | Handshake 中的 TransportEof 推出 Finish |
| 客户端开始发送请求头后停滞 | 连接关闭 | Timing 在第一个字节时设置 HANDSHAKE_TIMEOUT;Expired::Handshake 返回 handshake_timed_out():TimedOut,client did not complete its request in time |
双向都没有字节达 RELAY_IDLE_TIMEOUT |
连接关闭 | Timing::expired 推出 Finish(Expired::Idle) |
refuse 先写入响应,再推出 Effect::ShutdownTransport 和 Effect::Finish,因此运行时会在关闭写端之前把已写入的字节发出。ConnectFailed 和 OutboundError 以 debug 级别记录为 http: connect failed: … 和 http: outbound failed: …。
半关闭遵循 Passthrough:客户端 EOF 变为对出站的 Effect::Shutdown,出站 EOF 变为 ShutdownTransport,两个方向都关闭后再推出 Finish。
协议核心不持有任何 task、锁或 channel,因此自身没有需要取消的东西:运行时被 drop 时,协议核心随之销毁。客户端连接后从不发送字节时不会产生任何事件,因此也不会设置协议核心的截止时间。应用在 app/src/serve.rs 中的 drive 覆盖了这种情况:在 is_established() 变为 true 之前,它用 tokio::time::timeout(HANDSHAKE_TIMEOUT, …) 包裹运行时的每一步(失败时报 inbound handshake timed out after 10s),并在那一刻释放握手许可(permit);见 服务。
在客户端一侧,每个握手错误都会让明文流失败。最后两行来自客户端运行时,其余来自 codec:
| 情况 | 错误 |
|---|---|
请求长于 REQUEST_MAX |
InvalidInput,http: CONNECT request exceeds the codec's reserve |
状态码不是 200 |
ConnectionRefused,proxy responded with status <code> |
httparse 拒绝的响应头 |
InvalidData,malformed proxy response: <httparse error> |
| 没有状态行的响应头(只有空行) | InvalidData,proxy response missing status code |
MAX_HEAD 内没有结束符的响应头 |
InvalidData,http: proxy response head exceeds maximum size |
| 大于客户端运行时读缓冲区的响应头 | InvalidData,upstream frame larger than the client runtime's buffer |
| 上游在完整应答之前关闭 | UnexpectedEof,upstream closed during the handshake |
客户端运行时把 codec 的错误重新包装为 InvalidData 并保留其文本。应用和 katana 为该运行时设置的大小是 HTTP_BUF(16 KiB),小于 MAX_HEAD,因此对它们来说,超大的响应头会先在运行时中失败,codec 自己的检查来不及触发。
| 常量 | 值 | 位置 | 控制 |
|---|---|---|---|
MAX_HEAD |
64 KiB(64 * 1024) |
protocols/src/http/protocol.rs |
协议核心缓冲的最大请求头,或 codec 缓冲的最大响应头。 |
HttpCore::BUF_SIZE |
MAX_HEAD |
protocols/src/http/core.rs |
服务端运行时三个缓冲区(传输层读、传输层 staging、出站暂存)各自的大小。 |
MAX_HEADERS |
128 | protocols/src/http/protocol.rs |
交给 httparse 的请求头槽位数,请求和响应都适用。 |
HttpCore STAGING_RESERVE |
256 字节 | protocols/src/http/core.rs |
为固定响应保证的空间。 |
HttpConnect::REQUEST_MAX |
1024 字节 | protocols/src/http/codec.rs |
最大的 CONNECT 请求,也是 codec 的 STAGING_RESERVE。 |
HANDSHAKE_TIMEOUT |
10 秒 | protocols/src/core/mod.rs |
从第一个字节到请求头完整。 |
SNIFF_TIMEOUT |
300 毫秒 | protocols/src/sniff/mod.rs |
目标为 IP 的 CONNECT 的嗅探窗口。 |
SNIFF_LIMIT |
4 KiB | protocols/src/sniff/mod.rs |
保留的嗅探前缀的最大长度。 |
RELAY_IDLE_TIMEOUT |
300 秒 | protocols/src/core/mod.rs |
中继的空闲上限,每次 Transport 和 Outbound 事件都会刷新。 |
HTTP_BUF |
16 KiB | app/src/outbound/mod.rs,katana src/outbound/mod.rs |
HTTP 出站的客户端运行时缓冲区;实际上限制了上游响应头的大小。 |
| 默认端口 | CONNECT 和 https:// 为 443;其他为 80 |
protocols/src/http/core.rs |
authority 中没有端口时使用的端口。 |
每条连接的内存分配都受这些数值约束:rewritten 至多是一个请求头加上请求行、Host 行和 Connection: close,嗅探前缀则受 SNIFF_LIMIT 限制。
测试分两类。protocols/tests/unit/http/ 中的单元测试通过 #[path] 模块编译进 crate,因此可以访问私有项;pipeline 测试则在 loopback TCP 上运行真实的运行时。
| 文件 | 测试 | 固定的行为 |
|---|---|---|
protocols/tests/unit/http/core.rs |
connect_to_a_domain_answers_200_once_connected |
不完整的请求头不消费任何字节;目标为域名的 CONNECT 立即打开,Connected 之前不写入任何内容,并在 Open 时即视为已建立。 |
connect_refused_answers_502_and_closes |
ConnectFailed 写入 RESP_502,然后是 ShutdownTransport 和 Finish。 |
|
connect_to_an_ip_with_sniffing_replies_early_and_holds_the_prefix |
提前的 200、SNIFF_TIMEOUT 截止时间、Open 上的嗅探域名、前缀的 ForwardHeld,以及没有第二个 200。 |
|
plain_request_is_rewritten_into_the_held_buffer |
origin-form 改写、请求头剥离、Connection: close、只消费请求头、之后转发 body、held 缓冲区被清空。 |
|
origin_form_without_transparent_is_a_400 |
不打开流,写入 RESP_400 并 Finish。 |
|
missing_or_wrong_credentials_are_a_407 |
没有凭据时为 RESP_407;正确的 Basic 请求头以 alice 身份打开流。 |
|
an_oversized_head_is_refused |
MAX_HEAD 字节仍无结束符即为错误。 |
|
protocols/tests/unit/http/protocol.rs |
target_absolute_form,target_absolute_form_no_path,target_origin_form |
parse_request_target 的拆分和 https 标志。 |
forward_request_strips_hop_by_hop |
删除 HOP_BY_HOP 中的请求头和 Connection 中列出的请求头。 |
|
head_end_is_found_only_once_the_blank_line_arrives |
find_head_end 的偏移。 |
|
request_head_is_parsed_into_owned_parts |
parse_request_head、header_value、check_proxy_auth,以及对垃圾数据的拒绝。 |
|
connect_request_and_response_status_round_trip |
build_connect_request 的结果可以解析回来;parse_response_status 从固定响应中读出 200、407 和 502。 |
|
protocols/tests/unit/http/codec.rs |
connect_codec_awaits_a_200_then_passes_through |
请求行和凭据、不完整响应头时的 NeedMore、响应头之后的剩余字节、原样的 open 和 seal。 |
connect_codec_refuses_a_non_200 |
407 得到 ConnectionRefused。 |
|
protocols/tests/pipeline/http.rs |
new_server_vs_new_client_tcp |
HttpConnect 带凭据对接 HttpCore,目标为 IP 且开启嗅探(因此走提前 200 的路径):回显 100,000 字节,干净关闭。 |
new_server_refuses_bad_credentials_with_407 |
错误的密码表现为包含 407 的错误。 |
|
new_server_forwards_a_plain_request |
真实源站收到 GET /path?q=1 HTTP/1.1,客户端收到其响应。 |
|
new_server_connect_to_an_ip_answers_before_the_first_bytes |
开启嗅探时,200 在客户端发送 payload 之前到达。 |
在 Etemenanki 工作区中运行:
cargo test -p etemenanki-protocols --lib -- http::core http::protocol http::codeccargo test -p etemenanki-protocols --test pipeline http::单用 --lib http:: 过滤还会运行 sniff::http 中 HTTP 嗅探器的测试。协议核心的测试通过 CoreHarness 驱动 HttpCore,文件中的 moves 辅助函数会滤掉 SetDeadline effect,让断言只关注行为。修改事件路径时,测试应放在 tests/unit/http/core.rs;如果修改了真实客户端或源站在线路上看到的内容,还需要 pipeline 测试。测试 介绍了这些测试工具。