Транспорт gRPC
Введение
Транспорт gRPC туннелирует прокси-трафик через HTTP/2 с использованием фреймворка gRPC. Данные инкапсулируются в сообщениях Hunk, определенных в protobuf, и передаются через двунаправленные потоковые RPC. Этот транспорт поддерживает настраиваемые имена сервисов/потоков (для маскировки под легитимные gRPC-сервисы), режим "multi" для пакетной отправки нескольких буферов в одном сообщении, пул соединений, keepalive и управление заголовком authority. Он работает с TLS, REALITY и отпечатками uTLS.
Регистрация протокола
Зарегистрирован как "grpc" (transport/internet/grpc/grpc.go:3):
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):
// 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:
var (
globalDialerMap map[dialerConf]*grpc.ClientConn
globalDialerAccess sync.Mutex
)Соединения идентифицируются по ключу {Destination, MemoryStreamConfig}. Существующее соединение повторно используется, если его состояние не connectivity.Shutdown (dial.go:89-91).
Настройка клиентского соединения
При создании нового клиентского gRPC-соединения (grpc/dial.go:93-193):
- Экспоненциальная задержка: Начальная задержка 500 мс, максимум 19 с, джиттер 0.2 (
dial.go:94-102) - Контекстный дайлер: Пользовательский
grpc.WithContextDialer, который:- Вызывает
internet.DialSystemдля сырого TCP-соединения - Применяет TLS (стандартный или uTLS), если настроен
- Применяет REALITY, если настроен
- Передает контекст исходящей сессии (
dial.go:103-146)
- Вызывает
- Небезопасные учетные данные: Всегда
grpc.WithTransportCredentials(insecure.NewCredentials()), поскольку TLS обрабатывается на уровне сырого соединения, а не через систему учетных данных gRPC (dial.go:148) - Authority: Берется из конфигурации, или TLS ServerName, или домена назначения (
dial.go:150-158) - Keepalive: Опциональные
ClientParametersс настраиваемым таймаутом простоя, таймаутом проверки состояния и разрешением без потока (dial.go:160-166) - Начальный размер окна: Опциональное окно управления потоком gRPC (
dial.go:168-170) - Переопределение User-Agent: Использует рефлексию для установки user-agent, удаляя стандартный суффикс
grpc-go/version(dial.go:184-201)
Установление потока
dialgRPC (grpc/dial.go:51-75) открывает соответствующий поток:
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-сервер:
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):
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:
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-сообщения
message Hunk {
bytes data = 1;
}
message MultiHunk {
repeated bytes data = 1;
}Режим Single (Tun)
Каждый вызов Write отправляет один Hunk с байтами данных:
// 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:
// 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-сообщение:
// 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})
}Это снижает накладные расходы на сообщение, когда несколько мелких записей объединяются в пакет.
Сетевой поток
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):
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):
if config != nil {
options = append(options, grpc.Creds(credentials.NewTLS(
config.GetTLSConfig(tls.WithNextProto("h2")))))
}REALITY обрабатывается путем обертки слушателя (hub.go:126-128):
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.