Commit Graph
2239 Commits
Author SHA1 Message Date
Brendan Creane e7f595b491 ipn/ipnserver, ipn/localapi: require root to serve a path or Unix socket
The operator user could serve a path or Unix socket if tailscaled
found a sudo rule for them, but the check used the GECOS display name
and so failed for most users with a 401. The check cannot be repaired
safely: listing another user's sudo rules never prompts, so it proves
a rule exists, not that the user has authenticated. An operator who
had not entered their sudo password could serve any root-readable file
or root-only socket. Require root instead and say so in the error.

Fixes #21204
Fixes #21985
Fixes #21988

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-11-03 14:39:07 -08:00
Naman Sood d8eae46481 appc,feature/conn25,tailcfg: read conn25 config from non-experimental nodeattr
Updates tailscale/corp#39984.

Signed-off-by: Naman Sood <mail@nsood.in>
2026-11-03 12:30:26 -05:00
Claus Lensbøl f8358c7f3b ipn/ipnlocal: remove wireguard peers on profile switch (#22150)
Remove old unusable peers from the WG config after a profile switch.

Updates #22149

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-11-02 18:04:50 -05:00
Andrew Dunham e19eb275ea ipn/ipnlocal,cmd/tsnet-proxy: strip underscore variants of identity headers (#22143)
Serve deletes client-supplied `Tailscale-User-Login` (and similar) headers
before setting its own. However, `http.Header.Del` only matches the exact
canonical name. A client could send Tailscale_User_Login instead, which
was passed through to the backend.

For Go (and some other) backends, that's not an issue, but backends that
map headers to CGI-style variables (HTTP_TAILSCALE_USER_LOGIN) treat
both names the same.  Thus, for Funnel requests (or tagged nodes), where
serve sets no identity headers, the backend could trust the spoofed
value. The same applies to Tailscale-App-Capabilities.

This fixes the issue by also deleting any header whose name matches
case-insensitively after replacing '_' with '-'.

This is similar to how other projects do this:
- Django drops them: https://www.djangoproject.com/weblog/2015/jan/13/security/
- nginx does not allow them by default: https://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers
- Puma had a CVE and now drops them: https://nvd.nist.gov/vuln/detail/cve-2024-45614

Fixes tailscale/corp#50821

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2026-11-02 16:42:36 -05:00
Brendan CreaneandSam Jean f3b80645fb ipn/ipnlocal: enable full duplex in the serve reverse proxy (#22116)
Go's HTTP/1 server closes the request body once the handler starts
writing the response. The proxy's Transport may still be reading that
body, and when its final read fails it closes the backend connection
under the response it is copying. The client gets a truncated response.

Fixes #21894

Signed-off-by: Brendan Creane <bcreane@gmail.com>
Co-authored-by: Sam Jean <samj@tailscale.com>
2026-11-02 09:13:00 -08:00
Adriano Sela Aviles 5cf2727225 control,ipn: add Tailnet hint for device registration
Add an ipn.Prefs.Tailnet field, passed through controlclient.Options
into tailcfg.RegisterRequest.Tailnet, so a client can recommend or
require a tailnet at registration (e.g. to skip the tailnet picker when
the intent is already known) without impersonating MDM.

Previously this value could only come from the "Tailnet" syspolicy
setting. That setting still takes precedence as an administrative
override; the prefs value is used only when no policy is set.

Updates #22093

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-11-02 09:22:43 -07:00
Jaron Kramer 6c6dd62b60 ipn/store/kubestore: omit ARI replaces for unstamped shared certs (#22053)
A retained legacy cert may have been issued by a different account even when no pre-adoption local key is available. Require a matching fingerprint before claiming a shared-account renewal replaces it.

Fixes #22041

Signed-off-by: Jaron Kramer <info@jkramertech.com>
2026-10-31 11:43:17 +00:00
Claus Lensbøl 2c7aeadb1d ipn/ipnlocal,wgengine{,/magicsock}: cleanup TSMP disco logs (#22088)
Logging is quite aggressive and most of the logs can be hidden behind
component logging. A few logs are instead gated on if they changed or if
the original state was zero.

Updates #cleanup

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-10-30 11:08:15 -04:00
Adrian Dewhurst 7304dd42c1 feature/conn25, ipn/ipnlocal: use the new net/routelimits package for blocking host-scoped destinations
Similar to #20866 for 4via6, conn25 needs to block host-scoped
destinations. Due to the similarity between the two situations, the
matching is no longer in a 4via6-specific location. Since there is no
backwards compatibility to satisfy, there is no exception list for
conn25.

ipn/ipnlocal (for 4via6) and feature/conn25 now both use the new
package.

Updates tailscale/corp#50118
Updates tailscale/corp#46169

Change-Id: Ic3f9cccd2f5823a2ca48bd78607792b0fd6d7db9
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2026-10-29 13:57:37 -04:00
James Tucker 7ad13349f1 ipn/ipnlocal: randomize DNS query IDs
Allocate a fresh 16-bit ID for each QueryDNS request instead of using the
constant ID 1.

Updates tailscale/corp#48187

Change-Id: I9b47a3fbbef70f0462f1da4f3b29eb77cf31f9f0
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-29 08:53:10 -07:00
James Tucker 7623e71e9c ipn/ipnlocal,ipn/localapi: propagate context through DNS queries
Accept a caller context in LocalBackend.QueryDNS and pass it to the
internal resolver. Use the HTTP request context for localapi DNS queries
so cancellation and deadlines bound the lookup.

Updates #8338

Change-Id: I1bb8ec0b779c883ec40d52063adf25ff36cf43e3
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-29 08:52:50 -07:00
Sam JeanandJames Tucker 3a38840ccc ipn/ipnlocal: check the tailscaled socket on every serve unix dial
The unix-socket reverse proxy checked its backend against the tailscaled
socket only when the handler was built, but each backend connection
re-resolved the path from scratch. A backend that was benign at config
time could later be replaced with a symlink to the tailscaled socket by
anyone able to write to its directory, and the next dial followed it.
The proxy dials as root, so the same swap could redirect a serve or
funnel backend at any root-only unix socket on the host.

Move the check to the dial itself, in a dialServeUnixSocket method
shared by the HTTP transport, the h2c transport and the TCP-forward
handler, all of which previously dialed unix on their own. It refuses
tailscaled's socket with ErrProxyToTailscaledSocket immediately before
connecting.

On Linux 5.6 and newer, the dial pins the resolved socket inode with
openat2(2): an O_PATH descriptor opened with RESOLVE_NO_MAGICLINKS,
and RESOLVE_BENEATH under the socket's parent directory where the path
allows it, or by whole path otherwise. The pinned file is checked
against the tailscaled socket, and the connection is made to the pinned
file through /proc/self/fd, so replacing the path cannot retarget the
connect. On kernels without openat2(2), and on other platforms, the
path is dialed directly.

The connected peer is re-checked after the dial on every platform. The
peer address of a path-bound listener is the path the socket was bound
to, not the path that was dialed, so a symlink swapped in before the
dial is identified after the connect. This check is best-effort:
connections that have no determinable peer name are allowed.

Fixes tailscale/corp#50481

Change-Id: Iae5db207519db9c139a512c0427dce3dfa3ca672
Co-authored-by: James Tucker <james@tailscale.com>
Signed-off-by: Sam Jean <samj@tailscale.com>
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-27 13:08:08 -07:00
kari-ts 822061d89c ipn/ipnlocal: reset alwayson override before reconciling prefs (#21998)
https://github.com/tailscale/tailscale/commit/09982e1918f27c740320a4879bbafdd9f379733a
introduced an alwayson override reset before applying policy settings
during Start.
A prefs reconciliation refactor in
https://github.com/tailscale/tailscale/commit/cb7b49941eae3a933c4c5b7dc56398bce24d7e08
changed the ordering so that Start reconciled prefs before clearing the
override. If the user disconnected with a reason, reconciliation would
preserve WantRunning=false, and Start could retain that stale value
after the override was cleared.
https://github.com/tailscale/tailscale/commit/f4aea70f7a199e5ac155e225fe7ad4de374e9dea
added cached netmap startup, so processing the cached netmap can
reconcile the current prefs to WantRunning=true while Start still holds
a local prefs snapshot with WantRunning=false. Start can then notify the
frontend with the stale value even though the backend is transitioning
to Running with WantRunning=true.

This resets the alwayson override before reconciling prefs, restoring
the original Start ordering and ensuring that the prefs used throughout
Start reflect the effective alwayson policy

Updates tailscale/corp#44094
Updates tailscale/corp#44095

Signed-off-by: kari <kari@tailscale.com>
2026-10-27 12:42:35 -07:00
Sam Jean 713be260c3 ipn/ipnlocal: refuse serving TCP to the tailscaled socket (#21966)
tailscale serve --tcp=<port> unix:<path> dialed the target socket with
no check, so pointing it at tailscaled's own LocalAPI socket proxied a
tailnet peer straight into the LocalAPI. The LocalAPI authorizes a
unix-socket caller by the peer credentials of the dialing process --
tailscaled itself, as root -- so the peer got a write-capable LocalAPI
and could change the node's settings.

The HTTP proxy path already refused this via isTailscaledSocket; the
TCP-forward path, added later, did not. Reject it at config-set time in
setServeConfigLocked so "tailscale serve" fails up front, and keep a
dial-site guard returning ErrProxyToTailscaledSocket as a backstop for
configs set by other means.

RELNOTE: tailscale serve no longer proxies to tailscaled's own socket

Fixes tailscale/corp#50425

Change-Id: I5ed02e83d32bdfb09c8164907bf38cabcc406169

Signed-off-by: Sam Jean <samj@tailscale.com>
2026-10-26 21:52:39 -03: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
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
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
Michael Ben-Ami e310c02787 ipn/ipnext: add NodeBackend.NodeBy{Addr,ID}
The immediate use case is feature/conn25 identifying the connector peer
node that responds to a DoH request.

Updates tailscale/corp#46169

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-10-16 12:45:54 -04:00
chaosinthecrd e915ebc42a control/controlclient,ipn/ipnlocal,tstest/integration: test node removal
Adds tests that only a 404 map response wraps ErrNodeRemoved, that the
node-removed warning and NeedsLogin state are cleared by a new netmap or
a profile switch, and that a node restarted after it was deleted shows
the warning and reaches NeedsLogin.

Updates #18700

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-10-16 16:40:31 +01:00
chaosinthecrd 0f3c767d17 control/controlclient,ipn/ipnlocal,tsconst: go to NeedsLogin on node removal
When a node is deleted, control answers its map requests with a 404 and
the client keeps retrying in its current state, so watchers like
containerboot can't tell a deleted node from a lost connection. This
wraps that error in ErrNodeRemoved, and LocalBackend then moves to
NeedsLogin and sets a node-removed health warning, both cleared on the
next netmap or profile switch. It repurposes #18701 as a stopgap until a
broader fix, such as structured errors from control, exists.

Updates #18700

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-10-16 16:40:31 +01:00
Jonathan Nobels 5cc8aa7f07 net/dns: flush stale MagicDNS NXDOMAINs on Apple platforms (#21584)
Updates tailscale/corp#49775

Track authoritative NXDOMAINs for at least 60 seconds on macOS and iOS (where our short SOA ttls are not properly respected), and flush when a cached name becomes resolvable. Compile a no-op tracker elsewhere and avoid duplicate VPN reconfiguration on link changes.
2026-10-13 15:48:31 -04:00
Brendan Creane 4f86936ef0 ipn/ipnlocal: resolve auto exit node on first netcheck report (#21775)
On a fresh install the first netmap arrives before the first netcheck
report, so auto exit node resolution installed the blackhole
ipn.UnresolvedExitNodeID and nothing re-resolved it once the report
arrived. Refresh in setNetInfo while the exit node is still unresolved.

Fixes #18199

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-10-13 10:24:12 -07:00
Brad Fitzpatrick 0a1eb8c4da ipn/ipnlocal: don't answer single-label names from quad-100
Since 7e609b258 (v1.102), quad-100 looks up MagicDNS names in
nodeBackend.nodeByName, which also holds short-name keys for in-process
dials. So a query for "foo." matched peer "foo.tail-scale.ts.net" and
was answered with its Tailscale IP. 1ec348784 fixed that for MagicDNS
off; this fixes it for MagicDNS on, too, matching pre-1.102 behavior.
The OS applies the search domain to short names before they reach
quad-100, so a single-label query is a root zone name, not a tailnet
name, and must be forwarded (e.g. to LAN mDNS/LLMNR or a global
nameserver). Short names still work for in-process dials via
NodeByName.

Updates #20789

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ic8199a96cf79178210c9a85e0cebfb46567a894a
2026-10-09 11:01:40 -07:00
Alex Valiushko 76648a00fd net/tstun: implement independent injection queue (#21681)
Injected packets now arrive on a queue of their own rather than being
multiplexed onto queue 0. Wrapper.Read is deprecated. It still reads the
first kernel-backed queue, but from now on no single queue of a Wrapper
sees every packet.

Deletes the poll goroutine, the bufferConsumed token, the vectorOutbound
channel, tunVectorReadResult, a full-size slab, and the copy of every
accepted packet between the two slabs.

wireguard-go now runs an extra TUN reader goroutine, a net wash compared
to running our poll routine.

Measured on c8gn.2xlarge, Ubuntu 24.04, Kernel v6.14, tailscaled under
load from two peers, each running iperf3 -P8.

            tx mean   rx mean   cores
  main       7.00      8.84      3.49
  branch     8.94      8.86      3.95

Bumps wireguard-go pin to tailscale/wireguard-go@e61b6b7811.

Updates tailscale/corp#37878

Change-Id: Ide0e8a9f345371b2747fad59dd2bf963c8af5b63

Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
2026-10-08 13:13:11 -07:00
kari-ts a89b32904f ipn/localapi: use caller scope for syspolicy requests (#21701)
This resolves unscoped policy requests to the connected user's effective
policy instead of the default scope.

Tested by changing the device-scoped AlwaysOn policy at runtime and
running tailscale syspolicy reload. Cofnirmed tailscaled immediately
enforced the updated policy with tailscale down

Updates tailscale/corp#41463

Signed-off-by: kari <kari@tailscale.com>
2026-10-08 08:35:14 -07:00
kari-ts a5ae05dd9d ipn/ipnlocal: use session user for policy change notifications (#21699)
This fetches the effective policy snapshot for the user scope instead of
using the default policy scope, ensuring that policy change
notifications reflect the correct user-scoped effective policy.

Updates tailscale/corp#41463t

Signed-off-by: kari <kari@tailscale.com>
2026-10-07 19:01:49 -07:00
Brad Fitzpatrick 2a61a720e5 ipn/ipnlocal: drop the appc import from ts_omit_appconnectors builds
LocalBackend named appc.AppConnector in its appConnector field, in the
field's constructor call, and in the AppConnector accessor, so even
builds with ts_omit_appconnectors linked the appc package and its
dependencies. The buildfeatures.HasAppConnectors checks let the linker
drop the code but could not remove the import.

Add a build-tag-gated type for the field: appcAppConnector is an alias
for appc.AppConnector by default, and in ts_omit_appconnectors builds it
is an empty struct with no-op versions of the methods local.go calls, so
the shared code still type checks. The constructor call moves behind a
newAppConnector helper in the same gated file, and the AppConnector
accessor keeps its *appc.AppConnector signature there, with a nil-
returning version in the omitted build.

This takes tailscale.com/appc out of depaware-min.txt and
depaware-minbox.txt. Add a DepChecker test to keep it out; it also sets
ts_omit_conn25, since conn25 uses appc as well.

Updates #12614

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ia7e2d9c4f1b8e6a3d5c0f2b7e9a1c4d6f8b0e2a5
2026-10-06 13:12:00 -07:00
Brendan Creane edf8abd69e ipn/conffile: reject null services and endpoints in Services config files (#21647)
A JSON null for a service or for an endpoint target unmarshals as a nil
pointer, and loadConfigV0 dereferenced it without a check. This made
"tailscale serve set-config" panic. Return an error that names the
service or endpoint instead.

Fixes #21534

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-10-05 12:29:40 -07:00
Brad Fitzpatrick 92a64b306d ipn/{ipnext,ipnlocal},feature/conn25: add ExtraDNSRoutes hook for conn25 split DNS
dnsConfigForNetmap called appc.AppDNSRoutes directly to synthesize the
conn25 split DNS routes from the self node's capability map, which was
the one place node_backend.go depended on the appc package and was
marked with a TODO to make it a hook.

Add ipnext.Hooks.ExtraDNSRoutes, which returns extension-supplied split
DNS routes. authReconfigLocked calls it and passes the result into
dnsConfigForNetmap, which adds the routes alongside the netmap's own
routes with the same UseWithExitNode handling as before.

The hook takes no arguments: the conn25 extension computes the routes
in its OnSelfChange handler and clears them on a switch to a different
node, and it already tracks the AppConnector.Advertise pref from
ProfileStateChange. Both of those hooks fire before the reconfig that
consults ExtraDNSRoutes, so the state is current. The conditions are
unchanged: no routes when this node itself advertises as an app
connector, and none without the experimental capability.

Updates tailscale/corp#37125

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I6c2e9f4b8a1d3e7f5c0b2a9d8e6f4c3b1a7d5e9f
2026-10-05 10:14:23 -07:00
Alex Chan 9128778b65 types/key: rename NLPublic/Private to TLPublic/Private
The NL prefix is one of the last references to the "Network Lock" name
which we're cleaning up.

Updates tailscale/corp#37904

Change-Id: I751194e0b49447e97a65ed6975e3da680226ce85
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-10-02 16:03:51 +01:00
Jonathan Nobels d8c7cafeda feature/exitnodehealth: warn when selected exit nodes are unavailable (#21017)
updates tailscale/corp#33007

When a selected exit node can't carry internet traffic due to a misconfiguration (wrong ID, node
deleted, routes removed, etc), blackhole default routes are installed and all internet traffic is dropped.
That is the correct behavior -- better to drop than leak to the local network -- but it was entirely silent:
the stable-ID lookup in nodeBackend.updateRouteManagerPrefs misses without a log line, and Status
leaves ExitNodeStatus nil. The user sees a healthy Tailscale with no internet. If an admin fat-fingers the
exit node name in an IT policy, for example, it's easy to break every node with zero feedback.

This adds an exit-node-unavailable health warning covering every way the selection can fail to carry
traffic (short of reachability which is a separate concern), reported via exitnodehealth.ArgExitNodeReason.

The warning names the exit node, caching its display name while it is still a peer so the name survives
its departure, and falls back to the stable ID or IP. When the selection is mandated by the ExitNodeID
or ExitNodeIP policy settings, the message tells the user to contact their network administrator instead
of suggesting they pick another exit node.

To test this, set a forced exit node policy with some random ip or node id. The warning has a 5 second
 threshold. It should clear as soon as you change to a proper exit node.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2026-10-01 08:57:20 -04:00
Brad Fitzpatrick 5d5607fea2 ipn/ipnlocal: use more buildfeatures consts so the linker drops dead code
Several features with ts_omit build tags were still linked into
minimal builds because LocalBackend called into them unconditionally,
so the linker couldn't prove them unreachable. Guard those call sites
with their buildfeatures constants:

  * app connectors: only subscribe to route update and store events
    when app connectors are included; appc is their only publisher.
    This also drops AdvertiseRoute and UnadvertiseRoute.
  * client update: only subscribe to the tailnet default auto-update
    event when auto-updates are supported, as that's all it affects.
  * syspolicy: only register the policy change watch when system
    policy support is included.
  * advertise routes and exit nodes: only validate AdvertiseRoutes
    when one of the two features is included.
  * netstack: only netstack consults the intercepted TCP ports, so
    don't build the port matching func without it.
  * ssh: don't check SSH prefs, report the "SSH on but unusable"
    health message, or intercept port 22 without SSH support. The
    RunSSH pref is still accepted without effect, as before; port 22
    was previously intercepted by netstack builds without SSH.
  * web client: don't track whether the web client should run when
    it's not included.
  * tailnet lock: peers marked UnsignedPeerAPIOnly are only special
    in that tailnet lock skips their signature checks, and the packet
    filter check guards that exemption. Without tailnet lock, all
    peers from control are trusted anyway, so skip the check.

This reduces the size of a build_dist.sh --min tailscaled binary on
linux/amd64 by 65,536 bytes (0.5%), about 28 KB of it in ipnlocal and
most of the rest in eventbus and slices generic instantiations for the
events no longer subscribed to.

Updates #12614

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I8f4d4c0779e84f46f3b076d0fb35514c69a52d8f
2026-09-30 18:34:57 -07:00
Kristoffer Dalby cf7d4c6015 ipn/ipnlocal: report peers dropped by a full netmap as PeersRemoved
Peer-change watchers upsert PeersChanged, so peers missing from a full
netmap were never removed from their view.

Updates #15660

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-09-29 19:23:51 +02:00
Kristoffer Dalby c4a66d1ce1 ipn/ipnlocal, tstest/largetailnet: test removals bundled with full netmaps
A peer removal sent with any MapResponse field that forces a full netmap
rebuild never reaches IPN bus watchers; these tests fail.

Updates #15660

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-09-29 19:23:51 +02:00
Brad Fitzpatrick d4f2bb4cff ipn/ipnlocal: skip the kernel peerapi listener on FreeBSD when netstack is present
FreeBSD is a weak-host stack like Linux, so a LAN-adjacent machine can
send a SYN for a node's Tailscale IP to the node's NIC and get the
kernel's SYN-ACK from the peerapi port, confirming which tailnet
identity that MAC address belongs to. Linux closes this with
SO_BINDTODEVICE and macOS/iOS with IP_BOUND_IF, but FreeBSD has no
per-socket interface bind (only IP_RECVIF and SO_SETFIB).

Instead, treat FreeBSD like Android: when netstack is present, don't
create a kernel-level peerapi listener at all and use the fake listener,
since netstack already intercepts peerapi connections from peers in
userspace and the kernel socket only ever served the local host. When
netstack is compiled out (ts_omit_netstack), the kernel listener is the
only way to serve peers, so keep it and log that it must be restricted
with pf.

Along the way, tailscaled's own pf source NAT rule on FreeBSD turned out
to rewrite the source of such a SYN-ACK to the LAN IP and a random port,
so a plain connect() from the LAN never completes. The SYN-ACK still
leaks, though, so the natlab test now watches for SYN-ACKs on the
attacker's NIC with tcpdump instead of checking for a completed
handshake, and gains a FreeBSD variant. With the fix disabled, that
variant fails on the leaked SYN-ACK from the peerapi port.

Fixes #21419
Updates tailscale/corp#48248

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I0d2de4ebc9bae28450d500bd7b6ddbfa70f9052f
2026-09-25 12:10:40 -07:00
Mike Jensen 94ea82ab55 ipn/localapi: restrict debug-log access to PermitWrite (#21447)
The debug-log endpoint was gated on PermitRead, which any local user connecting to the world-writable tailscaled socket passes. Although forged log entries are always possible, we should limit the ability for local users to influence that flow with respects to a given node.

This change requires PermitWrite for debug-log, matching the trust level of every other mutating debug endpoint. Also add a buildfeatures.HasDebug guard to debug-dial-types for parity with serveDebug.

Fixes tailscale/corp#48143

Change-Id: I0c0044b6b44fe7cbfb6734ac18bca3dc36eaffbf

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-09-25 12:29:56 -06:00
chaosinthecrd 3d62394afc ipn/ipnlocal: add TS_SERVE_ALLOW_ALL_INTERFACES to opt serve out of the tun bind
On Linux, serve's kernel listeners are now bound to the tunnel interface, which
stops LAN-adjacent hosts completing a handshake with the listener (see #21420).
That bind drops packets that arrive on another interface addressed to the node's
Tailscale IP, which breaks the Kubernetes operator's
`tailscale.com/experimental-forward-cluster-traffic-via-ingress` ingress feature.

Add `TS_SERVE_ALLOW_ALL_INTERFACES`, which makes serve's listener skip the
interface bind so those packets are answered again. It applies only to serve
listeners, not the web client listener, which shares the same code but stays
bound. It only affects serve; the peerapi listener stays bound too. This is a
targeted opt-out for the operator to set on the affected proxies. Enabling it
re-exposes the serve listener to the local network.

Updates tailscale/corp#48248

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-09-25 16:35:59 +01:00
Brad Fitzpatrick e87076a378 ipn/auditlog: store the audit log in the backend's state directory
The store path was hardcoded to %ProgramData%\Tailscale\audit-log.json on
Windows, so a tailscaled run by a regular user with its own --statedir
logged "[unexpected] failed to create audit log store ... Access is denied"
on every profile switch. Use the backend's TailscaleVarRoot when it is
known, falling back to the platform default otherwise. For the Windows
service the state directory is %ProgramData%\Tailscale, so its path is
unchanged; an explicit SetStoreFilePath still wins.

Updates #2791

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I5d7f9b1c3e5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e
2026-09-24 11:52:21 -07:00
Brad Fitzpatrick 6612bed24d ipn/desktop: skip the extension when sessions can't be enumerated
Registering the session state callback enumerates the existing desktop
sessions, which fails for a tailscaled run by an unprivileged user
(WTSEnumerateSessions returns "No more data is available"). That was
reported as "init failed", which reads like a bug. Such a tailscaled has
no other users' sessions to track, so report it as skipping the extension,
the same way an unavailable session manager already is.

Updates #2791

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I7f9b1d3e5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b
2026-09-24 11:51:51 -07:00
YewFence 7122baac1b ipnlocal: fix portlist service event type mismatch (#21433)
The portlist extension publishes a PortlistServices event, but the
LocalBackend subscriber accepted the underlying slice type instead. The
event bus matches event types exactly, so endpoint updates were dropped
before reaching Hostinfo.Services.

Add a regression test covering the event-to-Hostinfo path.

Fixes #20192

Signed-off-by: YewFence <hello@yewfence.dev>
2026-09-23 11:55:33 -04:00
Brad Fitzpatrick b3de3b2217 ipn/ipnlocal, net/netns: bind Linux peerapi listener to the tun device
Linux is a weak-host stack, so a LAN-adjacent machine can complete a
TCP handshake with a node's peerapi listener by sending a packet to
the node's Tailscale IP, with no credentials and no tailnet
membership.

macOS and iOS already bind the listener to the tunnel interface, and
Windows is protected by its strong host model, so Linux tun mode was
the only platform that leaked.

Bind the Linux listener to the tunnel interface as well, so the
kernel only answers connections that arrive from the tunnel or from
the local host. A natlab VM test verifies that a same-LAN machine can
no longer complete the handshake, while local and peer peerapi keep
working.

FreeBSD has the same weak-host exposure but no per-socket equivalent,
so handling it there with pf is a TODO (#21419).

Updates tailscale/corp#48248

Reported-By: Samuel Keeley (@keeleysam)
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I5f8501b0938c9f7aa39c4c12ebddd988c72e89bf
2026-09-22 14:10:30 -07:00
Brad Fitzpatrick 1c778640ea net/netmon, ipn/ipnlocal, wgengine: don't lose a network change during startup
If the network comes up during the few milliseconds NewLocalBackend
takes, tailscaled starts with its control client paused and never
unpauses: the interface state snapshot was taken before the netmon
subscription (kept late since #17252), so a change published in between
reached nobody. #21261 hit it on fast-booting NixOS microVMs, and it was
behind the natlab TestEasyEasy CI flake, where a gokrazy node's DHCP
lease landed in that window and "tailscale up" hung at "awaiting
unpause".

Re-read netmon's state after subscribing rather than subscribing before
the snapshot (as #21281 proposed), which would bring back the #17252
data race and let a fresh delta be overwritten by the stale snapshot.
netmon.New and the userspace engine had the same shape of gap between
their snapshot and their subscription; close those too.

Under CPU pressure the hang reproduced in 1 of 22 TestEasyEasy runs
before and 0 of 120 after.

Thanks to @c-vigo for the detailed diagnosis in #21261 and to @zzz-yu
for pointing out this problem and proposing a fix in #21281!

Fixes #21261
Updates #14902
Updates #19126
Updates #deflake

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ifa21f8055a85321a5afda7800140fc5045c6ce08
2026-09-19 10:46:50 -07:00
Alex Chan b16bc957aa ipn,tsnet: replace LocalBackend.NetMap with NetMapNoPeers/NetMapWithPeers
This method has been deprecated for four months; replace all uses with
the Peers or NoPeers equivalents.

Updates #12542

Change-Id: I7b5800d0d92775839dbfb6021751a5dfacc53d2f
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-09-17 13:43:14 +01:00
Andrew Dunham 158a2476c0 tailcfg: add Node.StableTailnetID and bump capver (#21314)
Add Node.StableTailnetID for control to send the current tailnet's
stable ID on the self node. Expose it as CurrentTailnet.StableID in
LocalAPI status and `tailscale status --json`, and display it in
`tailscale whoami`.

Bump CurrentCapabilityVersion to 148.

Updates #14375

RELNOTE: Show the current tailnet's stable ID in status JSON and whoami.

Change-Id: I0525ff735de8113c8d124045a94e00c19a5a02e2

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2026-09-16 14:28:46 -04:00
Brad Fitzpatrick 96492df022 ipn/ipnlocal: log the profile's login name, not the method value
The profile switch log lines passed cp.UserProfile().LoginName without
calling it, so they printed "%!q(func() string=0x7ff7141cd060)" in place
of the login name.

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I4e6a8c0b2d5f7a9c1e3b5d7f9a1c3e5b7d9f1a3c
2026-09-16 09:05:27 -07:00
maxiscoding28 044abcc9d2 ipn/ipnlocal: make Serve idle connection limit configurable (#21179)
* ipn/ipnlocal: make Serve idle connection limit configurable

Allow Kubernetes operator proxy pods to override the per-host idle connection limit through an environment knob.\n\nUpdates tailscale/tailscale#20875

Signed-off-by: maxiscoding28 <max.winslow@icloud.com>

* ipn/ipnlocal: document Serve idle connection default

Signed-off-by: maxiscoding28 <max.winslow@icloud.com>

---------

Signed-off-by: maxiscoding28 <max.winslow@icloud.com>
2026-09-16 11:01:27 +01:00
leoca ec1e07c737 ipn/ipnlocal: don't panic on an over-long name in the peerAPI DNS debug mode
dnsQueryForName builds the query for GET /dns-query?q=<name>, the peerAPI's
interactive debug mode. The name comes from the peer's query string and the
only thing done to it is appending a trailing dot, but the query is built
with dnsmessage.MustNewName, which panics as soon as the name is longer than
255 bytes.

The panic happens before the query reaches the resolver, so the nameAllowed
filter never gets a say and only sourceAllowed has to be true: any of the
user's own untagged devices, and any peer an extension hook lets through,
such as a client using this node as an exit node with DNS proxying allowed.
http.Server recovers it, so tailscaled survives, but the peer's connection is
dropped instead of answered and the node logs a panic trace per request.

Just under the limit the name was mishandled too: a 255-byte name is accepted
by NewName but rejected by Question when it packs the name, and that error
was dropped, so Finish returned a well-formed query with no question in it
which was then handed to the resolver.

Have dnsQueryForName use NewName, check the error from Question, and return
the error from Finish. handleDNSQuery turns a name it cannot build a query
for into the 400 it already uses for the other malformed-request cases.

Fixes #21307

Change-Id: I7c1a4b7e4f9a1d2c3b5e8f0a6d4c2b9e1f3a7d50
Signed-off-by: leoca <leo.camus23@gmail.com>
2026-09-15 17:53:19 -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
Brad Fitzpatrick 8344d97054 control/controlclient,control/ts2021,util/httpbody: bound control response sizes
The map response reader now caps a single message at 256 MiB on the wire
and 1 GiB after zstd decompression. The server-chosen uint32 size prefix
previously let a malicious control server make us allocate up to 4 GiB
before reading any body bytes, and the decoded size was unbounded, so a
small zstd frame could expand into gigabytes of JSON. A 16 MB cap has
been hit by real production traffic before, so both limits sit far above
plausible legitimate sizes. The size-prefixed read moved into a
readMapResponseMessage helper, and the newer control/tsp path already
enforced both kinds of bounds; this brings the long-poll path it
replaces in line, with more generous limits for large tailnets.

ts2021.Client.Do additionally caps every noise response body with
httpbody.LimitSize, so a malicious or buggy control server can't make us
buffer an unbounded response. Client.Do shadows the embedded
http.Client's Do method, so register, set-dns, set-device-attr,
audit-log, all DoNoiseRequest consumers (webclient, tailnet lock, SSH
actions, id-token, feature queries), and the debug CLI get the cap
without per-call-site changes, and future noise endpoints get it for
free.

The cap lives in the new util/httpbody package so other HTTP clients can
adopt the same convention: LimitSize looks the size limit up from
res.Request's context (a Response knows the Request that produced it),
falling back to DefaultMaxSize, 1 MiB, when the context carries no
override. It is like io.LimitReader except that reads past the limit
fail with an error wrapping httpbody.ErrTooLarge instead of silently
truncating, and a body of at most the limit, including one of exactly
the limit, reads back without error: the wrapper probes for EOF once the
limit is exhausted to tell an exactly-at-limit body from an oversize
one. The per-request override, httpbody.WithMaxSize, is a context key,
so transports pick it up with no API changes; LimitSizeTo applies an
explicit limit ignoring any override. Repeated LimitSize or LimitSizeTo
calls replace the previous limit rather than compounding it, so a later
call can raise or remove the limit an earlier one set.

Responses that stream an unbounded number of individually bounded
messages disable the cap with httpbody.WithMaxSize(ctx, 0): the
/machine/map long-poll and control/tsp's map session, whose messages are
already capped per-message (by readMapResponseMessage and decodeMsg, and
by tsp's framedReader and boundedReader). Their non-200 error bodies are
not message streams, so those are capped with LimitSizeTo instead.

The tailnet lock /tka/init/begin, /tka/sync/offer and /tka/affected-sigs
responses can carry per-node key signatures or missing AUMs, which at
100,000 peers reach tens of MB, so they raise the cap to 512 MiB. The
per-response io.LimitedReader decoders that silently truncated those
responses at 1 or 10 MiB are removed: the transport cap is now the
single enforcement point, and it reports oversize bodies instead of
truncating them.

The /key fetch over plain TLS switches from io.LimitReader to
httpbody.LimitSizeTo, so an oversized response reports the problem
instead of producing a confusing truncated-JSON error.

Thanks to Ben Carman for the report!

Updates tailscale/corp#48187

Reported-by: Ben Carman
Change-Id: Ibf95e1ab9e4f0d7ef8866e8c26e62ed2a514455a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-14 18:58:41 -07:00
M. J. Fromberger 6b0c1de7a0 ipn/ipnlocal: update controlknobs when loading a netmap from cache (#20422)
When a controlclient receives non-keepalive netmap, it updates the controlknobs
based on the capability map of the self node.  We added a new knob based on
tailcfg.NodeAttrCacheNetworkMaps in be2f554dd3, and this ensures we correctly
propagate the attribute to the knob when coming up from a cache as well.

Updates #12639
Updates tailscale/projects#27

Change-Id: I40d34053c9743757f9fddca715379fc63a9ae6a0
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-09-14 15:57:00 -07:00