← All Articles

How to use Tailscale for gRPC authentication in Golang

Khash SajadiKhash Sajadi
Mar 28th 24Updated Apr 30th 24

Friends of this blog know that I am a big fan of building internal tools, or as we call them, "tools that help scale people". As the name suggests, internal tools are used, well, internally and as such usually will require their access to be restricted to the company's staff and network. In the past, I've written about how to use Tailscale for authentication of internal tools using HTTP. In this post, I will show you how to use Tailscale for gRPC authentication in Golang.

Prerequisites

This post assumes you have a basic understanding of gRPC in Golang and have a working gRPC server and client. If you are new to gRPC, I recommend you read the official gRPC documentation. I also assume you have a Tailscale account and have set up your Tailscale network.

gRPC Middleware

gRPC middleware is a way to intercept and manipulate requests and responses in gRPC. Given that gRPC can be both single call (Unary) or streaming, we will need a middleware that can handle both.

Let's start by creating a simple gRPC server, without any authentication:

  // ...
	lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", "localhost", 50051))
	if err != nil {		
		return err
	}

	grpcServer := grpc.NewServer()

	return grpcServer.Serve(lis)
  // ...

Now, let's build our Tailscale gRPC middleware:

package utils

import (
  "context"
  "strings"
  "time"

  "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip"
  "google.golang.org/grpc"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
  "tailscale.com/client/tailscale"
)

const (
TsSocket = ""
TsTailnet = "CHANGE_ME"
TsHostInfo = "ts_host_info" // context key
)

// wrappedStream wraps around the embedded grpc.ServerStream, and intercepts the RecvMsg and
// SendMsg method call.
type wrappedStream struct {
  grpc.ServerStream
  ctx context.Context
}

func (w *wrappedStream) Context() context.Context {
  return w.ctx
}

func (w *wrappedStream) SetContext(ctx context.Context) {
  w.ctx = ctx
}

func (w *wrappedStream) RecvMsg(m any) error {
  return w.ServerStream.RecvMsg(m)
}

func (w *wrappedStream) SendMsg(m any) error {
  return w.ServerStream.SendMsg(m)
}

func newStreamContextWrapper(ss grpc.ServerStream) StreamContextWrapper {
  ctx := ss.Context()
  return &wrappedStream{
  	ss,
  	ctx,
  }
}

type StreamContextWrapper interface {
  grpc.ServerStream
  SetContext(context.Context)
}

func getTSLocalClient() *tailscale.LocalClient {
  localClient := &tailscale.LocalClient{}
  if TsSocket != "" {
  	localClient.Socket = TsSocket
  }

  return localClient
}

func TailscaleUnaryInterceptor() grpc.UnaryServerInterceptor {
  return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
  	var err error
  	ctx, err = tailscaleInterceptor(ctx)
  	if err != nil {
  		return nil, err
  	}

  	return handler(ctx, req)
  }
}


func TailscaleStreamInterceptor() grpc.StreamServerInterceptor {
  return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
  	md, ok := metadata.FromIncomingContext(ss.Context())
  	if !ok {
  		return status.Errorf(codes.InvalidArgument, "missing metadata")
  	}

  	netCtx := metadata.NewIncomingContext(ss.Context(), md)

  	ctx, err := tailscaleInterceptor(netCtx)
  	if err != nil {
  		return err
  	}

  	wrappedStream := newStreamContextWrapper(ss)
  	wrappedStream.SetContext(ctx)

  	return handler(srv, wrappedStream)
  }
}

func tailscaleInterceptor(ctx context.Context) (context.Context, error) {
  remoteAddr, ok := realip.FromContext(ctx)
  if !ok {
  	return nil, status.Errorf(codes.InvalidArgument, "missing real IP")
  }

  timeoutCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
  defer cancel()

  localClient := getTSLocalClient()

  tsStatus, err := localClient.Status(timeoutCtx)
  if err != nil {
  	return nil, status.Errorf(codes.Unauthenticated, "failed to get tailscale status")
  }

  if tsStatus.BackendState != "Running" {
  	return nil, status.Errorf(codes.Unauthenticated, "tailscale backend is not running")
  }

  tsInfo, err := localClient.WhoIs(ctx, remoteAddr.String())
  if err != nil {
  	return nil, status.Errorf(codes.Unauthenticated, "failed to get tailscale info")
  }

  var tailnet string
  if !tsInfo.Node.Hostinfo.ShareeNode() {
  	var ok bool
  	_, tailnet, ok = strings.Cut(tsInfo.Node.Name, tsInfo.Node.ComputedName+".")
  	if !ok {
  		return nil, status.Errorf(codes.Unauthenticated, "can't extract tailnet name")
  	}

  	tailnet = strings.TrimSuffix(tailnet, ".ts.net.")
  }

  if expectedTailnet := TsTailnet; expectedTailnet != "" && expectedTailnet != tailnet {
  	return nil, status.Errorf(codes.Unauthenticated, "user is part of different tailnet")
  }

  ctx = context.WithValue(ctx, TsHostInfo, tsInfo)

  return ctx, nil
}

This interceptor, works on the same principle as the HTTP middleware we built in the previous post. It uses the caller's IP address, taken from the gRPC context, to query the Tailscale client for the caller's information. It then checks if the caller is part of the expected Tailscale network and if they are, it adds the caller's information to the context.

To use this middleware, make sure to change the TsTailnet constant to the name of your Tailscale network. You can also change the TsSocket constant to the path of your Tailscale socket file if you are running the Tailscale client in a non-standard location. The TsTailnet is the one you can find on your Tailscale account, and usually ends with .ts.net.

Now, let's use this middleware in our gRPC server:

  // ...
  lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", "localhost", 50051))
  if err != nil {		
    return err
  }

  trustedPeers := []netip.Prefix{
		netip.MustParsePrefix("127.0.0.1/32"),
	}
	// Define headers to look for in the incoming request.
	headers := []string{realip.XForwardedFor, realip.XRealIp}
	// Consider that there is one proxy in front,
	// so the real client ip will be rightmost - 1 in the csv list of X-Forwarded-For
	// Optionally you can specify TrustedProxies
	realIpOpts := []realip.Option{
		realip.WithTrustedPeers(trustedPeers),
		realip.WithHeaders(headers),
		realip.WithTrustedProxiesCount(1),
	}

	grpcServer := grpc.NewServer(
		grpc.ChainUnaryInterceptor(
			realip.UnaryServerInterceptorOpts(realIpOpts...),
			utils.TailscaleUnaryInterceptor,
		),
		grpc.ChainStreamInterceptor(
			realip.StreamServerInterceptorOpts(realIpOpts...),
			utils.TailscaleStreamInterceptor,
		),
	)

  return grpcServer.Serve(lis)
  // ...

In both the interceptor and the server code, we're using the excellent go-grpc-middleware to fetch the real caller IP from the call.

One important note in hosting your internal tools over Tailscale is to bind the server, strictly to your Tailscale network, and not to any public IP. In the example above, you can see that my binding address is localhost which can be used when your gRPC server is hosted in Kubernetes pod with a sidecar container, running the Tailscale client. However, when hosting this on a VM or your development environment, you'd need to find the Tailscale IP address and use it as the binding address. You can do this using the Tailscale command line tool by running tailscale ip -4.

As you can see, the interceptor uses the Tailscale client to find the caller's identity and places that on the context. This was any subsequent gRPC service can use this information to authorize the caller.

To use this information in you gRPC service, you can do something like this:

  // ...
  import "tailscale.com/client/tailscale/apitype"
  // ...
  tsContext, ok := ctx.Value(utils.TsHostInfo).(*apitype.WhoIsResponse)
	if !ok {		
		return nil, status.Errorf(codes.Unauthenticated, "missing caller information")
	}
  // you can use tsContext to authorize the caller ie tsContext.UserProfile.LoginName

And that's it! You now have a gRPC middleware that uses Tailscale to authenticate requests. This middleware can be used to secure your internal tools and services, ensuring that only your staff can access them.


Try Cloud 66 for Free, No credit card required