This library enables carrying gRPC over gRPC. There are a few niche use cases where this could be useful, but the most widely applicable one is likely for letting gRPC servers communicate in the reverse direction, sending requests to connected clients.
The tunnel is itself a gRPC service, which provides bidirectional streaming methods for forward and reverse tunneling.
-
Forward Tunnel: A forward tunnel is the same direction as normal gRPC connections. This means that the gRPC client that created the tunnel is also the client for requests that flow through the tunnel. For forward cases, the server must register service handlers for the gRPC services that can be accessed over the tunnel.
A forward tunnel allows for all requests made on the tunnel to be directed to the same server. With a typical gRPC client, connecting to a replicated server, requests are typically load balanced across backends. For typical stateless applications, this is desirable for resource utilization and fault tolerance. But some applications that are not stateless may need affinity. The tunnel provides that affinity. Instead of the client making multiple requests, which could all be directed to different backends, the client makes one request to open a tunnel. The resulting tunnel can then be used to create other RPC stubs, so that all requests issued via those stubs are directed to the single backend to which the tunnel was opened.
-
Reverse Tunnel: A reverse tunnel is the opposite: requests flow in the reverse direction of a normal gRPC connection. This means that the gRPC client that created the tunnel actually acts as the server. The gRPC server with which the reverse tunnel was opened acts as the client, sending requests to the gRPC client.
A reverse tunnel allows for rich "server push"-like capabilities, where a server can push data to the client (by initiating an RPC through the reverse tunnel) and even get back a response. This kind of functionality can be built using regular gRPC bidirectional streams, but this library provides a better and more familiar abstraction. A reverse tunnel is also classically used in cases where the server cannot be directly dialed due to network topology (e.g. being behind NAT). In these cases, the server dials a central router and registers itself, allowing the router to forward requests to the server over the reverse tunnel.
Talk of tunneling can make concepts like "client" and "server" confusing, since reverse tunnels swap the typical roles. So in the hopes of clarity, the rest of this document will use the following terms to hopefully avoid confusion:
- channel: A conduit through which gRPC requests can be sent and responses received. This can be a TCP connection but can also be a tunnel.
- tunnel: A single gRPC stream that can act as a channel, carrying other gRPC requests and responses over that stream.
- tunnel service: The gRPC service via which a tunnel is opened. This is
a service named
grpctunnel.v1.TunnelService
and defines the actual tunneling protocol, in the form of bidirectional streaming RPCs. - tunnel handler: The implementation of the tunnel service. This is the
code that handles RPCs to the
grpctunnel.v1.TunnelService
and actually implements tunnels. - network client: The gRPC client that opened the tunnel. For typical TCP connections, this is the TCP client.
- network server: The gRPC server to which a tunnel was opened. For typical TCP connections, this is the TCP server.
- tunnel client: The gRPC client that initiates requests on a tunnel.
- tunnel server: The gRPC server handles requests on a tunnel.
- forward tunnel: A tunnel in which the network client is also the tunnel client, and the network server is also the tunnel server.
- reverse tunnel: A tunnel in which the network server is actually the tunnel client, and the network client acts as the tunnel server.
The tunnel handler is constructed via
grpctunnel.NewTunnelServiceHandler
.
The options provided with this call are for configuring reverse tunnel support.
The handler has other methods that can be used to interact with reverse tunnels
that have been established.
The resulting handler has a Service
method, which returns the actual service
implementation, which can then be registered with a gRPC server using
tunnelpb.RegisterTunnelServiceServer
.
handler := grpctunnel.NewTunnelServiceHandler(
grpctunnel.TunnelServiceHandlerOptions{},
)
svr := grpc.NewServer()
tunnelpb.RegisterTunnelServiceServer(svr, handler.Service())
// TODO: Configure services for forward tunnels.
// TODO: Inject handler into code that will use reverse tunnels.
// Start the gRPC server.
l, err := net.Listen("tcp", "0.0.0.0:7899")
if err != nil {
log.Fatal(err)
}
if err := svr.Serve(l); err != nil {
log.Fatal(err)
}
A forward tunnel is one in which the tunnel client is the same as the network client, and the tunnel server is the same as the network server. So the client that opened the tunnel is also the one that initiates RPCs through the tunnel.
sequenceDiagram
participant network client
participant network server
network client->>+network server: opens the tunnel via RPC `grpctunnel.v1.TunnelService/OpenTunnel`
rect rgb(245, 248, 255)
loop forward tunnel created
network client->>+network server: sends RPC request over the tunnel
network server->>network server: handles request
network server->>-network client: sends RPC response over the tunnel
end
end
network server->>-network client: tunnel closed
The tunnel is opened by the client using the generated stub for the tunneling
protocol: tunnelpb.TunnelServiceClient
.
Once a stream has been established via the stub's OpenTunnel
method, it can
be used to create a channel via grpctunnel.NewChannel
.
That channel can be used to create other stubs, as if it were a
*grpc.ClientConn
. All RPCs issued from stubs created with this channel will be
directed through the tunnel.
// Dial the server.
cc, err := grpc.Dial(
"127.0.0.1:7899",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal(err)
}
tunnelStub := tunnelpb.NewTunnelServiceClient(cc)
// Opens a tunnel and return a channel.
ch, err := grpctunnel.NewChannel(tunnelStub).Start(context.Background())
if err != nil {
log.Fatal(err)
}
// TODO: Create stubs using ch to send RPCs through the tunnel.
To close the tunnel, use the channel's Close
method. This will also close the
underlying stream. If any RPCs are in progress on the channel when it is closed,
they will be cancelled. The channel is also closed if the context passed to
Start
is cancelled or times out.
To use client interceptors with these channels, wrap them using
grpchan.InterceptClientConn
before creating stubs.
To handle RPCs that are issued over tunnels, the server must register service
handlers using the TunnelServiceHandler
.
handler := grpctunnel.NewTunnelServiceHandler(
grpctunnel.TunnelServiceHandlerOptions{},
)
svr := grpc.NewServer()
tunnelpb.RegisterTunnelServiceServer(svr, handler.Service())
// Register services to be used with forward tunnels.
foopb.RegisterFooServer(handler, &fooServer{})
// To expose a service over both tunnels and non-tunnel connections, you must
// register it with the gRPC server, too.
serviceImpl := newBarServer()
barpb.RegisterBarServer(handler, serviceImpl)
barpb.RegisterBarServer(svr, serviceImpl)
To use server interceptors with these handlers, wrap the TunnelServiceHandler
using grpchan.WithInterceptor
before registering the other handlers.
With forward tunnels, authentication can be done on the initial OpenTunnel
RPC
that opens the tunnel. The identity of the client can then be stored in a
context value, from a server interceptor. These context values are also
available to server interceptors and handlers that process tunneled requests.
So an authorization interceptor could extract the client identity from the
request context.
If using mutual TLS, you can use peer.FromContext
(part of the gRPC runtime)
to examine the client's identity, which would have been authenticated via client
certificate. Like other context values, this value is available to all server
interceptors and handlers of tunneled requests and will be the same peer that
opened the tunnel.
A reverse tunnel is one in which the tunnel client is actually the network server; the tunnel server is the network client. So it's the server to which the tunnel was opened that actually initiates RPCs through the tunnel.
sequenceDiagram
participant network client
participant network server
network client->>+network server: opens the tunnel via RPC `grpctunnel.v1.TunnelService/OpenReverseTunnel`
rect rgb(245, 248, 255)
loop reverse tunnel created
network server->>+network client: sends RPC request over the tunnel
network client->>network client: handles request
network client->>-network server: sends RPC response over the tunnel
end
end
network server->>-network client: tunnel closed
Because the typical roles of client and server are reversed, usage of reverse tunnels is a bit more complicated than usage of forward tunnels.
The tunnel is opened by the client using the generated stub for the tunneling
protocol: tunnelpb.TunnelServiceClient
.
However, since the network client will act as the channel server, handlers for
the exposed services must be registered before the tunnel is actually created.
This is done by creating a grpctunnel.ReverseTunnelServer
and then using it to register service implementations, just as one would
register service implementations with a *grpc.Server
.
Once all services are registered, we can call Serve
to actually open the
reverse tunnel and accept and process RPC requests sent by the network server.
// Dial the server.
cc, err := grpc.Dial(
"127.0.0.1:7899",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal(err)
}
// Register services for reverse tunnels.
tunnelStub := tunnelpb.NewTunnelServiceClient(cc)
channelServer := grpctunnel.NewReverseTunnelServer(tunnelStub)
foopb.RegisterFooServer(channelServer, &fooServer{})
barpb.RegisterBarServer(channelServer, newBarServer())
// Open the reverse tunnel and serve requests.
if _, err := channelServer.Serve(context.Background()); err != nil {
log.Fatal(err)
}
The Serve
function returns once the tunnel is closed, either via the
tunnel client closing the channel or some other interruption of the
stream (including the context being cancelled or timing out).
To use server interceptors with these handlers, wrap the ReverseTunnelServer
using grpchan.WithInterceptor
before registering the other handlers.
The network server for reverse services is where things get really interesting. The network server will be acting as a tunnel client. So it needs a way to inspect the reverse tunnels, to decide which one should be used for an RPC. It is even possible to group multiple reverse tunnels so they act like a connection pool, where RPCs can be scattered over multiple tunnels in a round-robin fashion.
When the handler is created, it is given options that control the behavior of reverse tunnels:
NoReverseTunnels
: Reverse tunnels can be completely disabled this way, which will cause all network clients to receive a "Not Implemented" error if they try to establish reverse tunnels.OnReverseTunnelOpen
,OnReverseTunnelClose
: These callbacks, if provided, let your application know whenever a reverse tunnel is opened or closed, to track the available tunnels. Each such tunnel is a channel that can be used to send RPCs to the corresponding network client.AffinityKey
: This is a function for grouping reverse tunnels. The function is given aTunnelChannel
and returns a key. The function has access to the corresponding stream's context, from which it can query for properties that may be useful for grouping -- such as the authenticated peer, request headers provided when the tunnel was opened, and any other context values that may have been populated by interceptors. All reverse tunnels with the same key can be treated like a connection pool using the handler'sKeyAsChannel
method.
The callbacks provide the most flexibility for how to make use of the available reverse tunnels. But the handler has other methods that should be sufficient for most usages:
AsChannel()
: Returns a channel that effectively groups all reverse tunnels into a single connection pool. Issuing RPCs with this channel will round-robin through them. This works even if no affinity key function was provided when then handler was created.KeyAsChannel()
: Similar to above, but allows for selecting a subset of reverse tunnels to treat as a single pool. This is only useful if an affinity key function is provided when the handler is created.AllReverseTunnels()
: Returns a slice of all available reverse tunnels. This allows flexibility for selecting a reverse tunnel, but at a potential performance cost since it requires the caller to re-query and re-scan the slice prior to issuing an RPC. (Storing the slice and using it for future RPCs is risky because the slice is a snapshot that can quickly become stale: new reverse tunnels may be opened and items in the slice may be closed.)
All of these channels can be used just like a *grpc.ClientConn
, for creating
RPC stubs and then issuing RPCs to the corresponding network client.
To use client interceptors with these channels, wrap them using
grpchan.InterceptClientConn
before creating stubs.
With reverse tunnels, authentication is a little different than with forward
tunnels. The credentials associated with the initial OpenReverseTunnel
RPC
are those for the tunnel server. Unless you are using mutual TLS (where
both parties authenticate via certificate), you will need to supply additional
authentication material with tunneled requests.
One way to send authentication material is to have the client (which is actually the network server) use client interceptors to include per-call credentials with every request. This approach closely resembles how non-tunneled RPCs are handled: both sides use interceptors to send and verify credentials with every operation.
A more efficient way involves only authenticating once, since all calls over the tunnel will have the same authenticated client. This can be done by having a server interceptor that sends authentication materials in response headers. These will be received by the client almost immediately after the tunnel is opened. This identity can then be stored in the context using a mutable value:
// Client interceptor for the OpenReverseTunnel RPC:
func reverseCredentialsInterceptor(
ctx context.Context,
desc *grpc.StreamDesc,
cc *grpc.ClientConn,
method string,
streamer grpc.Streamer,
opts ...grpc.CallOption,
) (grpc.ClientStream, error) {
if method != "/grpctunnel.v1.TunnelService/OpenReverseTunnel" {
return streamer(ctx, desc, cc, method, opts...)
}
// Store mutable value in context.
var authInfo any
ctx = context.WithValue(ctx, reverseCredentialsKey{}, &authInfo)
// Invoke RPC; open the tunnel.
stream, err := streamer(ctx, desc, cc, method, opts...)
if err != nil {
return nil, err
}
// Get credentials from response headers.
md, err := stream.Header()
if err != nil {
return nil, err
}
// If authentication fails, authInfo could include details about
// the failure so that tunneled RPCs can fail with appropriate
// error details.
//
// An alternative is to just close the tunnel immediately, right
// here. But then there is no way to send information about the
// authn error to the peer.
//
// Note that modifying authInfo here is okay. But it is not safe
// to modify after returning from this interceptor since that
// could lead to data races with tunneled RPCs reading it.
authInfo = authenticate(md)
return stream, nil
}
// Server interceptor for tunneled RPCs.
// (Unary interceptor shown; streaming interceptor would be similar.)
func tunneledAuthzInterceptor(
ctx context.Context,
method string,
req, reply interface{},
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
// Get authInfo from context. This was stored in the context by
// the interceptor above. More detailed error messages could be
// used or error details added if authInfo contains details about
// authn failures.
authInfo, ok := ctx.Value(reverseCredentialsKey{}).(*any)
if !ok || authInfo == nil || *authInfo == nil {
return status.Error(codes.Unauthenticated, "unauthenticated")
}
if !isAllowed(method, *authInfo) {
return status.Error(codes.PermissionDenied, "not authorized")
}
// RPC is allowed.
return invoker(ctx, method, req, reply, cc, opts...)
}