Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
// Tests for the ssh-monitor process and its client. They drive a// real monitor child. spawnMonitor re-exec's the current test binary// through the monitorSpawnPrefix seam, and the helper test below// dispatches to beMonitor, the same child dispatch// "tailscaled be-child ssh-monitor" performs in production, minus// cmd/tailscaled's beChild layer.
package tailssh
import ( "context" "errors" "fmt" "io" "os" "os/exec" "runtime" "strconv" "strings" "testing" "time"
"golang.org/x/sys/unix")
// testMonitorPrefix re-execs the test binary, selecting only the helper// test, with the monitor's argv following "--", where the testing// framework's flag parsing stops. -test.timeout=0 because the monitor// child is a long-lived process, not a test with its own deadline.var testMonitorPrefix = []string{"-test.run=^TestSSHMonitorHelperProcess$", "-test.timeout=0", "--", "ssh-monitor"}
// TestSSHMonitorHelperProcess is not a test. It is the entry point of// the re-exec'd monitor child process. It only acts when the binary is// exec'd with the prefix above. In a normal test run there is no "--"// in os.Args and it returns immediately.func TestSSHMonitorHelperProcess(t *testing.T) { var args []string for i, a := range os.Args { if a == "--" && i+2 < len(os.Args) && os.Args[i+1] == "ssh-monitor" { args = os.Args[i+2:] break } } if args == nil { return } if err := beMonitor(args); err != nil { fmt.Fprintln(os.Stderr, "ssh-monitor child:", err) os.Exit(1) } // RPC-EOF teardown completed. Exit cleanly, like the real be-child. os.Exit(0)}
// startTestMonitor spawns a real monitor child bound to the given identity// and registers cleanup that closes it, idempotent with an explicit// Close.func startTestMonitor(t *testing.T, uid, gid int, groups []int) *monitorClient { t.Helper() old := monitorSpawnPrefix monitorSpawnPrefix = testMonitorPrefix t.Cleanup(func() { monitorSpawnPrefix = old }) mc, err := spawnMonitor(monitorExe(t), uint32(uid), uint32(gid), intToU32s(groups), "testuser") if err != nil { t.Fatalf("spawnMonitor: %v", err) } t.Cleanup(func() { if err := mc.Close(); err != nil { t.Errorf("close monitor: %v", err) } }) return mc}
// intToU32s widens a group list to the 32-bit ABI form the monitor's// identity carries. Test identities come from os.Getgroups, which is// int-typed.func intToU32s(gids []int) []uint32 { out := make([]uint32, len(gids)) for i, g := range gids { out[i] = uint32(g) } return out}
func testGroups(t *testing.T) []int { t.Helper() g, err := os.Getgroups() if err != nil { t.Fatalf("os.Getgroups: %v", err) } return g}
func testCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 10*time.Second)}
// trueExe returns an absolute path to a true binary for monitor// children, which require an absolute argv[0]. macOS has no /bin/true;// it ships the binary only in /usr/bin.func trueExe(t *testing.T) string { t.Helper() p, err := exec.LookPath("true") if err != nil { t.Skipf("no true binary on PATH: %v", err) } return p}
type exitNote struct { pid monitorProcess code int}
// collectExits registers an exit callback that appends to a buffered// channel, so the monitor's reader goroutine never blocks delivering a// notification.func collectExits(mc *monitorClient) chan exitNote { ch := make(chan exitNote, 8) mc.SetExitCallback(func(pid monitorProcess, code int) { ch <- exitNote{pid, code} }) return ch}
// readAllWithDeadline reads f to EOF under a read deadline. The timeout// surfaces as t.Fatal rather than a hang.func readAllWithDeadline(t *testing.T, f *os.File, timeout time.Duration) string { t.Helper() if err := f.SetReadDeadline(time.Now().Add(timeout)); err != nil { t.Fatalf("SetReadDeadline: %v", err) } defer f.SetReadDeadline(time.Time{}) var sb strings.Builder buf := make([]byte, 4096) for { n, err := f.Read(buf) sb.Write(buf[:n]) if err == io.EOF { return sb.String() } if err != nil { t.Fatalf("read %s: %v", f.Name(), err) } }}
// runChild execs argv on the monitor with the given env, passes pipes as// the child's stdio, and returns the child's stdout, fully drained// until EOF so the child must exit.func runChild(t *testing.T, mc *monitorClient, argv, env []string) string { t.Helper() // stdin: read end handed to the child. Nothing is written to it. stdin, stdinKeep, err := os.Pipe() if err != nil { t.Fatal(err) } defer stdinKeep.Close() stdout, stdoutW, err := os.Pipe() if err != nil { t.Fatal(err) } stderr, stderrW, err := os.Pipe() if err != nil { t.Fatal(err) } ctx, cancel := testCtx() defer cancel() pid, err := mc.Exec(ctx, argv, env, "/", false, nil, stdin, stdoutW, stderrW) if err != nil { t.Fatalf("exec %v: %v", argv, err) } if pid.PID <= 0 { t.Fatalf("exec %v: pid %v, want > 0", argv, pid) } // The monitor closed its copies. Drop ours so reads see EOF once the // child exits. stdoutW.Close() stderrW.Close() out := readAllWithDeadline(t, stdout, 5*time.Second) if e := readAllWithDeadline(t, stderr, 5*time.Second); e != "" { t.Logf("child %v stderr: %s", argv, e) } stdout.Close() stderr.Close() return out}
func waitExitNote(t *testing.T, ch chan exitNote, timeout time.Duration) exitNote { t.Helper() select { case n := <-ch: return n case <-time.After(timeout): t.Fatalf("no exit notification within %v", timeout) return exitNote{} }}
// waitExitNoteAny waits for a notification on any of the channels. The// delivery time, not the subscription time, decides which callback// receives it.func waitExitNoteAny(t *testing.T, timeout time.Duration, chs ...chan exitNote) exitNote { t.Helper() deadline := time.Now().Add(timeout) for { for _, ch := range chs { select { case n := <-ch: return n default: } } if time.Now().After(deadline) { t.Fatalf("no exit notification within %v", timeout) } time.Sleep(10 * time.Millisecond) }}
func TestMonitorPingRoundTrip(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) ctx, cancel := testCtx() defer cancel() if err := mc.Ping(ctx); err != nil { t.Fatalf("ping: %v", err) }}
func TestMonitorExecRoundTrip(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) exits := collectExits(mc)
// A second subscriber replaces the first only after the exec reply // returns, while the child is still running. The notification is // unsolicited, not tied to the call that spawned it. out := runChild(t, mc, []string{"/bin/sh", "-c", "sleep 0.3; echo hi; exit 0"}, nil) if got := strings.TrimSpace(out); got != "hi" { t.Fatalf("child output = %q, want %q", got, "hi") } exits2 := collectExits(mc) // subscribes after spawn, before exit n := waitExitNoteAny(t, 5*time.Second, exits, exits2) if n.code != 0 { t.Fatalf("exit notification code = %d, want 0", n.code) }
// A failing child reports its code. runChild(t, mc, []string{"/bin/sh", "-c", "exit 7"}, nil) n = waitExitNote(t, exits2, 5*time.Second) if n.code != 7 { t.Fatalf("exit notification code = %d, want 7", n.code) }}
func TestMonitorExecNoStdio(t *testing.T) { // Zero passed descriptors means the child's stdio maps to /dev/null. mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) exits := collectExits(mc) ctx, cancel := testCtx() defer cancel() trueExe := trueExe(t) pid, err := mc.Exec(ctx, []string{trueExe}, nil, "/", false, nil) if err != nil { t.Fatalf("exec %s: %v", trueExe, err) } n := waitExitNote(t, exits, 5*time.Second) if n.pid != pid || n.code != 0 { t.Fatalf("exit notification = pid %d code %d, want pid %d code 0", n.pid, n.code, pid) }}
// TestMonitorExecEmptyEnv pins the env contract: an exec request with// no env gives the child an empty environment, never the monitor's// own environment. In tests that is the test process's environment.func TestMonitorExecEmptyEnv(t *testing.T) { envExe, err := exec.LookPath("env") if err != nil { t.Skipf("no env binary on PATH: %v", err) } mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) out := runChild(t, mc, []string{envExe}, nil) if out != "" { t.Fatalf("child environment = %q, want empty (the monitor's env must not leak)", out) }}
// TestMonitorExecChildInheritsNoFds pins the CLOEXEC hygiene. The// monitor must not leak its inherited RPC fd, or anything else, into// exec'd children. The child lists its own fds by having ls enumerate// the shell's fd directory.func TestMonitorExecChildInheritsNoFds(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("/proc/<pid>/fd listing is linux-only") } mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) out := runChild(t, mc, []string{"/bin/sh", "-c", "ls -1 /proc/$$/fd"}, nil) assertChildHasStdioOnly(t, out)}
// TestMonitorScrubbedSpawnStrays pins the entry scrub. The monitor may// inherit non-close-on-exec descriptors from its parent: tailscaled// cannot guarantee the state of its whole descriptor table. Even so,// the monitor must exec children with a clean descriptor table.func TestMonitorScrubbedSpawnStrays(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("/proc/<pid>/fd listing is linux-only") } // A pipe without close-on-exec, inherited by the monitor child and, // unscrubbed, by everything it execs. stray := make([]int, 2) if err := unix.Pipe(stray); err != nil { t.Fatal(err) } t.Cleanup(func() { unix.Close(stray[0]) unix.Close(stray[1]) }) mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) out := runChild(t, mc, []string{"/bin/sh", "-c", "ls -1 /proc/$$/fd"}, nil) assertChildHasStdioOnly(t, out)}
// assertChildHasStdioOnly fails if the child's /proc/<pid>/fd listing// shows anything beyond stdio.func assertChildHasStdioOnly(t *testing.T, out string) { t.Helper() for _, f := range strings.Fields(out) { n, err := strconv.Atoi(f) if err != nil { t.Fatalf("child output %q: unexpected listing entry %q: %v", out, f, err) } if n > 2 { t.Fatalf("child inherited fd %d (output %q): monitor leaked a descriptor", n, out) } }}
// TestCheckAgentSocketPath pins the shape the agent-forwarding socket// path must have. The path comes from the child and steers a root-run// directory removal on the session side, so anything beyond the shape// the child is supposed to produce is rejected.func TestCheckAgentSocketPath(t *testing.T) { for _, tt := range []struct { path string valid bool }{ {"/tmp/auth-agent123456/listener.sock", true}, {"/tmp/auth-agent/listener.sock", true}, {"/tmp/auth-agent123/inner/listener.sock", false}, // nested dir {"/home/u/auth-agent123/listener.sock", false}, // outside agentDirRoot {"/var/run/auth-agent123/listener.sock", false}, // outside agentDirRoot {"tmp/auth-agent123/listener.sock", false}, // not absolute {"/tmp/auth-agent123/other.sock", false}, // wrong file name {"/tmp/other123/listener.sock", false}, // wrong dir name {"/tmp/auth-agent123/../../etc/listener.sock", false}, // traversal {"/etc/passwd", false}, // nothing like a socket path } { err := checkAgentSocketPath(tt.path) if got := err == nil; got != tt.valid { t.Errorf("checkAgentSocketPath(%q) err = %v, want valid=%v", tt.path, err, tt.valid) } }}
// runIncubatorChild execs argv on the monitor as an incubator request// and returns the child's stdout, fully drained until EOF so the child// must exit.func runIncubatorChild(t *testing.T, mc *monitorClient, argv []string) string { t.Helper() // stdin: read end handed to the child. Nothing is written to it. stdin, stdinKeep, err := os.Pipe() if err != nil { t.Fatal(err) } defer stdinKeep.Close() stdout, stdoutW, err := os.Pipe() if err != nil { t.Fatal(err) } stderr, stderrW, err := os.Pipe() if err != nil { t.Fatal(err) } ctx, cancel := testCtx() defer cancel() pid, err := mc.ExecIncubator(ctx, argv, nil, "/", false, nil, stdin, stdoutW, stderrW) if err != nil { t.Fatalf("incubator exec %v: %v", argv, err) } if pid.PID <= 0 { t.Fatalf("incubator exec %v: pid %v, want > 0", argv, pid) } stdoutW.Close() stderrW.Close() out := readAllWithDeadline(t, stdout, 5*time.Second) if e := readAllWithDeadline(t, stderr, 5*time.Second); e != "" { t.Logf("child %v stderr: %s", argv, e) } stdout.Close() stderr.Close() return out}
// TestMonitorExecIncubatorIdentityBinding pins the identity contract of// incubator execs: the monitor rewrites the request's identity flags to// its spawn-bound identity whatever the request claims, and refuses an// incubator argv that is missing any of them. The child prints the// arguments it was exec'd with, after the monitor's rewrite.func TestMonitorExecIncubatorIdentityBinding(t *testing.T) { groups := testGroups(t) mc := startTestMonitor(t, os.Getuid(), os.Getgid(), groups)
argv := []string{"/bin/sh", "-c", `printf '%s\n' "$@"`, "sh", "--uid=1", "--gid=1", "--groups=1,2", "--local-user=not-us"} out := runIncubatorChild(t, mc, argv) want := strings.Join([]string{ "--uid=" + formatID(uint32(os.Getuid())), "--gid=" + formatID(uint32(os.Getgid())), "--groups=" + joinGids(intToU32s(groups)), "--local-user=testuser", }, "\n") + "\n" if out != want { t.Fatalf("incubator argv after the monitor's rewrite = %q, want %q", out, want) }}
// TestMonitorExecIncubatorIdentityRequired pins that an incubator exec// missing an identity flag is refused before any child is spawned: the// incubator's own flag defaults would otherwise run the child as root.func TestMonitorExecIncubatorIdentityRequired(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) argv := []string{"/bin/sh", "-c", "echo spawned", "sh", "--uid=1"} ctx, cancel := testCtx() defer cancel() if _, err := mc.ExecIncubator(ctx, argv, nil, "/", false, nil); err == nil { t.Fatal("incubator exec without --gid, --groups, and --local-user flags was accepted") }}
func TestMonitorSignal(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) exits := collectExits(mc)
pid, err := mc.Exec(mustCtx(t), []string{"/bin/sleep", "30"}, nil, "/", false, nil) if err != nil { t.Fatalf("exec sleep: %v", err) }
// Signaling an unknown pid must be refused. if err := mc.Signal(mustCtx(t), monitorProcess{PID: pid.PID + 1000000, ID: pid.ID}, "SIGTERM"); err == nil { t.Fatal("signal of a pid that is not a monitor child should fail") } // A bad signal name is an error too. if err := mc.Signal(mustCtx(t), pid, "SIGFAKE"); err == nil { t.Fatal("signal with unknown name should fail") }
if err := mc.Signal(mustCtx(t), pid, "SIGTERM"); err != nil { t.Fatalf("signal: %v", err) } n := waitExitNote(t, exits, 5*time.Second) if n.pid != pid { t.Fatalf("exit notification pid = %d, want %d", n.pid, pid) } if want := 128 + int(unix.SIGTERM); n.code != want { t.Fatalf("exit notification code = %d, want %d (128+SIGTERM)", n.code, want) }}
func mustCtx(t *testing.T) context.Context { t.Helper() ctx, cancel := testCtx() t.Cleanup(cancel) return ctx}
// TestMonitorExecEnvFD pins the exec op's fourth descriptor: an envfd// exec carries stdin/stdout/stderr plus the env-fd pipe, which the// monitor maps to the child's fd 3, os/exec ExtraFiles slot 0, the// fd the incubator's --env-fd names.func TestMonitorExecEnvFD(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) exits := collectExits(mc)
// The payload pipe: what launchProcess's forwardedEnvFile builds. envR, envW, err := os.Pipe() if err != nil { t.Fatal(err) } go func() { defer envW.Close() envW.Write([]byte(`["FORWARDED=pair"]`)) }()
stdout, stdoutW, err := os.Pipe() if err != nil { t.Fatal(err) } defer stdout.Close() ctx, cancel := testCtx() defer cancel() // The child asserts the pair is visible on fd 3, and that its // stdin, fd 0, is a separate stream, the /dev/null fd the request // carried. devNull, err := os.OpenFile(os.DevNull, os.O_RDONLY, 0) if err != nil { t.Fatal(err) } defer devNull.Close() argv := []string{"/bin/sh", "-c", `v=$(cat <&3); [ "$v" = '["FORWARDED=pair"]' ] && echo fd3-ok || echo fd3-bad:$v`} pid, err := mc.Exec(ctx, argv, nil, "/", false, envR, devNull, stdoutW, stdoutW) if err != nil { t.Fatalf("exec envfd: %v", err) } // The monitor closed its copies; drop ours so EOF arrives when the // child exits. stdoutW.Close() envR.Close() out := readAllWithDeadline(t, stdout, 5*time.Second) if got := strings.TrimSpace(out); got != "fd3-ok" { t.Errorf("child output = %q; want fd3-ok", got) } n := waitExitNote(t, exits, 5*time.Second) if n.pid != pid || n.code != 0 { t.Errorf("exit notification = pid %d code %d; want pid %d code 0", n.pid, n.code, pid) }}
// TestMonitorBadExec asserts error propagation for unspawnable and// malformed exec requests.func TestMonitorBadExec(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) ctx, cancel := testCtx() defer cancel()
for _, argv := range [][]string{ {"/nonexistent/binary/definitely-not-here"}, {"sh", "-c", "echo hi"}, // relative argv[0] must be refused {}, } { if _, err := mc.Exec(ctx, argv, nil, "/", false, nil); err == nil { t.Fatalf("exec %v: unexpectedly succeeded", argv) } } // The channel still works after the errors. if err := mc.Ping(ctx); err != nil { t.Fatalf("ping after failed execs: %v", err) }}
// TestMonitorTeardown closes the client and asserts the teardown path.// The monitor exits promptly, through EOF, group SIGHUP, reaping,// SIGKILL of survivors, and exit 0. Its children die with it.func TestMonitorTeardown(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) exits := collectExits(mc)
pid, err := mc.Exec(mustCtx(t), []string{"/bin/sleep", "30"}, nil, "/", false, nil) if err != nil { t.Fatalf("exec sleep: %v", err) }
start := time.Now() if err := mc.Close(); err != nil { t.Fatalf("close: monitor did not exit cleanly: %v", err) } if d := time.Since(start); d > 2*time.Second { t.Errorf("monitor teardown took %v, want < 2s", d) }
// The sleep child must be dead. The monitor was its parent, so it is // not our child. Check its liveness directly: gone or a reaped // zombie on linux. On other platforms probe with signal 0. deadline := time.Now().Add(5 * time.Second) for !processDead(pid.PID) { if time.Now().After(deadline) { t.Fatalf("child %d still alive %v after monitor teardown", pid, time.Since(start)) } time.Sleep(20 * time.Millisecond) } // The peer is gone; drain whatever exit notifications arrived, if // any. select { case n := <-exits: t.Logf("exit notification during teardown: %+v", n) default: }}
// TestMonitorSIGHUPTeardown pins the external-restart path. A SIGHUP// sent directly to the monitor process triggers the same teardown as// RPC EOF. The ping first guarantees the monitor's loop and its SIGHUP// handler are up.func TestMonitorSIGHUPTeardown(t *testing.T) { mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t)) ctx, cancel := testCtx() defer cancel() if err := mc.Ping(ctx); err != nil { t.Fatalf("ping: %v", err) } if err := unix.Kill(mc.Pid(), unix.SIGHUP); err != nil { t.Fatalf("kill monitor SIGHUP: %v", err) } start := time.Now() if err := mc.Close(); err != nil { t.Fatalf("close: monitor did not exit cleanly: %v", err) } if d := time.Since(start); d > 2*time.Second { t.Errorf("monitor SIGHUP teardown took %v, want < 2s", d) }}
// processDead reports whether pid is dead, either gone or a zombie// awaiting reaping by its re-parented parent.func processDead(pid int) bool { if runtime.GOOS == "linux" { b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) if err != nil { return true // gone: reaped } // The state char follows the closing paren of the comm field. if i := strings.LastIndexByte(string(b), ')'); i >= 0 && i+2 < len(b) { switch string(b)[i+2] { case 'Z', 'X': // zombie / dead return true } } return false } return errors.Is(unix.Kill(pid, 0), unix.ESRCH)}
// TestMonitorIdentityBinding pins that the monitor's spawn-bound identity// is the identity its children run under, and that no request content can// influence it. The exec request carries identity-looking environment// values and the child still reports the bound uid.//// Helper children in these tests inherit the monitor's credentials, so// the binding is exercised with the monitor bound to the current uid,// which works both under root and non-root test runs.func TestMonitorIdentityBinding(t *testing.T) { uid := os.Getuid() mc := startTestMonitor(t, uid, os.Getgid(), testGroups(t)) exits := collectExits(mc) env := []string{ "HOME=/", "PATH=/", "USER=root", "LOGNAME=root", "UID=9999", } out := runChild(t, mc, []string{"/usr/bin/id", "-u"}, env) if got := strings.TrimSpace(out); got != strconv.Itoa(uid) { t.Fatalf("child id -u = %q, want bound uid %d", got, uid) } waitExitNote(t, exits, 5*time.Second)}
// monitorExe is the test-side monitor executable, the test binary. The// spawn-prefix seam routes it to the helper.func monitorExe(t *testing.T) string { t.Helper() exe, err := os.Executable() if err != nil { t.Fatal(err) } return exe}