Hysteria
QUIC-based transport tuned for lossy links with a brutal congestion controller.
Every mainstream congestion controller treats packet loss as a signal to slow down. On long cross-border routes that assumption is wrong — loss often comes from a policer or a saturated peering link, not from your own sending rate, so TCP-style backoff collapses throughput to a crawl. Hysteria’s answer: you tell it the bandwidth, and it sends at that rate no matter what the network says back.
Brutal doesn’t slow down on loss — it speeds up
The Brutal congestion controller’s only reaction to packet loss is to transmit more. The two hooks where a normal controller would shrink its window are literally empty stubs:
transport/internet/hysteria/congestion/brutal/brutal.go transport/internet/hysteria/congestion/common/pacer.gofunc (b *BrutalSender) OnPacketAcked(/* … */) {
// Stub
}
func (b *BrutalSender) OnCongestionEvent(/* … */) {
// Stub
} Instead, BrutalSender samples ack/loss counts — via the extended OnCongestionEventEx hook —
into five one-second slots and computes an ackRate, the fraction of packets that survived the
last five seconds. The token-bucket pacer
then sends at bps / ackRate:
bs.pacer = common.NewPacer(func() congestion.ByteCount {
return congestion.ByteCount(float64(bs.bps) / bs.ackRate)
}) If 15% of packets are dropped, ackRate is 0.85 and Brutal transmits at 1.18× the configured
rate so the goodput stays pinned at what you declared. The congestion window is sized the same
way — bps × RTT × 2 / ackRate — and InSlowStart() hardcodes false: there is no ramp-up,
no probing, no recovery phase.
The rate is negotiated down, never up
Both peers declare a bandwidth, and the effective rate is the minimum of the two. The client
sends its downlink rate in a Hysteria-CC-RX header during auth; the server replies with its
own. Each side then paces at min(own up, peer's declared down) — and if either side declares 0 (or the server answers auto), the code silently falls back to BBR instead of Brutal.
{
"protocol": "hysteria",
"settings": { "version": 2, "address": "203.0.113.7", "port": 443 },
"streamSettings": {
"network": "hysteria",
"security": "tls",
"tlsSettings": { "serverName": "example.com", "alpn": ["h3"] },
"hysteriaSettings": { "version": 2, "auth": "your-password" },
"finalmask": {
"quicParams": {
"congestion": "brutal", // reno | bbr | brutal | force-brutal
"brutalUp": "50 mbps", // parsed as bits/s, stored as bytes/s
"brutalDown": "200 mbps"
}
}
}
} force-brutal skips the negotiation and uses your brutalUp unconditionally — useful only when
you control both ends and know the path.
The handshake is a real HTTP/3 request
Authentication is an ordinary HTTP/3 POST to https://hysteria/auth carrying a Hysteria-Auth header and a Hysteria-Padding header stuffed with 256–2047 random alphanumeric
characters; success is the made-up status code 233. Anything else — wrong path, wrong method, wrong password — falls through to a masquerade
handler (404, a static file dir, a reverse proxy, or a fixed string), so an active prober
speaking HTTP/3 just sees a boring web server.
After auth, one QUIC connection per server is shared by everything (clientManager keys clients
by address). Each proxied TCP connection is one QUIC stream whose first write is prefixed with
varint frame type 0x401 — an unknown-to-browsers HTTP/3 frame that the server’s StreamDispatcher claims before the h3 stack can reject it. Inside the stream, the proxy layer
sends a tiny address header (varint length + address + more random padding) and the server
answers with a status byte followed by an optional error message and its own padding.
UDP rides datagrams and fragments only on demand
Each UDP packet becomes one unreliable QUIC datagram — no retransmission, no head-of-line blocking — and fragmentation is reactive, not preventive. The wire format:
proxy/hysteria/protocol.go proxy/hysteria/frag.go proxy/hysteria/client.go+------------+-----------+---------+------------+-------------+---------+---------+
| Session ID | Packet ID | Frag ID | Frag count | Addr length | Address | Payload |
| uint32 BE | uint16 BE | uint8 | uint8 | QUIC varint | bytes | … |
+------------+-----------+---------+------------+-------------+---------+---------+ The client always tries to send the whole packet first. Only when quic-go returns DatagramTooLargeError (the frame cap is 1200 bytes) does FragUDPMessage split the payload,
assigning a random non-zero packet ID so the receiver can group the pieces. The Defragger on
the other side is minimal by design: it tracks one packet ID at a time, and any fragment
carrying a different packet ID discards all partial state — under loss it drops an incomplete packet rather
than buffering, which is the right call for real-time UDP traffic.
Declare the bandwidth you have, not the bandwidth you want
Brutal is a contract, not a boost button. It holds throughput at the declared rate on paths where BBR and Cubic starve — but past the path’s real capacity, extra sending only raises loss for you and everyone sharing the bottleneck.
Inflate brutalUp to 'go faster'
Declaring 500 Mbps on a 50 Mbps path drives loss up, ackRate down, and the sender to 1.25× —
saturating the link with retransmitted junk while goodput drops below what an honest number
would deliver.
Measure, then declare slightly under
Speed-test both directions and set brutalUp/brutalDown just below the measured rates. The
negotiation takes min(up, peer down) anyway — honest numbers on both ends are what make Brutal
outrun BBR on a 15%-loss link.