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 CC

Архитектура

Hysteria2 в Xray-core разделён между двумя уровнями:

  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 CC]
        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 LengthЦелое число переменной длины QUICот 1 до 2048
AddressСтрока UTF-8 (например, "example.com:443")Формат host:port
Padding LengthЦелое число переменной длины QUICот 0 до 4096
PaddingСлучайные байтыОтбрасывается при чтении
go
func WriteTCPRequest(w io.Writer, addr string) error {
    padding := tcpRequestPadding.String()  // 64-512 случайных байт
    // 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 = OK, 0x01 = Ошибка
Message LengthQUIC varintот 0 до 2048
MessageбайтыСообщение об ошибке (опционально)
Padding LengthQUIC varintот 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 байта, big-endian
    PacketID  uint16  // 2 байта, big-endian
    FragID    uint8   // Индекс фрагмента (начинается с 0)
    FragCount uint8   // Общее количество фрагментов (1 = без фрагментации)
    Addr      string  // "host:port" с префиксом длины varint
    Data      []byte  // Оставшиеся байты
}

Исходный код: proxy/hysteria/protocol.go:153-160

ПолеРазмерОписание
Session ID4 байтаИдентифицирует UDP-сессию (в настоящее время установлен в 0)
Packet ID2 байтаИдентифицирует пакет для сборки из фрагментов
Fragment ID1 байтИндекс фрагмента (от 0 до FragCount-1)
Fragment Count1 байтОбщее количество фрагментов (1 = без фрагментации)
Address LengthQUIC varintДлина строки адреса
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-байтовое кодирование
}

Исходный код: proxy/hysteria/protocol.go:220-249

UDP-фрагментация

Когда UDP-сообщение превышает MTU QUIC-датаграммы, оно разделяется на фрагменты:

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
    // Разделение данных между фрагментами с общим 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 }  // Без фрагментации
    if m.PacketID != d.pktID || m.FragCount != uint8(len(d.frags)) {
        // Новый пакет, сброс состояния
        d.pktID = m.PacketID
        d.frags = make([]*UDPMessage, m.FragCount)
    }
    // Сбор фрагмента, сборка при завершении
}

Исходный код: 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 {
    // Для TCP: открытие QUIC-потока
    conn, err := dialer.Dial(hyCtx.ContextWithRequireDatagram(ctx, isUDP), c.server.Destination)

    if target.Network == net.Network_TCP {
        // Запись заголовка TCP-запроса
        WriteTCPRequest(bufferedWriter, target.NetAddr())
        // Чтение заголовка TCP-ответа
        ok, msg, err := ReadTCPResponse(conn)
        // Двунаправленное копирование
    }
}

Исходный код: 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: чтение датаграмм, дефрагментация, диспетчеризация
        for {
            msg, _ := ParseUDPMessage(b[:n])
            dfMsg := df.Feed(msg)
            if dfMsg != nil {
                dest, _ := net.ParseDestination("udp:" + dfMsg.Addr)
                // Диспетчеризация через DispatchLink
            }
        }
    } else {
        // Режим TCP: чтение запроса, диспетчеризация, запись ответа
        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 varint. Это упрощает реализацию за счёт незначительных дополнительных накладных расходов.

  3. Session ID: В настоящее время жёстко закодирован как 0 на клиенте. Поле существует для будущего мультиплексирования нескольких сессий, но в данный момент не используется.

Исходный код: proxy/hysteria/client.go:204

  1. Дополнение: И запрос, и ответ включают дополнение случайной длины для противодействия отпечаткам трафика. Дополнение запроса — 64-512 байт, дополнение ответа — 128-1024 байт.

Технический анализ для целей повторной реализации.