Hysteria 2:协议与客户端
源码文件:23 个 · 核对版本 Etemenanki 596916d
Etemenanki/protocols/Cargo.tomlEtemenanki/protocols/src/hysteria/mod.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/hysteria/protocol.rsEtemenanki/protocols/src/hysteria/quic.rsEtemenanki/protocols/src/hysteria/auth.rsEtemenanki/protocols/src/hysteria/obfs.rsEtemenanki/protocols/src/hysteria/connection.rsEtemenanki/protocols/src/hysteria/slot.rsEtemenanki/protocols/src/hysteria/connector.rsEtemenanki/protocols/src/hysteria/server/datagrams.rsEtemenanki/concepts/src/link.rsEtemenanki/concepts/src/client.rsEtemenanki/app/src/config.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/protocols/tests/unit/hysteria/protocol.rsEtemenanki/protocols/tests/unit/hysteria/auth.rsEtemenanki/protocols/tests/unit/hysteria/obfs.rsEtemenanki/protocols/tests/unit/hysteria/slot.rsEtemenanki/protocols/tests/pipeline/hysteria.rsEtemenanki/app/tests/integration/e2e_hysteria.rsEtemenanki/app/tests/support/mod.rs
Hysteria 2 是运行在 QUIC 之上的代理协议。它的设计目标是:在不知道密码的人看来,它就是一台 HTTP/3 Web 服务器。本页先介绍 protocols/src/hysteria/ 中两端共用的部分:线格式原语、数据报分片和 Salamander 混淆器;然后介绍客户端这一半,它拨号到 Hysteria 服务器,承载被路由到 hysteria2 出站的流。服务端那一半 Hy2Inbound 另有专页介绍。
Hysteria 不符合常见的出站形态,即每个流各自拨号一条上游连接。客户端每条 QUIC 连接只认证一次。此后,每个被代理的 TCP 连接都是这条连接上的一个 QUIC 双向流,UDP 则走这条连接的数据报通道。因此客户端为每个出站维持一条长期连接,并在它断开时重建,与 WireGuard 客户端维持隧道的方式相同。修改 codec、连接槽或 connector 之前请先读本页。上游规范见 Hysteria 2 协议文档。本实现移植自参考 Go 代码树。
| 模块 | 负责 | 使用方 |
|---|---|---|
protocol.rs |
QUIC varint、填充、TCPRequest/TCPResponse、UDPMessage、Defragger |
客户端和服务端 |
quic.rs |
send_udp_message(整包发送,遇到 TooLarge 再分片)和 datagram_error |
客户端;服务端只用 datagram_error |
obfs.rs |
Salamander 密钥流,以及位于 quinn 之下的 SalamanderSocket 包装 |
客户端和服务端 |
auth.rs |
唯一一次 HTTP/3 /auth 请求及其响应的读取 |
客户端 |
config.rs |
Hy2Config、Obfs、DEFAULT_MAX_CONCURRENT_STREAMS |
客户端(服务端复用 Obfs) |
connection.rs |
Hy2Conn:socket、endpoint、QUIC 与 TLS 设置、HTTP/3 设置、认证、代理流、UDP 会话 |
客户端 |
slot.rs |
ConnSlot:惰性的单飞(single-flight)连接、存活检查、重连退避 |
客户端 |
connector.rs |
Hy2Connector、Hy2Stream、Hy2DatagramLink |
app 的出站表 |
整个模块位于 etemenanki-protocols 的 hysteria Cargo feature 之后。该 feature 会引入第二套 TLS 栈(quinn、h3、h3-quinn、rustls、rustls-native-certs、rustls-pemfile、rustls-pki-types)以及 blake2。它默认关闭,所以下游 crate 只有在主动要求时才会引入这套栈。etemenanki-app 和 katana 都启用了它。工作区中其他所有 TLS 路径都使用 OpenSSL。这里使用 rustls 只是因为 quinn 不提供其他加密后端。
为什么这不是每个流各自拨号的客户端
Section titled “为什么这不是每个流各自拨号的客户端”connection.rs 的模块文档在“Why this is not an OutboundTransport”标题下解释了这一点。这里的其他客户端都为每条 circuit 拨号一条新的上游,因此可以写成一个作用于注入拨号器之上的 codec。在当前代码中,这种形态是 concepts/src/client.rs → ProxyClientConnector。Hysteria 无法这样工作:
- 凭据只在一次 HTTP/3 请求中发送一次,作用于整条 QUIC 连接。
- 被代理的 TCP 连接是在这条已认证连接上打开的流,而不是某个拨号器可以交出来的字节流。
- UDP 关联共用同一条连接的数据报通道,彼此以会话 ID 区分。
因此 Hy2Conn 自己持有 UDP socket、quinn Endpoint 和 h3 句柄。Hy2Connector 直接实现 Connector<Flow<T>>,每个流都取用同一个共享的 Hy2Conn。在服务端,同样的理由解释了为什么 Hy2Inbound 自己持有 socket,而不放在服务端协议核心之下。
本节的所有内容都在 protocols/src/hysteria/protocol.rs 中。线上的每个长度都来自对端,因此代码在用它分配内存或在缓冲区中前进之前,都会先检查它是否在范围内。
QUIC 变长整数
Section titled “QUIC 变长整数”所有长度和帧类型都使用 RFC 9000 §16 中的 QUIC varint:前两位给出宽度,值的其余部分按大端序跟在后面。它不是 gRPC 传输层使用的 protobuf base-128 varint,两者不能互换。
| 前缀位 | 字节数 | 值位数 | 最大值(Rust 名称) |
|---|---|---|---|
00 |
1 | 6 | 63(MAX_VARINT_1) |
01 |
2 | 14 | 16,383(MAX_VARINT_2) |
10 |
4 | 30 | 1,073,741,823(MAX_VARINT_4) |
11 |
8 | 62 | 4,611,686,018,427,387,903(MAX_VARINT_8) |
pub fn varint_len(value: u64) -> Result<usize, ProtocolError>pub fn put_varint(buf: &mut BytesMut, value: u64) -> Result<(), ProtocolError>pub async fn read_varint<R>(reader: &mut R) -> Result<u64, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub fn read_varint_slice(buf: &[u8]) -> Result<Option<(u64, usize)>, ProtocolError>- 编码器总是选择最窄的宽度。超过 62 位的值返回
ProtocolError::Overflow("hysteria2 varint exceeds 62 bits")。上游的varintPut在这种情况下会 panic。 - 读取函数接受非最短编码,这是 RFC 9000 允许的:
0x40 0x25读作 37。 - 当缓冲区只包含 varint 的一部分时,
read_varint_slice返回Ok(None)。切片解析器(parse_tcp_request_body、parse_tcp_response、parse_udp_message_at)都建立在它之上。
填充用于模糊每条消息的大小。填充字节取自 62 个 ASCII 字母数字字符(PADDING_CHARS)。编码器把填充作为参数传入,以便在测试中保持确定性,调用方则传入 Padding::generate()。Padding 是一个半开区间 [min, max),与上游的 Go 类型相同。
| 常量 | 范围(字节) | 用于 |
|---|---|---|
AUTH_REQUEST_PADDING |
256 到 2047 | /auth 请求的 Hysteria-Padding 头(客户端) |
AUTH_RESPONSE_PADDING |
256 到 2047 | 233 响应的 Hysteria-Padding 头(服务端) |
TCP_REQUEST_PADDING |
64 到 511 | TCPRequest(客户端) |
TCP_RESPONSE_PADDING |
128 到 1023 | TCPResponse(服务端) |
读取时,接受长度不超过 MAX_PADDING_LENGTH(4096)的任意填充。discard_padding 先检查长度上限,再把字节排入 tokio::io::sink(),因此很长的填充永远不会变成同等大小的内存分配。
TCPRequest
Section titled “TCPRequest”客户端把 TCPRequest 作为每个代理流的开头字节写出:
| 字段 | 大小 | 含义 |
|---|---|---|
| 帧类型 | varint | FRAME_TYPE_TCP_REQUEST = 0x401,编码为 0x44 0x01 |
| 地址长度 | varint | 1 到 MAX_ADDRESS_LENGTH(2048) |
| 地址 | 可变 | UTF-8 编码的 host:port authority。IPv6 主机带方括号([2001:db8::1]:443) |
| 填充长度 | varint | 读取时为 0 到 4096 |
| 填充 | 可变 | 丢弃 |
pub fn encode_tcp_request(address: &str, padding: &str) -> Result<Bytes, ProtocolError>pub async fn read_tcp_request<R>(reader: &mut R) -> Result<String, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub async fn read_tcp_request_body<R>(reader: &mut R) -> Result<String, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub fn parse_tcp_request_body(buf: &[u8]) -> Result<Option<(String, usize)>, ProtocolError>编码器拒绝空地址、超过 2048 字节的地址,以及超过 4096 字节的填充。read_tcp_request 读取并检查帧类型。read_tcp_request_body 和 parse_tcp_request_body 从地址长度开始。服务端需要后两者,因为它已经读过帧类型,并据此判定该流是代理流。
TCPResponse
Section titled “TCPResponse”服务端对每个 TCPRequest 回复一个 TCPResponse:
| 字段 | 大小 | 含义 |
|---|---|---|
| 状态 | u8 |
STATUS_OK(0x00)或 STATUS_ERROR(0x01) |
| 消息长度 | varint | 0 到 MAX_MESSAGE_LENGTH(2048) |
| 消息 | 可变 | 服务端的说明,通常为空 |
| 填充长度 | varint | 0 到 4096 |
| 填充 | 可变 | 丢弃 |
pub struct TcpResponse { pub ok: bool, pub message: String,}
pub fn encode_tcp_response(ok: bool, message: &str, padding: &str) -> Result<Bytes, ProtocolError>pub async fn read_tcp_response<R>(reader: &mut R) -> Result<TcpResponse, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub fn parse_tcp_response(buf: &[u8]) -> Result<Option<(TcpResponse, usize)>, ProtocolError>pub fn sanitise_message(raw: &[u8]) -> String这里的代码有意比上游更严格。上游把任何非零状态字节都当作错误。本代码对 0x00 和 0x01 以外的任何值都返回 ProtocolError::Malformed("hysteria2 response status"),因为发送其他值的对端不是合规的服务器。消息在被用到任何地方之前都要经过 sanitise_message。该函数以有损方式解码非法 UTF-8,删除所有控制字符(包括换行,使对端无法伪造日志行),并最多保留 MAX_MESSAGE_KEPT(128)个字符。
UDPMessage
Section titled “UDPMessage”一个 UDPMessage 放在一个 QUIC 数据报(RFC 9221)中传输,因此它不可靠,到达顺序也不确定。
| 字段 | 大小 | 含义 |
|---|---|---|
| 会话 ID | u32,大端序 |
所属关联。服务端把它映射到一个出站 UDP 端口 |
| 包 ID | u16,大端序 |
把同一数据报的各分片关联在一起。未分片时为 0 |
| 分片 ID | u8 |
本分片的序号,从 0 开始 |
| 分片数 | u8 |
分片总数。1 表示未拆分 |
| 地址长度 | varint | 1 到 2048 |
| 地址 | 可变 | 远端对端的 host:port,UTF-8 |
| 负载 | 数据报的剩余部分 | 不得为空 |
固定部分为 8 字节(UDP_HEADER_FIXED)。负载没有长度前缀:地址之后的所有内容都是负载。因此只有在头部之内才能检测到截断。QUIC 要么完整投递一个数据报,要么完全不投递,所以这不会造成问题。
pub struct UdpMessage { pub session_id: u32, pub packet_id: u16, pub frag_id: u8, pub frag_count: u8, pub addr: String, pub payload: Bytes,}
impl UdpMessage { pub fn header_size(&self) -> Result<usize, ProtocolError> pub fn encoded_size(&self) -> Result<usize, ProtocolError> pub fn encode(&self) -> Result<Bytes, ProtocolError> pub fn fragment(&self, max_size: usize) -> Result<Option<Vec<UdpMessage>>, ProtocolError>}
pub fn parse_udp_message_at(datagram: &[u8]) -> Result<(UdpHeader, Range<usize>), ProtocolError>pub fn parse_udp_message(datagram: &[u8]) -> Result<UdpMessage, ProtocolError>两个方向都拒绝空负载:encode 返回 "hysteria2 empty udp payload",解析器也一样。线格式无法表达空负载,因为它看起来与在地址之后被截断的消息完全相同。parse_udp_message_at 以区间形式返回负载,服务端因此可以无拷贝地转发。
quic.rs → send_udp_message 是客户端的发送路径:
pub fn send_udp_message(conn: &Connection, message: UdpMessage) -> io::Result<()>pub fn datagram_error(e: quinn::SendDatagramError) -> io::Error- 先整包发送消息。上游也是这样做的,在常见情况下不会增加分片头。
- 只有当 quinn 返回
SendDatagramError::TooLarge时,才读取conn.max_datagram_size():即对端通告的最大数据报帧大小与当前路径 MTU 允许值两者中的较小者。如果它为None,发送以Unsupported“hysteria2: the peer did not offer QUIC datagrams”失败。否则为消息分配一个随机的非零包 ID(rand::random_range(1..=u16::MAX)),因为零保留给未分片的情况。 UdpMessage::fragment(limit)把负载切成每块limit - header_size()字节。如果仅头部就放不下,或需要超过 255 个分片(分片数是u8),它返回None。调用方把None转为InvalidInput:“hysteria2: datagram cannot be split small enough for this connection”。
服务端不调用 send_udp_message。它的数据报核心(server/datagrams.rs)在核心启动时读取一次连接的数据报大小(取不到时回退到 MAX_DATAGRAM_FRAME_SIZE)。它把每个回复与这个大小比较,回复过大时自行调用 UdpMessage::fragment,同样使用随机的非零包 ID。两端共用 datagram_error,它把 quinn 的发送错误映射为调用方可以据以处理的 io::ErrorKind:UnsupportedByPeer 和 Disabled 变为 Unsupported,TooLarge 变为 InvalidInput,ConnectionLost 变为 BrokenPipe。
重组:Defragger
Section titled “重组:Defragger”#[derive(Debug, Default)]pub struct Defragger { packet_id: u16, fragments: Vec<Option<Bytes>>, received: usize, size: usize,}
impl Defragger { pub fn feed(&mut self, message: UdpMessage) -> Option<UdpMessage>}每个关联有一个 Defragger。在客户端它位于 Hy2DatagramLink 中,在服务端位于数据报核心中。feed 的规则如下:
- 如果
frag_count <= 1,原样返回消息。 - 如果
frag_id >= frag_count,丢弃消息。 - 如果分片属于另一个
packet_id,或分片数不同,reset会丢弃正在进行的重组。与上游一样,Defragger一次只重组一个包。两个大数据报交错到达时会损失一个包,但这样无需任何计时器就能让缓冲区有界。这一点很重要,因为分片数和到达顺序都由对端决定。 - 已经存储过的分片会被忽略,所以重复分片不能让包提前完成。
- 如果重组后的大小将超过
MAX_UDP_SIZE(4096 字节,即上游的MaxUDPSize),则清空缓冲区并丢弃该分片。此后关联照常工作。 - 最后一个缺失的分片到达时,
feed返回一条消息,其frag_id = 0、frag_count = 1,负载按序号顺序拼接。
HTTP/3 认证
Section titled “HTTP/3 认证”protocols/src/hysteria/auth.rs 移植自上游的 protocol/http.go。在客户端,它负责 /auth 交换。connection.rs 也使用 h3,用来建立 HTTP/3 连接并持有其 SendRequest 句柄。服务端在 server/ 下有自己的 h3 代码。h3 是 0.0.x 版本的 crate,所以它的 API 变化会波及这几处。
pub async fn authenticate( send_request: &mut SendRequest<h3_quinn::OpenStreams, bytes::Bytes>, password: &str, client_rx: u64,) -> io::Result<AuthOutcome>
pub struct AuthOutcome { pub udp_enabled: bool, pub server_rx: ServerRx,}
pub enum ServerRx { Unlimited, Auto, Bps(u64),}客户端发送一个没有 body 的请求,然后结束请求流。服务端要等到这一步之后才应答。
| 伪头部或头部 | Rust 名称 | 值 |
|---|---|---|
:method |
POST |
|
:authority |
AUTH_HOST |
hysteria |
:path |
AUTH_PATH |
/auth |
hysteria-auth |
HEADER_AUTH |
密码,设置为 sensitive 的 HeaderValue |
hysteria-cc-rx |
HEADER_CC_RX |
客户端的接收速率,单位为字节每秒。Hy2Conn 总是发送 0 |
hysteria-padding |
HEADER_PADDING |
AUTH_REQUEST_PADDING.generate() |
请求 URI 为 https://hysteria/auth。在 Hysteria-CC-RX 中发送 0 表示“我不知道自己的接收速率,请使用拥塞控制”。这是正确的,因为客户端没有 Brutal 拥塞控制器,而且这也是上游在未配置带宽时发送的值。
服务端只有以状态 STATUS_AUTH_OK(233)应答才表示接受客户端。任何其他状态都意味着服务端在展示它的伪装网站。此时 authenticate 返回 PermissionDenied,文本为 hysteria2 authentication rejected with status {status},其中 status 的打印形式例如 404 Not Found。除此之外不包含任何内容,因此凭据和响应 body 都不会泄露到错误中。Hy2Conn::connect 会像处理其他每个地址级错误一样,把这个错误并入最终 ConnectionRefused 错误的文本中(见下文)。状态为 233 时,读取两个响应头:
hysteria-udp(HEADER_UDP):只有当值等于true(不区分大小写比较)时,udp_enabled才为true。缺少该头表示false。hysteria-cc-rx:parse_server_rx去掉值两端的空白,把auto(任意大小写)映射为ServerRx::Auto,0映射为ServerRx::Unlimited,其他u64映射为ServerRx::Bps。缺失或无法解析的值视为Auto,因此客户端从不凭空设定服务端没有要求的速率限制。客户端以 debug 级别记录server_rx,除此之外不据此采取任何行动。
如果密码包含不能出现在头部值中的字节,build_request 以 InvalidInput 失败,附带固定消息“hysteria2 password is not a valid header value”。该消息从不引用密码的值。
Salamander 混淆
Section titled “Salamander 混淆”protocols/src/hysteria/obfs.rs 移植自上游的 extras/obfs/salamander.go。它在 quinn 之下包装 UDP socket。quinn 把完整的 QUIC 包交给这个包装层,完全不知道混淆的存在。
| 字段 | 大小 | 含义 |
|---|---|---|
| Salt | 8 字节(SALT_LEN) |
每个包都是新的随机字节 |
| Body | 可变 | 与 BLAKE2b-256(psk ‖ salt) 异或后的 QUIC 包,32 字节的密钥(KEY_LEN)重复到与包等长 |
Salamander 是混淆而不是加密:负载由 QUIC 自身的 TLS 保护。新鲜的 salt 保证相同的明文在线上永远不会两次呈现相同的样子。预共享密钥至少要有 MIN_PSK_LEN(4)字节,否则 Salamander::new 会拒绝,与上游的 ErrPSKTooShort 一致。
impl Salamander { pub fn new(psk: &[u8]) -> Result<Self, ProtocolError> pub fn obfuscate(&self, payload: &[u8], out: &mut Vec<u8>) pub fn deobfuscate_in_place( &self, buf: &mut [u8], len: usize, stride: usize, ) -> Option<(usize, usize)>}
pub struct SalamanderSocket { inner: Arc<dyn AsyncUdpSocket>, obfs: Salamander, scratch: Mutex<Vec<u8>>,}
impl SalamanderSocket { pub fn new(inner: Arc<dyn AsyncUdpSocket>, psk: &[u8]) -> Result<Self, ProtocolError>}flowchart LR
quinn["quinn endpoint"]
subgraph sal["SalamanderSocket"]
tx["try_send:生成新 salt,异或写入 scratch"]
rx["poll_recv:按 stride 切分,去掉 salt,异或,紧凑排列"]
end
inner["内层 AsyncUdpSocket"]
wire(("线上的 UDP"))
quinn -->|"每个 Transmit 一个 QUIC 包"| tx
tx -->|"salt 加 body,segment_size 为 None"| inner
inner --> wire
wire --> inner
inner -->|"一批 RecvMeta"| rx
rx -->|"去混淆后的包"| quinn
Offload
Section titled “Offload”- 拒绝 GSO。 每个数据报都需要自己的 salt,所以一个 salt 无法覆盖打包进同一缓冲区的一批数据报。
max_transmit_segments返回1,告诉 quinn 不要批量发送。try_send还会拒绝任何segment_size小于其内容长度的Transmit,返回InvalidInput“hysteria2 obfs: segmented transmit is not supported”。这项检查是为了防范将来的改动,而不是针对当前会执行到的路径。 - 拆分 GRO。 内层 socket 上的接收 offload 无法关闭,所以
max_receive_segments如实报告内层 socket 的值。deobfuscate_in_place以stride为步长遍历合并后的缓冲区(最后一个数据报可能更短),用各自的 salt 解开每个数据报,并把结果向缓冲区前部紧凑排列。每个数据报缩短 8 字节,因此写位置永远不会越过读位置。它返回新的(len, stride - SALT_LEN)。只要有任何一段不超过 8 字节,就丢弃整个缓冲区,因为从中间去掉一个数据报会破坏固定步长的布局。 - 包装层无法区分 Salamander 包和其他任何 9 字节及以上的数据报。它对收到的任何内容都去掉 8 字节并做异或,quinn 解密失败时会丢弃结果。
- 随后
deobfuscate_batch丢弃失败的槽位,把存活的槽位向前复制,并改写它们的RecvMeta。如果整批都是垃圾,poll_recv会循环并再次轮询内层 socket,而不是返回Ready(Ok(0)),否则 quinn 的 endpoint 驱动会空转。socket 读空之后,内层调用返回Pending并已注册 waker。 may_fragment直接透传内层 socket 的结果,因为 quinn 据此推导allow_mtud。混淆后的包比 quinn 认为自己发出的包大 8 字节。这是自洽的,因为路径 MTU 探测探的就是混淆后的大小。上游同样没有对此做补偿,代码注释也警告不要去“修复”它。
scratch 发送缓冲区放在 parking_lot::Mutex 之后,因为 try_send 接收的是 &self。一个 quinn endpoint 由单个任务驱动其 socket,所以这把锁不存在争用。
pub struct Hy2Config { pub server: Destination, pub server_name: String, pub password: String, pub verify: VerifyMode, pub obfs: Option<Obfs>, pub max_concurrent_streams: usize,}
pub enum Obfs { Salamander { psk: Vec<u8> },}
pub const DEFAULT_MAX_CONCURRENT_STREAMS: usize = 102_400;Hy2Config、Obfs、Salamander 和 SalamanderSocket 都有手写的 Debug 实现。Hy2Config 打印服务器、服务器名、校验模式、混淆设置和流数上限。混淆相关类型只打印密钥的长度。它们都不会打印密码或密钥。
app 如何构建它
Section titled “app 如何构建它”app/src/outbound/mod.rs → build_hy2_config 为 protocol 为 hysteria2、hysteria 或 hy2 的出站构建配置。每项检查都是 fail closed(出错即拒绝):
| 设置 | 转换为 | 何时拒绝 |
|---|---|---|
server、port |
server,一个带 DialNetwork::Udp 的 Destination |
任一缺失 |
settings.server_name |
server_name,默认为 server |
|
settings.password |
password |
缺失或为空 |
settings.allow_insecure、settings.ca_file |
VerifyMode::Insecure、CustomCa(pem)(在此处读取文件)或 System |
两者同时设置,或无法读取 CA 文件 |
settings.obfs、settings.obfs_password |
Some(Obfs::Salamander { psk }),PSK 即密码按原样写出的字节 |
设置了 obfs_password 但没有 obfs;obfs 不是 "salamander"(精确比较,区分大小写);或 obfs_password 缺失或不足 4 字节 |
settings.max_concurrent_streams |
max_concurrent_streams,默认为 DEFAULT_MAX_CONCURRENT_STREAMS |
0 |
address_family |
传给 Hy2Connector::with_address_family 的 AddressFamilyStrategy |
不是已知策略 |
Hysteria2OutboundSettings 使用 deny_unknown_fields,所以拼错的键会报错。该出站还会拒绝 tcp 以外的 stream network 和 none 以外的 stream security(reject_stream),因为它从不读取 [stream]。upstream_dest_opt 对它返回 None,所以 Hysteria 出站不能作为负载均衡器成员:负载均衡器的健康探测是 TCP 连接,而 Hysteria 服务器只监听 UDP。connector 通过 with_resolver 使用 app 的 Resolver。
pub struct Hy2Conn { _endpoint: Endpoint, conn: quinn::Connection, _h3: h3::client::SendRequest<h3_quinn::OpenStreams, bytes::Bytes>, _h3_driver: AbortOnDropHandle<()>, streams: Arc<Semaphore>, sessions: Sessions, next_session: AtomicU32, _datagram_pump: Option<AbortOnDropHandle<()>>, pub outcome: AuthOutcome,}
type Sessions = Arc<Mutex<HashMap<u32, mpsc::Sender<UdpMessage>>>>;
impl Hy2Conn { pub async fn connect( config: &Hy2Config, address_family: AddressFamilyStrategy, resolver: &Resolver, ) -> io::Result<Self> pub fn is_alive(&self) -> bool pub fn remote_address(&self) -> SocketAddr pub fn max_datagram_size(&self) -> Option<usize> pub async fn open_tcp( &self, address: &str, ) -> io::Result<(OwnedSemaphorePermit, quinn::SendStream, quinn::RecvStream)> pub fn open_udp(&self) -> io::Result<UdpSession> pub fn send_udp(&self, session: u32, addr: &str, payload: Bytes) -> io::Result<()>}
pub fn client_config(config: &Hy2Config) -> io::Result<ClientConfig>以下划线开头的字段只是为了被持有:
_endpoint让 endpoint 驱动和 socket 保持存活。_h3_driver和_datagram_pump是AbortOnDropHandle,所以Hy2Conn被丢弃时两个任务都会停止。_h3最为关键。最后一个SendRequest被丢弃时,h3会以HTTP_NO_ERROR关闭 QUIC 连接,并连带关闭所有代理流。所以这个句柄被存下来,之后再也不使用。
is_alive 就是 conn.close_reason().is_none():它反映的是承载代理流的 QUIC 连接,而不是 HTTP/3 层。
连接、认证与第一个流
Section titled “连接、认证与第一个流”Hy2Conn::connect 用 destination_to_socketaddrs 解析 config.server,该函数会应用地址族策略和解析器。如果得不到任何地址,返回 AddrNotAvailable。然后它按地址族策略给出的顺序尝试每一个候选地址,因此同时有 A 和 AAAA 记录的主机在某一地址族不可达时仍能连上。每次尝试(connect_to)都有自己的 CONNECT_TIMEOUT(10 秒),这一预算涵盖绑定 socket、QUIC 握手、HTTP/3 设置和 /auth 往返。如果所有尝试都失败,错误为 ConnectionRefused,文本为 hysteria2: no address answered (…),其中列出每个地址各自的错误或 timed out。每次尝试的错误类型,例如 /auth 被拒的 PermissionDenied 或缺少信任库的 NotFound,只以文本形式保留在这条消息中。
sequenceDiagram participant R as Runtime participant C as Hy2Connector participant S as ConnSlot participant T as connect 任务 participant H as Hysteria 服务器 R->>C: connect(flow) C->>S: acquire() S->>T: spawn Hy2Conn::connect T->>H: QUIC Initial、TLS 1.3、ALPN h3、SNI H-->>T: 握手完成 T->>T: h3::client::new,spawn h3 驱动 T->>H: POST /auth,携带 Hysteria-Auth、CC-RX 0、Padding H-->>T: 233,携带 Hysteria-UDP、Hysteria-CC-RX T->>T: 若启用 UDP 则 spawn 数据报泵 T->>S: state = Ready(conn) T-->>C: 共享结果 Ok(conn) C->>C: try_acquire_owned 流许可 C->>H: open_bi,然后发送 TCPRequest 0x401 H-->>C: TCPResponse 状态 0x00 C-->>R: Outbound::Stream(Hy2Stream)
针对单个地址的每次尝试执行以下步骤:
bind_socket把一个std::net::UdpSocket绑定到目标地址族的未指定地址(0.0.0.0:0或[::]:0),并用quinn::TokioRuntime包装。如果设置了obfs,再把结果包进SalamanderSocket。Endpoint::new_with_abstract_socket为这一条连接创建一个仅客户端的 endpoint(EndpointConfig::default(),无服务端配置)。endpoint.connect_with(client_config(config)?, addr, &config.server_name)执行 QUIC 握手。server_name既是 SNI,也是证书必须匹配的名称。h3::client::new(h3_quinn::Connection::new(conn.clone()))在连接的一个克隆上建立 HTTP/3。为驱动 spawn 的任务等待driver.poll_close,并以 debug 级别记录连接的结束方式,文本经过auth::describe(即sanitise_message)处理。auth::authenticate(&mut send_request, &config.password, 0)执行/auth交换。- 如果
outcome.udp_enabled为 true,spawnpump_datagrams。否则不启动它,因为它会永远等待一个没有人写入的通道。 - 以
config.max_concurrent_streams个许可创建流信号量,next_session从1开始。
QUIC 与 TLS 设置
Section titled “QUIC 与 TLS 设置”client_config 构建 quinn 的 ClientConfig:
| 设置 | 值 | Rust 名称 |
|---|---|---|
| ALPN | h3 |
ALPN_H3 |
| 流接收窗口 | 8 MiB | STREAM_RECEIVE_WINDOW |
| 连接接收窗口 | 20 MiB(STREAM_RECEIVE_WINDOW / 2 * 5) |
CONNECTION_RECEIVE_WINDOW |
| 空闲超时 | 30 秒 | MAX_IDLE_TIMEOUT |
| Keep-alive 间隔 | 10 秒 | KEEP_ALIVE |
这些值与上游客户端的默认值一致。client_config 在每次连接尝试时运行,所以信任库或 PEM 的问题会在连接槽发起连接时暴露,而不是在构建配置时。tls_config 使用 ring provider 构建 rustls ClientConfig,通过 builder_with_provider 显式指定。如果某个下游 crate 在同一次构建中启用了第二个加密 provider feature,ClientConfig::builder() 会 panic。QUIC 总是承载 TLS 1.3(RFC 9001)。校验方式由 VerifyMode 决定:
VerifyMode |
信任 | 说明 |
|---|---|---|
System |
system_roots():来自 rustls_native_certs::load_native_certs 的平台证书库 |
无法解析的证书会被跳过。如果一个都没加载到,结果为 NotFound“hysteria2: no system root certificates could be loaded” |
CustomCa(pem) |
系统根证书加上 PEM 文件中的每个证书 | 它先加载系统根证书,所以平台证书库为空时这里同样失败,错误同为 NotFound。无法解析的 PEM 条目,或 rustls 拒绝加入的证书,为 InvalidInput。得不到任何证书的文件会被拒绝(InvalidInput“hysteria2: the CA file contains no certificates”),而不是退回到仅用系统根证书 |
Insecure |
NoVerification |
接受任何证书链和任何名称。它仍会通过 rustls 自带的 verify_tls12_signature 和 verify_tls13_signature 校验握手签名。只有显式设置 allow_insecure 才会走到这里 |
代理流:open_tcp
Section titled “代理流:open_tcp”HTTP/3 和代理共用一条连接。服务端通过流的第一个 varint 识别代理流:0x401 位于 HTTP/3 没有定义帧类型的区间,因此服务端的 HTTP/3 层会把这些流转交给代理。所以客户端用 conn.open_bi() 直接在 QUIC 连接上打开代理流,从不经过 h3,与上游的 conn.OpenStream() 一样。
- 许可。 先执行
streams.try_acquire_owned()。如果没有剩余许可,调用立即以WouldBlock“hysteria2: connection is at its concurrent-stream limit”失败,而不是等待。 - 打开。
open_bi()在OPEN_STREAM_TIMEOUT(5 秒)内运行。超时则错误为TimedOut,失败则错误为BrokenPipe。 - 请求。 客户端用 quinn 自带的
SendStream::write_all写出encode_tcp_request(address, &TCP_REQUEST_PADDING.generate())。 - 响应。 客户端在
open_tcp返回之前读取TCPResponse。如果服务端拒绝,错误为ConnectionRefused“hysteria2: server refused the target”,经过清理的消息非空时后面再接上: {message}。这样,不可达的目标表现为连接失败,而不是一个打开后立即结束的流。出于同样的原因,没有实现上游的 “fast open” 模式。 - 返回。 许可与流的两半一起返回。
UDP 会话:open_udp、send_udp 与数据报泵
Section titled “UDP 会话:open_udp、send_udp 与数据报泵”pub struct UdpSession { pub id: u32, pub inbound: mpsc::Receiver<UdpMessage>, pub guard: SessionGuard,}
pub struct SessionGuard { id: u32, sessions: Sessions,}open_udp 在两种情况下以 Unsupported 拒绝:服务端没有通告 UDP(outcome.udp_enabled 为 false),或对端没有提供 QUIC 数据报(max_datagram_size() 为 None)。如果客户端接受了它无法投递的数据报,这些数据报会在到达时被静默丢弃。已有 MAX_UDP_SESSIONS(256)个会话时,open_udp 也会以 WouldBlock 拒绝。会话 ID 来自 next_session.fetch_add(1),会回绕。分配器跳过 0 和仍在使用的 ID,尝试 MAX_UDP_SESSIONS + 1 次后以“hysteria2: no free UDP session id”放弃。每个会话有一个有界的 mpsc::channel(UDP_SESSION_BACKLOG)(256 条消息)。
SessionGuard 被丢弃时,会从映射表中移除对应条目。协议中没有关闭会话的消息,所以服务端在自己的空闲超时后才释放它绑定的端口。guard 与接收端分开,是为了让中继可以把两者移到不同的任务中。
send_udp 构建一个未分片的 UdpMessage(packet_id: 0、frag_count: 1),并调用 quic::send_udp_message,后者仅在需要时才分片。
flowchart LR
conn["quinn::Connection read_datagram"]
pump["pump_datagrams 任务"]
parse{"parse_udp_message"}
map{"会话 ID 在 Sessions 中?"}
chan["会话 mpsc,256"]
link["Hy2DatagramLink:先 Defragger,再 parse_authority"]
drop(("丢弃"))
conn --> pump --> parse
parse -->|"Err"| drop
parse -->|"Ok"| map
map -->|"否"| drop
map -->|"是:try_send"| chan
chan -->|"已满"| drop
chan --> link
pump_datagrams 每条连接一个任务,它绝不能阻塞:
- 无法解析的数据报会被丢弃,并记录一条 trace 日志。
- 会话的 sender 在锁内克隆出来,因此发送时从不持有锁。
- 对已满通道的
try_send会丢弃新到达的消息,所以一个慢速关联不会拖住其他所有关联。 - 当
read_datagram失败,也就是连接已经不在时,循环结束。
数据报泵转交的消息仍是分片状态:重组由各个关联自己负责。
protocols/src/hysteria/slot.rs 让一个出站的所有流共享同一个 Hy2Conn,惰性地创建它,并在它断开后重建。
pub enum SlotState { Idle, Connecting(PendingConnect), Ready(Arc<Hy2Conn>),}
pub struct ConnSlot { pub state: SlotState, pub failures: u32, pub retry_at: Option<Instant>, pub started_at: Option<Instant>,}
pub type ConnectResult = Result<Arc<Hy2Conn>, Arc<io::Error>>;pub type PendingConnect = Shared<BoxFuture<'static, ConnectResult>>;
pub enum Acquired { Ready(Arc<Hy2Conn>), Pending(PendingConnect),}
pub async fn acquire( slot: &Arc<Mutex<ConnSlot>>, config: &Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: &Resolver,) -> io::Result<Arc<Hy2Conn>>
pub fn inspect_slot( slot: &Arc<Mutex<ConnSlot>>, config: &Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: &Resolver,) -> io::Result<Acquired>
pub fn start_connect( slot: Arc<Mutex<ConnSlot>>, config: Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: Resolver,) -> PendingConnect锁是 parking_lot::Mutex。连接槽的所有决策都在 inspect_slot 中做出,这是一个持锁且不 await 任何东西的同步函数。acquire 只在释放 guard 之后才 await 返回的 PendingConnect,所以缓慢的握手只会阻塞正在等待它的流。io::Error 不是 Clone,而 Shared 要求输出可 Clone,所以错误以 Arc<io::Error> 传递。acquire 再把它重建为一个类型和文本都相同的新 io::Error。
stateDiagram-v2 [*] --> Idle Idle --> Connecting: inspect_slot,未在退避 Idle --> Idle: 正在退避,返回 connection_down Connecting --> Connecting: 后来的调用方共享同一个 future Connecting --> Ready: 任务 Ok,note_success,设置 started_at Connecting --> Idle: 任务 Err,note_failure Ready --> Ready: is_alive,分发 Arc 克隆 Ready --> Idle: 下次 acquire 时发现已断开
- 单飞。 发现
Connecting(pending)的调用方会克隆同一个Sharedfuture。在一次握手期间到达的两个流绝不会打开两条连接。 - 由任务而不是等待方写回结果。
start_connect在一个分离的tokio::spawn中运行Hy2Conn::connect,把结果写入连接槽,然后通过oneshot发送。因此即使所有等待方都离开了(generation 被取消,或客户端挂断),握手也会完成,结果留给下一个流使用。如果任务未发送结果就消失,等待方会得到“hysteria2: the connect task disappeared”。 - 惰性存活检查。 没有任何东西在后台监视连接。断开的连接要等下一个流调用
acquire时才被发现:inspect_slot看到Ready(conn)且!conn.is_alive(),以 warn 级别记录hysteria2: connection closed, reconnecting,并转入Idle。仍持有Arc<Hy2Conn>的流会让那个Hy2Conn一直存活到它们结束。 - 退避。
note_failure递增failures,并设置retry_at = now + min(RECONNECT_BACKOFF_BASE × 2^min(failures, 5), RECONNECT_BACKOFF_MAX)。基数为 1 秒、上限为 30 秒时,连续失败依次等待 2 秒、4 秒、8 秒、16 秒,从第五次失败起为 30 秒。第一次等待是 2 秒而不是 1 秒,因为failures在计算延迟之前就已递增。只要backing_off()为 true,inspect_slot就返回connection_down()(BrokenPipe“hysteria2: connection is down, waiting before the next attempt”),不发起拨号。 - 早夭的连接。 发现断开的连接时,
died_young()检查它的存活时间是否短于MIN_HEALTHY_LIFETIME(10 秒)。没有started_at的连接算作早夭。早夭算作一次失败(note_failure),存活较久的则清除惩罚(note_success),连接槽会立即重连。没有这条规则,一个接受连接后立刻关闭它的服务器(例如因为达到了用户上限)会被每个到达的流重新拨号。成功连接会调用note_success,把failures重置为 0。所以在这种模式下,每次早夭只计一次失败,连接槽在下次尝试前等待 2 秒,等待时间不会增长。
connector
Section titled “connector”pub struct Hy2Connector { config: Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: Resolver, slot: Arc<Mutex<ConnSlot>>, udp_warned: Arc<AtomicBool>,}
impl Hy2Connector { pub fn new(config: Hy2Config) -> Self pub fn with_address_family(config: Hy2Config, address_family: AddressFamilyStrategy) -> Self pub fn with_resolver(mut self, resolver: Resolver) -> Self pub fn slot(&self) -> &Arc<Mutex<ConnSlot>>}
type DialFuture = Pin<Box<dyn Future<Output = io::Result<Outbound<Hy2Stream, Hy2DatagramLink>>> + Send>>;
impl<T: Send + Sync + 'static> Connector<Flow<T>> for Hy2Connector { type Stream = Hy2Stream; type Datagram = Hy2DatagramLink; type Future = DialFuture;
fn connect(&mut self, flow: Flow<T>) -> DialFuture}Hy2Connector 的每个克隆都通过 Arc 共享同一个 slot 和 udp_warned,所以这些克隆共用一条连接。connect 把 self 克隆进一个装箱的 dial(flow.destination)。dial 先取得连接,再按 dest.network 分支:
DialNetwork::Udp调用open_udp(),返回Outbound::Datagram(Hy2DatagramLink { conn, session, defragger })。如果open_udp以Unsupported失败,connector 只在第一次(udp_warned.swap(true))记录一条警告“hysteria2: …; datagrams routed to this outbound are dropped”,这样把 UDP 路由到不支持 UDP 的服务器的运维人员能看到出了问题的迹象。- 其他任何网络调用
open_tcp(&format_authority(&dest)),返回Outbound::Stream(Hy2Stream { .. })。
pub struct Hy2Stream { send: quinn::SendStream, recv: quinn::RecvStream, _permit: OwnedSemaphorePermit, _conn: Arc<Hy2Conn>,}
pub struct Hy2DatagramLink { conn: Arc<Hy2Conn>, session: UdpSession, defragger: Defragger,}Hy2Stream 是一个原始的 QUIC 双向流。AsyncRead 转发给 RecvStream,AsyncWrite 转发给 SendStream。poll_shutdown 发送 QUIC FIN,服务端将其视为正常结束。丢弃流同样会结束发送端:quinn 0.11 的 SendStream 在未结束就被丢弃时会发送 FIN,被丢弃的 RecvStream 则请求服务端停止发送。(poll_shutdown 上的注释说丢弃会重置流,但 quinn 并不这样做。)流在整个生命周期内都持有它的许可和一个 Arc<Hy2Conn>。
Hy2DatagramLink 以 Addr = Destination 实现 DatagramLink:
poll_send_to把目标格式化为 authority,把缓冲区复制成Bytes,调用send_udp,并立即返回Ready。它从不返回Pending。poll_recv_from从会话通道中拉取消息,并逐条喂给Defragger。它用parse_authority(&addr, 0)解析地址,跳过地址无法解析或端口为0的消息。它最多复制buf.remaining()字节,并返回带network: DialNetwork::Udp的来源地址。如果会话通道关闭,返回BrokenPipe“hysteria2: the connection behind this association is gone”。通道的 sender 会一直留在Sessions映射表中,直到链路自己的SessionGuard被丢弃,而链路还持有Hy2Conn,所以连接丢失并不会关闭通道。连接断开的表现是poll_send_to返回BrokenPipe(quinn 的ConnectionLost),以及接收端一片沉默。
| 不变量 | 机制 | 由测试固定 |
|---|---|---|
| 一条已认证的连接承载多个代理流 | Hy2Conn::_h3 持有最后一个 SendRequest,所以 h3 永远不会关闭连接 |
one_connection_carries_several_proxy_streams_after_auth、proxy_streams_are_independent_of_each_other(e2e_hysteria.rs);new_server_vs_new_client_tcp(pipeline/hysteria.rs) |
| 并发的流共享一次连接 | 在锁内克隆的 SlotState::Connecting(Shared) |
concurrent_callers_share_one_connect、stream_and_datagram_circuits_share_one_connect(unit/hysteria/slot.rs) |
| 握手比它的等待方活得更久 | start_connect 中分离的 tokio::spawn 写入连接槽 |
the_connect_finishes_even_when_every_waiter_leaves |
| 宕机的服务器不会被每个流都拨号一次 | note_failure,以及在 inspect_slot 中检查的 backing_off |
a_backing_off_slot_refuses_without_dialling、a_failed_connect_leaves_the_slot_retryable、backoff_grows_with_each_failure_and_is_capped、a_healthy_connection_clears_the_penalty、a_connection_that_never_started_counts_as_dying_young |
| 流数上限覆盖整个中继过程,结束后释放 | 存放在 Hy2Stream::_permit 中的 OwnedSemaphorePermit |
stream_permits_are_released_when_a_circuit_ends(e2e_hysteria.rs,上限为 4,共 20 条 circuit) |
| 被拒绝的目标表现为连接失败,而不是空流 | open_tcp 在返回之前读取 TCPResponse |
a_failed_connect_is_answered_with_a_refusal(pipeline/hysteria.rs) |
| 不中继 UDP 的服务器会被报告,而不是静默丢弃 | open_udp 检查 udp_enabled 和 max_datagram_size |
a_server_without_udp_refuses_associations(pipeline/hysteria.rs) |
| 凭据从不出现在错误中 | 拒绝文本只含状态;HeaderValue::set_sensitive;无法编码的密码使用固定消息 |
the_credential_is_marked_sensitive_and_never_printed、an_unencodable_password_is_refused_without_quoting_it(unit/hysteria/auth.rs);a_wrong_credential_is_refused(pipeline/hysteria.rs);a_wrong_password_is_refused_without_echoing_it(e2e_hysteria.rs) |
| 服务端文本无法伪造日志行 | sanitise_message:不含控制字符,最多 128 个字符 |
sanitise_message_strips_control_characters、sanitise_message_caps_length_and_tolerates_bad_utf8、tcp_response_sanitises_the_message_it_returns |
| varint 采用 QUIC 格式,且永不 panic | put_varint/read_varint,宽度由两个前缀位决定 |
varint_matches_rfc9000_vectors、varint_accepts_non_minimal_encoding、varint_width_boundaries、varint_above_62_bits_is_refused_not_panicked、varint_truncated_at_every_boundary |
| 长度在分配之前检查 | read_length_prefixed、discard_padding、parse_length_prefixed |
tcp_request_rejects_out_of_range_address_lengths、tcp_response_rejects_out_of_range_lengths、tcp_response_truncated_at_every_boundary、tcp_response_padding_shorter_than_promised_is_truncation、udp_truncated_in_the_header_is_refused_and_never_panics |
0x00 和 0x01 以外的状态字节被拒绝 |
read_tcp_response 和 parse_tcp_response 中的 match status |
tcp_response_rejects_an_undefined_status_byte |
| 重组占用的内存有界 | Defragger:一次一个包、MAX_UDP_SIZE、序号与重复检查 |
reassembly_stops_at_the_maximum_datagram_size、a_new_packet_id_discards_the_one_in_progress、a_repeated_fragment_does_not_complete_the_datagram、a_fragment_index_past_the_count_is_dropped、fragments_arriving_out_of_order_still_reassemble |
| 分片符合对端上限,并能与 Go 互通 | send_udp_message 只在 TooLarge 时分片,使用非零包 ID |
fragments_reassemble_into_the_original、a_limit_below_the_header_cannot_be_fragmented、a_payload_needing_more_than_255_fragments_is_refused;a_datagram_too_large_for_one_frame_is_fragmented(e2e_hysteria.rs);new_server_vs_new_client_udp |
| Salamander 是 BLAKE2b-256,每个包一个 salt | Salamander::keystream,obfuscate 中生成新 salt |
keystream_matches_an_independent_blake2b256、keystream_repeats_every_32_bytes、each_packet_gets_a_fresh_salt、obfuscate_does_not_leak_the_previous_packet |
| 不用 GSO;GRO 按数据报逐个解开 | max_transmit_segments() == 1,deobfuscate_in_place 按步长遍历 |
segmented_transmit_is_refused_and_never_requested、a_coalesced_batch_is_unwrapped_per_datagram、a_coalesced_batch_with_a_short_tail_is_unwrapped、a_coalesced_batch_with_an_impossible_tail_is_discarded、receive_segments_and_fragmentation_follow_the_inner_socket |
| 垃圾批次绝不会让 quinn 空转 | poll_recv 循环,直到有包存活或内层 socket 返回 Pending |
a_batch_of_junk_yields_pending_not_zero、survivors_are_packed_to_the_front_of_the_batch |
| 混淆不匹配时失败,绝不回退到明文 | 配置了混淆时,两个方向的每个包都经过 SalamanderSocket;不使用相同密钥的对端收发的都只是 quinn 无法解密的包,所以握手无法完成 |
salamander_against_a_plain_server_fails_rather_than_falling_back;与 Go 的线上兼容性见 app_socks_to_hysteria2_with_salamander(e2e_hysteria.rs) |
| debug 输出中没有 PSK | Salamander、SalamanderSocket、Obfs、Hy2Config 的手写 Debug |
debug_output_never_contains_the_psk(unit/hysteria/obfs.rs) |
失败路径与取消
Section titled “失败路径与取消”| 位置 | 条件 | 结果 |
|---|---|---|
build_hy2_config(app) |
无法读取 CA 文件 | std::fs::read 的错误,在构建配置时报告 |
Hy2Conn::connect |
名称解析不出可用地址 | AddrNotAvailable |
Hy2Conn::connect |
所有地址都失败或超时 | ConnectionRefused,列出每个地址及其错误文本 |
connect_to(单个地址) |
服务器名对 QUIC 无效 | InvalidInput,包含在上面的 ConnectionRefused 中报告 |
connect_to(单个地址) |
QUIC 握手失败(包括 TLS 校验失败) | ConnectionRefused,包含在上面的 ConnectionRefused 中报告 |
tls_config(单个地址) |
没有系统根证书,或 CA 文件中没有可用证书 | NotFound 或 InvalidInput,包含在上面的 ConnectionRefused 中报告 |
authenticate(单个地址) |
状态不是 233 |
只含状态的 PermissionDenied,包含在上面的 ConnectionRefused 中报告 |
acquire |
connect 任务没有结果就结束 | Other“hysteria2: the connect task disappeared” |
inspect_slot |
正在退避 | 来自 connection_down() 的 BrokenPipe |
open_tcp |
没有剩余的流许可 | WouldBlock |
open_tcp |
open_bi 超过 5 秒 |
TimedOut |
open_tcp |
服务端应答 STATUS_ERROR |
ConnectionRefused,附带经过清理的消息 |
open_tcp |
TCPResponse 格式错误或被截断 |
InvalidData(格式错误)或 UnexpectedEof(截断),由 ProtocolError 转换而来 |
open_udp |
服务端不提供 UDP,或没有 QUIC 数据报 | Unsupported(每个 connector 只警告一次) |
open_udp |
已有 256 个会话,或没有空闲 ID | WouldBlock |
send_udp |
数据报被禁用、过大无法拆分、连接丢失 | Unsupported、InvalidInput、BrokenPipe |
send_udp |
负载为空 | InvalidData“hysteria2 empty udp payload” |
Hy2DatagramLink::poll_recv_from |
会话通道关闭 | BrokenPipe(链路自己的 sender 保持注册,所以链路存活期间不会发生) |
取消按以下方式进行:
- 如果某个等待方丢弃了 dial future,只有它自己的等待被取消。connect 任务仍会运行到底。
- 如果
open_tcp在返回之前被取消,许可和打开了一半的流会一起被丢弃,许可随之释放。 Hy2Stream或Hy2DatagramLink被丢弃时,会归还它的许可,或通过SessionGuard归还它的会话,并释放它对连接的引用。- 最后一个
Arc<Hy2Conn>被丢弃时,AbortOnDropHandle会中止 HTTP/3 驱动和数据报泵,连接、h3和 endpoint 句柄也随之被丢弃。
| 常量 | 值 | 文件 |
|---|---|---|
CONNECT_TIMEOUT |
每个解析出的地址 10 秒 | connection.rs |
OPEN_STREAM_TIMEOUT |
open_bi 5 秒 |
connection.rs |
DEFAULT_MAX_CONCURRENT_STREAMS |
每条连接 102,400 个流 | config.rs |
MAX_UDP_SESSIONS |
每条连接 256 个关联 | connection.rs |
UDP_SESSION_BACKLOG |
每个关联 256 条排队消息 | connection.rs |
STREAM_RECEIVE_WINDOW / CONNECTION_RECEIVE_WINDOW |
8 MiB / 20 MiB | connection.rs |
MAX_IDLE_TIMEOUT / KEEP_ALIVE |
30 秒 / 10 秒 | connection.rs |
RECONNECT_BACKOFF_BASE / RECONNECT_BACKOFF_MAX |
1 秒 / 30 秒 | slot.rs |
MIN_HEALTHY_LIFETIME |
10 秒 | slot.rs |
MAX_ADDRESS_LENGTH |
2048 字节 | protocol.rs |
MAX_MESSAGE_LENGTH / MAX_MESSAGE_KEPT |
线上 2048 字节 / 保留 128 个字符 | protocol.rs |
MAX_PADDING_LENGTH |
4096 字节 | protocol.rs |
MAX_UDP_SIZE |
重组后 4096 字节 | protocol.rs |
MAX_DATAGRAM_FRAME_SIZE |
1200 字节,即上游发出的最大数据报。连接未报告数据报大小时,服务端回退到这个值;客户端不使用它 | protocol.rs |
SALT_LEN / KEY_LEN / MIN_PSK_LEN |
8 / 32 / 4 字节 | obfs.rs |
DEFAULT_MAX_CONCURRENT_STREAMS 是路由到该出站的所有流量共用的预算,而不是每用户的上限。它设得足够高,使得在服务端保持上游默认 MaxIncomingStreams 为 1024 的情况下,本地信号量永远不会先耗尽。此时服务端会扣住流额度,open_bi 会等待(受 OPEN_STREAM_TIMEOUT 限制),而不是以 WouldBlock 快速失败。这一取舍是有意的:客户端不必让这个数字与服务端的设置保持同步。HTTP/3 的控制流和 QPACK 流是单向流,而 QUIC 对两个方向分别计数,所以它们不会占用这份预算。
重组后的负载大于调用方缓冲区时,Hy2DatagramLink::poll_recv_from 会将其截断。
| 文件 | 覆盖内容 |
|---|---|
protocols/tests/unit/hysteria/protocol.rs |
RFC 9000 附录 A.1 中的 varint 向量、填充范围、逐字节精确的 TCPRequest、两种响应结论、每个边界处的截断、消息清理、UDPMessage 成帧、分片以及 Defragger 的每条规则 |
protocols/tests/unit/hysteria/auth.rs |
请求的头部、Hysteria-CC-RX 解析、凭据脱敏 |
protocols/tests/unit/hysteria/obfs.rs |
一个独立的 BLAKE2b-256 向量、各种长度下的往返、GRO 批次、垃圾批次、拒绝 GSO、Debug 脱敏 |
protocols/tests/unit/hysteria/slot.rs |
单飞、退避计算、早夭、分离的 connect。它使用 server_name 为空的配置,这种配置在发出任何包之前就会失败,因此测试是即时且确定的 |
protocols/tests/pipeline/hysteria.rs |
本客户端对接本 crate 自己在环回地址上的 Hy2Inbound:TCP、带分片的 UDP、裸 Hy2Conn、Salamander、不支持 UDP 的服务器、错误的凭据、被拒绝的目标、在运行中的入站下替换用户表,以及一个在认证之前发送、从不会被中继的代理流 |
app/tests/integration/e2e_hysteria.rs |
与上游 Go 服务端互通:认证后的多个流、并行流、私有 CA、错误密码、半关闭语义、许可释放、UDP、对接 Go 分片器的分片、经由 SOCKS 的完整 app(TCP、UDP 和 Salamander),以及混淆不匹配 |
互通测试在每次测试运行时用 go build 构建一次 vendored 的 hysteria/ 代码树(app/tests/support/mod.rs → HYSTERIA_BIN)。当缺少 go 或构建失败时,它们会跳过自身而不是失败,所以在没有 Go 的机器上跑绿并不能证明互通。反方向的测试,即上游客户端对接本 crate 的入站,在 app/tests/integration/e2e_hysteria_inbound.rs 中,由服务端页面介绍。
cd Etemenankicargo test -p etemenanki-protocols --features hysteria hysteriacd Etemenankigit submodule update --init hysteriacargo test -p etemenanki-app --test integration e2e_hysteria::不加 --features hysteria 时,单元测试模块和 pipeline::hysteria 模块根本不会被编译,所以单纯的 cargo test -p etemenanki-protocols 不会运行其中任何测试。etemenanki-app 自己启用了该 feature。