Files
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

207 lines
6.9 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
package tailssh
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"time"
gliderssh "github.com/tailscale/gliderssh"
)
// monitorProcess identifies one exec child. ID is the exec request ID, which
// distinguishes successive children even when the operating system reuses a PID.
type monitorProcess struct {
PID int
ID int64
}
// monExitStatus publishes an exit code to any number of waiters. The registry
// completes it once; code may be read only after done is closed.
type monExitStatus struct {
done chan struct{}
code int
}
// osSessionState holds the platform-specific state of an [sshSession]'s
// process. It is embedded in [sshSession] and initialized by launchProcess.
//
// cmd describes the invocation on both paths. monChild identifies a monitor
// child; cmd.Process identifies a directly launched child.
type osSessionState struct {
// cmd is the session's incubator or shell process on the direct path.
// It is set but never started on the monitor path.
cmd *exec.Cmd
monChild monitorProcess // zero on the direct path
// monExit publishes the exit status, or 128+signal for a signaled death.
monExit *monExitStatus
// monDead is closed when the monitor's RPC channel is gone, so
// waiting sessions fail instead of blocking.
monDead <-chan struct{}
}
// forwardedEnvChildFD is the fd the incubator child reads the forwarded environment from, sent via
// --env-fd. It must match the payload file's index in launchProcess's ExtraFiles (fd = 3 + index).
const forwardedEnvChildFD = 3
// forwardedEnvFile returns the read end of a pipe holding the JSON-encoded forwarded pairs.
// The read end is passed to the incubator child via exec.Cmd.ExtraFiles to communicate
// secrets and config; the payload only ever exists in memory, never on any filesystem. A
// goroutine writes the payload and closes the write end. Caller must close the read end
// after the child starts.
func forwardedEnvFile(forwardedEnv []string) (*os.File, error) {
if len(forwardedEnv) == 0 {
return nil, errors.New("no forwarded environment")
}
b, err := json.Marshal(forwardedEnv)
if err != nil {
return nil, fmt.Errorf("marshaling forwarded environment: %w", err)
}
r, w, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("creating forwarded environment pipe: %w", err)
}
go func() {
defer w.Close()
// A short read fails the session child-side
_, _ = w.Write(b)
}()
return r, nil
}
// canSwitchToLocalUser reports whether this process can run a session as lu.
// A non-root tailscaled can only run sessions as itself.
func canSwitchToLocalUser(lu *userMeta) error {
if euid := os.Geteuid(); euid != 0 && runtime.GOOS != "plan9" {
if lu.Uid != fmt.Sprint(euid) {
return fmt.Errorf("can't switch to user %q from process euid %v", lu.Username, euid)
}
}
return nil
}
// waitProcess waits for the session's process to exit and returns its exit
// code. A non-nil error means the exit code could not be determined.
func (ss *sshSession) waitProcess() (exitCode int, err error) {
// Monitor path: the child's exit arrives as the monitor's async
// exit notification, with the code documented on monExit.
if code, ok, merr := ss.waitMonitorProcess(); ok {
return code, merr
}
err = ss.cmd.Wait()
if err == nil {
return 0, nil
}
if ee, ok := errors.AsType[*exec.ExitError](err); ok {
return ee.ProcessState.ExitCode(), nil
}
return 1, err
}
// hangupProcess closes the PTY. If mandated is true, it also sends SIGHUP
// and schedules SIGKILL. Otherwise it sends no explicit signals.
func (ss *sshSession) hangupProcess(mandated bool) {
// Register termination before PTY closure can send SIGHUP.
monitored := ss.hangupMonitorProcess(mandated)
if ss.ptyReq != nil {
// Close every PTY master descriptor so the terminal driver sends SIGHUP.
closeAll(ss.wrStdin, ss.rdStdout, ss.ptyResize)
if !mandated {
return
}
}
if monitored {
return
}
if ss.cmd == nil || ss.cmd.Process == nil {
return
}
p := ss.cmd.Process
// The process handle synchronizes signaling with exit and reaping.
if !mandated {
return
}
if ss.ptyReq == nil {
p.Signal(syscall.SIGHUP)
}
time.AfterFunc(knobDuration(sshHupEscalation(), defaultHupEscalation), func() {
p.Signal(syscall.SIGKILL)
})
}
// systemHostKeyFile returns the path of the system's OpenSSH host key of the
// given type ("rsa", "ecdsa", "ed25519") for tailssh to reuse, or "" to
// generate its own key instead. Only root can read the system keys.
func systemHostKeyFile(typ string) string {
if os.Geteuid() != 0 {
return ""
}
return "/etc/ssh/ssh_host_" + typ + "_key"
}
// handleSSHAgentForwarding starts a Unix socket listener and in the background
// forwards agent connections between the listener and the gliderssh.Session.
// On success, it assigns ss.agentListener.
//
// The listener and its containing directory are created by a user-cred
// child through conn.fwdListenAgent. See the docs there for the modes.
func (ss *sshSession) handleSSHAgentForwarding(s gliderssh.Session, lu *userMeta) error {
if !gliderssh.AgentRequested(ss) || !ss.conn.finalAction.AllowAgentForwarding {
return nil
}
// The latched AllowAgentForwarding bit may be stale. See recheckPolicy.
if !ss.conn.recheckPolicy() {
return nil
}
if sshDisableForwarding() {
// TODO(bradfitz): or do we want to return an error here instead so the user
// gets an error if they ran with ssh -A? But for now we just silently
// don't work, like the condition above.
return nil
}
ss.logf("ssh: agent forwarding requested")
ln, socket, err := ss.conn.fwdListenAgent(s.Context(), lu)
if err != nil {
return err
}
// The listener's own address is the kernel's bound path; socket is
// the path the child reported and the monitor validated. They must
// agree: the cleanup removes the directory of the validated path, so
// a child whose report and actual bind disagree is refused.
if got := ln.Addr().String(); got != socket {
ln.Close()
return fmt.Errorf("agent forwarding socket path %q does not match the listener's bound path %q", socket, got)
}
ss.agentListener = ln
go gliderssh.ForwardAgentConnections(ln, s)
return nil
}
// closeAgentForwarding closes the agent forwarding listener and removes the
// temporary directory that holds its socket, so neither outlives the
// session. The removal rules are platform-specific: on the monitor
// platforms the directory was created by a user-cred child while the
// removal runs with the daemon's credentials; see
// removeAgentForwardingDir in fwdmonitor.go and fwdplan9.go.
func (ss *sshSession) closeAgentForwarding() {
ln := ss.agentListener
if ln == nil {
return
}
ss.agentListener = nil
dir := filepath.Dir(ln.Addr().String())
ln.Close()
removeAgentForwardingDir(dir, ss.conn.localUser, ss.logf)
}