mirror of
https://github.com/tailscale/tailscale.git
synced 2026-11-07 10:03:06 -05:00
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
144 lines
4.1 KiB
Go
144 lines
4.1 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
|
|
|
|
// Session processes are launched by the connection's privileged monitor.
|
|
// tailscaled keeps the parent I/O descriptors and passes the child's stdio
|
|
// and optional environment pipe via SCM_RIGHTS.
|
|
|
|
package tailssh
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// hangupEscalation is the delay between SIGHUP and SIGKILL on session teardown.
|
|
const hangupEscalation = 5 * time.Second
|
|
|
|
// monExitRegistry matches exit notifications to child handles, including
|
|
// notifications received before the session registers its waiter.
|
|
type monExitRegistry struct {
|
|
mu sync.Mutex
|
|
waiters map[monitorProcess]*monExitStatus
|
|
pending map[monitorProcess]int // exits received before registration
|
|
}
|
|
|
|
// dispatchExit is the monitorClient exit callback, called from the
|
|
// client's read-loop goroutine.
|
|
func (s *monExitRegistry) dispatchExit(pid monitorProcess, code int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if ch, ok := s.waiters[pid]; ok {
|
|
delete(s.waiters, pid)
|
|
ch.complete(code)
|
|
return
|
|
}
|
|
if s.pending == nil {
|
|
s.pending = make(map[monitorProcess]int)
|
|
}
|
|
s.pending[pid] = code
|
|
}
|
|
|
|
// registerSessionExit delivers and removes a pending exit, or registers
|
|
// a waiter for a future notification.
|
|
func (s *monExitRegistry) registerSessionExit(pid monitorProcess, ch *monExitStatus) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if code, ok := s.pending[pid]; ok {
|
|
delete(s.pending, pid)
|
|
ch.complete(code)
|
|
return
|
|
}
|
|
if s.waiters == nil {
|
|
s.waiters = make(map[monitorProcess]*monExitStatus)
|
|
}
|
|
s.waiters[pid] = ch
|
|
}
|
|
|
|
func newMonExitStatus() *monExitStatus {
|
|
return &monExitStatus{done: make(chan struct{})}
|
|
}
|
|
|
|
func (s *monExitStatus) complete(code int) {
|
|
s.code = code
|
|
close(s.done)
|
|
}
|
|
|
|
// launchViaMonitor passes cmd and the child's descriptors to the monitor.
|
|
// After dispatch, cancellation does not abandon the exec reply: a successful
|
|
// launch always registers the child so run can terminate it and wait for exit.
|
|
func (ss *sshSession) launchViaMonitor(cmd *exec.Cmd, envFile *os.File, stdio []*os.File, ctty bool) error {
|
|
ss.monitorLaunchMu.Lock()
|
|
defer ss.monitorLaunchMu.Unlock()
|
|
if len(stdio) != 3 {
|
|
return fmt.Errorf("internal error: monitor exec needs 3 stdio fds, got %d", len(stdio))
|
|
}
|
|
if err := ss.ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
mc, err := ss.conn.connMonitor()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
child, err := mc.ExecIncubator(ss.ctx, cmd.Args, cmd.Env, cmd.Dir, ctty, envFile, stdio...)
|
|
if err != nil {
|
|
return fmt.Errorf("exec on session monitor: %w", err)
|
|
}
|
|
exit := newMonExitStatus()
|
|
ss.conn.monitor.exits.registerSessionExit(child, exit)
|
|
ss.monChild = child
|
|
ss.monExit = exit
|
|
ss.monDead = mc.Gone()
|
|
return nil
|
|
}
|
|
|
|
// waitMonitorProcess returns the child's exit code, or an error if its monitor
|
|
// dies without reporting an exit. ok is false for directly launched sessions.
|
|
func (ss *sshSession) waitMonitorProcess() (code int, ok bool, err error) {
|
|
if ss.monExit == nil {
|
|
return 0, false, nil
|
|
}
|
|
select {
|
|
case <-ss.monExit.done:
|
|
return ss.monExit.code, true, nil
|
|
case <-ss.monDead:
|
|
// The read loop delivers notifications before closing monDead.
|
|
// Recheck here because both cases may have become ready together.
|
|
select {
|
|
case <-ss.monExit.done:
|
|
return ss.monExit.code, true, nil
|
|
default:
|
|
return 1, true, errors.New("session monitor exited before the session process")
|
|
}
|
|
}
|
|
}
|
|
|
|
// hangupMonitorProcess requests monitor termination if mandated is true.
|
|
// It returns false for directly launched sessions. See [sshSession.hangupProcess].
|
|
func (ss *sshSession) hangupMonitorProcess(mandated bool) bool {
|
|
if ss.monChild.PID == 0 {
|
|
return false
|
|
}
|
|
mc := ss.conn.connMonitorIfAny()
|
|
if mc == nil {
|
|
// Only the monitor may signal its child process groups.
|
|
return true
|
|
}
|
|
if !mandated {
|
|
return true
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := mc.Terminate(ctx, ss.monChild, knobDuration(sshHupEscalation(), hangupEscalation)); err != nil {
|
|
ss.logf("ssh: committing session termination: %v", err)
|
|
}
|
|
return true
|
|
}
|