Files
James Tucker 5a05ac11d5 ssh/tailssh: bound session teardown so shutdown can't wedge on it
Bound the server shutdown wait and the output drain after a session
process exits. Descendants can keep stdio open after the session
process exits, so neither wait can depend on EOF alone. Abort the SSH
channel on drain timeout without sending EOF concurrently with an
outstanding output write.

Close all PTY master descriptors on cancellation and let the terminal
driver send SIGHUP. Duplicate the masters with CLOEXEC and O_NONBLOCK
before wrapping them with os.NewFile so the runtime poller can
interrupt pending I/O on Close. Keep the PTY child's blocking mode intact.
Use RawConn.Control for duplication and window changes to synchronize
with Close and avoid restoring blocking mode on the original master
through File.Fd.

Non-PTY sessions receive SIGHUP explicitly. Keep SIGKILL escalation
for monitor sessions and add it for directly launched sessions. Use
os.Process.Signal on the direct path to synchronize signaling with
reaping. Make the escalation delay configurable alongside the
shutdown and I/O drain timeouts.

Use a process-completion channel to stop the cancellation worker on
normal exit, and join the worker before sending exit status so any
cancellation message is written before channel teardown. Cover normal
completion and cancellation through live monitored SSH sessions.

Fixes tailscale/corp#50380
Fixes #22059
Updates #20628
Updates #21827

Signed-off-by: James Tucker <james@tailscale.com>
Change-Id: I2a66741ba156a607dc3df70a79e140f19737b7a5
2026-10-29 15:43:08 -07:00

199 lines
6.7 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 or sends SIGHUP, then schedules SIGKILL
// if the session process has not exited.
func (ss *sshSession) hangupProcess() {
if ss.ptyReq != nil {
// Close every PTY master descriptor so the terminal driver sends SIGHUP.
closeAll(ss.wrStdin, ss.rdStdout, ss.ptyResize)
}
if ss.hangupMonitorProcess() {
return
}
if ss.cmd == nil || ss.cmd.Process == nil {
return
}
p := ss.cmd.Process
// The process handle synchronizes signaling with exit and reaping.
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)
}