diff --git a/CHANGELOG.md b/CHANGELOG.md index 2345df7d0..7f15dc1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/cmd/tailcat/serve_test.go b/cmd/tailcat/serve_test.go index 8bf3c53ff..20e9a8fd5 100644 --- a/cmd/tailcat/serve_test.go +++ b/cmd/tailcat/serve_test.go @@ -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 diff --git a/cmd/tailcat/tailcat.go b/cmd/tailcat/tailcat.go index 06e2eb1d6..d32ab4a59 100644 --- a/cmd/tailcat/tailcat.go +++ b/cmd/tailcat/tailcat.go @@ -55,6 +55,7 @@ var ( flagServe *string flagKey *string flagAllow *string + flagLogClients *bool flagFiles *string flagSSHAuthorizedKeys *string flagPSK *bool @@ -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.") @@ -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) + } } } @@ -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 } @@ -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)