跳转到内容

Hysteria 2:服务端

源码文件:30 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/hysteria/mod.rs
  • Etemenanki/protocols/src/hysteria/server/mod.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/server/endpoint.rs
  • Etemenanki/protocols/src/hysteria/server/inbound.rs
  • Etemenanki/protocols/src/hysteria/server/shim.rs
  • Etemenanki/protocols/src/hysteria/server/io.rs
  • Etemenanki/protocols/src/hysteria/server/datagrams.rs
  • Etemenanki/protocols/src/hysteria/server/authenticator.rs
  • Etemenanki/protocols/src/hysteria/server/masquerade.rs
  • Etemenanki/protocols/src/hysteria/auth.rs
  • Etemenanki/protocols/src/hysteria/protocol.rs
  • Etemenanki/protocols/src/hysteria/quic.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/helpers/crypto.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/shim.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/inbound.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/datagrams.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/authenticator.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/masquerade.rs
  • Etemenanki/protocols/tests/unit/hysteria/protocol.rs
  • Etemenanki/protocols/tests/pipeline/hysteria.rs
  • Etemenanki/app/tests/unit/inbound.rs
  • Etemenanki/app/tests/integration/e2e_hysteria_inbound.rs
  • katana/src/inbound.rs
  • katana/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 指南页。
  • 按用户准入。 下游若要停用某个用户,需要按流处理,而不是通过监听器(见 认证器)。
protocols/src/hysteria/server/config.rs
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)。

protocols/src/hysteria/server/inbound.rs
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 争抢端口。

protocols/src/hysteria/server/endpoint.rs
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 中等待,而不是流被接受后再被丢弃。

protocols/src/hysteria/server/shim.rs
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 路径中取走。设计说明见 区分流的类型 一节。

protocols/src/hysteria/server/io.rs
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 就丢弃流会重置它,对端会将其视为中止。
protocols/src/hysteria/server/inbound.rs
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 区)见 服务端核心。

protocols/src/hysteria/server/datagrams.rs
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
}
protocols/src/hysteria/server/authenticator.rs
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>>;
}
protocols/src/hysteria/server/masquerade.rs
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 之后。

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 级别记录,并结束该任务。

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 服务器。

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 只负责搬运流:

  1. 它用 try_send 把 quinn 已接受的所有流排入 classify_tx。这个调用从不 await,因为 poll_accept_bidi 不能阻塞。
  2. 队列放不下的流会用 discard(…, Code::H3_EXCESSIVE_LOAD) 重置两个半边(发送侧 reset,接收侧 stop_sending)。如果只是丢弃它,对端会继续往一条无人读取的流里写数据。这个重置限制了等待分类的工作量。
  3. 然后它轮询 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 时才 stage Connected,并将其清除,因此一条流永远不会得到两个响应。
  • 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 把连接的数据报通道表示为一个 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);http crate 接受的任何值(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。 由结构保证;每个代理流测试都会覆盖。
位置 结果
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 直接等待端口本身:

  1. 从槽位中取出 endpoint。第二次调用会拿到 None 并直接返回。
  2. 以应用错误码 0x100 调用 endpoint.close(CLOSE_CODE, b""),关闭所有连接。
  3. 最多等待 DRAIN_TIMEOUT(3 秒)让 endpoint.wait_idle() 完成。
  4. 丢弃 endpoint 句柄。
  5. 每隔 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。