tsnet
Typically, every IP address in 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 in 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 in 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 in 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 in 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.Logf
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{
Logf: 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 in the tailnet")
srv := &tsnet.Server{
Hostname: *hostname,
Logf: func(string, ...any) { },
}
if *tsnetVerbose {
srv.Logf = 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 in 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 in 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 in 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 in 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(r.Context(), "tcp", "yourmachine:80")
if err != nil {
log.Fatal(err)
}
If MagicDNS is enabled, you can use MagicDNS names instead of full DNS names. For example, 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 in 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.