Files
tailscale/ssh/tailssh/monitor.go
James Tucker 9e396012a8 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.

Now only SSHTerminationError causes (revocation, session-duration,
recording failure) kill the process tree. Everything else — client
disconnect, daemon shutdown — closes I/O without signals: PTY masters
close and the driver SIGHUPs the foreground group, non-PTY processes
exit on EOF and SIGPIPE or keep running. Monitor teardown kills only
forwarding children, unless revocation armed the new kill-all op
first, since its per-session kicks race the close. No zombie leak:
the monitor reaps its table, and reparented survivors are reaped by
init.

Fixes #22076

Signed-off-by: James Tucker <james@tailscale.com>

Change-Id: I70b4ef40035330dd542d256ba79d889a12426a74
2026-10-30 04:24:56 +00:00

853 lines
28 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
// The per-connection privileged ssh-monitor process. tailscaled spawns
// it, as "tailscaled be-child ssh-monitor", once a connection is
// authorized, and speaks the protocol of monitor_rpc.go over the
// socketpair fd it inherits at spawn.
//
// The monitor owns every child process the connection spawns. Session
// children are the incubator in incubator.go, spawned with the
// monitor's credentials because the incubator needs root to exec
// login and su and to register the login session. Forwarding children
// in fwdchild.go are Unix dial relays and one-shot TCP dial/bind workers, spawned with the
// connection user's credentials.
//
// The connection's identity, the uid, gid, and groups of its
// localUser, is fixed in argv at spawn. Only the event loop touches
// the RPC channel, the child table, and file descriptors.
//
// RPC EOF or an external SIGHUP tears the connection down. Every child
// group is SIGHUPed, then a bounded reap sweep runs, and surviving
// groups are SIGKILLed before the monitor exits.
package tailssh
import (
"errors"
"flag"
"fmt"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/sys/unix"
)
const (
// monExecOpDeadline is the maximum accepted duration of an exec spawn.
// It is checked after cmd.Start returns; it does not interrupt Start
// or limit the child's lifetime.
monExecOpDeadline = 30 * time.Second
// monFwdOpDeadline bounds a forwarding child from spawn to
// result. On expiry the child's group is SIGKILLed.
monFwdOpDeadline = 10 * time.Second
// monTeardownReap bounds each reap phase in teardown. The monitor
// stops waiting after this interval even if children remain.
monTeardownReap = 2 * time.Second
)
// monChild is one entry of the monitor's child table, keyed by pid.
// Every child leads its own group, so pgid equals pid. A oneshot
// child, a dialer or listener, reports its outcome in its op's reply,
// and its exit is reaped silently by the sweep after its SIGCHLD.
// A Unix dialer stays alive to relay bytes after reporting its outcome.
type monChild struct {
pgid int // the child's process group. Equal to its pid.
id int64 // exec request ID, or zero for a forwarding child
oneshot bool // dialer or listener child. Outcome carried by the op reply.
}
// monitor is the running ssh-monitor process state, owned entirely by
// the event loop goroutine.
type monitor struct {
conn *rpcConn
args monitorArgs // the spawn-bound identity
children map[int]*monChild
hup chan os.Signal
broken bool // a reply or notification write failed. Leave the loop.
// killAll is set by the kill-all op on revocation. teardown then
// kills session children too.
killAll bool
// pendingFds holds relayed dial/listen descriptors, keyed by request
// ID, from the reply write until tailscaled's fdack receipt. See
// replyFD and the monitor_rpc.go header for the darwin kernel
// behavior this coverage exists for.
pendingFds map[int64][]int
}
// monitorArgs are the monitor's spawn arguments, in the style of
// incubatorArgs. The identity fields are the connection's bound
// identity, fixed at spawn.
type monitorArgs struct {
// uid, gid, and gids are the Linux ABI's 32-bit ids, as passed in
// argv at spawn.
uid uint32
gid uint32
gids []uint32
localUser string
rpcFD int
}
// formatID renders one 32-bit Linux uid or gid as the decimal string
// form the monitor's and the incubator's argv flags carry.
func formatID(v uint32) string {
return strconv.FormatUint(uint64(v), 10)
}
// parseMonitorArgs parses the arguments following "be-child ssh-monitor".
func parseMonitorArgs(args []string) (monitorArgs, error) {
var ma monitorArgs
var uidStr, gidStr, groups string
flags := flag.NewFlagSet("ssh-monitor", flag.ContinueOnError)
flags.StringVar(&uidStr, "uid", "", "uid of the connection's local user (bound at spawn)")
flags.StringVar(&gidStr, "gid", "", "gid of the connection's local user (bound at spawn)")
flags.StringVar(&groups, "groups", "", "comma-separated supplementary gids of the local user")
flags.StringVar(&ma.localUser, "local-user", "", "name of the connection's local user")
flags.IntVar(&ma.rpcFD, "rpc-fd", -1, "inherited AF_UNIX stream fd of the RPC channel")
if err := flags.Parse(args); err != nil {
return ma, err
}
if uidStr == "" || gidStr == "" {
return ma, errors.New("--uid and --gid are required")
}
if ma.rpcFD < 3 {
return ma, fmt.Errorf("invalid --rpc-fd %d: must be >= 3 (an ExtraFiles fd)", ma.rpcFD)
}
// Ids are parsed at their ABI width: unsigned 32-bit, the full range
// the kernel's uid_t and gid_t carry.
u64, err := strconv.ParseUint(uidStr, 10, 32)
if err != nil {
return ma, fmt.Errorf("unable to parse uid %q: %w", uidStr, err)
}
ma.uid = uint32(u64)
g64, err := strconv.ParseUint(gidStr, 10, 32)
if err != nil {
return ma, fmt.Errorf("unable to parse gid %q: %w", gidStr, err)
}
ma.gid = uint32(g64)
for g := range strings.SplitSeq(groups, ",") {
if g == "" {
continue
}
gid, err := strconv.ParseUint(g, 10, 32)
if err != nil {
return ma, fmt.Errorf("unable to parse group id %q: %w", g, err)
}
ma.gids = append(ma.gids, uint32(gid))
}
return ma, nil
}
// beMonitor is the entrypoint of the "tailscaled be-child ssh-monitor"
// subcommand, the per-connection privileged monitor process.
func beMonitor(args []string) error {
ma, err := parseMonitorArgs(args)
if err != nil {
return err
}
// Scrub every descriptor from 3 up to close-on-exec at entry,
// before anything else runs. The monitor inherits its parent's
// whole non-close-on-exec descriptor table, and a privileged
// monitor must not leak any of it into the children it execs. The
// RPC fd is included: the root-privileged channel must never
// survive into an exec'd child either.
if err := scrubFds(); err != nil {
return fmt.Errorf("ssh-monitor: scrub fds: %w", err)
}
conn, err := wrapUnixConn(ma.rpcFD) // consumes the raw fd
if err != nil {
return fmt.Errorf("ssh-monitor: rpc fd: %w", err)
}
// An external SIGHUP gets the same teardown as RPC EOF. The loop
// selects on it between events. Delivery waits for the current op
// to finish.
hup := make(chan os.Signal, 1)
signal.Notify(hup, unix.SIGHUP)
defer signal.Stop(hup)
m := &monitor{
conn: newRPCConn(conn),
args: ma,
children: make(map[int]*monChild),
pendingFds: make(map[int64][]int),
hup: hup,
}
return m.loop()
}
// spawnRPCChild spawns exe with argv. The child receives one end of a
// fresh AF_UNIX SOCK_STREAM socketpair as fd 3. The parent end is
// returned as a *net.UnixConn via wrapUnixConn, along with the started
// child. prefix names the child class in error messages.
//
// Both raw socketpair ends are close-on-exec at creation, as is the
// parent end's wrapUnixConn dup.
//
// env is used exactly as given. A nil env gives the child an empty
// environment, never the spawner's. Callers give the privileged
// monitor the daemon's environment without the systemd service
// variables, and give user-identity children an empty environment.
// The daemon's environment never reaches a child running as the
// connection's user.
func spawnRPCChild(prefix, exe string, argv, env []string, attr *syscall.SysProcAttr) (*exec.Cmd, *net.UnixConn, error) {
pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
if err != nil {
return nil, nil, fmt.Errorf("%s: socketpair: %w", prefix, err)
}
for _, fd := range pair {
if _, err := unix.FcntlInt(uintptr(fd), unix.F_SETFD, unix.FD_CLOEXEC); err != nil {
unix.Close(pair[0])
unix.Close(pair[1])
return nil, nil, fmt.Errorf("%s: fcntl(F_SETFD) on socketpair: %w", prefix, err)
}
}
childEnd := os.NewFile(uintptr(pair[1]), prefix+"-child")
parentConn, err := wrapUnixConn(pair[0]) // consumes the raw parent end
if err != nil {
childEnd.Close()
return nil, nil, fmt.Errorf("%s: parent end: %w", prefix, err)
}
cmd := exec.Command(exe, argv...)
cmd.Env = env
if cmd.Env == nil {
cmd.Env = []string{} // nil would inherit the spawner's environment
}
cmd.ExtraFiles = []*os.File{childEnd} // lands as fd 3 in the child
cmd.SysProcAttr = attr
if err := cmd.Start(); err != nil {
parentConn.Close()
childEnd.Close()
return nil, nil, fmt.Errorf("%s: start: %w", prefix, err)
}
// The child has its own dup since Start. Drop ours.
childEnd.Close()
return cmd, parentConn, nil
}
// monEvent carries one inbound RPC frame from the reader goroutine to
// the loop. An error ends the channel.
type monEvent struct {
f rpcFrame
fds []int
err error
}
// loop is the monitor's event loop. A reader goroutine blocks in
// readFrame on the RPC channel. The loop itself waits on inbound
// frames, external SIGHUP, and SIGCHLD, and runs the sole op handler,
// reaper, and teardown.
func (m *monitor) loop() error {
events := make(chan monEvent, 1)
go func() {
for {
f, fds, err := m.conn.readFrame()
events <- monEvent{f: f, fds: fds, err: err}
if err != nil {
return
}
}
}()
// Child exits arrive as SIGCHLD. The sweep below reaps the exiting
// children, so signal coalescing is safe.
sigchld := make(chan os.Signal, 1)
signal.Notify(sigchld, syscall.SIGCHLD)
defer signal.Stop(sigchld)
for {
select {
case <-m.hup:
m.teardown()
return nil
case <-sigchld:
m.sweep(true)
case ev := <-events:
if ev.err != nil {
// EOF, an I/O error, or a protocol error means the
// connection is over.
m.teardown()
return nil
}
m.handle(ev.f, ev.fds)
if m.broken {
m.teardown()
return nil
}
}
}
}
// handle dispatches one request frame. It runs on the loop goroutine.
// Descriptors not consumed by the op are closed here.
func (m *monitor) handle(f rpcFrame, fds []int) {
if f.ID == 0 {
// Unsolicited frames are dropped and their descriptors closed.
closeFds(fds)
return
}
switch f.Op {
case opPing:
closeFds(fds)
m.reply(rpcOKReply(f.ID, opPing))
case opExec:
m.handleExec(f, fds)
case opSignal:
closeFds(fds)
m.handleSignal(f)
case opKillAll:
closeFds(fds)
m.handleKillAll(f)
case opFdAck:
closeFds(fds)
if fds, ok := m.pendingFds[f.ID]; ok {
delete(m.pendingFds, f.ID)
closeFds(fds)
}
case opDial:
closeFds(fds)
m.handleDial(f)
case opListen:
closeFds(fds)
m.handleListen(f)
default:
closeFds(fds)
m.reply(rpcErrReply(f.ID, f.Op, fmt.Errorf("unknown op %q", f.Op)))
}
}
// incubatorArgv requires and normalizes identity flags to the connection's
// bound identity. It accepts only the canonical --name=value spelling for
// these flags, rejecting aliases that could override a normalized value.
// The caller is trusted to supply the executable and remaining arguments.
func (m *monitor) incubatorArgv(argv []string) ([]string, error) {
values := map[string]string{
"--uid=": formatID(m.args.uid),
"--gid=": formatID(m.args.gid),
"--groups=": joinGids(m.args.gids),
"--local-user=": m.args.localUser,
}
out := slices.Clone(argv)
seen := make(map[string]bool, len(values))
for i, a := range argv[1:] {
name, _, _ := strings.Cut(strings.TrimLeft(a, "-"), "=")
if _, identity := values["--"+name+"="]; identity && !strings.HasPrefix(a, "--"+name+"=") {
return nil, fmt.Errorf("incubator exec: non-canonical identity flag %q", a)
}
for flag, val := range values {
if strings.HasPrefix(a, flag) {
out[i+1] = flag + val
seen[flag] = true
}
}
}
for flag := range values {
if !seen[flag] {
return nil, fmt.Errorf("incubator exec: argv has no %s flag", strings.TrimSuffix(flag, "="))
}
}
return out, nil
}
// joinGids renders a supplementary group list as the comma-separated
// form the incubator's --groups flag takes.
func joinGids(gids []uint32) string {
parts := make([]string, len(gids))
for i, g := range gids {
parts[i] = formatID(g)
}
return strings.Join(parts, ",")
}
// handleExec spawns one child, mapping the request's file descriptors to
// the child's stdio. If cmd.Start returns after monExecOpDeadline, the
// child is killed and the request fails.
func (m *monitor) handleExec(f rpcFrame, fds []int) {
start := time.Now()
fail := func(err error) {
closeFds(fds)
m.reply(rpcErrReply(f.ID, opExec, err))
}
if len(f.Argv) == 0 {
fail(errors.New("exec: empty argv"))
return
}
// An envfd exec must carry exactly four descriptors, stdio plus
// the --env-fd pipe. This re-check catches the fourth fd without
// the flag, which the codec's count check cannot see.
if f.EnvFd != (len(fds) == 4) {
fail(fmt.Errorf("exec: %d fds with envfd=%v; want 4 fds with envfd, or up to 3 without", len(fds), f.EnvFd))
return
}
// argv[0] must be an absolute path.
if !filepath.IsAbs(f.Argv[0]) {
fail(fmt.Errorf("exec: argv[0] must be an absolute path, got %q", f.Argv[0]))
return
}
// The incubator starts with monitor credentials so it can run login
// or su and register the login session before dropping privileges.
// Normalize its identity flags to the connection's bound identity.
if f.Incubator {
argv, err := m.incubatorArgv(f.Argv)
if err != nil {
fail(err)
return
}
f.Argv = argv
}
cmd := exec.Command(f.Argv[0], f.Argv[1:]...)
// Env is used exactly as given. An exec request with no env gives
// the child an empty environment: the monitor's own environment
// belongs to tailscaled, and a privileged monitor must not leak it
// into connection children.
cmd.Env = f.Env
if cmd.Env == nil {
cmd.Env = []string{} // nil would inherit the monitor's environment
}
cmd.Dir = monChildDir(f.Dir)
// monStdio wraps the stdio descriptors, fds[:3], with /dev/null
// for any missing stream, and takes ownership of them. The env-fd
// descriptor, fds[3], present exactly when EnvFd is set, is
// wrapped separately below. A failure before ownership moves
// closes every descriptor exactly once.
files, err := monStdio(fds[:min(3, len(fds))])
if err != nil {
// monStdio's error path already closed every descriptor it was
// given. The env-fd descriptor beyond the sub-slice is still
// ours. Nil the slice so fail's closeFds is a no-op rather than
// a double close.
closeFds(fds[min(3, len(fds)):])
fds = nil
fail(err)
return
}
// From here the raw stdio ints are owned by files, each wrapped in
// an *os.File that closes it exactly once.
var envFile *os.File
if len(fds) == 4 {
envFile = os.NewFile(uintptr(fds[3]), "ssh-monitor-child-envfd")
defer envFile.Close()
}
// The files now own these descriptors; fail must not close them again.
fds = nil
if envFile != nil {
cmd.ExtraFiles = []*os.File{envFile}
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = files[0], files[1], files[2]
cmd.SysProcAttr = monChildAttr(f.Ctty)
// Re-scrub before the fork: anything opened or received since entry
// that missed close-on-exec marking must not reach the child. The
// stdio descriptors below survive the scrub: the child receives
// fresh non-close-on-exec dups on 0, 1, and 2.
if err := scrubFds(); err != nil {
monCloseFiles(files)
fail(err)
return
}
if err := cmd.Start(); err != nil {
monCloseFiles(files)
fail(err)
return
}
// Close our copies of the stdio descriptors. The child holds its
// own dups onto 0..2.
monCloseFiles(files)
if time.Since(start) > monExecOpDeadline {
// Kill the child and reply an error instead of ok. The kill is
// bounded by killGroupAndReap.
pid := cmd.Process.Pid
_ = killGroup(pid, unix.SIGKILL)
killGroupAndReap(pid)
fail(fmt.Errorf("exec: spawn-and-reply exceeded %s deadline", monExecOpDeadline))
return
}
// Its pgid is its pid. See monChildAttr.
m.children[cmd.Process.Pid] = &monChild{pgid: cmd.Process.Pid, id: f.ID}
m.reply(rpcExecReply(f.ID, cmd.Process.Pid))
}
// killGroupAndReap reaps a just-SIGKILLed child with wait4(WNOHANG) for
// up to 500ms, surrendering after the budget. It returns whether the
// child was reaped.
func killGroupAndReap(pid int) (reaped bool) {
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
var ws unix.WaitStatus
wpid, err := unix.Wait4(pid, &ws, unix.WNOHANG, nil)
if err != nil || wpid == pid {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
// handleSignal validates both PID and generation before signaling a child group.
func (m *monitor) handleSignal(f rpcFrame) {
replyErr := func(err error) { m.reply(rpcErrReply(f.ID, opSignal, err)) }
c, ok := m.children[f.Pid]
if !ok || f.ChildID == 0 || c.id != f.ChildID {
replyErr(fmt.Errorf("signal: pid %d with child ID %d is not a monitor child", f.Pid, f.ChildID))
return
}
sig := unix.SignalNum(f.Sig)
if sig == 0 {
replyErr(fmt.Errorf("signal: unknown signal %q", f.Sig))
return
}
if err := killGroup(c.pgid, sig); err != nil {
replyErr(err)
return
}
m.reply(rpcOKReply(f.ID, opSignal))
}
// killGroup signals the process group pgid with kill(-pgid, sig), so
// a child's descendants, such as a shell's subprocesses, die with it.
func killGroup(pgid int, sig unix.Signal) error {
return unix.Kill(-pgid, sig)
}
// handleDial serves the dial op with the connection's bound identity.
// Unix dials return a private data socket and lifetime pipe for a relay child;
// TCP dials return the connected socket and the child exits after handoff.
func (m *monitor) handleDial(f rpcFrame) {
if err := validateFwdDial(f.Net, f.Addr); err != nil {
m.reply(rpcErrReply(f.ID, opDial, err))
return
}
m.fwdOp(f.ID, opDial, fwdChildSpec{op: "dial", net: f.Net, addr: f.Addr}, func(string) rpcFrame {
return rpcOKReply(f.ID, opDial)
})
}
// handleListen serves the listen op. A one-shot listener child runs
// with the bound identity. See fwdchild.go for the socket and dir
// construction. The reply carries the resulting path or address.
func (m *monitor) handleListen(f rpcFrame) {
spec := fwdChildSpec{op: "listen"}
switch {
case f.Agent:
spec.agent = true
case f.Net == "unix":
if f.Addr == "" || !filepath.IsAbs(f.Addr) {
m.reply(rpcErrReply(f.ID, opListen, errors.New("listen unix: addr must be an absolute socket path")))
return
}
spec.net, spec.addr, spec.unlink = "unix", f.Addr, f.Unlink
case f.Net == "tcp":
if f.Addr == "" {
m.reply(rpcErrReply(f.ID, opListen, errors.New("listen tcp: addr is required")))
return
}
spec.net, spec.addr = "tcp", f.Addr
default:
m.reply(rpcErrReply(f.ID, opListen, fmt.Errorf("listen: network %q not supported (unix, tcp, or agent)", f.Net)))
return
}
m.fwdOp(f.ID, opListen, spec, func(payload string) rpcFrame {
rl := rpcFrame{ID: f.ID, Op: opListen, OK: true}
switch {
case f.Agent:
// The path comes from the child, which runs as the
// connection's user, and tailscaled removes the socket's
// directory as root when the session closes. Only the shape
// the child is supposed to produce is acceptable.
if err := checkAgentSocketPath(payload); err != nil {
return rpcErrReply(f.ID, opListen, err)
}
rl.Path = payload // the agent socket path
case f.Net == "unix":
rl.Path = f.Addr // echo the bound path
case f.Net == "tcp":
rl.Addr = payload // echo the bound address (a 127.0.0.1:0 request yields the concrete port)
}
return rl
})
}
// checkAgentSocketPath validates the socket path a forwarding child
// reports for the agent-forwarding listen op. The child runs as the
// connection's user, and tailscaled removes the socket's directory as
// root when the session closes, so a misbehaving child must not be able
// to steer that removal elsewhere: the path must be a listener.sock
// directly inside an auth-agent temp directory directly inside
// agentDirRoot.
func checkAgentSocketPath(p string) error {
if !filepath.IsAbs(p) {
return fmt.Errorf("agent socket path %q is not absolute", p)
}
if filepath.Base(p) != "listener.sock" {
return fmt.Errorf("agent socket path %q does not name listener.sock", p)
}
dir := filepath.Dir(p)
if filepath.Dir(dir) != agentDirRoot {
return fmt.Errorf("agent socket %q is not directly inside %s", p, agentDirRoot)
}
if base := filepath.Base(dir); !strings.HasPrefix(base, "auth-agent") {
return fmt.Errorf("agent socket %q is not inside an auth-agent directory", p)
}
return nil
}
// fwdOp runs one forwarding op. It spawns the child with the
// bound identity, waits for its result under monFwdOpDeadline, and
// relays the child's socket descriptor in the ok reply. The wait blocks
// the loop for at most the deadline.
func (m *monitor) fwdOp(id int64, op string, spec fwdChildSpec, reply func(payload string) rpcFrame) {
start := time.Now()
cmd, result, err := fwdSpawnChild(m, spec)
if err != nil {
m.reply(rpcErrReply(id, op, err))
return
}
child := &monChild{pgid: cmd.Process.Pid, oneshot: true}
m.children[cmd.Process.Pid] = child
wantFDs := 1
if op == opDial && spec.net == "unix" {
wantFDs = 2 // data socket and lifetime pipe writer
}
ok, payload, fds, rerr := fwdReadResult(result, start.Add(monFwdOpDeadline), wantFDs)
result.Close()
if rerr != nil {
m.killChild(child)
m.reply(rpcErrReply(id, op, fmt.Errorf("%s: %w", op, rerr)))
return
}
if !ok {
// The child's failure reason is preserved verbatim. It is the
// child's own error, e.g. the kernel's EACCES for the bound user.
m.reply(rpcErrReply(id, op, errors.New(payload)))
return
}
// Listener and TCP dial children exit after the handoff; Unix dial
// children relay until the connection closes. The sweep silently reaps
// each child after its SIGCHLD.
m.replyFD(reply(payload), fds...)
}
// killChild SIGKILLs a forwarding child's group and reaps it with
// killGroupAndReap, a bounded non-blocking wait. A child that survives
// SIGKILL for the budget is left in the table, reaped by the sweep
// whenever it dies.
func (m *monitor) killChild(c *monChild) {
_ = killGroup(c.pgid, unix.SIGKILL)
if killGroupAndReap(c.pgid) {
delete(m.children, c.pgid)
}
}
// replyFD writes a reply carrying descriptors and retains them in pendingFds
// until tailscaled acknowledges receipt. See monitor_rpc.go for the Darwin
// socket-lifetime requirement. A write failure closes the descriptors and
// marks the RPC channel broken.
func (m *monitor) replyFD(f rpcFrame, fds ...int) {
err := m.conn.writeFrame(f, fds...)
if err != nil {
closeFds(fds)
m.broken = true
return
}
m.pendingFds[f.ID] = fds
}
// sweep reaps exited children with wait4(WNOHANG) and drops them from
// the child table. With notify set, it emits one unsolicited exit
// notification per reaped pid.
func (m *monitor) sweep(notify bool) {
for pid, c := range m.children {
var ws unix.WaitStatus
wpid, err := unix.Wait4(pid, &ws, unix.WNOHANG, nil)
switch {
case err != nil:
// ECHILD means not our child or already reaped. Forget it
// either way.
delete(m.children, pid)
case wpid == pid:
if notify && !c.oneshot {
m.notifyExit(monitorProcess{PID: pid, ID: c.id}, monExitCode(ws))
}
delete(m.children, pid)
}
// wpid == 0: still running.
}
}
// monExitCode maps a wait status to the exit notification's code. It
// is the exit status for a normal exit, else the shell convention
// 128+signal for a signaled death.
func monExitCode(ws unix.WaitStatus) int {
if ws.Exited() {
return ws.ExitStatus()
}
return 128 + int(ws.Signal())
}
// scrubFdsByScan marks every open descriptor listed in dir, a
// /proc-style self-fd directory, close-on-exec from 3 up. It is the
// fallback path for platforms without close_range. Descriptors closed
// concurrently between the directory read and the fcntl are ignored.
func scrubFdsByScan(dir string) error {
ents, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, ent := range ents {
fd, err := strconv.Atoi(ent.Name())
if err != nil || fd < 3 {
continue
}
if _, err := unix.FcntlInt(uintptr(fd), unix.F_SETFD, unix.FD_CLOEXEC); err != nil && !errors.Is(err, syscall.EBADF) {
return err
}
}
return nil
}
// reply writes one frame on the loop goroutine. On write failure the
// connection is marked broken and the loop tears down.
func (m *monitor) reply(f rpcFrame) {
if err := m.conn.writeFrame(f); err != nil {
m.broken = true
}
}
func (m *monitor) notifyExit(child monitorProcess, code int) {
m.reply(rpcExitNotify(child, code))
}
// handleKillAll sets killAll, which makes teardown kill the session
// children. tailscaled sends this op before closing the RPC when
// access is revoked, because the session may be canceled too late to
// signal its process group itself.
func (m *monitor) handleKillAll(f rpcFrame) {
m.killAll = true
m.reply(rpcOKReply(f.ID, opKillAll))
}
// teardown is the monitor's exit path, reached on RPC EOF, a protocol
// error, or an external SIGHUP. It sends SIGHUP to every child process
// group, reaps briefly, then sends SIGKILL to the groups that did not
// exit. The SIGKILL pass uses the pgid snapshot taken below, because a
// process can outlive the reaped leader of its group.
//
// Forwarding children always die. They are tailscaled's own dialers,
// listeners, and relays, and must not outlive the connection. Session
// children die too when killAll is set, which happens on revocation.
// Otherwise the client disconnected on its own, and the session
// processes are not signaled here. The session in tailscaled closes
// its I/O without signaling, and the processes exit on their own or
// keep running.
func (m *monitor) teardown() {
// Close descriptors whose receipt has not been acknowledged.
for id, fds := range m.pendingFds {
closeFds(fds)
delete(m.pendingFds, id)
}
if !m.killAll {
// Leave session children alone, and forget them so the reap
// loops below do not wait on children that were not signaled.
for pid, c := range m.children {
if c.id != 0 {
delete(m.children, pid)
}
}
}
// Snapshot the pgid set at teardown entry. A group member can
// outlive its reaped leader, so the SIGKILL pass keys on this
// remembered set rather than on surviving table entries.
pgids := make(map[int]struct{}, len(m.children))
for _, c := range m.children {
pgids[c.pgid] = struct{}{}
}
for _, c := range m.children {
// ESRCH means already gone and the like. Nothing to do.
_ = killGroup(c.pgid, unix.SIGHUP)
}
deadline := time.Now().Add(monTeardownReap)
for len(m.children) > 0 && time.Now().Before(deadline) {
// Silent sweep. The peer is gone or going, so notifications
// have nowhere to go.
m.sweep(false)
if len(m.children) > 0 {
time.Sleep(20 * time.Millisecond)
}
}
if len(pgids) > 0 {
// Kill surviving group members even if their leader has exited,
// then wait for children to exit for at most monTeardownReap.
for pgid := range pgids {
_ = killGroup(pgid, unix.SIGKILL)
}
deadline = time.Now().Add(monTeardownReap)
for len(m.children) > 0 && time.Now().Before(deadline) {
m.sweep(false)
if len(m.children) > 0 {
time.Sleep(20 * time.Millisecond)
}
}
}
m.conn.c.Close()
}
// monStdio maps the request's descriptors onto the child's stdin,
// stdout, and stderr, in order, taking ownership of all of them. On
// success every descriptor in fds is wrapped in an *os.File which the
// caller closes with monCloseFiles; on error every descriptor in fds
// has already been closed and the caller must not touch fds again.
// When fewer than three descriptors arrive, the missing streams map
// to /dev/null so the child always has a valid 0..2.
func monStdio(fds []int) ([]*os.File, error) {
files := make([]*os.File, 3)
for i := range files {
if i < len(fds) {
files[i] = os.NewFile(uintptr(fds[i]), fmt.Sprintf("ssh-monitor-child-fd%d", i))
continue
}
f, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err != nil {
monCloseFiles(files[:i])
closeFds(fds[i:]) // never wrapped: close them raw, exactly once
return nil, err
}
files[i] = f
}
return files, nil
}
func monCloseFiles(files []*os.File) {
for _, f := range files {
f.Close()
}
}
// monChildDir returns the child's working directory, falling back to
// "/" when dir is empty, missing, or not a directory. The check is
// best-effort.
func monChildDir(dir string) string {
if dir == "" {
return "/"
}
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
return "/"
}
return dir
}