✨ Read this awesome post from Hacker News 📖
📂 **Category**:
💡 **What You’ll Learn**:
“Tailscale without Tailscale, by Tailscale”
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
Tailscale’s DERP relay network, 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.
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
....
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
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
💬
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.
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 genkeygenerates 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(seetailcat 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= 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
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= pins an explicit one
instead; --region=list shows the choices.)
TODO: make the client more robust here if the DERP map changes over
time: #7
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
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:
- a small integer referencing one of the default Tailscale-run tailcat servers), or
- 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-addressflag and thetailcat resolvesubcommand 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.
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), sorootisn’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.
-
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. -
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. -
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. -
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. -
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. -
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.
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)
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.
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.
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.
{💬|⚡|🔥} **What’s your take?**
Share your thoughts in the comments below!
#️⃣ **#tailscaletailcat #netcat #Tailscales #data #plane #Tailscales #control #plane #GitHub**
🕒 **Posted on**: 1787769254
🌟 **Want more?** Click here for more info! 🌟
