tailscale/tsnet
Brad Fitzpatrick 82cfea90ca
Some checks are pending
checklocks / checklocks (push) Waiting to run
CodeQL / Analyze (go) (push) Waiting to run
Dockerfile build / deploy (push) Waiting to run
test installer.sh / test (curl ca-certificates, kalilinux/kali-dev) (push) Waiting to run
test installer.sh / test (curl ca-certificates, kalilinux/kali-rolling) (push) Waiting to run
test installer.sh / test (curl, alpine:3.21) (push) Waiting to run
test installer.sh / test (curl, alpine:edge) (push) Waiting to run
test installer.sh / test (curl, alpine:latest) (push) Waiting to run
test installer.sh / test (curl, amazonlinux:latest) (push) Waiting to run
test installer.sh / test (curl, archlinux:latest) (push) Waiting to run
test installer.sh / test (curl, debian:oldstable-slim) (push) Waiting to run
test installer.sh / test (curl, debian:sid-slim) (push) Waiting to run
test installer.sh / test (curl, debian:stable-slim, 1.80.0) (push) Waiting to run
test installer.sh / test (curl, debian:testing-slim) (push) Waiting to run
test installer.sh / test (curl, elementary/docker:stable) (push) Waiting to run
test installer.sh / test (curl, elementary/docker:unstable) (push) Waiting to run
test installer.sh / test (curl, fedora:latest, 1.80.0) (push) Waiting to run
test installer.sh / test (curl, opensuse/leap:latest) (push) Waiting to run
test installer.sh / test (curl, opensuse/tumbleweed:latest) (push) Waiting to run
test installer.sh / test (curl, oraclelinux:8) (push) Waiting to run
test installer.sh / test (curl, oraclelinux:9) (push) Waiting to run
test installer.sh / test (curl, parrotsec/core:latest) (push) Waiting to run
test installer.sh / test (curl, rockylinux:8.7) (push) Waiting to run
test installer.sh / test (curl, rockylinux:9) (push) Waiting to run
test installer.sh / test (curl, ubuntu:20.04) (push) Waiting to run
test installer.sh / test (curl, ubuntu:22.04) (push) Waiting to run
test installer.sh / test (curl, ubuntu:24.04, 1.80.0) (push) Waiting to run
test installer.sh / test (wget, debian:oldstable-slim) (push) Waiting to run
test installer.sh / test (wget, debian:sid-slim) (push) Waiting to run
test installer.sh / notify-slack (push) Blocked by required conditions
natlab-basic / EasyEasy (push) Waiting to run
CI / gomod-cache (push) Waiting to run
CI / race-root-integration (1/4) (push) Blocked by required conditions
CI / race-root-integration (2/4) (push) Blocked by required conditions
CI / race-root-integration (3/4) (push) Blocked by required conditions
CI / race-root-integration (4/4) (push) Blocked by required conditions
CI / test (-race, amd64, 1/3) (push) Blocked by required conditions
CI / test (-race, amd64, 2/3) (push) Blocked by required conditions
CI / test (-race, amd64, 3/3) (push) Blocked by required conditions
CI / test (386) (push) Blocked by required conditions
CI / test (amd64) (push) Blocked by required conditions
CI / Windows (${{ matrix.name || matrix.shard}}) (win-bench, benchmarks) (push) Blocked by required conditions
CI / Windows (${{ matrix.name || matrix.shard}}) (win-shard-1-2, 1/2) (push) Blocked by required conditions
CI / Windows (${{ matrix.name || matrix.shard}}) (win-shard-2-2, 2/2) (push) Blocked by required conditions
CI / macos (push) Blocked by required conditions
CI / privileged (push) Blocked by required conditions
CI / cross (386, linux) (push) Blocked by required conditions
CI / cross (amd64, darwin) (push) Blocked by required conditions
CI / cross (amd64, freebsd) (push) Blocked by required conditions
CI / cross (amd64, openbsd) (push) Blocked by required conditions
CI / cross (amd64, windows) (push) Blocked by required conditions
CI / cross (arm, 5, linux) (push) Blocked by required conditions
CI / cross (arm, 7, linux) (push) Blocked by required conditions
CI / cross (arm64, darwin) (push) Blocked by required conditions
CI / cross (arm64, linux) (push) Blocked by required conditions
CI / cross (arm64, windows) (push) Blocked by required conditions
CI / cross (loong64, linux) (push) Blocked by required conditions
CI / ios (push) Blocked by required conditions
CI / crossmin (amd64, illumos) (push) Blocked by required conditions
CI / crossmin (amd64, plan9) (push) Blocked by required conditions
CI / crossmin (amd64, solaris) (push) Blocked by required conditions
CI / crossmin (ppc64, aix) (push) Blocked by required conditions
CI / android (push) Blocked by required conditions
CI / wasm (push) Blocked by required conditions
CI / tailscale_go (push) Blocked by required conditions
CI / fuzz (push) Waiting to run
CI / depaware (push) Blocked by required conditions
CI / go_generate (push) Blocked by required conditions
CI / make_tidy (push) Blocked by required conditions
CI / licenses (push) Blocked by required conditions
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=darwin, arm64, darwin, macOS) (push) Blocked by required conditions
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=linux, amd64, linux, Linux) (push) Blocked by required conditions
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=windows, amd64, windows, Windows) (push) Blocked by required conditions
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=1/4, amd64, linux, Portable (1/4)) (push) Blocked by required conditions
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=2/4, amd64, linux, Portable (2/4)) (push) Blocked by required conditions
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=3/4, amd64, linux, Portable (3/4)) (push) Blocked by required conditions
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=4/4, amd64, linux, Portable (4/4)) (push) Blocked by required conditions
CI / notify_slack (push) Blocked by required conditions
CI / merge_blocker (push) Blocked by required conditions
CI / check_mergeability_strict (push) Blocked by required conditions
CI / check_mergeability (push) Blocked by required conditions
tailscale.com/cmd/vet / vet (push) Waiting to run
all: fix JSON serialization under Go 1.27's finalized encoding/json/v2
Go 1.27 enables GOEXPERIMENT=jsonv2 by default: encoding/json is now
backed by the json/v2 machinery, and github.com/go-json-experiment/json
compiles as a thin alias of the standard library's encoding/json/v2.
Several tag options and behaviors we relied on did not make the cut for
the final Go 1.27 API, breaking tailscaled at runtime and four packages'
tests. This change adapts to the final API while keeping the wire format
byte-for-byte identical on all Go versions.

First, the `format` tag option was demoted to experimental. Its mere
presence in a struct tag now makes marshaling and unmarshaling fail at
runtime. tailcfg.SSHAction.SessionDuration had `format:nano` (added in
a2dc517d7 to pin the v1 representation), so on Go 1.27 any netmap
containing an SSH policy failed to decode, breaking every PollNetMap.
Remove the option here and in net/speedtest; time.Duration still
marshals as int64 nanoseconds under encoding/json on all Go versions
(Go 1.27's v1 mode sets FormatDurationAsNano by default), so old
clients and servers are unaffected. Add a regression test locking in
the exact wire format.

Consequently, invert the cmd/vet jsontags rule: it previously required
an explicit `format` tag on time.Duration fields, which is now exactly
wrong. It now rejects any `format` tag option, which would have caught
this bug in CI.

Second, the `inline` tag option was renamed to `embed`. The standard
library silently ignores `inline`, while the pinned go-json-experiment
module (used on Go 1.26) only knows `inline`. Specify both options in
types/prefs and logtail; each implementation ignores the option it does
not know, producing identical output. Drop `inline` once we require
Go 1.27.

Third, encoding/json (v1) now dispatches to MarshalJSONTo and
UnmarshalJSONFrom methods and its v1 options flow into nested
jsonv2.MarshalEncode calls. Types whose v1 methods deliberately
routed through jsonv2 for v2 semantics (types/opt.Value, the
types/prefs preference types) would silently change wire format
(e.g. nil slices becoming null). Pin jsonv2.DefaultOptionsV2 in
their jsonv2 methods so the representation is the same regardless
of the entry point.

Finally, json.Marshal costs one more allocation under Go 1.27,
tripping the types/logger.AsJSON alloc test. Switch its fmt.Formatter
to jsonv2.MarshalWrite with explicit v1 options, which writes directly
to the fmt.State: one allocation on both toolchains with unchanged
output. Depaware files pick up the go-json-experiment/json/v1 options
shim as a new dependency of types/logger.

With this change, go test ./... passes with both Go 1.26.5 and
go1.27rc2.

Updates #20220
Fixes #20528
Fixes #20254

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I694c7d57fd81e55a579c579e9be10032bca569d4
2026-07-19 09:58:50 -07:00
..
example tsnet: add opt-in SSH support (Server.ListenSSH) 2026-05-30 14:17:50 -07:00
depaware.txt all: fix JSON serialization under Go 1.27's finalized encoding/json/v2 2026-07-19 09:58:50 -07:00
example_tshello_test.go all: remove AUTHORS file and references to it 2026-01-23 15:49:45 -08:00
example_tsnet_listen_service_multiple_ports_test.go tsnet: make ListenService examples consistent with other tsnet examples 2026-01-26 14:59:18 -07:00
example_tsnet_test.go tsnet: make ListenService examples consistent with other tsnet examples 2026-01-26 14:59:18 -07:00
listenssh_test.go all: apply go fix 2026-07-10 17:39:16 -07:00
maybe_acme.go tsnet: link in feature/acme by default 2026-06-29 09:04:57 -07:00
packet_filter_test.go ipn/ipnlocal: consolidate test-only LocalBackend methods behind ForTest 2026-06-27 16:11:42 -07:00
README.md tsnet: make workload identity federation opt-in 2026-05-06 18:43:45 -07:00
tailnetlock_test.go ipn/ipnlocal, types/netmap, tsnet: filter unsigned peers on delta path 2026-06-23 08:12:36 -07:00
tsnet_test.go feature/captiveportal: move captive portal code out of ipnlocal, netcheck 2026-07-14 20:22:23 -04:00
tsnet.go wgengine,ipn/ipnlocal,tsnet,cmd/tailscaled: remove PeerForIP from the Engine interface 2026-07-13 15:15:16 -07:00

tsnet

Go Reference

Package tsnet embeds a Tailscale node directly into a Go program, allowing it to join a tailnet and accept or dial connections without running a separate tailscaled daemon or requiring any system-level configuration.

Overview

Normally, Tailscale runs as a background system service (tailscaled) that manages a virtual network interface for the whole machine. tsnet takes a different approach: it runs a fully self-contained Tailscale node inside your process using a userspace TCP/IP stack (gVisor). This means:

  • No root privileges required.
  • No system daemons to install or manage.
  • Multiple independent Tailscale nodes can run within a single binary.
  • The node's Tailscale identity and state are stored in a directory you control.

The core type is Server, which represents one embedded Tailscale node. Calling Server.Listen or Server.Dial routes traffic exclusively over the tailnet. The standard library's net.Listener and net.Conn interfaces are returned, so any existing Go HTTP server, gRPC server, or other net-based code works without modification.

Usage

import "tailscale.com/tsnet"

s := &tsnet.Server{
	Hostname: "my-service",
	AuthKey:  os.Getenv("TS_AUTHKEY"),
}
defer s.Close()

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

On first run, if no Server.AuthKey is provided and the node is not already enrolled, the server logs an authentication URL. Open it in a browser to add the node to your tailnet.

Authentication

A Server authenticates using, in order of precedence:

  1. Server.AuthKey.

  2. The TS_AUTHKEY environment variable.

  3. The TS_AUTH_KEY environment variable.

  4. An OAuth client secret (Server.ClientSecret or TS_CLIENT_SECRET), used to mint an auth key.

  5. Workload identity federation (Server.ClientID plus Server.IDToken or Server.Audience). Available only if the program imports the feature:

    import _ "tailscale.com/feature/identityfederation"

    The feature is not linked by default to keep the AWS SDK and other cloud-provider dependencies out of programs that don't use workload identity federation.

  6. An interactive login URL printed to Server.UserLogf.

If the node is already enrolled (state found in Server.Store), the auth key is ignored unless TSNET_FORCE_LOGIN=1 is set.

Identifying callers

Use the WhoIs method on the client returned by Server.LocalClient to identify who is making a request:

lc, _ := srv.LocalClient()
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, "Hello, %s!", who.UserProfile.LoginName)
}))

Tailscale Funnel

Server.ListenFunnel exposes your service on the public internet. Tailscale Funnel currently supports TCP on ports 443, 8443, and 10000. HTTPS must be enabled in the Tailscale admin console.

ln, err := srv.ListenFunnel("tcp", ":443")
// ln is a TLS listener; connections can come from anywhere on the
// internet as well as from your tailnet.

// To restrict to public traffic only:
ln, err = srv.ListenFunnel("tcp", ":443", tsnet.FunnelOnly())

Tailscale Services

Server.ListenService advertises the node as a host for a named Tailscale Service. The node must use a tag-based identity. To advertise multiple ports, call ListenService once per port.

srv.AdvertiseTags = []string{"tag:myservice"}

ln, err := srv.ListenService("svc:my-service", tsnet.ServiceModeHTTP{
	HTTPS: true,
	Port:  443,
})
log.Printf("Listening on https://%s", ln.FQDN)

Running multiple nodes in one process

Each Server instance is an independent node. Give each a unique Server.Dir and Server.Hostname:

for _, name := range []string{"frontend", "backend"} {
	srv := &tsnet.Server{
		Hostname:  name,
		Dir:       filepath.Join(baseDir, name),
		AuthKey:   os.Getenv("TS_AUTHKEY"),
		Ephemeral: true,
	}
	srv.Start()
}