Skip to content

Транспорт gRPC

Исходный код: transport/v2raygrpc/, transport/v2raygrpclite/, transport/v2ray/grpc.go, transport/v2ray/grpc_lite.go

Обзор

sing-box предоставляет две реализации gRPC:

  1. Полный gRPC (v2raygrpc): Использует google.golang.org/grpc, требует тег сборки with_grpc
  2. Облегченный gRPC (v2raygrpclite): Реализация на основе необработанного HTTP/2 с использованием golang.org/x/net/http2, всегда доступна

Обе реализации поддерживают протокол V2Ray «Gun» -- двунаправленный потоковый gRPC-сервис, туннелирующий произвольные TCP-данные.

Полная реализация gRPC

Клиент

go
type Client struct {
    ctx         context.Context
    dialer      N.Dialer
    serverAddr  string
    serviceName string
    dialOptions []grpc.DialOption
    conn        atomic.Pointer[grpc.ClientConn]
    connAccess  sync.Mutex
}

Кэширование соединений использует атомарный указатель + мьютекс (двойная проверка блокировки):

go
func (c *Client) connect() (*grpc.ClientConn, error) {
    conn := c.conn.Load()
    if conn != nil && conn.GetState() != connectivity.Shutdown {
        return conn, nil
    }
    c.connAccess.Lock()
    defer c.connAccess.Unlock()
    conn = c.conn.Load()  // Re-check after lock
    if conn != nil && conn.GetState() != connectivity.Shutdown {
        return conn, nil
    }
    conn, err := grpc.DialContext(c.ctx, c.serverAddr, c.dialOptions...)
    c.conn.Store(conn)
    return conn, nil
}

Интеграция TLS: Используется пользовательский адаптер TLSTransportCredentials, который связывает интерфейс TLS-конфигурации sing-box с credentials.TransportCredentials gRPC. Без TLS используется insecure.NewCredentials().

Параметры соединения включают:

  • Параметры keepalive (IdleTimeout, PingTimeout, PermitWithoutStream)
  • Конфигурацию backoff (базовый 500 мс, множитель 1.5x, максимум 19 с)
  • Пользовательский dialer, связывающий N.Dialer с net.Conn

Пользовательское имя сервиса

Протокол Gun использует пользовательское имя сервиса для пути gRPC-метода:

go
func ServerDesc(name string) grpc.ServiceDesc {
    return grpc.ServiceDesc{
        ServiceName: name,
        Streams: []grpc.StreamDesc{{
            StreamName:    "Tun",
            Handler:       _GunService_Tun_Handler,
            ServerStreams: true,
            ClientStreams: true,
        }},
        Metadata: "gun.proto",
    }
}

func (c *gunServiceClient) TunCustomName(ctx context.Context, name string, opts ...grpc.CallOption) (GunService_TunClient, error) {
    stream, err := c.cc.NewStream(ctx, &ServerDesc(name).Streams[0], "/"+name+"/Tun", opts...)
    // ...
}

Путь метода принимает вид /<serviceName>/Tun. Имя сервиса по умолчанию -- GunService.

Сервер

go
func (s *Server) Tun(server GunService_TunServer) error {
    conn := NewGRPCConn(server, nil)
    var source M.Socksaddr
    // Extract source from gRPC peer info
    if remotePeer, loaded := peer.FromContext(server.Context()); loaded {
        source = M.SocksaddrFromNet(remotePeer.Addr)
    }
    // Override with X-Forwarded-For if present (CDN support)
    if grpcMetadata, loaded := gM.FromIncomingContext(server.Context()); loaded {
        forwardFrom := strings.Join(grpcMetadata.Get("X-Forwarded-For"), ",")
        // Parse last valid address from comma-separated list
    }
    done := make(chan struct{})
    go s.handler.NewConnectionEx(log.ContextWithNewID(s.ctx), conn, source, M.Socksaddr{},
        N.OnceClose(func(it error) { close(done) }))
    <-done  // Block until connection handler completes
    return nil
}

GRPCConn

Адаптирует двунаправленный gRPC-поток к net.Conn:

go
type GRPCConn struct {
    GunService          // Send/Recv interface
    cache     []byte    // Buffered data from oversized Recv
    cancel    context.CancelCauseFunc
    closeOnce sync.Once
}

func (c *GRPCConn) Read(b []byte) (n int, err error) {
    if len(c.cache) > 0 {
        n = copy(b, c.cache)
        c.cache = c.cache[n:]
        return
    }
    hunk, err := c.Recv()
    n = copy(b, hunk.Data)
    if n < len(hunk.Data) {
        c.cache = hunk.Data[n:]
    }
    return
}

Protobuf-сообщение Hunk содержит единственное поле Data. Когда буфер чтения меньше полученного блока, избыточные данные кэшируются для последующих чтений.

Облегченная реализация gRPC

Проводной формат (протокол Gun)

Облегченная реализация вручную формирует проводной формат Gun поверх HTTP/2:

[0x00][4-byte big-endian frame length][0x0A][varint data length][data]

Где:

  • 0x00: Флаг сжатия gRPC (всегда без сжатия)
  • Длина кадра: 1 + varint_length + data_length
  • 0x0A: Тег поля Protobuf (поле 1, тип проводки 2 = ограниченная длина)
  • Varint длина данных: Стандартное varint-кодирование длины данных в формате Protobuf
go
func (c *GunConn) Write(b []byte) (n int, err error) {
    varLen := varbin.UvarintLen(uint64(len(b)))
    buffer := buf.NewSize(6 + varLen + len(b))
    header := buffer.Extend(6 + varLen)
    header[0] = 0x00
    binary.BigEndian.PutUint32(header[1:5], uint32(1+varLen+len(b)))
    header[5] = 0x0A
    binary.PutUvarint(header[6:], uint64(len(b)))
    common.Must1(buffer.Write(b))
    _, err = c.writer.Write(buffer.Bytes())
    if c.flusher != nil {
        c.flusher.Flush()
    }
    return len(b), nil
}

При чтении отбрасываются первые 6 байт (флаг сжатия + длина кадра + тег protobuf), считывается varint-длина данных, затем передаётся полезная нагрузка:

go
func (c *GunConn) read(b []byte) (n int, err error) {
    if c.readRemaining > 0 {
        // Continue reading from current frame
    }
    _, err = c.reader.Discard(6)
    dataLen, err := binary.ReadUvarint(c.reader)
    c.readRemaining = int(dataLen)
    // Read up to readRemaining bytes
}

Облегченный клиент

Использует http2.Transport напрямую с io.Pipe для двунаправленной потоковой передачи:

go
func (c *Client) DialContext(ctx context.Context) (net.Conn, error) {
    pipeInReader, pipeInWriter := io.Pipe()
    request := &http.Request{
        Method: http.MethodPost,
        Body:   pipeInReader,
        URL:    c.url,  // /<serviceName>/Tun
        Header: defaultClientHeader,  // Content-Type: application/grpc
    }
    conn := newLateGunConn(pipeInWriter)
    go func() {
        response, err := c.transport.RoundTrip(request)
        conn.setup(response.Body, err)
    }()
    return conn, nil
}

Заголовки клиента по умолчанию:

go
var defaultClientHeader = http.Header{
    "Content-Type": []string{"application/grpc"},
    "User-Agent":   []string{"grpc-go/1.48.0"},
    "TE":           []string{"trailers"},
}

Облегченный сервер

Выполняет валидацию специфических для gRPC требований:

go
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
    // Handle h2c preface
    if request.Method == "PRI" && len(request.Header) == 0 && request.URL.Path == "*" {
        s.h2cHandler.ServeHTTP(writer, request)
        return
    }
    // Validate path: /<serviceName>/Tun
    // Validate method: POST
    // Validate content-type: application/grpc
    writer.Header().Set("Content-Type", "application/grpc")
    writer.Header().Set("TE", "trailers")
    writer.WriteHeader(http.StatusOK)
    conn := v2rayhttp.NewHTTP2Wrapper(newGunConn(request.Body, writer, writer.(http.Flusher)))
    s.handler.NewConnectionEx(...)
}

Сервер поддерживает как TLS (h2), так и открытый текст (h2c) HTTP/2. Обработчик h2c обнаруживает преамбулу HTTP/2-соединения (PRI * HTTP/2.0).

Резервирование буфера (Front Headroom)

Облегченный GunConn объявляет резервирование буфера для записи без копирования:

go
func (c *GunConn) FrontHeadroom() int {
    return 6 + binary.MaxVarintLen64  // 6 + 10 = 16 bytes
}

Конфигурация

json
{
  "transport": {
    "type": "grpc",
    "service_name": "TunService",
    "idle_timeout": "15s",
    "ping_timeout": "15s",
    "permit_without_stream": false,
    "force_lite": false
  }
}
ПолеОписание
service_nameИмя gRPC-сервиса для пути метода /<name>/Tun
idle_timeoutТаймаут бездействия для keepalive
ping_timeoutТаймаут пинга для keepalive
permit_without_streamРазрешить keepalive-пинги без активных потоков (только полный gRPC)
force_liteПринудительно использовать облегченную реализацию даже с тегом сборки with_grpc