Commit Graph
11338 Commits
Author SHA1 Message Date
Jonathan Nobels 4e6a9ffc8c feature/exitnodehealth: warn when selected exit nodes are unavailable
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-09-18 14:12:40 -04:00
Fran Bull a9bb6d190b tailcfg: add nodecap conn25-connector-apps
Which will be set to a slice of app names a peer is a connector for.

Updates tailscale/corp#47251

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-09-18 09:13:46 -07:00
Brad Fitzpatrick f87a1b1a82 derp/derpserver: pool received packet payload buffers
Every packet the server relayed allocated a fresh []byte for its
payload in recvPacket or recvForwardPacket and dropped it once the
destination's sendLoop had written it. On one busy server, this was
observed allocating about 160 MB/sec of short-lived garbage, and GC
plus malloc were about 5% of the process CPU profile.

Instead, take payload buffers from a size-classed sync.Pool on the
Server, with power-of-two classes from 1 KiB up to derp.MaxPacketSize,
and return them once the packet has been written, forwarded, or
dropped. sync.Pool holds nothing per connection and is trimmed by the
GC, so idle clients pin no memory; only packets actually in flight
hold a buffer. A compile-time assertion ties the largest size class to
derp.MaxPacketSize, and the get and put helpers panic on sizes outside
the pool's classes rather than indexing past it.

Because the memory is now reused, PacketForwarder implementations must
not retain the payload after ForwardPacket returns. Make that explicit
in the signature: the payload is passed as a new derp.LoanedBytes
value, which exposes only Len, WriteTo, and Clone, so an implementation
has to copy to keep it. derp.Client and derphttp.Client, the real
implementations, already wrote it out synchronously; the test-only
channelFwd now clones.

BenchmarkSendRecv shows one fewer allocation per relayed packet and,
for 1000-byte packets, B/op down from 1278 to 263. ns/op on the
loopback benchmarks is dominated by syscalls and is unchanged within
noise.

Updates #21064

Change-Id: Ie40c82388ddb5d22f75fa828749b53fcaba9adde
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-18 08:55:48 -07:00
Adrian Dewhurst c2dc086468 util/multierr: mark multierr.New as deprecated
We banned use of multierr in various dep tests, so this makes the
situation more obvious if someone stumbles across it.

Change-Id: I17e80880e57e5005fcaea864a365ba264411e802
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2026-09-18 11:46:09 -04:00
Brad Fitzpatrick 47debc5a6f derp/derpserver: don't build debug log arguments on the packet path
sclient.debugLogf and Server.debugLogf check a debug flag before
logging, but Go evaluates and boxes their arguments before the call.
The per-packet call sites in run, handleFrameSendPacket,
handleFrameForwardPacket, sendPkt, recordDrop, and sendPacket's
deferred stats func were therefore calling key.NodePublic.ShortString
and boxing frame headers on every relayed packet, all for messages
that were then discarded.

On one busy server's heap profile, those discarded arguments were
about half of all objects allocated by the process. Guard each hot
call site with the debug flag so nothing is built unless it will be
logged, and document that requirement on both debugLogf methods.

While here, give the sender cardinality sketch its key bytes from a
stack array rather than an AppendTo(nil) allocation per packet.

BenchmarkSendRecv drops from 10 or 11 allocations per relayed packet
to 3, and BenchmarkConcurrentStreams from 11 to 4.

Updates #21064

Change-Id: Ibb7c4fbff546b6f1b40ce0a21d41ae0976705941
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-18 06:52:51 -07:00
Brad Fitzpatrick 1033e714ca cmd/testwrapper: run all package patterns in one go test invocation
testwrapper ran a separate, sequential "go test" invocation for each
package pattern on its command line. That is fine for a single "./..."
argument but not for callers that pass an explicit package list: CI
jobs in the corp repo passing ~200 packages ran ~200 serial go test
processes with no cross-package parallelism and a fixed set of
never-cacheable lookups per process, and spent several times longer
on process startup, package loading, cache lookups, and serial test
binary links than on running tests. See tailscale/corp#48453 for the
details.

Locally, on 203 packages with a fully warm build and test cache, so
measuring only the per-invocation overhead:

  old (203 go test processes):  26.4s
  new (1 go test process):       3.6s  (7.3x faster)

Our own Windows CI job hits the same path: its "sharded:N/M" mode
expands to an explicit list of that shard's packages via listpkgs, so
each shard ran one go test process per package, and Windows process
startup is slower still. Each shard now runs as one invocation.

Fixes tailscale/corp#48453
Updates tailscale/corp#47035

Change-Id: I3f796ff1724af40f93be9f918a7ddfde3bb45a91
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-18 02:56:29 -07:00
Brad Fitzpatrick 3323dc02f4 tsweb: restore AcceptsEncoding
Commit 6608b9a38 removed tsweb.AcceptsEncoding, saying it had no
callers, but it has many callers in the tailscale.io repo. Restore the
function and its test unchanged so those builds work again.

Updates #12170
Updates tailscale/corp#48447 (broken by this)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I7c3e2a9f5b1d4e8a6c0f2b3d9e1a7c5f4b8d2e6a
2026-09-17 21:36:46 -07:00
OSS Updater 28836381da go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2026-09-17 17:55:09 -07:00
yaruk-byte be89526457 tstest/integration: stop skipping Windows integration tests (#21187)
Updates #20750

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>
2026-09-17 17:11:00 -07:00
James Tucker aa1134d358 go.mod: update golangci-lint to v2
Moving to v2 because v1 references repositories that have been
deleted from GitHub, breaking GOPROXY=direct.

The lint config (.golangci.yml) was already in v2 format; this updates
the tool dependency used by 'make lint' to golangci-lint/v2 v2.13.2,
drops the now-obsolete blank import in internal/tooldeps in favor of a
Go 'tool' directive, and bumps the CI workflow binary to match.

Updates #cleanup

Signed-off-by: James Tucker <jftucker@gmail.com>
2026-09-17 16:55:23 -07:00
James Tucker 6608b9a387 tsweb/compserve, client/web: add zstd, remove brotli for precompressed assets
Adds tsweb/compserve: content negotiation for precompressed static
variants, with a transcode-to-identity fallback for clients that do not
accept an encoding (including when the raw file is absent), and
CompressWriter, which live-compresses dynamic responses with zstd in its
fastest mode, streamed incrementally with no buffering. Negotiation is
q-value and wildcard aware (gzip;q=0 previously matched gzip).

client/web serves its prebuilt embedded assets through compserve,
replacing brotli with zstd; the embedded FS is wrapped in
tsweb/vcstime for conditional-request mod times. tsweb/compserve/gzip.go
keeps transitional serving of gzip variants from pre-zstd file systems
(such as the currently published web-client-prebuilt module):
passthrough to gzip-accepting clients, transcoded to identity otherwise;
it becomes inert once a zstd-only module is published.

util/zstdframe gains pooled GetDecoder and GetStreamingEncoder
(concurrency=1). util/precompress is now a build-time tool, generating
zstd variants only. cmd/tsconnect and cmd/build-webclient consume the
new precompress/compserve split. tsweb.AcceptsEncoding and
tsweb/tswebutil are removed; negotiation lives in compserve and the
deprecated shim had no callers. go.mod bumps web-client-prebuilt.

Also fixes a transcoding bug where http.ServeContent's size probe via
the promoted zstd.Decoder.WriteTo could report a zero length, serving
empty bodies.

Updates tailscale/corp#20099

Signed-off-by: James Tucker <james@tailscale.com>
2026-09-17 15:23:49 -07:00
Dep Updater 178ef3db08 go.toolchain.rev: bump Go toolchain
* Go toolchain: https://github.com/tailscale/go/compare/d030173bb47a6c4a6f885cb56a97dd9eca5fb8b7...32e8826b089fee8cb0c5c4822b9794ca5004f23a

Triggered by @bradfitz via the bumpdep workflow.

Updates tailscale/go#189

Signed-off-by: Dep Updater <noreply+dep-updater@tailscale.com>
2026-09-17 15:03:04 -07:00
Patrick O'Doherty 2e72593fbf feature/clientupdate: require write access for update/install localapi (#21360)
The update/install localapi handler had no PermitWrite check, so any
local user who could reach the localapi socket could make the root
daemon self-update and restart itself. This has been the case since the
endpoint was added in November 2023.

Gate the handler behind PermitWrite so that only root or the operator
user can trigger a self-update, matching the other mutating handlers.

Updates tailscale/corp#48187

Change-Id: Iadfef939f5dab684652cd220e77de63b54bfca2f
Reported-by: Ben Carman <benthecarman@live.com>

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-09-17 14:30:40 -07:00
Naman Sood 027e249fcf net/socks5: correctly proxy half-closed TCP connections
Similar to #16462, when we are acting as a TCP proxy, we need to pass
through half-closes correctly since clients and servers will sometimes
close one direction of the connection and still rely on the other
direction working.

Fixes #20883.

Signed-off-by: Naman Sood <mail@nsood.in>
2026-09-17 17:22:32 -04:00
Naman Sood 35848427ce types/nettype: add HalfCloser type
We have multiple situations where we have a `net.Conn` representing a
TCP connection for a proxy and we need access to the underlying
`CloseWrite()` and `CloseRead()` functions to properly pass through
half-closes (see #16462, #20883). Add an interface we can cast to in
order to get access to these functions.

Updates #20883.

Signed-off-by: Naman Sood <mail@nsood.in>
2026-09-17 17:22:32 -04:00
Mike Shaver 16d19a7e5e cmd/tsidp: remove all but the warning from the in-tree tsidp code's README (#21354)
Updates tailscale/tsidp#185

Change-Id: I28cbb5b96a26b25db4311d793fcc7426bc9da9fa

Signed-off-by: Mike Shaver <shaver@tailscale.com>
2026-09-17 14:10:55 -04:00
yaruk-byte 1c7e77f094 tstest/integration: wait longer to remove the staged binaries at teardown (#21207)
The Windows GitHub runners' provisioning daemon, provjobd.exe, opens a
handle to each freshly-written tailscaled.exe. tb.TempDir's own cleanup
retries for a fixed 2s and gives up, failing whichever test happens to tear
down while the handle is held.

Updates #21099

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>
2026-09-17 09:44:05 -07:00
Mike Jensen 408504af89 net/packet: fix Transport panic from a bad IPv4 IHL (#21331)
`decode4` assigned `q.subofs` before validating it against the declared IP total length. A packet with an IHL past the end of the buffer was rejected but left `subofs` dangling there, so a later Transport call would panic.

This change only store `subofs` once validated. As defense in depth an additional bounds check is added in Transport.

Fuzzing was expanded and improved to get better coverage in `packet.go`.

Credit to @Dev-next-gen for finding and reporting.

Fixes tailscale/corp#48322
Updates tailscale/corp#46608

Change-Id: I198d06b921add9b188daad79d4ca473aed1e3b66

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-09-17 10:00:01 -06: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
Alex Chan ade2dc44b5 tka: add missing test case for "init with an untrusted key"
Updates tailscale/corp#45574

Change-Id: I1ab724b9b4629cd4a5b4bc71bf7417d58e49ee1d
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-09-17 13:29:16 +01:00
Brad Fitzpatrick 62f3130637 tstest/integration: deflake TestNATPing
TestNATPing called SetMasqueradeAddresses on the test control server
and then immediately read both nodes' status, expecting the new
masqueraded peer addresses to already be there. But the change reaches
the nodes asynchronously via their streaming map responses, so under
load the status check ran before the new map response arrived and the
test failed with "n1 sees n2 as 100.64.0.2; want 100.64.2.1" and the
like. This was the dominant failure mode on the flakes dashboard (11 of
the 18 most recent CI failures) and the only one found in a six hour
Antithesis run (run 30b4de27d89d8d7a651e7b43b3f1ec3f-61-9, 54 failures
in 16,599 runs).

Wait for each node's status to report the expected peer address
instead. Also retry the "tailscale ping" invocations, since a ping can
fail transiently right after a map response changes a peer's addresses
and before the engine is reconfigured; the second most common failure
mode was pings exiting with status 1. Failed pings now include the CLI
output in the error rather than a bare exit status.

Locally, flakestress (32 workers) reproduced the failure in 29 of 210
runs before this change (13.8%) and in 0 of 1,173 runs after.

The remaining Windows-only failure mode on the dashboard, TempDir
cleanup failing because tailscaled.exe is still open, is a
harness-wide issue that affects every integration test and is not
specific to this test.

Fixes #12169
Updates tailscale/corp#47865

Change-Id: I7c3e2b8a5d914f0e6a2b1c9d8e7f6a5b4c3d2e1f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-16 16:54:02 -07:00
Brad Fitzpatrick b0b1f0f566 go.mod: bump all direct deps to latest
This is the output of the new misc/bumpdeps tool (#21325) run with
--exclude-newer-than-days=7, which asks proxy.golang.org for the newest
version of every direct dependency, ignoring releases younger than a
week in favor of the newest older one, and runs a single go get.
gvisor tracks its "go" branch, wireguard-go its "tailscale" branch,
and golang-x-crypto its "main" branch (the proxy's @latest for it is
a stray v0.91.0 tag from 2024 that predates our acme fork changes).
Indirect deps only moved as far as MVS pulled them.

The week-long cooldown held back gvisor, the gokrazy modules,
chromedp/cdproto, and hashicorp/raft-boltdb/v2, whose only newer
versions are days old; they'll come along next time.

Several upstream changes needed small fixes: nfpm's PrepareForPackager
takes a modification time now (a zero time keeps the old behavior of
using the source file's mtime), esbuild's ServeOptions.Port became an
int while ServeResult.Host became a Hosts slice, client-go's
EventRecorder.Eventf is now recognized by vet as a printf wrapper (so
the k8s-operator calls that passed a preformatted message switch to
Event), google/nftables v0.3.0 reads back the kernel's
NF_NAT_RANGE_PROTO_SPECIFIED flag into a new expr.NAT.Specified field
(so the port map DNAT rule now sets it too or findRule never matches
the rule it just added), and staticcheck v0.8.1 knows encoding/json/v2's
embed tag option, so the two SA5008 suppressions for it are gone.

Two tests assumed old library behavior. client-go's fake clientset now
replays existing objects when a watch starts, as a real apiserver does,
so the k8s-proxy config test must tolerate the loader ignoring that
no-op event before the real reload arrives. fyne.io/systray moved its
dbusmenu object path and answers the first GetLayout with depth 1, so
the systray test now finds the menu via the item's Menu property and
polls until the submenu entries appear.

Then make tidy, make updatedeps, and make kube-generate-all (the
controller-gen bump to v0.22.0 changes doc strings, stops listing
top-level metadata as required, and crd-ref-docs now marks optional
fields).

Updates #8043

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3f9a2c6e8b1d4705a9e2c7b8d1f4e6a0c2b5d8e3
2026-09-16 16:01:50 -07:00
James Tucker 4f88a7ba46 tsweb/vcstime: add a VCS-time-stamped file system wrapper for embed.FS
embed.FS stores and serves no ModTime: files report a zero ModTime,
which disables HTTP caching semantics when served with net/http: no
Last-Modified header is sent and If-Modified-Since requests are never
answered with a 304.

vcstime.FS wraps an fs.FS (typically an embed.FS) and reports the
vcs.time commit time from the binary's build info as the modification
time of files that have no real timestamp of their own, enabling
http.FS to provide working cache headers for clean builds. Files that
already have a real timestamp are passed through untouched, and
binaries built without VCS stamping degrade to the file system's own
behavior.

Updates tailscale/corp#48172

Signed-off-by: James Tucker <james@tailscale.com>
2026-09-16 14:44:08 -07:00
Brad Fitzpatrick 46332efe57 .github/workflows: use the module proxy for generic bumpdep fetches
The bumpdep workflow ran every go get with GOPROXY=direct, copying the
corp update-oss workflow. For a module like github.com/gokrazy/kernel.amd64
that means cloning a repo full of kernel image blobs, which hung the
first real run of the workflow indefinitely.

Fetch generic modules through the default GOPROXY (proxy.golang.org),
which serves branch names like @main just fine. Keep GOPROXY=direct only
for wireguard-go, which is our own small repo where seeing a just-pushed
commit matters more than the proxy's cache lag. Also give the job a
45 minute timeout so a future hang doesn't hold a runner for six hours.

Updates tailscale/corp#48312

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3e7d1a9c5b2f48e06d7a1c9e4b3f2a8d6c5e7f10
2026-09-16 13:50:50 -07:00
Brad Fitzpatrick d1c5827ea2 .github/workflows: add bumpdep workflow to bump Go deps and open a PR
This is the OSS analog of the corp repo's update-oss workflow. It's
manually dispatched with a comma-separated list of dependencies and the
URL of the issue motivating the bump, updates each dependency, runs
"make tidy" and "make updatedeps", and opens a pull request from the
tailscale-code-updater app assigned to whoever triggered it.

Three names are special-cased: "go" runs ./pull-toolchain.sh,
"wireguard-go" tracks github.com/tailscale/wireguard-go@tailscale, and
"gvisor" tracks gvisor.dev/gvisor@go. Anything else is treated as a Go
module path and bumped to @latest (or to an explicit path@version). The
issue URL becomes the "Updates" line of the generated commit message.

Updates tailscale/corp#48312

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I6b2f9c1e8d4a7350b2e9f1c4d8a6e2b7f3c5d9a1
2026-09-16 12:58:41 -07:00
Brad Fitzpatrick fa7fb388b5 misc/bumpdeps: add tool to bump all go.mod deps to latest
Bumping deps one "go get foo@latest" at a time is slow with the ~500
modules in go.mod. This tool parses go.mod with x/mod/modfile, asks
proxy.golang.org concurrently for the newest version of each direct
dependency (about a second for the whole file), and then runs a single
"go get" with the modules that actually have something newer. Arguments
narrow it to modules whose path contains one of the given substrings,
so "bumpdeps gvisor" does the obvious thing.

gvisor.dev/gvisor, github.com/tailscale/wireguard-go, and
github.com/tailscale/golang-x-crypto follow branches ("go", "tailscale",
and "main" respectively) rather than tags, so those are resolved via the
proxy's @v/<branch>.info endpoint instead of @latest.

The --exclude-newer-than-days flag is a cooldown in the sense of
https://nesbitt.io/2026/03/04/package-managers-need-to-cool-down.html:
releases younger than that are ignored in favor of the newest one old
enough, so a compromised upstream has to go unnoticed that long before
we'd pick it up. It defaults to off. Branch-tracked modules have nothing
older to fall back to, so they're held until their head has aged.

The tool never downgrades, either by semver (the proxy's @latest can be
older than a pseudo-version we're already on) or by commit time (forks
can carry stray tags that sort above their real development branch, as
golang-x-crypto's v0.91.0 from 2024 does). It skips replaced modules and
modules whose latest release declares a different module path, since go
get rejects those and they need their import paths changed by hand.
Indirect deps are left to MVS by default; -indirect bumps them too, but
that tends to break the build when their importers haven't caught up.

Updates #8043

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I7c2e9a41d5f0b83e6a1c4d9f2b7e8a0c3d5f6b1e
2026-09-16 11:30:55 -07: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
Francois Marier 8b3b8f122e net/portmapper: cleanup old PCP TODOs
Requesting a UDP mapping is the right thing to do since an "all
protocols" (and "all ports") mapping would be akin to requesting
to be a DMZ on that network.

Updates #cleanup

Change-Id: Icc83d4fe14dbec0eb844b093d2d92756d6c05228
Signed-off-by: Francois Marier <francois@tailscale.com>
2026-09-16 11:01:34 -07: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
Brad Fitzpatrick e22eaaebfd control/tsp: don't use bootstrap DNS from the noise client
tsp is a protocol library and should not care about the system's routing
table, interfaces, or how to reach DERP for bootstrap DNS. Those are
tailscaled concerns. Without an explicit DNSCache, ts2021 built a resolver
whose LookupIPFallback did bootstrap DNS over DERP whenever the first dial
to control failed, and that path also crashed on tsp's nil netmon.Monitor.

Provide a plain dnscache.Resolver with no fallback so a failed dial is
just a failed dial.

Updates tailscale/corp#47865

Change-Id: If62ddb90139d5590ca3e0e3f54235bbbc0d8aec2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-16 07:29:31 -07:00
Brad Fitzpatrick 41a756c1fe net/dnsfallback: don't panic on nil netmon.Monitor in bootstrap DNS path
MakeLookupFunc documents its netMon parameter as optional, but
bootstrapDNSMap passed it straight to netns.NewDialer, which has panicked
on nil since 3672f29a4. Any caller that omitted the monitor and then had a
first dial fail (which is when dnscache consults LookupIPFallback) crashed
with "netns.NewDialer called with nil netMon". control/tsp clients hit this
under Antithesis fault injection.

Use a plain net.Dialer when no monitor is provided and add a regression
test.

Updates tailscale/corp#47865

Change-Id: Ie8255033602dd1f8243c1ced8f453fb7ab9be74d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-16 07:29:31 -07:00
Michael Ben-Ami 407d32ff42 appc: detect cycles in CNAME chain
Fixes tailscale/corp#48296
Updates tailscale/corp#48187

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-09-16 10:08:11 -04: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
Thomas Desrosiers 7add2af9ec prober: cache CRLs across TLS probes (#21286)
The TLS probe fetched and parsed the leaf certificate's CRL on every
run. That is fine when the CRL is small, but some CAs publish CRLs of
several megabytes: the one for the AWS ACM R2M04 intermediate is about
2.5MB, which at the default 15s interval is a continuous 170kB/s per
probed node.

Cache parsed CRLs by distribution point URL and reuse each for up to
an hour, or until its NextUpdate if that comes first. An hour is the
HTTP max-age Let's Encrypt serves on its root CRL. A CRL is cached
only after its signature verifies, every use still re-verifies it
against the probing leaf's issuer (the cache is keyed by URL alone),
and a CRL without a NextUpdate is never cached since it declares no
validity window.

Concurrent probes fetch through singleflight.DoChanContext to avoid
re-fetching a single CRL, with each waiter keeping its own deadline. A
caller that missed the cache re-checks it inside the singleflight
closure, since singleflight dedupes only calls that overlap.

Leaf certificates whose issuer is missing from the presented chain now
fail before any fetch. Previously the probe downloaded the CRL and then
panicked in CheckSignatureFrom, which the prober recovered and recorded
as a probe failure.

Also update the TLS probe's doc comments, which said OCSP where the
code checks a CRL.

Fixes #21310

Signed-off-by: Thomas Desrosiers <git@hive.pw>
2026-09-16 00:08:19 -04:00
Brad Fitzpatrick 678ad167e6 go.toolchain.rev: bump tailscale/go again
For https://github.com/tailscale/go/pull/188

Updates tailscale/corp#29053

Change-Id: I3d42156b3c2ef824b68033031d9be48fe7989176
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-15 20:54:53 -07:00
Brad Fitzpatrick a2263542f2 cmd/testcontrol: add --addr and --ssh-policy flags
The test control server always listened on 127.0.0.1:9911, which is
useless for a node in a VM on the same machine. --addr picks the listen
address; the DERP and STUN servers follow it.

--ssh-policy loads a tailcfg.SSHPolicy from a JSON or HuJSON file and
sends it to every node, which also grants them the SSH node capability so
that "tailscale up --ssh" is accepted. That is what testcontrol.Server
already supported for tests; this exposes it for manual testing of the
Tailscale SSH server.

Updates tailscale/corp#47865

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I2b7c4e9f1a3d5c6e8b0f2a4d6c8e1b3f5a7d9c0e
2026-09-15 20:03:21 -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
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
James Tucker 3ec674bc86 go.mod,wgengine/netstack,tstest/natlab/vnet: enable RACK and cubic with gVisor clock fix
Update gVisor to include its fix for RACK loss detection with coarse
monotonic clocks. Configure netstack with the 500 microsecond clock
resolution used on Windows so RACK accounts for timestamp quantization.

Remove the TCP recovery override that disabled RACK, enabling gVisor's
default RACK behavior on all platforms.

Switch netstack to cubic congestion control. The int overflow in CUBIC
sender cwnd arithmetic that required pinning reno has since been reworked
upstream into float arithmetic with RFC 9438 target clamping.

Align the natlab vnet stack with netstack: enable cubic, and drop the
now-redundant explicit SACK and receive-buffer moderation sets, both of
which are gVisor defaults.

Fixes #9707

Signed-off-by: James Tucker <jftucker@gmail.com>
2026-09-15 17:15:35 -07:00
Leo Camus 8b5a87010f net/packet: don't panic in Payload when dataofs is past length (#21232)
Payload guards a truncated packet by comparing both length and dataofs
against len(b), but the slice it returns is b[dataofs:length], so what
actually has to hold is dataofs <= length. Those are independent:
length comes from the IPv4 total length header field, while dataofs is
derived from the sub-protocol header, and decode4 never checks that the
declared total length covers the transport header.

A 28-byte IPv4/UDP packet declaring a total length of 20 decodes to
length=20, dataofs=28, len(b)=28, and Payload then evaluates b[28:20].
ICMPv4 and TCP reach the same state with 24- and 40-byte packets.

I found this by fuzzing Decode and then calling the accessors on the
result. I did not find a caller that can be driven into it from the
network: wireguard-go truncates decrypted packets to the declared IP
length, so on the inbound path dataofs > length implies dataofs >
len(b) and the existing guard already catches it.

Add a FuzzParsedPayload target that decodes and then calls Payload,
seeded with valid IPv4/IPv6 packets and with invalid ones, including
the three short total length packets above, and build it in
fuzz/oss-fuzz.sh.

Fixes #21231

Change-Id: Ie5d2100464b79750626b1bfefbe4020c4a42ca91

Signed-off-by: leoca <leo.camus23@gmail.com>
2026-09-15 17:55:00 -06:00
François Marier 7f9de91e99 net/portmapper: save and verify PCP nonces (#21277)
The nonce used in PCP identifies a particular port mapping. It needs
to be generated randomly for the initial mapping, and then saved so
that it can be used to renew or delete a mapping later. This is how
the router checks that we're authorized to change existing mappings.

If we receive a response for a different nonce, we should ignore it
since it's not meant for us (or the router is misbehaving).

Fixes #21127

Change-Id: Ic7874d376a74791807953f2f559c8a14a8eb75e9
Signed-off-by: Francois Marier <francois@tailscale.com>
2026-09-15 16:00:35 -07:00
Brad Fitzpatrick f92ca3f545 tsweb: add /debug/runtime-metrics page exposing Go runtime/metrics
The tsweb DebugHandler already links to expvar, Prometheus varz, and
pprof, but the only runtime visibility beyond pprof was the handful of
runtime.MemStats fields that varz special-cases. The runtime/metrics
package has far more (GC CPU classes, scheduler latencies, stop-the-world
pause histograms, heap breakdowns, and so on) and is the runtime's
preferred, cheaper interface.

Add a /debug/runtime-metrics handler. By default it serves only an index
of metric names, kinds, and descriptions, which are static, so viewing
the page does not read any values. The index is an HTML table for
browsers (Accept: text/html) and plain text otherwise; format=html or
format=text overrides the sniffing.

Values are read only on request, always as JSON:

  - /debug/runtime-metrics/gc/heap/allocs:bytes returns that metric's
    bare value, handy for scripts and curl.
  - /debug/runtime-metrics?name=NAME (repeatable) returns a JSON object
    keyed by metric name. A trailing * matches by prefix, so name=/gc/*
    returns the GC metrics and name=* returns everything.

Histograms are objects with counts and buckets arrays; infinite bucket
boundaries, which JSON cannot represent, are the strings "-Inf" and
"+Inf".

In the HTML index, exact metric names link to the bare value form, and
each run of metrics sharing a directory is headed by a row linking every
ancestor directory to its wildcard query (/gc/*, /gc/heap/*, ...).

Responses inherit the debug handler's nosniff, framing, and CSP headers,
and application/json is not a script MIME type, so the values cannot be
pulled in cross-origin as a script by a malicious page.

Like the pprof handlers, this is excluded from js/wasm builds to keep
them small.

Updates #21300

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I7c2e9f4a1b8d3e6f0a5c9b2d4e7f1a3c6b8d0e2f
2026-09-15 15:26:06 -07:00
Brad Fitzpatrick f1c4fe334f go.toolchain.rev: bump Go 1.27 toolchain
Updates tailscale/corp#29053

Change-Id: Ia7a1430a760564ef75a020338337d05f7e8132a8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-15 15:22:37 -07:00
Michael Ben-Ami b5e07cbf53 feature/conn25,types/appctype: delete TemporaryUnsafeBypassFilter from
conn25 node attribute type

This node attribute configuration knob, when enabled, was bypassing two
peercap (tailscale.com/cap/conn25/<app_name>) checks while they were
under development and testing:

- PeerAPI DoH on the connector verifying the peercap includes the app in
  query parameter of the request.
- PeerAPI ConnectorTransitIPRequest verifying the peercap includes the
  app in the request.

With this change, we always perform those checks, and delete bypass
knob.

Updates tailscale/corp#40076
Updates tailscale/corp#48041

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-09-15 15:34:05 -04:00
Brad Fitzpatrick 30f5d94808 derp/derpserver: pool per-client send queue buffers
Each connected client held two buffered channels of 32 pkts each (its
regular and disco send queues), about 5.5 KB per client for the
lifetime of the connection, even though almost all clients are idle at
any given moment.

Replace the channels with pktQueue, a mutex-guarded ring buffer whose
backing array comes from a per-server sync.Pool on first enqueue and
goes back when the queue drains empty, so an idle client holds no
queue memory at all. Enqueuers wake the client's sendLoop through a
one-slot channel.

Updates #21064

Change-Id: I2d7e8f4a1b6c3950e2a7d4b8f1c5e3a9d6b0c2e4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-15 12:10:16 -07:00
Brad Fitzpatrick 2fc4be0f76 net/dns/resolver: only accept UDP DNS replies from the queried resolver
The forwarder's upstream UDP socket is unconnected, so any host that
could send a datagram to its ephemeral port reached the single
ReadFromUDPAddrPort call that decided the query: a datagram with the
right 16-bit transaction ID was returned to the client as the
resolver's answer no matter what address it came from, and a datagram
with the wrong ID failed the query outright, handing it to the TCP
fallback (or to nothing at all, with TCP retries disabled).

sendUDP now keeps reading until a datagram arrives that could be a
reply to the query it sent: one from the resolver's address and port
carrying the transaction ID of the request. Other datagrams are
dropped and counted, so neither a spoofed reply nor a single stray
datagram can answer or end the query. This is the same source check
the kernel applies to a connected socket; the socket stays unconnected
because its ListenPacket path is what binds the query to a link
(IP_BOUND_IF on macOS, ForwardLinkSelector elsewhere). The read loop
still ends when the query context expires and closeOnCtxDone closes
the conn, as before.

On Windows an oversized datagram is reported as a truncation error
without a source address, so there the transaction ID remains the only
check, as before.

Thanks to Ben Carman for the report!

Updates tailscale/corp#48187

Reported-by: Ben Carman
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ibabbec5c82739b66caa6fb943faa376c39adbf64
2026-09-15 09:44:43 -07:00
Brendan Creane 3ea665b113 tstest/natlab/vmtest: cover openresolv with a second snippet registered (#21003)
TestOpenresolvDNS pins the case where Tailscale owns the only resolvconf
snippet, so on its own it would also pass if tailscaled never read an OS
base config at all. Register a snippet the way a DHCP client would, then
toggle accept-dns to force a reapply, and assert its nameserver becomes
quad-100's upstream.

SetAcceptDNS is the toggle: tailscaled has no reason to re-read the OS
config on its own, and this avoids rebooting the guest to change what the
OS resolver looks like mid-test.

Updates #20825
Updates tailscale/corp#44793

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-09-15 10:24:47 -06:00
Brad Fitzpatrick 87c35ff83b tsnet: deflake TestUserMetricsByteCounters
TestUserMetricsByteCounters is currently our top flake at http://flakes/

The test waited for a direct path between its two localhost nodes by
polling s1's status until CurAddr was set, then asserted that the
transferred bytes appeared in the path="direct_ipv4" counters. Both
steps were flaky:

- CurAddr is only reported while magicsock has a confirmed, currently
  trusted UDP address for the peer, which depends on a heartbeat ping
  and CallMeMaybe cycle that can lag arbitrarily under CI load. In the
  CI failure logs the transfer itself went direct and the byte counter
  assertions passed; the only failure was the polling helper's
  t.Error.

- On a machine where localhost has both IPv4 and IPv6, magicsock can
  pick ::1 (it prefers IPv6 on latency ties), so the transfer lands in
  the direct_ipv6 counters instead.

- If no direct path is established yet, the transfer goes over DERP, whose
  wire-byte total (relay framing, plus TCP retransmissions inside the
  tunnel under load) can exceed any fixed tolerance; one flakestress
  run counted 33% more than the payload sent.

Drop the direct-path wait and the hardcoded path label, and assert the
one invariant the test actually cares about: at least the number of
bytes sent must be counted across all path counters combined. There is
deliberately no upper bound, since the counters legitimately include
overhead, retransmissions, and the duplicate copies magicsock sends on
several paths while a direct path is being confirmed.

Verified with flakestress: the old test failed within 7 runs, while the
fixed test passed 33,521 runs with no failures.

See
http://flakes/analyze-test?name=tailscale.com%2ftsnet.TestUserMetricsByteCounters

Updates #deflake

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I4d8a9bfb9549ea9d8d33d3a3b55b4d03f9b9be78
2026-09-15 09:06:57 -07:00
Brad Fitzpatrick 0a621d23a4 util/syspolicy/source: read policies without the GP lock when it's denied
On Windows, I could run tailscaled in the terminal over SSH only when
built with ts_omit_syspolicy, but that's kinda a sad workaround. This
is the alternative.

syspolicy registers a Registry policy store for the current user
whenever tailscaled isn't running as LocalSystem, and the first read
of that store takes the Group Policy read lock via
EnterCriticalPolicySection(FALSE). That call fails with
ERROR_ACCESS_DENIED when the user has no interactive logon session,
which is the case for a tailscaled.exe started over SSH (or WinRM,
psexec, a non-interactive scheduled task). The store's Lock method
returned the error, the reader failed to load, and LocalBackend
treated that as fatal:

    ipnlocal.NewLocalBackend: syspolicy: LocalBackend failed to register
    policy change callback: failed to get a store reader: Access is denied.

The GP lock is only there to keep reads of several settings consistent
while Group Policy is being applied; the store already documents that
reading the Registry without it is safe, and already skips it when
gp.ErrLockRestricted is returned during service start. Treat
ERROR_ACCESS_DENIED the same way: log it and read unlocked. Other errors
from the lock still propagate.

The optionalPolicyLock wrapper now holds the lockableCloser interface
instead of *gp.PolicyLock so the test can inject a failing lock.

Tested on a Windows Server 2022 VM: the test passes from an SSH session,
and a foreground tailscaled started from that session now runs and logs in
instead of exiting at startup.

Updates #21290

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I395f356730092299cf020a6787c56ab3b9ed6fe5
2026-09-15 08:54:38 -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