284 Commits
Author SHA1 Message Date
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
James Tucker b101cb8bef ssh/tailssh: keep sessions when a prompt-free rule expires
Check mode grants recently approved users a prompt-free Accept rule
with a short RuleExpires. When that rule expired, the policy recheck
matched the base HoldAndDelegate rule instead. The recheck read that as
a policy edit and closed the connection. Live sessions died when the
prompt-free window lapsed.

Remember the rule that matched at authentication. If it has expired and
a hold rule matches instead, keep the connection. Only new connections
re-authenticate.

The expiry timer now fires at least 1ms past a lapsed deadline. A
zero-delay refire lands at the deadline, where the rule is not yet
expired, and re-arms. The real clock moves on between refires; a stopped
clock, such as a synctest bubble, does not.

The fallback test runs in a synctest bubble, so the expiry cannot race
the handshake on a slow runner. A bubble cannot hold child processes:
the test opens a session channel but runs no command, and the empty PATH
keeps the conn lifecycle free of subprocesses. The session-kill side of
expiry stays pinned on the real clock.

A policy edit that swaps a still-valid accept rule for a hold rule still
closes the connection. Recorder, identity, and accept-env checks still
run on the fallback path.

Updates tailscale/corp#50380

Change-Id: I46fa7d42142e90d00843744427c91ba29d68a467
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-28 14:47:00 -07:00
Francois Marier e8ebe6aba1 ssh/tailssh: parse UIDs consistently in isRootUser()
Since the incubator parses the UID in base 0 via flags.IntVar()
and isRootUser() uses base 10, it was possible to get inconsistent
results between the two using non-base 10 numbers.

Updates tailscale/corp#50380

Change-Id: I9b2fb16bc37153df7b695533cc73a3499cb774eb
Signed-off-by: Francois Marier <francois@tailscale.com>
2026-10-26 20:06:51 -07:00
James Tucker 055c809d5f ssh/tailssh: release reverse-forward cancellation callbacks
Close the specific forwarded listener on connection cancellation and stop its callback when the accept loop exits. Canceling a forward no longer retains a connection-lifetime waiter or looks up a replacement listener by address.

Test repeated cancel and rebind operations on one connection, along with callback cleanup when the listener closes independently. Make the existing connection-cancellation test fail if its listener stays open.

Updates tailscale/corp#50380

Change-Id: Ife7fb0acde012b0abf1a8d804b9c6a8994ee5703
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 17:42:07 -07:00
Percy Wegmann b1582deed4 ssh/tailssh: set $LOGNAME to same value as $USER in incubator
When the ssh incubator runs the shell itself instead of using `su` or `login`,
it needs to set the `$LOGNAME` environment variable. Just like OpenSSH `sshd`,
it sets this to the same value as `$USER`.

Updates tailscale/corp#50380

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2026-10-26 17:25:45 -05:00
James Tucker f5fdb18b2e ssh/tailssh: give delegated action fetches one timeout owner
Let fetchSSHAction own the per-fetch deadline and pass the connection cancellation context directly from clientAuth. Remove the duplicate timeout and its deferred cancellation inside the action loop. Derive the longer transport watchdog from the fetch timeout, keeping both durations unchanged.

Use virtual time to test the fetch deadline across retries, earlier parent deadlines, and parent cancellation. Verify that a successful fetch releases its request context.

Updates tailscale/corp#50380

Change-Id: I185958ca19566f04cbd68f33e4b1dad3d18aebb7
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 15:11:58 -07:00
James Tucker e3a4c41d38 ssh/tailssh: name the monitor for its connection-wide role
Move lazy monitor startup, credentials, and cleanup into connmonitor.go and rename the connection state and accessors. The monitor owns session processes as well as forwarding children. Move the session exit registry alongside session monitor handling and keep the Plan 9 stub in a matching file.

Update callers, tests, and diagnostic prefixes without changing monitor lifecycle behavior.

Updates tailscale/corp#50380

Change-Id: I760fa009176491da426639615907d129b68026fc
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 15:09:58 -07:00
James Tucker 2c1cea51e4 ssh/tailssh: clarify lifecycle and protocol comments
Describe exit statuses, output ordering, and descriptor ownership directly. Keep the Darwin descriptor acknowledgment rationale in the protocol documentation instead of repeating it at each handoff.

Clarify that the exec spawn limit is checked after Start returns, not an interrupting deadline. Remove claims that session exit statuses are unique or that unreaped children must be uninterruptible.

Updates tailscale/corp#50380

Change-Id: I613b83ac2ae92d7bf8a582c4e4b326ebbe55852d
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 15:09:10 -07:00
James Tucker 617c38d523 ssh/tailssh: register connections atomically with shutdown
Check shutdown and register initialized connections under the same lock. Reject a connection if shutdown ran during its construction, stopping its login-grace timer and closing the transport. Also close the transport when connection construction fails.

Cover shutdown before and during connection construction without starting an SSH handshake.

Updates tailscale/corp#50380

Change-Id: I634036a7e073b24d7ff80d6a6d0ae991c40f3c69
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 15:08:03 -07:00
James Tucker 87f3b61f31 ssh/tailssh: dial TCP forwards as the bound user
Route direct-tcpip channels, used by TCP -L and SOCKS -D forwards, through the per-connection monitor after the existing policy checks. Resolve names and create/connect TCP sockets in a child running with the authenticated local user credentials instead of the root daemon.

Hand the connected TCP descriptor back through the existing acknowledged fd protocol. TCP socket ownership survives the handoff, so UID-based firewall policy sees the mapped user. TCP has no per-message SCM_CREDENTIALS and needs no persistent relay: the child exits after handing off the socket. Bound resolution/dialing by the monitor forwarding deadline.

Share the channel-close-aware forwarding copy loop with Unix forwards, retaining bidirectional half-closes and bounded draining on full close. Plan 9 retains its existing in-process forwarding path.

Add root-run IPv4/IPv6 socket-owner regression tests, hostname and half-close coverage, policy/disable gates, invalid/refused dial handling, and a pin that the child is reaped while its socket remains usable. Update the TCP policy revocation fixture for child spawning and synchronize its assertion on transport shutdown.

Updates tailscale/corp#50380

Change-Id: I89159fd31e75c22446096587471afcd2bb4e78d4
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 13:10:45 -07:00
James Tucker 123232260e ssh/tailssh: retain the latest session window size
Drain any stale buffered size before sending the latest window update. The adapter is the only sender to a one-slot channel, so the final resize is retained even if the consumer races with the drain.

Cover late and slow readers over a real SSH session with deterministic synctest synchronization. Keep the final two resizes unread to exercise replacement of the buffered size.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
Change-Id: I8c077114a6d4765c4208d42a22f384c82f73f75c
2026-10-26 12:11:07 -07:00
James Tucker 6072693e91 ssh/tailssh: relay Unix forward payloads as the bound user
Keep the dialed service socket in the credentialed forwarding child and return a private socketpair endpoint instead. Passing the service socket to tailscaled preserves connect-time peer credentials but causes payload SCM_CREDENTIALS to identify the root daemon.

Keep dial children alive for bidirectional forwarding. Return a lifetime
pipe alongside the data socket so full closure or a discarded dial reply
ends the relay independently of data EOF. Preserve half-closes and the
monitor's descriptor acknowledgment/teardown ownership for both fds.
Listener children retain their existing descriptor handoff behavior.

Handle local Unix forwarding channel closure independently of the SSH
connection: the channel request stream closes on CHANNEL_CLOSE, not
CHANNEL_EOF. Stop waiting for service output while giving queued client
payload a bounded drain in each forwarding stage, so a silent or
non-reading service cannot retain the relay indefinitely.

Test relay exit/reaping with silent services, discarded replies, queued
payload and backpressure, including end-to-end SSH channel closure while
the transport stays connected. Fix a canary test's unsynchronized marker
assertion to wait for the reporting goroutine.

Add a root-run cross-UID SO_PASSCRED regression test and lifecycle coverage for both half-close directions and monitor shutdown with an idle relay.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
Change-Id: I54b8ddf02b9d82434e474fea2ec283c0e39fab3e
2026-10-26 11:34:49 -07:00
James Tucker 84ca98e518 ssh/tailssh: enforce recording failure before notifying control
Cancel fail-closed sessions before sending recording upload failure
notifications. Bound each notifyControl request to five seconds, including
recorder connection failures on the synchronous session startup path.
An unresponsive control request must not keep an unrecorded session running
or indefinitely delay rejection or fail-open startup.

Keep the timeout local to each notification, independent of the session
and upload lifetime, without adding another goroutine. Test blocked
startup notifications in both rejection and fail-open modes, and use
virtual time to cover earlier and simultaneous cancellation/deadlines.

Send SFTP recording rejection diagnostics to stderr, preserving the binary protocol on stdout and exit status 254. Add real SSH/recorder tests for fail-closed and fail-open behavior with blocked notifications, and an SFTP client regression test.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
Change-Id: I270d624975a6a9115502a4915ee5ff28a67d714b
2026-10-26 10:44:30 -07:00
James Tucker 5cb033d61e ssh/tailssh: initialize username hint exclusions
Allocate the exclusion set before adding client-supplied usernames. A true Exclude entry otherwise panics when SSH is enabled.

Exercise the c2n handler with true, false, absent, and unknown exclusions, and cover the result cap and disabled-SSH behavior.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
Change-Id: I1d9faf36e5809ccff888fc402329652e8b7d88c8
2026-10-26 10:00:23 -07:00
James Tucker d75f04c927 ssh/tailssh: enforce SSH policy at the connection level
Re-evaluate authenticated connections on policy updates and at session
and forwarding gates. Close connections whose grants are revoked or
narrowed, including forwarding permissions, user identity, duration,
AcceptEnv, and effective recording requirements. Recheck rule-derived
AcceptEnv and recording configuration for delegated connections too.
Compare recorder lists with duplicate counts preserved so duplicate
entries cannot hide a change of recording destination.

Treat recording-failure changes in either direction as material,
including fail-open to fail-closed transitions. An inconclusive user
lookup retains the previous identity but does not skip other policy
checks, including for `"*": "="` mappings. An absent account or a changed
username, UID, or primary GID revokes the connection.

Preserve explicit getent failures when the os/user fallback cannot find
the account, instead of treating every lookup failure as account deletion.
Numeric UID mappings use LookupId in the fallback, not Lookup by name.
Known limitation: NSS can report a directory-only account as not found
(getent exit status 2) during an sssd/LDAP outage. If the fallback also
reports the account missing, this is indistinguishable from deletion and
still revokes the connection. This availability tradeoff remains accepted
for now; explicit command failures, timeouts, and malformed output do not
by themselves establish that an account was deleted.

Reject forwarding requests using the latched Allow bit before doing a
policy recheck. Forwarding gates still re-evaluate policy, but use the
latched account identity rather than spawning getent for every forwarded
connection (including each SSH -D request). Account identity is rechecked
at authentication completion, policy updates, rule expiry, and session
gates.

Re-arm rule-expiry timers under the policy recheck lock and prevent
re-arming after teardown. Add connection-level login grace, extending
it during delegation and stopping it only on SSH authentication
success, not on unsigned key queries or partial authentication.
Close the raw transport and cancel delegated authentication on teardown.

Add regression tests for these transitions, wait for the auth-completion
recheck before test policy flips, and release stalled recorder handlers
even on test failure.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 08:48:36 -07:00
James Tucker 79d5abe043 ssh/tailssh: launch sessions via the monitor
Launch incubator sessions through the per-connection monitor, passing
stdio and the optional environment pipe via SCM_RIGHTS. tailscaled
retains the parent I/O descriptors. Keep direct launch when no
tailscaled binary is available, and on Plan 9.

Wait for dispatched exec replies even after session cancellation so
the session retains ownership of any child that starts. Publish exit
status through persistent completion state. Identify monitor children
by PID and exec request ID, and validate both when signaling. Remove
raw-PID signaling after monitor teardown.

Normalize incubator identity flags and reject alternate spellings
that could override them. The monitor RPC caller remains trusted to
supply the executable and other arguments; this is not a privilege
boundary against tailscaled.

Pass default TERM and locale values through the environment pipe,
preserving last-value-wins defaults and policy-forwarded precedence.
Use a separately owned close-on-exec PTY descriptor for resizing.

Add regression tests for launch cancellation, exit delivery, signal
identity, descriptor ownership, and environment handling.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 07:49:11 -07:00
James Tucker eba00ab363 ssh/tailssh: route SSH forwarding through the privileged monitor
Route tailssh's forwarding through the per-connection ssh-monitor, so
on unix forwarded sockets are dialed and bound by children with the
authenticated local user's credentials, never by root tailscaled.

- unix -L, direct-streamlocal: the lexical allowlist check stays in
  tailscaled as coarse policy. The allowlist is home, /tmp, and
  /run/user/<uid>. Relative and abstract paths are rejected. The dial
  then runs as the user through the monitor. The EvalSymlinks
  re-validation is dropped, and the kernel's DAC is the boundary. The
  allowlist is capped. A home directory that cleans to "/" or is empty
  would prefix-match every absolute path, and such an entry is dropped
  instead. Paths at or beyond the sun_path capacity reject lexically
  rather than dying at bind or dial with a generic EINVAL.
- unix -R, streamlocal-forward: the listener is created by a user-cred
  child with umask-at-bind 0600 and unlink-if-owned. Closing it does
  not unlink the socket, and the stale file on cancel is accepted.
- TCP -R, tcpip-forward: a tailssh-owned handler replaces gliderssh's
  ForwardedTCPHandler. The bind goes through the monitor's listen op
  with the local user's credentials, so privileged ports fail by
  kernel construction for non-root-mapped users. Root-mapped users are
  allowed, as in OpenSSH. Bind addresses are loopback-only unless
  TS_SSH_FORWARD_GATEWAY_PORTS is set, the equivalent of sshd's
  GatewayPorts.
- agent forwarding: the socket and its containing directory are
  created by a user-cred child, a 0600 socket in a 0700 user-owned
  directory, instead of a root-run MkdirTemp, Chmod, and Chown
  sequence. Session-end cleanup is unchanged.

The monitor is spawned lazily on the first forwarding or agent need,
from the tailscaled binary, and closed when the SSH connection ends.
closeFwdMonitor latches a closed flag under the fwd lock, so a caller
racing the teardown is refused by fwdMonitor, or reads the current
state without spawning through fwdMonitorIfAny, instead of respawning
a monitor nobody ever closes.

plan9 keeps today's behavior through a build-tagged seam, fwdmonitor.go
and fwdplan9.go. There is no monitor on plan9. Unix forwarding is
rejected there, and reverse TCP forwarding keeps the gliderssh
handler with the mayReversePortForwardTo gate.

Tests cover conn-level callbacks against a real monitor and real
one-shot fwd children: allowlist rejections, user-owned 0600 sockets, a
foreign sticky-dir socket not unlinked, loopback, gateway-ports, and
privileged-port TCP binds, agent directory hygiene and cleanup, and
TS_SSH_DISABLE_FORWARDING coverage, plus refused respawn after
teardown. The unix-fwd root-dial regressions cover a static
in-allowlist symlink to a root-owned 0600 canary, which must fail
permission denied as the bound user, and a dir-swap race, which must
never serve the protected canary.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 20:08:58 -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
James Tucker a1e51f4942 tsnet: authorize ListenSSH peers, fix PTY emulation and session queue bound
ListenSSH now documents its session trust model and takes an
authorization callback. A session is accepted from any peer that the
tailnet ACL's TCP rules allow and that resolves with WhoIs. The server
performs no SSH client authentication, does not map the peer to a local
user, and does not record the session. The application is responsible
for what each session does. Set Server.SSHAuthCallback to reject
unwanted sessions before they reach Accept. A rejected session is
refused at open with exit status 1 and is never yielded to Accept. The
callback is plumbed through LocalBackend.ListenSSH and HookListenSSH.
With no callback, behavior is unchanged.

The listener's SSH server runs session channels only, with no port
forwarding. Closing the listener closes the accepted connections.

- DisablePTYEmulation runs on every session, so a PTY session passes
  data through unchanged instead of translating "\n" to "\r\n". The
  translation corrupted binary data read through the net.Conn
  interface. Window changes are drained for every session, even when
  the application never calls Pty, because gliderssh writes window
  changes from the session request loop and an undrained channel
  blocks the session's requests. Pty drops window changes when the
  application is not reading them.

- The handshake is bounded by TS_SSH_LOGIN_GRACE_TIME, default 120
  seconds, the same as sshd's LoginGraceTime.

- TS_SSH_IDLE_TIMEOUT bounds connections with no SSH traffic. It is 0
  by default, and zero applies no bound, so quiet but live sessions
  are not closed by default. Both timeout knobs accept a Go duration
  such as "90s" or a bare number of seconds.

- Accepted sessions wait for Accept in a bounded queue. When the queue
  is full, further sessions are refused, so an application that stops
  calling Accept parks only a bounded number of sessions.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 11:31:39 -07:00
James Tucker b71e0f30a7 ssh/tailssh: drain the sftp test helper's stderr copier
sftpSubsystemSession waited for the stdout copier's done channel but not
the stderr copier's. The stderr copier carries the recording-policy
refusal notices. The deferred channel close could cut it off mid-drain.
The refusal text never reached the test's buffer. Both copiers are
now waited on before the channel closes.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 09:10:29 -07:00
James Tucker 729cd431b3 ssh/tailssh: record sftp sessions and honor recording failure policy
Remove the blanket sftp-subsystem exemption from session recording:
in a recording-mandatory tailnet, sftp sessions (and scp -s, which
rides the sftp subsystem) now go through recording setup with the same
OnRecordingFailure semantics — an unreachable recorder refuses the
session (exit 254) instead of letting file access proceed silently
unrecorded.

SFTP casts are metadata-only: the CastHeader identifies the peer, local
user, connection, and records Command = "sftp" (subsystem requests
carry no command string), but no I/O events are captured — the SFTP
wire protocol is binary, so pass-through capture would not be
human-meaningful and would balloon cast sizes. The upload/cleanup
lifecycle is unchanged. With no recorders configured, sftp is
unchanged.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 16:08:17 -07:00
Mike Jensen 1f9df5e6d4 ssh/tailssh: unify defaultPathForUser to use isRootUser (#21928)
This change unifies all root checks in `ssh/tailssh` to use `isRootUser`. This ensures we consistently avoid the `== "0"` check and protects us in any future root check changes (previously missed in #21919).

Updates tailscale/corp#50380

Change-Id: I598e4394cf3a779e622fa208378b33afee8723e3

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-10-24 15:54:15 -06:00
James Tucker 17c90d88c1 ssh/tailssh: fix off-by-one and clamp c2n SSH username hint cap
The add closure used len(res.Usernames) > max, letting one extra
username through the client's Max, and the client-supplied Max was used
unclamped. Honor Max exactly and clamp it to a 100-username ceiling;
the list is only a login hint.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 14:10:24 -07:00
James Tucker ff3bb58830 ssh/tailssh: validate accepted env values, deny GLIBC_TUNABLES, cap names
GLIBC_TUNABLES is parsed by glibc in root su/login environments with
AT_SECURE=0 (CVE-2023-4911 class); add it to the dangerous-env-var
denylist next to its GCONV_PATH/LOCPATH siblings.

The TERM/LANG/LC_* pairs accepted without policy (OpenSSH's default
AcceptEnv) had unvalidated values that reach root-exec'd login/su
environments, where glibc treats path-like locale names as locale-data
paths and leading dashes as its own options. Reject path- and
option-like values (and NUL, which would truncate on exec) instead of
trying to sanitize them.

Also cap accepted env var names at 256 bytes before any wildcard
matching, bounding the matcher input and the child's "su -w" allowlist;
real names are short (LOGIN_NAME_MAX).

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 13:55:35 -07:00
James Tucker d484ac300e ssh/tailssh: refuse direct-exec fallback for non-root users as root
When tailscaledPath is empty, newIncubatorCommand runs the session's
shell directly, with no be-child incubator to drop privileges. A root
tailscaled could therefore run a non-root user's session as root.
Refuse that case, mirroring canSwitchToLocalUser's semantics.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 13:52:48 -07:00
James Tucker 7dbc80ab71 ssh/tailssh: hand the agent forwarding dir to the user, remove it on session end
The agent forwarding socket's temp directory was left root-owned and
chmod 0755, letting every local user reach other users' agent sockets.
Chown the directory to the session user and keep it 0700 (the socket is
already 0600 and chowned first), like sshd's agent forwarding, and
remove the directory when the session ends instead of only closing the
listener.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 13:52:38 -07:00
James Tucker 4777494982 ssh/tailssh: pin rejection-banner content to policy-derived facts
A policy rejection names the requested sshUser: that name is the
client's own request and reflects only tailnet policy (the client's
own ACL view), never a local-host fact, so naming it leaks nothing.
What a client-visible rejection must never depend on is server-side
local-user information: local-user lookup failures send one fixed
message for every failure cause, with the underlying error logged
server-side only, so the client cannot tell "no such local user"
from any other lookup failure and no banner is a
username-existence oracle.

Pin both properties in TestSSHAuthFlow: the user-mismatch cases,
including a rule whose "=" mapping forces a local lookup of a user
that does not exist, all name the requested user identically, and an
accept rule whose mapped local user fails lookup gets the fixed
lookup-failure banner.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 13:52:26 -07:00
James Tucker 3c13466645 ssh/tailssh: write private host keys with 0600 permissions
Private host keys are only ever read by tailscaled itself; writing them
0700 needlessly allows group/other to walk into the key directory.
Use 0600, and pin it in TestSSHKeyGen.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 13:30:41 -07:00
James Tucker d90ffb62af ssh/tailssh: treat numerically-zero uids as root in isRootUser
An NSS uid like "00" passed the string != "0" check but parses to 0 in
the incubator, so a session could bypass non-root checks and run as
root. Compare the uid numerically instead; non-numeric uids keep the
previous behavior.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 13:23:22 -07:00
James Tucker ec40639b05 ssh/tailssh: extend dangerous env var denylist, re-check child-side
GCONV_PATH, LOCPATH, NLSPATH, HOSTALIASES and RES_OPTIONS can make
privileged processes (root su/login environments built from forwarded
vars) load attacker-controlled files or redirect resolver lookups; add
them to the isDangerousEnvVar denylist, and apply the same check in the
incubator child when it re-filters the inherited forwarded-env payload
(old parents may not have filtered them).

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 12:41:58 -07:00
James Tucker a819ec0753 ssh/tailssh: make acceptEnv pattern matching linear time
matchAcceptEnvPattern recursively retried each '*' suffix, which is
exponential in the number of '*'s in an acceptEnv pattern (2.7s observed
on a 400-byte crafted variable name). Replace it with an iterative
matcher that only backtracks to the most recent '*', preserving the
exact OpenSSH-style glob semantics. Cross-check it against a naive
backtracking reference over random inputs, and pin the crafted
backtracking case as a timing regression test.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 12:13:05 -07:00
James Tucker 6ce8b89912 ssh/tailssh: record the raw client command in session recordings
The CastHeader recorded a shell-parsed reconstruction of the session
command, but the command is passed to the user's shell verbatim, and the
shell lexer silently drops unbalanced quotes. A spoofed command could
therefore make the recording differ from what the shell actually ran.
Record ss.RawCommand() instead.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 11:32:32 -07:00
Simon Law 37fc7a7a72 format using go fmt version 1.27.2 (#21838)
Updates #cleanup

Change-Id: I3b0987b431fa4c601b90a2763ce73eb24a7713ce

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-10-21 23:17:21 -07:00
Sachin NeneandClaude Opus 5 af08ffa4d2 ssh/tailssh: dup the pty master with F_DUPFD_CLOEXEC (#21342)
launchProcess duplicated the pty master with syscall.Dup, which is a
plain dup(2) and does not set FD_CLOEXEC. The original master from
pty.Open is CLOEXEC (os.OpenFile), but the dup is not, and it stays
open in tailscaled until the session's stdout copier closes it.

Because child processes are started with os/exec, which relies on
FD_CLOEXEC to drop fds, every other SSH session started while this one
is alive inherits the dup'd master. It then survives the incubator's
exec into login, the user's shell, and everything the shell runs.

When the original session disconnects, tailscaled closes its own copies
but the leaked copies in other sessions' process trees keep the pty
open: the slave never hangs up, nobody reads the master, and any process
writing to that tty blocks forever once the buffer fills. On a host with
a long-lived tmux server this shows up as every tmux command hanging,
with the server stuck in n_tty_write on a dead client's tty.

Use F_DUPFD_CLOEXEC so the dup is never inherited across exec.

Fixes #21343

Signed-off-by: Sachin Nene <sachin@corvex.ai>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-16 17:45:34 -07:00
Patrick O'Doherty 9cdbc3ad3e ssh/tailssh: record stderr on non-PTY sessions (#21369)
On non-PTY exec sessions stdout was wrapped in the recording writer but
stderr was copied to the client raw, so any command output written to
stderr never appeared in the session recording. PTY sessions were
unaffected because they fold stdout and stderr into a single stream that
already flows through the recording writer.

Wrap stderr in rec.writer too, recording it under the "o" (output)
direction, matching how PTY sessions record combined output. The
asciinema cast format has no separate stderr channel, so "o" is the
correct code for players and tsrecorder to render it.

Strengthen TestSSHRecordingNonInteractive to run a command that writes to
both stdout and stderr and assert both appear in the recorded cast lines.

Updates tailscale/corp#48187

Change-Id: I1dc36073722677a85f2b23b331fd55b8df4d342e
Reported-by: Ben Carman <benthecarman@live.com>
Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-09-23 16:13:52 -07:00
Brad Fitzpatrick 664ba588ab ssh/tailssh: split the OS-specific parts of the server into their own files
The main server file mixed portable session handling with Unix details:
sending SIGHUP to end a session, decoding exec.ExitError, the euid check
for whether the process can switch users, agent forwarding's chown of the
socket, and reading /etc/ssh host keys as root. Those now sit behind small
functions (hangupProcess, waitProcess, canSwitchToLocalUser,
handleSSHAgentForwarding, systemHostKeyFile, isRootUser) in the new
process_unix.go, along with the incubator's forwarded-environment pipe
helpers, and the session's *exec.Cmd moves into an embedded osSessionState
struct defined there, so that the portable code no longer refers to the
process representation at all.

user.go keeps only the portable userMeta and userLookup; the login shell
and default PATH logic moves to user_unix.go. The SFTP child entrypoint and
its stdio adapter, which incubator.go and incubator_plan9.go each had a
copy of, move to sftp.go. The c2n usernames handler gains a hook for
platforms that list users some other way than /etc/passwd.

The agent socket's uid and gid are parsed as 31-bit rather than 32-bit
unsigned values so that the conversion to int for os.Chown cannot
overflow on 32-bit platforms, which is the pattern CodeQL flags.

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I4c7e2b9a0d3f5e1c8b6a4d2f0e9c7b5a3d1f8e6c
2026-09-15 19:32:40 -07:00
Brad Fitzpatrick 2ee809d10d feature: add TS_DISABLE_FEATURE to disable features at runtime
The ts_omit_<name> build tags omit a feature at build time; there has
been no way to do the same at runtime. Some users (either proactively
or in response to a security announcement) might like a way to disable
a feature that's linked-in in their binaries that they're not using.
Then a mitigation announcement can say "set this env var" without
asking users to rebuild or wait for a new release.

This adds env var TS_DISABLE_FEATURE, a comma-separated list of
feature names to disable, and the listed set is reported by the
debug-optional-features LocalAPI endpoint next to the registered set.
The legacy per-feature knobs such as TS_DISABLE_SSH_SERVER and
TS_DISABLE_TAILDROP keep working independently.

A disabled feature behaves as if it had not been linked: it is absent
from feature.IsRegistered, its hooks are unset, and its extensions and
handlers are not registered. Three pieces make that happen:

  * feature.Register now returns bool, false when disabled, and
    feature packages gate their registration init on it. It was added
    to the feature packages that never called it (including taildrop
    and ssh), which also completes the picture reported by
    debug-optional-features. taildrop, routecheck, favorites, and
    serviceclientprefs had registration split across several inits and
    now register from one gated init.
  * ipnext.RegisterExtension ignores a disabled feature's extension.
  * feature.Hook.Set and feature.Hooks.Add walk the call stack and
    silently skip when the calling package under feature/<name> is
    disabled. This covers sub-packages such as
    feature/captiveportal/netcheckhook, which cannot call Register
    themselves without colliding with their parent, and future
    packages whose authors forget the gate.

ssh/tailssh's registrations moved from its inits into tailssh.Register,
called from feature/ssh's gated init. The aws and kube state stores and
syspolicy's Windows store registration are gated too.

feature/register_disable_test.go runs this test binary as a child
process (it links condregister, as tailscaled does) with
TS_DISABLE_FEATURE set to every registered feature at once, and fails
if any of them register anyway, so a feature that ignores the variable
cannot land.

Updates #12614

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I720af6ccab844ae060a9dfd1539fee577fd483e3
2026-09-15 08:37:47 -07:00
Simon Law 00699abdfb tailcfg,tailcfg/{nodecap,selfcap}: split capability constants to their own packages (#20639)
Package tailcfg defines the types and constants used by the Tailscale
protocol, but since everything is all in one package, it’s difficult
to sift through the docs: https://pkg.go.dev/tailscale.com/tailcfg

We define and enumerate capabilities as string constants for
tailcfg.NodeCapability and tailcfg.PeerCapability. This PR extracts
them into their own packages:

- tailcfg.CapabilityFileSharing becomes nodecap.FileSharing
- tailcfg.NodeAttrOnlyTCP443 becomes nodecap.OnlyTCP443
- tailcfg.PeerCapabilityTaildrive becomes peercap.Taildrive

We originally intended for CapabilityFoo to grant an entitlement or
permission for Foo, and for NodeAttrBar to configure Bar in the
nodeAttrs section of the policy file. However, there was no technical
enforcement of this convention, so new capabilities have used the
NodeAttr prefix regardless of meaning. Therefore, this PR unifies
tailcfg.CapabilityFoo and tailcfg.NodeAttrBar into a single package as
nodecap.Foo and nodecap.Bar.

Ran `go fix -inline ./...` and committed the changes that replaced
uses of the tailcfg aliases with the authoritative ones.

Updates #20259

Change-Id: Ieb7e7e6c8247c39faf42fdf15c68cdc7c621c730
Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-08-07 16:30:35 -07:00
Patrick O'DohertyandMike Jensen 9d48dbd561 ssh/tailssh: keep acceptEnv values and names out of the incubator cmdline (#20552)
Previously the acceptEnv variables forwarded to the incubator child were
JSON-encoded onto its command line (--encoded-env), so their values were
visible in /proc/<pid>/cmdline to any other local user and were logged in
the session-start argv (locally and to log.tailscale.com except where
--no-logs-no-support was specified).

This change now carries those variables through an os.Pipe file
descriptor as a json encoded payload. Added end-to-end testing
helps validate secrets reach the session but are not in flags or logged.

Fixes tailscale/corp#44903

Change-Id: I5b137b20e9c06feec6b70aaf4e6925e6db74017e

Signed-off-by: Mike Jensen <mikej@tailscale.com>
Co-authored-by: Mike Jensen <mikej@tailscale.com>
2026-07-30 09:57:55 -06:00
Kristoffer Dalby dd426dbb51 ssh/tailssh: hold CHANNEL_EOF until both output streams drain
The previous commit sent CHANNEL_EOF from the stdout copier as soon as
stdout hit EOF. With the process exited and the send window exhausted by
a slow client, the stderr copier could still be draining its backlog;
x/crypto/ssh fails every write after EOF (WriteExtended checks sentEOF),
so the tail of stderr was silently dropped.

Send CloseWrite from run() itself, after cmd.Wait, exit-status and both
output copiers have finished. Wire order is unchanged: exit-status,
remaining output, EOF, CHANNEL_CLOSE.

The test withholds stderr window credit until the process has exited
server-side, then releases it and checks the final bytes arrived. It
also pins the harness login shell to /bin/sh; fish forks -c commands and
stays resident, changing pipe fd ownership under test.

Updates #18256

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-30 10:18:00 +02:00
James Tucker 92f43003fc ssh/tailssh: fix exit-status frame ordering
Send exit-status before EOF before CHANNEL_CLOSE per RFC 4254 §6.10.
The old code raced CloseWrite against Exit; macOS OpenSSH often saw
EOF first and dropped the exit code.

Replace atomic.Bool/atomic.Int32/channel sync with a sync.WaitGroup
over the output goroutines and emit:

    cmd.Wait -> ss.Exit -> closeAll(childPipes) -> wg.Wait -> ss.Close

The stdin copier stays out of wg: it blocks reading from the SSH
channel until the client half-closes, and many clients (go-scp,
plain `ssh host cmd`, scp, sftp) don't half-close before they
receive CHANNEL_CLOSE from us. Including it deadlocks. It self-
cleans via deferred ss.Close.

Align exit codes with shell convention:
  255 SSH-internal error (OpenSSH ssh.c:1693)
  254 recording-infrastructure failure
  127 command-not-found (POSIX)

Switch session termination from SIGKILL to SIGHUP, matching
OpenSSH PTY-master-close (session.c:2246). Process-group delivery
follows in a later commit.

Relies on the gliderssh wire-order change (tailscale/gliderssh#8),
which go.mod already pins since 3f5eb3199.

Based on tailscale/tailscale#18331.

Updates #18256

Signed-off-by: James Tucker <james@tailscale.com>
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-30 10:18:00 +02:00
Kristoffer Dalby 12fcb2217b ssh/tailssh: test exit-status frame ordering and stderr drain
Added before the fix so CI records the failure. Frame order is asserted
off the session interface rather than through a client, so it holds on
any GOOS instead of depending on how a particular client reacts.

Measured on CI against this commit, no fix yet:

  TestExitStatusPrecedesEOF   FAIL on darwin, linux/amd64 and the
        privileged linux job, 10/10 retries, classified permanent:
        got [eof exit-status], want [exit-status eof close]
  TestExitCodePassthrough     PASS on both
  TestStderrTailNotTruncated  PASS on both (guards a 2022 fix)

The ordering assertion is the only thing that pins this bug. Exit-code
assertions do not: OpenSSH 10.2p1 on darwin reads the pending
exit-status request after EOF just as linux does, so the wrong frame
order is invisible to the client's exit status over the direct-exec
path. The exit-status loss reported in #18256 comes from
/usr/bin/login -pq in the incubator path, which is a separate defect.

Updates #18256

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-30 10:18:00 +02:00
Kristoffer Dalby ea3f83216b ssh/tailssh: add exit-status test harness
Extract the in-process SSH server harness out of tailssh_test.go into
tailssh_exitcodes_test.go so the exit-status tests can share it, and add
the integrationtest-tagged exit-code suite (Go and OpenSSH clients).

Updates #18256

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-30 10:18:00 +02:00
Mario Minardi e48e7b730a ssh/tailssh: check if user matching autogroup:nonroot is root
Add a check to ensure that the user being matched to an
autogroup:nonroot rule is in fact a non-root user on the system.

Updates https://github.com/tailscale/corp/issues/43245

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-07-27 17:18:34 -06:00
Mario Minardi f368a96e01 ssh/tailssh: dissallow purely numeric usernames for SSH
Dissallow purely numeric usernames for SSH as these are ambiguous with
numeric UID values.

Updates https://github.com/tailscale/corp/issues/43245

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-06-11 17:52:51 -06:00
ferrumclaudepilgrim 3f70abdc6f cmd/tailscaled, version/distro: default to userspace-networking on Crostini
cros-garcon NULL-derefs on cold-boot netlink enumeration when
tailscale0 is present, preventing the Crostini container and
ChromeOS Terminal from starting cleanly. This is an upstream
ChromiumOS bug in cros-garcon; tailscaled can work around it
by defaulting to userspace-networking mode on Crostini.

Tailscale SSH continues to work via tailscaled's netstack.
Users can override with --tun=tailscale0 on ChromeOS builds
where cros-garcon is fixed.

Crostini is detected via /opt/google/cros-containers/bin/garcon,
which is present in every Crostini penguin container.

ssh/tailssh extends the existing Debian default-PATH case to
cover Crostini, since Crostini is Debian-based and benefits
from the same SSH PATH defaults.

RELNOTE: Crostini now defaults to userspace-networking.

Fixes #19488
Updates #12090

Signed-off-by: ferrumclaudepilgrim <ferrumclaudepilgrim@users.noreply.github.com>
2026-06-01 17:40:07 -07:00
Simon Law 92bfda580c cmd/tailscale/cli: fix time in tailscale routecheck (#19956)
When running `tailscale netcheck`, the reported timestamp used to be
in UTC and formatted according to RFC 3339 with a `T` to separate the
date from the time:

	sfllaw@h2co3:~$ tailscale netcheck | head -n3

	Report:
		* Time: 2026-06-01T21:12:32.252620138Z

This is machine-readable time leaking out to the user interface. Times
in normal commands are formatted for humans to read:

	sfllaw@h2co3:~$ date
	Mon 01 Jun 2026 02:39:14 PM PDT
	sfllaw@h2co3:~$ journalctl -t tailscaled | tail -n1
	Jun 01 14:35:21 h2co3 tailscaled[3328921]: wgengine: sending TSMP disco key advertisement to 100.90.144.102
	sfllaw@h2co3:~$ timedatectl show
	Timezone=America/Los_Angeles
	LocalRTC=no
	CanNTP=yes
	NTP=yes
	NTPSynchronized=yes
	TimeUSec=Mon 2026-06-01 14:38:32 PDT
	RTCTimeUSec=Mon 2026-06-01 14:38:32 PDT
	sfllaw@h2co3:~$ uptime --since
	2026-05-15 07:37:45

This PR makes the times printed by the CLI commands consistent:

- For `tailscale routecheck`, it now prints local time as
  `2026-05-15 07:37:45-07:00`.
- For `netlogfmt`, it has always printed local time with a space,
  but now includes the time zone.
- All machine-readable outputs continue to be standard RFC 3339 in
  UTC, i.e. `--format=json`.

As part of a general cleanup, this PR also adds standard common
time.Format layouts as tstime constants.

Fixes #19928

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-01 16:12:08 -07:00
Patrick O'Doherty 651049ec19 ssh/tailssh: reject dangerous LD_/DYLD_ env vars in acceptEnv filtering (#19914)
Block dynamic linker environment variables (LD_PRELOAD, LD_LIBRARY_PATH,
DYLD_INSERT_LIBRARIES, and friends) from being forwarded regardless of
acceptEnv policy, preventing privilege escalation via wildcard patterns
like "*".

We are not aware of any legitimate use of these variables so they are
safe to exclude from being passed.

Thanks to Tim Sageser (dtrsecurity) for this report.

Updates tailscale/corp#42033

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-06-01 09:19:27 -07:00
Brad Fitzpatrick 3e34e721e8 tsnet: add opt-in SSH support (Server.ListenSSH)
This adds tsnet.Server.ListenSSH which, if the SSH feature is linked,
returns a net.Listener whose Accept yields *tailssh.Session values (as
net.Conn). This lets tsnet apps accept incoming SSH connections to
implement custom TUI applications.

Basic apps can use net.Conn directly (Read/Write/Close). Rich apps
import ssh/tailssh and type-assert for peer identity, PTY, signals,
etc. If feature/ssh isn't imported, ListenSSH returns an error.

Includes a demo guess-the-number game in tsnet/example/ssh-game.

Updates tailscale/corp#37839

Change-Id: I4e7c3c96afb030cdf4da8f2d8b2253820628129a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-30 14:17:50 -07:00