Files
James Tucker f7c1b8e526 hostinfo,ssh/tailssh: don't leak systemd service env to child processes
tailscaled runs under a Type=notify unit, so NOTIFY_SOCKET is in its
environment. The desktop probe ran loginctl with that environment
inherited. loginctl sends sd_notify(3) messages on exit and error, and
lives in the daemon's cgroup, so systemd took each message as coming
from the tailscaled unit and rejected it:

  tailscaled.service: Got notification message from PID 1234, but
  reception only permitted for main PID 1000

One failing exec sends two messages, ERRNO=... and EXIT_STATUS=... That
is the pair of rejected lines seen at each SSH session on Debian-family
hosts. The per-connection ssh-monitor child also received the daemon's
full environment.

Helpers exec'd by tailscaled now get the daemon's environment without
the systemd service-instance variables: NOTIFY_SOCKET, WATCHDOG_PID,
WATCHDOG_USEC, LISTEN_FDS, LISTEN_PID, LISTEN_FDNAMES, INVOCATION_ID,
JOURNAL_STREAM. The scrub covers the loginctl and getenforce probes,
the direct-exec fallback probe, and the ssh-monitor spawn.

Session children still never receive the daemon's environment. The
monitor still gives children running as the connection's user no daemon
variables. The daemon's own environment is unchanged.

Updates tailscale/corp#50380

Change-Id: Ic686726473e851e2855e75eea11d7b3f75440311
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-28 15:17:19 -07:00

823 lines
27 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.
// 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 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))
}
// teardown is the monitor's orderly exit path, reached from RPC EOF, a
// protocol error, or an external SIGHUP. It SIGHUPs every child group,
// runs a brief bounded reap sweep, then SIGKILLs every group that
// survived the HUP and reaps again. A session-level escalation timer
// in sessmonitor.go, when one was scheduled, fails its Signal against
// this closed RPC channel, so the monitor's own teardown performs the
// SIGKILL. Children that survive even the SIGKILL are abandoned in
// their own groups.
func (m *monitor) teardown() {
// Close descriptors whose receipt has not been acknowledged.
for id, fds := range m.pendingFds {
closeFds(fds)
delete(m.pendingFds, id)
}
// 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
}