mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-29 06:28:57 -04:00
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>
463 lines
14 KiB
Go
463 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|