Skip to content

Hysteria2

Hysteria2 是一种基于 QUIC 的代理协议,专为高吞吐量、高丢包率的网络设计。Xray-core 使用 apernet/quic-go 库(一个修改版的 QUIC 实现)集成该协议,具有自定义拥塞控制("Brutal")和 QUIC 数据报支持以实现 UDP 代理。

概述

  • 方向:入站 + 出站
  • 传输:QUIC(基于 UDP)
  • 加密:QUIC TLS 1.3(由传输层处理)
  • 认证:基于密码(由传输层处理)
  • TCP 代理:通过 QUIC 流
  • UDP 代理:通过 QUIC 数据报(不可靠),支持分片
  • 拥塞控制:Brutal(固定带宽)或标准 QUIC 拥塞控制

架构

Xray-core 中的 Hysteria2 分为两层:

  1. 传输层transport/internet/hysteria/):处理 QUIC 连接建立、TLS、认证、拥塞控制
  2. 代理层proxy/hysteria/):处理应用层协议(TCP 请求/响应帧封装、UDP 消息格式)
mermaid
graph TD
    subgraph "代理层 (proxy/hysteria/)"
        A[客户端处理器] --> B[TCP 请求/响应]
        A --> C[UDP 消息帧封装]
        D[服务端处理器] --> B
        D --> C
    end
    subgraph "传输层 (transport/internet/hysteria/)"
        E[QUIC 连接] --> F[Brutal 拥塞控制]
        E --> G[TLS 1.3]
        E --> H[认证验证]
    end
    B --> E
    C --> E

线路格式

TCP 请求

TCP 代理使用 QUIC 流。每个 TCP 连接是一个新的 QUIC 流,请求格式如下:

+-------------------+-------------------+--------------------+-------------------+
| Address Length     | Address           | Padding Length     | Padding           |
| QUIC varint       | bytes             | QUIC varint        | bytes             |
+-------------------+-------------------+--------------------+-------------------+

源码:proxy/hysteria/protocol.go:28-62

字段编码约束
Address LengthQUIC 可变长整数1 到 2048
AddressUTF-8 字符串(如 "example.com:443"host:port 格式
Padding LengthQUIC 可变长整数0 到 4096
Padding随机字节读取时丢弃
go
func WriteTCPRequest(w io.Writer, addr string) error {
    padding := tcpRequestPadding.String()  // 64-512 random bytes
    // varint(addrLen) + addr + varint(paddingLen) + padding
}

源码:proxy/hysteria/protocol.go:64-77

TCP 响应

+--------+-------------------+-------------------+--------------------+-------------------+
| Status | Message Length    | Message           | Padding Length     | Padding           |
| 1B     | QUIC varint      | bytes             | QUIC varint        | bytes             |
+--------+-------------------+-------------------+--------------------+-------------------+

源码:proxy/hysteria/protocol.go:79-142

字段大小描述
Status1 字节0x00 = 成功、0x01 = 错误
Message LengthQUIC 可变长整数0 到 2048
Message字节错误消息(可选)
Padding LengthQUIC 可变长整数0 到 4096
Padding字节随机填充

响应头之后,QUIC 流承载原始的代理 TCP 数据。

默认填充

go
var (
    tcpRequestPadding  = padding.Padding{Min: 64, Max: 512}
    tcpResponsePadding = padding.Padding{Min: 128, Max: 1024}
)

源码:proxy/hysteria/config.go:7-9

UDP 消息

UDP 使用 QUIC 数据报(不可靠、无序)。每个数据报包含:

+------------+----------+---------+-----------+-------------------+---------+------+
| Session ID | Packet ID| Frag ID | Frag Count| Address Length    | Address | Data |
| 4B BE      | 2B BE    | 1B      | 1B        | QUIC varint       | bytes   | ...  |
+------------+----------+---------+-----------+-------------------+---------+------+

源码: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
}

源码:proxy/hysteria/protocol.go:153-160

字段大小描述
Session ID4 字节标识 UDP 会话(当前设置为 0)
Packet ID2 字节标识数据包,用于分片重组
Fragment ID1 字节分片索引(0 到 FragCount-1)
Fragment Count1 字节总分片数(1 = 未分片)
Address LengthQUIC 可变长整数地址字符串长度
Address字节"host:port" 格式的目标地址
Data剩余字节实际的 UDP 载荷

QUIC 可变长整数编码

Hysteria 使用 QUIC 的可变长整数编码(RFC 9000):

范围前缀位字节数
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
}

源码:proxy/hysteria/protocol.go:220-249

UDP 分片

当 UDP 消息超过 QUIC 数据报 MTU 时,会被拆分为分片:

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
}

源码:proxy/hysteria/frag.go:3-27

重组器

Defragger 重组分片的 UDP 消息。它一次只处理一个数据包 ID——如果在前一个数据包的所有分片到达之前收到新数据包,之前的状态会被丢弃:

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
}

源码:proxy/hysteria/frag.go:29-73

出站处理器(客户端)

文件:proxy/hysteria/client.go

TCP 流程

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

源码:proxy/hysteria/client.go:49-117

UDP 流程

对于 UDP,客户端使用 QUIC 数据报。需要 InterUdpConn 类型(来自 hysteria 传输层):

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{}}
}

源码:proxy/hysteria/client.go:119-164

UDPWriter 自动处理分片:

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)
        }
    }
}

源码:proxy/hysteria/client.go:190-235

入站处理器(服务端)

文件:proxy/hysteria/server.go

服务端同时处理 QUIC 流连接(TCP)和 QUIC 数据报连接(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{...})
    }
}

源码:proxy/hysteria/server.go:81-192

用户认证

用户在传输层进行验证。服务端从连接中提取用户信息:

go
type User interface{ User() *protocol.MemoryUser }
if v, ok := conn.(User); ok {
    inbound.User = v.User()
}

源码:proxy/hysteria/server.go:88-95

account.Validator 支持运行时用户管理(添加/移除/获取)。

源码:proxy/hysteria/server.go:57-75

常量

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

源码:proxy/hysteria/protocol.go:13-17

实现说明

  1. QUIC 依赖:Hysteria2 依赖 github.com/apernet/quic-go,这是 quic-go 的一个分支,包含针对 Brutal 拥塞控制和其他 Hysteria 特定功能的修改。

  2. 传输与协议分离:与大多数 Xray 协议中代理层处理加密不同,Hysteria 将所有加密操作委托给 QUIC/TLS 传输层。代理层仅处理请求/响应帧封装。

  3. 网络类型报告为 TCP:入站将其网络报告为 TCP(net.Network_TCP),尽管底层传输是 UDP/QUIC。这是因为从 Xray 的角度来看,QUIC 流的行为类似于 TCP 连接。

源码:proxy/hysteria/server.go:77-79

  1. 数据报要求:对于 UDP 代理,客户端传递 ContextWithRequireDatagram(ctx, true) 以通知传输层需要 QUIC 数据报支持。

源码:proxy/hysteria/client.go:59

  1. 简单的重组机制Defragger 一次只跟踪一个数据包。如果来自不同数据包的分片交错到达,重组将失败。这是一种有意的简单性权衡——在实践中,QUIC 数据报在单个连接中通常不会被重新排序。

  2. 地址为字符串格式:与 VMess/Trojan/Shadowsocks 中的二进制编码地址不同,Hysteria 使用纯文本 "host:port" 字符串作为地址,带有 QUIC 可变长整数长度前缀。这简化了实现,但增加了少量开销。

  3. Session ID:当前在客户端中硬编码为 0。该字段为未来的多会话多路复用而保留,但目前未使用。

源码:proxy/hysteria/client.go:204

  1. 填充:请求和响应都包含随机长度的填充以抵抗流量指纹识别。请求填充为 64-512 字节,响应填充为 128-1024 字节。

用于重新实现目的的技术分析。