From 80ec319660b9c9c6776e53fd5b4876531e70ef28 Mon Sep 17 00:00:00 2001 From: korjavin Date: Fri, 28 Aug 2026 12:10:50 +0000 Subject: [PATCH] cmd/tailcat: use a short deterministic host for ssh's ControlPath Fixes #12. "tailcat ssh" passed the raw ConnBlob (the "user@" argument, potentially 100+ bytes once a full DERP Region is embedded rather than just a RegionID) straight through as the ssh destination hostname. ssh substitutes that literal hostname into %n for ControlPath (commonly "~/.ssh/master-%r@%n:%p"), and the expansion has to fit an AF_UNIX socket path -- about 104-108 bytes total, including the home directory. A long ConnBlob blows that budget on its own, so ControlMaster/ControlPath multiplexing failed with "too long for Unix domain socket" in an otherwise ordinary ssh config, before tailcat itself was even invoked. Give ssh a short, deterministic pseudo-hostname (sshDestHost: an 8-byte SHA-256 prefix of the blob, "tailcat-xxxxxxxxxxxxxxxx", 24 bytes fixed width) instead of the blob itself, while keeping the real blob flowing into ProxyCommand exactly as before -- that's still what does the actual connection routing, so nothing about how tailcat proxies the connection changes. Deterministic hashing (not truncation) matters here: ssh's connection sharing keys a control socket off ControlPath, so the same blob has to keep producing the same short host across invocations for multiplexing to correctly reuse (and not collide across) the right server's socket. Any "user@" prefix on the original argument is preserved and now applied to the short host instead of the raw blob, so -l/%r semantics for a real system sshd (via "tailcat --serve=22") are unaffected. TestSSHDestHost covers: deterministic output for the same blob, no collision between distinct blobs, fixed-width output, and that a realistic ControlPath built from the short host stays comfortably under the ~104-108 byte AF_UNIX limit. go build/vet/test ./... (default and -tags ts_omit_ssh) all pass. --- cmd/tailcat/ssh.go | 38 ++++++++++++++++++++++++++++---- cmd/tailcat/ssh_test.go | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 cmd/tailcat/ssh_test.go 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) + } +}