Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cattail

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.

Why

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)

Usage

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 :8080

The 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 :8080

Anything 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.

How it works

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.

Security

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.

Docker

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 -d

Two 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.

Layout

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.

Building

$ go build ./cmd/cattail
$ go test -race ./...

Used by

Contributors

Languages