Hysteria2
Hysteria2 is a QUIC-based proxy protocol designed for high-throughput, lossy networks. Xray-core integrates it using the apernet/quic-go library (a modified QUIC implementation) with custom congestion control ("Brutal") and QUIC datagram support for UDP proxying.
Overview
- Direction: Inbound + Outbound
- Transport: QUIC (over UDP)
- Encryption: QUIC TLS 1.3 (handled by transport layer)
- Authentication: Password-based (handled by transport layer)
- TCP Proxying: Via QUIC streams
- UDP Proxying: Via QUIC datagrams (unreliable) with fragmentation support
- Congestion Control: Brutal (fixed bandwidth) or standard QUIC CC
Architecture
Hysteria2 in Xray-core is split between two layers:
- Transport layer (
transport/internet/hysteria/): Handles QUIC connection establishment, TLS, authentication, congestion control - Proxy layer (
proxy/hysteria/): Handles the application-level protocol (TCP request/response framing, UDP message format)
graph TD
subgraph "Proxy Layer (proxy/hysteria/)"
A[Client Handler] --> B[TCP Request/Response]
A --> C[UDP Message Framing]
D[Server Handler] --> B
D --> C
end
subgraph "Transport Layer (transport/internet/hysteria/)"
E[QUIC Connection] --> F[Brutal CC]
E --> G[TLS 1.3]
E --> H[Auth Validation]
end
B --> E
C --> EWire Format
TCP Request
TCP proxying uses QUIC streams. Each TCP connection is a new QUIC stream with the following request format:
+-------------------+-------------------+--------------------+-------------------+
| Address Length | Address | Padding Length | Padding |
| QUIC varint | bytes | QUIC varint | bytes |
+-------------------+-------------------+--------------------+-------------------+Source: proxy/hysteria/protocol.go:28-62
| Field | Encoding | Constraints |
|---|---|---|
| Address Length | QUIC variable-length integer | 1 to 2048 |
| Address | UTF-8 string (e.g., "example.com:443") | host:port format |
| Padding Length | QUIC variable-length integer | 0 to 4096 |
| Padding | Random bytes | Discarded on read |
func WriteTCPRequest(w io.Writer, addr string) error {
padding := tcpRequestPadding.String() // 64-512 random bytes
// varint(addrLen) + addr + varint(paddingLen) + padding
}Source: proxy/hysteria/protocol.go:64-77
TCP Response
+--------+-------------------+-------------------+--------------------+-------------------+
| Status | Message Length | Message | Padding Length | Padding |
| 1B | QUIC varint | bytes | QUIC varint | bytes |
+--------+-------------------+-------------------+--------------------+-------------------+Source: proxy/hysteria/protocol.go:79-142
| Field | Size | Description |
|---|---|---|
| Status | 1 byte | 0x00 = OK, 0x01 = Error |
| Message Length | QUIC varint | 0 to 2048 |
| Message | bytes | Error message (optional) |
| Padding Length | QUIC varint | 0 to 4096 |
| Padding | bytes | Random padding |
After the response header, the QUIC stream carries raw proxied TCP data.
Default Padding
var (
tcpRequestPadding = padding.Padding{Min: 64, Max: 512}
tcpResponsePadding = padding.Padding{Min: 128, Max: 1024}
)Source: proxy/hysteria/config.go:7-9
UDP Message
UDP uses QUIC datagrams (unreliable, unordered). Each datagram contains:
+------------+----------+---------+-----------+-------------------+---------+------+
| Session ID | Packet ID| Frag ID | Frag Count| Address Length | Address | Data |
| 4B BE | 2B BE | 1B | 1B | QUIC varint | bytes | ... |
+------------+----------+---------+-----------+-------------------+---------+------+Source: proxy/hysteria/protocol.go:144-216
type UDPMessage struct {
SessionID uint32 // 4 bytes, big-endian
PacketID uint16 // 2 bytes, big-endian
FragID uint8 // Fragment index (0-based)
FragCount uint8 // Total fragments (1 = no fragmentation)
Addr string // "host:port" with varint length prefix
Data []byte // Remaining bytes
}Source: proxy/hysteria/protocol.go:153-160
| Field | Size | Description |
|---|---|---|
| Session ID | 4 bytes | Identifies the UDP session (currently set to 0) |
| Packet ID | 2 bytes | Identifies the packet for fragmentation reassembly |
| Fragment ID | 1 byte | Fragment index (0 to FragCount-1) |
| Fragment Count | 1 byte | Total number of fragments (1 = unfragmented) |
| Address Length | QUIC varint | Length of address string |
| Address | bytes | Destination in "host:port" format |
| Data | remaining bytes | The actual UDP payload |
QUIC Variable-Length Integer Encoding
Hysteria uses QUIC's variable-length integer encoding (RFC 9000):
| Range | Prefix Bits | Bytes |
|---|---|---|
| 0-63 | 00 | 1 |
| 64-16383 | 01 | 2 |
| 16384-1073741823 | 10 | 4 |
| 1073741824-4611686018427387903 | 11 | 8 |
func varintPut(b []byte, i uint64) int {
if i <= 63 { b[0] = uint8(i); return 1 }
if i <= 16383 { b[0] = uint8(i>>8) | 0x40; b[1] = uint8(i); return 2 }
if i <= 1073741823 { b[0] = uint8(i>>24) | 0x80; /* ... */ return 4 }
// ...8-byte encoding
}Source: proxy/hysteria/protocol.go:220-249
UDP Fragmentation
When a UDP message exceeds the QUIC datagram MTU, it is split into fragments:
func FragUDPMessage(m *UDPMessage, maxSize int) []UDPMessage {
if m.Size() <= maxSize {
return []UDPMessage{*m}
}
maxPayloadSize := maxSize - m.HeaderSize()
fragCount := (len(fullPayload) + maxPayloadSize - 1) / maxPayloadSize
// Split data across fragments, sharing same PacketID
}Source: proxy/hysteria/frag.go:3-27
Defragger
The Defragger reassembles fragmented UDP messages. It handles one packet ID at a time -- if a new packet arrives before all fragments of the previous packet, the previous state is discarded:
type Defragger struct {
pktID uint16
frags []*UDPMessage
count uint8
size int
}
func (d *Defragger) Feed(m *UDPMessage) *UDPMessage {
if m.FragCount <= 1 { return m } // No fragmentation
if m.PacketID != d.pktID || m.FragCount != uint8(len(d.frags)) {
// New packet, reset state
d.pktID = m.PacketID
d.frags = make([]*UDPMessage, m.FragCount)
}
// Collect fragment, assemble when complete
}Source: proxy/hysteria/frag.go:29-73
Outbound Handler (Client)
File: proxy/hysteria/client.go
TCP Flow
func (c *Client) Process(ctx context.Context, link *transport.Link, dialer internet.Dialer) error {
// For TCP: open QUIC stream
conn, err := dialer.Dial(hyCtx.ContextWithRequireDatagram(ctx, isUDP), c.server.Destination)
if target.Network == net.Network_TCP {
// Write TCP request header
WriteTCPRequest(bufferedWriter, target.NetAddr())
// Read TCP response header
ok, msg, err := ReadTCPResponse(conn)
// Bidirectional copy
}
}Source: proxy/hysteria/client.go:49-117
UDP Flow
For UDP, the client uses QUIC datagrams. The InterUdpConn type (from the hysteria transport) is required:
if target.Network == net.Network_UDP {
iConn := stat.TryUnwrapStatsConn(conn)
_, ok := iConn.(*hysteria.InterUdpConn)
if !ok {
return errors.New("udp requires hysteria udp transport")
}
writer := &UDPWriter{Writer: conn, buf: make([]byte, MaxUDPSize), addr: target.NetAddr()}
reader := &UDPReader{Reader: conn, buf: make([]byte, MaxUDPSize), df: &Defragger{}}
}Source: proxy/hysteria/client.go:119-164
UDPWriter handles fragmentation automatically:
func (w *UDPWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
msg := &UDPMessage{SessionID: 0, FragCount: 1, Addr: addr, Data: b.Bytes()}
err := w.sendMsg(msg)
var errTooLarge *quic.DatagramTooLargeError
if go_errors.As(err, &errTooLarge) {
msg.PacketID = uint16(rand.Intn(0xFFFF)) + 1
fMsgs := FragUDPMessage(msg, int(errTooLarge.MaxDatagramPayloadSize))
for _, fMsg := range fMsgs {
w.sendMsg(&fMsg)
}
}
}Source: proxy/hysteria/client.go:190-235
Inbound Handler (Server)
File: proxy/hysteria/server.go
The server handles both QUIC stream connections (TCP) and QUIC datagram connections (UDP):
func (s *Server) Process(ctx context.Context, network net.Network,
conn stat.Connection, dispatcher routing.Dispatcher) error {
iConn := stat.TryUnwrapStatsConn(conn)
if _, ok := iConn.(*hysteria.InterUdpConn); ok {
// UDP mode: read datagrams, defragger, dispatch
for {
msg, _ := ParseUDPMessage(b[:n])
dfMsg := df.Feed(msg)
if dfMsg != nil {
dest, _ := net.ParseDestination("udp:" + dfMsg.Addr)
// Dispatch via DispatchLink
}
}
} else {
// TCP mode: read request, dispatch, write response
addr, _ := ReadTCPRequest(conn)
dest, _ := net.ParseDestination("tcp:" + addr)
WriteTCPResponse(bufferedWriter, true, "")
dispatcher.DispatchLink(ctx, dest, &transport.Link{...})
}
}Source: proxy/hysteria/server.go:81-192
User Authentication
Users are validated at the transport layer. The server extracts user info from the connection:
type User interface{ User() *protocol.MemoryUser }
if v, ok := conn.(User); ok {
inbound.User = v.User()
}Source: proxy/hysteria/server.go:88-95
The account.Validator supports runtime user management (add/remove/get).
Source: proxy/hysteria/server.go:57-75
Constants
const (
MaxAddressLength = 2048
MaxMessageLength = 2048
MaxPaddingLength = 4096
MaxUDPSize = 4096
)Source: proxy/hysteria/protocol.go:13-17
Implementation Notes
QUIC dependency: Hysteria2 depends on
github.com/apernet/quic-go, a fork of quic-go with modifications for Brutal congestion control and other Hysteria-specific features.Transport-protocol separation: Unlike most Xray protocols where the proxy layer handles encryption, Hysteria delegates all cryptographic operations to the QUIC/TLS transport layer. The proxy layer only deals with request/response framing.
Network type as TCP: The inbound reports its network as TCP (
net.Network_TCP), even though the underlying transport is UDP/QUIC. This is because from Xray's perspective, QUIC streams behave like TCP connections.
Source: proxy/hysteria/server.go:77-79
- Datagram requirement: For UDP proxying, the client passes
ContextWithRequireDatagram(ctx, true)to signal the transport layer that QUIC datagram support is needed.
Source: proxy/hysteria/client.go:59
Simple defragmentation: The
Defraggeronly tracks one packet at a time. If fragments from different packets are interleaved, reassembly fails. This is a deliberate simplicity trade-off -- in practice, QUIC datagrams are typically not reordered within a single connection.Address as string: Unlike binary-encoded addresses in VMess/Trojan/Shadowsocks, Hysteria uses plain text
"host:port"strings for addresses, with QUIC varint length prefixes. This simplifies implementation at the cost of slightly more overhead.Session ID: Currently hardcoded to
0in the client. The field exists for future multi-session multiplexing but is not used.
Source: proxy/hysteria/client.go:204
- Padding: Both request and response include random-length padding to resist traffic fingerprinting. Request padding is 64-512 bytes, response padding is 128-1024 bytes.