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 разделён между двумя уровнями:
- Транспортный уровень (
transport/internet/hysteria/): Обрабатывает установку QUIC-соединения, TLS, аутентификацию, управление перегрузкой - Прокси-уровень (
proxy/hysteria/): Обрабатывает протокол прикладного уровня (фрейминг TCP-запросов/ответов, формат UDP-сообщений)
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 | Случайные байты | Отбрасывается при чтении |
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
| Поле | Размер | Описание |
|---|---|---|
| Status | 1 байт | 0x00 = OK, 0x01 = Ошибка |
| Message Length | QUIC varint | от 0 до 2048 |
| Message | байты | Сообщение об ошибке (опционально) |
| Padding Length | QUIC varint | от 0 до 4096 |
| Padding | байты | Случайное дополнение |
После заголовка ответа QUIC-поток переносит необработанные проксированные TCP-данные.
Дополнение по умолчанию
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
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 ID | 4 байта | Идентифицирует UDP-сессию (в настоящее время установлен в 0) |
| Packet ID | 2 байта | Идентифицирует пакет для сборки из фрагментов |
| Fragment ID | 1 байт | Индекс фрагмента (от 0 до FragCount-1) |
| Fragment Count | 1 байт | Общее количество фрагментов (1 = без фрагментации) |
| Address Length | QUIC varint | Длина строки адреса |
| Address | байты | Назначение в формате "host:port" |
| Data | оставшиеся байты | Фактическая полезная нагрузка UDP |
Кодирование целых чисел переменной длины QUIC
Hysteria использует кодирование целых чисел переменной длины QUIC (RFC 9000):
| Диапазон | Биты префикса | Байты |
|---|---|---|
| 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-байтовое кодирование
}Исходный код: proxy/hysteria/protocol.go:220-249
UDP-фрагментация
Когда UDP-сообщение превышает MTU QUIC-датаграммы, оно разделяется на фрагменты:
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 пакета за раз — если новый пакет приходит до получения всех фрагментов предыдущего, предыдущее состояние отбрасывается:
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
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):
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 автоматически обрабатывает фрагментацию:
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):
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
Аутентификация пользователей
Пользователи валидируются на транспортном уровне. Сервер извлекает информацию о пользователе из соединения:
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
Константы
const (
MaxAddressLength = 2048
MaxMessageLength = 2048
MaxPaddingLength = 4096
MaxUDPSize = 4096
)Исходный код: proxy/hysteria/protocol.go:13-17
Примечания по реализации
Зависимость от QUIC: Hysteria2 зависит от
github.com/apernet/quic-go, форка quic-go с модификациями для управления перегрузкой Brutal и другими специфичными для Hysteria функциями.Разделение транспорта и протокола: В отличие от большинства протоколов Xray, где прокси-уровень обрабатывает шифрование, Hysteria делегирует все криптографические операции транспортному уровню QUIC/TLS. Прокси-уровень занимается только фреймингом запросов/ответов.
Тип сети как TCP: Входящий обработчик сообщает свою сеть как TCP (
net.Network_TCP), хотя нижележащий транспорт — UDP/QUIC. Это связано с тем, что с точки зрения Xray QUIC-потоки ведут себя как TCP-соединения.
Исходный код: proxy/hysteria/server.go:77-79
- Требование датаграмм: Для UDP-проксирования клиент передаёт
ContextWithRequireDatagram(ctx, true)для сигнализации транспортному уровню о необходимости поддержки QUIC-датаграмм.
Исходный код: proxy/hysteria/client.go:59
Простая дефрагментация:
Defraggerотслеживает только один пакет за раз. Если фрагменты от разных пакетов чередуются, сборка завершается неудачей. Это осознанный компромисс в пользу простоты — на практике QUIC-датаграммы обычно не переупорядочиваются в рамках одного соединения.Адрес как строка: В отличие от бинарного кодирования адресов в VMess/Trojan/Shadowsocks, Hysteria использует текстовые строки
"host:port"для адресов с префиксами длины QUIC varint. Это упрощает реализацию за счёт незначительных дополнительных накладных расходов.Session ID: В настоящее время жёстко закодирован как
0на клиенте. Поле существует для будущего мультиплексирования нескольких сессий, но в данный момент не используется.
Исходный код: proxy/hysteria/client.go:204
- Дополнение: И запрос, и ответ включают дополнение случайной длины для противодействия отпечаткам трафика. Дополнение запроса — 64-512 байт, дополнение ответа — 128-1024 байт.