Commit Graph
11545 Commits
Author SHA1 Message Date
Brad Fitzpatrick 467ef0f372 ssh/tailssh, tstest/natlab/vmtest: expand Tailscale SSH test coverage
Add Docker integration tests for TCP and Unix forwarding, pty size,
resize and modes, session env, envknobs, x11 rejection, session
teardown, SessionDuration, policy changes and RuleExpires on active
sessions, Shutdown, host keys and utmp, and run every integrationtest
test in the Dockerfile. Extend the vmtest SSH tests to use real
OpenSSH clients across the tailnet for pty, sftp/scp, agent and port
forwarding, AcceptEnv, ControlMaster, large transfers, banners, user
mapping, revocation, check mode (via a new testcontrol handler),
recording, and the tailscale ssh CLI. Fix make sshintegrationtest,
which only ever tested one image and always exited 0, and trigger the
workflow on go.mod changes and a schedule.

Updates #13038
Updates tailscale/corp#50380

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ie4fffad7e9298b6b77c3ec3a6ae81fd784d182ce
2026-10-26 22:19:22 +00: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
Mario Minardi 4a435a7b42 client,tool: bump node to latest 22.x
Bump node to latest 22.x release.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-10-26 14:29:40 -06: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
Claus Lensbøl 7a3650494f wgengine/magicsock: ensure discoKey nodeKey match (#21956)
The check for finding unambiguous disco pings was not comparing the
disco key and node keys correctly. Fix the error and add a test that
asserts the ping is not seen as unambiguous when the node key does not
match the disco key, and the disco key has multiple matches.

Fixes tailscale/corp#50343

Thank you to Brody Franks for reporting this issue.

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-10-26 14:21:52 -04: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
Alex Valiushko 12e619a070 wgengine/magicsock: apply peer MTU sockopts through net/sockopts (#21855)
And merge the repeated don't fragment codepaths between Linux and Darwin.

Updates tailscale/corp#48168

Change-Id: I5aef408f037c8def99522b4d820c853a6a6a6964
Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
2026-10-26 10:09:21 -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 df83989647 go.mod: bump gliderssh for window resize fix
Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 08:13:06 -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 5f40a0627e go.mod: update gliderssh for resource exhaustion fixes
Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 14:05:45 -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 afe142bc87 go.mod: update gliderssh to remove data race on timeouts
Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 07:52:59 -07:00
Mike Jensen 566f89e0fb ipn/ipnlocal: fix serve app caps leaking across mounts (#21931)
`serveWebHandler` wrote each mount's accepted app capabilities into the connection-scoped serveHTTPContext. Concurrent h2 streams of one TLS serve connection share that struct, so a request to a mount accepting no capabilities could be proxied carrying another mount's `Tailscale-App-Capabilities` header, letting backends that trust the header authorize the wrong mount.

This change fixes that issue by storing accepted capabilities per-request via a context value instead of a field on the shared struct.

Updates tailscale/corp#50380

Change-Id: I5d0a4a02266018f342ba75a1b3291176738ce05b

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-10-24 18:39:53 -06: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 3e61adf832 util/osuser: validate 'id' input, reject corrupt getent passwd output
Before invoking 'id -Gz <user>', apply the same input validation as the
getent path (checkGetentInput), so a crafted username like '-u' can't be
interpreted as an option by the id command.

parseGetentUser now rejects multi-line (multiple or corrupted records)
output and validates the username and shell fields (no leading dashes,
control characters, or invalid UTF-8), the fields that are used to look
up and run a session. Also add the missing leading-dash regression test
for the 'id' call path.

The passwd format has no escaping — gecos sub-fields are comma-separated
— so an eighth colon-separated field means a stray colon in some field,
shifting every field after it: home parses as a gecos fragment and the
shell as the real home directory. Short records keep the existing
treatment (padded, with the default-shell fallback); over-long records
now fail closed like the other corrupt-output cases.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-24 13:05:02 -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
Brad Fitzpatrick a6cffd0a6d drive/driveimpl/compositedav: don't run ttlcache cleanup goroutines
StatCache started each ttlcache with "go cache.Start()" and stopped
them in StatCache.stop. But ttlcache caches begin in the stopped
state, so if stop ran before the Start goroutine was scheduled,
Stop was a no-op and Start then ran forever, leaking a goroutine.
That made tests using tstest.ResourceCheck flaky:

    goroutine github.com/jellydator/ttlcache/v3.(*Cache[...]).Start

Rather than synchronize with Start, drop the background goroutine
entirely. Cache.Get already ignores expired items, so the goroutine
only existed to free memory. Instead, call DeleteExpired on each
write, which is cheap: it pops expired entries from the front of the
expiration heap and stops at the first unexpired one. With no
goroutines, StatCache.stop is no longer needed, so remove it.

Fixes #21824
Updates tailscale/corp#49459

Change-Id: Ib438fcd45296be79528613c5f5687725c38a137d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-10-23 20:41:40 -04:00
James Tucker ae4ab7e7d5 net/netmon,net/netns: cache Windows default interface indices
Windows socket setup enumerates adapters and the routing table through
GetWindowsDefault for every netns socket. These calls can be expensive,
particularly on systems where security software adds syscall latency.

Cache nonzero default interface indices per monitor and address family,
and use them from netns controlC. Invalidate on native route, address,
and IP interface change notifications.

Coalesce concurrent lookups and retry results invalidated during lookup.
Bound lookup and wait attempts; once the budget is exhausted, fall back
to a single uncached lookup rather than failing socket setup, erring
toward a possibly stale interface index over no socket at all. Perform
fresh lookups for zero indices and errors, and retain the existing
unbound-socket fallback for a fresh zero result.

Use uncached lookups after monitor shutdown or when interface-change
notification registration fails.

Test cache reuse, independent address families, notification invalidation,
concurrent fills, invalidation during lookup, route restoration after a
zero result, churn and wait-budget fallbacks, and shutdown.

Fixes #21885

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-23 17:50:52 -04:00
Alex Chan dd50731b7b util/set: provide a size hint when calling Set.Of
We already do this for `Set.OfSliceView`.

Updates #cleanup

Change-Id: I6ce3022ddabae2a0891e76ca45f3a7c601627a02
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-10-23 17:28:23 -04:00
Alex Chan 2ddef398f9 types/ipproto: fix a typo in a doc comment
Updates #cleanup

Change-Id: I5d046c6997770602336bda49d7a6fab41f76401c
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-10-23 17:10:21 -04:00
kari-ts 00650c0b3b ipn, ipn/local: expose hidden device gui gui_visibility (#21822)
This interprets HiddenNetworkDevices per watch session and sends GUI visibility metadata for peers (without filtering them). We refresh the visibility snapshot when policy or peer membership changes.

Updates tailscale/corp#44104

Signed-off-by: kari-ts <kari@tailscale.com>
2026-10-23 11:45:38 -07:00
Brad Fitzpatrick f8ad538a3b words: add a missing scale
Updates #words

Change-Id: Iff0c568fa4ffe2c2972521d5bf21dbdbe8af3a80
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-10-22 10:52:43 -04: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
Brad Fitzpatrick 47743a8b9a .github/workflows: only run tailcat check on PRs to main
Release branches don't need the heads-up about tailcat breakage.

Updates #cleanup

Change-Id: I132880827880f6b80cf4df34ac7e16dbe0b41eb4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-10-20 09:24:49 -07:00
Nick Khyl eaf3fc8daf net/dns: don't make quad-100 the primary resolver on Windows when hosts are unrouted
In #20775 (and the follow-up in #21471), we started installing quad-100
as the OS's primary resolver whenever cfg has hosts not covered by a split
DNS route, so that sandboxed macOS clients could still answer them.

However, that check was added to the generic split DNS gate rather than
to the Apple-specific one, so it also applies on Windows, where dcfg.Hosts
always carries every node's records and MagicDNSHostsUnrouted is set
whenever MagicDNS is disabled. As a result, a Windows node with MagicDNS
off and at least one split DNS route (or with MagicDNS on and a peer shared
in from another tailnet) gets a catch-all NRPT rule pointing at quad-100,
and all DNS traffic on the device is proxied through it instead of only
the configured suffixes.

The check is redundant for Apple platforms: iOS never takes the scoped
path there, and sandboxed macOS only does so via scopeApple, which already
requires !cfg.requiresPrimaryResolver(). This removes it from the generic
gate, restoring the pre-1.104 behavior on Windows, and adds tests.

Updates tailscale/corp#45534
Fixes #21863

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2026-10-20 09:21:11 -04:00
Dep Updater ef596ebd34 go.toolchain.rev: bump Go toolchain
* Go toolchain: https://github.com/tailscale/go/compare/186053ab2af2ef8fc43f3c1d1150216e89d4aff0...7ef7b403648ac3a9481a6e88e8b894eff50b0167

Triggered by @bradfitz via the bumpdep workflow.

Updates tailscale/corp#50253

Signed-off-by: Dep Updater <noreply+dep-updater@tailscale.com>
2026-10-20 05:35:13 -07:00
Patrick O'Doherty db03d4e19d clientupdate: use a per-update Linux download directory (#21853)
Create a fresh directory for each Linux tarball download and remove it
after unpacking or a failed download. Continue to prefer the user cache
directory and use the temporary directory when the cache path is
unavailable.

Fixes tailscale/corp#50119
Fixes tailscale/corp#48680

Change-Id: Iebe13d922f8fa691f26fe17d908b295b7153aadb

Credit: Winfunc

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-10-19 15:34:25 -07:00
License Updater 374d505a99 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-10-19 10:18:56 -07:00
Joe Tsai e3c3ef18a5 util/unitconv: add new package (#20868)
Package unitconv implements string conversion functionality
for unit prefixes such as those from the SI and IEC standards.
It is catered towards printing of values for human consumption.

This exists to support open-sourcing JSON migration tooling.

Updates #20220
Updates tailscale/corp#791

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2026-10-16 18:02:33 -07:00