跳转到内容

Hysteria 2:协议与客户端

源码文件:23 个 · 核对版本 Etemenanki 596916d
  • Etemenanki/protocols/Cargo.toml
  • Etemenanki/protocols/src/hysteria/mod.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/hysteria/protocol.rs
  • Etemenanki/protocols/src/hysteria/quic.rs
  • Etemenanki/protocols/src/hysteria/auth.rs
  • Etemenanki/protocols/src/hysteria/obfs.rs
  • Etemenanki/protocols/src/hysteria/connection.rs
  • Etemenanki/protocols/src/hysteria/slot.rs
  • Etemenanki/protocols/src/hysteria/connector.rs
  • Etemenanki/protocols/src/hysteria/server/datagrams.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/protocols/tests/unit/hysteria/protocol.rs
  • Etemenanki/protocols/tests/unit/hysteria/auth.rs
  • Etemenanki/protocols/tests/unit/hysteria/obfs.rs
  • Etemenanki/protocols/tests/unit/hysteria/slot.rs
  • Etemenanki/protocols/tests/pipeline/hysteria.rs
  • Etemenanki/app/tests/integration/e2e_hysteria.rs
  • Etemenanki/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 中。线上的每个长度都来自对端,因此代码在用它分配内存或在缓冲区中前进之前,都会先检查它是否在范围内。

所有长度和帧类型都使用 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 + ?Sized
pub 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 作为每个代理流的开头字节写出:

字段 大小 含义
帧类型 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 + ?Sized
pub async fn read_tcp_request_body<R>(reader: &mut R) -> Result<String, ProtocolError>
where
R: AsyncRead + Unpin + ?Sized
pub 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 从地址长度开始。服务端需要后两者,因为它已经读过帧类型,并据此判定该流是代理流。

服务端对每个 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 + ?Sized
pub 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 放在一个 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
  1. 先整包发送消息。上游也是这样做的,在常见情况下不会增加分片头。
  2. 只有当 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)),因为零保留给未分片的情况。
  3. 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。

#[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,负载按序号顺序拼接。

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”。该消息从不引用密码的值。

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
  • 拒绝 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/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 层。

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)

针对单个地址的每次尝试执行以下步骤:

  1. bind_socket 把一个 std::net::UdpSocket 绑定到目标地址族的未指定地址(0.0.0.0:0 或 [::]:0),并用 quinn::TokioRuntime 包装。如果设置了 obfs,再把结果包进 SalamanderSocket。
  2. Endpoint::new_with_abstract_socket 为这一条连接创建一个仅客户端的 endpoint(EndpointConfig::default(),无服务端配置)。
  3. endpoint.connect_with(client_config(config)?, addr, &config.server_name) 执行 QUIC 握手。server_name 既是 SNI,也是证书必须匹配的名称。
  4. h3::client::new(h3_quinn::Connection::new(conn.clone())) 在连接的一个克隆上建立 HTTP/3。为驱动 spawn 的任务等待 driver.poll_close,并以 debug 级别记录连接的结束方式,文本经过 auth::describe(即 sanitise_message)处理。
  5. auth::authenticate(&mut send_request, &config.password, 0) 执行 /auth 交换。
  6. 如果 outcome.udp_enabled 为 true,spawn pump_datagrams。否则不启动它,因为它会永远等待一个没有人写入的通道。
  7. 以 config.max_concurrent_streams 个许可创建流信号量,next_session 从 1 开始。

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 才会走到这里

HTTP/3 和代理共用一条连接。服务端通过流的第一个 varint 识别代理流:0x401 位于 HTTP/3 没有定义帧类型的区间,因此服务端的 HTTP/3 层会把这些流转交给代理。所以客户端用 conn.open_bi() 直接在 QUIC 连接上打开代理流,从不经过 h3,与上游的 conn.OpenStream() 一样。

  1. 许可。 先执行 streams.try_acquire_owned()。如果没有剩余许可,调用立即以 WouldBlock“hysteria2: connection is at its concurrent-stream limit”失败,而不是等待。
  2. 打开。 open_bi() 在 OPEN_STREAM_TIMEOUT(5 秒)内运行。超时则错误为 TimedOut,失败则错误为 BrokenPipe。
  3. 请求。 客户端用 quinn 自带的 SendStream::write_all 写出 encode_tcp_request(address, &TCP_REQUEST_PADDING.generate())。
  4. 响应。 客户端在 open_tcp 返回之前读取 TCPResponse。如果服务端拒绝,错误为 ConnectionRefused“hysteria2: server refused the target”,经过清理的消息非空时后面再接上 : {message}。这样,不可达的目标表现为连接失败,而不是一个打开后立即结束的流。出于同样的原因,没有实现上游的 “fast open” 模式。
  5. 返回。 许可与流的两半一起返回。

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) 的调用方会克隆同一个 Shared future。在一次握手期间到达的两个流绝不会打开两条连接。
  • 由任务而不是等待方写回结果。 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 秒,等待时间不会增长。
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)
位置 条件 结果
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 Etemenanki
cargo test -p etemenanki-protocols --features hysteria hysteria

不加 --features hysteria 时,单元测试模块和 pipeline::hysteria 模块根本不会被编译,所以单纯的 cargo test -p etemenanki-protocols 不会运行其中任何测试。etemenanki-app 自己启用了该 feature。