Commit Graph
4 Commits
Author SHA1 Message Date
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
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
James Tucker de16943cbf ssh/tailssh: add user-credential forwarding children and monitor dial/listen ops
Add the one-shot "be-child ssh-fwd" children that perform forwarded
socket work, and the monitor and client plumbing for the socket ops.
The conn and session flows do not use the ops yet. The next commit
routes them.

- fwdchild.go: a child re-execed by the monitor, born unprivileged at
  clone. The monitor applies the connection's bound identity as
  SysProcAttr.Credential, so the kernel's DAC sees the connection
  user's permissions. It dials a unix socket, or creates one with
  umask-at-bind mode and optional unlink-if-owned, or binds a TCP
  listener, or creates the agent-forwarding socket in a user-owned
  directory. The result returns to the monitor over an inherited
  socketpair as one status line with at most one descriptor attached.
- monitor.go: handleDial and handleListen run the ops on one-shot
  children with a spawn-to-result deadline, monFwdOpDeadline. On
  expiry the child's group is SIGKILLed. killChild reaps such a child
  with a bounded non-blocking wait. A child left in uninterruptible
  sleep is abandoned in its own group rather than blocking the loop
  goroutine. The dial and listen placeholder replies are gone, and
  their not-implemented test with them.
- monitor_client.go: Dial, Listen, and ListenAgent over the existing
  request-ID plumbing.
- incubator.go, monitor_linux.go, monitor_other.go: registration and
  per-platform spawn attributes for the fwd children.

Tests drive real children across uid boundaries. They cover dial and
bind round trips, unix and TCP listens, agent-listen directory
hygiene, unlink-if-owned, peer-credential assertions, and the
no-identity-keys-on-the-wire shape of every request.

Forwarding children spawn with an empty environment through
spawnRPCChild: they take everything from argv and the result socket,
and the daemon's environment must not reach a child running as the
connection's user.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 17:28:51 -07:00
James Tucker ed7903582b ssh/tailssh: add monitor RPC codec and SCM_RIGHTS fd passing
New self-contained code for the per-connection privileged ssh-monitor.
No existing behavior or wiring changes. Later commits wire the
forwarding and session paths onto it.

rpcFrame is the newline-delimited JSON wire struct with request IDs.
It covers dial, listen, exec, signal, and ping requests, ok and err
replies, and unsolicited exit notifications. rpcConn is the framing
codec over one socketpair end. Reads go only through ReadMsgUnix with
a CmsgSpace(3*sizeof(int)) OOB buffer. A plain Read or an undersized
buffer silently drops ancillary data, and the kernel closes the dropped
descriptors. A stream read never returns data beyond an
SCM_RIGHTS-carrying message, so a descriptor arrives with the first
chunk of a split line and survives reassembly. Writes attach
descriptors to the first byte of a line and never to an empty payload.
A short write resends the remainder as plain data, because resending
the ancillary data would duplicate the descriptors. Received
descriptors are marked close-on-exec at parse time, by the codec rather
than by the stdlib's platform-dependent handling. Herald attribution
validates a whole read's herald batch before queueing any set for a
frame, so a failed batch closes its sets exactly once and never
double-closes a set already queued for a frame. Lines are capped at
256 KiB, and NUL bytes and frames carrying more descriptors than their
op declares are rejected on receive. A read timeout returns
os.ErrDeadlineExceeded and preserves any partially received line, so
the monitor's poll loop can sweep between reads without losing the
frame. A read timeout is the only error that preserves state. Every
other error closes all held descriptors and clears the buffer.

sendFds, recvFds, and wrapUnixConn are shared by the monitor and client
sides. wrapUnixConn consumes its fd, because os.NewFile's finalizer
cannot be neutralized from outside.

Tests exercise ping and fd round-trips with liveness and FD_CLOEXEC
checks, multi-fd frames, kernel framing pins for first-chunk ancillary
delivery and undersized-OOB MSG_CTRUNC rejection, 64 KiB-plus
split-line reassembly under a shrunken SO_SNDBUF, oversized and NUL
rejection, the short-write resend path, the read-timeout
state-preservation contract, and 50-way multiplexed request/reply
traffic.

The monitor scrubs every descriptor from 3 up to close-on-exec at
entry and again before each exec. Go's exec closes nothing beyond the
descriptors it passes, so a single non-close-on-exec fd anywhere in the
spawner's table would otherwise reach every child the monitor execs.
close_range(CLOSE_RANGE_CLOEXEC) does the sweep in one syscall on
kernels 5.11+; older kernels scan the self-fd directory. A new test
injects a non-close-on-exec pipe at spawn and pins the clean-child
contract.

spawnRPCChild takes the child's environment explicitly: a nil env
gives the child an empty environment, never the spawner's. The monitor
child is spawned with the daemon's environment; user-identity children
are spawned with none, so the daemon's environment never reaches a
child running as the connection's user.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 15:23:22 -07:00