// 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 }