Skip to content
Merged
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 @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 67 additions & 1 deletion cmd/tailcat/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"bytes"
"encoding/json"
"errors"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
}
31 changes: 31 additions & 0 deletions cmd/tailcat/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
87 changes: 75 additions & 12 deletions cmd/tailcat/tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, ",") {
Expand All @@ -1644,16 +1673,28 @@ 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
case "exit-node", "exec":
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) {
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading