Hysteria 2:服务端
源码文件:30 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/hysteria/mod.rsEtemenanki/protocols/src/hysteria/server/mod.rsEtemenanki/protocols/src/hysteria/server/config.rsEtemenanki/protocols/src/hysteria/server/endpoint.rsEtemenanki/protocols/src/hysteria/server/inbound.rsEtemenanki/protocols/src/hysteria/server/shim.rsEtemenanki/protocols/src/hysteria/server/io.rsEtemenanki/protocols/src/hysteria/server/datagrams.rsEtemenanki/protocols/src/hysteria/server/authenticator.rsEtemenanki/protocols/src/hysteria/server/masquerade.rsEtemenanki/protocols/src/hysteria/auth.rsEtemenanki/protocols/src/hysteria/protocol.rsEtemenanki/protocols/src/hysteria/quic.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/helpers/crypto.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/instance.rsEtemenanki/protocols/tests/unit/hysteria/server/shim.rsEtemenanki/protocols/tests/unit/hysteria/server/inbound.rsEtemenanki/protocols/tests/unit/hysteria/server/datagrams.rsEtemenanki/protocols/tests/unit/hysteria/server/authenticator.rsEtemenanki/protocols/tests/unit/hysteria/server/masquerade.rsEtemenanki/protocols/tests/unit/hysteria/protocol.rsEtemenanki/protocols/tests/pipeline/hysteria.rsEtemenanki/app/tests/unit/inbound.rsEtemenanki/app/tests/integration/e2e_hysteria_inbound.rskatana/src/inbound.rskatana/src/manager/proxy.rs
Hysteria 2 服务端是工作区里唯一不挂在通用流式 accept 循环下的入站。Hysteria 2 客户端在每个 QUIC 连接上只认证一次,之后每代理一个 TCP 连接就打开一条双向 QUIC 流,UDP 则走同一连接的数据报通道。这些内容都不是以单一字节流的形式到达的,所以服务端自己持有 UDP socket 和 quinn endpoint,并为每个代理流、以及每个连接的数据报通道各驱动一个 ProxyServerRuntime。
本页面向修改 protocols/src/hysteria/server/ 的贡献者,内容包括:监听器(Hy2Inbound)、区分代理流与 HTTP/3 流的 HTTP/3 分派 shim、凭据交换与伪装(masquerade)、流核心(Hy2StreamCore)、数据报核心(Hy2UdpCore)、认证器,以及关闭流程。拨号一侧、共享的线格式原语和 Salamander 混淆见 Hysteria 2:客户端。整个模块位于 etemenanki-protocols 的 hysteria feature 之后,app 启用了该 feature。
| 组件 | 文件 → 符号 | 负责 |
|---|---|---|
| 监听器 | server/inbound.rs → Hy2Inbound |
quinn::Endpoint、accept 循环、连接数上限、每连接的任务集合、关闭与端口释放。 |
| QUIC 设置 | server/endpoint.rs → server_config、from_socket |
使用 ALPN h3 的 rustls TLS 1.3、流控窗口、流数上限、空闲超时、可选的 Salamander socket 包装。 |
| 分派 shim | server/shim.rs → Hy2H3Conn、classify、PrefixedBidi |
读取每条双向流的第一个 varint;若该流不是代理流,则将其重放给 h3。 |
| 凭据交换 | server/inbound.rs → http3、answer |
HTTP/3 服务端:合法的 /auth 返回 233,其余一律返回伪装响应。 |
| 代理流 | server/inbound.rs → Hy2StreamCore;server/io.rs → QuicIo |
解析 TCPRequest、发送 TCPResponse、嗅探,以及原样中继。 |
| UDP | server/datagrams.rs → QuicDatagrams、Hy2UdpCore |
以 session id 为键的会话、重组、回复的分片、空闲清扫。 |
| 凭据 | server/authenticator.rs → Authenticator |
一个共享密码、一张 user:pass 表,或一张不透明凭据表。 |
| 伪装 | server/masquerade.rs → Masquerade |
每个未认证请求得到的固定响应。 |
交给其他部分负责的事:
- 路由与拨号。 每个连接从调用方的
make_connector(ip)闭包获得一个 connector。在 etemenanki-app 中,它是带有入站 tag 和客户端地址的AppConnector(app/src/serve.rs→run_hysteria_inbound);见 服务运行。 - 配置检查。
app/src/inbound/mod.rs→build_hysteria2_inbound校验 TOML,出错即拒绝(fail closed);面向用户的设置见 Hysteria 2 指南页。 - 按用户准入。 下游若要停用某个用户,需要按流处理,而不是通过监听器(见 认证器)。
pub const DEFAULT_MAX_CONNECTIONS: usize = 4096;pub const DEFAULT_MAX_CIRCUITS: usize = 65_536;
pub struct ServerConfig<T> { pub authenticator: ArcSwap<Authenticator<T>>, pub masquerade: Masquerade, pub sniff: bool, pub udp: Option<std::time::Duration>, pub circuit_permits: Arc<Semaphore>,}
pub struct ListenerConfig<T> { pub connection: Arc<ServerConfig<T>>, pub quic: quinn::ServerConfig, pub obfs: Option<Obfs>, pub max_connections: usize,}ServerConfig 是单个连接所需的内容,监听器上的所有连接通过 Arc 共享它。udp 既是开关,也是关联的空闲超时:None 表示服务端告诉客户端 Hysteria-UDP: false,并且从不启动数据报运行时。T 是下游附加的每用户载荷(etemenanki-app 使用 ();katana 附加自己的用户 tag)。
pub struct Hy2Inbound<T> { config: Arc<ListenerConfig<T>>, endpoint: Arc<Mutex<Option<quinn::Endpoint>>>,}
impl<T> Hy2Inbound<T> { pub fn new(config: ListenerConfig<T>) -> Self; pub fn set_authenticator(&self, authenticator: Arc<Authenticator<T>>); pub async fn shutdown(&self);}
impl<T: Send + Sync + 'static> Hy2Inbound<T> { pub async fn run<C, F>( &self, socket: std::net::UdpSocket, make_connector: F, token: CancellationToken, ) -> io::Result<()> where F: Fn(IpAddr) -> C + Send + Sync + 'static, C: Connector<Flow<T>> + Send + 'static, C::Future: Send, C::Stream: Send, C::Datagram: DatagramLink<Addr = Destination> + Send;}Hy2Inbound 手动实现了 Clone(没有 T: Clone 约束):两个字段都是 Arc,所以克隆出来的句柄共享配置和 endpoint 槽位。调用方正是这样在一个句柄上运行 run,同时保留另一个句柄用于 set_authenticator 和 shutdown。endpoint 槽位是一个 parking_lot::Mutex<Option<quinn::Endpoint>>:run 填入,shutdown 取出。
run 接收的是已经绑定好的 std::net::UdpSocket,而不是地址。在 etemenanki-app 中,spawn_generation 用 bind_inbound 绑定 socket,再把它传给 run_hysteria_inbound(见 Generation 与重载);如果在这里再绑定一个 socket,就会与第一个 socket 争抢端口。
QUIC endpoint
Section titled “QUIC endpoint”pub const DEFAULT_MAX_INCOMING_STREAMS: u32 = 1024;
pub fn server_config(cert_pem: &[u8], key_pem: &[u8]) -> io::Result<ServerConfig>;pub fn bind(listen: SocketAddr, config: ServerConfig, obfs: Option<&Obfs>) -> io::Result<Endpoint>;pub fn from_socket( socket: std::net::UdpSocket, config: ServerConfig, obfs: Option<&Obfs>,) -> io::Result<Endpoint>;server_config 构建 rustls 配置和传输参数:
| 设置 | 常量 | 值 |
|---|---|---|
| TLS 版本 | 仅 rustls::version::TLS13 |
TLS 1.3 |
| ALPN | ALPN_H3 |
h3 |
| 加密 provider | rustls::crypto::ring::default_provider() |
显式指定 |
| 每流接收窗口 | STREAM_RECEIVE_WINDOW |
8 MiB |
| 连接接收窗口 | CONNECTION_RECEIVE_WINDOW |
STREAM_RECEIVE_WINDOW / 2 * 5 = 20 MiB |
| 每连接并发双向流数 | DEFAULT_MAX_INCOMING_STREAMS |
1024 |
| 空闲超时 | MAX_IDLE_TIMEOUT |
30 秒 |
| 客户端证书 | with_no_client_auth() |
无 |
其中两项并非风格选择,tls_config 上的注释说明了原因:
- 显式指定 provider,而不是使用
rustls::ServerConfig::builder()的默认值,因为当下游在构建中统一引入第二个加密 provider feature 时,该 builder 会 panic。 - 固定为 TLS 1.3。
QuicServerConfig在start_session内部对rustls::quic::ServerConnection::new做了 unwrap。不含 TLS 1.3 的配置能通过QuicServerConfig::try_from,却会在第一个连接的第一个包到达时,在 accept 循环内部 panic。
证书和私钥错误都带有 hysteria2: 前缀:PEM 无法解析时为 could not read the certificate: … 和 could not read the private key: …,以及 the certificate file contains no certificates、the key file contains no private key,rustls 拒绝这对证书与私钥时为 certificate and key do not match: …。
from_socket 把 socket 设为非阻塞,包装给 quinn 的 Tokio 运行时使用;当 obfs 为 Some(Obfs::Salamander { psk }) 时,再包一层 SalamanderSocket。Salamander 是对称的,不保存任何每对端状态,所以客户端的包装可以原样复用。流数上限本身没有维持成本:QUIC 通过不发放流额度来执行它,因此达到上限的客户端会在 open_bi 中等待,而不是流被接受后再被丢弃。
分派 shim
Section titled “分派 shim”pub enum FrameType { ProxyRequest, Http3(u64),}
pub struct Classified { pub kind: FrameType, pub frame_type_bytes: Bytes, pub rest: Bytes,}
impl Classified { pub fn replay(&self) -> Bytes;}
pub async fn classify<R>(stream: &mut R) -> Result<Classified, StreamErrorIncoming>where R: quic::RecvStream<Buf = Bytes> + Unpin;
pub struct AuthState<U> { user: OnceLock<U>,}
pub struct PrefixedRecv<R> { prefix: Bytes, inner: R,}
pub struct PrefixedBidi<S, B> { prefix: Bytes, inner: S, _buf: PhantomData<fn() -> B>,}
pub fn discard<S, B>(stream: &mut S, code: Code)where S: quic::SendStream<B> + quic::RecvStream, B: Buf;
pub type Hy2BidiStream<B> = PrefixedBidi<h3_quinn::BidiStream<B>, B>;
pub struct Hy2Opener(h3_quinn::OpenStreams);
pub struct Hy2H3Conn<B: Buf> { inner: h3_quinn::Connection, classify_tx: mpsc::Sender<h3_quinn::BidiStream<B>>, h3_ready_rx: mpsc::Receiver<Hy2BidiStream<B>>,}
impl<B: Buf> quic::Connection<B> for Hy2H3Conn<B> { type RecvStream = h3_quinn::RecvStream; type OpenStreams = Hy2Opener; // poll_accept_bidi, poll_accept_recv, opener}Hy2H3Conn 在 h3-quinn 连接之上实现了 h3 的 quic::Connection,因此 h3::server::Connection 可以在其上运行,而无需知道部分流已被从它的 accept 路径中取走。设计说明见 区分流的类型 一节。
pub struct QuicIo<S> { stream: S, pending: Bytes, finished: bool,}
impl<S> QuicIo<S> { pub fn new(stream: S, prefix: Bytes) -> Self; pub fn into_inner(self) -> S;}
impl<S> AsyncRead for QuicIo<S> where S: quic::RecvStream<Buf = Bytes> + Unpin { /* … */ }impl<S> AsyncWrite for QuicIo<S> where S: quic::SendStreamUnframed<Bytes> + Unpin { /* … */ }代理流以 h3 的流类型到达核心,因为 h3-quinn 的流构造函数是私有的,shim 无法交出原始的 quinn 流。QuicIo 把它们适配为 AsyncRead 和 AsyncWrite:
pending保存上一次poll_data数据块的剩余部分,因为调用方的缓冲区可能小于一个数据块。new用分类器读过帧类型之后多读到的字节来初始化它。finished锁存对端的流结束状态,之后的读取直接返回 EOF,不再轮询。- 写入只经过
poll_send(SendStreamUnframed)。如果混用send_data和poll_send,h3-quinn的发送流会 panic,所以QuicIo从不调用带帧的 API。 poll_flush是空操作;poll_shutdown调用poll_finish,即干净的半关闭。未 finish 就丢弃流会重置它,对端会将其视为中止。
pub struct Hy2StreamCore<T> { user: UserEntry<T>, sniff: bool, source: IpAddr, timing: Timing, prefix: SniffPrefix, state: State<T>,}
impl<T> Hy2StreamCore<T> { pub const BUF_SIZE: usize = 8 * 1024; pub fn new(user: UserEntry<T>, sniff: bool, source: IpAddr) -> Self; pub fn is_established(&self) -> bool;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for Hy2StreamCore<T> { type Key = Single; type Target = Flow<T>; type Error = io::Error; type TransportAddr = (); const STAGING_RESERVE: usize = 2048; // handle, held}
enum State<T> { Request, Sniff(Flow<T>), Relay { relay: Passthrough<Single>, reply: bool, }, Done,}BUF_SIZE 能容纳地址和 padding 都取最长值的请求;STAGING_RESERVE 能容纳 padding 最长的 TCPResponse。通用的 sans-I/O 约定(Event、Effect、staging 区)见 服务端核心。
pub const MAX_SESSIONS: usize = 256;pub const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
pub struct QuicDatagrams { conn: quinn::Connection, pending: Option<DatagramFuture>,}
impl QuicDatagrams { pub fn new(conn: quinn::Connection) -> Self;}
impl DatagramLink for QuicDatagrams { type Addr = (); // poll_send_to, poll_recv_from}
pub struct Hy2UdpCore<T> { user: UserEntry<T>, source: IpAddr, idle_timeout: Duration, max_datagram: usize, circuit_permits: Arc<Semaphore>, sessions: BTreeMap<u32, Session>, now: u64, armed: bool, held: Vec<u8>,}
impl<T> Hy2UdpCore<T> { pub const BUF_SIZE: usize = 16 * 1024; pub fn new( user: UserEntry<T>, source: IpAddr, idle_timeout: Duration, max_datagram: usize, circuit_permits: Arc<Semaphore>, ) -> Self;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for Hy2UdpCore<T> { type Key = u32; type Target = Flow<T>; type Error = io::Error; type TransportAddr = (); const STAGING_RESERVE: usize = 4096; const MAX_DATAGRAM: usize = MAX_UDP_SIZE; // handle, held}pub struct UserEntry<T> { pub label: CompactString, pub data: Arc<T>,}
pub enum Authenticator<T> { Shared { password: Box<[u8]>, entry: UserEntry<T>, }, UserPass(Vec<UserPassEntry<T>>), Passwords(Vec<PasswordEntry<T>>),}
impl<T> Authenticator<T> { pub fn shared( password: &str, label: impl Into<CompactString>, data: Arc<T>, ) -> io::Result<Self>; pub fn user_pass( users: impl IntoIterator<Item = (String, String, CompactString, Arc<T>)>, ) -> io::Result<Self>; pub fn passwords( users: impl IntoIterator<Item = (String, CompactString, Arc<T>)>, ) -> io::Result<Self>; pub fn authenticate(&self, credential: &str) -> Option<&UserEntry<T>>;}pub struct Masquerade { status: StatusCode, body: Bytes, content_type: CompactString,}
impl Masquerade { pub fn new(status: u16, body: impl Into<Bytes>, content_type: &str) -> io::Result<Self>; pub fn response(&self) -> Response<()>; pub fn body(&self) -> Bytes;}UserEntry 手动实现了 Clone,这样 T 就不必实现 Clone;载荷始终只存放在 Arc 之后。
任务与所有权
Section titled “任务与所有权”accept 循环之下的每个任务都由上一层的 JoinSet 持有,因此丢弃所有者会连带终止它下面的一切。
flowchart TB run["Hy2Inbound::run:accept 循环"] conn["连接任务:serve_connection"] h3["http3 任务:基于 Hy2H3Conn 的 h3 服务端"] cls["classifier 任务"] udp["数据报任务:Hy2UdpCore 运行时"] stream["代理流任务:Hy2StreamCore 运行时"] run -->|"JoinSet,持有连接许可"| conn conn -->|JoinSet| h3 conn -->|JoinSet| cls conn -->|"JoinSet,仅当 udp 为 Some"| udp cls -->|"JoinSet,持有 circuit 许可"| stream h3 -.->|"classify_tx:新的双向流"| cls cls -.->|"h3_ready:重放后的 HTTP/3 流"| h3 h3 -.->|"authed oneshot:UserEntry"| udp
| 任务 | 由谁创建 | 由谁持有 | 何时结束 |
|---|---|---|---|
| accept 循环 | 调用方(app 中为 run_hysteria_inbound) |
调用方 | endpoint.accept() 返回 None,或 token 被取消 |
| 连接 | accept 循环,在取得连接许可之后 | run 中的 connections: JoinSet<()> |
QUIC 握手失败,或其所有子任务都已结束 |
http3 |
serve_connection |
tasks: JoinSet<()> |
h3 初始化失败,或 h3.accept() 返回 Ok(None) 或错误 |
classifier |
serve_connection |
tasks |
classify_tx 发送端被丢弃(Hy2H3Conn 已不存在),或 h3_ready 接收端已不存在 |
| 数据报运行时 | serve_connection,当 config.udp 为 Some 时 |
tasks |
认证 oneshot 未发送就被丢弃,或运行时结束(连接关闭时 read_datagram 失败) |
| 代理流运行时 | classifier,在取得 circuit 许可之后 |
classifier 中的 serving: JoinSet<()> |
运行时 future 完成 |
http3 任务持有 h3::server::Connection,而后者持有 Hy2H3Conn。http3 任务结束时,classify_tx 发送端随之被丢弃,classifier 的 incoming.recv() 返回 None,丢弃 classifier 的 serving 集合会中止该连接的所有代理流。serve_connection 只需等待 tasks.join_next() 全部返回。
let Ok(permit) = permits.clone().try_acquire_owned() else { incoming.refuse(); continue;};run 用 endpoint::from_socket 构建 endpoint,把一个克隆存入 endpoint 槽位,并创建一个含 max_connections 个许可的 Semaphore。循环用 select! 同时等待三件事:下一个 Incoming、取消 token,以及 connections.join_next()(这样循环空闲时也能回收已结束的连接任务)。
拿不到许可的连接会被拒绝,而不是被忽略。Incoming::refuse 会立即告知客户端,而不是让它对着沉默不断重传握手。许可被移入连接任务,一直存活到任务结束,因此覆盖 QUIC 握手和整个连接。握手失败会以 debug 级别记录,并结束该任务。
认证与服务流
Section titled “认证与服务流”sequenceDiagram participant C as client participant Q as Hy2H3Conn participant K as classifier participant H as h3 server participant S as stream task C->>Q: 打开双向流 1:HEADERS POST /auth Q->>K: classify_tx.try_send(stream 1) K->>K: classify:第一个 varint 是 0x01,不是 0x401 K->>Q: h3_ready.send(stream 1,附带重放字节) Q->>H: poll_accept_bidi 返回 stream 1 H->>H: answer:认证器匹配,设置 AuthState H->>C: 233、Hysteria-UDP、Hysteria-CC-RX auto、padding C->>Q: 打开双向流 2:0x401 TCPRequest Q->>K: classify_tx.try_send(stream 2) K->>K: classify:0x401 且 AuthState 中有用户 K->>S: 在 QuicIo(stream 2, rest) 上创建运行时,持有 circuit 许可 S->>C: TCPResponse,然后中继
HTTP/3 一侧运行 h3::server::Connection::new(transport),并循环调用 accept()。对每个请求它调用 answer,后者只根据方法、路径和 host 做判断:
| 请求 | 连接状态 | 响应 |
|---|---|---|
POST,路径 AUTH_PATH(/auth),host AUTH_HOST(hysteria),凭据匹配 |
未认证 | 233,带 Hysteria-UDP、Hysteria-CC-RX: auto、Hysteria-Padding;AuthState 记录该用户 |
| 再次发送同样的请求,凭据任意 | 已认证 | 再次返回 233,不检查凭据;已记录的身份不变 |
| 同样的请求,凭据不匹配 | 未认证 | 伪装响应 |
| 其他任何请求 | 任意 | 伪装响应 |
成功响应由 accepted 构建:状态码 STATUS_AUTH_OK(233);Hysteria-UDP 根据 config.udp.is_some() 设为 true 或 false;Hysteria-CC-RX: auto(服务端不声明接收速率,客户端因此使用拥塞控制);Hysteria-Padding 取自 AUTH_RESPONSE_PADDING(256 到 2047 个字母数字字符)。凭据从 Hysteria-Auth header 读取;不是可见 ASCII header 字符串的值会被当作空字符串,而任何认证器都不接受空字符串,因为每个构造函数都拒绝空凭据。
AuthState 第一次记录到用户时,http3 会把 UserEntry 的一个克隆发送到 authed oneshot,数据报任务正是靠它来构建运行时。发送发生在写出响应之前。在此之前,数据报任务只在 oneshot 上等待,所以未认证的连接不会有任何数据报被读取;上游同样只在凭据被接受后才启动其会话管理器。
被拒绝的凭据得到的响应与访问错误 URL 时完全相同。这正是该协议赖以成立的抗审查特性:没有凭据时,服务端就是一个普通的 HTTP/3 Web 服务器。
区分流的类型
Section titled “区分流的类型”Hysteria 2 服务端是一个 HTTP/3 服务端,同时在同一连接的双向流上承载代理流量。两者通过流的第一个 QUIC varint 区分:FRAME_TYPE_TCP_REQUEST(0x401)不是 HTTP/3 帧类型。上游的 HTTP/3 服务端为此提供了一个 “proxy stream hijacker” 钩子;h3 没有这样的钩子,所以 shim 把这一分流放在更低一层,即 h3 所运行的传输层中。
classify 把完整的 poll_data 数据块追加到一个 BytesMut(初始容量 MAX_VARINT_BYTES,即 8)中,直到第一个字节最高两位所声明的宽度被覆盖,然后拆分出:
frame_type_bytes:varint 本身的字节,与收到时完全一致;rest:varint 之后读到的所有字节,即正文的开头;kind:0x401为ProxyRequest,否则为Http3(value)。
如果对端在给出帧类型之前就结束了流,classify 返回 StreamErrorIncoming(hysteria2: stream ended before its frame type)。
为什么必须重放 varint。 QUIC 接收流无法 peek,读取即消费。因此,结果是 HTTP/3 的流会以 Hy2BidiStream::new(classified.replay(), stream) 的形式交给 h3,这是一个 PrefixedBidi,其接收半边先产出重放的字节,再委托给内部流。replay 按线上顺序返回 frame_type_bytes 加 rest。当 h3 拆分流时,PrefixedBidi::split 把前缀移入 PrefixedRecv,因为只有接收半边能重放它。h3 察觉不到任何区别。代理流不再需要帧类型,因为 Hy2StreamCore 解析的是请求正文,所以它被包装为 Hy2BidiStream::unprefixed(stream),rest 则作为前缀交给 QuicIo。
为什么不在 poll_accept_bidi 中分类。 它是一个 Poll 函数,无法 await 那个 varint。因此等待被移到一个单独的 classifier 任务中,由队列和超时加以限制,而 poll_accept_bidi 只负责搬运流:
- 它用
try_send把 quinn 已接受的所有流排入classify_tx。这个调用从不 await,因为poll_accept_bidi不能阻塞。 - 队列放不下的流会用
discard(…, Code::H3_EXCESSIVE_LOAD)重置两个半边(发送侧reset,接收侧stop_sending)。如果只是丢弃它,对端会继续往一条无人读取的流里写数据。这个重置限制了等待分类的工作量。 - 然后它轮询
h3_ready_rx,返回 classifier 交回的下一条流。如果 classifier 已不存在,则返回ConnectionErrorIncoming::InternalError("hysteria2: stream classifier stopped")。
两个来源都会在返回 Pending 之前被轮询,因此两个 waker 都已注册;如果只在其中一个上唤醒,就会一直卡住,直到另一个碰巧触发。单向流(HTTP/3 控制流和 QPACK)只属于 h3,经由 poll_accept_recv 直接透传,不做分类。服务端自己打开的流经过 Hy2Opener,它为这些流包上一个空前缀,只是为了让类型与 h3 接受的类型一致。
classifier 任务从 classify_rx 逐条接收流,并在 tokio::time::timeout(CLASSIFY_TIMEOUT, …) 下对每条流运行 classify:
| 结果 | 处理 |
|---|---|
0x401 且 auth.user() 为 Some |
取得一个 circuit 许可,在 serving 中创建 Hy2StreamCore 运行时 |
0x401 但 circuit 许可已用完 |
discard(…, Code::H3_REQUEST_REJECTED) |
其他任何情况,或认证之前的 0x401 |
把 Hy2BidiStream::new(replay, stream) 发送到 h3_ready;如果 h3 已不存在,classifier 停止 |
classify 返回错误 |
以 debug 级别记录,丢弃该流 |
超过 CLASSIFY_TIMEOUT |
discard(…, Code::H3_REQUEST_CANCELLED) |
在认证之前到达的代理流会连同重放字节一起交给 HTTP/3,与上游做法一致。h3 看到一个它不认识的帧类型,这条流既不会得到应答,也不会被中继,这正是真正的 Web 服务器会做的事。
shim.rs 末尾的一个编译期项 const _: () = { assert_transport::<Bytes, Hy2H3Conn<Bytes>>(); } 证明了 h3 接受这个传输层。quic::Connection 要求其关联类型 OpenStreams 与自身的 SendStream 和 BidiStream 一致,因此 Hy2Opener 和 Hy2H3Conn 必须指定完全相同的一对类型。h3 是一个 0.0.x crate;当它的 trait 变化时,这个断言会最先失败,并把问题说清楚。
这些包装器对内部流是泛型的,原因同样是构造函数私有:这让 protocols/tests/unit/hysteria/server/shim.rs 可以用一个伪造的接收流来测试它们,而不需要真实的 QUIC 连接。
客户端写出一个 TCPRequest,并在发送载荷之前等待 TCPResponse。所有 varint 都是 QUIC varint(RFC 9000 第 16 节),而不是 protobuf varint。
TCPRequest,客户端到服务端:
| 字段 | 大小 | 含义 |
|---|---|---|
| frame type | varint | 0x401(FRAME_TYPE_TCP_REQUEST),线上占两个字节;由 classifier 消费 |
| address length | varint | 1 到 MAX_ADDRESS_LENGTH(2048) |
| address | bytes | host:port,UTF-8 |
| padding length | varint | 最多 MAX_PADDING_LENGTH(4096) |
| padding | bytes | 忽略 |
TCPResponse,服务端到客户端:
| 字段 | 大小 | 含义 |
|---|---|---|
| status | u8 | STATUS_OK(0x00)或 STATUS_ERROR(0x01) |
| message length | varint | 0 到 MAX_MESSAGE_LENGTH(2048) |
| message | bytes | 成功时为 Connected |
| padding length | varint | 服务端从 TCP_RESPONSE_PADDING 中取值,128 到 1023 字节 |
| padding | bytes | 字母数字字符,客户端忽略 |
parse_tcp_request_body 在使用每个长度之前都会做范围检查,并在缓冲区不足时返回 Ok(None),因此核心可以等待更多字节,而不必为攻击者选定的长度分配内存。地址不是 UTF-8、为空或超过 2048 字节,以及 padding 长度超过 4096,都属于 ProtocolError::Malformed,会结束该流。
stateDiagram-v2 [*] --> Request Request --> Relay: 已解析,不嗅探,Open 且 reply = true Request --> Sniff: 已解析,开启嗅探且目标为 IP,stage Connected Request --> Done: 无端口或地址无效,stage 拒绝响应并 Finish Request --> Done: TransportEof,Finish Sniff --> Relay: 得出结论、SNIFF_TIMEOUT 或 TransportEof,Open 并附带已保留字节,reply = false Relay --> Relay: Connected,若 reply 则 stage Connected Relay --> Done: ConnectFailed,若 reply 则发拒绝响应,然后 ShutdownTransport 并 Finish Done --> [*]
Request。 on_transport 调用 parse_tcp_request_body。地址用 parse_authority(&address, 0) 解析:默认端口是零而不是猜测值,端口仍为零的目标会被拒绝,返回状态为 0x01、消息为 bad address 的 TCPResponse,随后是 Effect::ShutdownTransport 和 Effect::Finish。因此,未指定端口的请求永远不会被意外路由。否则,核心构建一个 Flow:网络为 DialNetwork::Tcp,source 为客户端地址,NetworkUser 的授权为 UsernamePassword { username: user.label, password: Default::default() }。flow 携带的是标签和载荷,从不携带凭据。
Sniff。 当 sniff 开启且 worth_sniffing 判断目标是裸 IP 时,核心立即 stage Connected 并进入 Phase::Sniff。之后载荷字节进入 SniffPrefix(受嗅探预算限制),直到收集器给出结论、嗅探截止时间到达,或客户端半关闭。open_sniffed 把 flow 从状态中取出,将 prefix.result() 附加为 flow.sniffed,以 reply = false 打开它,并为已收集的字节推入 Effect::ForwardHeld。见 嗅探。
Relay。 open 推入 Effect::Open { key: Single, target },转发所有已保留的字节,设置 State::Relay,并进入 Phase::Relay。此后由 Passthrough<Single> 完成工作:传输层字节原样转发,出站字节原样 stage,任一端的流结束都会半关闭另一端(Effect::Shutdown 或 Effect::ShutdownTransport),两端都结束后才 finish。prefix.clear() 在下一个字节事件时执行,此时运行时已经应用了引用这些保留字节的转发。
Event::Connected仅当reply仍为 true 时才 stageConnected,并将其清除,因此一条流永远不会得到两个响应。Event::ConnectFailed仅当reply为 true 时才 stage 拒绝响应。在嗅探路径上,客户端已经收到Connected,所以核心改为用ShutdownTransport和Finish结束该流。Event::OutboundError调用on_outbound_gone,关闭传输层并 finish;已 stage 的字节仍会发完。
截止时间来自 protocols/src/core/mod.rs 中的 Timing,它为运行时唯一的定时器赋予按阶段区分的含义:请求已开始但未完整时为 HANDSHAKE_TIMEOUT(10 秒),嗅探时为 SNIFF_TIMEOUT(300 毫秒),中继时为 RELAY_IDLE_TIMEOUT(300 秒),且每个字节事件都会重新设置。握手超时返回 handshake_timed_out()(client did not complete its request in time);嗅探窗口到期时用已收集的内容打开 flow;中继空闲超时则 finish。
为什么 CONNECT 可能在目标连通之前就得到应答
Section titled “为什么 CONNECT 可能在目标连通之前就得到应答”客户端在发送任何载荷之前都会阻塞读取 TCPResponse,而嗅探意味着要读取载荷。如果服务端对一个需要嗅探的请求先等目标连通再应答,客户端就会等服务端、服务端又等客户端,直到嗅探截止时间到期。
所以规则如下,写在 inbound.rs 的模块文档中:
- 会被嗅探的请求(开启了嗅探且目标为 IP)立即应答
Connected,flow 在检查完最初的字节或嗅探窗口关闭后才打开; - 其他所有请求只在运行时报告
Event::Connected,即目标确实连通时才应答;目标不可达时应答拒绝。
单元测试 a_request_opens_and_is_answered_once_connected 断言在 Connected 之前不会 stage 任何内容;sniffing_an_ip_target_answers_early_and_opens_with_the_host 断言会提前应答,且之后不会再有第二个响应。
每个 QUIC 数据报承载一个 UDPMessage,或其中的一个分片:
| 字段 | 大小 | 含义 |
|---|---|---|
| session id | u32,大端 | 关联,由客户端选定 |
| packet id | u16,大端 | 把同一个包的各分片联系在一起;未分片时服务端发送 0 |
| fragment id | u8 | 本分片的序号,从 0 开始 |
| fragment count | u8 | 分片数量;1 表示未拆分 |
| address length | varint | 1 到 MAX_ADDRESS_LENGTH(2048) |
| address | bytes | host:port,UTF-8 |
| payload | bytes | 数据报的剩余部分;不能为空 |
QuicDatagrams
Section titled “QuicDatagrams”QuicDatagrams 把连接的数据报通道表示为一个 Addr = () 的 DatagramLink:一个 QUIC 连接恰好只有一个对端。poll_send_to 调用 conn.send_datagram,并通过 hysteria::quic::datagram_error 映射错误。poll_recv_from 在多次轮询之间把一个装箱的 read_datagram future 保存在 pending 中,把数据报复制进调用方的缓冲区,并把连接错误映射为 ErrorKind::BrokenPipe,从而在连接关闭时结束运行时。
运行时通过 ProxyServerRuntime::over_datagrams(QuicDatagrams::new(conn), core, make_connector(source)) 构建,缓冲区大小为 Hy2UdpCore::BUF_SIZE(16 KiB),足以容纳一个重组后的包及其各分片的头部。数据报传输模式见 服务端运行时。
flowchart TB
pkt["TransportDatagram"] --> parse{"parse_udp_message_at 成功?"}
parse -- 否 --> drop1["丢弃"]
parse -- 是 --> known{"session id 已知?"}
known -- 是 --> frag
known -- 否 --> cap{"少于 MAX_SESSIONS?"}
cap -- 否 --> drop2["丢弃"]
cap -- 是 --> permit{"有 circuit 许可?"}
permit -- 否 --> drop3["丢弃"]
permit -- 是 --> port{"地址带端口?"}
port -- 否 --> drop4["丢弃"]
port -- 是 --> open["Open key = session id,插入 Session"]
open --> frag{"frag_count 至多为 1?"}
frag -- 是 --> send["SendTo 数据报中的对应区间"]
frag -- 否 --> defrag["Defragger::feed"]
defrag -- 完成 --> held["复制到 held,SendToHeld"]
Hy2UdpCore 维护一个 BTreeMap<u32, Session>。Session 保存其最后一次活动时的清扫 tick、一个 Defragger,以及一个取自监听器 circuit_permits 的 OwnedSemaphorePermit,因此 UDP 关联与代理流计入同一份 circuit 预算,并在会话被移除时释放许可。
- 打开。 未知 session id 的第一个包会以
Effect::Open { key: id, target }打开该会话,其中 target 是用该包的地址构建、网络为DialNetwork::Udp的Flow。上限(MAX_SESSIONS,每连接 256 个)在取许可之前检查。 - 发送。 每个包都发往它自己的地址,而不是打开会话的那个地址:
Effect::SendTo { key, to, range }按区间从收到的数据报中转发载荷,不做复制。地址不带端口或载荷为空的包会被丢弃。 - 重组。 分片的包经过会话的
Defragger。它一次只保存一个包的分片(另一个包的分片会丢弃正在重组的包),丢弃序号超过分片数量的分片和重复分片,并把重组后的大小限制在MAX_UDP_SIZE(4096):会使大小超过上限的分片会丢弃正在重组的包。重组完成的载荷被复制到held,并通过Effect::SendToHeld发送。 - 无法解析的数据报会被丢弃,并以 trace 级别记录;无论哪种情况,整个数据报都会被消费掉。
- 失败。 某个会话的
ConnectFailed和OutboundError会移除该会话并释放其许可。SendFailed和TransportSendFailed只丢弃一个包,保留会话。
协议中没有关闭关联的消息,所以会话只会因空闲而退役,与上游一致。
核心有自己的时钟:now 计数清扫 tick。第一个包或回复会设置 Effect::SetDeadline(Some(SWEEP_INTERVAL))。每个 Event::Deadline 让 now 加一,移除所有满足 now - last >= idle 的会话(idle 为以整秒计的 idle_timeout,至少为 1),为每个被移除的会话推入 Effect::Close { key },并重新设置截止时间。任一方向的活动都会设置 last = now。被清扫掉的 session id 可以复用,它的下一个包会打开一个新的关联。
on_reply 把来自某个关联的每个回复包装成一个 UDPMessage,带上 session id、以 format_authority(from) 表示的回复来源,以及 frag_count = 1。空回复和超过 MAX_UDP_SIZE 的回复会被丢弃。
大小上限是 max_datagram,在数据报运行时启动时读取一次:conn.max_datagram_size(),没有时回退到 MAX_DATAGRAM_FRAME_SIZE(1200)。放得下的消息用 fx.put_to((), …) 整体 stage。更大的消息会获得一个随机的非零 packet_id(零保留给未分片的情况),并由 UdpMessage::fragment 拆成各自放得下的片段;如果仅头部就占满了空间,或拆分需要超过 255 个分片,则丢弃该回复。STAGING_RESERVE(4096)足以容纳一个最大回复的所有分片头部。分片代码与客户端使用的是同一个 UdpMessage::fragment,a_large_datagram_is_fragmented_in_both_directions 用上游客户端对其进行了测试。
Authenticator 决定一个 Hysteria-Auth 字符串属于谁。协议只携带一个不透明字符串,所以多用户服务端需要一种约定,目前有两种在用:
| 变体 | 构造函数 | 线上凭据 | 适用场景 |
|---|---|---|---|
Shared |
shared(password, label, data) |
密码本身 | 所有人共用一个密码 |
UserPass |
user_pass(users) |
user:pass,在第一个冒号处拆分;两侧的用户名都会转为小写 |
为上游服务端配置的客户端 |
Passwords |
passwords(users) |
整个字符串,每个用户一个不透明凭据 | 面板驱动的节点,凭据本身即可识别用户 |
构造过程出错即拒绝(fail closed):
| 拒绝情形 | 消息 |
|---|---|
| 共享密码为空 | hysteria2: the password must not be empty |
user_pass 条目的用户名或密码为空 |
hysteria2: a user needs both a name and a password |
用户名包含 :(这样的用户名永远无法被提交) |
hysteria2: a username cannot contain ':' — it separates the two on the wire |
| 两个用户名转为小写后相同 | hysteria2: two users share a name once lower-cased |
passwords 条目的凭据为空 |
hysteria2: a user needs a credential |
| 两个用户使用相同的凭据 | hysteria2: two users share one credential |
| 表为空 | hysteria2: the user table is empty |
authenticate 使用 helpers::crypto::ct_eq 做比较,这是本 crate 基于 subtle 的比较函数;它会扫描表中的每一个条目而不提前返回,把匹配结果记录在局部变量中。对于 UserPass,用户名和密码的比较结果用不短路的 & 组合。扫描是线性的;这之所以可以接受,是因为 Hysteria 2 每个 QUIC 连接只认证一次,而不是每个代理流认证一次。
在运行中的监听器下替换用户表
Section titled “在运行中的监听器下替换用户表”ServerConfig::authenticator 是一个 ArcSwap<Authenticator<T>>,answer 在每次检查凭据时加载它。Hy2Inbound::set_authenticator 存入一张新表:
- 监听器、它的 socket 以及所有存活连接都不受影响;
- 替换之前已认证的连接保留其
AuthState记录的UserEntry,因为AuthState是一个OnceLock,替换操作触及不到它; - 只有替换之后才认证的连接会看到新表。
如果改为重建监听器,就会重新绑定 UDP 端口并断开所有已连接的客户端。katana 在每次用户同步时都依赖这一点:它默认构建一张 Passwords 表(节点配置为 user_pass 时则构建 user_pass 表),先提交自己的准入注册表,再调用 set_authenticator。由于已接入的连接会保留原有身份,下游若要停用某个用户,必须自己拒绝该用户的流;katana 在其按流的准入检查中做到了这一点。etemenanki-app 从不替换用户表:它的表来自配置文件,配置变更会重建 generation。
Masquerade 是所有不带有效凭据的请求得到的固定响应:状态码、Content-Type、Content-Length,然后是正文,最后 finish。
Masquerade::default()与 Go 的http.NotFound逐字节相同:404、text/plain; charset=utf-8、正文404 page not found\n。这正是未配置伪装的上游服务端给出的响应,所以探测者无法通过 404 响应区分两者。Masquerade::new拒绝STATUS_AUTH_OK(233),因为把它返回给未认证的请求,等于告诉该请求它已经认证成功:hysteria2: 233 is the authentication success status and cannot be used for the masquerade。它也拒绝不是 HTTP 状态码的值(hysteria2: <n> is not an HTTP status code);httpcrate 接受的任何值(100 到 999)都由运维者自行选择。
在 etemenanki-app 中,只要设置了 status、body 或 content_type 中的任意一个,就会用默认值补齐其余项来构建 Masquerade::new;一个都不设置时使用 Masquerade::default()。
| 不变量 | 由谁保证 | 由谁固定 |
|---|---|---|
| 未认证的连接不会打开任何 flow。 | classifier 只在 (true, Some(user)) 时走代理分支,其余一律交给 HTTP/3。数据报任务在构建运行时之前等待 authed oneshot。 |
a_proxy_stream_before_authentication_is_never_relayed(protocols/tests/pipeline/hysteria.rs);a_connection_starts_unauthenticated(protocols/tests/unit/hysteria/server/shim.rs) |
| 连接的身份只设置一次,之后不再改变。 | AuthState 是 OnceLock;重复的 /auth 直接应答 233,不重新认证。 |
authenticating_records_who、a_second_authentication_cannot_change_the_user(shim.rs 单元测试) |
| 被拒绝的凭据与错误的 URL 无法区分。 | answer 对两者返回相同的 Masquerade 响应和正文。 |
没有测试比较这两个响应。a_wrong_credential_is_refused(pipeline)和 a_wrong_credential_never_gets_a_proxy(app/tests/integration/e2e_hysteria_inbound.rs)只固定了拒绝本身。 |
| 伪装响应从不使用成功状态码。 | Masquerade::new 拒绝 233;app 把该错误映射为入站的配置错误。 |
the_authentication_success_status_is_refused(protocols/tests/unit/hysteria/server/masquerade.rs);a_masquerade_that_says_authenticated_is_refused(app/tests/unit/inbound.rs) |
| 分类不丢失任何字节。 | HTTP/3 流靠 Classified::replay 和 PrefixedBidi/PrefixedRecv;代理流靠 QuicIo 的前缀。 |
a_varint_split_across_chunks_is_still_read、body_bytes_in_the_same_chunk_are_handed_back、replay_puts_the_bytes_back_in_wire_order、classify_then_replay_reconstitutes_an_http3_stream、a_prefixed_stream_reads_as_if_nothing_had_been_consumed(shim.rs 单元测试) |
| 未给出帧类型就结束的流不会让 classifier 一直等待。 | classify 在流结束时返回错误。 |
a_stream_that_ends_unnamed_is_an_error(shim.rs 单元测试) |
| 等待分类的工作量有上限。 | classify_tx 容量为 MAX_CLASSIFYING_STREAMS,溢出时 try_send 加 discard(H3_EXCESSIVE_LOAD);CLASSIFY_TIMEOUT 加 discard(H3_REQUEST_CANCELLED)。 |
没有专门的测试。 |
| 连接数上限覆盖整个连接。 | 连接许可被移入连接任务。 | 没有专门的测试。 |
| circuit 上限覆盖 circuit 的完整生命周期。 | 流许可被移入创建的运行时任务;会话许可存放在 Session 中,直到会话被移除。 |
UDP 会话:the_circuit_limit_refuses_new_sessions(protocols/tests/unit/hysteria/server/datagrams.rs)。代理流:没有专门的测试。 |
| 没有端口的请求永远不会被路由。 | 两个核心中都有 parse_authority(…, 0) 和端口为零的检查。 |
a_request_without_a_port_is_refused(protocols/tests/unit/hysteria/server/inbound.rs);unroutable_packets_are_dropped(datagrams.rs 单元测试) |
| 不嗅探的请求只在目标连通后才应答,且一条流至多得到一个响应。 | State::Relay 中的 reply 标志,在 Connected 时清除。 |
a_request_opens_and_is_answered_once_connected、sniffing_an_ip_target_answers_early_and_opens_with_the_host(inbound.rs 单元测试) |
| 连接失败会得到应答,而不是悬而不决。 | Event::ConnectFailed stage 一个拒绝响应并 finish。 |
a_failed_connect_is_refused_and_finished(单元);a_failed_connect_is_answered_with_a_refusal(pipeline) |
| 替换用户表不影响存活连接。 | 每次检查凭据时加载 ArcSwap;身份保存在 AuthState 中。 |
the_user_table_can_be_replaced_under_a_live_inbound(pipeline) |
| 凭据表没有歧义。 | user_pass 和 passwords 中的拒绝规则。 |
names_that_collide_once_lower_cased_are_refused、two_users_sharing_one_credential_are_refused、the_two_table_kinds_do_not_accept_each_others_credentials、a_prefix_of_a_credential_is_refused(authenticator.rs 单元测试) |
| 每个会话的重组内存有上限。 | Defragger 只保存一个包,并将其限制在 MAX_UDP_SIZE。 |
fragments_reassemble_into_the_original、a_fragment_index_past_the_count_is_dropped、a_repeated_fragment_does_not_complete_the_datagram(protocols/tests/unit/hysteria/protocol.rs);fragments_from_the_client_are_reassembled_into_a_held_send(datagrams.rs 单元测试) |
| 下一个 generation 绑定端口之前,端口已经空出。 | shutdown 反复尝试 UdpSocket::bind 直到成功,最长 RELEASE_TIMEOUT。 |
a_reload_rebinds_the_udp_port(e2e_hysteria_inbound.rs) |
h3 接受 shim 的传输层。 |
调用 assert_transport::<Bytes, Hy2H3Conn<Bytes>>() 的 const _ 块。 |
编译本身。 |
h3-quinn 的发送流从不同时经由两套 API 驱动。 |
QuicIo::poll_write 只使用 poll_send。 |
由结构保证;每个代理流测试都会覆盖。 |
失败路径与取消
Section titled “失败路径与取消”| 位置 | 结果 |
|---|---|
endpoint::from_socket 失败 |
run 返回该 io::Error;run_hysteria_inbound 记录 hysteria2 inbound failed: … 并调用 shutdown,后者发现没有 endpoint,直接返回。 |
| 没有连接许可 | incoming.refuse(),以 debug 级别记录。 |
| QUIC 握手失败 | 连接任务以 debug 级别记录 hysteria2: a handshake failed: … 后结束,释放其许可。 |
h3 初始化失败 |
http3 任务结束;Hy2H3Conn 被丢弃,因此 classifier 结束,该连接的所有代理流被中止。 |
resolve_request 失败,或写出响应失败 |
跳过该请求;连接继续服务。 |
| classifier 队列已满 | 新流的两个半边都以 H3_EXCESSIVE_LOAD 重置。 |
| 流在给出帧类型之前结束 | 以 debug 级别记录;丢弃该流。 |
流在 CLASSIFY_TIMEOUT 内未发送帧类型 |
两个半边都以 H3_REQUEST_CANCELLED 重置。 |
| 代理流没有 circuit 许可 | 两个半边都以 H3_REQUEST_REJECTED 重置。 |
| 新 UDP 会话没有 circuit 许可,或会话数已达上限 | 丢弃该数据报,以 trace 级别记录。 |
| UDP 包的地址中没有端口 | 丢弃该数据报,不记录日志;为新会话取得的许可会被释放。 |
| 代理流运行时失败 | 以 debug 级别记录(hysteria2: proxy stream failed: …);任务结束时释放其许可。 |
| 数据报运行时结束 | 以 debug 级别记录(hysteria2: datagram runtime ended: …);该连接上的 TCP 流继续运行。 |
取消自上而下进行。取消 token 会跳出 accept 循环;run 返回,丢弃其 connections 集合会中止所有连接任务,并经由它们各自的 JoinSet 中止所有流和数据报运行时。被中止的任务在析构时释放各自的许可。
丢弃 quinn endpoint 并不会立即释放其 UDP 端口。socket 属于 quinn 的 endpoint 驱动任务,只有当它在某次轮询中发现既没有存活连接、也没有剩余的 Endpoint 句柄时才会丢弃 socket;丢弃最后一个句柄只是唤醒它。调用方如果在 wait_idle 之后立即重新绑定,每次都会输掉这场竞争。因此 Hy2Inbound::shutdown 直接等待端口本身:
- 从槽位中取出 endpoint。第二次调用会拿到
None并直接返回。 - 以应用错误码
0x100调用endpoint.close(CLOSE_CODE, b""),关闭所有连接。 - 最多等待
DRAIN_TIMEOUT(3 秒)让endpoint.wait_idle()完成。 - 丢弃 endpoint 句柄。
- 每隔
RELEASE_POLL(20 毫秒)尝试一次std::net::UdpSocket::bind(local),最长RELEASE_TIMEOUT(3 秒)。之后端口仍被占用的话,记录一条警告(hysteria2: <addr> did not come free within 3s)并返回。
在 etemenanki-app 中,run_hysteria_inbound 在 run 返回后调用 shutdown,generation 代码会在下一个 generation 绑定之前 await 该任务。正是这一点让下一个 generation 在重载后能够绑定同一个 UDP 端口,即使旧 generation 当时仍有客户端连接。见 Generation 与重载。
| 常量 | 文件 | 值 | 限制对象 |
|---|---|---|---|
DEFAULT_MAX_CONNECTIONS |
server/config.rs |
4096 | 每个监听器的并发 QUIC 连接数(app 配置项 max_connections) |
DEFAULT_MAX_CIRCUITS |
server/config.rs |
65 536 | 整个监听器上的代理流与 UDP 会话总数(app 配置项 max_circuits) |
DEFAULT_MAX_INCOMING_STREAMS |
server/endpoint.rs |
1024 | 每连接的并发双向流数 |
STREAM_RECEIVE_WINDOW |
server/endpoint.rs |
8 MiB | 每流流控窗口 |
CONNECTION_RECEIVE_WINDOW |
server/endpoint.rs |
20 MiB | 每连接流控窗口 |
MAX_IDLE_TIMEOUT |
server/endpoint.rs |
30 秒 | QUIC 空闲超时 |
MAX_CLASSIFYING_STREAMS |
server/inbound.rs |
64 | classify_tx 和 h3_ready 的容量 |
CLASSIFY_TIMEOUT |
server/inbound.rs |
10 秒 | 流发送其帧类型的时限 |
MAX_VARINT_BYTES |
server/shim.rs |
8 | classifier 至多需要的字节数 |
DRAIN_TIMEOUT |
server/inbound.rs |
3 秒 | 关闭时等待 wait_idle 的时长 |
RELEASE_TIMEOUT、RELEASE_POLL |
server/inbound.rs |
3 秒、20 毫秒 | 关闭时等待端口释放 |
CLOSE_CODE |
server/inbound.rs |
0x100 |
关闭时的 QUIC 应用关闭码 |
Hy2StreamCore::BUF_SIZE |
server/inbound.rs |
8 KiB | 每个代理流的运行时缓冲区 |
Hy2StreamCore::STAGING_RESERVE |
server/inbound.rs |
2048 | 一个 TCPResponse 所需的空间 |
Hy2UdpCore::BUF_SIZE |
server/datagrams.rs |
16 KiB | 每个数据报通道的运行时缓冲区 |
Hy2UdpCore::STAGING_RESERVE |
server/datagrams.rs |
4096 | 一个回复的分片头部 |
MAX_SESSIONS |
server/datagrams.rs |
256 | 每连接的 UDP 关联数 |
SWEEP_INTERVAL |
server/datagrams.rs |
1 秒 | 空闲清扫 tick |
MAX_UDP_SIZE |
protocol.rs |
4096 | 重组后的包和回复载荷 |
MAX_DATAGRAM_FRAME_SIZE |
protocol.rs |
1200 | quinn 未报告时回退使用的数据报大小 |
MAX_ADDRESS_LENGTH |
protocol.rs |
2048 | TCPRequest 和 UDPMessage 中的地址 |
MAX_PADDING_LENGTH |
protocol.rs |
4096 | 任意帧上的 padding |
HANDSHAKE_TIMEOUT、SNIFF_TIMEOUT、RELAY_IDLE_TIMEOUT |
core/mod.rs、sniff/mod.rs |
10 秒、300 毫秒、300 秒 | Hy2StreamCore 各阶段的截止时间 |
app 拒绝 max_connections 和 max_circuits 取 0(inbound <tag>: max_connections must be at least 1),也拒绝 2 到 600 秒范围之外的 udp_idle_timeout(inbound <tag>: udp_idle_timeout must be between 2 and 600 seconds)。低于 2 秒时,繁忙的关联可能在两个包之间就被清扫掉;高于 600 秒时,失效的关联会占用其出站 socket 长达十分钟。katana 根据自己的每节点存活连接上限来设定 circuit_permits 的大小。这些上限与 TCP 入站的对比见 上限。
| 层级 | 文件 | 覆盖内容 |
|---|---|---|
| 单元,shim | protocols/tests/unit/hysteria/server/shim.rs |
分类(a_proxy_stream_is_recognised、anything_else_is_left_to_http3、被拆开的 varint、同一数据块中的正文字节、未给出帧类型的流)、前缀重放、stop_sending 委托,以及 AuthState。使用伪造的接收流,这正是包装器写成泛型的原因。 |
| 单元,流核心 | protocols/tests/unit/hysteria/server/inbound.rs |
在 CoreHarness 下测试 Hy2StreamCore:Connected 时打开并应答、ConnectFailed 时拒绝、无端口时拒绝、开启嗅探时提前应答、an_unfinished_handshake_times_out、transport_eof_finishes_or_shuts_the_outbound。 |
| 单元,数据报核心 | protocols/tests/unit/hysteria/server/datagrams.rs |
the_first_packet_of_a_session_opens_it_and_sends、a_reply_is_wrapped_for_its_session_and_split_when_too_big、重组为 SendToHeld、an_idle_session_is_closed_by_the_sweep、circuit 上限、无法路由的包。 |
| 单元,凭据 | protocols/tests/unit/hysteria/server/authenticator.rs |
全部三种表:在第一个冒号处拆分、用户名不区分大小写、拒绝凭据前缀、构建时的拒绝规则、两种多用户表互不接受对方的凭据。 |
| 单元,伪装 | protocols/tests/unit/hysteria/server/masquerade.rs |
the_default_is_gos_own_not_found、拒绝 233、无效状态码、a_configured_response_is_served_whole。 |
| 单元,线格式 | protocols/tests/unit/hysteria/protocol.rs |
TCPRequest、TCPResponse 和 UDPMessage 的编码、解析、分片,以及 Defragger。 |
| Pipeline | protocols/tests/pipeline/hysteria.rs |
在 loopback 上用本 crate 的 Hy2Connector 和裸 Hy2Conn 测试真实入站:同一连接上两条流的 TCP、包括 4096 字节载荷双向分片在内的 UDP、Salamander、不支持 UDP 的服务端(a_server_without_udp_refuses_associations)、错误凭据、被拒绝的目标、运行中替换用户表,以及认证之前的原始 QUIC 代理流。 |
| App 单元 | app/tests/unit/inbound.rs |
build_hysteria2_inbound:一份可用的配置,以及以下情形的拒绝:缺少证书或凭据、同时设置 password 和 users、obfs 设置、233 伪装、UDP 空闲超时、为零的上限、用户名、未知设置、[inbound.stream] 块和 Unix 监听地址。 |
| 互操作 | app/tests/integration/e2e_hysteria_inbound.rs |
用 vendored 源码树构建的上游 Go 客户端对接 app 的入站:TCP、UDP、a_large_datagram_is_fragmented_in_both_directions、obfuscated_traffic_interoperates、a_user_pass_credential_authenticates、错误凭据,以及在有存活客户端连接时的 a_reload_rebinds_the_udp_port。 |