diff --git a/tstest/natlab/vmtest/ssh_test.go b/tstest/natlab/vmtest/ssh_test.go index 2e876a93e..fc4ac110b 100644 --- a/tstest/natlab/vmtest/ssh_test.go +++ b/tstest/natlab/vmtest/ssh_test.go @@ -16,37 +16,77 @@ "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() +// TestTailscaleSSH_Ubuntu exercises the Tailscale SSH server ("tailscale up +// --ssh", not a system sshd) on an Ubuntu node, with an Ubuntu node as the SSH +// client. It tests logging in as both root and a non-root user, which +// exercises the incubator's su-based login path. +func TestTailscaleSSH_Ubuntu(t *testing.T) { + env, _, ubuntu, ssh, waitSSH, check := newTailscaleSSHTest(t, "ubuntu", vmtest.Ubuntu2404) if out, err := env.SSHExec(ubuntu, "useradd -m -s /bin/bash tsuser"); err != nil { t.Fatalf("useradd: %v\n%s", err, out) } + ubuntuIP := tailscaleIP4(t, env, ubuntu) + waitSSH("ubuntu", "root", ubuntuIP) + + 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") + + if out, code := ssh("nosuchuser", ubuntuIP, "true"); code == 0 { + t.Errorf("ubuntu nonexistent user: ssh succeeded, want failure:\n%s", out) + } +} + +// TestTailscaleSSH_FreeBSD exercises the Tailscale SSH server ("tailscale up +// --ssh", not a system sshd) on a FreeBSD node, with an Ubuntu node as the SSH +// client. It tests logging in as both root and a non-root user, which +// exercises the incubator's su-based login path. +func TestTailscaleSSH_FreeBSD(t *testing.T) { + env, _, freebsd, ssh, waitSSH, check := newTailscaleSSHTest(t, "freebsd", vmtest.FreeBSD150) + 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) + waitSSH("freebsd", "root", freebsdIP) + + 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") + + if out, code := ssh("nosuchuser", freebsdIP, "true"); code == 0 { + t.Errorf("freebsd nonexistent user: ssh succeeded, want failure:\n%s", out) + } +} + +// TestTailscaleSSH_Gokrazy exercises the gokrazy-specific cases in the +// Tailscale SSH server ("tailscale up --ssh", not a system sshd), with an +// Ubuntu node as the SSH client. 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_Gokrazy(t *testing.T) { + env, _, gokrazy, _, waitSSH, check := newTailscaleSSHTest(t, "gokrazy", vmtest.Gokrazy) + + gokrazyIP := tailscaleIP4(t, env, gokrazy) + waitSSH("gokrazy", "root", gokrazyIP) + + // $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", "/") +} + +func newTailscaleSSHTest(t *testing.T, serverName string, serverOS vmtest.OSImage) (*vmtest.Env, *vmtest.Node, *vmtest.Node, func(string, netip.Addr, string) (string, int), func(string, string, netip.Addr), func(string, string, netip.Addr, string, string)) { + t.Helper() + env := vmtest.New(t) + client := sshTestNode(env, "client", vmtest.Ubuntu2404) + server := sshTestNode(env, serverName, serverOS, vmtest.TailscaleSSH()) + env.Start() // 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 @@ -97,9 +137,6 @@ func TestTailscaleSSH(t *testing.T) { 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() @@ -113,30 +150,7 @@ func TestTailscaleSSH(t *testing.T) { } } - 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", "/") + return env, client, server, ssh, waitSSH, check } // sshTestNode adds a node named name running img behind its own