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
38 changes: 34 additions & 4 deletions cmd/tailcat/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
package main

import (
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"log"
Expand Down Expand Up @@ -38,9 +40,10 @@ func clientSSHMode(logf logger.Logf) {
dst := args[0] // either a derpaddr alone or "user@<derpaddr>"
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 {
Expand All @@ -50,16 +53,43 @@ 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",
"-o", "StrictHostKeyChecking no",
"-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])
}
48 changes: 48 additions & 0 deletions cmd/tailcat/ssh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading