From b8a475f7a4ecf072334722347dab99a3646df203 Mon Sep 17 00:00:00 2001 From: Atirna <288419661+atirna@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:47:12 +0530 Subject: [PATCH 1/2] cmd/tailcat: serve Unix-domain sockets Signed-off-by: Atirna <288419661+atirna@users.noreply.github.com> --- README.md | 18 +++++++++ cmd/tailcat/serve_test.go | 81 +++++++++++++++++++++++++++++++++++++++ cmd/tailcat/tailcat.go | 31 +++++++++++---- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a4fb7e3e4..e075641b0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,24 @@ $ tailcat serve 5555:10.2.200.213:5555 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`. +### Expose a local Unix-domain socket + +To proxy every connection to a local Unix-domain stream socket, pass its +pathname to `serve`. Tailcat does not create or change the socket, so its +filesystem permissions remain under the local service's control. The service +uses port 1, which is what `tailcat` dials when no destination port is given: + +```sh +$ tailcat serve --unix-socket=/run/git-annex/socket +# 🐈 Server listening with new address: tcXXXXXXXXX +``` + +Each client connection gets its own connection to the socket: + +```sh +$ tailcat tcXXXXXXXXX +``` + ### 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/serve_test.go b/cmd/tailcat/serve_test.go index 8bf3c53ff..d9be2df2b 100644 --- a/cmd/tailcat/serve_test.go +++ b/cmd/tailcat/serve_test.go @@ -11,9 +11,11 @@ import ( "io" "net" "net/netip" + "os" "os/exec" "path/filepath" "regexp" + "runtime" "strconv" "strings" "testing" @@ -225,6 +227,85 @@ func TestServePorts(t *testing.T) { } } +func TestServeUnixSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-domain sockets are unavailable") + } + _, err := parseCLI(t, "serve", "--unix-socket=/tmp/tailcat.sock") + if err != nil { + t.Fatalf("parse --unix-socket: %v", err) + } + if got := *flagUnixSocket; got != "/tmp/tailcat.sock" { + t.Fatalf("--unix-socket = %q", got) + } + e := newTestEnv(t) + socket := filepath.Join(t.TempDir(), "backend.sock") + ln, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + if err := os.Chmod(socket, 0600); err != nil { + t.Fatal(err) + } + accepted := make(chan net.Conn, 2) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + accepted <- c + } + }() + + _, addr, serverStderr := e.startServer("serve", "--unix-socket="+socket) + type client struct { + payload string + cmd *exec.Cmd + stdout bytes.Buffer + stderr bytes.Buffer + } + clients := []*client{ + {payload: "first Unix socket client"}, + {payload: "second Unix socket client"}, + } + for _, client := range clients { + client.cmd = e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, addr) + client.cmd.Stdin = strings.NewReader(client.payload) + client.cmd.Stdout = &client.stdout + client.cmd.Stderr = &client.stderr + if err := client.cmd.Start(); err != nil { + t.Fatal(err) + } + } + + for range clients { + select { + case c := <-accepted: + go func() { + io.Copy(c, c) + c.Close() + }() + case <-time.After(30 * time.Second): + t.Fatalf("Unix socket did not receive both clients\nserver stderr:\n%s", serverStderr.String()) + } + } + for _, client := range clients { + if err := client.cmd.Wait(); err != nil { + t.Fatalf("client: %v\nstderr:\n%s\nserver stderr:\n%s", err, client.stderr.String(), serverStderr.String()) + } + if got := client.stdout.String(); got != client.payload { + t.Errorf("client received %q; want %q", got, client.payload) + } + } + if fi, err := os.Stat(socket); err != nil { + t.Fatal(err) + } else if got := fi.Mode().Perm(); got != 0600 { + t.Errorf("socket mode = %o; want 0600", got) + } +} + // 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 d303549a6..2f79e0f43 100644 --- a/cmd/tailcat/tailcat.go +++ b/cmd/tailcat/tailcat.go @@ -55,6 +55,7 @@ var ( flagKey *string flagAllow *string flagFiles *string + flagUnixSocket *string flagSSHAuthorizedKeys *string flagPSK *bool flagVerbose *bool @@ -102,6 +103,7 @@ func newRootCommand() *ff.Command { 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.") 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.") + flagUnixSocket = serveFS.StringLong("unix-socket", "", "pathname of a Unix-domain stream socket to proxy connections to. It is served on port 1, the default port used by a tailcat client without a destination.") 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.") flagPSK = serveFS.BoolLongDefault("psk", true, "include a WireGuard pre-shared key in the tailcat address (recommended). Set false only for shorter addresses and compatibility with tailcat clients v0.5.0 and earlier; this weakens security.") @@ -1298,7 +1300,8 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { } // A server running only named services isn't the empty-port-list // accept-one-connection stdout mode. - oneShotStdout := len(portSet) == 0 && len(services) == 0 + unixSocket := *flagUnixSocket + oneShotStdout := len(portSet) == 0 && len(services) == 0 && unixSocket == "" var reg *tailcfg.DERPRegion var devDERP *derpserver.Server @@ -1398,6 +1401,9 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { // OnTCP gate. if !oneShotStdout && !services.Contains("exit-node") && !services.Contains("exec") { ports := slices.Sorted(maps.Keys(portSet)) + if unixSocket != "" && !portSet.Contains(1) { + ports = append([]uint16{1}, ports...) + } if sshServices && !portSet.Contains(22) { ports = append([]uint16{22}, ports...) } @@ -1433,11 +1439,17 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { return fmt.Sprintf("localhost:%v", port) } - tcpForwardTo := func(ipPortStr string) func(net.Conn) { + forwardTo := func(network, address string) func(net.Conn) { return func(c net.Conn) { - localConn, err := localDialer.Dial("tcp", ipPortStr) + var localConn net.Conn + var err error + if network == "unix" { + localConn, err = net.Dial(network, address) + } else { + localConn, err = localDialer.Dial(network, address) + } if err != nil { - logf("error proxying to %v: %v", ipPortStr, err) + logf("error proxying to %v: %v", address, err) c.Close() return } @@ -1459,7 +1471,7 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { if services.Contains("exit-node") { s.OnTCPForward = func(dst netip.AddrPort) (handler func(net.Conn)) { - return tcpForwardTo(dst.String()) + return forwardTo("tcp", dst.String()) } // Exit-node clients send UDP through the tunnel the same way they // send TCP (DNS, QUIC, ...). Without this, those flows are dropped: @@ -1504,8 +1516,11 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { if port == 22 && sshHandler != nil { return sshHandler } + if port == 1 && unixSocket != "" { + return forwardTo("unix", unixSocket) + } if portSet.Contains(port) { - return tcpForwardTo(tcpTarget(port)) + return forwardTo("tcp", tcpTarget(port)) } if execHandler != nil { return execHandler @@ -1513,7 +1528,7 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { if services.Contains("exit-node") { // Being an exit node includes localhost without needing // to specify all the local port ranges. - return tcpForwardTo(fmt.Sprintf("localhost:%v", port)) + return forwardTo("tcp", fmt.Sprintf("localhost:%v", port)) } if oneShotStdout { return func(c net.Conn) { @@ -1542,7 +1557,7 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { if !portSet.Contains(port) { return nil // RST } - return tcpForwardTo(fmt.Sprintf("localhost:%v", port)) + return forwardTo("tcp", fmt.Sprintf("localhost:%v", port)) } if err := s.Start(); err != nil { From b7ac680c4ade76814dcfe664baec0079e0ac6bec Mon Sep 17 00:00:00 2001 From: Atirna <288419661+atirna@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:44:53 +0530 Subject: [PATCH 2/2] cmd/tailcat: optional ,port suffix for --unix-socket Port 1 stays the default (the destination-less dial port), but the socket can now be served on any port: --unix-socket=/run/x/socket,8022. --- README.md | 17 ++++++++++++++--- cmd/tailcat/cli_test.go | 34 ++++++++++++++++++++++++++++++++++ cmd/tailcat/serve_test.go | 7 ------- cmd/tailcat/tailcat.go | 31 ++++++++++++++++++++++++++----- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e075641b0..65fed0410 100644 --- a/README.md +++ b/README.md @@ -127,13 +127,24 @@ Then on the client, `tailcat forward tcXXXXXXXXX 5555` followed by `adb connect ### Expose a local Unix-domain socket To proxy every connection to a local Unix-domain stream socket, pass its -pathname to `serve`. Tailcat does not create or change the socket, so its -filesystem permissions remain under the local service's control. The service -uses port 1, which is what `tailcat` dials when no destination port is given: +pathname to `serve`, with an optional `,port` suffix for the port to serve it +on. Tailcat does not create or change the socket, so its filesystem +permissions remain under the local service's control. The default is port 1, +which is what `tailcat` dials when no destination port is given: ```sh $ tailcat serve --unix-socket=/run/git-annex/socket # 🐈 Server listening with new address: tcXXXXXXXXX + +# or on a specific port, e.g. to keep port 1 free: +$ tailcat serve --unix-socket=/run/git-annex/socket,8022 +``` + +Clients reach a non-default port the same way as any served port, by naming +it in `forward` (here `8022` is the remote port): + +```sh +$ tailcat forward tcXXXXXXXXX 18022:8022 ``` Each client connection gets its own connection to the socket: diff --git a/cmd/tailcat/cli_test.go b/cmd/tailcat/cli_test.go index 1e57731a0..559cf0099 100644 --- a/cmd/tailcat/cli_test.go +++ b/cmd/tailcat/cli_test.go @@ -653,6 +653,40 @@ func TestParsePortSetTargets(t *testing.T) { } if !maps.Equal(targets, tt.wantTargets) { t.Errorf("parsePortSet(%q) targets = %v; want %v", tt.spec, targets, tt.wantTargets) + + } + } +} + +func TestParseUnixSocketFlag(t *testing.T) { + t.Parallel() + tests := []struct { + in string + wantSocket string + wantPort uint16 + wantErr bool + }{ + {"", "", 1, false}, + {"/run/x/socket", "/run/x/socket", 1, false}, + {"/run/x/socket,8080", "/run/x/socket", 8080, false}, + {",8022", "", 0, true}, + {"/run/x/socket,0", "", 0, true}, + {"/run/x/socket,notaport", "", 0, true}, + } + for _, tt := range tests { + socket, port, err := parseUnixSocketFlag(tt.in) + if tt.wantErr { + if err == nil { + t.Errorf("parseUnixSocketFlag(%q) = %q, %d, nil; want error", tt.in, socket, port) + } + continue + } + if err != nil { + t.Errorf("parseUnixSocketFlag(%q) error: %v", tt.in, err) + continue + } + if socket != tt.wantSocket || port != tt.wantPort { + t.Errorf("parseUnixSocketFlag(%q) = %q, %d; want %q, %d", tt.in, socket, port, tt.wantSocket, tt.wantPort) } } } diff --git a/cmd/tailcat/serve_test.go b/cmd/tailcat/serve_test.go index d9be2df2b..79829e742 100644 --- a/cmd/tailcat/serve_test.go +++ b/cmd/tailcat/serve_test.go @@ -231,13 +231,6 @@ func TestServeUnixSocket(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Unix-domain sockets are unavailable") } - _, err := parseCLI(t, "serve", "--unix-socket=/tmp/tailcat.sock") - if err != nil { - t.Fatalf("parse --unix-socket: %v", err) - } - if got := *flagUnixSocket; got != "/tmp/tailcat.sock" { - t.Fatalf("--unix-socket = %q", got) - } e := newTestEnv(t) socket := filepath.Join(t.TempDir(), "backend.sock") ln, err := net.Listen("unix", socket) diff --git a/cmd/tailcat/tailcat.go b/cmd/tailcat/tailcat.go index 2f79e0f43..59c79b965 100644 --- a/cmd/tailcat/tailcat.go +++ b/cmd/tailcat/tailcat.go @@ -103,7 +103,7 @@ func newRootCommand() *ff.Command { 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.") 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.") - flagUnixSocket = serveFS.StringLong("unix-socket", "", "pathname of a Unix-domain stream socket to proxy connections to. It is served on port 1, the default port used by a tailcat client without a destination.") + flagUnixSocket = serveFS.StringLong("unix-socket", "", "pathname of a Unix-domain stream socket to proxy connections to, with an optional ',port' suffix naming the port it is served on (default 1, the port a tailcat client dials without a destination).") 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.") flagPSK = serveFS.BoolLongDefault("psk", true, "include a WireGuard pre-shared key in the tailcat address (recommended). Set false only for shorter addresses and compatibility with tailcat clients v0.5.0 and earlier; this weakens security.") @@ -1237,6 +1237,24 @@ func splitExecArgs(args []string) (positional, execArgs []string) { return positional, execArgs } +func parseUnixSocketFlag(v string) (socket string, port uint16, err error) { + if v == "" { + return "", 1, nil + } + socket, portStr, hasPort := strings.Cut(v, ",") + if socket == "" { + return "", 0, fmt.Errorf("missing socket pathname in %q", v) + } + if !hasPort { + return socket, 1, nil + } + p, err := strconv.ParseUint(portStr, 10, 16) + if err != nil || p == 0 { + return "", 0, fmt.Errorf("invalid port %q in %q", portStr, v) + } + return socket, uint16(p), nil +} + // server runs a tailcat server. execArgs is the command given after // "--", or nil. func server(logf logger.Logf, serveSpec string, execArgs []string) { @@ -1300,7 +1318,10 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { } // A server running only named services isn't the empty-port-list // accept-one-connection stdout mode. - unixSocket := *flagUnixSocket + unixSocket, unixSocketPort, err := parseUnixSocketFlag(*flagUnixSocket) + if err != nil { + log.Fatalf("--unix-socket: %v", err) + } oneShotStdout := len(portSet) == 0 && len(services) == 0 && unixSocket == "" var reg *tailcfg.DERPRegion @@ -1401,8 +1422,8 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { // OnTCP gate. if !oneShotStdout && !services.Contains("exit-node") && !services.Contains("exec") { ports := slices.Sorted(maps.Keys(portSet)) - if unixSocket != "" && !portSet.Contains(1) { - ports = append([]uint16{1}, ports...) + if unixSocket != "" && !portSet.Contains(unixSocketPort) { + ports = append([]uint16{unixSocketPort}, ports...) } if sshServices && !portSet.Contains(22) { ports = append([]uint16{22}, ports...) @@ -1516,7 +1537,7 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { if port == 22 && sshHandler != nil { return sshHandler } - if port == 1 && unixSocket != "" { + if port == unixSocketPort && unixSocket != "" { return forwardTo("unix", unixSocket) } if portSet.Contains(port) {