I literally just figured out how tsnet fits into the picture (an in-process Go based entire network stack that gets the process to act as a node in the tailnet) and so that helps me understand this (everything in tsnet excepting the control plane). It's very impressive that they can do this in a reliable way.
dns node key for fun on one
and two machines that connect, with systemd to keep tailcat serving ssh running
I bet I could simplify out the WebSocket-to-UDP proxy by leaning into tailcat's dataplane and just speak straight UDP.
(just a cute demo, not intended for release or ongoing maintenance)
The only thing that convinces me that tailscale isn't a CIA op is how badly it works for real uses
A lot of them, instead of learning the usual linuxii you'll be using in containers at work dive headfirst into nix.
And it might be because it is easy to provide and maintain compared to a .deb package.
I've been making a mesh vpn like radmin vpn or hamachi that does not need a central authority server and most importantly, can run on all major OS thanks to wireguard-go.
Just a combo of never getting around to it and friends talking me out of it b/c of existing alternatives such as wormhole[2].
I see a bunch here on awesome-iroh page
Everything should be possible with just plain netcat and IP stack. Someone asks about NAT holepunching, encryption, static IDs, permissions, etc., yes this is what Internet lacks and why every P2P app reinvents it over and over again.
What is a data plane? What is a control plane? I don’t genuinely understand what these words mean :(
Half of the Software offering nowadays seems to be selling vendor lock-in at no added value and then making a profit.
Sure there's always some negligible added value, and then they reinvent a whole stack for their ecosystem. In the case of tailscale the added value seems to be avoiding going into your router and activating port forwarding? It looks like negative added value to me. Someone that tells you "don't do NAT traversal, just open a port" would be highly valuable, but saying no doesn't seem to be trendy, and is certainly not an easy sell.
if that is goal then why not go 100% open source to eliminate the ts derp control as well and get full sovereignty?
like netbird, openziti, zerotier, etc.
"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
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).
$ 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
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
$
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
{
"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.
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
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
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.
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:
--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.
Tailcat reuses Tailscale's client networking components but without the control plane.
root isn't required.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.
edit:
Which are probably tailscale's, as it's under tailscale/ github org?
The DERP server is already open source and tailcat can use any DERP server you run: https://github.com/tailscale/tailscale/tree/main/cmd/derper#...
We just provide some default ones (https://tailcat.dev/derpmap.json) to get started if you're not bandwidth-sensitive. But you don't have to use them.
Update: I added more explicit docs about this to https://github.com/tailscale/tailcat#bring-your-own-derp-rel...
Recently: https://github.com/golang/go/commit/128a36cf0367c46daff2528d...
https://github.com/golang/go/commit/3c0665e551be23e62167f625...
May be my idea of simpler cryptography is incorrect, but PAKEs like CPace do seem simpler than public key primitives in Noise.
The slightly tricky part would be managing a local NAT that blends the two networks, and also does MagicDNS to answer on those NATed IPs. If routes conflicted, I guess you'd have to pick one?
Seems very doable though.
Well, ignoring the tone... kinda yeah? I mean, if port forwarding works for you, and you have public IPv4, and you have no CGNAT, and you need a connection only between 2 hosts, then port forwarding is actually a half-decent idea. However, it's not what Tailscale excels at.
There's no vendor lock-in here and no payment or account required. If Tailscale as a company fails, tailcat keeps working if you run your own DERP server. It's just open source code, not a hosted service.
But these are a separate fleet (https://tailcat.dev/derpmap.json) separate from our usual ones, and not using "tailscale.com" in DNS or SNI anywhere.
WireGuard doesn't do NAT traversal. That's the main thing this adds. And this also adds a CLI tool + library to do streams over WireGuard w/o installing kernel routings, requiring root, etc.
It's really pretty cool.
> port forwarding is actually a half-decent idea
You make it sound like both options are equally valid, and as if port-forwarding were some esoteric technology. It's not, one is good, the other is bad.
5 years ago I had a similarly bland position, live and let live, I'd go to a client that was using a Ddns (zapto.org) for production loads serving thousands of customers. 5 Years later, I see them constantly facing SSL issues, and network issues, and even if they weren't it's still the wrong way to do things.
We have to stand for something, get an IP address, get a domain. If you don't have either of those, raise funds, get a budget, speak to people, you can't tech your way out of some things, I know we are hackers, but sometimes the solution is not hacking. Yes we should develop the power to do things even without approval from institutions and without collaboration from the central NIC, or the domain holder, etc... But we should refrain from using that power and actually integrate with domain holders, actually get IPv4/IPv6 blocks.
There's many reasons why you would use a ddns or similar tech, lack of budget, lack of main domain holder approval, lack of NIC approval, lack of ISP approval. In all cases, you are way better off by actually going the other way instead of using a ddns.
1- no budget: Get a budget, a domain is like 15$/yr. a 1024 IPv4 block leased from a NIC is like 600-2500$/year, so the cost of a Host or ISP is 0.6$ to 2.5$, maybe they charge you a margin and you end up paying 5$ to 50$/yr? If this is an issue, maybe your project should die, how much is your time worth? Fund your projects accordingly, or let them die. If this is someone elses project, they cannot afford you.
2- ISP: Call your ISP, if they can't help you access your router, or port forward or whatever, hang up, call again. If your solution is to use a ddns instead of picking up the phone, you have overtrained your hard tech skills and need to work on your soft skills. Speak to a human, you won't get very far if you never speak to humans. This is not a niche thing, we call it port-forwarding but it's how TCP and IP work in general, this is the stuff of the internet, if you can't do something as basic as that, you are never gonna make it, sorry.
3- Domain holder: If you work for a client, and they have a domain, but you don't know who holds it, or where the passwords are. Don't jump to a ddns or buying another domain, integrate, investigate, do an inventory. At the very least buy another domain, a ddns is just cheap to the point of being unprofessional, see 1.
There's a 4th excuse that I'll handle as well:
4- I'm just testing, this is just a throwaway project: Even if it is, you are still underfunding your bets if you don't even have a domain and standalone non-firewalled IP, even if reusing an existing domain and host, we are talking about like 5$ per month. Imagine a professional of any class that cannot afford a tool worth 5$, even a plumber has a toolkit with a screwdriver in it.
Even if you share a link to your project to a friend, if it says hsabhsabdsa.tailscale.net or bjsdbsajdbsa.vercel.app , you are already neutering your project. You can skip this 'phase', it's not necessary for a project to go through all phases, especially if a vendor is trying to convinve you that you should go through this phase, skip it, tell them no, build it right even for your day 1 demo, it's better than having a pre-day 1 demo that has low quality and does things wrong.
Idk? I found it pretty easy to configure by blindly following the tutorials and copy-pasting keys. The only footgun is the keepalive setting, which will screw up the tunnel if one end is behind NAT, that tripped me hard, but besides this, no issues at all.
NAT traversal is a different topic, WG won't help in that, and that can actually be a pain. I guess we should be using IPv6 already, and this tool would be largely redundant already. (not completely, encrypted access to isolated networks is a valid use case)
I wanted it to work on macOS so couldn't rely on network namespaces.
Public/private key pairs are hard? It's no more terrible than other projects that require configs.
Fwiw, I don't think vendor lock-in is a bad thing, but being open source doesn't mean there's no vendor-lock in. If a client builds their system on top of an open source technology, then switching away from that dependency would have a cost, even if that cost is engineering man-hours.
If tailscale does not work out of the box with netcat, and requires a custom additional dependency to work, it raises the question that tailscale will not work out of the box with other tools like tcpdump or stunnel and apache, and I will need to install other custom dependencies like taildump, tailtunned, tailapache.
When compared to other solutions that integrate without additional effort, installing vendor-specific dependency forks/clones means that there is an additional vendor lock-in.
the control plane is closed source, but headscale is an open source alternative that we embrace and encourage people to use if it meets their needs/desires
At some point it was a weaker clone, but it developed into a very high quality clone, and has graduated into being a co-production with the Java version.
Get a refund from wherever you got your facts.
I'll give you half a point for "iPad kids" since that term implies a lot of incorrect things but it does run on iPads.
He worked at Google for 12+ years. He created memcached, WebSub (PubSubHubbub), OpenID, and Perkeep (Camlistore). He's a late-stage co-founder of Tailscale.
If he's a junior engineer, then I have no idea what a senior engineer is.
This is using the infrastructure tailscale set up for a different purpose. Rather than requiring a tailnet or even an account anywhere, you can tailcat between any two endpoint. Tailscale is there because it runs the DERP relays which get the client's pubkey to the server, brokers the connection, and coordinates holepunching.
But Tailscale keeps sucking me in with new stuff to do. :(