WIP: rebase fork onto upstream/main (v1.103.0) #15
Closed
codinget
wants to merge 670 commits from
webnet into save/webnet-2026-07-29
pull from: webnet
merge into: :save/webnet-2026-07-29
:webnet
:feat/drive-peers-has-shares
:save/webnet-2026-07-29
:rebase/2026-05-18
No Reviewers
Labels
Clear labels
Agentic
Component/CI
Component/Funnel
Component/React
Component/State
Component/Taildrive
Component/Taildrop
Component/Tailscale
Component/Tailshare
Component/Transport
Component/tsconnect
Component/VFS
Component/WebRTC
Component/Worker
Human
Protocol/FTP
Protocol/HTTP
Protocol/SFTP
Protocol/SMB
Protocol/SSH
Protocol/WebDAV
Protocol/WebSocket
Security
Agent
claude-fable-5
Work done by Claude Fable 5
Agent
claude-opus-4-8
Work done by Claude Opus 4.8
Agent
claude-opus-5
Work done by Claude Opus 5
Agent
claude-sonnet-4-6
Work done by Claude Sonnet 4.6
Agent
claude-sonnet-5
Work done by Claude Sonnet 5
Agent
gpt-5.5
Work done by GPT 5.5
Agent
gpt-5.6-luna
Work done by GPT 5.6 Luna
Agent
gpt-5.6-sol
Work done by GPT 5.6 Sol
Agent
gpt-5.6-terra
Work done by GPT 5.6 Terra
Opened by an agent
Work on the CI tooling
Work on the Tailscale Funnel or certificate system
Work on a React binding
Work on a state store (eg Redux)
Work on the taildrive system
Work on the taildrop system
Work on the Tailscale fork
Work on the Tailshare app
Work on the transport system
Work on the tsconnect packages
Work on the VFS system
Work on the WebRTC system
Work on the worker system
Opened by a human
Kind
Bug
Bug work
Kind
Enhancement
Enhancement work
Kind
Feature
Feature work
Kind
Maintenance
Maintenance work
Priority
P0
1
Critical work that must be done right now
Priority
P1
2
Urgent work
Priority
P2
3
Medium priority work
Priority
P3
4
Low priority work
Priority
P4
5
Lowest priority work, wishlist-tier
Work on the FTP protocol
Work on the HTTP protocol
Work on the SFTP protocol
Work on the SMB protocol
Work on the SSH protocol
Work on the WebDAV protocol
Work on the WebSocket protocol
Security work
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: webnet/tailscale#15
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Rebases the 30
webnetcommits ontoupstream/main(v1.97.0 -> v1.103.0, 639 upstream commits since the 2026-04-09 merge base03c3551ee).Not intended to be merged: swap the branches instead.
Contents
29 rebased fork commits (one dropped, see below) + 1 new commit for the wasm feature-tag set.
Conflicts resolved
Notify.NetMap->Notify.SelfChange. Upstream stopped shipping peers on the IPN bus. The JS netmap snapshot is now rebuilt onSelfChangefromlb.NetMapWithPeers(); the fork'sExitNodeOption,StableNodeID,PeerAPIURLandServicesfields were folded into that new structure.feature/acme.ipn/ipnlocal/cert.gois now a thin hook shim andcert_disabled.gois gone, so the fork's changes moved:certStorespecial case ->feature/acme/certstore.gogetCertStoreLocalBackend.SetACMEHTTPClient->acme.SetHTTPClient(lb, c)on the extension (it had no callers even atwebnettip, but is kept rather than dropped)feature/condregister/maybe_acme.gobuild tag!js && !ts_omit_acme->!ts_omit_acme, so ACME links on jsupdateOutgoingFilesto takemap[string]ipn.OutgoingFile(values, not pointers); the exportedUpdateOutgoingFileswrapper and its wasm caller were adapted.build-pkg.go. Kept the fork's removal of wasm pre-compression while keeping upstream's newwriteBuildInfo().Dropped
fix(tsconnect): drop nethttpomithttp2 build tag— upstream did the same thing and more in the newcmd/tsconnect/wasmbuildpackage, whosebaseTagsexplicitly excludesnethttpomithttp2with a comment. The commit rebased to empty.New commit
Upstream's new
wasmbuild.Keepis scoped to its SSH-in-browser client and omitsacme,serve,taildrop,drive,advertiseexitnodeanduseexitnode. The JS bridge here exposes all of them, soKeepwas extended; without this the wasm build compiles but the features are stripped out.Verification
GOOS=js GOARCH=wasm, so the series is bisectable.wasmbuild.Tags()output.go test ./feature/acme/... ./feature/taildrop/... ./ipn/ipnlocal/...all pass.@webnet/tsconnectand manual feature testing have not been run here.🤖 Generated with Claude Code
Add a --headless flag to the Host.app Run subcommand for running macOS VMs without a GUI, enabling use from test frameworks. Key changes: - HostCli.swift: When --headless is set, run the VM via VMController + RunLoop.main.run() instead of NSApplicationMain. Using the RunLoop (not dispatchMain) is required because VZ framework callbacks depend on RunLoop sources. - VMController.swift: Add headless parameter to createVirtualMachine that configures a single socket-based NIC (no NAT NIC). This matches the NIC configuration used when creating/saving VMs, so saved state restoration works correctly. A NIC count mismatch causes VZ to silently fail to execute guest code. - TailMacConfigHelper.swift: Clean up socket network device logging. - Config.swift: Move VM storage from ~/VM.bundle to ~/.cache/tailscale/vmtest/macos/. - TailMac.swift: Fix dispatchMain→RunLoop.main.run() in the create command (same VZ RunLoop requirement). Updates #13038 Change-Id: Iea51c043aa92e8fc6257139b9f0e2e7677072fa2 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Add an opt-in metrics.LabelMap tracking why patchifyPeer fails to convert a PeersChanged entry into a PeersChangedPatch. The stats are gated behind the TS_DEBUG_PATCHIFY_PEER_MISS envknob so there is zero overhead in normal operation. peerChangeDiff now takes an optional onFalse callback that is called with the field name on every non-patchable return path. When the envknob is off, nil is passed and replaced with a no-op at the top of peerChangeDiff. The resulting metric renders as: counter_patchify_miss{why="Hostinfo"} 2 counter_patchify_miss{why="peer_not_found"} 1170 Updates tailscale/corp#40088 Change-Id: I2d4b9074bf42ec03ab296c0629a54106bafa873e Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Before: tka initialized at head 325557575a59525354484e4a534f494b4c4e56575435583737564b5036584c4d4c335534554255344c344c36484c5a444a323341 After: tka initialized at head 2UWWZYRSTHNJSOIKLNVWT5X77VKP6XLML3U4UBU4L4L6HLZDJ23A Printing the AUM hash as hex makes it difficult to compare to other AUM hashes; stringifying it will make it consistent with other printing. Updates #cleanup Change-Id: Ic1e23a9ce6a71a53cff7d2190f9fa06eb838ab89 Signed-off-by: Alex Chan <alexc@tailscale.com>Adds a CI check to keep opted-in directories' README.md files in sync with their package godoc. For now tsnet (and its sub-packages under tsnet/example) is the only opted-in tree. The list of directories lives in misc/genreadme/genreadme.go as defaultRoots, so CI and humans both just run `./tool/go run ./misc/genreadme` with no arguments. The check piggybacks on the existing go_generate job in test.yml and fails if any README.md is out of date, pointing the user at the same command. Along the way: - tempfork/pkgdoc now emits Markdown instead of plain text: headings become level-2 with no {#hdr-...} anchors, and [Symbol] doc links resolve to pkg.go.dev URLs, including for symbols in the current package (which the default Printer would otherwise emit as bare #Name fragments with no backing anchor in a README). Parsing no longer uses parser.ImportsOnly, so doc.Package knows the package's symbols and can resolve [Symbol] links at all. - genreadme also emits a pkg.go.dev Go Reference badge at the top of a library package's README; suppressed for package main. - tsnet/tsnet.go's package godoc is expanded in idiomatic godoc syntax — [Type], [Type.Method], reference-style [link]: URL definitions — rather than Markdown-flavored [text](url) or backtick-quoted identifiers, so that both pkg.go.dev and the generated README.md render cleanly from a single source. Fixes #19431 Fixes #19483 Fixes #19470 Change-Id: I8ca37e9e7b3bd446b8bfa7a91ac548f142688cb1 Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com> Signed-off-by: Walter Poupore <walterp@tailscale.com> Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>CmdName was re-opening the running executable and scanning it in 64KiB chunks for the Go modinfo markers on every call. The same modinfo is already parsed at startup and exposed via runtime/debug.ReadBuildInfo, so prefer that on non-Windows. Windows still takes the scanning path because its GUI-binary override keys off the on-disk executable name. benchstat of BenchmarkCmdName (Linux, before vs after): goos: linux goarch: amd64 pkg: tailscale.com/version cpu: Intel(R) Xeon(R) 6975P-C │ /tmp/old.txt │ /tmp/new.txt │ │ sec/op │ sec/op vs base │ CmdName-16 556045.5n ± 1% 825.6n ± 1% -99.85% (p=0.000 n=10) │ /tmp/old.txt │ /tmp/new.txt │ │ B/op │ B/op vs base │ CmdName-16 64.587Ki ± 0% 1.156Ki ± 0% -98.21% (p=0.000 n=10) │ /tmp/old.txt │ /tmp/new.txt │ │ allocs/op │ allocs/op vs base │ CmdName-16 8.000 ± 0% 7.000 ± 0% -12.50% (p=0.000 n=10) Fixes #19486 Change-Id: I925c5e28b64815a602459beb6c8dab8779339a6c Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Previously, handleLocalPackets intercepted traffic to the Tailscale service IP (100.100.100.100 / fd7a:115c:a1e0::53) only for an allow-list of ports: TCP 53/80/8080 and UDP 53. Any other port returned filter.Accept, letting the packet fall through to the ACL filter and wireguard-go, which would attempt a peer lookup. No peer owns the quad-100 AllowedIP, so after ~5s pendopen.go would log: open-conn-track: timeout opening ...; no associated peer node This is the common "conntrack error no peer found for 100.100.100.100:853" log spam seen in the wild (e.g. from systemd-resolved or another resolver speculatively trying DoT on quad-100). It also leaks quad-100 packets onto the tailnet. Remove the port allow-list so handleLocalPackets absorbs every quad-100 packet into netstack regardless of IP protocol or port. Traffic never reaches the conntrack / peer-routing layers. With the allow-list gone, acceptTCP needs a corresponding guard: on a quad-100 TCP port we don't serve, execution used to fall through to the isTailscaleIP case (quad-100 is in the tailscale IP range), which rewrote the dial target to 127.0.0.1:<port> and forwardTCP'd the connection to whatever happened to be listening on the host's loopback at that port. Add a hittingServiceIP case that RSTs cleanly instead, placed before the isTailscaleIP fallthrough. TestQuad100UnservedTCPPortDoesNotForward is a new integration test that injects a TCP SYN to 100.100.100.100:853 via handleLocalPackets, stubs forwardDialFunc, and asserts the dialer is not invoked; it catches regressions of the acceptTCP recursion/loopback-redirection case. Fixes #15796 Fixes #19421 Updates #3261 Updates #11305 Signed-off-by: James Tucker <james@tailscale.com>Add a Go benchmark that exercises a single tailnet client (a [tsnet.Server] running in the test process) against a synthetic large initial netmap and a stream of caller-driven peer add/remove deltas, all in-process. The harness is split in two parts: - tstest/largetailnet, a reusable package containing a [Streamer] that hijacks the map long-poll on a [testcontrol.Server] via the new AltMapStream hook, sends one initial MapResponse with N synthetic peers, and forwards caller-supplied delta MapResponses on the same stream. Helpers like MakePeer / AllocPeer build synthetic peers with unique IDs and addresses derived from the Tailscale ULA range. - tstest/largetailnet/largetailnet_test.go, BenchmarkGiantTailnet (headless tailscaled workload, no IPN bus subscriber) and BenchmarkGiantTailnetBusWatcher (GUI-client workload with one Notify subscriber attached). Both are gated on --actually-test-giant-tailnet (skipped by default), stand up an in-process testcontrol + tsnet.Server, let Up block until the initial N-peer netmap has been processed, then ResetTimer and run add+remove pairs via b.Loop. Per-delta sync is via a test-only [ipnlocal.LocalBackend.AwaitNodeKeyForTest] channel that closes once the just-added peer key appears in the netmap (no-watcher variant) or via bus-Notify drain (bus-watcher variant). To support the hijack, [testcontrol.Server] grows an AltMapStream hook and a small MapStreamWriter interface for benchmarks/stress tests that need to drive a controlled MapResponse sequence; the normal serveMap path is untouched when AltMapStream is nil. The streamer answers non-streaming "lite" map polls (which controlclient issues before the streaming long-poll to push HostInfo) with an empty MapResponse and returns immediately, so the streaming poll that follows is the one that gets the initial netmap. The benchmark is intended for before/after comparisons of netmap- and delta-handling changes targeted at large tailnets. CPU profiles on unmodified main show the expected O(N) hotspots: setControlClientStatusLocked / authReconfigLocked / userspaceEngine.Reconfig / setNetMapLocked, plus JSON encoding of the full Notify.NetMap to bus watchers (which dominates the BusWatcher variant). Median ms/op over 10 runs on unmodified main, by tailnet size N: N no-watcher bus-watcher 10000 32 166 50000 222 865 100000 504 1765 250000 1551 4696 Recommended invocation: go test ./tstest/largetailnet/ -run=^$ \ -bench='BenchmarkGiantTailnet(BusWatcher)?$' \ -benchtime=2000x -timeout=10m \ --actually-test-giant-tailnet \ --giant-tailnet-n=250000 \ -cpuprofile=/tmp/giant.cpu.pprof Updates #12542 Change-Id: I4f5b2bb271a36ba853d5a0ffe82054ef2b15c585 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>The Online bit in PeerStatus comes from control's last-known state and can lag reality, so gating "tailscale file cp" on it is both unreliable and pushes correctness onto the server. Just try the push directly. In runCp, when the target's PeerStatus says it's offline, no longer fail upfront; getTargetStableID returns the StableID anyway. Replace the static "is offline" warning with a 3-second timer armed for the first file: if the timer fires before peerAPI bytes have flowed, we print a warning to stderr. The wording depends on whether control reported the peer offline ("is reportedly offline; trying anyway") or online ("is not replying; trying anyway"). The warning is printed with a leading vt100 clear-line and a trailing newline so it doesn't get painted over by the progress redraw and so the next progress redraw lands on a fresh line below it. Both the timer disarm and the progress display now read from tailscaled's OutgoingFile.Sent (subscribed via WatchIPNBus) instead of the local-body counter. That's the difference between bytes-acked-by- local-tailscaled (what countingReader.n was measuring; useless for detecting an unreachable peer because for small files net/http buffers the entire body into the unix-socket conn before the peerAPI dial has even started) and bytes-pulled-toward-peerAPI (what tailscaled is actually doing, reflected in OutgoingFile.Sent). The previous code reported 100% within milliseconds for a 3 KiB file even when the peer was unreachable. Add --update-interval (default 250ms) to control the progress repaint cadence; zero or negative disables the progress display entirely. The printer now also stops repainting once it observes Sent at full size with a near-zero rate for >2s, so a stuck transfer doesn't keep clobbering whatever the rest of runCp is trying to print. Updates #18740 Change-Id: I189bd1c2cd8e094d372c4fee23114b1d2f8024b4 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>When --vmtest-web is set, Host.app is launched with --screenshot-port 0 to start a localhost HTTP server that captures the VZVirtualMachineView display. The Go test harness parses the SCREENSHOT_PORT=<port> line from stdout, then polls every 2 seconds for JPEG thumbnails and pushes them over WebSocket to the web dashboard. Clicking a screenshot thumbnail opens a full-resolution image proxied through the web UI's /screenshot/{node} endpoint. Screenshot events are excluded from the EventBus history (they're large and only the latest matters, stored in NodeStatus.Screenshot). Updates #13038 Change-Id: I9bc67ddd1cc72948b33c555d4be3d8db06a41f6d Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>DNSConfigresource (#19429) a29e42135bTwo cloud-platform nodes (e.g. sr-a and sr-b in TestSiteToSite) boot in parallel via errgroup and both call ensureCompiled and the inline image preparation block, racing to Begin() the same shared *Step (which is deduped by name in Env.Step). The second goroutine panics: panic: Step "Compile linux_amd64 binaries": Begin called in state running panic: Step "Prepare ubuntu-24.04 image": Begin called in state done ensureCompiled had a TOCTOU dedup attempt (released compileMu before doing the work, only added to the compiled set at the end), and image preparation had no dedup at all. Replace the compiled set with a per-key map[string]*sync.Once for each of compile and image preparation, so concurrent callers serialize on the Once and only the first executes Begin/work/End. Fixes commit02ffe5baa8. Updates #13038 Change-Id: If710bcc9e0aafebf0ad5b61553bae11458d976d7 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Add a vmtest that brings up two gokrazy nodes A and B behind two One2OneNAT networks (so direct UDP works in both directions and any slowness can't be blamed on NAT traversal), establishes a WireGuard tunnel A → B with TSMP, then rotates B's disco key four times and asserts that the data plane recovers in both directions after each rotation. All pings are TSMP (the data-plane ping; disco pings would not exercise the WireGuard tunnel itself). The five pings: 1. A → B (initial; brings up the tunnel; 30s budget) 2. B → A after rotate (LocalAPI rotate-disco-key debug action) 3. A → B after rotate (LocalAPI) 4. B → A after restart (SIGKILL; gokrazy supervisor respawns) 5. A → B after restart (SIGKILL) Each post-rotation ping gets a 15-second budget. Two unavoidable multi-second waits dominate today: - The rotate-then-a→b phase takes ~10s on main because of LazyWG. After B's WantRunning bounce, B's wgengine resets its sentActivityAt/recvActivityAt maps and trims A out of the wireguard-go config as an "idle peer"; B only re-adds A on inbound activity, by which point A's first few TSMP packets have been silently dropped at B's tundev. The bradfitz/rm_lazy_wg branch removes that trimming entirely (verified locally: this phase drops to <100ms there). - The restart phases take ~5s for wireguard-go's RekeyTimeout handshake retry. After SIGKILL+respawn the first WG handshake init from the restarted node sometimes goes into the void (likely the brief peer-removed window in the receiver's two-step maybeReconfigWireguardLocked reconfig during which the peer is absent from wireguard-go), and wg-go's 5s+jitter retransmit timer is the next opportunity to retry. That retry succeeds and the staged TSMP packet flushes. Intrinsic to the protocol's retransmit policy. Once LazyWG is removed and the first-handshake-after-reconfig race is fixed, the budget should drop to 5s. Supporting changes: ipn/ipnlocal: DebugRotateDiscoKey now toggles WantRunning off and back on after rotating the disco key. magicsock.Conn.RotateDiscoKey only resets local disco state; without also dropping wireguard-go session keys, peers keep encrypting with their stale per-peer session against us until their rekey timer fires (WireGuard has no data-plane signaling to invalidate sessions). Bouncing WantRunning runs the engine through Reconfig(empty) → authReconfig, which drops every peer's WG session so the next packet either way triggers a fresh handshake. ipn/ipnlocal, ipn/localapi: add a debug-only "peer-disco-keys" LocalAPI action ([LocalBackend.DebugPeerDiscoKeys]) that returns a map[NodePublic]DiscoPublic from the current netmap. Tests reach it via [local.Client.DebugResultJSON]. We do not surface disco keys via [ipnstate.PeerStatus] because adding a non-comparable [key.DiscoPublic] field there breaks reflect-based test helpers (e.g. TestFilterFormatAndSortExitNodes' use of cmp.Diff), and general LocalAPI clients have no need for disco keys. Since the debug LocalAPI is gated behind the ts_omit_debug build tag, this endpoint is automatically stripped from small binaries. cmd/tta: add /restart-tailscaled handler (Linux-only, via /proc walk) to drive the SIGKILL phase. On gokrazy the supervisor respawns tailscaled within a second. tstest/integration/testcontrol: add Server.AllOnline. When set, every peer entry in MapResponses is marked Online=true. Several disco-key handling fast paths in controlclient and wgengine (removeUnwantedDiscoUpdates, removeUnwantedDiscoUpdatesFromFull NetmapUpdate, the wgengine tsmpLearnedDisco fast path) only fire for online peers; without this flag, tests exercising disco-key rotation only hit the offline-peer code paths, which mask issues and are several seconds slower in this scenario. Finer-grained per-node online tracking can be added later. tstest/natlab/vmtest: add Env.RotateDiscoKey, Env.RestartTailscaled, Env.PeerDiscoKey, Node.Name, an [AllOnline] EnvOption that plumbs through to testcontrol.Server.AllOnline, and an exported Env.Ping(from, to, type, timeout). Ping replaces the unexported helper so callers can specify both a ping type (PingDisco for warming peer state, PingTSMP for asserting end-to-end connectivity) and a deadline. PeerDiscoKey returns its LocalAPI error so callers inside tstest.WaitFor can retry transient failures rather than fataling the test. Updates #12639 Updates #13038 Change-Id: I3644f27fc30e52990ba25a3983498cc582ddb958 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>If a user passes --advertise-tags=foo,bar (with no colons in any segment), automatically prepend "tag:" client-side so it goes on the wire as "tag:foo,tag:bar". Segments that already contain a colon are left untouched and must be fully-qualified ("tag:foo"), which keeps the door open for future colon-bearing syntax. This was originally added incd07437ad(2020-10-28) and then reverted in 1be01ddc6 (2020-11-10) over forward-compatibility concerns. But then it was realized in 2026-04-29 that this was always safe for future extensiblity anyway (tags can't contain colons-- tag:foo:bar is invalid anyway, per the 2020 CheckTag restrictions). So if we wanted to perhaps some hypothetical --advertise-tags=tagset:setfoo or "group:foo", we'd still have syntax to do, as it can't conflict with tag:group:foo. Avery signed off on this on Slack: "Ok, I withdraw my objection to auto-qualifying tag names in advertise-tags and I hope I won't regret it :)" Updates #861 Change-Id: I06935b0d3ae909894c95c9c2e185b7d6a219ff32 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Add two narrower accessors alongside the existing [LocalBackend.NetMap], with docs that distinguish their semantics: - NetMapNoPeers: cheap (returns the cached *netmap.NetworkMap with a possibly-stale Peers slice). For callers that only read non-Peers fields like SelfNode, DNS, PacketFilter, capabilities. - NetMapWithPeers: documented as returning an up-to-date Peers slice. For callers that genuinely need to iterate Peers or call PeerByXxx. Mark the existing NetMap deprecated and point readers at the two new accessors. NetMap, NetMapNoPeers, and NetMapWithPeers all currently return the same value (b.currentNode().NetMap()): this commit is a no-op behaviorally, just a renaming and migration of in-tree callers. A subsequent change in the same series will switch NetMapWithPeers to actually rebuild the Peers slice from the live per-node-backend peers map (O(N) per call), at which point the distinction between the two new accessors becomes load-bearing. Migrate in-tree callers to the appropriate accessor based on what fields they read: - NetMapNoPeers (most common): localapi handlers, peerapi accept, GetCertPEMWithValidity, web client noise request, doctor DNS resolver check, tsnet CertDomains/TailscaleIPs, ssh/tailssh SSH-policy/cap reads, several LocalBackend internals (isLocalIP, allowExitNodeDNSProxyToServeName, pauseForNetwork nil-check, serve config). - NetMapWithPeers: writeNetmapToDiskLocked (persist full netmap to disk for fast restart), PeerByTailscaleIP lookup. Tests still call the legacy NetMap; they'll see the deprecation warning but otherwise behave identically. Also add two pieces of plumbing the next change in this series will need, but which are already useful on their own: - [client/local.GetDebugResultJSON]: a generic [Client.DebugResultJSON] that decodes directly into a target type T, avoiding the marshal/unmarshal roundtrip callers otherwise need. - localapi "current-netmap" debug action: returns the current netmap (with peers) as JSON. Documented as debug-only — the netmap.NetworkMap shape is internal and may change without notice. This commit is part of a series breaking up a larger change for review; on its own it is a no-op refactor. Updates #12542 Change-Id: Idbb30707414f8da3149c44ca0273262708375b02 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Add two narrow LocalAPI accessors so callers don't have to subscribe to the IPN bus and pull a full *netmap.NetworkMap just to read DNS-shaped fields: - GET /localapi/v0/cert-domains returns DNS.CertDomains. - GET /localapi/v0/dns-config returns the full tailcfg.DNSConfig. Migrate in-tree callers off the netmap-on-the-bus pattern: - kube/certs.waitForCertDomain still wakes on the IPN bus but now queries CertDomains via LocalClient.CertDomains rather than reading n.NetMap.DNS.CertDomains. The kube LocalClient interface and FakeLocalClient gain a CertDomains method. - cmd/tailscale dns status calls LocalClient.DNSConfig directly instead of opening a NotifyInitialNetMap watcher. - cmd/tailscale configure kubeconfig switches from a netmap watcher + serviceDNSRecordFromNetMap to LocalClient.DNSConfig + serviceDNSRecordFromDNSConfig. This is part of a series moving callers away from depending on the netmap traveling on the IPN bus, so the bus payload can shrink in a later change. Updates #12542 Change-Id: Ie10204e141d085fbac183b4cfe497226b670ad6c Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>The purpose of this package is to test the iOS dependency closure, but it had drifted from the actual import list of the ipn-go-bridge package in the corp repo (the Go side of the iOS / macOS app). Update the imports to match ipn-go-bridge's GOOS=ios import list, adding many missing packages including wgengine/netstack, feature/{taildrop,syspolicy,condregister}, the util/syspolicy/* subpackages, types/{key,lazy,logid,netmap}, tsd, safesocket, util/{eventbus,must,set}, and several net/* and ipn/* packages. Drop two now-stale BadDeps entries (for now!): database/sql/driver and github.com/google/uuid are reached via wgengine/netstack -> github.com/prometheus-community/pro-bing, which netstack imports on darwin || ios for ICMP user-ping, so the iOS app already ships them. But we should fix that later. Updates #19633 Change-Id: Ic50779fdb195685a2e8ccd7c513eee91b0feeaf8 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>--report postureflag inupeac531da8eThe tailscale.com/wif package brings in the AWS SDK (github.com/aws/aws-sdk-go-v2/{config,sts,...} and github.com/aws/smithy-go) to support fetching ID tokens from AWS IMDS for workload identity federation. Until now, tsnet pulled this in unconditionally via feature/condregister/identityfederation, costing ~70 unwanted deps for every tsnet program whether or not it uses workload identity federation. These AWS SDK deps were originally removed from tsnet on 2025-09-29 by commit69c79cb9f("ipn/store, feature/condregister: move AWS + Kube store registration to condregister"). They were then accidentally added back on 2026-01-14 by commit6a6aa805d("cmd,feature: add identity token auto generation for workload identity", PR #18373) when the new wif package was wired into tsnet via feature/identityfederation. Drop the blanket import. tsnet programs that want workload identity federation now opt in with: import _ "tailscale.com/feature/identityfederation" The hook lookup in resolveAuthKey already uses GetOk and degrades gracefully when the feature isn't linked, so existing programs that don't use workload identity federation see no behavior change. The tailscale CLI still imports the condregister wrapper directly, so its behavior is also unchanged. Lock this in with TestDeps additions: tailscale.com/wif as a BadDep, plus substring checks in OnDep that fail on any github.com/aws/ or k8s.io/ dependency creeping back in. Also, switch cmd/gitops-pusher from the condregister wrapper to a direct import of feature/identityfederation: gitops-pusher's auth flow calls HookExchangeJWTForTokenViaWIF directly, so it shouldn't be subject to the ts_omit_identityfederation build tag. Updates #12614 Change-Id: I70599f2bdd4d3666b26a859d5b76caa5d6b94507 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>The (*SubscriberFunc[T]).dispatch method body — a ~40-line select loop with slow-subscriber timer, snapshot handling, ctx-cancel draining, and a CI stack-dump branch — was previously fully duplicated by the Go compiler for every distinct GC shape of T. None of that body actually depends on T except for the type assertion and the user callback invocation. This change moves the loop body into a non-generic dispatchFunc() helper, leaving (*SubscriberFunc[T]).dispatch as a tiny wrapper that: - performs the vals.Peek().Event.(T) type assertion - spawns the callback goroutine via `go runFuncCallback(s.read, t, callDone)` — a regular generic function call, not a closure, so that `go` binds the args to the goroutine's frame instead of allocating a closure on the heap. This preserves the zero-extra-allocation behavior of the original (*SubscriberFunc[T]).runCallback method. - resolves T's name via reflect.TypeFor[T]().String() (cached on the stack rather than recomputed on each %T formatting) - calls dispatchFunc with the callDone channel The %T formatting in the original logf calls is replaced with %s on the resolved name string, removing per-T fmt instantiations. A new BenchmarkBasicFuncThroughput is added alongside the existing BenchmarkBasicThroughput so per-event allocation behavior on the SubscribeFunc dispatch path is covered by the benchmark suite. Measured impact (util/eventbus/sizetest): SubscriberFunc per-flow attribution: linux/amd64: 912.5 B/flow -> 840.8 B/flow (-71.7 B/flow) linux/arm64: 917.5 B/flow -> 849.9 B/flow (-67.6 B/flow) The total per-flow size delta on amd64 dropped from 3,096.6 B to 3,039.2 B (-57 B/flow). The arm64 total stayed at 3,145.7 B because the linker's page-aligned section sizing absorbed the improvement on this binary; the symcost-attributed per-receiver number is the real signal. Behavior is unchanged: BenchmarkBasicThroughput stays at 0 allocs/op and BenchmarkBasicFuncThroughput holds at the same 2 allocs/op, 144 B/op as the prior eventbus implementation. All eventbus tests pass. Updates #12614 Change-Id: I85f933f50f58cd25bbfe5cc46bdda7aab22f0bf7 Signed-off-by: James Tucker <james@tailscale.com>Running all vmtests in tstest/natlab/vmtest locally was breaking later tasks in the queue. The goroutine dump on timeout had goroutines hanging around for 9 minutes, meaning that something was not getting cleaned up. goroutine 262 [select, 9 minutes]: gvisor.dev/gvisor/pkg/tcpip/adapters/gonet.commonRead({...}) Add a timeout of Now() to gonet TCP connections when the test ends (inspired by ServeUnixConn()), and wait for them to shut down before exiting the test. Updates #13038 Signed-off-by: Claus Lensbøl <claus@tailscale.com>Splits SubscriberFunc[T] into: - SubscriberFunc[T]: a thin user-facing facade that holds only a pointer to a non-generic core. It exposes Close() to user code, which forwards to the core. - subscriberFuncCore: a non-generic struct that owns all the subscriber state (stop flag, unregister, logf, slow timer, cached reflect.Type) and implements the bus's package-private subscriber interface. Its dispatch() invokes a closure captured at construction time that performs the vals.Peek().Event.(T) type assertion and runs the user callback on the unboxed value. The bus's outputs map and subscriber-interface itab are parameterized only by *subscriberFuncCore, not by T, eliminating both the per-T itab and the per-T generic dictionary that previously scaled with the number of subscribed event types. Measured impact (util/eventbus/sizetest): total per-flow binary cost: linux/amd64: 3039.2 B/flow -> 2252.8 B/flow (-786.4 B / -25.9%) linux/arm64: 3145.7 B/flow -> 2228.2 B/flow (-917.5 B / -29.2%) SubscriberFunc per-receiver attribution: linux/amd64: 840.8 B/flow -> 300.8 B/flow (-540.0 B / -64.2%) linux/arm64: 849.9 B/flow -> 303.8 B/flow (-546.1 B / -64.3%) Dropped per-T symbols (200-flow eventbus binary): - (*SubscriberFunc[T]).dispatch was 26,639 B total (130 B/T) - (*SubscriberFunc[T]).subscribeType was 3,600 B total ( 18 B/T) - .dict.SubscriberFunc[T] was 14,400 B total ( 72 B/T) - go:itab.*SubscriberFunc[T],... was 9,600 B total ( 48 B/T) Of the original 913 B/flow attributed to SubscriberFunc, 540 B/flow is now gone, dropping the receiver to 300 B/flow. Behavior is unchanged: BenchmarkBasicThroughput is within noise (1955 -> 1941 ns/op on the test box) and all eventbus tests pass. Updates #12614 Change-Id: I646b3b05fd8d95f9afead59bfd0f69cd18b7a709 Signed-off-by: James Tucker <james@tailscale.com>Mirrors the same refactor previously applied to SubscriberFunc: - Publisher[T]: a thin user-facing facade. Holds a pointer to a non-generic publisherCore and exposes Publish/Close/ShouldPublish. - publisherCore: a non-generic struct that owns the *Client back- pointer, stop flag, and cached reflect.Type. It implements the package-private publisher interface (publishType, Close). The bus's per-Client publisher set is set.Set[publisher] keyed on this single non-generic type. The publisher interface only exists to support diagnostic introspection (Debugger.PublishTypes returning the list of types a client publishes). Previously, satisfying that diagnostic-only interface forced *Publisher[T] to be the implementor and cost a per-T itab, generic dictionary, and equality function on every event type ever passed through Publish[T]. Moving the implementation to a non-generic core lets the diagnostic surface work unchanged while charging zero per-T cost for the diagnostic-driven generic interface. Publisher[T].Publish is also slimmed: the channel/select/stopFlag loop is now a non-generic publish() helper that takes the value as 'any'. The per-T body is reduced to forwarding the boxed value to the helper. Measured impact (util/eventbus/sizetest): total per-flow binary cost: linux/amd64: 2252.8 B/flow -> 1900.5 B/flow (-352.3 B / -15.6%) linux/arm64: 2228.2 B/flow -> 1835.0 B/flow (-393.2 B / -17.6%) Publisher per-receiver attribution: linux/amd64: 635.2 B/flow -> 369.6 B/flow (-265.6 B / -41.8%) linux/arm64: 751.7 B/flow -> 373.2 B/flow (-378.5 B / -50.4%) Cumulative reduction from the original baseline (5167ff412): linux/amd64: 3096.6 B/flow -> 1900.5 B/flow (-1196.1 B / -38.6%) linux/arm64: 3145.7 B/flow -> 1835.0 B/flow (-1310.7 B / -41.7%) Dropped per-T symbols (200-flow eventbus binary): - .dict.Publisher[T] was 14,400 B (72 B/T) - type:.eq.Publisher[T] was 11,832 B (58 B/T) - go:itab.*Publisher[T],publisher was 8,000 B (40 B/T) - (*Publisher[T]).Close shape stencils collapsed to 1 Behavior is unchanged: BenchmarkBasicThroughput is within noise (2018 -> 2038 ns/op at -benchtime=2s) and all eventbus tests pass. Updates #12614 Change-Id: I61979c2bf95d2a711c2321e6e0b4b7d15980e9f5 Signed-off-by: James Tucker <james@tailscale.com>The natlab vmtest suite (tstest/natlab/vmtest) and the integration nat tests are gated behind --run-vm-tests because they need KVM and are slow. Until now nothing in CI exercised them apart from a single canary TestEasyEasy run on every PR. Add .github/workflows/natlab-test.yml that runs the full opt-in suite on demand (workflow_dispatch), on PRs labeled "natlab", and on main every 12 hours via cron. The workflow has two phases: - "prepare" builds the gokrazy VM image, downloads the Ubuntu and FreeBSD cloud images once via the new natlabprep tool, and emits a dynamic JSON matrix of every TestX function it finds in the two opt-in packages. - "test" is a per-test matrix that depends on prepare. Each matrix job restores the shared caches and runs a single test, so adding a new TestFoo is automatically picked up on the next run without any workflow edits. Rename the existing natlab-integrationtest.yml to natlab-basic.yml since it's the small smoke variant (just TestEasyEasy on every PR); the new natlab-test.yml is the bigger suite. The job inside is renamed to EasyEasy for the same reason. Move the macOS arm64 host check from vmtest.Env.Start into vmtest.Env.AddNode so a test that adds a vmtest.MacOS node skips immediately on a non-macOS host, and add an explicit skipIfNotMacOSArm64 helper at the top of the two macOS-only tests so the platform requirement is obvious to readers. Quiet the takeAgentConnOne miss log in tstest/natlab/vnet by default (it was the overwhelming majority of bytes in CI logs, with no signal in healthy runs) and replace it with a periodic "still waiting" line that only fires after 10s, so a truly stuck agent connection still surfaces. Updates #13038 Change-Id: I4582098d8865200fd5a73a9b696942319ccf3bf0 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Two changes that share the same intent of reducing per-T duplication in code that doesn't actually depend on T: 1. Hoist the non-generic portion of newSubscriberFunc[T] into a newSubscriberFuncCore() helper. The hoisted work is the time timer setup, the subscriberFuncCore allocation, and the unregister closure (which captures only the non-generic reflect.Type and *subscribeState). The generic body now does only the two T-bound things it has to: compute reflect.TypeFor[T] and create the dispatch closure. Effect on the per-shape-stencil body of newSubscriberFunc[T]: before: 523 B per shape (in synthetic test) after: 293 B per shape (-230 B per shape; -56% on this body) 2. Cache reflect.Type.String() once at construction (in core.typeName) instead of recomputing it every time the dispatch closure runs. The dispatch closure also now takes the *subscriberFuncCore directly rather than building an intermediate dispatchFuncState struct on every call. Effect on the dispatch closure body (newSubscriberFunc[T].func1): before: 581 B per shape after: 480 B per shape (-101 B per shape; -17%) Combined effect on tailscaled (linux/amd64): named-symbol savings via symcost: ~7 KB stripped binary delta: -8 KB (page-quantized) arm64 binary delta: 0 (page-quantized) cumulative reduction from baseline (5167ff412): linux/amd64: -110,592 bytes (-0.391%) linux/arm64: -131,072 bytes (-0.499%) Throughput is also improved by the typeName cache: BenchmarkBasic goes from 2018 ns/op to 1864 ns/op (-7.6%) because the dispatch hot path no longer allocates a string on every event. Updates #12614 Change-Id: Ib3a3d6796785e16506330ec034e1144580d467a3 Signed-off-by: James Tucker <james@tailscale.com>Replace the process-global Server.mu lookup in the packet send hot path with a global hashtriemap mirror of local clientSet entries. The authoritative clients map remains guarded by Server.mu; clientsAtomic is only a lock-free fast path for active local clients. Misses, stale inactive client sets, duplicate accounting, and mesh forwarding still fall back to lookupDestUncached. This avoids taking Server.mu for the common local active-client send path, at the cost of adding one global concurrent map that mirrors Server.clients for local peers. The benchmark uses four destination peers. The before run sets TS_DEBUG_DERP_DISABLE_PEER_HASHTRIE=true to force the old mutex lookup path; the after run uses the hashtrie fast path. goos: linux goarch: amd64 pkg: tailscale.com/derp/derpserver cpu: Intel(R) Xeon(R) 6975P-C │ before │ after │ │ sec/op │ sec/op vs base │ LookupDestHashTrie-16 176.050n ± 1% 1.904n ± 6% -98.92% (p=0.000 n=10) │ before │ after │ │ B/op │ B/op vs base │ LookupDestHashTrie-16 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ ¹ all samples are equal │ before │ after │ │ allocs/op │ allocs/op vs base │ LookupDestHashTrie-16 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ ¹ all samples are equal Updates #3560 (very indirectly, historically) Updates #19713 (as an alternative to that PR) Change-Id: Ifb72e5c9854ad00e938cd24c6ab9c27312f297e8 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Brings Subscriber[T] in line with the same non-generic-core pattern already applied to SubscriberFunc[T] and Publisher[T]: - Renames subscriberFuncCore to subscriberCore and shares it between Subscriber[T] and SubscriberFunc[T]. Both typed facades hold a *subscriberCore plus their respective per-T delivery state (Subscriber: chan T; SubscriberFunc: nothing, the user callback is captured in the dispatch closure). - The bus's outputs map and subscriber-interface itab key on *subscriberCore for both subscriber kinds, so adding a new Subscribe[T] call site no longer pays a per-T itab, dictionary, or equality function for the subscriber-interface side. - Subscribe[T] now hoists the non-generic constructor portion into newSubscriberCore (timer setup, core allocation, cached type/typeName, unregister method-value), matching SubscribeFunc. The dispatch loop is intentionally NOT extracted to a non-generic helper for Subscriber[T], unlike SubscriberFunc[T]. The reason is the typed channel send 'case s.read <- t:' must appear lexically inside the select; the only way to lift it into a non-generic loop is to bridge typed and untyped via a per-event goroutine, which costs ~2.7x throughput on BenchmarkBasicThroughput. We keep dispatchTyped on the generic facade and accept the per-shape stencil cost as the cheaper alternative. Symbol-level effect on tailscaled (linux/amd64, measured via `go tool nm -size`): Before: (*Subscriber[T]).dispatch 2 shape stencils: 1,682 + 1,549 = 3,231 B 3 thin per-T wrappers: 124 B each = 372 B 2 deferwrap1 helpers: 62 B each = 124 B total: 3,727 B After: (*Subscriber[T]).dispatchTyped 2 shape stencils: 1,678 + 1,582 = 3,260 B 0 per-T wrappers (replaced by closure stored on core) 2 deferwrap1 helpers: 62 B each = 124 B total: 3,384 B dispatch path .text delta: -343 B (-9.2%) Per-shape stencils are ~1,600 B (.text body) + ~1,100 B (pclntab) = ~2,700 B each on production tailscaled. The shape count matches before/after (two distinct GC shapes for the Subscriber[T] event types in this binary). What changes is that the per-T thin wrappers are eliminated because Subscriber[T] no longer implements the subscriber interface directly. Whole-binary section deltas: .text: -2,304 B (includes the dispatch savings plus other small downstream effects) .rodata: +512 B (additional closure-type metadata) .gopclntab: -2,981 B (fewer per-T compiled functions => less metadata) Stripped tailscaled (linux/amd64): no change at the file level (the savings fall below the linker's section-alignment boundary). Unstripped builds shrink by ~2,900 B. Behavior is unchanged: BenchmarkBasicThroughput: 2,161 ns/op, 0 B/op, 0 allocs/op BenchmarkBasicFuncThroughput: 2,493 ns/op, 144 B/op, 2 allocs/op BenchmarkSubsThroughput: 3,727 ns/op, 0 B/op, 0 allocs/op Updates #12614 Change-Id: I97918ec68bd2cdb15958bbfd7687592b39663efe Signed-off-by: James Tucker <james@tailscale.com>A data race in a package matters more than any individual test result. Two related problems: 1. Where go test's race detector text ("WARNING: DATA RACE" plus the goroutine stack traces) lands in JSON output is timing- dependent: it can be attributed to a test that ends up reporting PASS (e.g. when the racing goroutines outlive the test that spawned them and TSan prints during a different test's window). testwrapper's main loop only flushes the logs of failed tests, so the race report ends up stuck in a passing test's buffer and is silently dropped. The race builders just see a bare "FAIL\nFAIL\tpkg\ttime". 2. If the failing test in such a package happens to be marked flaky, testwrapper retries it. That is the worst possible response to a race: the flaky test might not even be the racy code, and a second run without the racy goroutines could "succeed" while hiding the real bug. Address both: scan every output line for the race detector's first- line marker. Track whether the package observed a race at all, on the pkgFinished testAttempt. When a race was seen, fold every per- test log buffer into the package-level logs (so the full report surfaces from the existing pkg-fail flush path), and drop any flaky-test retry plans for that package so we fail immediately instead of running another attempt. Two new tests: - TestRaceSuppressesFlakyRetry verifies that a flaky test alongside a racy test does NOT get retried. - TestRaceAttributedToPassingTest verifies that a race attributed by test2json to a passing test still surfaces in the output. Also add a corpus of captured raw test binary outputs under cmd/testwrapper/testdata/, with one subdirectory per scenario, documenting the six representative shapes that go test -race can emit (race in test body, race in goroutines that outlive a test, race forced into a later test, race in TestMain post-m.Run, and a parallel-tests split-attribution case via a "=== NAME" redirect line). See its README.md for details. Fixes #19603 Change-Id: Ifbfcd67fb3b1882c4907bd9cb2d68a8b5a91dd54 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Add Go tests that drive a real headless Chromium (via chromedp) against the built cmd/tsconnect/pkg/ artifact and verify the @tailscale/connect public API surface end-to-end. The package has not been republished in three years, in part because no test exercises the produced artifact at runtime — only tsc --noEmit and a Go build run in CI. TestCreateIPN loads pkg.js into the browser, calls createIPN with a junk auth key, and asserts that pkg.createIPN / pkg.runSSHSession are functions and that createIPN() returns an IPN with the documented run/login/logout/ssh/fetch methods. No control-plane traffic. TestFetchTailnetPeer stands up a full local tailnet (testcontrol + DERP + a tsnet.Server peer) and verifies that the browser-side WASM client can join over WebSocket-noise to the same control, connect to DERP over WSS, and then ipn.fetch() an HTTP service hosted on the tsnet peer through the tailnet. The test asserts the response body matches a known string. Browser state transitions are logged: NoState -> NeedsLogin -> Starting -> Running. Tests are opt-in via --run-headless-browser-tests (matching the existing --run-vm-tests pattern in tstest/natlab/vmtest) so they never fire in casual `go test ./...` runs. When the flag is set, a test is skipped if cmd/tsconnect/pkg/ has not been built, and fails with t.Error if no chromium binary is found on $PATH (honoring $CHROME_BIN as an override). findChromium also falls back to /Applications/Google Chrome.app and /Applications/Chromium.app on darwin, since macOS Chrome's executable lives inside an .app bundle and is not on $PATH by default. The .github/workflows/test.yml wasm job is extended to install google-chrome-stable and run the tests with the flag after build-pkg. To prevent silently testing a stale pkg/main.wasm (built from an older checkout than the rest of the test invocation), build-pkg now writes pkg/build-info.json recording the sha256 of the raw (pre-wasm-opt) go-build output. The test does its own `go build` of cmd/tsconnect/wasm with the same -tags/-trimpath/-ldflags (factored into a new cmd/tsconnect/wasmbuild package shared by both call sites) and t.Fatalfs with a "rebuild" instruction on mismatch. Cost is near-zero because the Go build cache from the prior build-pkg makes the rebuild a cache hit. The new wasmbuild package also replaces cmd/tsconnect's hardcoded -tags string with a minimal-feature-set computation. wasmbuild.Keep names the small set of feature/featuretags entries the browser client actually needs (netstack, logtail, dns, health, c2n, ipnbus); wasmbuild.Tags() emits a ts_omit_<f> for every other omittable feature in feature/featuretags.Features, with transitive deps expanded via featuretags.Requires. An init() panics if Keep references a feature unknown to feature/featuretags so a rename there fails loudly. Net effect on size: 32M raw / 9.4M brotli before this change, 25M raw / 4.4M brotli after — vs the last-published 1.39.98 at 21M / 3.8M. The transitive package-import graph is unchanged (176 tailscale.com/* packages either way): featuretags omits eliminate dead code via `const HasX = false`, not imports. Trimming the import graph would require a separate, larger refactor splitting interface packages by build tag. Writing TestFetchTailnetPeer surfaced several real issues, all fixed here: * cmd/tsconnect built the wasm with the nethttpomithttp2 tag, but control/ts2021 (since commit1d93bdce2, "control/controlclient: remove x/net/http2, use net/http", Oct 2025) requires HTTP/2 from net/http's bundled implementation. With nethttpomithttp2 set, the bundle is excluded and the wasm client cannot speak HTTP/2 to any control plane, including production. Drop the tag. Wasm size grows ~1 MB raw / ~300 KB brotli (more than offset by the feature pruning above). The last published @tailscale/connect (1.39.98, early 2023) pre-dates the regression, which is why no consumer has reported the breakage. * tstest/integration/testcontrol.Server's /ts2021 noise upgrade endpoint rejected anything but POST. WebSocket clients (the only transport available to browser-WASM) come in as GET. Allow both; the controlhttp AcceptHTTP path dispatches on the Upgrade header, so the websocket library still enforces GET for WS upgrades. This matches production, where the same controlhttpserver.AcceptHTTP routes purely on the Upgrade header without checking method. * derp/derphttp's urlString built the DERP URL from node.HostName only, dropping node.DERPPort. Non-WS clients use a separate code path (connectToHost) that honors DERPPort, but WebSocket-only clients (browser-WASM) went through urlString and so could not reach a DERP running on any port other than 443. Include the port when it differs from the scheme default. Also move addWebSocketSupport from cmd/derper (where it was main-only) to derp/derpserver.AddWebSocketSupport so tstest/integration.RunDERPAndSTUN can wrap its DERP handler with WebSocket support — without that, the test DERP would not accept the browser's wss connection. Fixes #9394 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: Iff9cdee303e3b239924249b5bffb2fd04e02f391For large tailnets (~50k+ nodes) with frequent peer churn (ephemeral GitHub Actions workers etc.), tailscaled used to rebuild the full netmap and fan it out on the IPN bus on every MapResponse that added or removed a peer. There were two O(N) costs per delta: the full netmap rebuild + every Notify.NetMap encode to every bus watcher. This change tackles both: 1. Plumb O(1) peer add/remove through the delta path. PeersChanged and PeersRemoved no longer prevent the delta happy path; instead, they mutate the per-node-backend peer map in place. 2. Restrict ipn.Notify.NetMap emission to the platforms whose host GUIs still depend on it (Windows, macOS, iOS) and migrate in-tree consumers off it everywhere else: - Migrate reactive consumers (containerboot, kube agents, sniproxy, tsconsensus, etc.) off Notify.NetMap to the previously-added Notify.SelfChange signal so they no longer have to subscribe to the full netmap. - Add ipn.NotifyNoNetMap so GUI clients on "legacy-emit" platforms that have already migrated can opt out of the per-watcher NetMap encode. - Gate Notify.NetMap emission on the producer side by a compile- time GOOS check, so the supporting code is dead-code-eliminated on Linux and other geese where no GUI consumer needs it. Re-running BenchmarkGiantTailnet from tstest/largetailnet, which was added along with baseline numbers on unmodified main inad5436af0d, the per-delta cost (one peer add+remove pair) is now ~O(1) regardless of tailnet size N: N no-watcher (ms/op) bus-watcher (ms/op) before now factor before now factor 10000 32 0.11 300x 166 0.13 1300x 50000 222 0.11 2000x 865 0.13 6700x 100000 504 0.12 4100x 1765 0.13 13400x 250000 1551 0.12 12500x 4696 0.15 32400x Updates #12542 Change-Id: I94e34b37331d1a8ec74c299deffadf4d061fda9e Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Add four control-plane node attributes that let us disable UDP GSO/GRO on the magicsock UDP socket and UDP/TCP GRO on the Tailscale TUN device. These complement the pre-existing TS_DEBUG_DISABLE_UDP_{GRO,GSO} and TS_TUN_DISABLE_{UDP,TCP}_GRO envknobs. They exist so we can mitigate upstream Linux kernel regressions on a deployed fleet without requiring a client release, after two incidents (#13041, #19777) where buggy kernel patches landed upstream and the fix took an excessively long time to reach downstream distros. Knob changes are reacted to in setNetworkMapInternal / SetNetworkMap via a comparison against a cached "last applied" value and only an actual transition triggers work: magicsock Rebind()+ReSTUN for UDP, ApplyGROKnobs for TUN. The TUN side is gated by buildfeatures.HasGRO and is one-way (wireguard-go GRO disablement is sticky); re-enabling requires a client restart. Updates #13041 Updates #19777 Change-Id: I802993070afa659cc06809bb0bfbb7f8a0cdb273 Signed-off-by: James Tucker <james@tailscale.com>Several packages built their HTTP transports with http.DefaultTransport.(*http.Transport).Clone() The standard library only documents http.DefaultTransport as an http.RoundTripper, so an application is free to replace it with a RoundTripper that is not a *http.Transport (e.g. an instrumented or tracing wrapper). When such an application embeds tsnet.Server, the unchecked type assertion panics as soon as tsnet brings up its control connection, DNS bootstrap, or log uploader. Add netutil.NewDefaultTransport, which returns a clone of the global when it is still the standard *http.Transport (preserving existing behavior) and otherwise returns a fresh transport mirroring the stdlib defaults. Route every clone site through it. Updates #19937 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Achille Roussel <achille.roussel@gmail.com>tailscale routecheck(#19956) 92bfda580ctailscale routecheck(#19957) b47dd932f3-run='^$'quoting when skipping all tests (#19962) c898aeb0d8vnet only ever sent IPv6 RAs in response to a Router Solicitation. In practice this meant gokrazy VMs running with a dual-stack LAN never installed vnet's IPv6 default route: gokrazy brings the link up via DHCPv4 and the kernel never emits an RS on its own under that init path. Off-link IPv6 destinations like the fake DERP servers were therefore unreachable from any gokrazy test node that also had v4 on the same interface. (Pure-v6 nodes happened to work because the kernel sends an RS as part of v6-only autoconf.) Fix this in two complementary ways: - Send an unsolicited RA every 5s to the link-local all-nodes group on every v6-enabled network. This matches what real routers do (RFC 4861 §6.2.1, MaxRtrAdvInterval; we use a much shorter interval than the spec's 200s default so short-lived tests don't have to wait). - Send a unicast RA to a newly-registered MAC as soon as a client first transmits on the wire. Without this the first periodic RA can land before any VM has connected and the next one isn't until the next tick, which can be longer than the test runs. Factor the RA serialization out into buildIPv6RouterAdvertisement so the solicited, periodic, and per-client paths all share one body. Update TestSelfSignedDERPHashPinning to use a dual-stack hard-NAT builder and assert zero errors from DebugDERPRegion (instead of filtering "over IPv6" errors as it had to before this change). The new builder also sets TS_DEBUG_STRIP_ENDPOINTS=1 on tailscaled so disco can't find a direct path: without endpoint stripping, the now- working non-NATted IPv6 LAN gives the two hard-NAT'd nodes a direct route, defeating the test's "must traverse DERP" assertion. (Hard NAT alone was enough before this change because v6 routing was broken.) Also update sendBetweenClients in the vnet unit tests to tolerate the new on-register RA noise on its read path. Updates #13038 Updates #19973 Change-Id: Ic281dc53702a25fa773c46313f453837814233e8 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Since we don’t think anyone has actually imported the jsonoutput package yet, we still have a chance to rename its fundamental types: 1. Rename the JSONSchemaVersion struct to SchemaVersion because it is a flag.Value that can represent any schema version. 2. Rename the JSONSchemaVersion.Value field to SchemaVersion.Version so the struct reads better: if args.json.IsSet && args.json.Version == 1 { // ... } Updates #17613 Signed-off-by: Simon Law <sfllaw@tailscale.com>Adds tailscaled_serve_{inbound,outbound}_bytes_total, labeled by Tailscale Service name, by wrapping the peer-facing conn in tcpHandlerForVIPService. Per-service counters persist for the process lifetime rather than being evicted on serve-config changes. Fixes #19572 Signed-off-by: Raj Singh <raj@tailscale.com> Co-authored-by: Ethan Smith <ethan.smith@grafana.com>This patch adds support for the fmt.Stringer interface to the ipn.NotifyWatchOpt enum. This is useful when debugging these bitmasks. For example: fmt.Printf("%s", ipn.NotifyPeerChanges | ipn.NotifyNoNetMap) // Output: (ipn.NotifyPeerChanges | ipn.NotifyNoNetMap) Fixes #20066 Signed-off-by: Simon Law <sfllaw@tailscale.com>Outbound packets produced by netstack (used by tailscaled with --tun userspace-networking, by tsnet, and by the SOCKS5/HTTP proxies) enter the wrapper via InjectOutbound{,PacketBuffer} and take the injectedRead path, which bypasses Filter.RunOut. RunOut's side effect for UDP/SCTP is to insert the reverse-flow tuple into the connection-tracking LRU so that Filter.RunIn admits inbound replies that no explicit ACL rule covers. Skipping it on the injected path meant a netstack-side dial of UDP would send fine but the reply would be dropped as "no matching rule". The kernel-TUN path was already fine because it goes through RunOut. Fixes #14229 Fixes #20064 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I816ef55c493a12ff4f561cd89c095559b5c2743bThe ProxyGroup HA Service reconciler's validateService scanned every Service in the cluster with shouldExpose=true for duplicate hostnames. With multi-tailnet (Tailnet CRD) support, that scan reaches across tailnet boundaries: * A Service exposed via the single-proxy path (tailscale.com/expose) on the primary tailnet would block a ProxyGroup ingress Service for the same hostname on a secondary tailnet, even though the two live in different reconcilers and different tailnet DNS namespaces. * Two ProxyGroups joined to different tailnets via spec.tailnet would also block one another for shared hostnames, again despite living in separate DNS namespaces. In both cases the ProxyGroup ingress Service was silently dropped (IngressSvcInvalid event raised, queue cleared, ConfigMap never written, ProxyGroup never serves the backend). This change tightens the check in two ways: * Skip Services that aren't themselves managed by the ProxyGroup reconciler (use isTailscaleService instead of shouldExpose). * For ProxyGroup-managed Services attached to a different ProxyGroup, look up that ProxyGroup and skip the duplicate report when spec.Tailnet differs from the current one. Fall through and flag the collision on lookup failure so genuine duplicates are not silently allowed. Adds regression tests covering both the single-proxy and the different-tailnet cases. Updates the existing TestValidateService expected error to reflect the rephrased message. Updates #20069 Signed-off-by: tsushanth <78000697+tsushanth@users.noreply.github.com>The ACME serialization mutex (acmeMu) was a package-level global, and several ACME-related fields lived on LocalBackend even though the cert code is conditional and not linked into every binary. With multiple tsnet.Servers in one process (each its own LocalBackend), a process-wide acmeMu also serialized unrelated backends. Introduce a new feature/acme extension that owns the per-LocalBackend ACME/cert state in an ipnlocal.CertState value: - acmeMu, renewMu, renewCertAt (previously package globals) - pendingACMETLSALPNCerts, pendingCertDomains{,Mu}, getCertForTest, certRefreshCancel (previously LocalBackend fields, only meaningful when ACME was compiled in) ipnlocal/cert.go now reaches the state through b.certState(), which is routed by a feature.Hook installed at init by feature/acme. The CertState type lives in ipnlocal so cert.go can access its fields directly without a method explosion; the extension in feature/acme constructs and owns it. This is a baby step. The end goal is for the entire cert/ACME code to live in feature/acme, with ipnlocal only retaining whatever thin hooks the rest of LocalBackend needs to call into it. The current split (CertState and most of cert.go in ipnlocal, extension wrapper in feature/acme) is a deliberately temporary middle ground that keeps this PR small while making the next moves mechanical. The package is named feature/acme to match the existing HasACME / ts_omit_acme naming. condregister/maybe_acme.go wires it in for non-js builds. Updates #12614 Updates #20248 Updates #20249 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I520909f24ad11a9622ef33c2290fe36ad44d6f71Currently, PeerAPI DNS is only allowed if 1. The peer is owned by the same user as this device, or 2. The node is an exit node or app connector a. and the peer has access to a hypothetical DNS server at 0.0.0.0:53 (which approximately means "the peer has access to autogroup:internet") None of this is useful for conn25. This adds the most basic of hooks (and converts the existing logic to a hook, which should improve clarity and lead to the possibility of moving the existing checks into feature packages in future). There is an extra filter based on the name being queried that is performed later. It refuses names in tailcfg.DNSConfig.ExitNodeFilteredSet. That filter is not modified by this change. With this change, if conn25 is configured as a connector, then all PeerAPI DNS queries are permitted (still subject to the ExitNodeFilteredSet as noted above). More work is required: the goal before release (i.e. the WIPCode check is removed) is that each query should be checked against the list of domains in the requested conn25 app. For now, this only verifies that conn25 is configured (and does not include the autogroup:internet check, which is not how conn25 grants will operate when implemented, soon). This change has been manually tested against the scenario outlined in tailscale/corp#40117; unfortunately the code's structure makes writing a unit test difficult. The more comprehensive changes needed for tailscale/corp#40076 should include an integration test that covers this case. The hook must go in the ipnlocal package rather than the usual extension host to prevent a circular dependency on the ipnlocal.PeerAPIHandler interface. Registering PeerAPI handlers uses a similar strategy, likely because of, at least in part, this same problem. Updates tailscale/corp#40076 Fixes tailscale/corp#40117 Change-Id: I367714170b509d7a421f62672e5824b3590c2b9c Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>Adds two Gokrazy-based vmtests covering the tailscaled web client at port 5252: * TestWebClientLocalAccess enables the web client on a single node and exercises the canonical owner session flow against the node's own Tailscale IP: an unauthenticated GET /api/auth that identifies the caller, a GET /api/auth/session/new that issues a TS-Web-Session cookie, and a final GET /api/auth that reports authorized=true with the cookie. * TestWebClientRemoteAccess runs the same session flow from a peer node on the same tailnet against a second target node's web client, exercising netstack interception of incoming :5252 traffic, cross-node WhoIs, and the same-user "owner" path. It then flips the test control server's AllNodesSameUser off, re-logs in the client under a fresh identity, and asserts that GET /api/auth/session/new returns 401 with body "not-owner" -- exercising the cross-user rejection in client/web/auth.go. To make the natlab test environment exercise the same code path as production (check mode, where the web client posts to /machine/webclient/init via Noise and waits on a control-issued auth URL), this also: * Allowlists the natlab fake control hostname "control.tailscale" in client/web/auth.go's controlSupportsCheckMode so the web client follows the check-mode branch rather than the no-check-mode shortcut that immediately marks new sessions authenticated. * Adds /machine/webclient/{init,wait} handlers to testcontrol. init returns a placeholder auth ID and URL; wait returns Complete=true immediately, so the web client's awaitUserAuth resolves on its first call. Together these let the tests drive the full check-mode session lifecycle without a real browser-click loop. To support the multi-request HTTP flows from the test harness, this also adds: * vmtest.Env.HTTPGetStatus, a sister of HTTPGet that returns the upstream status code, body, and Set-Cookie cookies (as a vmtest.HTTPResponse) and accepts cookies on the outgoing request, so tests can drive flows that depend on cookie continuity. * Cookie pass-through in cmd/tta's /http-get handler: it forwards the Cookie request header upstream and surfaces upstream Set-Cookie response headers downstream. This is what lets HTTPGetStatus carry a session cookie across requests. Previously the only tests of the web client were in-process httptest-based handler tests in client/web/web_test.go; nothing exercised the actual port 5252 listener wiring, the cross-node auth path, cookie-driven session state transitions through the check-mode control round-trip, or the not-owner rejection end to end. Updates #13038 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: Idb01486a89b53ac02c6ad3358bcfcceca90dbc36Add three new helpers to the existing progresstracking package: - Ticker: spawns a 1 Hz goroutine that calls a report function with the current value of an atomic counter and a total. Returns a stop function (safe to call multiple times via sync.OnceFunc) that fires one final report and blocks until the goroutine exits. - NewWriter: wraps an io.Writer and calls onProgress at most once per interval with the cumulative byte count. - CountingWriter: an io.Writer that atomically counts bytes written, for use with Ticker. These will be used by the appliance flash and OTA update code in subsequent commits. Updates #1866 Change-Id: If353cea6506f5351b6fb19bfdb7bc9b78fe7855e Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Update ts-gokrazy to b83088f which includes: - Skip hardware watchdog when nowatchdog is on kernel cmdline - gokrazy.log_to_serial=1 tees service logs to /dev/console - Fix /etc/resolv.conf symlink (point at /tmp/resolv.conf where userspace DHCP writes, not /proc/net/pnp which is always empty) All these things are more emulating a Raspberry Pi in qemu when doing local development of the appliance image. Updates #1866 Change-Id: Iba7847e5deb237b1e485b74a4126e31fd118333a Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Adds a Linux-only framebuffer status display (cmd/fbstatus) that draws to /dev/fb0 on the Tailscale gokrazy appliance. It shows: - the Tailscale logo - the current tailscaled state (starting, needs login, running) - a QR code with the login URL when enrollment is needed (triggers StartLoginInteractive automatically so the URL appears without user action) - the LAN IP or "Waiting for DHCP (MAC)" pinned at the bottom-left - Tailscale IPs once connected VT switching: Ctrl-Alt-F2 drops to a busybox text shell on VT2 (for debugging with a USB keyboard), Ctrl-Alt-F1 returns to the GUI. Rendering pauses while the text VT is active. On boot, fbstatus pokes the gokrazy unix socket API to restart the breakglass SSH service (which uses DontStartOnBoot by default). It waits until DHCP assigns an IP so breakglass binds to the LAN address rather than just localhost. Included in the tsapp-pi.arm64 gokrazy build by default. Updates #1866 Change-Id: Ifdce4ad8e8c2e1005c840f579e637974a0a266d3 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Several improvements to the gokrazy appliance build and flash workflow: gokrazy/build.go: - Round Pi image size up to a power of 2 (QEMU raspi3b requires it) - Use monogok's mkfs.Perm for the /perm ext4 partition (pure Go, cross-platform, no e2fsprogs dependency) gokrazy/mkfs: - Accept optional PermFile entries to include in the freshly-created ext4 filesystem (used for breakglass authorized_keys) - Use progresstracking.Ticker for flush progress reporting gokrazy/tsapp*/config.json: - Point breakglass at /perm/breakglass.authorized_keys (not ec2) - Fix Pi SerialConsole to serial0,115200 (not ttyS0) cmd/tailscale/cli/configure-flash-appliance.go: - Add --add-ssh-authorized-keys flag to write an authorized_keys file into /perm during flash (for breakglass SSH access) - Use progresstracking.CountingWriter + Ticker for write progress Makefile: - tsapp-build-and-flash-pi: auto-include ~/.ssh/id_ed25519.pub - tsapp-qemu-pi: use virt machine + UEFI + ramfb + e1000 (working network + framebuffer), with DTB watchdog patch and gokrazy.log_to_serial for debugging - Auto-detect UEFI firmware path across Debian/Homebrew/Fedora Updates #1866 Change-Id: Ifa97ad34c509a81e1637d9bce12a788037dfe5ec Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>tailscale exit-node suggest --force-probeca91eafce5Add a new Prefs.RemoteConfig bool. When true, a c2n endpoint at /remoteapi/localapi/* proxies into this node's LocalAPI at /localapi/* with full read/write permission, giving the tailnet admin the same API surface a local root/admin user has via the tailscale CLI. All LocalAPI versions (v0, v1, ...) proxy through. RemoteConfig is an alternative to Tailscale's default per-feature double opt-in, in which both the tailnet admin and the local machine owner must consent to each individual setting change. It is a single client-side "I trust the tailnet admin" switch that, once on, hands over full remote management of this node's settings and LocalAPI without any further local prompt or confirmation. This is only appropriate when the tailnet admin already owns the machine (e.g. a corporate fleet device) or the local user has explicitly delegated full control. It should never be enabled on a personal/BYOD device with an untrusted tailnet admin. The trust model is documented on the pref, on the hidden --remote-config CLI flag, and on the feature/remoteconfig package. The node advertises its RemoteConfig state to the control plane via a new Hostinfo.RemoteConfig bool. This is only true when the feature is both compiled in (buildfeatures.HasRemoteConfig) and its init actually ran (feature.IsRegistered("remoteconfig")); tsnet builds have the former but not the latter and correctly report false. The handler lives in feature/remoteconfig and can be omitted with the ts_omit_remoteconfig build tag. tsnet's TestDeps guards against accidentally pulling it in. Updates tailscale/corp#18043 Change-Id: I72ce10a90a0e4e738c72c940af3af64c986160b2 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>Like the earlier RemoteConfig change, gate Hostinfo.AllowsUpdate on feature.IsRegistered("clientupdate") in addition to the buildfeatures.HasClientUpdate build-tag const. tsnet binaries don't import feature/clientupdate even though ts_omit_clientupdate isn't set, so they shouldn't tell control they can be remotely updated. Add the previously missing feature.Register call to feature/clientupdate, document the binary-support requirement on tailcfg.Hostinfo.AllowsUpdate, and make tsnet's dep test verify it doesn't depend on feature/clientupdate. Updates #12614 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I526ef11f2a4141f5fce161b1f77263324014b5c4FreeBSD guests downloaded their test binaries from vnet's files.tailscale VIP at roughly 250 kB/s in CI, and transfers sometimes wedged outright for many minutes, which is why TestSubnetRouterFreeBSD timed out in about a third of its runs. Locally the same path moves data at 100+ MB/s, so the problem was never CPU; it was TCP behavior under two independent constraints, both diagnosed with a new throughput harness (TestVnetPerfFreeBSDDownload), a VNET_TCP_DEBUG endpoint sampler, and pcaps: First, throughput is capped at receive-window/RTT. FreeBSD starts its receive window at 64 kB and autoscales it in slow 16 kB steps, and on an oversubscribed CI runner the effective RTT of the userspace vnet data path reaches hundreds of milliseconds, giving almost exactly the observed 250 kB/s. Fix: raise the FreeBSD guest's TCP buffer sysctls in cloud-init before the downloads, and raise netstack's receive buffer sizing for the reverse (upload) direction. Second, the outright wedge: when netstack bursts more data than the QEMU socket plus the guest's virtio RX ring can absorb, a wide swath of segments is dropped downstream of vnet, and netstack's loss recovery then crawls, retransmitting one or two segments per 200 ms RTO for minutes at a time (a 33 MB transfer was observed taking 526 seconds against an otherwise idle receiver). Rather than depending on recovery from mass loss, make the path effectively lossless by keeping the maximum in-flight data (the 1 MB netstack send buffer) below the downstream buffering: grow the guests' virtio RX rings from 256 to 1024 descriptors, enlarge the vnet-QEMU unix socket buffers, and grow the netstack link endpoint queue from 512 to 4096 packets so a send burst can't overflow it. Also fixed along the way, found while chasing the above: * pcapWriter fsync'd after every packet, serializing all traffic behind disk writes when a test enables pcap; a pcap-enabled run was capped at about 290 kB/s. Keep the per-packet Flush but drop the per-packet fsync. * Traffic originating from vnet's own netstack (control plane, DERP, file servers) bypassed conditionedWrite, so SetLatency and SetPacketLoss silently didn't apply to it. * writeEthernetFrameToVM held one global mutex (and a shared scratch buffer) across writes to all VMs, so one guest slow to drain its socket stalled traffic to every VM on the server. The write lock is now per-VM-connection. TestSubnetRouterFreeBSD now passes locally in 31s (down from 4.5 minutes), still passes with the vnet simulating a 100 ms RTT (downloads at 2-8 MB/s, previously 250-600 kB/s), and passes in 65s with KVM disabled while pinned to two host CPUs, a harsher environment than the CI runners. The benchmark test is opt-in via --run-perf-tests (in addition to --run-vm-tests) so CI doesn't spend a matrix job re-measuring it on every run. VMTEST_NO_KVM=1 forces TCG for reproducing slow-host behavior. Fixes tailscale/corp#44805 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I1a7945a7e9c7d083b0ea2a3530eda0e9757dff18* cmd/{k8s-operator,containerboot,k8s-proxy},kube: support IPv6 in egress ProxyGroup Add support for dual-stack and IPv6 clusters in egress ProxyGroup. Previously, egress ProxyGroup only supported IPv4: the operator and containerboot assumed IPv4 for ClusterIP Services, EndpointSlices, and health check headers. This change introduces the following: - Create a per-family EndpointSlice instead of a single IPv4 EndpointSlice. - Update the egress services readiness reconciler to account for both IPv4 and IPv6 EndpointSlices. - Update the pod readiness reconciler to use the primary Pod IP (PodIPs[0]) for readiness checks, instead of hard-coding to use IPv4. - Update the /healthz handler to return both PodIPv4Header and PodIPv6Header. - Add an IPv6 address field to egress status. - Update containerboot and k8s-proxy to use the new health check logic. Updates tailscale/corp#41677 Change-Id: If66a3146df48c75b1e65a71632bbc9fc75feded2 Signed-off-by: Becky Pauley <becky@tailscale.com> * cmd/{k8s-operator,containerboot}: improve dual-stack egress ProxyGroup On dual-stack clusters, an egress ProxyGroup Service has one EndpointSlice per IP family (IPv4 and IPv6). However, EndpointSlices were only recreated when the ExternalName Service configuration changed, so a deleted EndpointSlice was not recreated. The egress readiness reconciler also had no mechanism to identify which IP families should exist (previously only an IPv4 EndpoitSlice was required). We now create an EndpointSlice for every IP family the ClusterIP Service supports. Also mark an egress Service NotReady when an EndpointSlice for an expected IP family (derived from the Service's ClusterIPs) is missing, so a dual-stack Service missing a family's EndpointSlice is no longer reported Ready. Clarify that the egress pre-shutdown and Pod readiness health checks verify only one IP family on dual-stack clusters. Change-Id: I35b03daf76ac817cd516e9a731770b2d85f6ee16 Signed-off-by: Becky Pauley <becky@tailscale.com> --------- Signed-off-by: Becky Pauley <becky@tailscale.com>go vetin CI 38345dce3dTestContainerBoot/kube_shutdown_during_state_write flaked with exit code 1 instead of 0 when SIGTERM arrived while "tailscale up" was still running. Two problems combined: tailscaleUp and tailscaleSet wrapped errors with %v, flattening the error chain, so main's errors.Is(err, context.Canceled) check could not recognize a graceful shutdown. Even with %w, cmd.Run under a canceled context usually reports the death of the killed subprocess ("signal: killed") rather than the context error that caused it, since Wait prefers the process error. Check ctx.Err() explicitly and return it (wrapped with %w) so that a shutdown-driven cancellation is recognized wherever it lands relative to the subprocess lifetime. Before: the exit-code failure reproduced 4 times in 808 stress runs under CPU starvation. After: 0 in 1195 runs. Fixes #19380 Change-Id: Ie15ca722d2d5ac2a3f79b2d0ab01fb71d4b9220d Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>tailscale dns --json(#20017) f3ec43d7dddbe32de290to150650d8ab150650d8abto50a454c6c4Wire up the userspace networking primitives to the JS bridge so browser callers can initiate outbound and receive inbound traffic over the Tailscale network: - ipn.dial(network, addr) wraps a tsdial UserDial into a JS Conn with read/write/close/localAddr/remoteAddr. - ipn.listen(network, addr) wraps a netstack ListenPacket into a JS PacketConn with readFrom/writeTo/close/localAddr. - ipn.listenICMP("icmp4"|"icmp6"|"icmp") creates a raw ICMP endpoint on the underlying gVisor stack and wraps it as a PacketConn for sending/receiving ping traffic. To support listenICMP, netstack.Impl gains a Stack() accessor that returns the underlying *stack.Stack so jsIPN can call NewEndpoint with icmp.ProtocolNumber4/6. Binary I/O uses js.CopyBytesToGo / js.CopyBytesToJS to move bytes across the syscall/js boundary without base64 round-trips.Extend ipn.listen to also accept "tcp"/"tcp4"/"tcp6" and return a TCPListener bound to a netstack gonet.TCPListener. The listener exposes accept/close/addr like a Go net.Listener and additionally implements Symbol.asyncIterator so JS callers can write: for await (const conn of listener) { ... } The async iterator returns done when the listener is closed (via errors.Is(net.ErrClosed)) and rejects on any other accept error. Symbol-keyed properties are set via Reflect.set since syscall/js only exposes string-keyed Set.apitype.WaitingFile has no json tags so it serialised as {Name, Size}. Introduce a local jsWaitingFile struct with json:"name" / json:"size" so the JS side receives idiomatic camelCase property names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>netstack.ListenTCP requires a full host:port address; callers passing the standard net.Listen form (":0" for any-interface ephemeral port) would get ParseAddrPort error. Prepend "0.0.0.0" when the address starts with ":" so the API matches Go's net.Listen behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Add SetExplicitServices on LocalBackend so the browser WASM node can declare TCP/UDP services that get uploaded to the control server and distributed to all peers in the netmap — without the OS port-scanner (portlist extension) that cannot run in a browser. The ShouldUploadServices gate in hostInfoWithServicesLocked is bypassed when services were set explicitly, leaving all other callers unaffected. On the JS side, a new setServices(services) method accepts an array of {proto, port, description?} objects. The netmap JSON now includes a services field on every node (self and peers), populated from Hostinfo.Services with internal peerapi entries stripped (they are already reflected in peerAPIURL). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>50a454c6c4to15a70243edBranches swapped.
Pull request closed