Skip to content

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:

  1. Transport layer (transport/internet/hysteria/): Handles QUIC connection establishment, TLS, authentication, congestion control
  2. Proxy layer (proxy/hysteria/): Handles the application-level protocol (TCP request/response framing, UDP message format)
mermaid
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 --> E

Wire 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

FieldEncodingConstraints
Address LengthQUIC variable-length integer1 to 2048
AddressUTF-8 string (e.g., "example.com:443")host:port format
Padding LengthQUIC variable-length integer0 to 4096
PaddingRandom bytesDiscarded on read
go
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

FieldSizeDescription
Status1 byte0x00 = OK, 0x01 = Error
Message LengthQUIC varint0 to 2048
MessagebytesError message (optional)
Padding LengthQUIC varint0 to 4096
PaddingbytesRandom padding

After the response header, the QUIC stream carries raw proxied TCP data.

Default Padding ​

go
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

go
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

FieldSizeDescription
Session ID4 bytesIdentifies the UDP session (currently set to 0)
Packet ID2 bytesIdentifies the packet for fragmentation reassembly
Fragment ID1 byteFragment index (0 to FragCount-1)
Fragment Count1 byteTotal number of fragments (1 = unfragmented)
Address LengthQUIC varintLength of address string
AddressbytesDestination in "host:port" format
Dataremaining bytesThe actual UDP payload

QUIC Variable-Length Integer Encoding ​

Hysteria uses QUIC's variable-length integer encoding (RFC 9000):

RangePrefix BitsBytes
0-63001
64-16383012
16384-1073741823104
1073741824-4611686018427387903118
go
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:

go
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:

go
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 ​

go
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:

go
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:

go
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):

go
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:

go
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 ​

go
const (
    MaxAddressLength = 2048
    MaxMessageLength = 2048
    MaxPaddingLength = 4096
    MaxUDPSize       = 4096
)

Source: proxy/hysteria/protocol.go:13-17

Implementation Notes ​

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

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

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

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

  1. Simple defragmentation: The Defragger only 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.

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

  3. Session ID: Currently hardcoded to 0 in the client. The field exists for future multi-session multiplexing but is not used.

Source: proxy/hysteria/client.go:204

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

Technical analysis for re-implementation purposes.