Files
tailscale/tstest/natlab/vmtest/ssh_test.go
T
Mario MinardiandMario Minardi 8bc4c09a46 tstest/natlab: add FreeBSD test case for SSH test
Add logic to generateFreeBSDUserData to allow for an SSH connection as
root for FreeBSD.

Add FreeBSD test cases to ssh_test that exercise the same paths as the
existing Ubuntu tests but for BSD.

Updates https://github.com/tailscale/corp/issues/44813
Updates https://github.com/tailscale/tailscale/issues/13038

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-07-14 08:28:48 -06:00

167 lines
5.9 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest_test
import (
"fmt"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"github.com/creachadair/mds/shell"
"tailscale.com/tstest/natlab/vmtest"
"tailscale.com/tstest/natlab/vnet"
)
// TestTailscaleSSH exercises the Tailscale SSH server ("tailscale up --ssh",
// not a system sshd) on Ubuntu, FreeBSD, and gokrazy nodes, with an Ubuntu node
// as the SSH client.
//
// On Ubuntu and FreeBSD it tests logging in as both root and a non-root user,
// which exercises the incubator's su-based login path.
//
// On gokrazy it exercises the gokrazy special cases in the SSH code:
// util/osuser hard-codes the login shell to /tmp/serial-busybox/ash and
// falls back to a synthesized root user (uid 0, home dir "/") when user
// lookup fails, since gokrazy has no user database; and the incubator's
// findSU refuses to use su on gokrazy, so sessions are handled in-process.
func TestTailscaleSSH(t *testing.T) {
env := vmtest.New(t)
client := sshTestNode(env, "client", vmtest.Ubuntu2404)
ubuntu := sshTestNode(env, "ubuntu", vmtest.Ubuntu2404, vmtest.TailscaleSSH())
gokrazy := sshTestNode(env, "gokrazy", vmtest.Gokrazy, vmtest.TailscaleSSH())
freebsd := sshTestNode(env, "freebsd", vmtest.FreeBSD150, vmtest.TailscaleSSH())
env.Start()
if out, err := env.SSHExec(ubuntu, "useradd -m -s /bin/bash tsuser"); err != nil {
t.Fatalf("useradd: %v\n%s", err, out)
}
if out, err := env.SSHExec(freebsd, "pw useradd tsuser -m"); err != nil {
t.Fatalf("pw useradd: %v\n%s", err, out)
}
ubuntuIP := tailscaleIP4(t, env, ubuntu)
gokrazyIP := tailscaleIP4(t, env, gokrazy)
freebsdIP := tailscaleIP4(t, env, freebsd)
// ssh runs cmd as user on the node at ip over Tailscale SSH, from the
// client node, returning the combined output and the ssh client's exit
// code. The command run via Env.SSHExec always exits 0 so that its
// transport-error (exit 255) retry loop doesn't kick in when a
// Tailscale SSH connection is expected to fail.
const exitMarker = "tailscale-ssh-exit="
ssh := func(user string, ip netip.Addr, cmd string) (string, int) {
t.Helper()
inner := "ssh" +
" -o StrictHostKeyChecking=no" +
" -o UserKnownHostsFile=/dev/null" +
" -o BatchMode=yes" +
" -o ConnectTimeout=10" +
" -o LogLevel=ERROR" +
" " + user + "@" + ip.String() +
" " + shell.Quote(cmd)
out, err := env.SSHExec(client, inner+" 2>&1; echo "+exitMarker+"$?")
if err != nil {
t.Fatalf("ssh %s@%s: %v\n%s", user, ip, err, out)
}
i := strings.LastIndex(out, exitMarker)
if i == -1 {
t.Fatalf("ssh %s@%s: no exit marker in output:\n%s", user, ip, out)
}
code, err := strconv.Atoi(strings.TrimSpace(out[i+len(exitMarker):]))
if err != nil {
t.Fatalf("ssh %s@%s: bad exit marker in output:\n%s", user, ip, out)
}
return strings.TrimSpace(out[:i]), code
}
// waitSSH waits for the node's Tailscale SSH server to accept a
// connection; the first one can race tailscaled's SSH server startup
// and WireGuard path setup.
waitSSH := func(name, user string, ip netip.Addr) {
t.Helper()
deadline := time.Now().Add(2 * time.Minute)
for {
out, code := ssh(user, ip, "echo ok")
if code == 0 && strings.Contains(out, "ok") {
t.Logf("[%s] Tailscale SSH up as %s@%s", name, user, ip)
return
}
if time.Now().After(deadline) {
t.Fatalf("[%s] Tailscale SSH as %s@%s never came up; last output (exit %d):\n%s", name, user, ip, code, out)
}
time.Sleep(2 * time.Second)
}
}
waitSSH("ubuntu", "root", ubuntuIP)
waitSSH("gokrazy", "root", gokrazyIP)
waitSSH("freebsd", "root", freebsdIP)
check := func(desc, user string, ip netip.Addr, cmd, want string) {
t.Helper()
out, code := ssh(user, ip, cmd)
if code != 0 {
t.Errorf("%s: ssh %s@%s %q exited %d:\n%s", desc, user, ip, cmd, code, out)
return
}
if out != want {
t.Errorf("%s: ssh %s@%s %q = %q, want %q", desc, user, ip, cmd, out, want)
}
}
check("ubuntu root login", "root", ubuntuIP, "id -un && pwd", "root\n/root")
check("ubuntu non-root login", "tsuser", ubuntuIP, "id -un && pwd", "tsuser\n/home/tsuser")
check("freebsd root login", "root", freebsdIP, "id -un && pwd", "root\n/root")
check("freebsd non-root login", "tsuser", freebsdIP, "id -un && pwd", "tsuser\n/home/tsuser")
// A nonexistent user is rejected on Ubuntu...
if out, code := ssh("nosuchuser", ubuntuIP, "true"); code == 0 {
t.Errorf("ubuntu nonexistent user: ssh succeeded, want failure:\n%s", out)
}
// and rejected on FreeBSD...
if out, code := ssh("nosuchuser", freebsdIP, "true"); code == 0 {
t.Errorf("freebsd nonexistent user: ssh succeeded, want failure:\n%s", out)
}
// ... but on gokrazy any username works and becomes root, via the
// synthesized-user fallback in util/osuser (uid 0, home dir "/").
// $0 is the login shell the session ran the command with, which on
// gokrazy is hard-coded rather than looked up with getent. These
// sessions also implicitly exercise the incubator's su-less
// in-process path, since gokrazy has no su.
check("gokrazy root login shell", "root", gokrazyIP, "echo $0", "/tmp/serial-busybox/ash")
check("gokrazy nonexistent user maps to root", "nosuchuser", gokrazyIP, "pwd", "/")
}
// sshTestNode adds a node named name running img behind its own
// easy-NAT network.
func sshTestNode(env *vmtest.Env, name string, img vmtest.OSImage, opts ...any) *vmtest.Node {
n := env.NumNodes()
opts = append([]any{
env.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT),
vmtest.OS(img),
}, opts...)
return env.AddNode(name, opts...)
}
// tailscaleIP4 returns the node's IPv4 Tailscale address.
func tailscaleIP4(t *testing.T, env *vmtest.Env, n *vmtest.Node) netip.Addr {
t.Helper()
st := env.Status(n)
for _, ip := range st.Self.TailscaleIPs {
if ip.Is4() {
return ip
}
}
t.Fatalf("no IPv4 Tailscale address for %s; have %v", n.Name(), st.Self.TailscaleIPs)
panic("unreachable")
}