A reverse tunnel over tailcat: the server holds a fixed address and owns no ports, clients dial in and offer ports from their own machines, and the server reaches them.
tailcat's own serve/forward pair puts the fixed address on the side that
owns the ports:
tailcat forward ──dials──▶ tailcat serve ──▶ the server's localhost:3000
(listens locally) (fixed address)
cattail turns that around, which is what you want when the machine with the service is the one behind NAT, and the machine everyone can reach is the one with the stable address:
cattail serve ◀──dials── cattail connect ──▶ the client's localhost:3000
(fixed address, (offers ports)
listens locally)
On the machine everyone connects to, once:
$ cattail serve
# 🐈 cattail server listening, clients connect with:
#
# cattail connect tcpGFwWCDJ_vIaW… --name=<name> <listen:target>
#
# Forwarded ports bind to 127.0.0.1. Anyone with this address may expose ports here.The address is derived from a key saved on first run, so it is byte-identical on every restart. Hand it to each client:
$ cattail connect tcpGFwWCDJ_vIaW… --name=laptop 8080:3000
cattail: connected as "laptop"
cattail: exposing 127.0.0.1:3000 as the server's :8080The server now listens on 127.0.0.1:8080 and proxies every connection to the
laptop's own localhost:3000. A mapping of 8080:3000 means server 8080 →
client 3000; a bare 8080 uses 8080 on both sides. Several mappings and
several clients work at once, each client under its own --name.
A third field puts a host in the middle, and the target is then reached from the client rather than on it:
$ cattail connect tcpGFwWCDJ_vIaW… --name=laptop 8080:192.168.1.5:80
cattail: exposing 192.168.1.5:80 as the server's :8080Anything the client can reach works — another machine on its LAN, a container
on its compose network, a name only its resolver knows. Bracket an IPv6
address, 8080:[fd00::1]:80, so its own colons stay unambiguous.
cattail serve [--bind=ADDR] [--key=PATH] [--verbose]
cattail connect [--name=NAME] [--no-retry] [--verbose] <tc-addr> <listen[:host]:target> [...]
Forwarded ports bind to 127.0.0.1 unless --bind=0.0.0.0 says otherwise.
tailcat cannot invert this on its own. tailcat.Server exposes only OnTCP
handlers and tailcat.Client only Dial, so connections can physically
travel in one direction: client to server. The inversion happens one layer up.
The client dials the server once, on a single control port. After a
length-prefixed registration exchange, both ends run
yamux over that same connection with
the roles reversed — yamux.Client on the cattail server, so it is the side
that opens streams:
[server host] [client host]
tailcat.Server (fixed addr) ◀── dials ── tailcat.Client
│ │
yamux.Client ═══ mux over that 1 conn ═══ yamux.Server
│ │
net.Listen(:8080) net.Dial(localhost:3000)
Each stream carries the listen port as a two-byte header. The client maps that back to one of its own targets, so the server never names a host or port on the far side and can only reach what the client explicitly offered. The host in a three-field mapping is resolved and dialled on the client, and the server only ever prints it.
There is no authentication beyond the address itself. Knowing the tailcat
address is the capability — it embeds a WireGuard pre-shared key, so treat it
as a secret. Anyone holding it can make the server bind local ports, so under
--bind=0.0.0.0 they can publish their own service on the server's network.
tailcat.Server has an AllowedClients allowlist if you later want one.
The direction of trust is worth keeping straight. The server cannot reach past
what a client offered, but a client's own mappings are unconstrained: a
three-field mapping turns that client into a doorway onto whatever it can
reach, so 8080:192.168.1.5:22 publishes someone else's SSH on the server.
Only the person running connect decides that, which is the right place for
the decision, but it is a decision.
examples/server/ and examples/client/ are two compose files, because that
is how this is actually deployed: the server on the machine everyone can
reach, the client next to whatever it exposes.
$ cd examples/server && docker compose up -d
$ docker compose logs cattail | grep 'cattail connect'Then, on the other machine:
$ cd examples/client
$ cp .env.example .env # paste the address into it
$ docker compose up -dTwo things about the server side. Its identity lives in a named volume — the address is derived from that key, so losing the volume means handing every client a new address. And the ports it publishes have to be listed by hand: clients choose their listen ports at runtime, so there is nothing for compose to derive the list from.
One thing about the client side. A two-field mapping dials cattail's own
loopback, which inside a container is the container. The example therefore
uses 8080:web:80, naming the sibling service, and needs no special network
wiring. The alternative, for a mapping that really is loopback-local, is to
share the service's namespace with network_mode: service:web and write
8080:80.
cmd/cattail/labs/cattail/ the CLI
internal/cattail/labs/cattail/ protocol.go framing and the registration types
tunnel.go both halves of the tunnel, over a plain net.Conn
server.go tailcat server wiring and key persistence
client.go tailcat client wiring and the reconnect loop
examples/ server and client compose files
tunnel.go takes an already-connected net.Conn, so the whole tunnel is
tested over a loopback pair with no DERP and no network.
$ go build ./cmd/cattail
$ go test -race ./...