tsnet

Typically, every IP address on your tailnet has to be strongly associated with a single machine. This makes it difficult to run multiple services on a single machine. Especially if all of those services have different access control rules. tsnet lets you run multiple services on the same machine with different IP addresses, access control rules, and even user identities.

tsnet is a library that lets you embed Tailscale inside of a Go program. This uses a userspace TCP/IP networking stack and makes direct connections to your nodes over your tailnet just like any other machine on your tailnet would. When combined with other features of Tailscale, this lets you create new and interesting ways to use computers that you would have never thought about before.

Inside Tailscale, we use tools built on top of tsnet constantly. It powers our internal URL shortener golink. It powers the support tooling that our support team uses every single day to help you with your problems. There are also a number of internal tools that are built on top of Tailscale and tsnet at such a fundamental level, that they would be impossible to make without them.

If you want to see examples of how programs use tsnet in practice, you can check on the Go documentation site. People have used this to expose metrics, administrative endpoints and more.

Getting started

To get started, you’ll need to import the tsnet library into your Go project. This will require a few prerequisites:

  • Have the Go compiler toolchain installed
  • A text editor or some other tool to manipulate code must be installed
  • A terminal window must be open
  • A new Go project must be created, or you must run this inside an existing go project

From here, we will do the following steps:

  • Add tsnet to your project with go get
  • Import tsnet into a program
  • Create a simple HTTP server that says “hello tailnet”
  • Start the server and demonstrate it working

Installing tsnet

Run the following command to add tsnet to your dependencies and make it available for use in your program.

go get tailscale.com/tsnet

Import tsnet

Inside your Git repository, create a folder named cmd and then create another folder inside that called tshello. Copy the following code into a new file named main.go:

// The tshello server demonstrates how to use Tailscale as a library.
package main

import (
	"flag"
	"fmt"
	"html"
	"log"
	"net/http"
	"strings"

	"tailscale.com/tsnet"
)

var (
	addr = flag.String("addr", ":80", "address to listen on")
	hostname = flag.String("hostname", "tshello", "hostname to use on the tailnet")
)

func main() {
	flag.Parse()
	s := new(tsnet.Server)
	s.Hostname = *hostname
	defer s.Close()
	ln, err := s.Listen("tcp", *addr)
	if err != nil {
		log.Fatal(err)
	}
	defer ln.Close()

	lc, err := s.LocalClient()
	if err != nil {
		log.Fatal(err)
	}

	log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		fmt.Fprintf(w, "<html><body><h1>Hello, tailnet!</h1>\n")
		fmt.Fprintf(w, "<p>You are <b>%s</b> from <b>%s</b> (%s)</p>",
			html.EscapeString(who.UserProfile.LoginName),
			html.EscapeString(firstLabel(who.Node.ComputedName)),
			r.RemoteAddr)
	})))
}

func firstLabel(s string) string {
	s, _, _ = strings.Cut(s, ".")
	return s
}

Start the program with go run:

go run .

The program will print an authentication link. Open it in your favorite web browser and add it to your tailnet like any other machine. Open another terminal window and try to ping it:

$ ping tshello -c 2
PING tshello (100.105.183.159) 56(84) bytes of data.
64 bytes from tshello.shark-harmonic.ts.net (100.105.183.159): icmp_seq=1 ttl=64 time=25.0 ms
64 bytes from tshello.shark-harmonic.ts.net (100.105.183.159): icmp_seq=2 ttl=64 time=1.12 ms

Then connect to it using curl:

$ curl http://tshello
<html><body><h1>Hello, world!</h1>
<p>You are <b>tagged-devices</b> from <b>pneuma</b> (100.78.40.86:49214)</p>

From here you can do anything you want with the Go standard library HTTP stack, or anything that is compatible with it (such as Gin/Gonic or Gorilla/mux). If you want to make outgoing HTTP connections to resources on your tailnet, use the HTTP client that the tsnet.Server exposes:

httpCli := srv.HTTPClient()

You can use this like any other net/http#Client:

resp, err := httpCli.Get("http://tshello")
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

_, err = io.Copy(os.Stdout, resp.Body)
if err != nil {
	log.Fatal(err)
}

API overview

tsnet is a very small package with only a few moving parts. In the interest of brevity, only the most relevant parts will be covered here.

tsnet.Server

The most important concept is the tsnet.Server. It contains all of the state for your program’s connection to Tailscale.

A zero value tsnet.Server is ready to use:

srv := new(tsnet.Server)
if err := srv.Start(); err != nil {
	log.Fatalf("can't start tsnet server: %v", err)
}
defer s.Close()

You can change settings for a tsnet.Server instance up until you run any methods on it. The settings you can change include the following:

Server.Hostname

This setting lets you control the host name of your program on your tailnet. By default, this will be the name of your program, such as foo for a program stored at /usr/local/bin/foo). You can also override this by setting the Hostname field:

srv := new(tsnet.Server)
srv.Hostname = "kirito"
Server.Dir

This setting lets you control the directory that the tsnet.Server stores data in persistently. By default, tsnet will store data in your user configuration directory based on the name of the binary. Note that this folder must already exist or tsnet calls will fail.

Here is how to override this to store data in /data/tsnet:

dir := filepath.Join("/data", "tsnet")

if err := os.MkdirAll(dir, 0700); err != nil {
	log.Fatal(err)
}

srv := new(tsnet.Server)
srv.Dir = dir

You can have as many tsnet.Server instances as you want per OS process, but you will need to change the state directory if this is the case:

baseDir := "/data"
var servers []*tsnet.Server
for _, hostname := range []string{"ichika", "nino", "miku", "yotsuba", "itsuki"} {
	os.MkdirAll(filepath.Join(baseDir, hostname), 0700)
	srv := &tsnet.Server{
		Hostname: hostname,
		AuthKey: os.Getenv("TS_AUTHKEY"),
		Ephemeral: true,
		Dir: filepath.Join(baseDir, hostname),
	}
	if err := srv.Start(); err != nil {
		log.Fatalf("can't start tsnet server: %v", err)
	}
	servers = append(servers, srv)
}
Server.Ephemeral

This setting lets you control whether the node should be registered as an ephemeral node. Ephemeral nodes are automatically cleaned up after they disconnect from the control plane. This is useful when using tsnet in serverless environments or when facts and circumstances forbid you from using persistent state.

srv := new(tsnet.Server)
srv.Ephemeral = true
Server.AuthKey

This setting lets you set an authkey so that your program will automatically authenticate with the Tailscale control plane. By default it pulls from the environment variable TS_AUTHKEY, but you can set your own logic like this:

tsAuthKey = flag.String("ts-authkey", "", "Tailscale authkey")

flag.Parse()

srv := new(tsnet.Server)
srv.AuthKey = *tsAuthkey

This will set the authkey to the value of the flag --ts-authkey.

Server.Logger

This setting lets you override the logging logic for each tsnet instance. By default this will output everything to the log.Printf function, which can get spammy. To disable all tsnet logs, you can use a value like this:

srv := &tsnet.Server{
	Logger: func(string, ...any) { },
}

This should be guarded behind a command line flag in case you need these logs for debugging issues:

tsnetVerbose := flag.Bool("tsnet-verbose", false, "if set, verbosely log tsnet information")
hostname := flag.String("tsnet-hostname", "hikari", "hostname to use on the tailnet")

srv := &tsnet.Server{
	Hostname: *hostname,
	Logger: func(string, ...any) { },
}

if *tsnetVerbose {
	srv.Logger = log.New(os.Stderr, fmt.Sprintf("[tsnet:%s] ", *hostname), log.LstdFlags).Printf
}

This will prefix all tsnet loglines with the prefix [tsnet:<hostname>], which can be useful when trying to debug issues.

Server.Start

Once you are done changing your settings, you can start the connection to your tailnet using the Start method:

srv := new(tsnet.Server)

if err := srv.Start(); err != nil {
	log.Fatal(err)
}
defer srv.Close()
This method is implicitly called by most of the other tsnet calls if it has not been called already.

Be sure to close the server instance at some point. It will stay open until either the OS process ends or the server is explicitly closed:

defer srv.Close()
Server.Close

This call will release all resources associated with the tsnet server. Any calls to the server after this call will fail. All socket connections will be closed. All listeners will be closed. The node will not respond to ping messages. It is the same as turning off Tailscale on a machine on your tailnet.

It is suggested to create your tsnet instances in your main function and then use defer to clean them up:

srv := new(tsnet.Server)

if err := srv.Start(); err != nil {
	log.Fatal(err)
}
defer srv.Close()
Server.Listen

This call will create a network listener on your tailnet. It will return a net.Listener that will return connections from your tailnet.

srv := new(tsnet.Server)
srv.Hostname = "tadaima"

ln, err := srv.Listen("tcp", ":80")
if err != nil {
	log.Fatal(err)
}

log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hi there! Welcome to the tailnet!")
})))
This method will implicitly call the Start method if it has not been called already.
Server.ListenTLS

This call will create a TLS-wrapped network listener on your tailnet. It will return a net.Listener that wraps every incoming connection with TLS using the Let’s Encrypt support Tailscale offers. You can use this to create HTTPS services:

srv := new(tsnet.Server)
srv.Hostname = "aegis"

ln, err := srv.ListenTLS("tcp", ":443")
if err != nil {
	log.Fatal(err)
}

log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hi there! Welcome to the tailnet!")
})))
This method will implicitly call the Start method if it has not been called already.
Server.ListenFunnel

This call will create a TLS-wrapped network listener that accepts connections from both your tailnet and the public internet using Funnel. You can use this to expose your services to the public internet.

srv := new(tsnet.Server)
srv.Hostname = "ophion"

ln, err := srv.ListenFunnel("tcp", ":443")
if err != nil {
	log.Fatal(err)
}

log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hi there! Welcome to the tailnet!")
})))

You can create a Funnel-only listener using tsnet.FunnelOnly():

publicLn, err := srv.ListenFunnel("tcp", ":443", tsnet.FunnelOnly())
if err != nil {
	log.Fatal(err)
}

privateLn, err := srv.ListenTLS("tcp", ":443")
if err != nil {
	log.Fatal(err)
}

This can let you have different logic or endpoints exposed within your tailnet and to the general public.

This method will implicitly call the Start method if it has not been called already.
Server.Dial

This call will let you create outgoing connections to nodes on your tailnet. The resulting connections can be treated like any other network connection in Go.

srv := new(tsnet.Server)
srv.Hostname = "gaga"

conn, err := srv.Dial("tcp", "yourmachine:80")
if err != nil {
	log.Fatal(err)
}

If MagicDNS is enabled, you can use MagicDNS names instead of full DNS names (eg: yourmachine.tail-scale.ts.net).

This method will implicitly call the Start method if it has not been called already.
Server.HTTPClient

This is a convenience wrapper that lets you create a HTTP client preconfigured to make outgoing connections on your tailnet.

srv := new(tsnet.Server)
srv.Hostname = "looker"

cli := srv.HTTPClient()

resp, err := cli.Get("http://yourmachine/hello")
if err != nil {
	log.Fatal(err)
}
This method does not implicitly call the Start method if it has not been called already, but when you make any HTTP requests with that client, it will be called at time of use. In the above example, the Start method will be called when cli.Get("http://yourmachine/hello") is called.
Server.LocalClient

When you install Tailscale on a computer normally, you can make changes to its configuration using the tailscale command line tool. tsnet doesn’t offer the ability to use the tailscale command line tool to change its configuration, but you can use the LocalClient to make all of the same changes. The tailscale command line tool is built on the back of the LocalClient type.

One common way to use this is to look up identity information for incoming connections in a HTTP server:

s := new(tsnet.Server)
s.Hostname = "aran"
defer s.Close()

ln, err := s.Listen("tcp", ":80")
if err != nil {
	log.Fatal(err)
}
defer ln.Close()

lc, err := s.LocalClient()
if err != nil {
	log.Fatal(err)
}

log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	fmt.Fprintf(w, "<html><body><h1>Hello, world!</h1>\n")
	fmt.Fprintf(w, "<p>You are <b>%s</b> from <b>%s</b> (%s)</p>",
		html.EscapeString(who.UserProfile.LoginName),
		html.EscapeString(firstLabel(who.Node.ComputedName)),
		r.RemoteAddr)
		})))

The who value is of type apitype.WhoIsResponse. Depending on your jurisdiction’s definition of personally-identifying information, this may contain personally-identifying information.

This method will implicitly call the Start method if it has not been called already.