Skip to content

Hysteria 2: server

Source files: 30 · checked against 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

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.

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 is AppConnector with 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_inbound validates 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).
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 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).

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 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.

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 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. QuicServerConfig unwraps rustls::quic::ServerConnection::new inside start_session. A configuration without TLS 1.3 would pass QuicServerConfig::try_from and 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.

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 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.

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 { /* … */ }

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:

  • pending holds the rest of the last poll_data chunk, since a caller’s buffer may be smaller than one chunk. new seeds it with the bytes the classifier read past the frame type.
  • finished latches 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 if send_data is mixed with poll_send, so QuicIo never touches the framed API.
  • poll_flush is a no-op; poll_shutdown calls poll_finish, a clean half-close. Dropping the stream unfinished would reset it, which the peer reads as an abort.
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 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.

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 implements Clone by hand so that T need not be Clone; the payload only ever lives behind the Arc.

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.

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.

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.

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: ProxyRequest for 0x401, otherwise Http3(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:

  1. It drains every stream quinn has accepted into classify_tx with try_send. The call never awaits, because poll_accept_bidi cannot block.
  2. A stream that does not fit in the queue is reset with discard(…, Code::H3_EXCESSIVE_LOAD), both halves (reset on the send side and stop_sending on the receive side). Dropping it instead would leave the peer writing into a stream nobody reads. This reset bounds the work waiting for classification.
  3. It then polls h3_ready_rx and returns the next stream the classifier handed back. If the classifier is gone, it returns ConnectionErrorIncoming::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.

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.

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::Connected stages Connected only if reply is still true, and clears it, so a stream never gets two responses.
  • Event::ConnectFailed stages a refusal only if reply is true. On the sniff path the client was already told Connected, so the core ends the stream instead with ShutdownTransport and Finish.
  • Event::OutboundError calls on_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 Connected at 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.

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 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.

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 a Flow with DialNetwork::Udp built 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 at MAX_UDP_SIZE (4096): a fragment that would take it past the cap discards the packet in progress. The finished payload is copied into held and sent with Effect::SendToHeld.
  • Unparseable datagrams are dropped and logged at trace level; the whole datagram is consumed either way.
  • Failures. ConnectFailed and OutboundError for a session remove it, releasing its permit. SendFailed and TransportSendFailed drop 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 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.

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 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.

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 UserEntry its AuthState recorded, because AuthState is a OnceLock that 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 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’s http.NotFound, byte for byte: 404, text/plain; charset=utf-8, body 404 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::new refuses STATUS_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 the http crate 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().

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.
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.

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:

  1. Take the endpoint out of the slot. A second call finds None and returns.
  2. endpoint.close(CLOSE_CODE, b"") with application error code 0x100, which closes every connection.
  3. Wait up to DRAIN_TIMEOUT (3 s) for endpoint.wait_idle().
  4. Drop the endpoint handle.
  5. Try std::net::UdpSocket::bind(local) every RELEASE_POLL (20 ms), for up to RELEASE_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.

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.