Files
tailscale/ssh/tailssh/monitor_watchdog_linux_test.go
James Tucker 79d5abe043 ssh/tailssh: launch sessions via the monitor
Launch incubator sessions through the per-connection monitor, passing
stdio and the optional environment pipe via SCM_RIGHTS. tailscaled
retains the parent I/O descriptors. Keep direct launch when no
tailscaled binary is available, and on Plan 9.

Wait for dispatched exec replies even after session cancellation so
the session retains ownership of any child that starts. Publish exit
status through persistent completion state. Identify monitor children
by PID and exec request ID, and validate both when signaling. Remove
raw-PID signaling after monitor teardown.

Normalize incubator identity flags and reject alternate spellings
that could override them. The monitor RPC caller remains trusted to
supply the executable and other arguments; this is not a privilege
boundary against tailscaled.

Pass default TERM and locale values through the environment pipe,
preserving last-value-wins defaults and policy-forwarded precedence.
Use a separately owned close-on-exec PTY descriptor for resizing.

Add regression tests for launch cancellation, exit delivery, signal
identity, descriptor ownership, and environment handling.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 07:49:11 -07:00

114 lines
3.6 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
package tailssh
import (
"context"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// TestMonitorWatchdogHelperProcess is re-exec'd as a monitor-owned session
// or one-shot forwarding child. Both ignore parent-death SIGHUP so only
// orderly monitor teardown (including its SIGKILL pass) can stop them.
func TestMonitorWatchdogHelperProcess(t *testing.T) {
for i, arg := range os.Args {
if arg != "--" || i+2 >= len(os.Args) || os.Args[i+1] != "watchdog-helper" {
continue
}
signal.Ignore(syscall.SIGHUP)
if err := os.WriteFile(os.Args[i+2], []byte(strconv.Itoa(os.Getpid())), 0600); err != nil {
os.Exit(3)
}
time.Sleep(25 * time.Second)
os.Exit(0)
}
}
// TestMonitorCloseWatchdogForwardChild reproduces the case where a stalled
// forwarding op consumes the watchdog before the monitor can tear down its
// HUP-immune session and forwarding child groups.
func TestMonitorCloseWatchdogForwardChild(t *testing.T) {
markerDir := t.TempDir()
forwardMarker := filepath.Join(markerDir, "forward-pid")
t.Setenv("TS_TEST_SSH_FWD_PREFIX", strings.Join([]string{
"-test.run=^TestMonitorWatchdogHelperProcess$", "-test.timeout=0", "--", "watchdog-helper", forwardMarker,
}, " "))
mc := startTestMonitor(t, os.Getuid(), os.Getgid(), testGroups(t))
// The session must be running and ignoring HUP before Close starts.
sessionMarker := filepath.Join(markerDir, "session-pid")
ctx, cancel := testCtx()
session, err := mc.Exec(ctx, []string{monitorExe(t), "-test.run=^TestMonitorWatchdogHelperProcess$", "-test.timeout=0", "--", "watchdog-helper", sessionMarker}, nil, "/", false, nil)
cancel()
if err != nil {
t.Fatalf("start session: %v", err)
}
defer func() {
// Only signal this test's own session group, never a reused PID.
if b, err := os.ReadFile(sessionMarker); err == nil && strings.TrimSpace(string(b)) == strconv.Itoa(session.PID) && !processDead(session.PID) {
_ = syscall.Kill(-session.PID, syscall.SIGKILL)
}
}()
waitWatchdogMarker(t, sessionMarker, session.PID)
requestDone := make(chan error, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 16*time.Second)
defer cancel()
_, err := mc.Listen(ctx, "tcp", "127.0.0.1:0", false)
requestDone <- err
}()
forwardPID := waitWatchdogMarker(t, forwardMarker, 0)
defer func() {
if !processDead(forwardPID) {
_ = syscall.Kill(forwardPID, syscall.SIGKILL)
}
}()
start := time.Now()
err = mc.Close()
t.Logf("Close duration=%v error=%v; session=%d forward=%d", time.Since(start), err, session.PID, forwardPID)
if err != nil {
t.Errorf("monitor failed to complete teardown: %v", err)
}
select {
case err := <-requestDone:
t.Logf("inflight Listen returned: %v", err)
case <-time.After(2 * time.Second):
t.Error("inflight Listen did not return after Close")
}
if !processDead(forwardPID) {
t.Errorf("HUP-immune forwarding child %d survived Close", forwardPID)
}
if !processDead(session.PID) {
t.Errorf("HUP-immune session child %d survived Close", session.PID)
}
}
func waitWatchdogMarker(t *testing.T, path string, wantPID int) int {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if b, err := os.ReadFile(path); err == nil {
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil || pid <= 0 || (wantPID > 0 && pid != wantPID) {
t.Fatalf("invalid helper PID marker %q: %q", path, b)
}
return pid
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("helper did not write PID marker %q", path)
return 0
}