From 49fb03edb5f5b8331729837062dcd1720962f5bd Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Sun, 20 Sep 2026 16:12:15 +0000 Subject: [PATCH] cmd/tailcat: add port:target mappings to serve Served ports were always proxied to the same port on localhost. To reach one service on another host on the server's network, such as an Android device's adb port on the LAN, the only options were exit-node mode, which exposes the whole network, or the exec service wrapping socat. A serve spec entry may now be "port:target", where the target is a bare port meaning that port on localhost ("8080:80") or a host:port elsewhere ("5555:10.2.200.213:5555", IPv6 in brackets). Mappings mix with plain ports, ranges, and services in the same spec, join the served-ports packet filter like any other port, and are announced at startup with one "# Proxying port N to host:port" line each. Signed-off-by: Brad Fitzpatrick --- CHANGELOG.md | 5 +++ README.md | 10 +++++ cmd/tailcat/cli_test.go | 68 +++++++++++++++++++++++++++++- cmd/tailcat/serve_test.go | 31 ++++++++++++++ cmd/tailcat/tailcat.go | 87 +++++++++++++++++++++++++++++++++------ 5 files changed, 188 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0cb3087..264a7d280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- `tailcat serve` takes port mappings like `5555:10.2.200.213:5555` + to proxy a served port to a host on the server's network, or + `8080:80` to a different port on localhost, instead of always the + same port on localhost. This exposes one LAN service without the + whole network that `exit-node` would. - `--serve=exit-node` servers now forward UDP flows; previously only TCP was forwarded, so DNS, QUIC, and other UDP traffic through an exit node went nowhere. diff --git a/README.md b/README.md index 40ef3805e..d3990c06c 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,16 @@ HTTP/1.1 200 OK .... ``` +A port mapping proxies a port somewhere other than the same port on localhost: to a different local port, or to a host and port elsewhere on the server's network. This serves port 5555 by proxying it to an Android device's adb port on the LAN, without exposing the rest of the network the way `exit-node` would: + +```sh +$ tailcat serve 5555:10.2.200.213:5555 +# Proxying port 5555 to 10.2.200.213:5555 +# 🐈 Server listening with new address: tcXXXXXXXXX +``` + +Then on the client, `tailcat forward tcXXXXXXXXX 5555` followed by `adb connect 127.0.0.1:5555`. Write IPv6 targets in brackets: `5555:[fd7a::1]:5555`. + ### Forward local ports to a tailcat server To make ports served by a tailcat server available as ordinary local TCP ports (for browsers, database clients, or other tools that do not support SOCKS or stdio), run `forward` with the server's tailcat address: diff --git a/cmd/tailcat/cli_test.go b/cmd/tailcat/cli_test.go index d15fe2552..1e57731a0 100644 --- a/cmd/tailcat/cli_test.go +++ b/cmd/tailcat/cli_test.go @@ -7,9 +7,11 @@ import ( "bytes" "encoding/json" "errors" + "maps" "os" "os/exec" "path/filepath" + "slices" "strings" "testing" @@ -207,7 +209,7 @@ func TestServeSSHAuthorizedKeysFlag(t *testing.T) { if len(args) != 1 || args[0] != "ssh" { t.Errorf("serve args = %q; want [ssh]", args) } - _, services, err := parsePortSet("ssh") + _, services, _, err := parsePortSet("ssh") if err != nil { t.Fatal(err) } @@ -590,3 +592,67 @@ func TestGenkeyEmbedDERPMapUnknownRegion(t *testing.T) { t.Errorf("output = %q; want it to name the missing region", out) } } + +// TestParsePortSetTargets covers the "port:target" serve mappings: +// a bare port target means that port on localhost, a host:port +// target is kept as given (IPv6 in brackets), and conflicting +// mappings of one port are rejected. +func TestParsePortSetTargets(t *testing.T) { + for _, tt := range []struct { + spec string + wantPorts []uint16 + wantTargets map[uint16]string + wantErr string + }{ + { + spec: "5555:10.2.200.213:5555", + wantPorts: []uint16{5555}, + wantTargets: map[uint16]string{5555: "10.2.200.213:5555"}, + }, + { + spec: "8080:80,443", + wantPorts: []uint16{443, 8080}, + wantTargets: map[uint16]string{8080: "localhost:80"}, + }, + { + spec: "5555:[fd7a::1]:5555", + wantPorts: []uint16{5555}, + wantTargets: map[uint16]string{5555: "[fd7a::1]:5555"}, + }, + { + spec: "5555:android.lan:5555", + wantPorts: []uint16{5555}, + wantTargets: map[uint16]string{5555: "android.lan:5555"}, + }, + { + spec: "5555:10.2.200.213:5555,5555:10.2.200.213:5555", + wantPorts: []uint16{5555}, + wantTargets: map[uint16]string{5555: "10.2.200.213:5555"}, + }, + {spec: "5555:10.2.200.213:5555,5555:10.2.200.214:5555", wantErr: "mapped to both"}, + {spec: "5555:10.2.200.213", wantErr: "not a port or host:port"}, + {spec: "0:10.2.200.213:5555", wantErr: "not a valid port"}, + {spec: "5555:10.2.200.213:0", wantErr: "not a valid port"}, + {spec: "5555:10.2.200.213:99999", wantErr: "not a valid port"}, + {spec: "5555::5555", wantErr: "not a port or host:port"}, + {spec: "http:10.2.200.213:5555", wantErr: "not a valid port"}, + } { + ports, _, targets, err := parsePortSet(tt.spec) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("parsePortSet(%q) error = %v; want one containing %q", tt.spec, err, tt.wantErr) + } + continue + } + if err != nil { + t.Errorf("parsePortSet(%q): %v", tt.spec, err) + continue + } + if got := slices.Sorted(maps.Keys(ports)); !slices.Equal(got, tt.wantPorts) { + t.Errorf("parsePortSet(%q) ports = %v; want %v", tt.spec, got, tt.wantPorts) + } + if !maps.Equal(targets, tt.wantTargets) { + t.Errorf("parsePortSet(%q) targets = %v; want %v", tt.spec, targets, tt.wantTargets) + } + } +} diff --git a/cmd/tailcat/serve_test.go b/cmd/tailcat/serve_test.go index 22818dbdf..8bf3c53ff 100644 --- a/cmd/tailcat/serve_test.go +++ b/cmd/tailcat/serve_test.go @@ -387,3 +387,34 @@ func socks5Connect(t *testing.T, proxyAddr string, dst netip.AddrPort) net.Conn c.SetDeadline(time.Time{}) return c } + +// TestServePortMapping serves a port mapped to a different port on +// 127.0.0.1 (standing in for another host on the server's network) +// and checks that a client connecting to the served port reaches the +// mapping's target rather than the same port on localhost. +func TestServePortMapping(t *testing.T) { + t.Parallel() + e := newTestEnv(t) + echoPort := startEchoListener(t) + // Serve a port that nothing listens on locally, so only the + // mapping can make the connection work. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + servedPort := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + mapping := fmt.Sprintf("%d:127.0.0.1:%d", servedPort, echoPort) + _, addr, serverStderr := e.startServer("serve", mapping) + waitForLog(t, serverStderr, fmt.Sprintf("# Proxying port %d to 127.0.0.1:%d\n", servedPort, echoPort)) + + const payload = "echo through a port mapping" + got, err := runClient(t, e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, addr, strconv.Itoa(servedPort)), serverStderr, payload) + if err != nil { + t.Fatalf("client to mapped port: %v", err) + } + if got != payload { + t.Errorf("mapped port echoed %q; want %q", got, payload) + } +} diff --git a/cmd/tailcat/tailcat.go b/cmd/tailcat/tailcat.go index cb7f1444d..d303549a6 100644 --- a/cmd/tailcat/tailcat.go +++ b/cmd/tailcat/tailcat.go @@ -425,9 +425,13 @@ const serveLongHelp = `Run a tailcat server, printing its tailcat address for cl connect to. Running tailcat with no arguments is the same as running "tailcat serve" with no arguments. -The arguments are port numbers, port ranges, and service names, -either as separate arguments or comma-separated. Ports are proxied -to the same port on localhost. Service names are: +The arguments are port numbers, port ranges, port mappings, and +service names, either as separate arguments or comma-separated. +Ports are proxied to the same port on localhost. A port mapping +"port:target" proxies a port elsewhere instead: to a different port +on localhost ("8080:80") or to a host:port on the server's network +("5555:10.2.200.213:5555", or "5555:[fd7a::1]:5555" for IPv6). +Service names are: all serve all ports exit-node run an exit node for all addresses @@ -472,6 +476,11 @@ Serve all ports: tailcat serve all +Serve port 5555, proxied to port 5555 on another machine on the +server's network: + + tailcat serve 5555:10.2.200.213:5555 + Serve a port and the auth-free SSH server: tailcat serve 80,no-auth-ssh @@ -1229,7 +1238,7 @@ func splitExecArgs(args []string) (positional, execArgs []string) { // server runs a tailcat server. execArgs is the command given after // "--", or nil. func server(logf logger.Logf, serveSpec string, execArgs []string) { - portSet, services, err := parsePortSet(serveSpec) + portSet, services, targets, err := parsePortSet(serveSpec) if err != nil { log.Fatalf("invalid port or service to serve: %v", err) } @@ -1414,6 +1423,16 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { // for why the OS resolver can't be trusted to (issue #108). localDialer := &net.Dialer{Resolver: localhostdns.Resolver} + // tcpTarget returns the host:port a served TCP port is proxied + // to: its mapping's target if the serve spec gave one, else the + // same port on localhost. + tcpTarget := func(port uint16) string { + if t, ok := targets[port]; ok { + return t + } + return fmt.Sprintf("localhost:%v", port) + } + tcpForwardTo := func(ipPortStr string) func(net.Conn) { return func(c net.Conn) { localConn, err := localDialer.Dial("tcp", ipPortStr) @@ -1477,13 +1496,16 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { execHandler = s.ExecConnHandler(execArgs) fmt.Fprintf(os.Stderr, "# Running %v for each connection\n", strings.Join(execArgs, " ")) } + for _, port := range slices.Sorted(maps.Keys(targets)) { + fmt.Fprintf(os.Stderr, "# Proxying port %d to %v\n", port, targets[port]) + } s.OnTCP = func(port uint16) (handler func(net.Conn)) { if port == 22 && sshHandler != nil { return sshHandler } if portSet.Contains(port) { - return tcpForwardTo(fmt.Sprintf("localhost:%v", port)) + return tcpForwardTo(tcpTarget(port)) } if execHandler != nil { return execHandler @@ -1626,12 +1648,19 @@ var ( numRx = regexp.MustCompile(`^\d+$`) ) -func parsePortSet(s string) (ports set.Set[uint16], services set.Set[string], _ error) { +// parsePortSet parses a serve spec: a comma-separated list of ports, +// port ranges, service names, and port mappings of the form +// "port:target", where target is a port on localhost or a host:port +// elsewhere. It returns the set of served ports, the named services, +// and the targets of the mapped ports; ports without a target are +// proxied to the same port on localhost. +func parsePortSet(s string) (ports set.Set[uint16], services set.Set[string], targets map[uint16]string, _ error) { services = set.Set[string]{} if s == "" { - return nil, nil, nil + return nil, nil, nil, nil } ret := set.Set[uint16]{} + targets = map[uint16]string{} s = strings.TrimSpace(s) for _, r := range strings.Split(s, ",") { @@ -1644,7 +1673,7 @@ func parsePortSet(s string) (ports set.Set[uint16], services set.Set[string], _ continue case "ssh", "no-auth-ssh", "files": if !tailCatSSHEnabled { - return nil, nil, fmt.Errorf("SSH support not included in binary per build tags") + return nil, nil, nil, fmt.Errorf("SSH support not included in binary per build tags") } services.Add(r) continue @@ -1652,8 +1681,20 @@ func parsePortSet(s string) (ports set.Set[uint16], services set.Set[string], _ services.Add(r) continue } + if portStr, targetStr, ok := strings.Cut(r, ":"); ok { + port, target, err := parsePortTarget(portStr, targetStr) + if err != nil { + return nil, nil, nil, err + } + if prev, ok := targets[port]; ok && prev != target { + return nil, nil, nil, fmt.Errorf("port %d is mapped to both %v and %v", port, prev, target) + } + ret.Add(port) + targets[port] = target + continue + } if !numRx.MatchString(r) && !portRangeRx.MatchString(r) { - return nil, nil, fmt.Errorf("%q is not a known named service (want one of: all, ssh, no-auth-ssh, files, exec, exit-node)", r) + return nil, nil, nil, fmt.Errorf("%q is not a known named service (want one of: all, ssh, no-auth-ssh, files, exec, exit-node)", r) } a, b := r, "" if portRangeRx.MatchString(r) { @@ -1662,13 +1703,13 @@ func parsePortSet(s string) (ports set.Set[uint16], services set.Set[string], _ lo, err := strconv.ParseUint(a, 10, 16) if err != nil { - return nil, nil, fmt.Errorf("%q is not a valid port", a) + return nil, nil, nil, fmt.Errorf("%q is not a valid port", a) } hi := lo if b != "" { hi, err = strconv.ParseUint(b, 10, 16) if err != nil { - return nil, nil, fmt.Errorf("%q is not a valid port number", b) + return nil, nil, nil, fmt.Errorf("%q is not a valid port number", b) } } if hi < lo { @@ -1678,7 +1719,29 @@ func parsePortSet(s string) (ports set.Set[uint16], services set.Set[string], _ ret.Add(uint16(i)) } } - return ret, services, nil + return ret, services, targets, nil +} + +// parsePortTarget parses the two halves of a "port:target" serve +// mapping. The target is either a bare port, meaning that port on +// localhost, or a host:port (with an IPv6 host in brackets). It +// returns the served port and the target as a dialable host:port. +func parsePortTarget(portStr, targetStr string) (uint16, string, error) { + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil || port == 0 { + return 0, "", fmt.Errorf("%q is not a valid port in mapping %q", portStr, portStr+":"+targetStr) + } + if numRx.MatchString(targetStr) { + return uint16(port), net.JoinHostPort("localhost", targetStr), nil + } + host, targetPort, err := net.SplitHostPort(targetStr) + if err != nil || host == "" { + return 0, "", fmt.Errorf("target %q in mapping %q is not a port or host:port", targetStr, portStr+":"+targetStr) + } + if p, err := strconv.ParseUint(targetPort, 10, 16); err != nil || p == 0 { + return 0, "", fmt.Errorf("%q is not a valid port in mapping %q", targetPort, portStr+":"+targetStr) + } + return uint16(port), net.JoinHostPort(host, targetPort), nil } // portRanges coalesces the ascending-sorted ports into contiguous