diff --git a/cmd/tailcat/ssh.go b/cmd/tailcat/ssh.go index bc0327145..59e175dd0 100644 --- a/cmd/tailcat/ssh.go +++ b/cmd/tailcat/ssh.go @@ -6,6 +6,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "flag" "fmt" "log" @@ -38,9 +40,10 @@ func clientSSHMode(logf logger.Logf) { dst := args[0] // either a derpaddr alone or "user@" cmdArgs := args[1:] - connBlobStr := dst - if strings.Contains(dst, "@") { - _, connBlobStr, _ = strings.Cut(dst, "@") + sshUser, connBlobStr, hasUser := strings.Cut(dst, "@") + if !hasUser { + connBlobStr = sshUser + sshUser = "" } exe, err := os.Executable() if err != nil { @@ -50,6 +53,10 @@ func clientSSHMode(logf logger.Logf) { if err != nil { log.Fatalf("no ssh client found in $PATH: %v", err) } + sshDst := sshDestHost(connBlobStr) + if sshUser != "" { + sshDst = sshUser + "@" + sshDst + } argv := []string{ sshExe, "-o", "UpdateHostKeys no", @@ -57,9 +64,32 @@ func clientSSHMode(logf logger.Logf) { "-o", "UserKnownHostsFile /dev/null", "-o", "LogLevel ERROR", "-o", fmt.Sprintf("ProxyCommand=%s --key=%q %s %s", exe, *flagKey, connBlobStr, portOrIPPort), - dst, + sshDst, } argv = append(argv, cmdArgs...) err = syscall.Exec(sshExe, argv, os.Environ()) log.Fatalf("failed to exec: %v", err) } + +// sshDestHost returns the hostname to give the system ssh client as the +// connection destination for a tailcat ConnBlob. It is a short, deterministic +// function of blob rather than blob itself. +// +// ssh substitutes the literal destination hostname into %n in ControlPath +// (commonly "~/.ssh/master-%r@%n:%p"), and that expansion has to fit in an +// AF_UNIX socket path (~100 bytes total, including the home directory and +// ".ssh/master-" prefix). A ConnBlob can run past that on its own, so ssh +// with connection multiplexing fails with "too long for Unix domain socket" +// before tailcat is ever invoked (#12). The real blob is unaffected: it's +// passed to ProxyCommand as its own argument and still does the actual +// routing, so this string only ever labels the connection for ssh's +// bookkeeping (%n, and StrictHostKeyChecking is already off). +// +// Deterministic hashing, not blob truncation, matters here: ssh's connection +// sharing keys a control socket off ControlPath, so the same blob must +// always produce the same short host or multiplexing silently stops +// reusing (or worse, collides across) the right server. +func sshDestHost(blob string) string { + sum := sha256.Sum256([]byte(blob)) + return "tailcat-" + hex.EncodeToString(sum[:8]) +} diff --git a/cmd/tailcat/ssh_test.go b/cmd/tailcat/ssh_test.go new file mode 100644 index 000000000..bbcf44127 --- /dev/null +++ b/cmd/tailcat/ssh_test.go @@ -0,0 +1,48 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_ssh + +package main + +import ( + "strings" + "testing" +) + +func TestSSHDestHost(t *testing.T) { + // A realistic ConnBlob, taken from an existing test fixture elsewhere + // in this package. + const blob = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu" + + got := sshDestHost(blob) + if !strings.HasPrefix(got, "tailcat-") { + t.Fatalf("sshDestHost(%q) = %q; want tailcat- prefix", blob, got) + } + if len(got) > 24 { + t.Fatalf("sshDestHost(%q) = %q (%d bytes); want a short, fixed-width host", blob, got, len(got)) + } + + // Deterministic: same blob always produces the same host, so ssh's own + // connection sharing (keyed off ControlPath, hence off this string) + // keeps reusing the right control socket across invocations. + if again := sshDestHost(blob); again != got { + t.Fatalf("sshDestHost(%q) is not deterministic: %q != %q", blob, got, again) + } + + // Distinct blobs must not collide, or ssh would multiplex two different + // tailcat servers onto the same control socket. + const otherBlob = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGF2" + if other := sshDestHost(otherBlob); other == got { + t.Fatalf("sshDestHost collided for distinct blobs: %q", got) + } + + // The whole point: it must actually fit an AF_UNIX ControlPath, unlike + // a long ConnBlob used directly. Linux's sun_path is 108 bytes, + // including the trailing NUL; macOS's is 104. Give plenty of headroom + // for a real home directory and username. + const controlPath = "/home/someuser/.ssh/master-someuser@" + "PLACEHOLDER" + ":22" + if got := len(strings.Replace(controlPath, "PLACEHOLDER", sshDestHost(blob), 1)); got >= 100 { + t.Fatalf("example ControlPath is %d bytes; want comfortably under the ~104-108 byte AF_UNIX limit", got) + } +}