Hysteria 2: server
Source files: 30 · checked against 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
The Hysteria 2 server is the one inbound in the workspace that does not sit under the generic stream accept loop. A Hysteria 2 client authenticates once per QUIC connection and then opens a bidirectional QUIC stream per proxied TCP connection, with UDP riding the same connection’s datagram channel. No part of that arrives as a single byte stream, so the server owns its UDP socket and its quinn endpoint, and drives one ProxyServerRuntime per proxy stream plus one per connection’s datagram channel.
This page is for contributors who change protocols/src/hysteria/server/. It covers the listener (Hy2Inbound), the HTTP/3 dispatch shim that tells proxy streams from HTTP/3 streams, the credential exchange and masquerade, the stream core (Hy2StreamCore), the datagram core (Hy2UdpCore), the authenticator, and shutdown. The dialling side, the shared wire primitives and Salamander obfuscation are on Hysteria 2: client. The whole module is behind the hysteria feature of etemenanki-protocols; the app enables it.
Responsibilities
Section titled “Responsibilities”| Component | File → symbol | Owns |
|---|---|---|
| Listener | server/inbound.rs → Hy2Inbound |
The quinn::Endpoint, the accept loop, the connection limit, the per-connection task set, shutdown and port release. |
| QUIC setup | server/endpoint.rs → server_config, from_socket |
rustls TLS 1.3 with ALPN h3, flow-control windows, stream limit, idle timeout, optional Salamander socket wrapper. |
| Dispatch shim | server/shim.rs → Hy2H3Conn, classify, PrefixedBidi |
Reading the first varint of each bidirectional stream and replaying it to h3 when the stream is not a proxy stream. |
| Credential exchange | server/inbound.rs → http3, answer |
The HTTP/3 server: 233 for a good /auth, the masquerade for everything else. |
| Proxy streams | server/inbound.rs → Hy2StreamCore; server/io.rs → QuicIo |
TCPRequest parsing, the TCPResponse, sniffing, and the verbatim relay. |
| UDP | server/datagrams.rs → QuicDatagrams, Hy2UdpCore |
Sessions keyed by session id, reassembly, fragmentation of replies, the idle sweep. |
| Credentials | server/authenticator.rs → Authenticator |
A shared password, a user:pass table, or a table of opaque credentials. |
| Masquerade | server/masquerade.rs → Masquerade |
The fixed response every unauthenticated request gets. |
What it leaves to others:
- Routing and dialling. Each connection gets a connector from the caller’s
make_connector(ip)closure. In etemenanki-app that isAppConnectorwith the inbound tag and the client address (app/src/serve.rs→run_hysteria_inbound); see Serving. - Configuration checks.
app/src/inbound/mod.rs→build_hysteria2_inboundvalidates the TOML and fails closed; the user-facing settings are on the Hysteria 2 guide page. - Per-user admission. A downstream that retires users does so per flow, not through the listener (see Authenticator).
Key types
Section titled “Key types”Configuration
Section titled “Configuration”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 is what one connection needs, and every connection on the listener shares it through the Arc. udp is both the switch and the association idle timeout: None means the server tells clients Hysteria-UDP: false and never starts a datagram runtime. T is the per-user payload a downstream attaches (etemenanki-app uses (); katana attaches its own user tag).
The listener
Section titled “The listener”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 is Clone by hand (no T: Clone bound): both fields are Arcs, so a clone shares the configuration and the endpoint slot. That is how a caller runs run on one handle and keeps another for set_authenticator and shutdown. The endpoint slot is a parking_lot::Mutex<Option<quinn::Endpoint>>: run fills it, shutdown takes it.
run takes an already bound std::net::UdpSocket, not an address. In etemenanki-app, spawn_generation binds the socket with bind_inbound and passes it to run_hysteria_inbound (see Generations and reload); binding a second socket here would race the first one for the port.
The QUIC endpoint
Section titled “The 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 builds the rustls configuration and the transport parameters:
| Setting | Constant | Value |
|---|---|---|
| TLS versions | rustls::version::TLS13 only |
TLS 1.3 |
| ALPN | ALPN_H3 |
h3 |
| Crypto provider | rustls::crypto::ring::default_provider() |
named explicitly |
| Per-stream receive window | STREAM_RECEIVE_WINDOW |
8 MiB |
| Connection receive window | CONNECTION_RECEIVE_WINDOW |
STREAM_RECEIVE_WINDOW / 2 * 5 = 20 MiB |
| Concurrent bidirectional streams per connection | DEFAULT_MAX_INCOMING_STREAMS |
1024 |
| Idle timeout | MAX_IDLE_TIMEOUT |
30 s |
| Client certificates | with_no_client_auth() |
none |
Two of these are not stylistic, and the comments on tls_config say why:
- The provider is named rather than taken from
rustls::ServerConfig::builder(), because that builder panics when a downstream unifies a second crypto-provider feature into the build. - TLS 1.3 is pinned.
QuicServerConfigunwrapsrustls::quic::ServerConnection::newinsidestart_session. A configuration without TLS 1.3 would passQuicServerConfig::try_fromand then panic on the first packet of the first connection, inside the accept loop.
Certificate and key errors are prefixed with hysteria2:: could not read the certificate: … and could not read the private key: … for PEM that does not parse, the certificate file contains no certificates, the key file contains no private key, and certificate and key do not match: … when rustls refuses the pair.
from_socket sets the socket non-blocking, wraps it for quinn’s Tokio runtime, and, when obfs is Some(Obfs::Salamander { psk }), wraps it again in SalamanderSocket. Salamander is symmetric and holds no per-peer state, so the client’s wrapper serves unchanged. The stream limit costs nothing to hold: QUIC enforces it by withholding stream credit, so a client at the limit waits in open_bi instead of having streams accepted and dropped.
The dispatch shim
Section titled “The dispatch 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 implements h3’s quic::Connection over an h3-quinn connection, so h3::server::Connection runs on top of it without knowing that some streams are taken out of its accept path. The section Classifying streams explains the design.
Stream I/O
Section titled “Stream I/O”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 { /* … */ }Proxy streams reach the core as h3 stream types, because h3-quinn’s stream constructors are private and the shim cannot hand out raw quinn streams. QuicIo adapts them to AsyncRead and AsyncWrite:
pendingholds the rest of the lastpoll_datachunk, since a caller’s buffer may be smaller than one chunk.newseeds it with the bytes the classifier read past the frame type.finishedlatches the peer’s end of stream, so later reads return EOF without polling again.- Writes go only through
poll_send(SendStreamUnframed).h3-quinn’s send stream panics ifsend_datais mixed withpoll_send, soQuicIonever touches the framed API. poll_flushis a no-op;poll_shutdowncallspoll_finish, a clean half-close. Dropping the stream unfinished would reset it, which the peer reads as an abort.
The stream core
Section titled “The stream core”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 fits a request with the longest address and padding; STAGING_RESERVE fits a TCPResponse with the longest padding. The generic sans-I/O contract (Event, Effect, the staging area) is on Server core.
The datagram side
Section titled “The datagram side”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}Credentials and masquerade
Section titled “Credentials and masquerade”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 implements Clone by hand so that T need not be Clone; the payload only ever lives behind the Arc.
Data flow
Section titled “Data flow”Tasks and ownership
Section titled “Tasks and ownership”Every task below the accept loop is owned by a JoinSet one level up, so dropping an owner takes everything below it with it.
flowchart TB run["Hy2Inbound::run: accept loop"] conn["connection task: serve_connection"] h3["http3 task: h3 server over Hy2H3Conn"] cls["classifier task"] udp["datagram task: Hy2UdpCore runtime"] stream["proxy stream task: Hy2StreamCore runtime"] run -->|"JoinSet, holds connection permit"| conn conn -->|JoinSet| h3 conn -->|JoinSet| cls conn -->|"JoinSet, only if udp is Some"| udp cls -->|"JoinSet, holds circuit permit"| stream h3 -.->|"classify_tx: new bidi streams"| cls cls -.->|"h3_ready: replayed HTTP/3 streams"| h3 h3 -.->|"authed oneshot: UserEntry"| udp
| Task | Spawned by | Owned by | Ends when |
|---|---|---|---|
| accept loop | caller (run_hysteria_inbound in the app) |
caller | endpoint.accept() returns None, or token is cancelled |
| connection | accept loop, after taking a connection permit | connections: JoinSet<()> in run |
the QUIC handshake fails, or all its child tasks have ended |
http3 |
serve_connection |
tasks: JoinSet<()> |
h3 setup fails, or h3.accept() returns Ok(None) or an error |
classifier |
serve_connection |
tasks |
the classify_tx sender is dropped (the Hy2H3Conn is gone), or the h3_ready receiver is gone |
| datagram runtime | serve_connection, when config.udp is Some |
tasks |
the authentication oneshot is dropped unsent, or the runtime ends (read_datagram fails when the connection closes) |
| proxy stream runtime | classifier, after taking a circuit permit |
serving: JoinSet<()> in classifier |
the runtime future completes |
The http3 task holds the h3::server::Connection, which holds the Hy2H3Conn. When it ends, the classify_tx sender goes with it, the classifier’s incoming.recv() returns None, and dropping the classifier’s serving set aborts every proxy stream of that connection. serve_connection simply waits for tasks.join_next() to drain.
Accepting a connection
Section titled “Accepting a connection”let Ok(permit) = permits.clone().try_acquire_owned() else { incoming.refuse(); continue;};run builds the endpoint with endpoint::from_socket, stores a clone in the endpoint slot, and creates a Semaphore of max_connections permits. The loop select!s over three things: the next Incoming, the cancellation token, and connections.join_next() (so finished connection tasks are reaped while the loop is idle).
A connection that finds no permit is refused, not ignored. Incoming::refuse tells the client at once, instead of letting it retransmit its handshake into silence. The permit moves into the connection task and lives until the task ends, so it covers the QUIC handshake and the whole connection. A failed handshake is logged at debug and ends the task.
Authenticating and serving streams
Section titled “Authenticating and serving streams”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: open bidi stream 1: HEADERS POST /auth Q->>K: classify_tx.try_send(stream 1) K->>K: classify: first varint is 0x01, not 0x401 K->>Q: h3_ready.send(stream 1 with replayed bytes) Q->>H: poll_accept_bidi returns stream 1 H->>H: answer: authenticator matches, AuthState set H->>C: 233, Hysteria-UDP, Hysteria-CC-RX auto, padding C->>Q: open bidi stream 2: 0x401 TCPRequest Q->>K: classify_tx.try_send(stream 2) K->>K: classify: 0x401 and AuthState has a user K->>S: spawn runtime over QuicIo(stream 2, rest), with circuit permit S->>C: TCPResponse, then relay
The HTTP/3 side runs h3::server::Connection::new(transport) and loops over accept(). For each request it calls answer, which decides on method, path and host only:
| Request | Connection state | Response |
|---|---|---|
POST, path AUTH_PATH (/auth), host AUTH_HOST (hysteria), credential matches |
not authenticated | 233 with Hysteria-UDP, Hysteria-CC-RX: auto, Hysteria-Padding; AuthState records the user |
| the same request again, with any credential | authenticated | 233 again, without checking the credential; the stored identity does not change |
| the same request, credential does not match | not authenticated | the masquerade |
| anything else | any | the masquerade |
The success response is built by accepted: status STATUS_AUTH_OK (233), Hysteria-UDP set to true or false from config.udp.is_some(), Hysteria-CC-RX: auto (the server declines to state a receive rate, so the client uses congestion control), and Hysteria-Padding drawn from AUTH_RESPONSE_PADDING (256 to 2047 alphanumerics). The credential is read from the Hysteria-Auth header; a value that is not a visible-ASCII header string is read as the empty string, which no authenticator accepts because every constructor refuses an empty credential.
The first time AuthState holds a user, http3 sends a clone of the UserEntry down the authed oneshot, which is what lets the datagram task build its runtime. The send happens before the response is written. Until then the datagram task only waits on the oneshot, so no datagram is read for a connection that has not authenticated, as upstream starts its session manager only after a credential is accepted.
A rejected credential gets exactly the response a wrong URL gets. That is the censorship-resistance property the protocol rests on: without a credential the server is an ordinary HTTP/3 web server.
Classifying streams
Section titled “Classifying streams”A Hysteria 2 server is an HTTP/3 server that also carries proxy traffic on the same connection’s bidirectional streams. The two are told apart by the first QUIC varint of the stream: FRAME_TYPE_TCP_REQUEST (0x401) is not an HTTP/3 frame type. Upstream’s HTTP/3 server has a “proxy stream hijacker” hook for this; h3 has none, so the shim puts the split one layer lower, in the transport h3 runs on.
classify appends whole poll_data chunks to a BytesMut (initial capacity MAX_VARINT_BYTES, 8) until the width announced by the first byte’s top two bits is covered, then splits:
frame_type_bytes: the varint’s own bytes, exactly as received;rest: every byte read past the varint, which is the start of the body;kind:ProxyRequestfor0x401, otherwiseHttp3(value).
If the peer finishes the stream before naming it, classify returns a StreamErrorIncoming (hysteria2: stream ended before its frame type).
Why the varint has to be replayed. A QUIC receive stream cannot be peeked; reading consumes. A stream that turns out to be HTTP/3 is therefore handed to h3 as Hy2BidiStream::new(classified.replay(), stream), a PrefixedBidi whose receive half yields the replayed bytes before delegating. replay returns frame_type_bytes followed by rest, in wire order. When h3 splits the stream, PrefixedBidi::split moves the prefix into a PrefixedRecv, because only the receive half can replay it. h3 cannot tell the difference. A proxy stream does not need the frame type again, since Hy2StreamCore parses the request body, so it is wrapped as Hy2BidiStream::unprefixed(stream) and rest goes to QuicIo as its prefix.
Why classification is not done in poll_accept_bidi. That is a Poll function and cannot await the varint. So the waiting moves into a separate classifier task, bounded by a queue and a timeout, and poll_accept_bidi only moves streams:
- It drains every stream quinn has accepted into
classify_txwithtry_send. The call never awaits, becausepoll_accept_bidicannot block. - A stream that does not fit in the queue is reset with
discard(…, Code::H3_EXCESSIVE_LOAD), both halves (reseton the send side andstop_sendingon the receive side). Dropping it instead would leave the peer writing into a stream nobody reads. This reset bounds the work waiting for classification. - It then polls
h3_ready_rxand returns the next stream the classifier handed back. If the classifier is gone, it returnsConnectionErrorIncoming::InternalError("hysteria2: stream classifier stopped").
Both sources are polled before Pending is returned, so both wakers are armed; waking on only one would stall until the other happened to fire. Unidirectional streams (HTTP/3 control and QPACK) belong to h3 alone and pass through poll_accept_recv unclassified. Streams the server opens itself go through Hy2Opener, which wraps them with an empty prefix only so the type matches what h3 accepts.
The classifier task receives streams from classify_rx one at a time and runs classify on each under tokio::time::timeout(CLASSIFY_TIMEOUT, …):
| Outcome | Action |
|---|---|
0x401 and auth.user() is Some |
take a circuit permit, spawn a Hy2StreamCore runtime into serving |
0x401 but no circuit permit left |
discard(…, Code::H3_REQUEST_REJECTED) |
anything else, or 0x401 before authentication |
send Hy2BidiStream::new(replay, stream) to h3_ready; if h3 has gone, the classifier stops |
classify returned an error |
logged at debug, the stream is dropped |
CLASSIFY_TIMEOUT passed |
discard(…, Code::H3_REQUEST_CANCELLED) |
A proxy stream that arrives before authentication is handed to HTTP/3 with its bytes replayed, as upstream does. h3 sees a frame type it does not know, and the stream is neither answered nor relayed, which is what a real web server would do with it.
A compile-time item at the end of shim.rs, const _: () = { assert_transport::<Bytes, Hy2H3Conn<Bytes>>(); }, proves h3 accepts the transport. quic::Connection requires its OpenStreams associated type to agree with its own SendStream and BidiStream, so Hy2Opener and Hy2H3Conn must name exactly the same pair. h3 is a 0.0.x crate; when its traits move, this assertion fails first and says so plainly.
The wrappers are generic over the inner stream for the same private-constructor reason: it lets protocols/tests/unit/hysteria/server/shim.rs exercise them against a fake receive stream instead of a live QUIC connection.
Proxy streams
Section titled “Proxy streams”Wire format
Section titled “Wire format”The client writes a TCPRequest and waits for a TCPResponse before sending payload. All varints are QUIC varints (RFC 9000 section 16), not protobuf varints.
TCPRequest, client to server:
| Field | Size | Meaning |
|---|---|---|
| frame type | varint | 0x401 (FRAME_TYPE_TCP_REQUEST), two bytes on the wire; consumed by the classifier |
| address length | varint | 1 to MAX_ADDRESS_LENGTH (2048) |
| address | bytes | host:port, UTF-8 |
| padding length | varint | at most MAX_PADDING_LENGTH (4096) |
| padding | bytes | ignored |
TCPResponse, server to client:
| Field | Size | Meaning |
|---|---|---|
| status | u8 | STATUS_OK (0x00) or STATUS_ERROR (0x01) |
| message length | varint | 0 to MAX_MESSAGE_LENGTH (2048) |
| message | bytes | Connected on success |
| padding length | varint | the server draws from TCP_RESPONSE_PADDING, 128 to 1023 bytes |
| padding | bytes | alphanumerics, ignored by the client |
parse_tcp_request_body range-checks each length before it is used and returns Ok(None) while the buffer is short, so the core can wait for more bytes without allocating for an attacker-chosen length. An address that is not UTF-8, empty, or longer than 2048 bytes, and a padding length over 4096, are ProtocolError::Malformed and end the stream.
The state machine
Section titled “The state machine”stateDiagram-v2 [*] --> Request Request --> Relay: parsed, no sniff, Open with reply = true Request --> Sniff: parsed, sniff and IP target, stage Connected Request --> Done: no port or bad address, stage refusal and Finish Request --> Done: TransportEof, Finish Sniff --> Relay: verdict, SNIFF_TIMEOUT or TransportEof, Open with held bytes and reply = false Relay --> Relay: Connected, stage Connected if reply Relay --> Done: ConnectFailed, refusal if reply, then ShutdownTransport and Finish Done --> [*]
Request. on_transport calls parse_tcp_request_body. The address is parsed with parse_authority(&address, 0): the default port is zero rather than a guess, and a destination whose port is still zero is refused with a TCPResponse of status 0x01 and message bad address, followed by Effect::ShutdownTransport and Effect::Finish. A request that names no port can therefore never be routed by accident. Otherwise the core builds a Flow with network DialNetwork::Tcp, the client address as source, and a NetworkUser whose authorization is UsernamePassword { username: user.label, password: Default::default() }. The flow carries the label and the payload, never the credential.
Sniff. When sniff is set and worth_sniffing says the destination is a bare IP, the core stages Connected at once and enters Phase::Sniff. Payload bytes then go into the SniffPrefix (bounded by the sniff budget) until the collector gives a verdict, the sniff deadline passes, or the client half-closes. open_sniffed moves the flow out of the state, attaches prefix.result() as flow.sniffed, opens it with reply = false, and pushes Effect::ForwardHeld for the collected bytes. See Sniffing.
Relay. open pushes Effect::Open { key: Single, target }, forwards any held bytes, sets State::Relay, and enters Phase::Relay. From here Passthrough<Single> does the work: transport bytes are forwarded verbatim, outbound bytes are staged verbatim, and each end of stream half-closes the other side (Effect::Shutdown or Effect::ShutdownTransport), finishing once both have ended. prefix.clear() runs at the next byte event, after the runtime has applied the forward that referenced the held bytes.
Event::ConnectedstagesConnectedonly ifreplyis still true, and clears it, so a stream never gets two responses.Event::ConnectFailedstages a refusal only ifreplyis true. On the sniff path the client was already toldConnected, so the core ends the stream instead withShutdownTransportandFinish.Event::OutboundErrorcallson_outbound_gone, which shuts the transport and finishes; staged bytes still drain.
Deadlines come from Timing in protocols/src/core/mod.rs, which gives the runtime’s single timer a meaning per phase: HANDSHAKE_TIMEOUT (10 s) while a started request is incomplete, SNIFF_TIMEOUT (300 ms) while sniffing, and RELAY_IDLE_TIMEOUT (300 s) while relaying, re-armed on every byte event. An expired handshake returns handshake_timed_out() (client did not complete its request in time); an expired sniff window opens the flow with what was collected; an idle relay finishes.
Why a CONNECT may be answered before the target is up
Section titled “Why a CONNECT may be answered before the target is up”A client blocks reading the TCPResponse before it sends any payload, and sniffing means reading payload. If the server waited for the target before answering a request it wants to sniff, the client would wait for the server and the server for the client until the sniff deadline expired.
So the rule, stated in the module docs of inbound.rs, is:
- a request that will be sniffed (sniffing enabled and an IP target) is answered
Connectedat once, and the flow opens once the first bytes have been inspected or the sniff window closes; - every other request is answered only when the runtime reports
Event::Connected, that is, when the target really is up, and with a refusal when it is not.
The unit test a_request_opens_and_is_answered_once_connected asserts that nothing is staged before Connected; sniffing_an_ip_target_answers_early_and_opens_with_the_host asserts the early answer and that no second response follows.
Datagrams
Section titled “Datagrams”Wire format
Section titled “Wire format”Each QUIC datagram carries one UDPMessage or one fragment of one:
| Field | Size | Meaning |
|---|---|---|
| session id | u32, big-endian | the association, chosen by the client |
| packet id | u16, big-endian | ties the fragments of one packet together; the server sends 0 when unfragmented |
| fragment id | u8 | index of this fragment, from 0 |
| fragment count | u8 | number of fragments; 1 means not split |
| address length | varint | 1 to MAX_ADDRESS_LENGTH (2048) |
| address | bytes | host:port, UTF-8 |
| payload | bytes | the rest of the datagram; must not be empty |
QuicDatagrams
Section titled “QuicDatagrams”QuicDatagrams is the connection’s datagram channel as a DatagramLink with Addr = (): a QUIC connection has exactly one peer. poll_send_to calls conn.send_datagram and maps errors through hysteria::quic::datagram_error. poll_recv_from keeps one boxed read_datagram future in pending across polls, copies the datagram into the caller’s buffer, and maps a connection error to ErrorKind::BrokenPipe, which ends the runtime when the connection closes.
The runtime is built with ProxyServerRuntime::over_datagrams(QuicDatagrams::new(conn), core, make_connector(source)) and a buffer of Hy2UdpCore::BUF_SIZE (16 KiB), enough for a reassembled packet plus its fragments’ headers. See Server runtime for the datagram transport mode.
Sessions
Section titled “Sessions”flowchart TB
pkt["TransportDatagram"] --> parse{"parse_udp_message_at ok?"}
parse -- no --> drop1["drop"]
parse -- yes --> known{"session id known?"}
known -- yes --> frag
known -- no --> cap{"fewer than MAX_SESSIONS?"}
cap -- no --> drop2["drop"]
cap -- yes --> permit{"circuit permit?"}
permit -- no --> drop3["drop"]
permit -- yes --> port{"address has a port?"}
port -- no --> drop4["drop"]
port -- yes --> open["Open key = session id, insert Session"]
open --> frag{"frag_count at most 1?"}
frag -- yes --> send["SendTo range of the datagram"]
frag -- no --> defrag["Defragger::feed"]
defrag -- complete --> held["copy into held, SendToHeld"]
Hy2UdpCore keeps a BTreeMap<u32, Session>. A Session holds the sweep tick of its last activity, a Defragger, and an OwnedSemaphorePermit from the listener’s circuit_permits, so a UDP association counts against the same circuit budget as a proxy stream and releases its permit when the session is removed.
- Opening. The first packet of an unknown session id opens it with
Effect::Open { key: id, target }, where the target is aFlowwithDialNetwork::Udpbuilt from that packet’s address. The cap (MAX_SESSIONS, 256 per connection) is checked before the permit is taken. - Sending. Every packet is sent to its own address, not the address that opened the session:
Effect::SendTo { key, to, range }forwards the payload by range out of the received datagram without copying. A packet whose address has no port, or whose payload is empty, is dropped. - Reassembly. A fragmented packet goes through the session’s
Defragger. It holds one packet’s fragments at a time (a fragment of a different packet discards the one in progress), drops a fragment index past the count and repeated fragments, and caps the reassembled size atMAX_UDP_SIZE(4096): a fragment that would take it past the cap discards the packet in progress. The finished payload is copied intoheldand sent withEffect::SendToHeld. - Unparseable datagrams are dropped and logged at trace level; the whole datagram is consumed either way.
- Failures.
ConnectFailedandOutboundErrorfor a session remove it, releasing its permit.SendFailedandTransportSendFaileddrop one packet and keep the session.
The protocol has no message to close an association, so sessions retire on idleness only, as upstream’s do.
The sweep
Section titled “The sweep”The core has its own clock: now counts sweep ticks. The first packet or reply arms Effect::SetDeadline(Some(SWEEP_INTERVAL)). Each Event::Deadline increments now, removes every session with now - last >= idle (where idle is idle_timeout in whole seconds, at least 1), pushes Effect::Close { key } for each, and re-arms the deadline. Activity in either direction sets last = now. A session id that was swept can be reused, and its next packet opens a fresh association.
Replies and fragmentation
Section titled “Replies and fragmentation”on_reply wraps each reply from an association in a UDPMessage with the session id, the reply’s source as format_authority(from), and frag_count = 1. Empty replies and replies over MAX_UDP_SIZE are dropped.
The size limit is max_datagram, read once when the datagram runtime starts: conn.max_datagram_size(), falling back to MAX_DATAGRAM_FRAME_SIZE (1200). A message that fits is staged whole with fx.put_to((), …). A larger one gets a random non-zero packet_id (zero is reserved for the unfragmented case) and is split by UdpMessage::fragment into pieces that each fit; if the header alone leaves no room, or the split would need more than 255 fragments, the reply is dropped. STAGING_RESERVE (4096) covers the fragment headers of a maximal reply. The fragmentation code is the same UdpMessage::fragment the client uses, and a_large_datagram_is_fragmented_in_both_directions exercises it against the upstream client.
Authenticator
Section titled “Authenticator”Authenticator decides whom a Hysteria-Auth string belongs to. The protocol carries one opaque string, so a multi-user server needs a convention, and there are two in use:
| Variant | Constructor | Wire credential | Built for |
|---|---|---|---|
Shared |
shared(password, label, data) |
the password | one password for everyone |
UserPass |
user_pass(users) |
user:pass, split on the first colon; the username is lower-cased on both sides |
clients configured for an upstream server |
Passwords |
passwords(users) |
the whole string, one opaque credential per user | panel-driven nodes, where the credential identifies the user by itself |
Construction fails closed:
| Refusal | Message |
|---|---|
| empty shared password | hysteria2: the password must not be empty |
user_pass entry with an empty name or password |
hysteria2: a user needs both a name and a password |
username containing : (it could never be presented) |
hysteria2: a username cannot contain ':' — it separates the two on the wire |
| two usernames equal after lower-casing | hysteria2: two users share a name once lower-cased |
passwords entry with an empty credential |
hysteria2: a user needs a credential |
| two users with the same credential | hysteria2: two users share one credential |
| empty table | hysteria2: the user table is empty |
authenticate compares with helpers::crypto::ct_eq, the crate’s subtle-backed comparison, and scans every entry of a table without returning early, recording the match in a local. For UserPass the name and password comparisons are combined with a non-short-circuit &. The scan is linear; that is affordable because Hysteria 2 authenticates once per QUIC connection, not once per proxied stream.
Swapping the table under a live listener
Section titled “Swapping the table under a live listener”ServerConfig::authenticator is an ArcSwap<Authenticator<T>>, and answer loads it for each credential check. Hy2Inbound::set_authenticator stores a new table:
- the listener, its socket and every live connection are untouched;
- a connection authenticated before the swap keeps the
UserEntryitsAuthStaterecorded, becauseAuthStateis aOnceLockthat the swap cannot reach; - only connections authenticated after the swap see the new table.
Rebuilding the listener instead would rebind the UDP port and drop every connected client. katana relies on this on every user sync: it builds a Passwords table by default (a user_pass table when a node is configured for it), commits its admission registry first, and then calls set_authenticator. Because a connection that is already in keeps its identity, a downstream that retires a user must refuse that user’s flows itself; katana does so in its per-flow admission check. etemenanki-app never swaps: its table comes from the config file, and a config change rebuilds the generation.
Masquerade
Section titled “Masquerade”Masquerade is the fixed response every request gets that is not a good credential: status, Content-Type, Content-Length, then the body, then finish.
Masquerade::default()is Go’shttp.NotFound, byte for byte:404,text/plain; charset=utf-8, body404 page not found\n. That is what an upstream server with no masquerade configured answers, so a probe cannot tell the two apart by their 404.Masquerade::newrefusesSTATUS_AUTH_OK(233), because serving it to an unauthenticated request would tell that request it had authenticated:hysteria2: 233 is the authentication success status and cannot be used for the masquerade. It also refuses a value that is not an HTTP status code (hysteria2: <n> is not an HTTP status code); anything thehttpcrate accepts (100 to 999) is the operator’s choice.
In etemenanki-app, setting any of status, body or content_type builds a Masquerade::new with the defaults filling the rest; setting none uses Masquerade::default().
Invariants
Section titled “Invariants”| Invariant | Enforced by | Pinned by |
|---|---|---|
| No flow is opened for a connection that has not authenticated. | The classifier takes the proxy branch only on (true, Some(user)); everything else goes to HTTP/3. The datagram task waits on the authed oneshot before it builds a runtime. |
a_proxy_stream_before_authentication_is_never_relayed (protocols/tests/pipeline/hysteria.rs); a_connection_starts_unauthenticated (protocols/tests/unit/hysteria/server/shim.rs) |
| A connection’s identity is set once and never changes. | AuthState is a OnceLock; a repeated /auth is answered 233 without re-authenticating. |
authenticating_records_who, a_second_authentication_cannot_change_the_user (shim.rs unit tests) |
| A rejected credential is indistinguishable from a wrong URL. | answer returns the same Masquerade response and body for both. |
No test compares the two responses. a_wrong_credential_is_refused (pipeline) and a_wrong_credential_never_gets_a_proxy (app/tests/integration/e2e_hysteria_inbound.rs) pin the refusal itself. |
| The masquerade never uses the success status. | Masquerade::new refuses 233; the app maps the error into the inbound’s config error. |
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) |
| Classification loses no byte. | Classified::replay and PrefixedBidi/PrefixedRecv for HTTP/3; QuicIo’s prefix for proxy streams. |
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 unit tests) |
| A stream that ends unnamed does not leave the classifier waiting. | classify returns an error on end of stream. |
a_stream_that_ends_unnamed_is_an_error (shim.rs unit tests) |
| Work awaiting classification is bounded. | classify_tx capacity MAX_CLASSIFYING_STREAMS, try_send plus discard(H3_EXCESSIVE_LOAD) on overflow; CLASSIFY_TIMEOUT plus discard(H3_REQUEST_CANCELLED). |
No dedicated test. |
| The connection limit covers the whole connection. | The connection permit moves into the connection task. | No dedicated test. |
| The circuit limit covers a circuit’s whole life. | The stream permit moves into the spawned runtime task; the session permit lives in Session until the session is removed. |
UDP sessions: the_circuit_limit_refuses_new_sessions (protocols/tests/unit/hysteria/server/datagrams.rs). Proxy streams: no dedicated test. |
| A request without a port is never routed. | parse_authority(…, 0) and a check for port zero, in both cores. |
a_request_without_a_port_is_refused (protocols/tests/unit/hysteria/server/inbound.rs); unroutable_packets_are_dropped (datagrams.rs unit tests) |
| A non-sniffed request is answered only once the target is up, and a stream gets at most one response. | reply flag in State::Relay, cleared on Connected. |
a_request_opens_and_is_answered_once_connected, sniffing_an_ip_target_answers_early_and_opens_with_the_host (inbound.rs unit tests) |
| A failed connect is answered, not left hanging. | Event::ConnectFailed stages a refusal and finishes. |
a_failed_connect_is_refused_and_finished (unit); a_failed_connect_is_answered_with_a_refusal (pipeline) |
| Swapping the user table leaves live connections alone. | ArcSwap loaded per credential check; identity stored in AuthState. |
the_user_table_can_be_replaced_under_a_live_inbound (pipeline) |
| Credential tables are unambiguous. | Refusals in user_pass and 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 unit tests) |
| Reassembly memory per session is bounded. | Defragger holds one packet and caps it at 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 unit tests) |
| The port is free before the next generation binds it. | shutdown polls UdpSocket::bind until it succeeds, for up to RELEASE_TIMEOUT. |
a_reload_rebinds_the_udp_port (e2e_hysteria_inbound.rs) |
h3 accepts the shim’s transport. |
const _ block calling assert_transport::<Bytes, Hy2H3Conn<Bytes>>(). |
Compilation. |
h3-quinn’s send stream is never driven through both APIs. |
QuicIo::poll_write uses only poll_send. |
Structural; exercised by every proxy-stream test. |
Failure paths and cancellation
Section titled “Failure paths and cancellation”| Where | What happens |
|---|---|
endpoint::from_socket fails |
run returns the io::Error; run_hysteria_inbound logs hysteria2 inbound failed: … and calls shutdown, which finds no endpoint and returns. |
| No connection permit | incoming.refuse(), logged at debug. |
| QUIC handshake fails | The connection task logs hysteria2: a handshake failed: … at debug and ends, releasing its permit. |
h3 setup fails |
The http3 task ends; the Hy2H3Conn is dropped, so the classifier ends and every proxy stream of the connection is aborted. |
resolve_request fails, or writing a response fails |
That request is skipped; the connection keeps serving. |
| Classifier queue full | The new stream is reset on both halves with H3_EXCESSIVE_LOAD. |
| Stream ends before its frame type | Logged at debug; the stream is dropped. |
Stream sends no frame type within CLASSIFY_TIMEOUT |
Reset on both halves with H3_REQUEST_CANCELLED. |
| No circuit permit for a proxy stream | Reset on both halves with H3_REQUEST_REJECTED. |
| No circuit permit, or session cap reached, for a new UDP session | The datagram is dropped, logged at trace. |
| No port in a UDP packet’s address | The datagram is dropped without a log line; a permit taken for a new session is released. |
| Proxy stream runtime fails | Logged at debug (hysteria2: proxy stream failed: …); its permit is released when the task ends. |
| Datagram runtime ends | Logged at debug (hysteria2: datagram runtime ended: …); TCP streams on the connection continue. |
Cancellation runs top-down. Cancelling token breaks the accept loop; run returns, and dropping its connections set aborts every connection task and, through their JoinSets, every stream and datagram runtime. Aborted tasks release their permits as they drop.
Shutdown and the port
Section titled “Shutdown and the port”Dropping a quinn endpoint does not release its UDP port straight away. The socket belongs to quinn’s endpoint driver task, which drops it only on a poll that finds no live connections and no Endpoint handle left; dropping the last handle merely wakes it. A caller that rebinds immediately after wait_idle loses that race every time. Hy2Inbound::shutdown therefore waits on the port itself:
- Take the endpoint out of the slot. A second call finds
Noneand returns. endpoint.close(CLOSE_CODE, b"")with application error code0x100, which closes every connection.- Wait up to
DRAIN_TIMEOUT(3 s) forendpoint.wait_idle(). - Drop the endpoint handle.
- Try
std::net::UdpSocket::bind(local)everyRELEASE_POLL(20 ms), for up toRELEASE_TIMEOUT(3 s). If the port is still busy after that, log a warning (hysteria2: <addr> did not come free within 3s) and return.
In etemenanki-app, run_hysteria_inbound calls shutdown after run returns, and the generation code awaits that task before the next generation binds. That is what lets the next generation bind the same UDP port after a reload, even while the old generation still had clients connected. See Generations and reload.
Limits
Section titled “Limits”| Constant | File | Value | Bounds |
|---|---|---|---|
DEFAULT_MAX_CONNECTIONS |
server/config.rs |
4096 | concurrent QUIC connections per listener (app key max_connections) |
DEFAULT_MAX_CIRCUITS |
server/config.rs |
65 536 | proxy streams plus UDP sessions across the listener (app key max_circuits) |
DEFAULT_MAX_INCOMING_STREAMS |
server/endpoint.rs |
1024 | concurrent bidirectional streams per connection |
STREAM_RECEIVE_WINDOW |
server/endpoint.rs |
8 MiB | per-stream flow-control window |
CONNECTION_RECEIVE_WINDOW |
server/endpoint.rs |
20 MiB | per-connection flow-control window |
MAX_IDLE_TIMEOUT |
server/endpoint.rs |
30 s | QUIC idle timeout |
MAX_CLASSIFYING_STREAMS |
server/inbound.rs |
64 | capacity of classify_tx and of h3_ready |
CLASSIFY_TIMEOUT |
server/inbound.rs |
10 s | time for a stream to send its frame type |
MAX_VARINT_BYTES |
server/shim.rs |
8 | bytes the classifier needs at most |
DRAIN_TIMEOUT |
server/inbound.rs |
3 s | wait for wait_idle on shutdown |
RELEASE_TIMEOUT, RELEASE_POLL |
server/inbound.rs |
3 s, 20 ms | wait for the port on shutdown |
CLOSE_CODE |
server/inbound.rs |
0x100 |
QUIC application close code on shutdown |
Hy2StreamCore::BUF_SIZE |
server/inbound.rs |
8 KiB | runtime buffer per proxy stream |
Hy2StreamCore::STAGING_RESERVE |
server/inbound.rs |
2048 | room for one TCPResponse |
Hy2UdpCore::BUF_SIZE |
server/datagrams.rs |
16 KiB | runtime buffer per datagram channel |
Hy2UdpCore::STAGING_RESERVE |
server/datagrams.rs |
4096 | fragment headers of one reply |
MAX_SESSIONS |
server/datagrams.rs |
256 | UDP associations per connection |
SWEEP_INTERVAL |
server/datagrams.rs |
1 s | idle sweep tick |
MAX_UDP_SIZE |
protocol.rs |
4096 | reassembled packet and reply payload |
MAX_DATAGRAM_FRAME_SIZE |
protocol.rs |
1200 | fallback datagram size when quinn reports none |
MAX_ADDRESS_LENGTH |
protocol.rs |
2048 | address in TCPRequest and UDPMessage |
MAX_PADDING_LENGTH |
protocol.rs |
4096 | padding on any frame |
HANDSHAKE_TIMEOUT, SNIFF_TIMEOUT, RELAY_IDLE_TIMEOUT |
core/mod.rs, sniff/mod.rs |
10 s, 300 ms, 300 s | per-phase deadlines of Hy2StreamCore |
The app refuses 0 for max_connections and max_circuits (inbound <tag>: max_connections must be at least 1), and a udp_idle_timeout outside 2 to 600 seconds (inbound <tag>: udp_idle_timeout must be between 2 and 600 seconds). Below 2 s a busy association would be swept between packets; above 600 s a dead one holds its outbound socket for ten minutes. katana sizes circuit_permits from its own per-node live-connection limit. See Limits for how these compare with the TCP inbounds.
| Layer | File | What it covers |
|---|---|---|
| Unit, shim | protocols/tests/unit/hysteria/server/shim.rs |
Classification (a_proxy_stream_is_recognised, anything_else_is_left_to_http3, split varints, body bytes in the same chunk, unnamed streams), prefix replay, stop_sending delegation, and AuthState. Uses a fake receive stream, which is why the wrappers are generic. |
| Unit, stream core | protocols/tests/unit/hysteria/server/inbound.rs |
Hy2StreamCore under CoreHarness: open and answer on Connected, refusal on ConnectFailed, no-port refusal, early answer with sniffing, an_unfinished_handshake_times_out, transport_eof_finishes_or_shuts_the_outbound. |
| Unit, datagram core | 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, reassembly into SendToHeld, an_idle_session_is_closed_by_the_sweep, the circuit limit, unroutable packets. |
| Unit, credentials | protocols/tests/unit/hysteria/server/authenticator.rs |
All three tables: splitting on the first colon, case-insensitive names, prefixes refused, build-time refusals, the two multi-user kinds rejecting each other’s credentials. |
| Unit, masquerade | protocols/tests/unit/hysteria/server/masquerade.rs |
the_default_is_gos_own_not_found, the 233 refusal, invalid status codes, a_configured_response_is_served_whole. |
| Unit, wire | protocols/tests/unit/hysteria/protocol.rs |
TCPRequest, TCPResponse and UDPMessage encoding, parsing, fragmentation and Defragger. |
| Pipeline | protocols/tests/pipeline/hysteria.rs |
The real inbound on loopback against this crate’s Hy2Connector and a bare Hy2Conn: TCP over two streams of one connection, UDP including 4096-byte payloads fragmented both ways, Salamander, a server without UDP (a_server_without_udp_refuses_associations), wrong credentials, refused targets, the live table swap, and a raw-QUIC proxy stream before authentication. |
| App unit | app/tests/unit/inbound.rs |
build_hysteria2_inbound: a working config, and the refusals for a missing certificate or credential, password with users, obfs settings, a 233 masquerade, the UDP idle timeout, zero limits, usernames, unknown settings, an [inbound.stream] block and Unix listen addresses. |
| Interop | app/tests/integration/e2e_hysteria_inbound.rs |
The upstream Go client, built from the vendored tree, against the app’s inbound: TCP, UDP, a_large_datagram_is_fragmented_in_both_directions, obfuscated_traffic_interoperates, a_user_pass_credential_authenticates, a wrong credential, and a_reload_rebinds_the_udp_port with a live client connection. |