Files
tailscale/ssh/tailssh/monitor_test.go
T
James Tucker 1334899864 ssh/tailssh: stop killing session processes on graceful disconnects
Monitor teardown SIGHUPed and SIGKILLed every remembered child group
when the transport closed. sshd signals nothing at a non-PTY session
end; background jobs with redirected stdio survive and reparent to
init. Clients rely on that: VS Code Remote-SSH starts its remote
server as a plain background job in the session's group and expects
it to outlive connection drops, and tailscale upgrades run as
non-PTY execs whose work should survive a daemon restart.

Use an explicit graceful monitor close to preserve session jobs on
ordinary disconnect and daemon shutdown. Close PTY masters normally,
letting the terminal driver signal the foreground group, but do not
signal non-PTY jobs. Remove Linux Pdeathsig from session children;
forwarding children retain it and always die with the connection.

Commit duration and recording-failure termination to the monitor
before publishing cancellation or writing the termination message.
The monitor owns per-session HUP-to-KILL escalation even after the
leader exits or the connection closes. Revocation arms connection-wide
cleanup. Pin every termination target's leader until after SIGKILL,
including during teardown, so group signaling cannot hit reused PIDs.
Select teardown targets explicitly and leave graceful survivors alone.

Unexpected RPC loss fails closed. If a termination mandate cannot be
delivered, close the RPC rather than subsequently requesting graceful
teardown; this may also kill sibling jobs on a broken channel. Keep
both the escalation and monitor shutdown waits bounded.

Add coverage for default-HUP graceful survivors, actual duration and
recording-failure termination followed by immediate disconnect, sibling
survival, HUP-exiting leaders with immune group members, broken RPCs,
and leader pinning through teardown's SIGKILL pass.

Fixes #22076

Signed-off-by: James Tucker <james@tailscale.com>

Change-Id: I70b4ef40035330dd542d256ba79d889a12426a74
2026-10-30 16:39:06 -07:00

675 lines
21 KiB
Go

// 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 checks that KillAll followed by Close terminates children.
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)
}
if err := mc.KillAll(mustCtx(t)); err != nil {
t.Fatal(err)
}
start := time.Now()
if err := mc.Close(); err != nil {
t.Fatalf("close: monitor did not exit cleanly: %v", err)
}
// Allow one grace period, one reap period, and scheduling slack.
if d := time.Since(start); d > 2*monTeardownReap+time.Second {
t.Errorf("monitor teardown took %v, want at most %v", d, 2*monTeardownReap+time.Second)
}
// 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
}