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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ $

Or you can serve a local TCP port, forwarded to localhost:

Pass `--verbose` before `serve` to log each accepted TCP connection and UDP
flow to stderr, including its source address, authenticated peer key, and
destination. These logs can reveal client and service addresses.

```sh
$ tailcat serve 8080,8443 # or: tailcat serve all
# 🐈 Server listening with new address: tcXXXXXXXXX
Expand Down
6 changes: 5 additions & 1 deletion cmd/tailcat/perf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
func TestPerf(t *testing.T) {
t.Parallel()
e := newTestEnv(t)
_, addr, serverStderr := e.startServer("serve", "perf")
_, addr, serverStderr := e.startServer("--verbose", "serve", "perf")
waitForLog(t, serverStderr, "Accepting perf tests")

perfCmd := func(t *testing.T, args ...string) []byte {
Expand Down Expand Up @@ -50,6 +50,7 @@ func TestPerf(t *testing.T) {
}
}
waitForLog(t, serverStderr, "# perf test from ")
waitForLog(t, serverStderr, "incoming TCP from ")
if !strings.Contains(serverStderr.String(), "TCP client -> server ") {
t.Errorf("server log missing test summary:\n%s", serverStderr.String())
}
Expand Down Expand Up @@ -96,6 +97,9 @@ func TestPerf(t *testing.T) {
if got.RTT == nil || got.RTT.Count == 0 {
t.Errorf("no RTT samples in %+v", got.Result)
}
if logs := serverStderr.String(); !strings.Contains(logs, "incoming UDP from ") || !strings.Contains(logs, "(peer key ") {
t.Errorf("verbose server did not log the UDP flow and peer key: %s", logs)
}
})
}

Expand Down
8 changes: 8 additions & 0 deletions cmd/tailcat/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ func TestServeWithoutPSK(t *testing.T) {
if got != payload {
t.Errorf("server echoed %q; want %q", got, payload)
}
if strings.Contains(serverStderr.String(), "incoming TCP from ") {
t.Errorf("server logged a connection without --verbose: %s", serverStderr.String())
}
}

func TestServeRemembersSavedKeyWithoutPSK(t *testing.T) {
Expand Down Expand Up @@ -197,6 +200,11 @@ func TestServePorts(t *testing.T) {
if got != payload {
t.Errorf("served port echoed %q; want %q", got, payload)
}
if logs := serverStderr.String(); !strings.Contains(logs, "incoming TCP from ") ||
!strings.Contains(logs, "(peer key ") ||
!strings.Contains(logs, fmt.Sprintf(":%d (server key ", port)) {
t.Errorf("verbose server did not log the accepted connection and its peer/destination: %s", logs)
}

// The packet filter silently drops SYNs to unserved ports (no
// RST; see Server.ServedTCPPorts), so instead of waiting out the
Expand Down
4 changes: 2 additions & 2 deletions cmd/tailcat/tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func newRootCommand() *ff.Command {
rootFS := ff.NewFlagSet("tailcat")
flagServe = rootFS.StringLong("serve", "", "comma-separated list of port numbers, port ranges, or service names to serve; the same list the serve subcommand takes as arguments. Service names are: 'all' (serve all ports), 'exit-node' (run an exit node for all addresses), 'ssh' (public-key-authenticated SSH server; see serve's --ssh-authorized-keys flag), 'no-auth-ssh' (auth-free SSH server), 'files' (file server for SFTP clients; see serve's --files flag), 'exec' (run the command after -- for each connection, with the connection as its stdio), 'perf' (accept throughput tests from 'tailcat perf'). If empty, it accepts a single connection on any port, writes it to stdout, and exits.")
flagKey = rootFS.StringLong("key", "", "'new' for an ephemeral key. If empty, the default saved key is used if it exists ('default' in server mode, 'client-default' in client modes; see genkey), else an ephemeral key. Otherwise the path to a *.private.json or a name like 'foo' to read it from $CONFIG/tailcat/keys/foo.private.json")
flagVerbose = rootFS.BoolLong("verbose", "be verbose")
flagVerbose = rootFS.BoolLong("verbose", "be verbose; in server mode, log accepted connections and flows")
flagJSON = rootFS.BoolLong("json", "in server mode, write {\"listenAddr\": ...} JSON to stdout; with perf, write the results as JSON")
flagDERPMapURL = rootFS.StringLong("derpmap-url", cmp.Or(os.Getenv("TAILCAT_DERPMAP_URL"), tailcat.DefaultDERPMapURL), "URL of the JSON DERP map used to resolve or auto-select a DERP region; its default can also be set with the TAILCAT_DERPMAP_URL environment variable")

Expand Down Expand Up @@ -1395,7 +1395,7 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) {
ci.ServerDiscoPublic = tailcat.DiscoPublicForNode(priv)
connStr := ci.Addr()

s := &tailcat.Server{Key: priv, PresharedKey: psk, DisablePresharedKey: !usePSK, Logf: logf, Region: reg}
s := &tailcat.Server{Key: priv, PresharedKey: psk, DisablePresharedKey: !usePSK, Logf: logf, LogConnections: *flagVerbose, Region: reg}
sshServices := services.Contains("ssh") || services.Contains("no-auth-ssh") || services.Contains("files")
if sshServices && !tailcat.SupportsSSHServer() {
log.Fatalf("Tailscale SSH server not supported on %v", runtime.GOOS)
Expand Down
37 changes: 33 additions & 4 deletions tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ type Server struct {
// If nil, log.Printf is used.
Logf logger.Logf

// LogConnections reports accepted TCP connections and UDP flows, including
// their remote address, authenticated peer key, and destination. It is
// disabled by default; logs can reveal client and service addresses.
LogConnections bool

// Region, if non-nil, is the DERP region to use as the bootstrap
// relay, without fetching any DERP map.
Region *tailcfg.DERPRegion
Expand Down Expand Up @@ -697,15 +702,35 @@ func (s *Server) startLocked(ctx context.Context) error {
}
ns.ProcessLocalIPs = true
ns.ProcessSubnets = true
logIncoming := func(network string, c net.Conn) {
if !s.LogConnections {
return
}
peer, ok := s.PeerKey(c.RemoteAddr())
if !ok {
logf("incoming %s from %v (unknown peer key) to %v (server key %v)", network, c.RemoteAddr(), c.LocalAddr(), priv.Public())
return
}
logf("incoming %s from %v (peer key %v) to %v (server key %v)", network, c.RemoteAddr(), peer, c.LocalAddr(), priv.Public())
}
wrapTCP := func(h func(net.Conn)) func(net.Conn) {
if h == nil || !s.LogConnections {
return h
}
return func(c net.Conn) {
logIncoming("TCP", c)
h(c)
}
}
ns.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
if dst.Addr() == lb.addr {
if ln := s.listenerForPort("tcp", dst.Port()); ln != nil {
return ln.handle, true
return wrapTCP(ln.handle), true
}
if s.OnTCP == nil {
return nil, true // send RST
}
return s.OnTCP(dst.Port()), true
return wrapTCP(s.OnTCP(dst.Port())), true
}
if s.OnTCPForward == nil {
return nil, true // send RST
Expand All @@ -716,7 +741,7 @@ func (s *Server) startLocked(ctx context.Context) error {
copy(a4[:], d6[12:16])
dst = netip.AddrPortFrom(netip.AddrFrom4(a4), dst.Port())
}
return s.OnTCPForward(dst), true
return wrapTCP(s.OnTCPForward(dst)), true
}
ns.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
var h func(ConnPacketConn)
Expand All @@ -738,7 +763,11 @@ func (s *Server) startLocked(ctx context.Context) error {
if h == nil {
return nil, true
}
return func(c nettype.ConnPacketConn) { h(newIdlePacketConn(c, s.udpIdleTimeout())) }, true
return func(c nettype.ConnPacketConn) {
flow := newIdlePacketConn(c, s.udpIdleTimeout())
logIncoming("UDP", flow)
h(flow)
}, true
}
lb.ns = ns
sys.Set(ns)
Expand Down