跳转到内容

HTTP 代理

源码文件:19 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/http/mod.rs
  • Etemenanki/protocols/src/http/config.rs
  • Etemenanki/protocols/src/http/protocol.rs
  • Etemenanki/protocols/src/http/core.rs
  • Etemenanki/protocols/src/http/codec.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/protocols/tests/unit/http/core.rs
  • Etemenanki/protocols/tests/unit/http/protocol.rs
  • Etemenanki/protocols/tests/unit/http/codec.rs
  • Etemenanki/protocols/tests/pipeline/http.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/outbound/mod.rs
  • katana/src/outbound/mod.rs

HTTP 代理位于 protocols/src/http/。它分为两半,共用同一组线路格式辅助函数:

  • HttpCore 是服务端,一个 ProxyCoreDecode,每条连接只服务一个 HTTP/1.x 代理请求:要么是 CONNECT 隧道,要么是带绝对 URI 的普通请求,由它改写后转发给源站。
  • HttpConnect 是客户端,一个 ProxyCoreEncode codec,它向上游代理发送一个 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)]。

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 测试也使用同一常量。

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 的出站池见 入站与出站。

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 Required
Proxy-Authenticate: Basic realm="proxy"
Connection: close
HTTP/1.1 400 Bad Request
Proxy-Connection: close
Connection: close
HTTP/1.1 502 Bad Gateway
Connection: close

每一行都以 \r\n 结尾,每个响应都以一个空行结束,没有 body。

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。

对于普通请求,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:

  1. 对整个未解析区域调用 find_head_end。没有结束符时返回 Ok(0),运行时保留这些字节并继续读取;但如果该区域已达到 MAX_HEAD,则视为错误。
  2. 用 parse_request_head 只解析 data[..end]。end 之后的字节(请求 body,或客户端提前发送的隧道字节)留在运行时的缓冲区中。
  3. 调用 on_head,它先认证,再按方法分支;方法与 CONNECT 不区分大小写比较。
  4. 返回 Ok(end),只消费请求头。

请求目标是 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

进入 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 表为空时,所有请求都被接受,用户名为 "",携带 anonymous payload。
  • 否则 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 请求头从转发的请求头中移除。

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

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::codec
cargo test -p etemenanki-protocols --test pipeline http::

单用 --lib http:: 过滤还会运行 sniff::http 中 HTTP 嗅探器的测试。协议核心的测试通过 CoreHarness 驱动 HttpCore,文件中的 moves 辅助函数会滤掉 SetDeadline effect,让断言只关注行为。修改事件路径时,测试应放在 tests/unit/http/core.rs;如果修改了真实客户端或源站在线路上看到的内容,还需要 pipeline 测试。测试 介绍了这些测试工具。