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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,35 @@ HTTP/1.1 200 OK
....
```

### Expose a local Unix-domain socket

To proxy every connection to a local Unix-domain stream socket, pass its
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:

```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:
Expand Down
33 changes: 33 additions & 0 deletions cmd/tailcat/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -590,3 +590,36 @@ func TestGenkeyEmbedDERPMapUnknownRegion(t *testing.T) {
t.Errorf("output = %q; want it to name the missing region", out)
}
}

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)
}
}
}
74 changes: 74 additions & 0 deletions cmd/tailcat/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import (
"io"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -225,6 +227,78 @@ func TestServePorts(t *testing.T) {
}
}

func TestServeUnixSocket(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix-domain sockets are unavailable")
}
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
Expand Down
52 changes: 44 additions & 8 deletions cmd/tailcat/tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ var (
flagKey *string
flagAllow *string
flagFiles *string
flagUnixSocket *string
flagSSHAuthorizedKeys *string
flagPSK *bool
flagVerbose *bool
Expand Down Expand Up @@ -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, 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.")

Expand Down Expand Up @@ -1226,6 +1228,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) {
Expand Down Expand Up @@ -1289,7 +1309,11 @@ 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, 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
var devDERP *derpserver.Server
Expand Down Expand Up @@ -1389,6 +1413,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(unixSocketPort) {
ports = append([]uint16{unixSocketPort}, ports...)
}
if sshServices && !portSet.Contains(22) {
ports = append([]uint16{22}, ports...)
}
Expand All @@ -1414,11 +1441,17 @@ 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}

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
}
Expand All @@ -1440,7 +1473,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:
Expand Down Expand Up @@ -1482,16 +1515,19 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) {
if port == 22 && sshHandler != nil {
return sshHandler
}
if port == unixSocketPort && unixSocket != "" {
return forwardTo("unix", unixSocket)
}
if portSet.Contains(port) {
return tcpForwardTo(fmt.Sprintf("localhost:%v", port))
return forwardTo("tcp", fmt.Sprintf("localhost:%v", port))
}
if execHandler != nil {
return execHandler
}
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) {
Expand Down Expand Up @@ -1520,7 +1556,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 {
Expand Down