Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
shared DERP relay mostly measures its rate limit; `--via-derp`
allows relayed tests through your own relay, never through
Tailscale's shared ones.
- `tailcat serve --log-clients` logs each incoming connection to
stderr: the client's public key, the port or destination it
connected to, and whether its path is direct or relayed through
DERP.
([#39](https://github.com/tailscale/tailcat/issues/39))

## v0.7.0 (2026-09-19)

Expand Down
28 changes: 28 additions & 0 deletions cmd/tailcat/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,34 @@ func TestServePorts(t *testing.T) {
}
}

// TestServeLogClients checks that --log-clients logs each connection, and only with the flag.
func TestServeLogClients(t *testing.T) {
t.Parallel()
e := newTestEnv(t)
port := startEchoListener(t)

logRx := regexp.MustCompile(fmt.Sprintf(`client nodekey:[0-9a-f]{64} connected to tcp port %d \((direct \S+|relayed via DERP \S+|path unknown)\)`, port))
for _, logClients := range []bool{true, false} {
flags := []string{"serve", strconv.Itoa(int(port))}
if logClients {
flags = []string{"serve", "--log-clients", strconv.Itoa(int(port))}
}
_, addr, serverStderr := e.startServer(flags...)

const payload = "log me"
got, err := runClient(t, e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, addr, strconv.Itoa(int(port))), serverStderr, payload)
if err != nil {
t.Fatalf("client: %v", err)
}
if got != payload {
t.Errorf("echoed %q; want %q", got, payload)
}
if logged := logRx.MatchString(serverStderr.String()); logged != logClients {
t.Errorf("--log-clients=%v: logged connection = %v; server stderr:\n%s", logClients, logged, serverStderr.String())
}
}
}

// TestServeExitNode verifies that a --serve=exit-node server forwards
// connections to arbitrary IP:port destinations, both for a plain
// client given an IP:port argument and through the SOCKS5 proxy that
Expand Down
45 changes: 42 additions & 3 deletions cmd/tailcat/tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ var (
flagServe *string
flagKey *string
flagAllow *string
flagLogClients *bool
flagFiles *string
flagSSHAuthorizedKeys *string
flagPSK *bool
Expand Down Expand Up @@ -101,6 +102,7 @@ func newRootCommand() *ff.Command {

serveFS = ff.NewFlagSet("serve").SetParent(rootFS)
flagAllow = serveFS.StringLong("allow", "", "comma-separated list of public keys to allow access to the server, or 'none' to allow no clients. If empty, all clients are allowed.")
flagLogClients = serveFS.BoolLong("log-clients", "log each incoming connection to stderr: the client's public key, what it connected to, and whether its path is direct or relayed through DERP")
flagFullAddress = serveFS.BoolLong("full-address", "print a longer tailcat address with embedded DERP server info instead of a reference to a DERP map region ID. This lets clients connect more quickly, without a DERP map fetch.")
flagFiles = serveFS.StringLong("files", "", "directory to serve to SFTP clients (scp, sftp) with the 'files' service, with an optional :ro (read-only, the default), :rw (read-write), :wo (flat write-only drop box), or :wo+ (recursive write-only drop box) suffix. If empty, the current directory is served read-only. Giving --files implies the 'files' service.")
flagSSHAuthorizedKeys = serveFS.StringLong("ssh-authorized-keys", "", "comma-separated SSH public key sources for the 'ssh' service: authorized_keys file paths, literal OpenSSH public key lines, or names like 'alice@github' (fetched from https://github.com/alice.keys). All sources are loaded and validated at startup.")
Expand Down Expand Up @@ -1472,16 +1474,50 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) {
}
}

// logClient logs an accepted connection for --log-clients.
logClient := func(c net.Conn, what string) {
src := "unknown client"
path := "path unknown"
if k, ok := s.PeerKey(c.RemoteAddr()); ok {
src = k.String()
if ps, ok := s.Status().Peer[k]; ok {
switch {
case ps.CurAddr != "":
path = "direct " + ps.CurAddr
case ps.Relay != "":
path = "relayed via DERP " + ps.Relay
}
}
}
log.Printf("client %v connected to %v (%v)", src, what, path)
}
logTCP := func(what string, h func(net.Conn)) func(net.Conn) {
if h == nil || !*flagLogClients {
return h
}
return func(c net.Conn) {
logClient(c, what)
h(c)
}
}

if services.Contains("exit-node") {
s.OnTCPForward = func(dst netip.AddrPort) (handler func(net.Conn)) {
return tcpForwardTo(dst.String())
return logTCP("tcp "+dst.String(), tcpForwardTo(dst.String()))
}
// Exit-node clients send UDP through the tunnel the same way they
// send TCP (DNS, QUIC, ...). Without this, those flows are dropped:
// the tunnel is up and TCP works, but every UDP flow silently goes
// nowhere. See OnUDPForward and ProxyPacketConns in the README.
s.OnUDPForward = func(dst netip.AddrPort) (handler func(tailcat.ConnPacketConn)) {
return udpForwardTo(dst)
h := udpForwardTo(dst)
if !*flagLogClients {
return h
}
return func(c tailcat.ConnPacketConn) {
logClient(c, "udp "+dst.String())
h(c)
}
}
}

Expand Down Expand Up @@ -1532,7 +1568,7 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) {
fmt.Fprintf(os.Stderr, "# Proxying port %d to %v\n", port, targets[port])
}

s.OnTCP = func(port uint16) (handler func(net.Conn)) {
onTCP := func(port uint16) (handler func(net.Conn)) {
if port == 22 && sshHandler != nil {
return sshHandler
}
Expand Down Expand Up @@ -1579,6 +1615,9 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) {
}
return tcpForwardTo(fmt.Sprintf("localhost:%v", port))
}
s.OnTCP = func(port uint16) (handler func(net.Conn)) {
return logTCP(fmt.Sprintf("tcp port %v", port), onTCP(port))
}

if err := s.Start(); err != nil {
log.Fatalf("Server.Start: %v", err)
Expand Down