Files
tailscale/ssh/tailssh/monitor_client.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

471 lines
14 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
// monitorClient is tailscaled's side of the ssh-monitor RPC channel.
// It spawns the per-connection monitor process, re-exec'ing the
// current binary. It multiplexes request IDs, dispatches unsolicited
// exit notifications, and serializes writes.
//
// Dial, Listen, and ListenAgent serve the conn's forwarding paths,
// and Exec serves the session path.
package tailssh
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"slices"
"strings"
"sync"
"syscall"
"time"
"tailscale.com/hostinfo"
)
// monitorSpawnPrefix is the argv prefix following the executable path,
// used to spawn the monitor process: "be-child ssh-monitor" in
// tailscaled. Tests override this seam to re-exec the test binary and
// dispatch to beMonitor.
var monitorSpawnPrefix = []string{"be-child", "ssh-monitor"}
// spawnMonitor spawns the per-connection ssh-monitor process and returns
// the connected client. uid/gid/groups/localUser carry the connection's
// bound identity, as documented on monitorArgs.
func spawnMonitor(exe string, uid, gid uint32, groups []uint32, localUser string) (*monitorClient, error) {
gids := make([]string, len(groups))
for i, g := range groups {
gids[i] = formatID(g)
}
argv := append(slices.Clone(monitorSpawnPrefix),
"--uid="+formatID(uid),
"--gid="+formatID(gid),
"--groups="+strings.Join(gids, ","),
"--local-user="+localUser,
"--rpc-fd=3",
)
// The monitor is its own session leader. It is a tailscaled
// re-exec, not a service instance. Give it the daemon's environment
// without the systemd service variables, so neither the monitor
// nor any helper it execs can notify the tailscaled unit. See
// [hostinfo.EnvironWithoutSystemdServiceVars].
cmd, parentConn, err := spawnRPCChild("ssh-monitor", exe, argv,
hostinfo.EnvironWithoutSystemdServiceVars(nil), &syscall.SysProcAttr{Setsid: true})
if err != nil {
return nil, err
}
mc := &monitorClient{
c: parentConn,
rpc: newRPCConn(parentConn),
pending: make(map[int64]chan *rpcResult),
cmd: cmd,
done: make(chan struct{}),
}
go mc.readLoop()
return mc, nil
}
// rpcResult is one inbound reply. It holds the frame and, for the
// socket ops, the descriptor the reply carried. Ownership of the
// descriptors passes to the waiting call. If there is none, they are
// closed.
type rpcResult struct {
f rpcFrame
fds []int
}
// monitorClient is tailscaled's connection to one ssh-monitor process.
type monitorClient struct {
c *net.UnixConn
rpc *rpcConn
cmd *exec.Cmd
// writeMu serializes writeFrame, so the client has one logical
// writer.
writeMu sync.Mutex
mu sync.Mutex // guards the fields below
nextID int64
pending map[int64]chan *rpcResult
onExit func(monitorProcess, int)
closed bool
// done is closed when the read loop exits, on RPC EOF or a read
// error. The monitor is gone or the channel broke.
done chan struct{}
}
// Pid returns the monitor process's pid for diagnostics and tests.
func (mc *monitorClient) Pid() int {
return mc.cmd.Process.Pid
}
func (mc *monitorClient) readLoop() {
defer close(mc.done)
// A protocol/read failure must also trigger monitor teardown.
defer mc.c.Close()
for {
f, fds, err := mc.rpc.readFrame()
if err != nil {
return
}
// Acknowledge receipt of any relayed descriptors so the monitor
// releases its hold on its copies. The recvmsg in readFrame is
// what transfers ownership; see the monitor_rpc.go header for
// the darwin kernel behavior the acknowledgment covers. A write
// failure means the channel is going away; the monitor's
// teardown closes its parked copies either way.
if len(fds) > 0 {
mc.writeMu.Lock()
_ = mc.rpc.writeFrame(rpcFdAckReq(f.ID))
mc.writeMu.Unlock()
}
mc.dispatch(f, fds)
}
}
// dispatch routes one inbound frame. Replies, with any carried
// descriptors, go to their waiting call; a reply whose caller gave up
// has its descriptors closed here. Unsolicited exit notifications go
// to the registered callback.
func (mc *monitorClient) dispatch(f rpcFrame, fds []int) {
if f.ID == 0 {
closeFds(fds) // notifications never carry descriptors
if f.Op == opExit {
mc.mu.Lock()
h := mc.onExit
mc.mu.Unlock()
if h != nil {
h(monitorProcess{PID: f.Pid, ID: f.ChildID}, f.Code)
}
}
// Other unsolicited frames are ignored.
return
}
mc.mu.Lock()
ch, ok := mc.pending[f.ID]
delete(mc.pending, f.ID)
if ok {
ch <- &rpcResult{f: f, fds: fds}
} else {
// The caller gave up, on ctx done or client death, and nobody
// will consume the descriptors. Close them here.
closeFds(fds)
}
mc.mu.Unlock()
}
// call sends a request and waits for its reply, cancellation, or channel death.
// Dispatched exec requests ignore cancellation so their child handles cannot be
// lost. Descriptors not handed to the caller are closed here or by dispatch.
func (mc *monitorClient) call(ctx context.Context, f rpcFrame, fds ...int) (rpcFrame, []int, error) {
var zero rpcFrame
if err := ctx.Err(); err != nil {
return zero, nil, err
}
mc.mu.Lock()
if mc.closed {
mc.mu.Unlock()
return zero, nil, errors.New("ssh-monitor: client closed")
}
mc.nextID++
id := mc.nextID
f.ID = id
ch := make(chan *rpcResult, 1)
mc.pending[id] = ch
mc.mu.Unlock()
mc.writeMu.Lock()
err := ctx.Err()
if err == nil {
err = mc.rpc.writeFrame(f, fds...)
}
mc.writeMu.Unlock()
if err != nil {
mc.mu.Lock()
delete(mc.pending, id)
mc.mu.Unlock()
return zero, nil, fmt.Errorf("ssh-monitor: write %q: %w", f.Op, err)
}
done := ctx.Done()
if f.Op == opExec {
// Once dispatched, exec must return its child handle even if the
// session is canceled. The caller then owns termination and waiting.
// Monitor death also ends this wait and tears down its children.
done = nil
}
select {
case r := <-ch:
if !r.f.OK {
closeFds(r.fds) // an error reply should carry none; be safe
return r.f, nil, errors.New("ssh-monitor: " + r.f.Err)
}
return r.f, r.fds, nil
case <-done:
// Delete the entry under the lock: after this, dispatch finds no
// waiter and closes a late reply's descriptors itself. A reply
// already sitting in the channel is drained here and closed.
mc.mu.Lock()
delete(mc.pending, id)
mc.mu.Unlock()
select {
case r := <-ch:
closeFds(r.fds)
default:
}
return zero, nil, fmt.Errorf("ssh-monitor: op %q: %w", f.Op, ctx.Err())
case <-mc.done:
mc.mu.Lock()
delete(mc.pending, id)
mc.mu.Unlock()
select {
case r := <-ch:
closeFds(r.fds)
default:
}
return zero, nil, errors.New("ssh-monitor: RPC channel closed")
}
}
// SetExitCallback registers h for unsolicited child exit notifications.
// nil disables delivery.
func (mc *monitorClient) SetExitCallback(h func(monitorProcess, int)) {
mc.mu.Lock()
mc.onExit = h
mc.mu.Unlock()
}
// Gone returns a channel closed when the RPC read loop exits.
func (mc *monitorClient) Gone() <-chan struct{} {
return mc.done
}
// Ping round-trips a ping request.
func (mc *monitorClient) Ping(ctx context.Context) error {
_, _, err := mc.call(ctx, rpcPingReq(0))
return err
}
// execRequest builds an exec frame and descriptor list. envFile, when
// present, follows stdio and becomes the child's fd 3.
func execRequest(argv, env []string, dir string, ctty bool, envFile *os.File, stdio []*os.File) (rpcFrame, []int) {
fds := make([]int, 0, len(stdio)+1)
for _, f := range stdio {
fds = append(fds, int(f.Fd()))
}
if envFile != nil {
fds = append(fds, int(envFile.Fd()))
}
f := rpcExecReq(0, argv, env, dir, ctty, envFile != nil, len(fds))
return f, fds
}
// execOnMonitor sends an exec frame and returns the child handle.
func (mc *monitorClient) execOnMonitor(ctx context.Context, f rpcFrame, fds []int) (monitorProcess, error) {
r, _, err := mc.call(ctx, f, fds...)
if err != nil {
return monitorProcess{}, err
}
if r.Pid <= 0 || r.ChildID == 0 || r.ChildID != r.ID {
mc.c.Close()
return monitorProcess{}, errors.New("ssh-monitor: invalid exec child handle")
}
return monitorProcess{PID: r.Pid, ID: r.ChildID}, nil
}
// Exec launches trusted argv with the given environment and directory.
// Once dispatched, it waits for the child handle or monitor death even if
// ctx is canceled; the caller owns termination of a successfully launched child.
func (mc *monitorClient) Exec(
ctx context.Context,
argv, env []string,
dir string,
ctty bool,
envFile *os.File,
stdio ...*os.File,
) (monitorProcess, error) {
f, fds := execRequest(argv, env, dir, ctty, envFile, stdio)
return mc.execOnMonitor(ctx, f, fds)
}
// ExecIncubator launches a trusted incubator invocation with its identity
// flags normalized to the monitor's bound identity. Like Exec, it returns
// the child handle even if ctx is canceled after dispatch.
func (mc *monitorClient) ExecIncubator(
ctx context.Context,
argv, env []string,
dir string,
ctty bool,
envFile *os.File,
stdio ...*os.File,
) (monitorProcess, error) {
f, fds := execRequest(argv, env, dir, ctty, envFile, stdio)
f.Incubator = true
return mc.execOnMonitor(ctx, f, fds)
}
// Signal asks the monitor to signal a child process group.
func (mc *monitorClient) Signal(ctx context.Context, child monitorProcess, sig string) error {
_, _, err := mc.call(ctx, rpcSignalReq(0, child, sig))
return err
}
// KillAll sets the flag that makes teardown kill the session children.
// Call it before Close when access is revoked, because the session may
// be canceled after the RPC is already closed.
func (mc *monitorClient) KillAll(ctx context.Context) error {
_, _, err := mc.call(ctx, rpcKillAllReq(0))
return err
}
// Dial dials a unix socket through a relay child running with the
// connection's bound identity. The returned data socket and lifetime pipe
// are acknowledged to the monitor on receipt, releasing its held copies.
// See the monitor_rpc.go header.
func (mc *monitorClient) Dial(ctx context.Context, addr string) (*monitorDialConn, error) {
_, fds, err := mc.call(ctx, rpcDialReq(0, "unix", addr))
if err != nil {
return nil, err
}
if len(fds) != 2 {
closeFds(fds)
return nil, fmt.Errorf("ssh-monitor: dial reply carried %d fds, want 2", len(fds))
}
lifetime := os.NewFile(uintptr(fds[1]), "ssh-fwd-lifetime")
uc, err := wrapUnixConn(fds[0]) // consumes the descriptor
if err != nil {
lifetime.Close()
return nil, err
}
return &monitorDialConn{UnixConn: uc, lifetime: lifetime}, nil
}
// DialTCP resolves and dials addr through a one-shot child running with the
// connection's bound identity. The kernel socket owner is preserved when the
// connected TCP socket is handed back to tailscaled.
func (mc *monitorClient) DialTCP(ctx context.Context, addr string) (*net.TCPConn, error) {
_, fds, err := mc.call(ctx, rpcDialReq(0, "tcp", addr))
if err != nil {
return nil, err
}
if len(fds) != 1 {
closeFds(fds)
return nil, fmt.Errorf("ssh-monitor: TCP dial reply carried %d fds, want 1", len(fds))
}
f := os.NewFile(uintptr(fds[0]), "ssh-fwd-tcp")
defer f.Close()
nc, err := net.FileConn(f)
if err != nil {
return nil, err
}
tc, ok := nc.(*net.TCPConn)
if !ok {
nc.Close()
return nil, fmt.Errorf("ssh-monitor: TCP dial returned %T", nc)
}
return tc, nil
}
// monitorDialConn preserves UnixConn's half-close methods, but a full Close
// also ends the relay even if the service remains open and silent. Discarding
// an unclaimed RPC reply closes the lifetime fd too, without needing a wrapper.
type monitorDialConn struct {
*net.UnixConn
lifetime *os.File
}
func (c *monitorDialConn) Close() error {
err := c.UnixConn.Close()
c.lifetime.Close()
return err
}
// Listen asks the monitor for a listener bound with the connection's
// bound identity; see fwdchild.go for the umask-at-bind construction
// and the optional if-owned pre-unlink. Closing the returned listener
// does not unlink a unix socket file; a later bind with unlink-if-owned
// removes stale socket files.
func (mc *monitorClient) Listen(ctx context.Context, network, addr string, unlink bool) (net.Listener, error) {
r, fds, err := mc.call(ctx, rpcListenReq(0, network, addr, unlink, false))
if err != nil {
return nil, err
}
if len(fds) != 1 {
closeFds(fds)
return nil, fmt.Errorf("ssh-monitor: listen reply carried %d fds, want 1", len(fds))
}
return wrapListenerFD(fds[0], r.Path)
}
// ListenAgent asks the monitor for the agent-forwarding socket: a
// user-owned 0700 temp dir and, inside it, a 0600 socket at
// listener.sock, created by the bound-identity child. It returns the
// listener and the socket path. Removing the containing directory is
// the caller's job.
func (mc *monitorClient) ListenAgent(ctx context.Context) (ln net.Listener, path string, err error) {
r, fds, err := mc.call(ctx, rpcListenReq(0, "", "", false, true))
if err != nil {
return nil, "", err
}
if r.Path == "" {
closeFds(fds)
return nil, "", errors.New("ssh-monitor: agent listen reply carried no path")
}
if len(fds) != 1 {
closeFds(fds)
return nil, "", fmt.Errorf("ssh-monitor: agent listen reply carried %d fds, want 1", len(fds))
}
ln, err = wrapListenerFD(fds[0], r.Path)
if err != nil {
return nil, "", err
}
return ln, r.Path, nil
}
// wrapListenerFD wraps a raw listener descriptor in a net.Listener. fd is
// consumed. net.FileListener dups it into the returned listener with
// close-on-exec set, and the original is closed here exactly once.
func wrapListenerFD(fd int, name string) (net.Listener, error) {
f := os.NewFile(uintptr(fd), name)
ln, err := net.FileListener(f)
f.Close() // exactly-once close of the raw descriptor. The dup in ln is unaffected
if err != nil {
return nil, err
}
return ln, nil
}
// Close tears the connection down by closing the RPC channel, which
// triggers the monitor's teardown. Close waits for the monitor to
// exit. A wedged monitor is killed after the forwarding-op deadline,
// both teardown reap sweeps, and a margin for scheduling and child reap.
func (mc *monitorClient) Close() error {
mc.mu.Lock()
if mc.closed {
mc.mu.Unlock()
return nil
}
mc.closed = true
mc.mu.Unlock()
mc.c.Close()
<-mc.done
waitErr := make(chan error, 1)
go func() { waitErr <- mc.cmd.Wait() }()
select {
case err := <-waitErr:
return err
case <-time.After(monFwdOpDeadline + 2*monTeardownReap + 5*time.Second):
mc.cmd.Process.Kill()
return <-waitErr
}
}