Skip to content

Транспорт gRPC

Введение

Транспорт gRPC туннелирует прокси-трафик через HTTP/2 с использованием фреймворка gRPC. Данные инкапсулируются в сообщениях Hunk, определенных в protobuf, и передаются через двунаправленные потоковые RPC. Этот транспорт поддерживает настраиваемые имена сервисов/потоков (для маскировки под легитимные gRPC-сервисы), режим "multi" для пакетной отправки нескольких буферов в одном сообщении, пул соединений, keepalive и управление заголовком authority. Он работает с TLS, REALITY и отпечатками uTLS.

Регистрация протокола

Зарегистрирован как "grpc" (transport/internet/grpc/grpc.go:3):

go
const protocolName = "grpc"
  • Дайлер: grpc/dial.go:37-39
  • Слушатель: grpc/hub.go:137-139
  • Конфигурация: grpc/config.go:11-15

Архитектура имен сервисов

Старый стиль (по умолчанию)

Когда ServiceName не начинается с /, он рассматривается как классическое имя gRPC-сервиса. Имена потоков по умолчанию: "Tun" и "TunMulti":

/GunService/Tun        (режим одного буфера)
/GunService/TunMulti   (режим множественных буферов)

Новый стиль с произвольным путем

Когда ServiceName начинается с /, он разбирается как полный произвольный путь (grpc/config.go:17-59):

go
// ServiceName = "/my/custom/path/StreamA|StreamB"
//   serviceName = "my/custom/path"
//   tunStreamName = "StreamA"
//   tunMultiStreamName = "StreamB"

Формат: /<путь_сервиса>/<имя_tun>|<имя_tun_multi>

На стороне клиента в режиме multi полный путь используется напрямую (без разделения по |):

// ServiceName = "/my/custom/path/StreamB"  (клиентский режим multi)

Это позволяет операторам маскировать gRPC-трафик под любой произвольный gRPC-сервис.

Процесс Dial

Пул соединений

getGrpcClient (grpc/dial.go:77-193) управляет глобальным пулом объектов grpc.ClientConn:

go
var (
    globalDialerMap    map[dialerConf]*grpc.ClientConn
    globalDialerAccess sync.Mutex
)

Соединения идентифицируются по ключу {Destination, MemoryStreamConfig}. Существующее соединение повторно используется, если его состояние не connectivity.Shutdown (dial.go:89-91).

Настройка клиентского соединения

При создании нового клиентского gRPC-соединения (grpc/dial.go:93-193):

  1. Экспоненциальная задержка: Начальная задержка 500 мс, максимум 19 с, джиттер 0.2 (dial.go:94-102)
  2. Контекстный дайлер: Пользовательский grpc.WithContextDialer, который:
    • Вызывает internet.DialSystem для сырого TCP-соединения
    • Применяет TLS (стандартный или uTLS), если настроен
    • Применяет REALITY, если настроен
    • Передает контекст исходящей сессии (dial.go:103-146)
  3. Небезопасные учетные данные: Всегда grpc.WithTransportCredentials(insecure.NewCredentials()), поскольку TLS обрабатывается на уровне сырого соединения, а не через систему учетных данных gRPC (dial.go:148)
  4. Authority: Берется из конфигурации, или TLS ServerName, или домена назначения (dial.go:150-158)
  5. Keepalive: Опциональные ClientParameters с настраиваемым таймаутом простоя, таймаутом проверки состояния и разрешением без потока (dial.go:160-166)
  6. Начальный размер окна: Опциональное окно управления потоком gRPC (dial.go:168-170)
  7. Переопределение User-Agent: Использует рефлексию для установки user-agent, удаляя стандартный суффикс grpc-go/version (dial.go:184-201)

Установление потока

dialgRPC (grpc/dial.go:51-75) открывает соответствующий поток:

go
func dialgRPC(ctx context.Context, dest net.Destination,
    streamSettings *internet.MemoryStreamConfig) (net.Conn, error) {
    grpcSettings := streamSettings.ProtocolSettings.(*Config)
    conn, _ := getGrpcClient(ctx, dest, streamSettings)
    client := encoding.NewGRPCServiceClient(conn)

    if grpcSettings.MultiMode {
        grpcService, _ := client.(encoding.GRPCServiceClientX).TunMultiCustomName(
            ctx, grpcSettings.getServiceName(), grpcSettings.getTunMultiStreamName())
        return encoding.NewMultiHunkConn(grpcService, nil), nil
    }

    grpcService, _ := client.(encoding.GRPCServiceClientX).TunCustomName(
        ctx, grpcSettings.getServiceName(), grpcSettings.getTunStreamName())
    return encoding.NewHunkConn(grpcService, nil), nil
}

Процесс Listen

Настройка сервера

grpc.Listen (grpc/hub.go:53-135) создает gRPC-сервер:

go
func Listen(ctx context.Context, address net.Address, port net.Port,
    settings *internet.MemoryStreamConfig, handler internet.ConnHandler) (internet.Listener, error) {
    // ...
    s = grpc.NewServer(options...)
    // Регистрация с произвольными именами:
    encoding.RegisterGRPCServiceServerX(s, listener,
        grpcSettings.getServiceName(),
        grpcSettings.getTunStreamName(),
        grpcSettings.getTunMultiStreamName())
    // ...
    s.Serve(streamListener)
}

Обработчики потоков

Структура Listener реализует GRPCServiceServer (grpc/hub.go:20-42):

go
func (l Listener) Tun(server encoding.GRPCService_TunServer) error {
    tunCtx, cancel := context.WithCancel(l.ctx)
    l.handler(encoding.NewHunkConn(server, cancel))
    <-tunCtx.Done()
    return nil
}

func (l Listener) TunMulti(server encoding.GRPCService_TunMultiServer) error {
    tunCtx, cancel := context.WithCancel(l.ctx)
    l.handler(encoding.NewMultiHunkConn(server, cancel))
    <-tunCtx.Done()
    return nil
}

Обработчик блокируется на tunCtx.Done(), поддерживая gRPC-поток активным до закрытия соединения.

Регистрация произвольного сервиса

RegisterGRPCServiceServerX (grpc/encoding/customSeviceName.go:57-60) создает произвольный grpc.ServiceDesc:

go
func RegisterGRPCServiceServerX(s *grpc.Server, srv GRPCServiceServer,
    name, tun, tunMulti string) {
    desc := ServerDesc(name, tun, tunMulti)
    s.RegisterService(&desc, srv)
}

ServerDesc (customSeviceName.go:9-30) генерирует дескриптор сервиса с:

  • Произвольным ServiceName
  • Двумя двунаправленными потоками с произвольными именами
  • Установленными ServerStreams: true и ClientStreams: true

Формат данных на проводе

Protobuf-сообщения

protobuf
message Hunk {
    bytes data = 1;
}

message MultiHunk {
    repeated bytes data = 1;
}

Режим Single (Tun)

Каждый вызов Write отправляет один Hunk с байтами данных:

go
// encoding/hunkconn.go:131-141
func (h *HunkReaderWriter) Write(buf []byte) (int, error) {
    err := h.hc.Send(&Hunk{Data: buf[:]})
    return len(buf), nil
}

Чтение получает по одному Hunk за раз и копирует из его поля Data:

go
// encoding/hunkconn.go:91-105
func (h *HunkReaderWriter) Read(buf []byte) (int, error) {
    if h.index >= len(h.buf) {
        h.forceFetch()  // Recv() следующий Hunk
    }
    n := copy(buf, h.buf[h.index:])
    h.index += n
    return n, nil
}

Режим Multi (TunMulti)

Режим multi объединяет несколько буферов в одно gRPC-сообщение:

go
// encoding/multiconn.go:115-134
func (h *MultiHunkReaderWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
    hunks := make([][]byte, 0, len(mb))
    for _, b := range mb {
        if b.Len() > 0 {
            hunks = append(hunks, b.Bytes())
        }
    }
    h.hc.Send(&MultiHunk{Data: hunks})
}

Это снижает накладные расходы на сообщение, когда несколько мелких записей объединяются в пакет.

Сетевой поток

mermaid
sequenceDiagram
    participant Client as Клиент
    participant gRPC Client as gRPC-клиент
    participant HTTP/2
    participant gRPC Server as gRPC-сервер
    participant Server as Сервер

    Client->>gRPC Client: Write(data)
    gRPC Client->>HTTP/2: DATA-фрейм (Hunk{data})
    HTTP/2->>gRPC Server: DATA-фрейм
    gRPC Server->>Server: Read() -> data

    Server->>gRPC Server: Write(response)
    gRPC Server->>HTTP/2: DATA-фрейм (Hunk{response})
    HTTP/2->>gRPC Client: DATA-фрейм
    gRPC Client->>Client: Read() -> response

Обертка соединения

HunkConn

NewHunkConn (encoding/hunkconn.go:41-73) оборачивает gRPC-поток как net.Conn:

  • Использует cnc.NewConnection из common/net/cnc для построения net.Conn
  • Извлекает удаленный адрес из gRPC peer.FromContext
  • Поддерживает заголовок метаданных x-real-ip для передачи реального IP

MultiHunkConn

NewMultiHunkConn (encoding/multiconn.go:37-69) аналогичен, но использует ConnectionInputMulti/ConnectionOutputMulti для пакетных операций с буферами.

Оба типа реализуют StreamCloser для CloseSend(), сигнализирующего завершение потока со стороны клиента.

TLS и безопасность

TLS на стороне клиента

TLS обрабатывается на уровне сырого соединения в контекстном дайлере (grpc/dial.go:128-143):

go
if tlsConfig != nil {
    config := tlsConfig.GetTLSConfig()
    if fingerprint := tls.GetFingerprint(tlsConfig.Fingerprint); fingerprint != nil {
        return tls.UClient(c, config, fingerprint), nil
    } else {
        return tls.Client(c, config), nil
    }
}
if realityConfig != nil {
    return reality.UClient(c, realityConfig, gctx, dest)
}

Это обходит встроенный TLS gRPC, используя insecure.NewCredentials() на уровне gRPC.

TLS на стороне сервера

На сервере TLS обрабатывается иначе -- через систему учетных данных gRPC (grpc/hub.go:82-85):

go
if config != nil {
    options = append(options, grpc.Creds(credentials.NewTLS(
        config.GetTLSConfig(tls.WithNextProto("h2")))))
}

REALITY обрабатывается путем обертки слушателя (hub.go:126-128):

go
if config := reality.ConfigFromStreamSettings(settings); config != nil {
    streamListener = goreality.NewListener(streamListener, config.GetREALITYConfig())
}

Примечания по реализации

  • Повторное использование соединений: gRPC мультиплексирует потоки через одно HTTP/2-соединение. globalDialerMap кэширует объекты ClientConn во избежание повторного подключения для каждого нового прокси-соединения.
  • Заголовок Authority: Критически важен для сценариев с CDN/обратным прокси. Приоритет: явная конфигурация > TLS ServerName > домен назначения (dial.go:150-158).
  • Хак с User-Agent: gRPC-Go безусловно добавляет grpc-go/<version> к user-agent. Xray использует reflect + unsafe.Pointer для перезаписи этого значения (dial.go:197-201), устанавливая по умолчанию строку user-agent Chrome.
  • URL-кодированные имена: Имена сервисов и потоков URL-кодируются для обеспечения корректных gRPC-путей (config.go:17-58).
  • Резолвер passthrough: Вызов grpc.NewClient использует схему passthrough:/// для отключения DNS-разрешения gRPC, поскольку Xray сам управляет разрешением (dial.go:179-180).
  • Блокировка на сервере: Обработчики Tun/TunMulti блокируются на tunCtx.Done(). Контекст отменяется при закрытии HunkReaderWriter, что разблокирует обработчик и завершает gRPC-поток.
  • Без обфускации заголовков: В отличие от TCP-транспорта, gRPC не поддерживает обертку заголовков через ConnectionAuthenticator.

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