← All Articles

Using Tailscale for Authentication of Internal Tools

Khash SajadiKhash Sajadi
Sep 8th 23

Using Tailscale with Go for authentication

Overview

JWT is a popular way for authentication and authorization, especially for service to service communications. When it comes to internal tools, distribution and renewal of JWT can become a challenge. Our internal support systems use JWT to authenticate and authorize access and they are written in a few different languages and run on different hosting options. By using JWT, can authenticate and authorize access for our different tools or different operations within those tools to all of these internal systems.

Until recently, we used to generate JWT on an internal web application that is only available internally. Authenticated and authorized users will navigate to the app and download a new JWT based on their permissions. This worked fine for a while but recently we switched to a better and more seamless system, as we started using Tailscale as our main VPN provider.

In this post, I share how we built a small service in Go that issues JWT to callers using Tailscale as identity provider.

Design

The main component of this approach is a web server, written in Go that is connected to our Tailscale network. This server inspects the incoming traffic to authenticate the caller by calling to the local Tailscale client.

The principles of this approach is simple: write a web server in Go and call a local, authenticated Tailscale client to get the information you need about the caller, using it's Tailscale IP address.

In this example, I'm using Echo as the web server framework, just to make my life easier. You can use any other framework or even the standard library.

Implementation

I won't go into the details of how to write a web server in Go. If you're familiar with Go, I'm sure you either have done it before, or you can find many examples online. In this part, I'll focus on an Echo middleware that authenticates the caller using Tailscale client.

utils/tailscale_auth_middleware.go

package utils

import (
	"fmt"
	"net/http"
	"net/netip"
	"net/url"
	"strings"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/rs/zerolog/log"
	"github.com/spf13/viper"
	"golang.org/x/net/context"
	"tailscale.com/client/tailscale/apitype"
)

const tailnetContextKey = "tailnet"

func TailscaleAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		// get the caller's ip and port
		remoteAddrStr := c.Request().RemoteAddr
		// parse the ip and port
		remoteAddr, err := netip.ParseAddrPort(remoteAddrStr)
		if err != nil {
			return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("invalid remote address: %s", remoteAddrStr))
		}

		ctx := c.Request().Context()

		localClient := GetTSLocalClient()

		timeoutCtx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
		defer cancel()

		status, err := localClient.Status(timeoutCtx)
		if err != nil {
			return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("failed to get tailscale status: %s", err))
		}
		log.Debug().Str("status", status.BackendState).Msg("Tailscale status")

		if status.BackendState != "Running" {
			return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("tailscale is not running, connected or authenticated: %s", status.BackendState))
		}

		info, err := localClient.WhoIs(ctx, remoteAddr.String())
		if err != nil {
			return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
		}

		var tailnet string
		if !info.Node.Hostinfo.ShareeNode() {
			var ok bool
			_, tailnet, ok = strings.Cut(info.Node.Name, info.Node.ComputedName+".")
			if !ok {
				return echo.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("can't extract tailnet name from hostname %q", info.Node.Name))
			}

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

		if expectedTailnet := viper.GetString("auth.tailscale.expected_tailnet"); expectedTailnet != "" && expectedTailnet != tailnet {
			return echo.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("user is part of tailnet %s, expected: %s", tailnet, url.QueryEscape(expectedTailnet)))
		}

		c.Set(tailnetContextKey, info)

		log.Debug().Str("tailnet", tailnet).Interface("tailscaleInfo", info).Msg("tailscale info")

		return next(c)
	}
}

func GetTailscaleInfo(c echo.Context) (bool, *apitype.WhoIsResponse) {
	info, ok := c.Get(tailnetContextKey).(*apitype.WhoIsResponse)
	return ok, info
}

Here I use the amazing Viper library to fetch auth.tailscale.expected_tailnet from a configuration file. This is the name of the tailnet that I expect the caller to be part of. If this is not set, the caller can be part of any tailnet. You can set this in your Tailscale admin panel, under DNS settings. There you can see a name like tailnet-12kl.ts.net. This is the name of the tailnet that you can use here, but remove the .ts.net part.

utils/tailscale.go

package utils

import (
	"github.com/spf13/viper"
	"tailscale.com/client/tailscale"
)

func GetTSLocalClient() *tailscale.LocalClient {
	localClient := &tailscale.LocalClient{}
	if viper.GetString("auth.tailscale.socket") != "" {
		localClient.Socket = viper.GetString("auth.tailscale.socket")
	}

	return localClient
}

This is a second file that returns a client instance to the local Tailscale client. This file also uses Viper to fetch the path to the Tailscale socket. If this is not set, the default socket will be used. Remember, the Tailscale client needs to be running on the same machine as the web server and already connected to the same network.

Now, let's register this middleware with our Echo server:

Now, we can use the GetTailscaleInfo function to get the information about the caller:

main.go

package main

import (
  "github.com/labstack/echo/v4"  
  "github.com/spf13/viper"
  // don't forget to import the utils package
)

func main() {
  e := echo.New()
  e.Use(utils.TailscaleAuthMiddleware)

  e.GET("/", func(c echo.Context) error {
    ok, info := utils.GetTailscaleInfo(c)
    if !ok {
      return echo.NewHTTPError(http.StatusUnauthorized, "not a tailscale user")
    }

    user := info.UserProfile.LoginName

    return c.String(http.StatusOK, fmt.Sprintf("Hello, %s!", user))
  })

  e.Logger.Fatal(e.Start(viper.GetString("server.address")))
}

Server authentication

While this approach works for authenticated users connected to your Tailscale network, you might want to use it to authenticate your servers as well. For this, Tailscale returns tagged-devices as the user name. You can use this to identify your servers and authenticate them. For example:

if user == "tagged-devices" {
		tags := tailscaleInfo.Node.Tags
}

This populates tags as a slice of strings with all the tags you have assigned to your servers, in Tailscale admin UI.

JWT

You can use this code without generating a JWT. This will give you authenticated users and servers and you can do whatever you need with that information. At this point, your caller is authenticated. However, you can go further and generate JWT for your callers. This is what we do in our internal tools.

Conclusion

In this post, I shared how we use Tailscale to authenticate our internal tools and servers. This approach is simple and easy to implement. It also works for both users and servers. I hope you find it useful.


Try Cloud 66 for Free, No credit card required