tailcat

package module
v0.0.0-...-c04c5af Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: BSD-3-Clause Imports: 65 Imported by: 0

README

Tailcat

"Tailscale without Tailscale, by Tailscale"

Tailcat

Tailcat is a remix of Tailscale open source pieces to act like netcat, but over Tailscale's data plane, without Tailscale's control plane. Tailscale's data plane (magicsock, internally) gives you point-to-point WireGuard®-encrypted tunnels between two machines with DERP as the NAT-hole-punching communication side channel and the ultimate relay-of-last-resort if NAT traversal fails. Instead of using the Tailscale control plane, all tailcat connection metadata is exchanged out of band, however you want.

The tailcat CLI (in cmd/tailcat) is built on the tailcat Go library (importable as github.com/tailscale/tailcat).

Whether you use tailcat as a CLI tool or library, one side runs a tailcat server (listener) and gets back a short connection token. The other side passes that token to tailcat's client side to connect. All traffic between the two is encrypted end-to-end with WireGuard. The initial connection bootstraps through a DERP server (see below), and then magicsock performs NAT traversal to upgrade to a direct peer-to-peer UDP connection when possible (usually!).

You don't need a Tailscale account, root/admin access on the machine (it doesn't alter your machine's routing tables, DNS, etc.). It's just a userspace library and CLI tool.

And it's all open source.

You can use our free rate-limited DERP relays (the default DERP map is https://tailcat.dev/derpmap.json) or you can run your own.

There's also an experimental in-browser web demo (tailcat compiled to WebAssembly) at https://tailscale.github.io/tailcat/ that can send and receive files or text, interoperating with the CLI. Browser traffic is relayed over DERP only, with no direct connections until WebRTC support (#4).

Install

$ go install github.com/tailscale/tailcat/cmd/tailcat@latest

Or with Nix flakes, run it directly or install it:

$ nix run github:tailscale/tailcat
$ nix profile install github:tailscale/tailcat

Usage

Pipe stdin/stdout between two machines

Server starts, printing out its ephemeral address:

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
(hangs, waiting...)

And then the client can:

$ echo hello | tailcat tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
$ 

Then the server unblocks:

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
hello
$
Expose local ports through the tunnel

Or you can serve a local TCP port, forwarded to localhost:

$ tailcat --serve=8080,8443 # or --serve=all
# 🐈 Server listening with new address: tcXXXXXXXXX

And then the client:

$ tailcat tcXXXXXXXXX 8080
GET / HTTP/1.1
Host: foo

HTTP/1.1 200 OK
....
Auth-free SSH server

On Linux and macOS, you can run an SSH server too with no auth. (If you want auth, you can just tailcat --serve=22 and proxy to your system SSH server)

$ tailcat --serve=no-auth-ssh
# 🐈 Server listening with new address: tcXXXXXXXXX

And on the client side:

$ tailcat ssh tcXXXXXXXXX
$ tailcat ssh tcXXXXXXXXX ls -la
Misc commands

Ping to test connectivity; each pong reports whether it arrived via a DERP relay or a direct path. --until-direct keeps pinging (up to --timeout, default 10s) until a direct path works, exiting non-zero if one doesn't:

$ tailcat ping --until-direct <token>
pong in 42.1ms via DERP(sfo)
pong in 1.2ms via 203.0.113.7:41641

Run a command through a SOCKS5 proxy routed over the tunnel:

$ tailcat socks <token> curl http://server.tailcat:8081/

Tokens also work directly as URL hostnames: the SOCKS proxy recognizes and dials them, so the token argument is optional. (Tokens are case-sensitive; this works with curl and most CLI tools, but not with browsers, which lowercase hostnames.)

$ tailcat socks curl http://<token>:8081/

Act as an exit node so the client can reach the server's network:

$ tailcat --serve=exit-node

Parse a connection token and print its contents (the server's WireGuard public key and DERP info) as JSON, without connecting to anything:

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
{
    "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
    "RegionID": 302
}

Resolve a short token (which references a DERP region by ID, requiring clients to fetch the DERP map) into a longer self-contained one with the DERP server info embedded, letting clients connect more quickly:

$ tailcat resolve tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA

Parsing that resolved token shows the embedded DERP info:

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA
{
    "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
    "Region": [
        {
            "Nodes": [
                {
                    "HostName": "tc302a.ipn.dev",
                    "IPv4": "208.111.39.38",
                    "IPv6": "2607:f740:0:3f::720"
                }
            ]
        }
    ]
}

A server can print the long self-contained form directly with the --full-address flag.

Key Management

A server's address (connection token) is derived from its WireGuard key, so the key you use determines who can reach you:

  • Ephemeral keys (the default): each server run generates a fresh key in memory and prints an address nobody has ever seen. When the process exits, the key is discarded and the address is dead forever. This is the safe default: sharing that address only ever refers to that one run.

  • Saved keys: tailcat genkey generates a key saved to disk so the address stays stable across restarts. The flip side: anyone you've ever shared that address with can connect to any future server using that key, unless you restrict clients with --allow (see tailcat genkey --client).

The CLI says at startup which kind it's using, so you know whether you're starting a fresh single-use server or re-listening on an address you may have shared in the past.

$ tailcat genkey --region=nyc
# prints the token; key saved to ~/.config/tailcat/keys/default.private.json

# later; the key named "default" is used automatically once it exists:
$ tailcat --serve=8080
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

# ... unless you force a one-off ephemeral key:
$ tailcat --serve=8080 --key=new
# 🐈 Server listening with new address: tcXXXXXXXXX

That is, default is a magic key name: once it exists, plain tailcat silently uses it instead of generating an ephemeral key, and the startup line above is what tells you which happened. Use --key=new to get an ephemeral key anyway, --key=<name> to use a different saved key, or tailcat genkey --delete --key=default to remove the saved default key. tailcat genkey --list lists your saved keys.

Tokens can also be published as DNS TXT records and looked up by name; a DNS name works anywhere the CLI takes a token:

# If example.com has a TXT record "tailcat=tc..."
$ tailcat example.com 8080
$ tailcat ssh example.com
$ tailcat ping example.com

Examples

Protected SSH server over DNS

Who needs port forwarding or port knocking? This runs an SSH server reachable from anywhere by name, with no open inbound ports on the server, where WireGuard authenticates the client before the SSH server ever sees a packet.

On the client machine, generate a client identity keypair. It prints the public key, which is all the server needs to know:

client$ tailcat genkey --client
# wrote file to ~/.config/tailcat/keys/client-default.private.json
nodekey:cfb6bfa77a0654d7450947fd6acef17d2cd848da1d30b2540b13dac272ddfd16

On the server, generate a server keypair pinned to its nearest DERP region (see why below), then serve SSH to only that client:

server$ tailcat genkey --fixed-region
# wrote file to ~/.config/tailcat/keys/default.private.json
tcXXXXXXXXX

server$ tailcat --serve=22 --allow=nodekey:cfb6bf...ddfd16
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

Publish the token in DNS as a TXT record:

my-server.example.com. 300 IN TXT "tailcat=tcXXXXXXXXX"

And then the client side is just:

client$ tailcat ssh my-server.example.com

Client modes automatically use the saved client-default key when it exists, so no extra flags are needed to present the allowed identity. Anyone else's handshake is silently ignored: they can't reach the SSH server, or even learn that one is running.

Why --fixed-region: it discovers the nearest DERP region once, at genkey time, and bakes its ID into both the printed token and the saved key file, so server restarts bind to the same region (keeping the published token valid) without re-probing. Plain tailcat genkey defaults to --region=auto, which instead bakes in "pick at startup": fine for one-off use, but a token published in DNS should name a fixed region so clients and future server restarts all rendezvous in the same place. (--region=<name> pins an explicit one instead; --region=list shows the choices.)

TODO: make the client more robust here if the DERP map changes over time: https://github.com/tailscale/tailcat/issues/7

Bring your own DERP relay

Nothing requires Tailscale's relays: run your own DERP server (it needs a hostname with a TLS certificate, which derper can get itself via Let's Encrypt), then generate a server key that uses it by passing its hostname (or several, comma-separated) as the region:

server$ tailcat genkey --region=derp.example.com
tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ

server$ tailcat --serve=22

The token embeds your relay's hostname:

$ tailcat parse tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ
{
    "ServerPublic": "nodekey:8022c28ea8f52ec7a0a51b644ce00fef3aae150731a01c61a3abd3ac26e14a49",
    "Region": [
        {
            "Nodes": [
                {
                    "HostName": "derp.example.com"
                }
            ]
        }
    ]
}

so clients need no extra flags and never contact Tailscale's DERP map server or relays, and the only rate limits are yours. Alternatively, if you run a whole fleet of relays, serve your own DERP map JSON and point both sides at it with --derpmap-url.

Go library

A minimal server that answers any TCP port through the tunnel and prints its token. The zero value Server picks defaults for anything unset: a fresh ephemeral key, the nearest region of the default DERP map, and log.Printf logging (set Logf to logger.Discard for quiet):

package main

import (
	"fmt"
	"log"
	"net"

	"github.com/tailscale/tailcat"
)

func main() {
	s := &tailcat.Server{
		OnTCP: func(port uint16) func(net.Conn) {
			return func(c net.Conn) {
				fmt.Fprintf(c, "hello from port %v\n", port)
				c.Close()
			}
		},
	}
	if err := s.Start(); err != nil {
		log.Fatal(err)
	}
	fmt.Println(s.ConnBlob())
	select {}
}

And a minimal client that dials it, given that token as its argument. Like Server, the Client zero value works with just its Server token field set (tailcat.NewClient is shorthand for exactly that), and the tunnel is established lazily by the first dial:

package main

import (
	"context"
	"io"
	"log"
	"os"

	"github.com/tailscale/tailcat"
)

func main() {
	cl := tailcat.NewClient(tailcat.ConnBlob(os.Args[1]))
	defer cl.Close()
	c, err := cl.DialTCPPort(context.Background(), 80)
	if err != nil {
		log.Fatal(err)
	}
	io.Copy(os.Stdout, c)
}
$ ./client tcomFwWCAWf933BLELdzd3RkHiOufJ...
hello from port 80

How it works

Connection tokens

A Tailcat server is identified by a connection token (called a ConnBlob internally). It looks like tcXYZ... and is a "tc" prefix followed by base64-encoded CBOR containing:

  • The server's WireGuard public key (Curve25519, 32 bytes)
  • DERP info. Either:
    1. a small integer referencing one of the default Tailscale-run tailcat servers), or
    2. full DERP server metadata, to either use a custom DERP server, or to avoid the client needing a potential round-trip to fetch the latest DERP map (the server's --full-address flag and the tailcat resolve subcommand produce this form)

A typical token with just an integer region ID is around 50 bytes. With embedded DERP node details it's longer but self-contained.

Network stack

Tailcat reuses Tailscale's client networking components but without the control plane.

  • WireGuard -- a userspace WireGuard implementation for encrypting all tunnel traffic. It doesn't use a kernel TUN/TAP device (nor does it configure any networking routes or DNS settings), so root isn't required.
  • magicsock -- Tailscale's transport layer that multiplexes traffic over direct UDP and DERP relays. It handles STUN-based endpoint discovery and UDP hole-punching for NAT traversal.
  • Netstack (gVisor) -- a userspace TCP/IP stack that terminates TCP connections inside the process. This is what lets Tailcat accept inbound connections and dial outbound ones without any OS network configuration.
  • DERP relay -- Tailscale's encrypted relay protocol, used as a rendezvous channel and as a fallback data path when direct connectivity isn't possible.
Connection flow
  1. Server starts. It generates (or loads) a WireGuard keypair, connects to a DERP relay, and prints its connection token to stderr. It then waits for clients.

  2. Client parses the token to learn the server's public key and DERP region. It generates its own ephemeral keypair and connects to the same DERP relay.

  3. Discovery handshake. The client sends a "Meow" ping message to the server through the DERP relay. This message carries the client's node public key. The server receives it, adds the client to its WireGuard peer list and network map, reconfigures the WireGuard engine, and replies with a "Meowed" acknowledgment.

  4. WireGuard tunnel. With both sides configured as WireGuard peers, the standard WireGuard handshake proceeds (routed through DERP initially). Once complete, the tunnel is up and encrypted traffic can flow.

  5. NAT traversal. In parallel, each side advertises its UDP endpoints (public IP:port learned via STUN, plus local interface addresses) to the other in disco call-me-maybe messages over DERP, re-advertising whenever they change. Both sides then run Tailscale's disco protocol and attempt UDP hole-punching. If successful, traffic upgrades from the DERP relay to a direct peer-to-peer path. If hole-punching fails, DERP continues as a fallback and the connection still works, just with rate-limited throughput if you're using our public hosted DERP relays.

  6. Data transfer. The client dials a TCP port on the server through the tunnel. gVisor's TCP/IP stack on both sides handles connection setup. On the server, the incoming connection is dispatched to a handler based on the port: forwarding to localhost, piping to stdout, running an SSH session, etc.

Addressing

Each peer currently derives a deterministic IPv6 address from its WireGuard public key, but that's an implementation detail not exposed to end users and might change. (e.g. we might remove those bytes from the IP headers entirely and recover that redundant MTU)

Stability

Tailcat is free to use, but it comes with no API or CLI stability promises: the Go API, the CLI flags and output, and the wire format may all change. The public rate-limited Tailcat DERP relays have no uptime SLAs or throughput targets, and we may revoke access to them at any time, for any reason. Everything is provided best effort, without a contractual relationship (e.g. dedicated DERP relays and/or support) saying otherwise.

Contact Sales?

If you don't want to run and support things on your own, or want any help, contact sales and we can exchange money for goods and services.

History

Tailcat began life in September 2023 as "derpcat", written on a long flight while catching up on bad movies: the first sketch was commit 9e4d925cc ("cmd/dc: start of derpcat tool"), and it first worked in commit 911915fbb ("derpcat: it's alive!", whose commit message notes "UA 605 PDX-ORD en route to Ireland. yay not buying the wifi."). Back then it lived inside a fork of the tailscale.com repo and it bitrot several times as the Tailscale internals moved on without it. We've since brought it back to life and refactored it to be a regular Go module client of the tailscale.com repo instead of a fork of it.

It was open sourced August 2026 at the TailscaleUp conference.

Documentation

Overview

Package tailcat implements a control-plane-free network pipe built on Tailscale's data plane which provides encryption (WireGuard) and NAT traversal. This is the library behind the "tailcat" CLI command (cmd/tailcat).

A Server listens for incoming clients via a DERP relay. Clients discover the server through a compact ConnBlob (connection blob) that encodes the server's public key and DERP region. DERP is used only for the initial bootstrap; once both sides learn each other's endpoints, Tailscale's magicsock layer upgrades to a direct peer-to-peer UDP path whenever possible, just like the normal Tailscale data plane. DERP remains available as a fallback relay if a direct path cannot be established.

Once connected, the two sides exchange arbitrary TCP traffic over the WireGuard tunnel with no Tailscale account or coordination server required. Optionally, the server can run an auth-free SSH server on port 22, providing remote shell access over the tunnel.

The name "tailcat" is a nod to the classic "netcat" tool, but with Tailscale's WireGuard encryption + NAT traversal.

Using Tailscale's DERP servers is not required; you can run your own DERP server and provide its region information in the ConnBlob.

This package has no API stability promises: types, functions, and the wire format may all change. See the Stability section of the README (https://github.com/tailscale/tailcat/#readme) for details, including the terms of Tailscale's public DERP relays.

Index

Constants

View Source
const DefaultDERPMapURL = "https://tailcat.dev/derpmap.json"

DefaultDERPMapURL is the URL of the JSON-encoded tailcfg.DERPMap that ConnInfo.Expand fetches when no alternate DERP map source is specified via options.

Variables

View Source
var ExpandForServer expandForServer

ExpandForServer is an option for ConnInfo.Expand that marks the DERP map fetch as being on behalf of a tailcat server (which will listen on the chosen region) rather than a client. It is sent as a hint header to the DERP map server.

View Source
var README string

README is the tailcat README.md, embedded so the CLI can print it with its --readme flag. That lets people (and AI agents) with only the binary learn how to use it without web access.

View Source
var Verbose = false

Verbose controls whether extra diagnostic logging is emitted during DERP region auto-detection (netcheck).

Functions

func EncodeMeowPing

func EncodeMeowPing(nodeKey key.NodePublic, discoKey key.DiscoPublic) []byte

EncodeMeowPing encodes a meow ping packet containing the sender's node public key and disco public key.

func EncodeMeowed

func EncodeMeowed() []byte

EncodeMeowed encodes a meowed (acknowledgment) packet.

func FetchDERPMap

func FetchDERPMap(ctx context.Context, opts ...any) (*tailcfg.DERPMap, error)

FetchDERPMap fetches and decodes the JSON DERP map. The opts may contain any of the following types:

func IsMeowPacket

func IsMeowPacket(pkt []byte) bool

IsMeowPacket reports whether pkt starts with the meow magic prefix.

func IsMeowedPacket

func IsMeowedPacket(pkt []byte) bool

IsMeowedPacket reports whether pkt is a meowed (acknowledgment) packet.

func ParseConnBlobRaw

func ParseConnBlobRaw(cb ConnBlob) (any, error)

ParseConnBlobRaw decodes cb into its wire form, without restoring the implicit fields that ParseConnBlob synthesizes (region and node IDs, region codes, node names). The returned value is only meant for JSON display, as by the CLI's "parse" subcommand: its JSON form shows just the fields the encoded blob actually carries.

func ParseMeowPing

func ParseMeowPing(pkt []byte) (nodeKey key.NodePublic, discoKey key.DiscoPublic, ok bool)

ParseMeowPing parses a meow ping packet, returning the sender's node public key and disco public key. The pkt must have already been verified with IsMeowPacket.

func PickBestRegion

func PickBestRegion(ctx context.Context, dm *tailcfg.DERPMap) (regionID int, err error)

PickBestRegion runs a netcheck over the DERP regions in dm and returns the region ID with the lowest latency. It returns 0 (and a nil error) if the netcheck report contained no usable region latencies.

func ProxyConns

func ProxyConns(a, b net.Conn)

ProxyConns copies data between a and b in both directions until both sides have finished, then closes both connections.

When one direction's copy finishes (its source reached EOF), the destination gets a write shutdown via CloseWrite if supported, propagating the TCP half-close instead of tearing down the whole connection. This lets protocols where one side signals end-of-request with a FIN and then reads the response (netcat style) work through the proxy.

func SupportsSSHServer

func SupportsSSHServer() bool

SupportsSSHServer reports whether the platform supports running the built-in auth-free SSH server.

Types

type Client

type Client struct {
	// Server is the token identifying the server to connect to.
	// It is required and must be set before the client's first use.
	Server ConnBlob

	// Key is the client's node identity, which servers can allowlist.
	// If zero, a new ephemeral key is generated at first use.
	// If set, it must be set before the client's first use.
	Key key.NodePrivate

	// Logf is the logger used for debug messages. If nil, log.Printf
	// is used. If set, it must be set before the client's first use.
	Logf logger.Logf

	// DERPMapURL, if non-empty, is an alternate URL to fetch the DERP
	// map from when the token doesn't embed the relay details.
	// If empty, [DefaultDERPMapURL] is used. If set, it must be set
	// before the client's first use.
	DERPMapURL string

	// DERPMapCache, if non-nil, caches fetched DERP maps. If nil, a
	// process-wide in-memory cache is used. If set, it must be set
	// before the client's first use.
	DERPMapCache DERPMapCache
	// contains filtered or unexported fields
}

Client connects to a Server over a WireGuard tunnel relayed through DERP. Populate Server (the only required field, or use the NewClient shorthand), then just dial: Client.Dial, Client.DialTCPPort, and Client.DialTCP lazily establish the tunnel on first use, picking defaults for any unset fields. Client.Ping does the same and is useful to test connectivity first or to measure the relay round-trip time.

func NewClient

func NewClient(server ConnBlob) *Client

NewClient returns a client that will connect to the server identified by the given token. It is shorthand for &Client{Server: server}; see Client for the optional fields that may also be set before the client's first use.

func (*Client) Close

func (c *Client) Close() error

Close shuts down the client, closing the WireGuard engine and DERP connections.

func (*Client) Dial

func (c *Client) Dial(ctx context.Context, network, addr string) (net.Conn, error)

Dial opens a connection to the given network/address through the server's WireGuard tunnel. The address is resolved relative to the server.

On a Client's first use (any Dial method or Client.Ping), the client lazily brings up its network stack, resolving the server's DERP region over the network if the ConnBlob didn't embed it, and registers itself with the server.

func (*Client) DialTCP

func (c *Client) DialTCP(ctx context.Context, ap netip.AddrPort) (net.Conn, error)

DialTCP opens a TCP connection to an arbitrary IP:port through the server, which must be configured as an exit node (see Server.OnTCPForward). IPv4 addresses are mapped into the NAT64 prefix (64:ff9b::/96) for transport over the IPv6-only WireGuard tunnel. See Client.Dial for the lazy startup behavior.

func (*Client) DialTCPPort

func (c *Client) DialTCPPort(ctx context.Context, port uint16) (net.Conn, error)

DialTCPPort opens a TCP connection to the given port on the server. See Client.Dial for the lazy startup behavior.

func (*Client) DiscoPing

func (c *Client) DiscoPing(ctx context.Context) (*ipnstate.PingResult, error)

DiscoPing sends a disco ping to the server and reports how the pong came back: the result's Endpoint field is set if it arrived over a direct path, else DERPRegionID (and DERPRegionCode) say which relay carried it. Unlike Client.Ping, which always measures the DERP path, a disco ping also actively triggers direct path discovery, so pinging repeatedly upgrades the connection when NAT traversal is possible. It starts the client and registers with the server first if needed.

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) (PingResult, error)

Ping starts the client if needed (see Client.Dial for the lazy startup behavior), sends a meow ping to the server via DERP, and waits for the meowed acknowledgment, which also tells the server to add us as a WireGuard peer. Calling it is optional (Dial does it implicitly) but useful to test connectivity or measure the relay round-trip time. The internal timeout is 10 seconds regardless of ctx.

func (*Client) PublicKey

func (c *Client) PublicKey() key.NodePublic

PublicKey returns the client's node public key, generating the key first if the Key field is zero and the client hasn't yet been used.

type ConnBlob

type ConnBlob string

ConnBlob is a compact, URL-safe string that a server gives to clients so they can connect. It is the "tc"-prefixed base64url encoding of CBOR-encoded ConnInfo. A typical ConnBlob looks like "tcomFwWC…".

func (ConnBlob) Resolve

func (b ConnBlob) Resolve(ctx context.Context, opts ...any) (ConnBlob, error)

Resolve returns a self-contained equivalent of b with the DERP relay's details embedded, so that later use of the blob requires no network access to fetch the DERP map. It is to a ConnBlob roughly what a DNS lookup is to a hostname: the resolved form is longer, works offline, and pins the relay details as they were at resolution time. If b already embeds its relay details, it is returned unchanged. The opts are as documented on ConnInfo.Expand.

type ConnInfo

type ConnInfo struct {
	ServerPublic NodePublic // a key.NodePublic

	// Region, if non-empty, lists the regions of a DERPMap.
	// Either Region or RegionID must be set. If Region is set
	// the client can avoid doing a lookup to discover the DERP map
	// but the ConnBlob is longer.
	//
	// As of 2023-09-22, a maximum of 1 region may be provided.
	// In the future, a server might advertise its presence in
	// multiple DERP regions and clients could try them all.
	Region []*tailcfg.DERPRegion `json:",omitempty"`

	// RegionID lists the number of one of Tailscale's provided
	// DERP servers. If set, Region may be omitted and the ConnBlob
	// is shorter, at the cost of the client needing to fetch
	// the derpmap from tailscale.com once at startup.
	// If -1 (for use when saving a keypair to disk for reuse later), a region
	// is selected automatically at startup based on latency.
	RegionID int `json:",omitempty"`
}

ConnInfo describes how to reach a server: its public key and which DERP relay region to use. It is serialized into a ConnBlob for exchange, via the wire types in wire.go.

func ParseConnBlob

func ParseConnBlob(cb ConnBlob) (ConnInfo, error)

ParseConnBlob decodes a ConnBlob back into a ConnInfo, restoring fields that were stripped during encoding (RegionID, RegionCode, node names).

func (*ConnInfo) ConnBlob

func (ci *ConnInfo) ConnBlob() ConnBlob

ConnBlob serializes the ConnInfo into a compact ConnBlob string. It is encoded via the wire types (see wire.go), which drop the DERP region fields tailcat doesn't use. Some other fields (RegionID, RegionCode, RegionName, node names that are redundant next to an explicit HostName) are zeroed before encoding to reduce size; ParseConnBlob restores them.

func (*ConnInfo) Expand

func (ci *ConnInfo) Expand(ctx context.Context, opts ...any) error

Expand populates ci.Region from a DERP map if only ci.RegionID was set. If ci.Region is already populated, Expand is a no-op. When RegionID is -1, the best region is selected automatically via netcheck latency probes.

The opts may contain any of the following types:

  • DERPMapURL: fetch the DERP map from an alternate URL instead of DefaultDERPMapURL.
  • *tailcfg.DERPMap: expand from the provided DERP map instead of fetching one over the network.
  • ExpandForServer: mark the DERP map fetch as being on behalf of a tailcat server rather than a client.
  • DERPMapCache: cache fetched DERP maps (defaults to a process-wide in-memory cache).

type DERPMapCache

type DERPMapCache interface {
	// Get returns the previously stored DERP map response for url:
	// its raw JSON, the server's ETag (or ""), and when it was
	// stored. It returns ok == false if nothing usable is stored.
	Get(url string) (data []byte, etag string, storedAt time.Time, ok bool)

	// Put stores the DERP map response for url, replacing any prior
	// entry and marking it stored as of now. An empty etag means the
	// server sent none.
	Put(url string, data []byte, etag string) error
}

DERPMapCache is an option for ConnInfo.Expand and FetchDERPMap that caches fetched DERP maps. Without one, a process-wide in-memory cache is used; provide an implementation (like the tailcat CLI's on-disk one) to persist across processes. Implementations just store bytes; the freshness policy lives in the fetcher: a stored map younger than an hour is used without any network traffic, an older one is revalidated with If-None-Match (the ETag is opaque to us), and a stored map of any age is used as a fallback if the fetch fails or times out.

type DERPMapURL

type DERPMapURL string

DERPMapURL is an option for ConnInfo.Expand specifying an alternate URL to fetch the DERP map from instead of DefaultDERPMapURL.

type NodePublic

type NodePublic struct {
	key.NodePublic
}

NodePublic is a wrapper around key.NodePublic just so we can have a slightly smaller CBOR representation without the "np" prefix.

func (NodePublic) Equal

func (a NodePublic) Equal(b NodePublic) bool

Equal reports whether a and b represent the same public key.

func (NodePublic) MarshalBinary

func (p NodePublic) MarshalBinary() ([]byte, error)

MarshalBinary implements encoding.BinaryMarshaler for CBOR serialization, encoding the raw 32-byte key without the "nodekey:" text prefix.

func (*NodePublic) UnmarshalBinary

func (p *NodePublic) UnmarshalBinary(x []byte) error

UnmarshalBinary implements encoding.BinaryUnmarshaler for CBOR deserialization.

type PingResult

type PingResult struct {
	// Latency is the round-trip time for the meow/meowed handshake
	// through the DERP relay.
	Latency time.Duration
}

PingResult is the result of a successful Client.Ping call.

type PrivateKey

type PrivateKey struct {
	Private key.NodePrivate
	Public  ConnInfo
}

PrivateKey is a node identity: a private key paired with the connection info needed to reach this node. The DERP region in Public must be populated by the caller before the key is usable.

func NewPrivateKey

func NewPrivateKey() *PrivateKey

NewPrivateKey returns a new PrivateKey, but without the DERP region populated. It's up to the caller to populate that.

type Server

type Server struct {
	// Key is the server's node identity.
	// If zero, Start generates a new ephemeral key.
	Key key.NodePrivate

	// Logf is the logger used for debug messages.
	// If nil, log.Printf is used.
	Logf logger.Logf

	// Region, if non-nil, is the DERP region to use as the bootstrap
	// relay, without fetching any DERP map.
	Region *tailcfg.DERPRegion

	// RegionID, if non-zero and Region is nil, is the ID of the DERP
	// map region to use. If zero, the nearest region is picked based
	// on latency at Start.
	RegionID int

	// DERPMapURL, if non-empty, is an alternate URL to fetch the DERP
	// map from when Region is nil. If empty, [DefaultDERPMapURL] is
	// used.
	DERPMapURL string

	// DERPMapCache, if non-nil, caches fetched DERP maps. If nil, a
	// process-wide in-memory cache is used.
	DERPMapCache DERPMapCache

	// AllowedClients, if non-empty, restricts which client node keys
	// may connect; all others are silently ignored. If empty, all
	// clients are allowed. See [Server.AddAllowedClient] to add more
	// at runtime.
	AllowedClients []key.NodePublic

	// AllowProxy, if non-nil, reports whether
	// a TCP or UDP proxy is allowed for that target.
	AllowProxy func(netip.AddrPort) bool

	// OnTCP, if non-nil, specifies a func that returns a handler to handle
	// incoming connections to the provided port. If nil or if it returns nil,
	// then a RST is sent.
	//
	// This only applies to connections directly to the server node and not
	// when being a subnet router. See OnTCPForward for relayed connections.
	//
	// It must be set before calling Start.
	OnTCP func(port uint16) (handler func(net.Conn))

	// OnTCPForward, if non-nil, specifies a func that returns a handler to handle
	// incoming connections to the provided IP:port. If nil or if it returns nil,
	// then a RST is sent.
	//
	// This only applies to connections relayed through the server and not to the server
	// itself. See OnTCP for direct connections to the server.
	//
	// It must be set before calling Start. Setting it also widens the
	// packet filter installed at Start to admit traffic to any
	// destination, not just the server's own address.
	OnTCPForward func(netip.AddrPort) (handler func(net.Conn))

	// ServedTCPPorts, if non-nil, restricts which TCP ports on the
	// server's own address the packet filter admits new inbound
	// connections to. If nil, connections to all ports reach OnTCP,
	// which remains the per-port gate either way. Callers that know
	// their served ports statically (like the tailcat CLI) can set
	// this for defense in depth.
	//
	// Unlike OnTCP's nil-handler response, packets dropped by the
	// filter get no RST; a client dialing a filtered port times out.
	//
	// It must be set before calling Start.
	ServedTCPPorts []filter.PortRange
	// contains filtered or unexported fields
}

Server listens for clients over a WireGuard tunnel relayed through DERP. Incoming TCP connections are dispatched via Server.OnTCP (for connections addressed to the server itself) and Server.OnTCPForward (for connections the server relays to other addresses, acting as an exit node).

The zero value is a usable server: optionally populate the configuration fields, then call Server.Start, which picks defaults for anything unset.

func (*Server) AddAllowedClient

func (s *Server) AddAllowedClient(k key.NodePublic)

AddAllowedClient adds k as an allowed client.

Until a key is allowed (here or via Server.AllowedClients), all clients are allowed.

func (*Server) Addr

func (s *Server) Addr() netip.Addr

Addr returns the server's IPv6 address derived from its public key. It must only be called after Server.Start.

func (*Server) Close

func (s *Server) Close() error

Close shuts down the server, closing the WireGuard engine and DERP connections.

func (*Server) ConnBlob

func (s *Server) ConnBlob() ConnBlob

ConnBlob returns the token that clients use to connect to this server. It embeds the full DERP region, so clients don't need to fetch the DERP map from the network. It must only be called after Server.Start.

func (*Server) DrainTCP

func (s *Server) DrainTCP(ctx context.Context) error

DrainTCP waits until every TCP connection in the server's netstack has fully closed, meaning the peer has acknowledged all sent data and the final FIN. It returns nil once drained, or ctx's error.

The whole TCP stack runs inside this process, so exiting right after a net.Conn Close can lose the FIN before it is ever transmitted, leaving the peer waiting for an EOF that never comes. A process that closes a connection and then exits should first call DrainTCP with a timeout bounding ctx, in case the peer is gone and the FIN is never acknowledged.

It is meant for the passive closer (the side that closes second), which goes straight to CLOSED once its FIN is acked. A connection this side closed first instead parks in TIME-WAIT and would block DrainTCP until the TIME-WAIT timer fires.

func (*Server) HandleTailscaleSSHConn

func (s *Server) HandleTailscaleSSHConn(c net.Conn)

HandleTailscaleSSHConn handles an incoming TCP connection as an SSH session. Authentication is not required — the WireGuard tunnel provides identity. The connection is served using the gliderlabs/ssh library with a single ed25519 host key generated on first use in ~/.config/tailcat/ssh/.

Two modes are supported: if the SSH client sends a command, it is executed via the user's shell with "-c"; otherwise an interactive login shell is started with a PTY.

func (*Server) Start

func (s *Server) Start() error

Start connects to the DERP relay and begins accepting clients, first picking defaults for any unset configuration fields: a new ephemeral key, log.Printf for logging, and the nearest region of the default DERP map.

func (*Server) Status

func (s *Server) Status() *ipnstate.Status

Status returns the current WireGuard and DERP connection status.

Directories

Path Synopsis
cmd
tailcat command
tailcat-web command
The tailcat-web command is a development server for the tailcat browser app in the web/ directory.
The tailcat-web command is a development server for the tailcat browser app in the web/ directory.
tailcat-webdist command
The tailcat-webdist command builds the distribution directory of static files needed to serve the tailcat browser app: index.html, app.js, wasm_exec.js, and the js/wasm main.wasm binary with precompressed .zst and .gz variants.
The tailcat-webdist command builds the distribution directory of static files needed to serve the tailcat browser app: index.html, app.js, wasm_exec.js, and the js/wasm main.wasm binary with precompressed .zst and .gz variants.
internal
wasmbuild
Package wasmbuild builds the tailcat web WebAssembly binary and the distribution directory of static files that servers of the web app need.
Package wasmbuild builds the tailcat web WebAssembly binary and the distribution directory of static files that servers of the web app need.
The tailcat web app is the WebAssembly (js/wasm) build of tailcat for browsers.
The tailcat web app is the WebAssembly (js/wasm) build of tailcat for browsers.
Package webdemo serves the tailcat browser app (the js/wasm build of tailcat in the web/ directory) from a distribution directory of prebuilt static files, as produced by cmd/tailcat-webdist.
Package webdemo serves the tailcat browser app (the js/wasm build of tailcat in the web/ directory) from a distribution directory of prebuilt static files, as produced by cmd/tailcat-webdist.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL