Skip to content

gRPC Transport

Source: transport/v2raygrpc/, transport/v2raygrpclite/, transport/v2ray/grpc.go, transport/v2ray/grpc_lite.go

Overview

sing-box provides two gRPC implementations:

  1. Full gRPC (v2raygrpc): Uses google.golang.org/grpc, requires build tag with_grpc
  2. Lite gRPC (v2raygrpclite): Raw HTTP/2 implementation using golang.org/x/net/http2, always available

Both implement the V2Ray "Gun" protocol -- a bidirectional streaming gRPC service that tunnels arbitrary TCP data.

Full gRPC Implementation

Client

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
}

Connection caching uses atomic pointer + mutex (double-checked locking):

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 integration: Uses a custom TLSTransportCredentials adapter that bridges sing-box's TLS config interface to gRPC's credentials.TransportCredentials. Without TLS, uses insecure.NewCredentials().

Dial options include:

  • Keepalive parameters (IdleTimeout, PingTimeout, PermitWithoutStream)
  • Backoff config (500ms base, 1.5x multiplier, 19s max)
  • Custom dialer bridging N.Dialer to net.Conn

Custom Service Name

The Gun protocol uses a custom service name for the gRPC method path:

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...)
    // ...
}

The method path becomes /<serviceName>/Tun. Default service name is GunService.

Server

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

Adapts a gRPC bidirectional stream to 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
}

The Hunk protobuf message contains a single Data field. When the read buffer is smaller than the received chunk, excess data is cached for subsequent reads.

Lite gRPC Implementation

Wire Format (Gun Protocol)

The lite implementation manually constructs the Gun wire format over HTTP/2:

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

Where:

  • 0x00: gRPC compressed flag (always uncompressed)
  • Frame length: 1 + varint_length + data_length
  • 0x0A: Protobuf field tag (field 1, wire type 2 = length-delimited)
  • Varint data length: Standard protobuf varint encoding of data length
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
}

Reading discards the first 6 bytes (compressed flag + frame length + protobuf tag), reads the varint data length, then streams the payload:

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
}

Lite Client

Uses http2.Transport directly with io.Pipe for bidirectional streaming:

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
}

Default client headers:

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

Lite Server

Validates gRPC-specific requirements:

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(...)
}

The server supports both TLS (h2) and cleartext (h2c) HTTP/2. The h2c handler detects the HTTP/2 connection preface (PRI * HTTP/2.0).

Front Headroom

The lite GunConn declares front headroom for zero-copy writes:

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

Configuration

json
{
  "transport": {
    "type": "grpc",
    "service_name": "TunService",
    "idle_timeout": "15s",
    "ping_timeout": "15s",
    "permit_without_stream": false,
    "force_lite": false
  }
}
FieldDescription
service_namegRPC service name for method path /<name>/Tun
idle_timeoutKeepalive idle timeout
ping_timeoutKeepalive ping timeout
permit_without_streamAllow keepalive pings without active streams (full gRPC only)
force_liteForce lite implementation even with with_grpc build tag