Author SHA1 Message Date
codingetandClaude 4ae5083960 fix(driveprobe): only count listing entries below the collection
hasChild treated any href that was not the collection as a share, so a
peer answering about an unrelated collection looked like it had shares.
Follow RFC 4918 §9.1 instead: the collection comes first and anything
after it is a member, with the first href counted only if it is itself
below the root.

Also accumulate href text across tokens; the XML decoder may split
character data, which the previous token-at-a-time check miscounted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:51:57 +00:00
codingetandClaude 3b239fe9e2 feat(tsconnect/wasm): add hasShares filter to listDrivePeers
PeerCapabilityTaildriveSharer says a peer may share with us, not that it
does, so listDrivePeers is a superset of the peers actually exposing
shares. listDrivePeers now takes an options object; with
{hasShares: true} each candidate's taildrive root is probed with a
Depth-1 PROPFIND and only peers listing at least one share are kept.

The probe and its multistatus parsing live in cmd/tsconnect/driveprobe
so they can be tested without syscall/js. Probes run in parallel with a
bounded worker count, a per-probe timeout and a bounded response read;
a probe that fails drops that peer and is logged rather than failing
the call, so the filter is positive-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:32:03 +00:00
codingetandClaude 7e9868f50e fix(tsconnect/wasm): filter offline and unreachable drive peers
listDrivePeers only checked PeerCapabilityTaildriveSharer, which is
usually granted to a whole group or tag, so it returned most of the
tailnet including offline peers and peers with no reachable peerAPI.
Apply the same conjunction as LocalBackend.driveRemotesFromPeers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 01:32:15 +00:00
codingetandCodex d94244830b cmd/tsconnect: remove obsolete wasm bridge APIs
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
2026-07-30 22:34:09 +00:00
codingetandClaude 15a70243ed fix(ipnlocal): allow Funnel ingress from unsigned peer relays
Upstream 0eb38dc2e denies peer capabilities to any peer with
UnsignedPeerAPIOnly set. Funnel ingress relays are precisely that: per the
docs on tailcfg.Node.UnsignedPeerAPIOnly they get no network access and exist
only to reach this node's peerapi. Since canIngress() resolves
PeerCapabilityIngress through the capability map, the relay can no longer
reach the one endpoint it is allowed to use, and Funnel connections are
refused after the client's ClientHello.

Split peerCapsLocked so the unsigned-peer denial can be skipped for the
ingress path alone. Every other caller keeps upstream's stricter behaviour.

Fork-local patch, tracked in webnet/tailscale#16 for reverting once upstream
restores Funnel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude cf52316095 fix(tsconnect/wasm): close conn on upgradeTLS configuration errors too
Previously only a handshake failure closed the underlying conn;
configuration errors (malformed caCerts PEM, bad cert/key pair)
returned with the conn still open, while the JS wrapper treats every
upgradeTLS rejection as fatal and marks the conn closed — leaking a
live Go conn that could no longer be closed from JS. Close on every
error path so the JS contract (any rejection after validation is
fatal) holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH
2026-07-28 20:10:02 +00:00
codingetandClaude 375fad6adb feat(tsconnect/wasm): add upgradeTLS to wrapped conns
Adds an upgradeTLS method to the conn objects returned by dial/accept,
wrapping the existing net.Conn with crypto/tls in place (STARTTLS /
FTPS AUTH TLS style). Client mode reuses the dialTLS options
(serverName, insecureSkipVerify, caCerts), now factored into
tlsClientConfigFromJS; server mode takes isServer with certPem/keyPem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH
2026-07-28 20:10:02 +00:00
codingetandClaude 487ac2cf17 feat(wasm): expose taildrive WebDAV server and listDrivePeers via JS bridge
Add drive.go (build tag !ts_omit_drive): implements drive.FileSystemForRemote
with a JS-backed handler. Streams request bodies chunk-by-chunk via
readBodyChunk() and response bodies via write()/end() callbacks so no
full-body buffering occurs regardless of file size. The handler is nil-safe:
returns 404 until setDriveHandler() is called from JS.

Add drive_stub.go (build tag ts_omit_drive): no-op stubs for stripped builds.

Add peer.go: extract buildPeerAPIURL helper (previously inline in run()).

Modify wasm_js.go: call initDriveForRemote before NewLocalBackend (SubSystem
is set-once), expose setDriveHandler and listDrivePeers via wireDriveJS,
and refactor the inline peerAPI URL logic to use buildPeerAPIURL.

listDrivePeers mirrors native driveRemotesFromPeers: returns empty if
DriveAccessEnabled() is false, then filters peers by PeerCapabilityTaildriveSharer
using lb.PeerCaps(addr).HasCapability() (the live ACL-derived cap map).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 8cbf31ca49 fix(tsconnect): avoid nil services slice in netmap JSON
userServicesFromView returned a nil slice when a node advertised no
services, which (combined with the omitempty tag) caused the
services field to be dropped or serialize as null instead of [].
TypeScript declares services as a non-optional array, so JS callers
calling .find()/.some() on it would throw intermittently depending on
which netmap snapshot they observed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 4a0b942852 fix(tsconnect): restart map poll after SetExplicitServices
The previous implementation only triggered a lite map update (non-streaming,
OmitPeers=true), whose response is discarded. This meant notifyNetMap was
never called after setServices, so self.services was never visible to the
local node and peers received the update only on their next periodic poll.

Add RestartMap() to controlclient.Auto and call it from SetExplicitServices
after the lite update. This cancels the current streaming poll and starts a
fresh one, causing the control server to send back a full netmap that
includes the updated SelfNode.Hostinfo.Services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 9a44000533 feat(tsconnect): expose service advertisement to JS
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>
2026-07-28 20:10:02 +00:00
codingetandClaude 6fa024a8af fix(tsconnect/wasm): nil-check lb and ln in shutdown() before use
lb and ln are only initialised during run(); calling shutdown() before
run() panics on nil. Guard both fields before dereferencing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude d789fa3e85 fix(tsconnect/wasm): normalise ":port" listen addr to "0.0.0.0:port"
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>
2026-07-28 20:10:02 +00:00
codingetandClaude 862b569e8c fix(safesocket/js): use unique memconn name per IPN instance
Each call to newIPN() starts an independent Go backend that calls
safesocket.Listen() to serve the ipnserver IPC channel. Because
memName was a global constant, the second instance would fail with
"addr unavailable" and log.Fatal the whole WASM process.

Use an atomic counter to give each listener a distinct name
(Tailscale-IPN-1, Tailscale-IPN-2, …). The connect() path is
unchanged: in the wasm/tsconnect build all LocalAPI calls go through
the in-process httptest handler, so connect() is never called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 7b631aa83e feat(tsconnect/wasm): add shutdown() to jsIPN
Expose a shutdown() method on the JS-side IPN object that stops the
LocalBackend, closes the safesocket listener (which unblocks srv.Run),
and signals main() to return so the Go runtime exits cleanly.

This allows the host environment (Node.js process or browser service
worker) to terminate normally once the Tailscale WASM module is no
longer needed, instead of being kept alive indefinitely by open handles,
goroutines, or the Go runtime's blocking main goroutine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 34841c4801 feat(taildrop): stream files via ReadableStream on send and receive
Send: accept a ReadableStreamDefaultReader instead of a Uint8Array.
jsStreamReader (new io.ReadCloser) awaits reader.read() Promises via the
channel+FuncOf pattern, feeding chunks directly to the HTTP PUT body.
No js.CopyBytesToGo of the full file.

Receive: openWaitingFile now returns a pull-based ReadableStream backed by
the Go io.ReadCloser (jsReadableStream helper). Each pull call reads up
to 64 KiB and enqueues a Uint8Array chunk; no io.ReadAll.

jsFileOps.OpenReader: JS now returns a ReadableStream instead of a
Uint8Array; Go wraps it in jsStreamReader for streaming delivery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude efdb8c56be chore(tsconnect): drop wasm pre-compression from build-pkg
Consumers are now responsible for compressing assets; the package ships
only the raw main.wasm binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 37df6f9853 fix(wasm): correct ICMP case in ping type error message
The constant tailcfg.PingICMP is "ICMP" not "icmp"; the error message
was listing the wrong string, causing user confusion about valid values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 24338efd08 fix(wasm): validate ping type early; fallback DNS resolver for exit node
Add a switch guard before the 30-second context in ping() so that invalid
ping type strings (e.g. "disco" vs "Disco") reject immediately with a clear
error rather than silently timing out because userspaceEngine.Ping has no
default case.

For queryDNS(), detect SERVFAIL responses returned with an empty resolver
list (the typical state when an exit node is active but the DNS manager
forwarder has no configured upstreams) and fall back to querying 8.8.8.8
via the dialer — which honours exit-node routing — for A/AAAA record types.
Fall further back to the browser's native resolver if UserDial fails.

Also accept bare IP addresses in whoIs() (in addition to ip:port) so
callers don't need to fabricate a port when they only have a peer IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude aab02cbf00 feat(tsconnect): add peerAPIURL to netmap and localAPI in-process bridge
Include the PeerAPI base URL (http://ip:port) in every node entry of the
notifyNetMap payload — for self via LocalBackend.GetPeerAPIPort, for peers
by reading the PeerAPI4/PeerAPI6 Services entries in their Hostinfo. The URL
mirrors the address-family preference used by peerAPIBase (prefer IPv4).

Add a localAPI(method, path, body?) WASM binding that dispatches in-process
HTTP requests directly to a LocalAPI handler with full read/write/cert
permissions, returning {status, body}. Enables TypeScript callers to access
any LocalAPI endpoint (ACL policy, Taildrive shares, etc.) without network
setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 962cee914d feat(tsconnect): add whoIs, queryDNS, ping, suggestExitNode WASM bindings
Expose four LocalBackend capabilities to JavaScript:
- whoIs(addrPort, proto?): resolves a connecting ip:port to a tailnet node
  and user profile; returns null for unknown peers
- queryDNS(name, type?): queries the tailnet DNS resolver (MagicDNS +
  upstream); parses A/AAAA/CNAME/TXT answers into strings
- ping(ip, type?, size?): pings a tailnet peer (TSMP, disco, ICMP, peerapi)
  with a 30 s context timeout; returns latency and path details
- suggestExitNode(): asks the coordination server for the best exit node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 07bbd6901b feat(tsconnect): add getCert, listenTLS, setFunnel + fix TLS cert for WASM
Enable ACME TLS certificates on js/wasm by dropping the !js build tag from
cert.go and routing storage through the state store. Add getCert, listenTLS,
and setFunnel WASM bindings with a combinedTLSListener that merges Funnel
ingress and direct tailnet connections. Notify the control plane immediately
after serve config changes to accelerate Funnel DNS provisioning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude c1c1f26c90 fix(tsconnect): pin types to avoid monorepo @types pollution
Replace skipLibCheck with an explicit types list so TypeScript and
dts-bundle-generator only auto-include @types/golang-wasm-exec and
@types/qrcode, preventing @types/eslint-scope and @types/ws from
leaking in from a parent node_modules when built inside a monorepo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 101a52e75c fix(tsconnect): skipLibCheck to avoid monorepo @types conflicts
When tsconnect is built inside a JS monorepo, TypeScript walks up the
directory tree and auto-discovers @types/eslint-scope and @types/ws
from the root node_modules, causing spurious type errors unrelated to
tsconnect itself. skipLibCheck suppresses these.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 23ef28b4ae fix(tsconnect): lowercase name/size in waitingFiles JSON
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>
2026-07-28 20:10:02 +00:00
codingetandClaude 18db7a0f94 fix(taildrop): restore incoming file progress notifications
The io.Copy in PutFile was writing directly to wc, bypassing the
incomingFile wrapper whose Write method increments f.copied and fires
a throttled sendFileNotify on progress. As a result, notifyIncomingFiles
on the JS side only ever fired once (on completion) with received=0,
making progress UI impossible. The original inFile wrapping was lost
during the Android SAF refactor.

Also surface the PartialFile.Done flag through jsIncomingFile so JS can
distinguish the final "transfer complete" notification from in-progress
updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude 0b277058d3 fix(tsconnect): guard nil n.Prefs in notify callback
n.Prefs is *PrefsView (a pointer), so calling n.Prefs.Valid() on a
Notify where Prefs is nil auto-dereferenced nil and panicked. The
callback's defer recover() swallowed the panic, which meant every
Notify without Prefs (Health-only, FilesWaiting, IncomingFiles,
OutgoingFiles, etc.) never reached the file-related JS calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude 038aa47b83 feat(tsconnect): add outgoing file transfer progress notifications
- Export UpdateOutgoingFiles on taildrop.Extension so it can be called
  from outside the package (wasm bridge, package main).
- Wrap sendFile's PUT body with progresstracking.NewReader so bytes-sent
  is sampled roughly once per second during transfer.
- Create an OutgoingFile entry (with UUID, peer ID, name, declared size)
  before the PUT and call UpdateOutgoingFiles on each progress tick and
  on completion (setting Finished/Succeeded). This flows into the IPN
  notify stream as OutgoingFiles notifications.
- Add jsOutgoingFile struct and wire n.OutgoingFiles into a new
  notifyOutgoingFiles callback in run(), mirroring notifyIncomingFiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude 06258280de feat(tsconnect): add notifyFilesWaiting and notifyIncomingFiles callbacks
Wire two new callbacks into the IPN notify stream:

- notifyFilesWaiting: fires when a completed inbound transfer is staged
  and ready to retrieve via waitingFiles(). Triggered by n.FilesWaiting
  in the notify stream.
- notifyIncomingFiles: fires with a JSON snapshot of in-progress inbound
  transfers whenever progress changes (roughly once per second while
  active, plus once at completion). The jsIncomingFile struct carries
  name, started (Unix ms), declaredSize, and received bytes. An empty
  array indicates all active transfers have finished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude b9555a463b feat(taildrop): fix DirectFileMode, void callbacks, and empty WaitingFiles
- Add SetStagedFileOps to Extension: sets fileOps without enabling
  DirectFileMode, so WASM clients use staged retrieval (WaitingFiles,
  OpenFile, DeleteFile) instead of direct-write mode.
- Add directFileOps bool field: SetFileOps (Android SAF) sets it true;
  SetStagedFileOps (WASM JS) leaves it false. onChangeProfile now uses
  `fops != nil && e.directFileOps` to determine DirectFileMode.
- Add jsCallVoid to jsFileOps: void ops (openWriter, write, closeWriter,
  remove) now use cb(err?: string) instead of cb(null, err: string).
- Fix waitingFiles() returning JSON null when no files are waiting:
  normalise nil slice to empty slice before marshalling.
- Update wireTaildropFileOps to call SetStagedFileOps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude b2547cc664 feat(tsconnect): expose exit node selection to JS
Add exit node support to the wasm JS bridge:

- Include `exitNodeOption` and `stableNodeID` on each peer in the
  notifyNetMap payload so callers can identify which peers are exit
  nodes and reference them by stable ID.
- Call `notifyExitNode(stableNodeID)` whenever prefs change, so
  callers can track which exit node (if any) is currently active.
- Expose `setExitNode(stableNodeID)` — sets ExitNodeID via EditPrefs.
- Expose `setExitNodeEnabled(enabled)` — toggles the last-used exit
  node on/off via SetUseExitNodeEnabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codinget 358f47bc79 feat(tsconnect): add TCP listening to ipn.listen
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.
2026-07-28 20:10:01 +00:00
codinget 58095f829c feat(tsconnect): expose dialTLS to JS
Add ipn.dialTLS(addr, opts?) which dials a TCP connection through
the Tailscale dialer and performs a TLS handshake on top, returning
a JS Conn just like ipn.dial.

WASM has no system root pool, so verification defaults to the
baked-in LetsEncrypt ISRG roots already linked via net/bakedroots.
That covers any tailnet HTTPS endpoint provisioned via
`tailscale cert`. Callers can override with opts.caCerts (PEM) or
bypass entirely with opts.insecureSkipVerify, and override SNI with
opts.serverName.

Marginal binary cost is ~10 KiB on top of the existing ~31.6 MiB
wasm: crypto/tls and the x509 verification path are already pulled
in by control/controlclient and net/tlsdial.
2026-07-28 20:10:01 +00:00
codinget d42da2fbd7 feat(tsconnect): expose dial, listen and listenICMP to JS
Wire 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.
2026-07-28 20:10:01 +00:00
codingetandClaude c695e579fa fix(tsconnect): link fork features into the wasm build
Upstream's featuretags work turned the wasm build into an allow-list scoped
to its SSH-in-browser client, which strips most of what this fork's JS bridge
exposes. Two problems, both silent at build time:

- cmd/tsconnect/wasm never imported feature/condregister, so extensions only
  registered if the wasm happened to import them directly (taildrop did, ACME
  did not). Without it getCert/listenTLS/setFunnel fail with "cert support not
  compiled in this build".
- The Keep allow-list omitted acme, serve, taildrop, drive, tailnetlock,
  bakedroots and the exit node features. bakedroots matters especially: a
  browser has no system roots, so net/tlsdial's LetsEncrypt fallback is the
  only verification path there.

Invert the polarity to an explicit Omit list, matching how this build behaved
before featuretags existed. Only feature/ace is omitted, because it does not
compile for GOOS=js. Trimming the bundle is worth doing later with
measurements; an allow-list turns each mistake into a runtime failure rather
than a build error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:09:08 +00:00
Brad FitzpatrickandBrad Fitzpatrick c0c453334a ipn/ipnlocal: evict stale node indexes when a delta upsert replaces a peer
When a node is renamed in the admin console, control sends peers a
single MapResponse delta: a PeersChanged entry carrying the full
updated node with its new Name, and no new DNSConfig (MagicDNS
records are computed client-side from peer names). That arrives as a
NodeMutationUpsert, but nodeBackend's upsert path only added the new
node's index entries and never removed the replaced node's, so
nodeByName retained the old name, and nodeByAddr, nodeByKey, and
nodeByStableID could likewise go stale if those fields changed.

Since 7e609b258 the quad-100 resolver serves MagicDNS answers on
demand from those live indexes, so a renamed peer's old name kept
resolving until something rebuilt the indexes from a full netmap,
such as toggling Tailscale off and on.

Evict the replaced node's index entries before adding the new ones.
Also consolidate the natlab DNS coverage into a single TestMagicDNS
that boots one VM and exercises extra records, search domains, and
peer add/rename/remove end to end, injecting the same MapResponse
shapes that production control sends.

Updates tailscale/corp#45631

Change-Id: I8a418317d930ec8ce112f7bd19bfd5778117a65e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-27 19:43:11 -07:00
Brad FitzpatrickandBrad Fitzpatrick d0b4d44963 util/set: add OfSliceView constructor and Set.AddSliceView
Building a Set from a views.Slice previously required set.Of(v.AsSlice()...),
which allocates an intermediate slice copy before allocating the set. Add
OfSliceView and AddSliceView to populate a set directly from the view,
mirroring the existing AddSlice/AddSeq/AddSet family.

Updates tailscale/corp#45499

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3f2a9d417c60be8e5f1acd42708e2f9a4d6c1b7e
2026-07-27 16:30:30 -07:00
Mario MinardiandMario Minardi e48e7b730a ssh/tailssh: check if user matching autogroup:nonroot is root
Add a check to ensure that the user being matched to an
autogroup:nonroot rule is in fact a non-root user on the system.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-07-27 17:18:34 -06:00
Simon LawandGitHub f3ec43d7dd cmd/tailscale/tsdnsjsonv0: extract a new package for tailscale dns --json (#20017)
This patch extracts all the DNS related JSON handling from the
cmd/tailscale/jsonoutput package into a new tsdnsjsonv0 package.

It adds package documentation for tsdnsjsonv0 with a big WARNING that
this is an unstable format with no backwards compatibility guarantees.
When we stabilize this format, we should spin off a new tsdnsjsonv1
package that uses jsonoutput.ResponseEnvelope to declare version 1.

Updates #13326
Updates #18750

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-27 13:59:29 -07:00
Simon LawandGitHub 358c975aea cmd/tailscale/jsonoutput: hoist jsonoutput out of cli package (#20591)
Flatten the cmd/tailscale package hierarchy by extracting the
jsonoutput package out of the cmd/tailscale/cli package.

Updates #cleanup

Change-Id: I92f80db75b0328e82f1596b6a42f6f6ef5a94bfa

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-27 13:41:15 -07:00
Brad FitzpatrickandBrad Fitzpatrick 514e50bd1b util/syspolicy/source: fix data race between Reader.Close and reload
Reader.Close set r.store to nil without holding r.mu, while reload read
r.store while holding r.mu. If a policy store is closed while a
concurrent reload is in flight, reload could observe a nil store and
crash tailscaled with a nil interface method call in
readPolicySettingValue.

Nil out r.store only while holding r.mu, and make reload return the
last known policy once the reader is closing instead of reading from
a store that may no longer exist.

Fixes tailscale/corp#45548
Fixes tailscale/triage#394

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I494cfe9ea1df67b563bc061db9e6944f87b42a4e
2026-07-27 09:28:51 -07:00
Brad FitzpatrickandBrad Fitzpatrick c90380f3dd prober: deflake TestProberConcurrency
The fake ticker has a one-element channel buffer and drops ticks when
the probe loop goroutine isn't already blocked on the channel, so
advancing the fake clock 50 times in a tight loop didn't guarantee
that the loop observed enough ticks to start three concurrent probe
runs. Under CI load, only two of the three run goroutines could be
spawned before the convergence timeout expired.

Advance the clock inside the polling loop instead, so ticks keep
firing until all three probe goroutines have started. Verified with
flakestress: the old test failed within ~41k runs, while the fixed
test passed 175,214 runs with no failures.

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

Updates #deflake

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I673a4918bbb5fea6b650e0dc1bc491c4af922b19
2026-07-27 08:03:13 -07:00
Brad FitzpatrickandBrad Fitzpatrick 420a8e5a1a drive/driveimpl: handle Unicode normalization mismatches in filenames
Files whose names contain characters with Unicode decompositions (such
as umlauts or voiced kana) could not be opened or written over
Taildrive.

Background: keyboards and IMEs emit NFC (precomposed) characters on
every platform, so filenames on Linux (ext4 etc) and Windows (NTFS)
disks are usually NFC bytes. NFD (decomposed) names mostly come from
Apple software: HFS+ forced a variant of NFD on write, and Apple's
frameworks still decompose paths via fileSystemRepresentation. APFS
preserves whatever bytes it is given but does normalization-insensitive
lookups (it stores a hash of the normalized name), so canonically
equivalent names find the same file. ext4 and NTFS lookups, by
contrast, are byte-exact.

On the wire, the macOS WebDAV client sends paths in NFD form (they
pass through the decomposing file system representation, and unlike
Apple's NFS client there is no "nfc" mount option). Windows and Linux
WebDAV clients pass names through as the application provided them,
typically NFC. WebDAV itself mandates no normalization, and PROPFIND
hrefs reflect the server's on-disk bytes.

The two forms are canonically equivalent but byte-wise different, so a
macOS client requesting the NFD form of an NFC-named file on a Linux
or Windows host got a 404 from the exact-byte lookup. Even against an
APFS host, where the filesystem absorbs the mismatch, the client-side
StatCache could still infer a 404: a cached directory listing in one
form caused depth 0 PROPFINDs in the other form to be treated as not
found without ever reaching the server. The inverse direction (NFD
bytes on a Linux disk, copied there from a Mac, requested in NFC form
by a Windows or Linux client) was broken too.

Alternative regimes considered: normalizing names at storage time (as
Nextcloud and Syncthing's autoNormalize do) would rename user files in
shared directories as a side effect of serving them; normalizing
request paths to a fixed form on the wire is unsound because the
on-disk form is unknowable a priori (ext4 can hold either form, or
both). Instead, adopt the APFS model: preserve bytes, but make lookups
normalization-insensitive.

Concretely, wrap the remote file server's webdav.Dir in a
normalizingFS that, when an exact path lookup fails, rescans the
parent directory for an entry whose name is canonically equivalent,
comparing the NFC form of both sides (which also sidesteps Apple's
nonstandard decomposition tables). Exact matches always win, and newly
created files keep the exact bytes the client sent. Also NFC-normalize
StatCache keys so canonically equivalent names share a cache entry.

The change is covered at three levels: unit tests for the StatCache,
an in-process two-node test in drive/driveimpl, and a new TestTaildrive
VM integration test in tstest/natlab/vmtest that shares a directory
between two Ubuntu VMs and exercises the NFC/NFD cases over the real
stack with curl playing the part of a macOS WebDAV client.

Fixes #15020

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9c2f157e604efc629828581e08d5b3191dbb7d4e
2026-07-27 06:35:54 -07:00
Brad FitzpatrickandBrad Fitzpatrick b93d9ba1ff cmd/containerboot: return context error when canceled during tailscale up/set
TestContainerBoot/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>
2026-07-27 05:42:29 -07:00
chaosinthecrd 97a75c837d cmd/k8s-operator,ipn/store/kubestore,kube/kubetypes: share ACME account key per tailnet
Introduce a per-tailnet shared ACME account key so that all ingress
ProxyGroup replicas on a tailnet present the same account identity to
Let's Encrypt. This lets renewals claim the ARI "replaces" exemption
from the 50-certs-per-week rate limit, surviving Pod restarts,
ProxyGroup recreation, and cluster migrations.

The operator provisions a "tailscale-acme-accounts" Secret in its
namespace, guarded by a finalizer and a deletion warning event, and
watched so it is recreated promptly if removed. Proxies migrate any
pre-existing per-pod key into the shared Secret on first boot, adopt
the shared key on subsequent boots, and restore it on cert writes if
the Secret was recreated empty. Certs are stamped with the fingerprint
of the issuing account so renewals skip the "replaces" claim when the
account doesn't match.

Opt-in per-ProxyGroup via the tailscale.com/share-acme-account
annotation, or operator-wide via OPERATOR_SHARED_ACME_ACCOUNT_KEY.

Updates #18251
Updates #20288

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-07-27 12:06:41 +01:00
Alex ChanandAlex Chan 2900f3494a types/persist: add a comment explaining "NetworkLockKey"
Changing the name to "TailnetLockKey" would be clearer but introduces
more risk; this is an easy and low-stakes improvement.

Updates tailscale/corp#37904

Change-Id: I38d804202538b8670a80e744eb4dcb689f0002df
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-27 09:55:36 +01:00
Will NorrisandWill Norris cd34d441be cmd/tailscale: add tailnet info to whoami output
Updates #14375

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
2026-07-24 11:39:53 -07:00
Alex ChanandAlex Chan aec6f8bf13 tka/sync: improve the signature of SeedAUMs
I wrote this function two hours ago, tried to use it in corp, and
immediately found myself confused about the meaning of the arguments.
Time for named parameters!

Updates tailscale/corp#40404

Change-Id: Ic2866e052ccc9f6361b8d529233df54d63abbaa1
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-24 19:16:46 +01:00
Alex ChanandAlex Chan b5fb042501 tka/sync: add regression test for compacted nodes on forked chains
We previously identified sync failures that occur when a node falls behind
the remote, and compacts away most its local state. We fixed the underlying
issue in #19444, but that PR only tested the basic scenario where the
local chain is a direct ancestor of the remote chain.

This patch adds an explicit regression test for the case where a node is on
a fork (that is, its HEAD is not part of the remote's active chain).

Although #19444 happened to cover this case, other proposed patches did not
handle the forked state. Adding this test locks in the behaviour and prevents
future sync regressions in this area.

Also, add a shared helper for writing this sort of TKA sync test.

Updates tailscale/corp#40404

Change-Id: I78fdc6beaf71392edf11806197f126db48886f93
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-24 16:42:42 +01:00
Brad FitzpatrickandBrad Fitzpatrick 8c98d2a417 misc/git_hook: reject pushes that add large files
Add a large blob check to the pre-push hook, using the same git tree
diff logic as corp's check-file-size CI workflow (the
check-git-accidental-large-file GitHub Action): diff the pushed tree
against the remote's old tree (or the merge base with the remote's
default branch for new refs) and reject any new or changed blob over
1.5 MB. Unlike the CI check, which only guards PRs into main, the hook
runs before pushing to any branch, catching mistakes before they
permanently bloat the remote repo.

Set TS_SKIP_LARGE_FILE_CHECK=1 to push a large file intentionally,
mirroring the skip-large-file-check commit message tag honored by CI.

This folds the go.mod replace check and the new check into a single
CheckPrePush entry point so both share one read of the hook's stdin;
corp's git-hook.go needs the matching call site update when it next
bumps its tailscale.com dependency.

Updates tailscale/corp#9863

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I1c8cf2a277ce854d45c0ea809bed7c06b3295374
2026-07-24 08:22:19 -07:00
Michael Ben-Amiandmzbenami b062abb1ea appc,ipn/ipnlocal: install conn25 DNS routes when using exit node
We were early-returning when the node was using an exit node, before
Connectors 2025 split DNS routes were calculated and installed.

Now we assemble the routes first, then install them in both exit node
and non-exit-node contexts. The returned resolvers set UseWithExitNode
to true even though as of today, we believe they should be installed in
all cases without regard to that boolean value. With the boolean, we
preserve the flexibility to toggle behavior without touching ipnlocal.

We also add a TODO to turn the extra split DNS route gathering into a
feature hook (tailscale/corp#37125).

This does not affect appc connectors, which receive split DNS routes,
and the UseWithExitNode value directly from control.

Updates #16384

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-24 09:39:40 -04:00
David BondandGitHub 9535e3b99b cmd/k8s-operator: include peer relay CRD in generation (#20593)
This commit modifies the generation command for the kubernetes operator
to include the CRD for peer relays in the helm chart and static
manifests

Updates: #fixup

Signed-off-by: David Bond <davidsbond93@gmail.com>
2026-07-24 14:01:41 +01:00
Alex ChanandAlex Chan f6fa294635 tka/sync: send checkpoints to ensure far-behind nodes can catch up
Previously there was a mismatch between how nodes store AUMs and what
the control plane would offer during sync:

- Client compaction: Nodes aggressively compact their TKA state -- they
  keep the last 24 AUMs, every AUM received in the last two weeks, and
  then everything from there back to the last checkpoint. Depending on
  when it compacts, a node may only have ~50 AUMs.
- Exponential sampling: To save bandwidth, the control plane would send
  a SyncOffer containing ancestors at exponentially increasing intervals
  (4th, 16th, 64th, 256th...).

If a node has been offline for too long, the exponential sampling skips
the node's smaller window. When the SyncOffer and local state are disjoint,
the node cannot find a common ancestor to use for synchronisation.
It enters a failure loop where it keeps polling for new TKA state, but
it cannot catch up and has an increasingly-outdated view of the tailnet.

This patch replaces the exponential sampling with a SyncOffer that sends
every checkpoint ancestor of the current HEAD. Since every node is
guaranteed to keep at least one checkpoint after compaction, we're more
likely to have an intersection for the sync process.

This patch also increases `maxSyncHeadIntersectionIter`, which in
practice means the control plane will send every checkpoint in the
current chain. This means all affected nodes will be able to find an
intersection and catch up immediately, without requiring a client update.

It's still possible for a node to be unable to sync, but these edge cases
become less likely with this change. (For example, if a node is 1000+ AUMs
behind, or if it creates a local branch and then compacts away the
intersection with the main chain.)

This patch includes a regression test with synthetic data, and I
verified the fix with customer data.

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

Change-Id: I2174011bb23a2b5972f6d1591aadcc016e3cba35
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-24 09:01:38 +01:00
Fran Bull 1d2baa12fe tailcfg: bump tailcfg.CapabilityVersion for conn25
We have some client builds on the unstable track where the conn25 code
doesn't run if the TAILSCALE_USE_WIP_CODE env var is not set. But the
split DNS routes for conn25 configured domains do get installed. This
means that users running those builds would get traffic for configured
domains black holed if the env var is not set.

This issue was fixed in 425a916ce.

Bump tailcfg.CapabilityVersion, and then a corresponding change to the
control server to not send conn25 config to lower versions will
avoid this issue for those users.

Updates tailscale/corp#45363

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-07-23 14:30:22 -07:00
Tom ProctorandGitHub 682005aaa6 cmd/cigocacher,go.mod: add logging for canceled PUTs (#20584)
Pulls in bradfitz/go-tool-cache#43 and:

* Add logging for canceled PUTs to ensure we have some visibility.
* Scale PUT timeouts with object size.
* Control the Shutdown timeout separately from the PUT timeout.

Updates tailscale/corp#45334

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2026-07-23 20:15:41 +01:00
Nick O'NeillandGitHub ac33a4cc1a VERSION.txt: this is v1.103.0 (#20583)
Signed-off-by: Nick O'Neill <nick@tailscale.com>
2026-07-23 12:06:08 -07:00
Rollie MaandGitHub f4978b4b9b feature,client: add serviceclientprefs for desktop client service launch (#20501)
Add serviceclientprefs, an optional feature that stores and loads the
desktop clients' saved service launch preferences, one file per login
profile.

- Add GET|POST /localapi/v0/prefs/service-clients to load and save the
  current profile's service client prefs.
- Add local client GetServiceClientPrefs and SetServiceClientPref that
  call the new local api endpoint.
- Store the prefs with the ipn/store FileStore at
  TailscaleVarRoot()/profile-data/<profileID>/service-client-prefs/<hex-encoded-key>,
  so DeleteProfile cleans them up for free. Fall back to an in-memory
  store when there's no var root.
- Register the feature and its local api route from build tagged files
  so the whole thing drops out under ts_omit_serviceclientprefs.
- Add the serviceclient package holding Pref and Prefs (saved client,
  username, database name, and last used time), so the local api client
  and desktop apps can import the types without the feature machinery.

Change-Id: I340a99c1b332d181fb1556fbf3e8003bb3b95a08
Updates: https://github.com/tailscale/tailscale/issues/20429

Signed-off-by: Rollie Ma <rollie@tailscale.com>
2026-07-22 20:36:05 -07:00
Tom ProctorandGitHub 66bb4ac61f go.mod,cmd/cigocacher: make PUTs async (#20578)
Pull in bradfitz/go-tool-cache#40, and configure PUTs to be async so we
never slow down the build or extend the build time for the sake of
writing to the remote cache.

Updates tailscale/corp#45334

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2026-07-22 22:01:39 +01:00
Brad FitzpatrickandBrad Fitzpatrick b3c259bd5c net/netutil: add test coverage for ipForwardingEnabledLinux per-interface reads
The existing test only exercised the not-found-interface path. Now that
ipForwardingEnabledLinux opens its sysctl key with os.OpenInRoot
(840c6e3d3, #20572), also verify that the global keys and the
per-interface keys for every interface actually present on the machine
can be read without error, for both IPv4 and IPv6.

Updates #20572

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ie204a163ab9f8670abedd79a4ac81e400f71aab7
2026-07-22 13:33:20 -07:00
basavaraj-sm05andBrad Fitzpatrick 840c6e3d3d net/netutil: confine ipForwardingEnabledLinux read with os.OpenInRoot
Signed-off-by: basavaraj-sm05 <basavaraj@digiscrypt.com>
2026-07-22 12:36:27 -07:00
Brad FitzpatrickandBrad Fitzpatrick 5384d23690 cmd/derper: add opt-in support for LetsEncrypt IP address certificates
LetsEncrypt made certificates for bare IP addresses generally
available in January 2026. They require the short-lived ACME
certificate profile and are valid for about six days.

Add a new --acme-ip-certs flag. When set (with the default
--certmode=letsencrypt), connections that arrive by IP address (no
TLS SNI, or an IP address SNI matching the connection's destination
address) get a LetsEncrypt cert for that IP, obtained on demand using
the "shortlived" profile and the HTTP-01 challenge served on derper's
plaintext HTTP port. Because the certificate is requested for
whatever address the connection actually arrived on, it works for
both IPv4 and IPv6 with no per-address configuration, and a client
can never make us request a certificate for an address that isn't
ours. Connections with a DNS name in the SNI keep using the regular
autocert manager for --hostname.

autocert can't do any of this itself, as it neither orders IP address
identifiers nor serves connections without SNI, so this adds a small
dedicated cert manager using tailscale.com/tempfork/acme instead.

Clients can then connect to https://<IP> without the DERPMap CertName
pinning that self-signed certs from --certmode=manual require.

Updates tailscale/corp#45167
Updates #11776

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I8e2d5b0a7c4f9e1b3d6a8c2f5e0b9d4a7c1f3e6d
2026-07-22 12:33:42 -07:00
Brad FitzpatrickandBrad Fitzpatrick fdd81c68b3 go.mod: bump some deps to match corp
Updates tailscale/corp#43243
Updates tailscale/corp#45354

Change-Id: I810a8107641f16619c2036b3c8bd0d7293d1943c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-22 11:00:06 -07:00
SalehandBrad Fitzpatrick 1a14668a31 feature/acme: trim trailing dot from domain before cert lookup
An SNI ServerName with a trailing dot (e.g. "host.ts.net.") failed
cert lookup because stored cert names have no trailing dot. Per RFC
6066 section 3 the SNI HostName carries no trailing dot, but some
clients send a fully-qualified name with one.

Trim the trailing dot at the boundary in getCertPEMWithValidity so all
lookup paths (the GetCertificate hook, Serve, and the localapi) resolve
the dotted and dotless forms to the same certificate.

Fixes #10233

Signed-off-by: Saleh <root@lr0.org>
2026-07-22 10:52:40 -07:00
Michael Ben-Amiandmzbenami 4d846b5501 feature/conn25: add and register c2n handler for /conn25/state
Gets the active state just like the LocalAPI endpoint does. See #20471.

Updates tailscale/corp#40125

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-22 13:04:41 -04:00
Michael Ben-Amiandmzbenami 8f89d4fb55 feature/conn25: rename LocalAPI endpoint to conn25/state
Previously it was conn25-state. The new name prepares for the ability to
add new endpoints behind the conn25/ prefix, and prepares for parity for
an upcoming c2n endpoint with the same name.

Updates tailscale/corp#40125

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-22 13:04:41 -04:00
Michael Ben-Amiandmzbenami 2820b5e99d feature/conn25: rename localapi.go to api.go
And rename serveStateGet to serveLocalAPIStateGet to prepare for adding
a c2n handler that is backed by the same methods as the LocalAPI
handler.

Updates tailscale/corp#40125

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-22 13:04:41 -04:00
Kristoffer DalbyandKristoffer Dalby c802c3ff05 go.mod: revert tailscale/breakglass fork require
Revert the direct fork dependency and its regenerated depaware/flake
manifests; not ready to ship yet.

This reverts commit 745bb8507.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-22 16:39:24 +02:00
Kristoffer DalbyandKristoffer Dalby a19f8f290e gokrazy/tsapp: revert breakglass access lockdown
Revert the tailscale/breakglass fork and its access-control flags;
not ready to ship yet.

This reverts commit 1d82c1b3d.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-22 16:39:24 +02:00
Mike JensenandGitHub 0eb38dc2e5 ipn,magicsock: deny peer capabilities to unsigned peers (#20561)
Unsigned peers aren't covered by tailnet lock, so they must never hold peer capabilities even if the packet filter grants them. This change extends the check for unsigned-peers to ensure full coverage in capabilities.

Fixes tailscale/corp#45116

Change-Id: I918af24f0b9855e55921cbdad109cc68e745e125

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-07-22 08:27:15 -06:00
Kristoffer DalbyandKristoffer Dalby 1d82c1b3d0 gokrazy/tsapp: lock down breakglass access
Point tsapp at the tailscale/breakglass fork, fetch SSH keys from EC2
IMDSv2, restrict to the sec-scan user and internal CIDRs, start on
boot, and stop after 120s idle.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-22 16:03:07 +02:00
Kristoffer DalbyandKristoffer Dalby 745bb85072 go.mod: require tailscale/breakglass fork for tsapp
Depend on the tailscale/breakglass fork directly for its new
access-control flags. The fork renamed its module path so no replace
directive (disallowed here) is needed. Upstream gokrazy/breakglass
stays for the arm64 appliances.

Regenerate depaware manifests and nix flake hashes for the pkg/sftp
bump pulled in by the fork.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-22 16:03:07 +02:00
Mario MinardiandMario Minardi c8ae72b537 various: change OAuth and WIF auth key resolvers to take struct args
Change signature of OAuth and identityfederation auth key resolution
hooks to take in structs instead of lists of args as they were getting
unwieldily.

Updates https://github.com/tailscale/tailscale/issues/20339

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-07-21 15:44:45 -06:00
Brad FitzpatrickandBrad Fitzpatrick 3ccc7725a3 tstest, util/testenv: drop tstest's dependency on the testing package
Change tstest's exported functions (AssertNotParallel, Replace,
Parallel, RequireRoot, SkipOnKernelVersions, MinAllocsPerRun, FixLogs,
UnfixLogs, CheckIsZero, ResourceCheck) to take testenv.TB instead of
testing.TB or *testing.T, so importing tstest from non-test code no
longer links the testing package and its flag registration side
effects into the binary. Add testenv.Verbose to replace the one use of
testing.Verbose, and a deptest check to keep testing out of tstest's
dependency graph.

Callers are unaffected: *testing.T and testing.TB both satisfy
testenv.TB.

Updates tailscale/corp#45223

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ib373ff66ceff638d071582baf8367245987e9155
2026-07-21 13:03:23 -07:00
Adriano Sela AvilesandAdriano Sela Aviles d11757863d cmd/tailscale/cli: remove wip-code gate for service list cmd
Updates #20166

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-07-21 11:33:36 -07:00
Fran Bull 425a916ce2 ipn/ipnlocal: check WIP env var before doing conn25
registering of split dns routes.

Updates tailscale/corp#43680

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-07-21 09:02:47 -07:00
Brad FitzpatrickandBrad Fitzpatrick 4ad1243332 util/testenv: add ArtifactDir, Attr, Output methods to TB
The TB interface exists to mirror testing.TB without importing the
testing package, but it had fallen behind: Go 1.25 added Attr and
Output, and Go 1.26 added ArtifactDir. Add the missing methods and a
reflection-based test that TB has every exported method of testing.TB,
so future additions to testing.TB fail a test instead of silently
diverging. It can't be a compile-time assertion because testing.TB has
an unexported method.

Updates #16330
Updates #18682

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9ba093afefdf3a6311ef4648bc1a13add9af453d
2026-07-20 21:20:11 -07:00
Brendan CreaneandGitHub a7cb5745a2 tstest/natlab/vmtest: add Fedora + DNS-backend coverage, harden non-KVM boot (#20409)
* tstest/natlab/vmtest: make cloud VM boot robust without KVM

Adding heavier distro images (Fedora) surfaced several ways the cloud VM
boot path breaks under TCG software emulation (no /dev/kvm), especially
with multiple concurrent VMs on few cores.

- Add a virtio-rng device to the cloud path so early boot doesn't block in
  getrandom() waiting for the CRNG to seed.
- When no hardware acceleration is available, relax the stuck-console
  watchdog (tuned for KVM's ~1-2s first output) and serialize VM boots so a
  heavy guest doesn't starve its siblings' emulation threads.
- Bound the bring-up context to the test deadline and dump each VM's console
  on failure, so a hang surfaces as a diagnosable Fatalf instead of an
  opaque `go test -timeout` panic (which skips cleanups).

Fixes tailscale/corp#44794
Updates tailscale/corp#44793

Signed-off-by: Brendan Creane <bcreane@gmail.com>

* tstest/natlab/vmtest: add Fedora and DNS-backend test coverage

Add the first RHEL-family distro and the machinery to assert and provision
distinct DNS backends, so adding a distro isn't "basically equivalent" to
the others.

- Add a Fedora 43 image (NetworkManager + systemd-resolved, SELinux
  enforcing). restorecon-relabel the curl'd binaries so they exec under
  enforcing mode.
- Add DNSBackend/AssertDNSBackend, reading the dns_manager_linux_mode_*
  clientmetric to assert which backend tailscaled selected.
- Add a WithDNSMode node option. WithDNSMode(DNSDirect) masks
  systemd-resolved and writes a plain resolv.conf pointing at natlab's fake
  DNS, forcing the direct backend -- so one image covers multiple backends.

Fixes tailscale/corp#44796
Updates tailscale/corp#44793

Signed-off-by: Brendan Creane <bcreane@gmail.com>

---------

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-07-20 12:41:25 -07:00
License UpdaterandWill Norris c130a9b520 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-07-20 12:37:39 -07:00
Brad FitzpatrickandBrad Fitzpatrick a84a264228 tempfork/acme: sync with tailscale/golang-x-crypto, add profiles support
This bumps go.mod to the current tailscale/golang-x-crypto, picking up
its rebase onto current upstream golang.org/x/crypto and its
cherry-pick of the pending upstream change
https://go-review.googlesource.com/c/crypto/+/788000, which adds ACME
certificate profile support: a new WithOrderProfile order option and
profile discovery via the directory metadata. That change has not yet
been submitted upstream and is subject to final API changes before it
lands there.

It then re-vendors that fork's acme package into tempfork/acme as
usual (per the TestSyncedToUpstream workflow), except for upstream's
pebble_test.go, which is now excluded from the sync: its
TestWithPebble downloads the Pebble module from outside our go.mod,
then builds and runs its binaries during tests.

Profile support is needed to request LetsEncrypt IP address
certificates, which require the "shortlived" profile.

Updates tailscale/corp#45167

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3f7c2a91e5d8b4a6c0e2f9d1b7a3c8e6f4d0a2b9
2026-07-20 11:37:37 -07:00
Brad FitzpatrickandBrad Fitzpatrick de0553be66 util/httpm: exempt tempfork from TestUsedConsistently
Files under tempfork are vendored copies of upstream code that we
want to keep as close to upstream as possible, so don't require them
to use httpm constants. An upcoming tempfork/acme sync brings in
upstream test files using net/http's method constants.

Updates tailscale/corp#45167

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: If2a90b1d7c5e8f3a6b4d0c9e2a7f5b8d1c4e6a3f
2026-07-20 11:37:37 -07:00
Brad FitzpatrickandBrad Fitzpatrick 172124da8c client/local, ipn, tailcfg: document which LocalAPI client APIs are stable
The client/local package doc said its API is not necessarily stable, but
that caveat was easy to miss and only a few cert methods said anything
explicit either way. People have been surprised by IPN bus changes
between releases.

Add explicit "API maturity" notes, matching the existing wording on the
cert methods, marking stable: BugReport, BugReportWithOpts, CertDomains,
CheckUpdate, CurrentDERPMap, DialTCP, UserDial, DisconnectControl,
GetPrefs, EditPrefs, Status, StatusWithoutPeers, SetUseExitNode,
SwitchProfile, UserProfile, and the WhoIs* methods. Mark unstable:
ipn.Notify, WatchIPNBus, DoLocalRequest, the Debug*, Drive*, Check*,
EventBus*, and Stream* methods, SetComponentDebugLogging,
TailDaemonLogs, ShutdownTailscaled, GetDNSOSConfig, GetEffectivePolicy,
GetServeConfig, and GetAppConnectorRouteInfo.

Also note on tailcfg.DERPMap that the type is subject to minor changes
over time though its general shape is stable, document that
ipn.Prefs.CorpDNS is the internal name for "tailscale set --accept-dns",
and add a package doc paragraph to client/local saying that methods
without an explicit API maturity note should be assumed unstable.

Updates #20406

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9333c58ae312e392c61d7de77987282e84ce2aeb
2026-07-20 10:41:59 -07:00
Claus LensbølandGitHub b14f7b7543 wgengine/magicsock: properly clean up peer disco maps (#20543)
Updates tailscale/corp#45124
Updates tailscale/corp#45128

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-07-20 13:12:58 -04:00
David BondandGitHub be0e460a20 cmd/k8s-operator,k8s-operator: Kubernetes Peer Relays (#20495)
This commit contains the Kubernetes implementation of peer relays via the new `PeerRelay` CRD. It's a mega branch consisting of the commits of other PRs gone into this work:

1. https://github.com/tailscale/tailscale/pull/20211
2. https://github.com/tailscale/tailscale/pull/20329
3. https://github.com/tailscale/tailscale/pull/20423
4. https://github.com/tailscale/tailscale/pull/20503

An instance of the `PeerRelay` CRD deploys a `StatefulSet` of containerboot instances configured to advertise themselves as peer relays using the IP addresses configured via `LoadBalancer` services on each cloud provider (with some AWS specifics as it's less automatic than its competing cloud providers). 

Per replica, a `LoadBalancer` type `Service` resource is provisioned and its IP address is used to configure the respective relay.

This has been tested with success in AWS, GCP & Azure and provides additional modification to `Service` resources via the CRD for any other kinds of deployment environments. It also contains some work that may appear to be duplication of what already exists within `cmd/k8s-operator` so we can start building an appropriate migration path for `Connector`, `ProxyGroup` etc into respective `k8s-operator/reconciler/*` packages.

Closes https://github.com/tailscale/corp/issues/34524
2026-07-20 16:37:15 +01:00
Brad FitzpatrickandBrad Fitzpatrick 2dd5d82f56 tsnet: fix data race in chanTUN test device
The wireguard-go receive path could be in chanTUN.Write, selecting to
send on the Inbound channel, while test cleanup called chanTUN.Close,
which closed that same channel. The select on the closed channel in
Write did not synchronize with Close, so the race detector flagged
the send racing with the close. It could also have panicked with a
send on a closed channel.

Add a mutex serializing Write and Close. Write now checks for closed
under the lock before doing a non-blocking send, so Close can't close
Inbound mid-send.

Fixes #20541

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I8d8d10250cef0c1931753c78eebff6e8286f7201
2026-07-20 08:25:22 -07:00
Brad FitzpatrickandBrad Fitzpatrick 246c82a658 derp, wgengine: let clients advertise an opaque app name to DERP servers
Add an AppName field to the DERP ClientInfo so DERP servers can
attribute connections to the application making them, primarily for
best effort stats purposes. The value is plumbed per engine instance
rather than via a process global, so a process hosting multiple stacks
can attribute each one's DERP connections separately:
wgengine.Config.DERPAppName flows through magicsock.Options and
derphttp.Client into the naclbox-sealed ClientInfo JSON. Old servers
ignore the unknown field.

There are no callers in the tree yet setting the name.

Updates tailscale/corp#24454

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ia7d3e9c2b6f8140e5a9d7c3b2e6f1a8d4c0b5e9f
2026-07-20 07:33:44 -07:00
Fran Bull 37e175a032 feature/conn25: send ICMP errors back to sources
if we can't find a mapping for the magic IP they're sending traffic to.

Fixes tailscale/corp#34257

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-07-20 07:33:00 -07:00
Fran Bull 8df4816be4 net/packet: generate ICMP destination unreachable packets
Updates tailscale/corp#34257
Signed-off-by: Fran Bull <fran@tailscale.com>
2026-07-20 07:33:00 -07:00
Brad FitzpatrickandBrad Fitzpatrick f65372c9ba net/bakedroots: add LetsEncrypt Generation Y roots (YE, YR)
LetsEncrypt announced its new "Generation Y" root hierarchy on
2025-11-24 and switched its default ACME profile to issue from the new
roots in May 2026. Our baked-in fallback root store only contained the
Generation X roots (ISRG Root X1 and X2), so chains terminating at the
new ISRG Root YE (ECDSA P-384) or ISRG Root YR (RSA 4096) roots failed
to verify when the system roots were also missing them.

Add both new self-signed roots, fetched from
https://letsencrypt.org/certificates/ (valid 2025-09-03 to 2045-09-02).

Also add a live network test, run by default in CI only (or with
--run-live-lets-encrypt-test), that verifies the baked-in roots alone
are sufficient to validate LetsEncrypt's per-root test endpoints for
X1, X2, YE, and YR.

Fixes #20527

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ic69076d8ae5aae08db167095745e92c53357afe3
2026-07-20 07:23:35 -07:00
Brad FitzpatrickandBrad Fitzpatrick 82cfea90ca all: fix JSON serialization under Go 1.27's finalized encoding/json/v2
Go 1.27 enables GOEXPERIMENT=jsonv2 by default: encoding/json is now
backed by the json/v2 machinery, and github.com/go-json-experiment/json
compiles as a thin alias of the standard library's encoding/json/v2.
Several tag options and behaviors we relied on did not make the cut for
the final Go 1.27 API, breaking tailscaled at runtime and four packages'
tests. This change adapts to the final API while keeping the wire format
byte-for-byte identical on all Go versions.

First, the `format` tag option was demoted to experimental. Its mere
presence in a struct tag now makes marshaling and unmarshaling fail at
runtime. tailcfg.SSHAction.SessionDuration had `format:nano` (added in
a2dc517d7 to pin the v1 representation), so on Go 1.27 any netmap
containing an SSH policy failed to decode, breaking every PollNetMap.
Remove the option here and in net/speedtest; time.Duration still
marshals as int64 nanoseconds under encoding/json on all Go versions
(Go 1.27's v1 mode sets FormatDurationAsNano by default), so old
clients and servers are unaffected. Add a regression test locking in
the exact wire format.

Consequently, invert the cmd/vet jsontags rule: it previously required
an explicit `format` tag on time.Duration fields, which is now exactly
wrong. It now rejects any `format` tag option, which would have caught
this bug in CI.

Second, the `inline` tag option was renamed to `embed`. The standard
library silently ignores `inline`, while the pinned go-json-experiment
module (used on Go 1.26) only knows `inline`. Specify both options in
types/prefs and logtail; each implementation ignores the option it does
not know, producing identical output. Drop `inline` once we require
Go 1.27.

Third, encoding/json (v1) now dispatches to MarshalJSONTo and
UnmarshalJSONFrom methods and its v1 options flow into nested
jsonv2.MarshalEncode calls. Types whose v1 methods deliberately
routed through jsonv2 for v2 semantics (types/opt.Value, the
types/prefs preference types) would silently change wire format
(e.g. nil slices becoming null). Pin jsonv2.DefaultOptionsV2 in
their jsonv2 methods so the representation is the same regardless
of the entry point.

Finally, json.Marshal costs one more allocation under Go 1.27,
tripping the types/logger.AsJSON alloc test. Switch its fmt.Formatter
to jsonv2.MarshalWrite with explicit v1 options, which writes directly
to the fmt.State: one allocation on both toolchains with unchanged
output. Depaware files pick up the go-json-experiment/json/v1 options
shim as a new dependency of types/logger.

With this change, go test ./... passes with both Go 1.26.5 and
go1.27rc2.

Updates #20220
Fixes #20528
Fixes #20254

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I694c7d57fd81e55a579c579e9be10032bca569d4
2026-07-19 09:58:50 -07:00
Brad FitzpatrickandBrad Fitzpatrick ece1b12ebf cmd/tsconnect/wasm: don't return non-nil net.Conn interface on dial error
The NetstackDialTCP/UDP hooks returned the result of DialContextTCP/UDP
directly, so on error they returned a non-nil net.Conn interface holding
a nil *gonet.TCPConn or *gonet.UDPConn pointer, tripping up callers that
check the interface against nil and then call Close, crashing the wasm
worker. Apply the same fix that 46bdbb387 made for tailscaled and tsnet.

Fixes #20529

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I4fd66bb7615ee9b2d204256a43288ed7b7a12f35
2026-07-19 06:48:06 -07:00
Brad FitzpatrickandBrad Fitzpatrick 7be0054a7b words: redress historical wrongs against the tuatara
b5a41ff381 originally added both tuatara and mispelled tautara,
one as a tail and one as a scale.

f174ecb6fd added tuatara as a scale, not noticing the tautara
imposter.

Fixes #20522

Change-Id: Ie4fcb262ac705c766f55d406835de419856bb170
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-18 15:34:33 -07:00
Nick RossiandGitHub b91e844014 util/def,cmd/containerboot: add LookupEnv, simplify env parsing (#20277)
Simplifies cmd/containerboot env var parsing. Most of the private helpers did
not earn their abstraction: defaultEnv(name, "") is just os.Getenv(name), and
the rest collapse into cmp.Or and the existing def.Bool. defaultEnv,
defaultEnvs and defaultBool are gone.

Adds def.LookupEnv, the env companion to def.Bool, for the one case that needs
it: TS_KUBE_SECRET, where an explicit "" disables Kubernetes secret storage and
must stay distinct from unset (cmp.Or cannot express that).

Updates #20018

Signed-off-by: Nick Rossi <nrossi0530@gmail.com>
2026-07-17 18:32:52 -07:00
Brendan CreaneandGitHub d2af6a4d39 net/dns/publicdns: don't upgrade Control D port-53-only addresses to DoH (#20463)
DoHEndpointFromIP mapped the entire 2606:1a40::/48 range to a
dns.controld.com/<id> DoH URL, but the ID-encoded addresses in that range
are legacy plaintext-DNS endpoints that refuse :443. They now fall through
as ordinary port-53 resolvers; the free anycast freedns.controld.com/pN
addresses still upgrade via exact match.

Fixes #20433

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-07-17 15:33:17 -07:00
Mike JensenandGitHub 689c6c2e6d ipn/ipnlocal: reject SrcCaps-based packet filter rules for unsigned peers (#20513)
This change ensures `packetFilterPermitsUnlockedNodes` also considers SrcCaps-based grants when checking for unsigned peer access.

Fixes tailscale/corp#45116

Change-Id: I0ac938367888f67ed6f355fc19959cc8c31722a2

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-07-17 15:58:06 -06:00
Brendan CreaneandGitHub bab3f5fce7 wgengine/router/osrouter: remove orphaned tailnet addrs on cleanup (#20304)
* net/tsaddr: unmap IPv4-mapped IPv6 addrs in IsTailscaleIP

IsTailscaleIP branched on ip.Is4() before checking the CGNAT range, so an
IPv4-mapped IPv6 address (e.g. ::ffff:100.64.0.1) took the IPv6 path and was
tested only against the ULA range, wrongly returning false for a Tailscale
CGNAT address. Unmap at the top so both forms are classified identically;
Unmap is cheap and IsTailscaleIPv4 stays IPv4-only for callers that need it.

Signed-off-by: Brendan Creane <bcreane@gmail.com>

* wgengine/router/osrouter: remove orphaned tailnet addrs on cleanup

The orphan-address sweep added in #20199 ran only inside Router.Set, so the
teardown path (tailscaled --cleanup, and the unconditional cleanup at daemon
start) never removed stale Tailscale addresses a previous instance left on a
persistent tailscale0 -- it only flushed iptables/nftables.

Wire address removal into cleanUp: with no desired config, every Tailscale-range
address on the interface is an orphan, so enumerate and delete them all (IPv4
and IPv6, best-effort) in removeOrphanedAddrsForCleanup.

tailscaleInterfaceAddrs now yields the interface's addresses as an
iter.Seq[netip.Prefix], and the filters compose lazily over it: tailscaleAddrs
(every Tailscale-range address; used by cleanup), deletableAddrs (isDeletableAddr:
Tailscale-range and deletable now, i.e. excluding v6 when v6 is unavailable; used
by the live Set sweep), and orphanedAddrs (drops the desired addresses). The Set
sweep ranges the composed iterator directly, so no throwaway slices are built.
delAddress is made idempotent: it attempts both the loopback-rule teardown and
the address delete and joins their errors, so a missing firewall rule can't leak
the address, and it no longer no-ops on v6 (cleanup relies on that to remove v6
orphans even when this process never brought IPv6 up).

The Set-time sweep is otherwise unchanged; re-running it on network changes
(netmon) for late orphans remains a follow-up (tailscale/corp#43882).

Updates #19974
Fixes tailscale/corp#44173

Signed-off-by: Brendan Creane <bcreane@gmail.com>

---------

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-07-17 14:18:09 -07:00
Brad FitzpatrickandBrad Fitzpatrick 0433cc6929 feature/syslog, cmd/tailscaled, logpolicy: add optional --syslog flag
Add a new modular syslog feature providing a tailscaled --syslog flag
that sends the daemon's logs to the system syslog daemon instead of
stderr, which is useful when running as a daemon without a service
manager that captures stderr (e.g. OpenWrt's procd).

The feature package registers two new hooks: one to register its flag
before flag parsing, and one that tailscaled calls early in main to
redirect the standard library's default logger. Because logpolicy later
points the default logger at logtail, whose local console copy writes
to stderr, logpolicy now also consults the hook and sends its console
copy to the same sink (with timestamps disabled, as syslog records its
own).

The feature is linked by default only on Linux, FreeBSD, and OpenBSD,
and can be removed with the ts_omit_syslog build tag. If connecting to
the syslog daemon fails at startup, tailscaled logs a warning and
continues logging to stderr.

Fixes #16270

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I8f3a92d4c1e6b70a5d29e4f61b3c874250a9de13
2026-07-17 13:55:23 -07:00
yaruk-byteandGitHub def265083b tstest/integration: run a smoke test against a Windows tailscaled service (#20382)
* tstest/integration: run a test against a real Windows tailscaled service

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: serialize Windows service tests and clean up state

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: address review feedback on Windows service tests

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: use background context for service teardown

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: run Windows service test via the normal windows CI run

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: address review feedback on Windows service tests

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

---------

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>
2026-07-17 12:29:12 -07:00
Alex ChanandAlex Chan 11a6255b22 scripts/installer.sh: remove an unused PACKAGE_NAME variable
This variable wasn't used in the commit when it was introduced (bd5c509).

Fixes #19841

Change-Id: I82a2ba613c71eb99d98c5e7063e8cd077ba03ece
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-17 20:12:50 +01:00
Claus LensbølandGitHub 9175fe2675 net/tstun: drop TSMP messages injected into TUN (#20511)
Enforce that TSMP messages are only accepted for transmission over the
wireguard connection from within the client.

Updates tailscale/corp#45059

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-07-17 15:10:08 -04:00
Claus LensbølandGitHub 82a381e54b control/controlclient,net/tstun,wgengine/magicsock: fix handling of zero keys in TSMP (#20508)
Updates tailscale/corp#45042

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-07-17 14:02:53 -04:00
Brendan CreaneandGitHub c1edf7f458 wgengine/router/osrouter: sanitize interfaceV6UsableForTun path with os.OpenInRoot (#20505)
interfaceV6UsableForTun interpolates the interface name into a /proc path.
A plain filepath.Join + os.Open only cleans the path, so a tunname with
".." (or a symlinked component) could read outside /proc/sys/net/ipv6/conf.
Open under that fixed directory with os.OpenInRoot, which rejects any path
escaping the root (openat-based, so also TOCTOU-resistant), still using
filepath.Join to build the relative name. See https://go.dev/blog/osroot.

Updates #20447

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-07-17 10:17:06 -07:00
Jordan WhitedandJordan Whited cc0b3ddbbe net/packet: add TSMPType docs
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-07-17 09:56:25 -07:00
Jordan WhitedandJordan Whited 3076698cdf net/packet: fix TSMPType docs
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-07-17 09:28:49 -07:00
Jordan WhitedandJordan Whited 94381a191a disco: fix UDPRelayEndpoint.AddrPorts slice cap math
Fixes tailscale/corp#45066

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-07-17 09:11:12 -07:00
Michael Ben-Amiandmzbenami 7ec9b7ffa3 feature/conn25: preserve TTL on DNS rewrites
We were accidentally hardcoding TTL 0 before.

Fixes tailscale/corp#45025

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-17 10:27:14 -04:00
Brendan CreaneandGitHub cfd101f9d7 configure DNS even when router.Set fails (#20488)
* wgengine: configure DNS even when router.Set fails

Reconfig configured the router first and returned on any router.Set error,
before the DNS block ran. On a host where router config fails on every
reconfig -- e.g. a tun MTU below 1280 that breaks IPv6, or a kernel missing
netfilter features -- the OS resolver was never told about MagicDNS or the
tailnet search domain, so tailnet names failed to resolve with no DNS error
in the logs.

Record the router error and continue instead of returning on it, still
attempt dns.Set, and join the router, DNS, and VPN-reconfigure errors into
the return value. DNS stays after router config (still needed: some DNS
managers refuse to apply settings before the device has an address); only
the error coupling is broken. Fixes a regression from 84430cdfa (v1.8.0).

Updates #20447

Signed-off-by: Brendan Creane <bcreane@gmail.com>

* wgengine/router/osrouter: gate IPv6 on per-interface support, not just global

getV6Available reported IPv6 usable whenever the netfilter runner reported
global IPv6 support, missing the case where the kernel has IPv6 but has not
enabled it on tailscale0 specifically -- e.g. when the tun MTU is below the
1280-byte IPv6 minimum, so /proc/sys/net/ipv6/conf/tailscale0 never exists
and the v6 address and route adds fail, aborting the whole Set. See #20447.

AND a per-interface check into getV6Available, evaluated per call so a later
Set picks up v6 if the interface gains it. All v6-gated operations funnel
through getV6Available, so Set now skips v6 gracefully instead of erroring.
Also remove the dead r.v6Available field that masked this with its global
name.

Updates #20447

Signed-off-by: Brendan Creane <bcreane@gmail.com>

---------

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-07-16 14:53:30 -07:00
Michael Ben-Amiandmzbenami b2de420e3d feature/conn25,types/appctype: serve active Conn25 state over localapi
At /v0/conn25-state.

State includes whether the node is configured for Connectors 2025, as
well as client-specific and connector-specific state, if the node is
acting in those contexts.

Client-specific state includes the reserved Magic IPs and Transit IPs on
the client that have not been returned to their IP pools, and their
associated apps, domains, real destination IPs, and active flow counts.

We also report IP pool utilization: the number of magic and transit IPs
in use versus each pool's capacity, split by IP family.

Connector-specific state includes a peer list of clients that have
registered Transit IPs with the connector, and the apps are real
destination IPs the Transit IPs map to.

Updates tailscale/corp#40125

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-16 16:35:38 -04:00
Mike JensenandGitHub 71e5a98404 Update tailscale/gliderssh to pull in tailscale/gliderssh#12 (#20485)
Updates tailscale/corp#41997

Change-Id: I5fb3d4705766deb71abd0b79a186e99e86be0b15

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-07-16 13:46:23 -06:00
Jordan WhitedandJordan Whited 6a2aa6889e go.mod: bump wireguard-go for priority msg callback
Updates #20081

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-07-16 08:59:34 -07:00
ayanamistandHarry Harpham 50f1c285ba ipn/ipnlocal,cmd/tailscale/cli: support unix socket targets for TCP serve
Allow `tailscale serve --tcp <port> unix:/path/to/socket` and `tailscale serve --tls-terminated-tcp <port> unix:/path/to/socket` to forward TCP connections to a Unix domain socket. Previously only host:port targets were supported for TCP serve mode.

Updates #20161

Signed-off-by: ayanamist <ayanamist@gmail.com>
2026-07-16 09:13:38 -06:00
Alex ChanandAlex Chan 38345dce3d .github: double the timeout for go vet in CI
The job is consistently failing on main when it hits the 5 minute
timeout; let's double it to get useful results.

Updates #cleanup

Change-Id: Iaff2f95d4944929e6832273c94d628f376e2d30e
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-16 16:05:04 +01:00
Mike O'DriscollandGitHub 71b90de0d4 derp/derpserver,cmd/derper: use slices.Clip for cert chain copies (#20484) 2026-07-15 22:13:27 -04:00
Mike O'DriscollandGitHub bf7d815631 derp/derpserver,cmd/derper: don't mutate cert provider's shared tls.Certificate (#20478)
ModifyTLSConfigToAddMetaCert (and its inline copy in cmd/derper) appended
the DERP meta cert directly to the *tls.Certificate returned by the
underlying GetCertificate. autocert returns a certificate sharing a cached
chain slice (and, on the TLS-ALPN token path, the same pointer) across
concurrent handshakes, so the in-place append was a data race and could
grow the served chain unboundedly.

Return a shallow copy with the meta cert appended to a fresh backing
array instead, and have cmd/derper reuse ModifyTLSConfigToAddMetaCert
rather than duplicating the wrapper.

Fixes #20352

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-07-15 17:16:40 -04:00
Brad FitzpatrickandBrad Fitzpatrick bef2cd8088 .github, tstest/natlab/vmtest: replace old VM runner job with natlab tests
The "vm" CI job ran a single test (TestRunUbuntu2404 from
tstest/integration/vms) on a privileged self-hosted runner. Its coverage
is nearly all redundant with the modern natlab vmtest suite, which boots
real Ubuntu VMs and already exercises connectivity, kernel TUN, SSH,
Taildrop, ACME, and OS DNS integration on GitHub-hosted runners.

The two things it tested that natlab didn't are added back as natlab
tests so the runner can be decommissioned:

TestUbuntuSystemdUnit runs tailscaled via the stock systemd unit that
Linux packages ship (cmd/tailscaled/tailscaled.service with
tailscaled.defaults as its EnvironmentFile) instead of launching the
binary directly, verifying the unit's directives and its Type=notify
readiness handshake.

TestDNSExtraRecordsSearchDomains verifies that control-plane DNS
ExtraRecords and search domains are resolvable through the guest's OS
resolver (libc to systemd-resolved to quad-100).

Updates #13038

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I8d0dfb8b8153289e7ca78f3af03dfece9497bfe8
2026-07-15 15:25:14 -04:00
Brad FitzpatrickandBrad Fitzpatrick 6bf05cb63e ipn, ipn/ipnlocal: remove darwin & ios from goosGetsLegacyNetmapNotify
The Apple clients' last consumer of the legacy Notify.NetMap field was
converted to peer deltas in tailscale/corp#44962, so tailscaled no
longer needs to build and emit full netmaps on the IPN bus for darwin
and ios. Windows is now the only remaining platform on the legacy path.

Updates #12542

Change-Id: I295d826735191bb601d2b69d8d85d37a5a82b6c9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-15 14:47:16 -04:00
Brad FitzpatrickandBrad Fitzpatrick 168b20d3b4 ipn/ipnlocal: remove android from goosGetsLegacyNetmapNotify
The android client was converted in https://github.com/tailscale/tailscale-android/pull/797

Updates #12542

Change-Id: Ibb2cc6fbafdad93ae44e1a60e5cc5de8183f9b97
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-15 13:41:08 -04:00
Brad FitzpatrickandBrad Fitzpatrick 65fd320aa6 ipn/ipnlocal: use the live peer map, not the netmap's stale Peers slice
The nodeBackend's netMap.Peers slice is frozen at the last full netmap
install; the live per-peer state lives in the nodeBackend.peers map,
updated by delta mutations. Three spots still read the stale slice or
paid to materialize a fresh one:

AppendMatchingPeers iterated netMap.Peers and re-looked-up each ID in
the peers map (with a lock round-trip per peer), so peers added by a
delta since the last full netmap were invisible to it. That affected
its callers: taildrop's file-target list, exit node suggestions, and
conn25's connector discovery. It now snapshots the peers map directly
(sorted by node ID, matching the old netmap ordering).

DebugPeerDiscoKeys read netMap.Peers and so returned stale disco keys
after deltas. It now reads the peers map via the new
nodeBackend.peerDiscoKeys.

pingPeerAPI called NetMapWithPeers, building and sorting the full
O(n) peer slice, just to linearly scan it for one IP. It now uses the
nodeBackend's existing by-address index and the new O(1) PeerByID
accessor, and passes the peers-free netmap to peerAPIBase, which only
reads the self node's addresses.

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I2e57527d64733b4eb17006f896faaa907b1d128c
2026-07-15 13:29:26 -04:00
Mario MinardiandMario Minardi 0bae201912 tstest/natlab: split SSH test into separate tests
Split TestTailscaleSSH into separate tests per host OS being tested to
allow for potentially running these tests in parallel on different
machines.

Updates https://github.com/tailscale/tailscale/issues/13038

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-07-15 10:53:13 -06:00
Brad FitzpatrickandBrad Fitzpatrick 0fb8226708 gokrazy, tstest, cmd/vnet: switch amd64 kernel to gokrazy/kernel.amd64
The tailscale/gokrazy-kernel module was a fork of rtr7/kernel that
stalled at Linux 6.8.9 (July 2024). All of the kernel config options we
had added in that fork (ENA, Xen for EC2, virtio-mmio for qemu microvm,
virtio RNG, IPv6 policy routing, netlink diag, etc) are now present in
the gokrazy project's own gokrazy/kernel.amd64 module, which tracks
current kernel.org releases (Linux 7.1.3 as of this change) and is the
gokrazy project's supported kernel for x86_64 PCs and VMs.

Switch the tsapp and natlabapp images, the natlab VM tests, and the
CI workflow to gokrazy/kernel.amd64, drop the tailscale/gokrazy-kernel
dependency, and update gokrazy/kernel.arm64 to latest while here.

Verified with TestEasyEasy, TestJustIPv6, and TestTailscaleSSH in
tstest/natlab/vmtest with --run-vm-tests.

Updates #1866

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I90c3765a4e18f5609b4d77b51ac38d17c8e3688a
2026-07-15 11:45:02 -04:00
Brad FitzpatrickandBrad Fitzpatrick 4660a961eb ipn/ipnlocal, wgengine/wgcfg/nmcfg: stop building peer lists on delta path
Processing a peer add/remove delta still materialized the full netmap
(an O(n) slicesx.MapValues plus sort over all peers, at 10k+
peers in a large tailnet) twice per delta: once in UpdateNetmapDelta
purely to hand the self node to Engine.SetSelfNode, and once in
authReconfigLocked.

Neither needs peers anymore. SetSelfNode gets the self node from the
existing nodeBackend.Self accessor. authReconfigLocked only reads
self-node fields (SelfNode, NodeKey, GetAddresses, HasCap) now that
WireGuard peers ride the incremental route manager and per-peer config
source, so it can use the peers-free NetMap accessor.

That also makes nmcfg.WGCfg vestigial: since wgcfg.Config lost its
Peers field, its peer walk existed only to emit the [v1] skip logs
(expired peers, unselected exit nodes, unaccepted subnet routes),
duplicating filtering the route manager already does. Delete the
package and construct the two-field wgcfg.Config inline. The skip
logs go away; if they're missed, the route manager can log them
incrementally at upsert time instead of rescanning every peer on
every reconfig.

With this, the runtime.DidRange analysis (see the ts_rangehook test)
shows a delta netmap update performing no O(n) range loops except
updateRouteManagerExtras, and the delta phase of that test drops from
1.09s to 0.14s for 400 deltas at n=10000 (from 4.79s at the
start of this effort, before the incremental route manager work).

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ia0e03ef9db0c988790b2c29de1f0505305e93f58
2026-07-15 11:21:36 -04:00
Brad FitzpatrickandBrad Fitzpatrick 3515b009c2 ipn/ipnext, ipn/ipnlocal, feature/conn25: pass peer seq to AllowedIPs hook
The ExtraWireGuardAllowedIPs hook was called once per peer on every
authReconfig, so each netmap delta paid an O(n) scan over all peers
even when conn25 (the only implementer) wasn't configured and every
call returned nothing.

Invert the API: the hook now receives an iter.Seq2 of the current
peers and returns the extra prefixes keyed by node ID. An idle
extension returns nil without iterating, so the unconfigured case
does no per-peer work at all.

With this, the runtime.DidRange analysis (see the ts_rangehook test)
no longer reports the updateRouteManagerExtras peer scan on netmap
deltas.

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9181e77416fa22f4c904620d42e9bcb934165216
2026-07-15 10:12:58 -04:00
coyaSONGandHarry Harpham f68e4d93fd cmd/tailscale/cli: fix plain TCP serve status
Do not label plain TCP forwarding as TLS over TCP. Render status
annotations only when TLS termination or PROXY protocol is configured.

Fixes #20367

Change-Id: I3f6507365ceedc2950451810e9715afb85176fc5
Signed-off-by: coyaSONG <66289470+coyaSONG@users.noreply.github.com>
2026-07-15 08:01:37 -06:00
Kristoffer DalbyandKristoffer Dalby a534ad5e86 gokrazy/build: expose AMI pipeline as composable steps
Make CheckAWSAuth, UploadToS3, ImportSnapshot, and RegisterAMI public so
a build server can run them individually (e.g. Marketplace publishing
after RegisterAMI). Rename BuildAMI to BuildAndImportAMI, now a thin
orchestrator over them. Each step records into Result and returns its
artifact; the AWS steps guard on their predecessor and error clearly if
called out of order.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-15 15:15:17 +02:00
Kristoffer DalbyandKristoffer Dalby 720cd0a2d0 all: regenerate dep manifests for aws-sdk-go-v2/service/ec2
Generated by make updatedeps and ./tool/go run ./tool/updateflakes
after adding service/ec2 (and the smithy-go bump it pulls in).

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-15 15:15:17 +02:00
Kristoffer DalbyandKristoffer Dalby a7f3e08335 gokrazy/build: use AWS SDK instead of shelling out to aws CLI
Replace the four aws CLI shell-outs (s3 cp, ec2 import-snapshot,
describe-import-snapshot-tasks, register-image) with aws-sdk-go-v2 S3
and EC2 clients. Credentials come from the SDK default chain, so
existing aws sso login / aws configure / AWS_PROFILE / env / aws-vault
sessions keep working.

Verify auth via sts:GetCallerIdentity before the slow image build so a
logged-out user fails in seconds, not minutes. Upload via the S3
manager (concurrent multipart) with a native progress reader, log
[n/4] step lines, and bail on terminal import-snapshot failure states
instead of polling forever.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-15 15:15:17 +02:00
Kristoffer DalbyandKristoffer Dalby b049ce71a5 gokrazy/build: show import-snapshot progress, quiet in CI
Capture the AWS Progress/StatusMessage fields and report them instead of
reprinting the full describe-import-snapshot-tasks JSON every 5s. On a
terminal, repaint one live percentage line; otherwise log one line per
phase change so CI stays terse.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-15 15:15:17 +02:00
Kristoffer DalbyandKristoffer Dalby 7653a1e438 gokrazy: split build.go into thin main + reusable build package
Move the appliance/AMI build logic into tailscale.com/gokrazy/build so
Go callers (e.g. flash-appliance) can call it directly instead of
driving build.go over --json. build.go is now a thin flag wrapper; flags
and --json output are unchanged. Package-level state becomes a Builder
with an exported Config, ctx-first steps, and a Build one-shot.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-15 15:15:17 +02:00
Brad FitzpatrickandBrad Fitzpatrick bb4f458207 feature/captiveportal: move captive portal code out of ipnlocal, netcheck
Captive portal detection was half-migrated: it had a build tag and
buildfeatures constant, but its code still lived in build-tag-gated
files in ipn/ipnlocal and net/netcheck, with its per-backend state
(context, cancel func, signaling channel) as fields on LocalBackend.

Move it under feature/captiveportal. The health-driven detection loop
becomes an ipnext.Extension holding its own state: it starts and
stops the loop from the BackendStateChange hook and subscribes to
health.Change events on the eventbus itself, removing the captive
portal hooks and special cases from LocalBackend entirely. The DERP
map now comes from a new ipnext.NodeBackend.DERPMap method, and the
preferred DERP region from magicsock's last netcheck report (the
same underlying source as the previously used Hostinfo.NetInfo).

The netcheck probe hook is now exported with a signature free of
netcheck internals, and its implementation moves to the small
feature/captiveportal/netcheckhook package, which installs the hook
as an import side effect. That package stays free of tsd/wgengine
dependencies so the tailscale CLI can keep probing for captive
portals in "tailscale netcheck" without linking the daemon-side
extension. The net/captivedetection library itself is unchanged and
stays put; after this change it is only linked when something pulls
in netcheckhook or the feature extension.

tailscaled links the feature by default via condregister as before,
but tsnet no longer does (shrinking tsnet, k8s-operator, and tsidp);
tsnet users who want it can blank-import the feature package, and
tsnet's dep test now locks that in.

Updates #12614

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3f1d09f9dc03e18f9a648ab5e42d16fa540b3fa9
2026-07-14 20:22:23 -04:00
Brad FitzpatrickandBrad Fitzpatrick 72ca0cae4b wgengine/wgcfg,wgengine,ipn/ipnlocal: remove Peers from wgcfg.Config
The wireguard-go device now learns its peer set solely from the live
per-peer config source that LocalBackend installs with
Engine.SetPeerConfigFunc, backed by the route manager. Peers are
created lazily on first packet and converged per peer with
Engine.SyncDevicePeer, so the full-peer-list snapshot in wgcfg.Config
and the diff-and-reconfigure machinery around it (wgcfg.Peer,
ReconfigDevice, and the engine's full device sync in
maybeReconfigWireguardLocked) are dead weight: they duplicated state
that the route manager already owns and forced every netmap change to
rebuild and rehash the entire peer list.

Delete the Peers field and the Peer type from wgcfg, along with
ReconfigDevice and maybeReconfigWireguardLocked. Engine.Reconfig no
longer does any device peer work; it only manages the private key,
addresses, and the non-peer subsystems. Full-netmap application converges the device by
syncing exactly the peers whose routes the route manager reports as
changed or removed.

Updates #12542

Change-Id: Ic776e42cfaa5be6b9329b3d381d5cbde17d7078b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 19:57:59 -04:00
Brad FitzpatrickandBrad Fitzpatrick 87c0d36942 net/routemanager,ipn/ipnlocal: name the changed-allowed-IPs map type
The map[key.NodePublic][]netip.Prefix that flows from route manager
commits to WireGuard device syncs has subtle semantics (a nil value
means the peer was removed or no longer contributes any prefixes)
that were documented on routemanager.Result.AllowedIPs and then
re-documented, or not, at each signature that passed it along. Give
it a named type, PeersWithRouteChanges, and document the nil
semantics once on it.

Updates #12542

Change-Id: I2566361a5331eb11b2b70a5bcdb497cc20ee561d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 19:57:59 -04:00
Brad FitzpatrickandBrad Fitzpatrick f0ce89b715 net/tstun,wgengine,ipn/ipnlocal: make tstun's peerConfigTable use RouteManager table
Previously tstun.Wrapper.SetWGConfig walked wgcfg.Config.Peers on every
netmap to rebuild its own IP-to-peer table for masquerade NAT rewrites
and jailed-peer classification. Now the tun layer instead consumes the
route manager's shared immutable outbound snapshot directly, via a new
Engine.SetPeerRoutes method: LocalBackend pushes the snapshot (plus this
node's native Tailscale addresses) after every route manager commit that
can change it, and per-packet lookups read the interned PeerRoute
attributes from that table.

When no current peer is jailed or masqueraded, LocalBackend installs a
nil table (gated on RouteManager.HasDataPlaneAttrs), preserving the
per-packet nil-check fast path. The exitNodeRequiresMasq machinery is
deleted: its purpose was populating the table with all peers so that
more-specific entries shadow an exit node's /0, and the always-full
route manager table gives that shadowing inherently.

This is the last step before removing the Peers field from wgcfg.Config.

Updates #12542

Change-Id: Ifce09ca929a3f2511303ca1d6efdd583739494ce
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 12:41:11 -07:00
Mario MinardiandMario Minardi e4144230f4 util/osuser: reject leading dashes in usernames
Reject leading dashes in usernames and add double dash to getent call
on linux to prevent values sent as usernames being interpreted as
command options.

Fixes https://github.com/tailscale/corp/issues/44813

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-07-14 12:59:07 -06:00
Brad FitzpatrickandBrad Fitzpatrick 9d01b036c7 tstest/natlab/vmtest: test jailed and masqueraded peers end-to-end
The tun-layer per-peer data plane (jailed packet filter selection and
masquerade NAT rewrites) had no end-to-end coverage: nothing asserted
that a peer the control server marks jailed actually has its flows
dropped, or that masquerade addresses assigned by control actually
carry rewritten traffic in both directions.

Add a two-gokrazy-node natlab test driving both knobs from the test
control server and probing with HTTP requests between the nodes'
webservers after each netmap transition, asserting each response
carries the serving node's greeting so masqueraded flows provably
reach the intended node. TSMP pings are deliberately not used as
probes: tstun answers those before running the packet filter, so
they succeed even for jailed peers.

Updates #12542

Change-Id: Ia978e8d368f08a5a1117280f12bd50310969d0ec
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 11:39:56 -07:00
Brad FitzpatrickandBrad Fitzpatrick 0c4dcbdfeb ipn/ipnlocal: replace magicDNSAddrs bool params with a flags type
Two adjacent bool arguments at call sites are easy to transpose and
hard to read. Use an unexported bitmask type instead, per review
feedback on PR #20414.

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I0417270c9c3f4b2522911c39aad375cbaf025a82
2026-07-14 10:59:00 -07:00
Brad FitzpatrickandBrad Fitzpatrick 3d07b5d6e1 wgengine,cmd/tailscaled,feature/bird: move BIRD integration to ./feature
The BIRD (BGP) integration previously lived half in cmd/tailscaled
(which created a chirp client via a build-tag-gated file on some
platforms) and half in wgengine (which carried the client in its
Config and toggled the "tailscale" protocol as the node gained or
lost primary subnet router duty).

Move it all to a new feature/bird package, installed on the engine
via the new wgengine.HookNewBird hook, like other feature/* packages.
wgengine.Config.BIRDClient (and the wgengine.BIRDClient interface)
are replaced by a BIRDSocket path from which the engine constructs
the feature's Bird handle at startup. The subnet router overlap
detection and protocol toggling move into feature/bird, preserving
the previous ordering: state is recomputed before Reconfig's
ErrNoChanges early return and applied after the router is configured.

tailscaled keeps BIRD support by default on the platforms that
previously had it (linux, darwin, freebsd, openbsd) via
feature/condregister.

Also, add an integration test, as this feature lacked much test
coverage previously.

Updates #12614

Change-Id: I7866a50779e454c87933b358735f7dcd9e2b126f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 10:48:09 -07:00
Mike JensenandGitHub 9bd62683dd go.mod: revert update vulnerable dependencies (#20435) (#20456)
This reverts commit 468a7f4973 on request to @ChaosInTheCRD

Although passing all our CI checks, @ChaosInTheCRD would like to plan manual testing as part of incorporating these updates.

Updates #cleanup

Change-Id: I3f007b571b884c9538a97ac5d3ded782bcba2347

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-07-14 10:26:32 -06:00
Michael Ben-Amiandmzbenami c9b5a918ce feature/conn25: add client metrics for dns response rewrite errors and remove
noisy logs

Remove most logs in mapDNSResponse() that could potentially be spammed by
a misbehaving or abusive DNS client or resolver.

Keep logs, and complement with metrics, for failed rewrites, as they
likely point to an internal error, e.g. ip pools exhausted. Metrics
allow for potential alerting in the future.

Updates tailscale/corp#40125
Updates tailscale/corp#40126

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-14 15:31:33 +00:00
Mike JensenandGitHub 468a7f4973 go.mod: update vulnerable dependencies (#20435)
This change updates vulnerable dependencies with a direct fix path. Updated:
  * github.com/prometheus/prometheus@v0.311.3 - Direct dependency addressing https://pkg.go.dev/vuln/GO-2026-5710 and https://pkg.go.dev/vuln/GO-2026-5662
  * github.com/go-openapi/swag@v0.27.0 - Needed to fix mutual dependency on github.com/go-openapi/testify after prometheus update
  * github.com/go-git/go-git/v5@v5.19.1 - Addresses https://pkg.go.dev/vuln/GO-2026-5496
  * helm.sh/helm/v3@v3.21.1 - Root update to address most containerd CVEs
  * github.com/containerd/containerd@v1.7.33 - Addresses remaining container CVEs, in total: https://pkg.go.dev/vuln/GO-2026-5758 https://pkg.go.dev/vuln/GO-2026-5475 https://pkg.go.dev/vuln/GO-2026-5378
  * sigs.k8s.io/controller-runtime updated to v0.23.3 - This is needed to accommodate the k8s.io/api v0.35.3 update (test change needed for update)

Vulnerabilities were discovered from govulncheck, which includes reachability in the analysis.

Updates #cleanup

Change-Id: I8345745d22a7e6ee106b58c410889e0aef748be4

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-07-14 08:29:50 -06:00
Mario MinardiandMario Minardi 8bc4c09a46 tstest/natlab: add FreeBSD test case for SSH test
Add logic to generateFreeBSDUserData to allow for an SSH connection as
root for FreeBSD.

Add FreeBSD test cases to ssh_test that exercise the same paths as the
existing Ubuntu tests but for BSD.

Updates https://github.com/tailscale/corp/issues/44813
Updates https://github.com/tailscale/tailscale/issues/13038

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-07-14 08:28:48 -06:00
Brad FitzpatrickandBrad Fitzpatrick 7e609b2581 ipn/ipnlocal,net/dns/resolver: serve MagicDNS names from live indexes
Every netmap change, including an incremental delta of a single peer,
rebuilt the full MagicDNS state twice: dnsConfigForNetmap walked all
peers to build the dns.Config.Hosts map, and resolver.SetConfig then
walked that map again to build its reverse (PTR) index. On a tailnet
with 10k peers that is a lot of garbage per delta.

Instead, add a resolver.MagicDNSHosts hook, installed once by
LocalBackend, that the quad-100 resolver consults on demand at query
time. It is backed by nodeBackend's nodeByName, nodeByAddr, and peers
indexes, which are already maintained incrementally as netmap deltas
arrive. The subdomain-resolve capability check also moves to the hook
(checking the node's CapMap at query time), so dns.Config's
SubdomainHosts is no longer populated.

dns.Config.Hosts remains for control's DNS.ExtraRecords, which are
few and which feed the split-DNS decisions in dns.Manager's
compileConfig, and on Windows it still carries every node's records
because the hosts-file fallback path (compileHostEntries) needs the
complete enumerable set. Those compileConfig decisions also consulted
the per-node Hosts entries (hasHostsWithoutSplitDNSRoutes), so a new
Config.MagicDNSHostsUnrouted bit preserves that signal now that node
records are not listed: with MagicDNS names present but MagicDNS
domain routing off, quad-100 stays in the OS resolver path.

One small behavior change: reverse (PTR) lookups now also answer for
node addresses whose forward records are filtered out by the
IPv6-suppression rule (issue #1152), since nodeByAddr indexes all node
addresses. Previously such addresses were absent from the pushed
Hosts map and thus from the reverse index.

Updates #12542
Updates tailscale/corp#43949

Change-Id: I63b99199c2b3b124c08cb8bbaea1f63165095294
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 07:26:41 -07:00
Brad FitzpatrickandBrad Fitzpatrick 6a635c4e55 ipn/ipnlocal: route extra WireGuard AllowedIPs through the route manager
The conn25 extension's ExtraWireGuardAllowedIPs hook (Transit IPs)
was only appended to wgcfg.Config.Peers in authReconfig. Now that
outbound peer selection comes from the route manager's outbound
table via the engine's PeerByIPPacketFunc (which, when installed,
replaces wireguard-go's AllowedIPs trie lookup entirely) and lazily
created peers get their allowed IPs from the route manager via the
engine's peer config func, those extras never reached either path:
outbound packets to Transit IPs matched no peer, and lazily created
peers didn't accept inbound Transit IP sources.

Teach the route manager a per-peer set of extra allowed IPs, staged
by Mutation.SetExtraAllowedIPs. They appear in the outbound table
and in PeerAllowedIPs (so both outbound routing and per-peer allowed
source prefixes see them) but are excluded from the OS route set,
preserving the hook's contract that the extras reach WireGuard but
not the OS routing table. authReconfig now feeds the hook's results
into the route manager and incrementally syncs any changed peers to
the WireGuard device; the append to cfg.Peers remains only so Reconfig's
full per-peer device sync doesn't strip the extras from active
peers, and goes away with Config.Peers.

Updates #12542

Change-Id: I06c8fa30929fbf8fe171a2d34c47c6fcc3abfa16
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 07:11:34 -07:00
Brad FitzpatrickandBrad Fitzpatrick aff605d163 ipn/ipnlocal,wgengine: move disco-key change detection to nodeBackend
Engine.Reconfig previously diffed cfg.Peers disco keys against the
previous config to find restarted peers and flush their WireGuard
sessions, with a TSMP-learned-key map to suppress resets for key
changes that arrived over a working session. That was the last
per-peer state computed from wgcfg.Config.Peers inside the engine,
and it only ran on full reconfigs, so incremental netmap deltas
never got session resets at all.

Move the detection into nodeBackend, which sees every peer change:
full netmaps in SetNetMap and incremental upserts in
UpdateNetmapDelta both now report which peers changed disco keys,
with the same TSMP suppression and mismatch accounting as before.
LocalBackend acts on the result via a new Engine.ResetDevicePeer
method, which just removes the peer from the WireGuard device and
lets the peer lookup func lazily re-create it with fresh state.

LocalBackend.PatchDiscoKey now records TSMP-learned keys in
nodeBackend instead of forwarding to the engine, so the engine's
PatchDiscoKey method and tsmpLearnedDisco map are gone. The
controlclient patchDiscoKeyer interface becomes the exported
DiscoKeyUpdater so LocalBackend can compile-time assert that it
implements it, alongside its NetmapDeltaUpdater friends, replacing
the test that asserted the same of the engine.

This is one of the last steps toward removing Peers from wgcfg.Config.

Updates #12542

Change-Id: I6b42e460f42924816beae89ca43731cb91b66054
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-14 06:32:35 -07:00
Kristoffer DalbyandKristoffer Dalby 911c5e58ed gokrazy: add --json output, --region pinning, git-derived AMI names
Make build.go drivable and consumable by a CI builder. --json prints one
machine-readable result line to stdout while all logs/progress go to stderr,
so scripts can capture data cleanly. --region (honoring $AWS_REGION, default
us-east-1) pins import+register deterministically. AMI names derive from git:
<app>-<tag> on a tagged commit, else <app>-<describe>-<unixtime>.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-14 14:51:20 +02:00
Tom ProctorandGitHub 7e62ead76e cmd/testwrapper: add a max retry time across all failures (#20453)
We've occasionally seen CI jobs retry broken commits for a long time
because we only implement a budget per test. Add a cap to ensure we
never spend an unreasonable amount of time on retries.

Updates tailscale/corp#43604

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2026-07-14 13:44:21 +01:00
BeckyPauleyandGitHub 6ee7bcb458 cmd/{k8s-operator,containerboot,kube}: support IPv6 in egress ProxyGroup (#19898)
* 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>
2026-07-14 13:26:40 +01:00
Tom MeadowsandGitHub 9711883a2f kube/certs: honour Retry-After and skip escalation on transient errors (#20376)
Rate-limit responses from the CA now use the Retry-After hint (via
client/local.RateLimitRetryAfter) instead of walking the local retry
schedule.

Failures that never reached the CA -- context deadline/cancel,
ECONNREFUSED, ECONNRESET, EHOSTUNREACH, EPIPE, and net.Error
timeouts -- retry at retrySchedule[0] without advancing retryCount.

Updates tailscale/corp#42164

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-07-14 13:22:01 +01:00
Tom MeadowsandGitHub 236564af75 cmd/k8s-operator: reorder Ingress cleanup so cert loop stops before VIPService delete (#20426)
The cert loop only stops when the domain leaves the ServeConfig.
Deleting the VIPService first left the loop hammering ACME for a
domain the control plane no longer recognised, burning retry slots.

Reorder to: remove from serve config, unadvertise, delete VIPService,
clean cert resources.

Updates #20288

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-07-14 12:11:56 +01:00
Kristoffer DalbyandKristoffer Dalby 58fcaaf9a5 ipn/conf: support RemoteConfig in the config file
Add ConfigVAlpha.RemoteConfig so a user-data/cloud-init config can
delegate remote control to the tailnet admin (see Prefs.RemoteConfig).

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-14 10:29:19 +02:00
Kristoffer DalbyandKristoffer Dalby f9cd687180 flake.nix: add awscli2 for building the appliance AMI
Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-14 10:29:19 +02:00
Kristoffer DalbyandKristoffer Dalby e212b075a7 gokrazy: register appliance AMIs as HVM
register-image defaults to paravirtual: arm64 rejects it outright and
amd64 won't boot on Nitro. Force HVM; pick UEFI boot mode per arch.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-14 10:29:19 +02:00
Kristoffer DalbyandKristoffer Dalby 5e0972344a gokrazy: read config from EC2 user-data on the appliance
One AMI now self-configures from user-data or, when absent, enrolls over
serial.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-14 10:29:19 +02:00
Kristoffer DalbyandKristoffer Dalby 758c28fd41 cmd/tailscaled: allow "optional:" prefix on -config
optional:vm:user-data boots unconfigured when the source is absent
instead of failing; an invalid config still fails.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-14 10:29:19 +02:00
Kristoffer DalbyandKristoffer Dalby d9aeaa504b ipn/conffile: add ErrNoConfig for absent config sources
Load wraps read-phase failures with it so callers can distinguish a
missing config from an invalid one.

Updates #1866

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-07-14 10:29:19 +02:00
Brad FitzpatrickandBrad Fitzpatrick 318807bdb9 ipn/ipnlocal: derive the OS routes from the route manager
routerConfigLocked previously computed router.Config.Routes with
peerRoutes, a from-scratch pass over cfg.Peers on every reconfig.
The route manager already maintains the same set incrementally (ULA
and CGNAT coarsening included) and updateRouteManagerPrefs runs
earlier in authReconfig, so its OS route set is current by the time
the router config is built. Read it from there instead, and delete
peerRoutes and its tests; the routemanager package tests cover the
same behavior. This removes a consumer of cfg.Peers, which is on
its way out.

Updates #12542

Change-Id: I4a5b7a63d530e3fe1b70f0faf3f49def6a10be2e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-13 19:18:52 -07:00
Brendan CreaneandGitHub 8eb18d902e net/dns/publicdns: use Control D anycast IPs for premium DoH (#20434)
For dns.controld.com premium resolvers we synthesized per-resolver IPv6
addresses by encoding the resolver ID into the 2606:1a40::/48 range, but
those are legacy plaintext-DNS (port 53) endpoints that refuse TCP :443.
DoH now dials Control D's shared anycast IPs (the resolver ID stays in the
URL path), fixing SERVFAIL on IPv6-only/NAT64 networks where the v4
anycast fallback isn't reachable.

Fixes #20430

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-07-13 16:47:23 -07:00
Brad FitzpatrickandBrad Fitzpatrick 55b1a4de74 net/netcheck: don't mutate Client.TimeNow in AddReportHistoryForTest
AddReportHistoryForTest temporarily swapped out Client.TimeNow without
holding any lock, racing with concurrent GetReport calls reading it
from ReSTUN goroutines during tests. Instead, pass the current time to
addReportHistoryAndSetPreferredDERP explicitly so the test helper never
needs to touch the field.

Fixes #20438

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9c4f0a2f9427b5f1d3e8b06a49f0d2b71c3ee8a4
2026-07-13 15:20:36 -07:00
KevinLiang10andGitHub b803ba048c ipn/ipnlocal: update getServeHandler path handling on malformed url (#20431)
This commit updates the path matching logic in getServeHandler for malformed
request targets like "*" (e.g. "GET *") and "" (e.g. "CONNECT" authority-form).
Those paths never reduce to "/" as absolute path would. An absolute path check
was added and an additional check on no further reduce was added in the loop.

Fixes tailscale/corp#44814

Signed-off-by: kevinliang10 <kevinliang@tailscale.com>
2026-07-13 15:15:44 -07:00
Brad FitzpatrickandBrad Fitzpatrick 9cb1147805 wgengine,ipn/ipnlocal,tsnet,cmd/tailscaled: remove PeerForIP from the Engine interface
Engine.PeerForIP was pure delegation to the callback that LocalBackend
installs via SetPeerForIPFunc, so external callers going through the
engine were taking a pointless round trip: LocalBackend called
b.e.PeerForIP, which called right back into LocalBackend, and the
netstack UseNetstackForIP hooks in tsnet and tailscaled did the same
dance one layer removed.

Export LocalBackend.PeerForIP and make those callers use it directly.
The engine-internal cold paths (Ping, TSMP disco advertisements,
pendopen diagnostics) still need the lookup and have no netmap of
their own, so SetPeerForIPFunc stays on the interface, but the lookup
method itself is now unexported and gone from the Engine interface.
In tailscaled the UseNetstackForIP hook moves from netstack setup to
just after the LocalBackend is created, since the backend doesn't
exist yet when netstack is wired up.

Updates #12542

Change-Id: Ib1e1a4fa5c84ee0dcb9ce5d1910047f2bab9453c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-13 15:15:16 -07:00
Brad FitzpatrickandBrad Fitzpatrick c436dec43c tstest, cmd/tta: add Tailscale SSH end-to-end VM test
Add TestTailscaleSSH to tstest/natlab/vmtest, exercising the Tailscale
SSH server (tailscale up --ssh, not a system sshd) end to end: an
Ubuntu client node SSHes over the tailnet into an Ubuntu server node
as both root and a non-root user, and into a gokrazy node.

The gokrazy sessions exercise the gokrazy special cases in the SSH
code: util/osuser hard-codes the login shell to serial-busybox ash and
synthesizes a root user when lookup fails (so any username works and
becomes root, unlike Ubuntu where nonexistent users are rejected), and
the incubator's findSU refuses su on gokrazy, handling sessions
in-process.

To support this, testcontrol gains an SSHPolicy field that's sent in
MapResponses along with the CapabilitySSH node capability, tta's /up
handler accepts an ssh=true parameter, and vmtest gains a
TailscaleSSH node option that wires the two together with a
permissive any-principal policy.

Updates tailscale/corp#44813
Updates #13038

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3f6b9c41a72e05d8c94dd7f6ab1937cf24b81c92
2026-07-13 15:04:51 -07:00
Brad FitzpatrickandBrad Fitzpatrick 2506ede862 wgengine,ipn/ipnlocal: remove Engine.PeerKeyForIP and the engine's peer route table
The engine kept its own longest-prefix-match table (peerByIPRoute),
rebuilt from the full peer list on every reconfig, to route outbound
packets and answer PeerKeyForIP. That's now the route manager's job:
LocalBackend already installs a PeerByIPPacketFunc backed by the
RouteManager's incrementally-maintained outbound table, so the
engine's copy was redundant state with redundant O(n peers) rebuild
work.

Delete the table, the PeerKeyForIP interface method, and the BART-only
default callback. LocalBackend's peerForIP now queries the
RouteManager's outbound table directly for the subnet-route and
exit-node fallback. Engines running without a LocalBackend (such as
wgengine/bench) must install their own outbound peer lookup, since the
device's standard AllowedIPs trie only covers peers that already
exist and can't lazily create them.

Updates #12542

Change-Id: I25100399e273ed6c2bb1f6136b7cd81bc83e7313
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-13 14:38:55 -07:00
Brad FitzpatrickandBrad Fitzpatrick 3c800dcd71 .github/workflows: fix natlab test discovery with fully-excluded files
The Discover tests step runs under set -euo pipefail, so when every
Test function in a file is filtered out by the exclude regex (as with
vnetperf_test.go, whose TestVnetPerf* tests need special invocation),
the final grep -vE produces no output and exits 1, failing the whole
step. Tolerate empty results from both greps so such files simply
contribute no matrix entries. Same for a hypothetical future test
file containing only helpers and no Test functions.

Updates tailscale/corp#44805

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: If20c9a1de37e97b1553bd7d5559e2b876a45c19b
2026-07-13 13:35:13 -07:00
Brad FitzpatrickandBrad Fitzpatrick f831469c27 wgengine,ipn/ipnlocal: sync wireguard-go peers incrementally on netmap deltas
Previously, any peer added or removed by an incremental netmap delta
was only visible to wireguard-go after a full authReconfig: wgcfg's
ReconfigDevice re-installed a PeerLookupFunc closing over a freshly
built map of every peer's allowed IPs, doing O(n) work per change.

Instead, install the wireguard-go device hooks once, backed by live
state. Engine.SetPeerConfigFunc installs a single long-lived
PeerLookupFunc that queries LocalBackend's per-node RouteManager on
demand, and Engine.SyncDevicePeer does O(1) per-peer device sync
(remove, or update allowed IPs) as each delta mutation is applied.
Full reconfigs keep an O(n peers) device sync for now, but with no
lookup closure to reinstall and no removed-peer resurrection race; a
later change removes full-config peer syncing entirely.

The RouteManager's PeerAllowedIPs accessor backs the new hooks: its
sorted output makes unchanged state a no-op update, and its peer
filtering mirrors nmcfg.WGCfg, so expired peers and peers predating
both DERP and disco contribute no prefixes and thus cannot be lazily
created in the device, which matters because wireguard-go validates
inbound source IPs against per-peer allowed IPs.

The engine's SetPeerByIPPacketFunc callback is now authoritative when
installed, since LocalBackend's implementation covers subnet routes
and exit-node routes via the RouteManager's outbound table; the
engine's own reconfig-time BART table only serves engines running
without a LocalBackend.

The forced authReconfig on peer add/remove stays for now: the
WireGuard device no longer needs it, but OS routes, the quad-100
resolver's MagicDNS hosts map, and tstun's masquerade/jailed peer
config are still derived from the full peer set. Making those
delta-aware is the next step before gating it.

Updates #12542

Change-Id: I3ba8c7c324bca0ad0269279d03f53b1f17fb63a2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-13 13:35:13 -07:00
Brad FitzpatrickandBrad Fitzpatrick ff1c7ef23c ipn/ipnlocal,net/routemanager: keep a routemanager.RouteManager updated per node
Give nodeBackend a RouteManager and keep it in sync as routing
inputs change: full netmaps resync the whole peer set (removals plus
no-op-cheap upserts), incremental netmap deltas mirror their peer
upserts and removes into the same mutation batch, and
authReconfigLocked pushes the routing-relevant prefs (exit node,
subnet route acceptance, OneCGNAT) after resolving the exit node's
stable ID to its current numeric node ID.

A selected exit node that doesn't resolve to a current peer (a
nonexistent node, or MDM's "auto:any" placeholder awaiting
resolution) is not the same as no exit node: per the long-standing
ipn.Prefs.ExitNodeID contract, it blackholes internet traffic rather
than letting it escape to the local network. RouteManager's Prefs
gains an ExitNodeSelected bit so its OS route set keeps the default
routes in that case, with no outbound peer to carry them, matching
what routerConfigLocked does today, as pinned by TestRouterConfigExitNodeBlackhole in the previous commit.

All mutations happen with nodeBackend.mu held, satisfying the
RouteManager's serialized Begin/Commit contract.

Nothing consumes its snapshots yet; the wgengine data plane and OS
router wiring come next.

Updates #12542

Change-Id: I677b6b2c9efb8e41b3d27071bd9db73e01640d3b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-13 10:20:54 -07:00
Brad FitzpatrickandBrad Fitzpatrick ce050f1ca1 ipn/ipnlocal: test the unresolved-exit-node blackhole routes
Selecting an exit node that doesn't resolve to any current peer (a
nonexistent node, or MDM's "auto:any" placeholder before it is
resolved) installs the blackhole default routes, so internet traffic
is dropped rather than escaping to the local network. That behavior
is documented on ipn.Prefs.ExitNodeID and five-plus years old, but
nothing tested it. Lock it in ahead of an upcoming change that moves
OS route computation to net/routemanager and must preserve it.

Updates #12542

Change-Id: I0f63b0d5ce46061a74c69b75f7f83f115da7c3d4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-13 09:04:17 -07:00
Brad FitzpatrickandBrad Fitzpatrick 18a95394df ipn/ipnlocal: shut down old control client before starting new one
LocalBackend.Start previously shut down the previous control client in
a goroutine, letting it run concurrently with the new one. An in-flight
lite map update carrying stale Hostinfo.RequestTags could then be
processed by the control plane after the new client had already changed
the node's tags. Control treats such a request as an invalid tag
transition and expires the node key to force a reauth, so retagging a
node with "tailscale up --advertise-tags" intermittently logged the
machine out.

Instead, detach the old client under b.mu and shut it down
synchronously with the lock released, before creating the new client.
Shutdown cancels the old client's in-flight requests and waits for its
goroutines to exit, so the cancellation of any stale update reaches the
server before the new client sends its first request. Per the deadlock
history in #18052, Shutdown must not be called with b.mu held; this
uses the same pattern as DisconnectControl.

Also teach the testcontrol server to model the control plane's tag
transition handling (including expiring the node key on an invalid
transition and ignoring updates from canceled requests), add an
integration test reproducing the race, and add an ipnlocal test
verifying that Start waits for the old client to shut down.

Updates #20365
Updates #18052

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: If8c8e145bdadcef1b1b8fe6209453cf5f5a8d616
2026-07-13 08:28:23 -07:00
Fernando SerbonciniandGitHub 505330d09f Revert "go.mod: Update vulnerable dependencies (#20388)" (#20420)
This reverts commit ca9f6971e5.

The dependency updates broke the K8s E2E tests. Reverting so the
updates can be re-landed with the tests passing.

flake.nix, shell.nix, and flakehashes.json were regenerated with
tool/updateflakes rather than reverted, since a later commit
(6fdffd9e5) also updated them for the gowebdav bump.

Change-Id: Id4afd7788d305a674841168e2a66a0009212ffd3

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-07-13 10:56:05 -04:00
Brad FitzpatrickandBrad Fitzpatrick 125fd88c30 tstest/natlab: fix vnet TCP throughput collapse to slow guests
FreeBSD 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
2026-07-13 06:21:45 -07:00
Brad FitzpatrickandBrad Fitzpatrick 296f6c1f78 ipn/ipnlocal: index peers by stable node ID
PeerByStableID did an O(n peers) scan, and an upcoming change needs
the same StableNodeID-to-NodeID resolution whenever prefs change (to
resolve the selected exit node for the route manager, which keys
peers by NodeID because that is the identity netmap delta mutations
carry). Maintain a nodeByStableID index alongside the existing
nodeByAddr and nodeByKey indexes, updated on full netmaps and on
delta mutations.

Updates #12542

Change-Id: Id1e5105a7470b02312533f0f46b69e6945cd62f0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-11 12:12:37 -07:00
Adriano Sela AvilesandAdriano Sela Aviles d69bf2685a all: apply go fix
Updates #cleanup

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-07-10 17:39:16 -07:00
Brad FitzpatrickandBrad Fitzpatrick a5102d3fcb net/routemanager: add incremental route manager
Add a new RouteManager type that tracks per-peer self addresses and
advertised routes and incrementally maintains two read-only
snapshots: an IP-to-outbound-peer bart table carrying the per-peer
attributes the data plane needs (jailed state, masquerade addresses),
and a coarsened OS route set (including OneCGNAT consolidation).

Mutations are staged in a transaction (Begin/Commit) and applied to
the snapshots via bart's Persist methods, which path-copy only the
few trie nodes along the affected prefix, so a single-peer delta
costs a bounded amount of work independent of the number of peers,
instead of the O(n) full-world rebuild done today. Snapshots are
published via atomic pointer swap for lock-free reads on the hot
path, and Commit reports which peers' allowed IPs changed so callers
can sync wireguard-go incrementally. This is the same immutable value
snapshot pattern as used in the recent containerboot change,
364b952d62.

Nothing uses it yet; this is pulled out of a future change that wires
it into ipnlocal and wgengine, to make that PR smaller.

Updates #12542

Change-Id: Iccc5258024e6f90311835b79fd2d83b2adb0d09d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-10 16:57:09 -07:00
James TuckerandJames Tucker 045c979802 docs: add CLI evolution guidelines
The guidelines here provide a written version of common guidance around
our CLI evolution that designers/implementors should consider as they
propose/implement new or evolving CLI surfaces.

Updates #engdocs

Change-Id: Idcbc0900a4fda98bd2b29ac8bbc26dc1cb1be48f
Signed-off-by: James Tucker <james@tailscale.com>
2026-07-10 14:55:33 -07:00
Adriano Sela AvilesandAdriano Sela Aviles 66a51c426f cmd: apply go fix
Updates #cleanup

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-07-10 14:26:11 -07:00
Aaron Klotz 2b62cb54a7 net/dns, util/winutil: improve detection of group policy affecting NRPT
Due to a customer issue, I investigated the Windows Dnscache service more
intensively. I learned that the only time it attempts to read the NRPT
from group policy is in response to a group policy change notification.

Under the hypothesis that policy refresh is not effectively delivering GP
notifications due to its dependency on reaching a DC, I replaced our use
of the RefreshPolicyEx with the quasi-documented GenerateGPNotification API.

Tests have been updated to ensure they check that they are running as
LocalSystem, which is required for GenerateGPNotification.

Fixes #20187

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2026-07-10 13:53:26 -06:00
KevinLiang10andGitHub a68be19739 wgengine/netstack: reject unserved ports on Service (VIP) IPs (#20363)
A connection to a Tailscale Service IP on a port the service does
not serve was forwarded to the underlying host. `acceptTCP` fell through to
the isTailscaleIP case (a VIP is in the Tailscale IP range), which rewrote
the dial target to 127.0.0.1:<port> and forwardTCP'd the connection onto
whatever unrelated listener happened to be on the host's loopback at that
port.

This is reachable through the service IP by any peer which was granted
access only to the service (dst: svc:foo), so it exposes host ports the
peer has no ACL access to via the machine's regular IP. This happens
when there tailscaled has a Tun interface and the forward bits are set.

In this commit, we added a guard in acceptTCP, before the isTailscaleIP case
that RSTs connections to a VIP service IP on a port with no serve handler.
Served ports return earlier via TCPHandlerForDst, so only unserved ports reach the guard.
Layer 3 services are unaffected: their traffic is released to the host in
injectInbound and never reaches acceptTCP.

Fixes #20362

Signed-off-by: kevinliang10 <kevinliang@tailscale.com>
2026-07-10 14:06:15 -04:00
Brad FitzpatrickandBrad Fitzpatrick 7771ce4e58 wgengine/magicsock: delete Conn.UpdatePeers, derive peer state internally
[This commit is pulled out of a branch that ultimately removes the
wgcfg.Config.Peers field and removes all O(n peers) processing when
handling deltas]

magicsock.Conn.UpdatePeers existed so wgengine.Reconfig could tell
magicsock the set of WireGuard peers from cfg.Peers, used only to
garbage collect the derpRoute and peerLastDerp maps and to ReSTUN when
the first peers appear. magicsock already learns the full peer list
directly from LocalBackend via SetNetworkMap, UpsertPeer, and
RemovePeer, so do that bookkeeping there and delete the API and its
cfg.Peers use.

Updates #12542

Change-Id: Id07551fc1950239f08a73a9ab02d69ce78d0de0c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-10 10:47:44 -07:00
Andrew LytvynovandGitHub 3872880617 cmd/cloner: handle named slices as map values (#20387)
Previously cloner only handled literal slices for values, like
`map[string][]int`. This adds support for named types with an underlying
type of slice, like `map[string]IntSlice` with `type IntSlice []int`.

Updates tailscale/corp#44077

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-07-10 08:56:49 -07:00
Brad FitzpatrickandBrad Fitzpatrick 0e79b322a9 tsweb/varz: add node_boot_time_seconds expvar
Export the machine's boot time (the btime line from Linux's
/proc/stat) as node_boot_time_seconds, named to match what
Prometheus's node exporter uses for the same value. Combined with
process_start_unix_time, this can be used to distinguish process
restarts from whole node restarts.

The value is parsed once per process lifetime, not per scrape, and
the metric is only published when a value is available, so non-Linux
systems don't export a bogus zero.

Updates tailscale/corp#44743

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I5f53186b97bb1482bd1a5387c0910b0ae26544ff
2026-07-10 08:47:59 -07:00
scientificworldandBrad Fitzpatrick 16f600df8c ipn/conf: add ConfigVAlpha.AdvertiseExitNode
Fixes #19941

Change-Id: I69e63a8036f50cfee2ed770a88f92ce344412f4d
Signed-off-by: scientificworld <scientificworld@users.noreply.github.com>
2026-07-10 07:24:03 -07:00
Brad FitzpatrickandBrad Fitzpatrick 6fdffd9e5e go.mod: bump github.com/studio-b12/gowebdav
For https://github.com/studio-b12/gowebdav/pull/87

Fixes #20295

Change-Id: I8ae6ff6969c84fcd510f0e15e0487fbfe9f7c821
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-10 06:12:20 -07:00
Brad FitzpatrickandBrad Fitzpatrick b3d0ebcca3 ipn/ipnlocal: only send AllowsUpdate if clientupdate feature is linked in
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: I526ef11f2a4141f5fce161b1f77263324014b5c4
2026-07-09 19:08:05 -07:00
Brad FitzpatrickandBrad Fitzpatrick ac84eb4900 ipn/ipnlocal: pass self node view to reconfigAppConnectorLocked
The netmap.NetworkMap type is deprecated and going away, and
reconfigAppConnectorLocked only needed its SelfNode field anyway.
Take a tailcfg.NodeView instead and check its validity in place of
the old nil netmap check.

Updates #12542

Change-Id: Id617845b67416404500cca438ce4ac0372cd8a8e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-09 15:04:59 -07:00
Mike JensenandGitHub ca9f6971e5 go.mod: Update vulnerable dependencies (#20388)
This change updates vulnerable dependencies with a direct fix path. Updated:
  * github.com/prometheus/prometheus@v0.311.3 - Direct dependency addressing https://pkg.go.dev/vuln/GO-2026-5710 and https://pkg.go.dev/vuln/GO-2026-5662
  * github.com/go-openapi/swag@v0.27.0 - Needed to fix mutal dependency on github.com/go-openapi/testify after prometheus update
  * github.com/go-git/go-git/v5@v5.19.1 - Addresses https://pkg.go.dev/vuln/GO-2026-5496
  * helm.sh/helm/v3@v3.21.1 - Root update to address most containerd CVEs
  * github.com/containerd/containerd@v1.7.33 - Addresses remaining container CVEs, in total: https://pkg.go.dev/vuln/GO-2026-5758 https://pkg.go.dev/vuln/GO-2026-5475 https://pkg.go.dev/vuln/GO-2026-5378

Updates #cleanup

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-07-09 15:52:34 -06:00
Brad FitzpatrickandBrad Fitzpatrick 7965d496a6 feature: add README explaining the modular feature system
We had an internal Google doc about this (Tailscalars:
http://go/clientmod) but that doesn't help open source contributors or
agents.

So move the docs to git.

Updates #12614

Change-Id: I0b0e9f0286b23b4fb1b51ff3d41eba75edf62cdf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-09 13:30:47 -07:00
Brad FitzpatrickandBrad Fitzpatrick 692f84df8d wgengine,wgcfg,feature/netlog: move network flow logging behind a feature hook
wgcfg.Config.NetworkLogging carried the network flow logging identity
inside the WireGuard config, where it was unrelated to WireGuard; it
lived there mainly so that identity changes would defeat Reconfig's
ErrNoChanges check and reach the netlog startup/shutdown logic.

Remove the field and move the whole netlog lifecycle into a new
feature/netlog package, installed on the engine via the new
wgengine.HookNewNetLogger hook, like other feature/* packages. The
logging identity now comes from LocalBackend's current netmap via the
widened NetLogSource interface (replacing Engine.SetNetLogNodeSource),
so nmcfg no longer parses audit log IDs into the config. The engine
still calls the hook before its ErrNoChanges return and before
router.Set (to capture initial packets), and again after router.Set
(to capture final packets), preserving the previous ordering.

Core wgengine no longer imports wgengine/netlog, so minimal builds
drop it entirely. tailscaled keeps netlog via feature/condregister,
and tsnet imports feature/condregister/netlog explicitly to keep
netlog enabled by default in tsnet-based binaries (tsidp,
k8s-operator).

This is pulled out of a future change that removes wgcfg.Config.Peers,
to make that PR smaller.

Updates #12542
Updates #12614

Change-Id: I41ca7dfe43c51e977c41b5f8e934bd1f0e6e6e24
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-09 12:56:37 -07:00
Brad FitzpatrickandBrad Fitzpatrick b7de1753b7 wgengine/wgcfg: remove unused Config DNS and MTU fields
Nothing uses them. DNS and MTU are handled elsewhere.

This is pulled out of a future change that removes wgcfg.Config.Peers,
to make that PR smaller.

Updates #12542

Change-Id: I2ec8ae38dc6cce08bcc44e6c1f9177311202af89
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-09 10:05:59 -07:00
Tom MeadowsandGitHub 69ee776dfb feature/acme: lock ACME per-domain instead of globally (#20303)
The extension's acmeMu was a single lock around getCertPEM. Any
in-flight ACME flow blocked every other domain. With many domains
(ProxyGroup ingress) the queue would back up and per-call timeouts
started firing while we were just waiting on the lock -- the cert
loop treated that as a failure.

Replace with one mutex per domain. Different domains run at the
same time. Same domain still queues so the first run fills the
cache and the rest read from it.

The old global lock also kept ACME account setup safe by accident.
Two goroutines could both find no account key, both generate one,
both write -- last one wins on disk but each carries on with its
own. Add acmeAccountMu around acmeKey and ensureACMEAccount to
keep that path single-file. Otherwise two first-time issuances for
different domains end up with separate accounts at LE.

Updates #20288
Updates tailscale/corp#42164

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-07-09 15:06:17 +02:00
Patrick O'DohertyandBrad Fitzpatrick 70244f40e0 go.mod: bump Go to 1.26.5
Bump the Go toolchain to 1.26.5.

Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-07-08 17:15:35 -07:00
License UpdaterandWill Norris 63efd06933 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-07-08 11:14:50 -07:00
BeckyPauleyandGitHub 384e776dfa cmd/k8s-operator: ensure EndpointSlices exist on every egress reconcile (#20347)
EndpointSlices were created in provision(), which was called only if certain
fields on the ExternalName Service had changed. If an EndpointSlice was
deleted, it was never re-created (because the owning Service had not
changed).

Move EndpointSlice provisioning after this gated provision step so that it
runs on every reconcile.

Fixes #20322

Change-Id: I416fb5e4b40f2029efb97aa6ca7ceb3e31b0d52d

Signed-off-by: Becky Pauley <becky@tailscale.com>
2026-07-08 16:39:20 +01:00
Tom MeadowsandGitHub 87b3d7b7e5 ipn/localapi,client/local: honour Retry-After on cert rate-limit (#20315)
* ipn/localapi,ipnlocal,feature/acme,client/local: honour Retry-After on cert rate-limit

serveCert now responds with 429 + Retry-After when the underlying ACME
error is a rate limit, instead of a generic 500. client/local surfaces
this as a typed RateLimitedError with the parsed hint so callers can
back off intelligently.

Updates tailscale/corp#42164

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

* tsweb,feature/acme,ipn/localapi,ipnlocal: generalise cert error → HTTP mapping via tsweb.HTTPStatuser

Introduces a tsweb.HTTPStatuser interface, any error can implement
to describe its intended HTTP response (code, message, headers).
Moves CertRateLimitedError from ipnlocal to feature/acme where it's
constructed, and it now uses HTTPStatuser to return 429 + Retry-After.

serveCert now checks for tsweb.HTTPStatuser rather than the specific
error type, so it no longer needs to know about the ACME rate-limit
type.

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

---------

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-07-08 13:34:40 +01:00
SalehandGitHub 9106b237eb cmd/tailscale/cli: fix nil dereference in configure kubeconfig (#20324)
PeerStatus.AllowedIPs is only populated when a peer has allowed IPs, so
it is nil for peers whose backing nodes are offline or not yet approved,
such as a kube-apiserver ProxyGroup with no healthy nodes. When the
argument to "tailscale configure kubeconfig" resolved to a Tailscale
Service ExtraRecord, nodeOrServiceDNSNameFromArg iterated AllowedIPs of
every peer without a nil check and panicked with SIGSEGV.

Skip peers with no AllowedIPs so the command reports the existing "is in
MagicDNS, but is not currently reachable on any known peer" error
instead of crashing.

Fixes #20255

Signed-off-by: Salih Muhammed <root@lr0.org>
2026-07-08 10:39:54 +01:00
Brad FitzpatrickandBrad Fitzpatrick 887005d255 cmd/tailscale: add 'configure pve-appliance' to make Proxmox VM of appliance
This is a variant of "tailscale configure flash-appliance" but for running
on Proxmox PVE hosts to make a Proxmox VM running the experimental
Tailscale Appliance.

This also makes the "Esc" key make the fbstatus GUI open up a terminal,
instead of Control-Alt-F2 which is hard to type over NoVNC.

And make gafpush unidirectional, to not require a local port be opened locally,
which I hit while working on this.

And make fbstatus included in all appliance variants, but bail out early
and stop respawing if the machine has no framebuffer (e.g. AWS VMs).

Updates #1866

Change-Id: I18ec2a16e4d5ff5574e16fe55c0e8d06cf4fab7f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-07 13:12:13 -07:00
Brad FitzpatrickandBrad Fitzpatrick c1ae2bb1f8 cmd/tailscale, ipn, feature/remoteconfig: add remote-config support
Add 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>
2026-07-07 12:10:34 -07:00
Adel-AyoubandBrad Fitzpatrick 2051c5f358 wgengine,util/execqueue: wait for in-flight linkChange before closing
ExecQueue.Shutdown does not wait for a function that is already
executing, so Close could tear down magicConn, dns, wgdev, and tundev
while a queued linkChange was still using them, panicking during
shutdown. Add ExecQueue.ShutdownAndWait, which discards queued
functions that have not started and waits for the in-flight one, and
use it in Close with a bounded context before tearing anything down.
The eventbus client is closed first and is the queue's only producer,
so no new work can arrive after the drain.

Updates #17641

Change-Id: I0350bcb59c1ee4b0dcac88cf66b93828466c8c98
Signed-off-by: Adel-Ayoub <adelayoub.maaziz@gmail.com>
2026-07-07 06:01:08 -07:00
Alex ChanandAlex Chan 3d52c3f03e all: fix more typos caused by unnecessary repetition
Updates #cleanup

Change-Id: I5c0b8f0152581231252ab97dd1820d8b3fcbe450
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-06 10:36:17 +01:00
Alex ValiushkoandGitHub 943b97e2f3 wgengine/magicsock: skip sendDiscoPingsLocked when TS_DEBUG_NEVER_DIRECT_UDP (#20298)
Fixes #20101

Change-Id: I09dd8b6527857d4d05ed01ac3ac4183b6a6a6964
Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
2026-07-03 10:14:26 -07:00
Alex ChanandAlex Chan b838d5caf7 all: fix typos where we repeat repeat ourselves
Found with the regex `\b([A-Za-z]+) \1\b`.

Updates #cleanup

Change-Id: I4cc51784d9b6437d3d0c66b531828707f87f7fd5
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-03 16:09:03 +01:00
Alex ChanandAlex Chan 72c22667b8 util/winutil: fix a typo where we repeat we repeat ourselves
Found with the regex `\b([A-Za-z]+ [A-Za-z]+) \1\b`.

Updates #cleanup

Change-Id: If52c32e700cb2f9f97f2e1c812d48d788a758c51
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-07-03 10:40:37 +01:00
Simon LawandSimon Law be16cc0d3d cmd/tailscale/cli/jsonoutput: extract routecheck’s output format
In PR #19641, we added the `tailscale routecheck` command that
supports both `--format=json` and `--format=json-line`. To allow other
packages to import and unmarshal that JSON structure, this patch
exports that format in a new tsroutecheckjsonv0 package.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
Simon LawandSimon Law 5cbe32e0bc cmd/tailscale/cli/jsonoutput: add support for --format=json
`tailscale netcheck` is the only command that doesn’t support the
`--json` flag, but rather requires `--format=json`. This patch adds a
flag.Value named jsonoutput.Format that handles a boolean `--json`
flag, a versioned `--json=2` flag, and an optional
`--format=json-line` flag.

Updates #17613
Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
Simon LawandSimon Law ca91eafce5 cmd/tailscale/cli: add tailscale exit-node suggest --force-probe
Add a new `--force-probe` flag to `tailscale exit-node suggest` that
waits for a routecheck.Refresh to finish before suggesting an exit
node.

This flag is currently hidden from the help text, but this flag is a
hint to the user that exit-node suggestions are based on routecheck
reachability reports.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
Simon LawandSimon Law 932260511e ipn/ipnlocal: use routecheck reports to make exit node suggestions
Now that the routecheck subsystem is continuously collecting
reachability reports in the background, we can add a hook to
LocalBackend for fetching its report. That allows
suggestExitNodeUsingTrafficSteering to consult that report when
disqualifying candidates, instead of blocking on an immediate probe.

Exit node suggestions will only consult the report when the
`client-side-reachability` and `client-side-reachability-routecheck`
node attributes are both set on the current node.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
Simon LawandSimon Law cb7e536804 feature/routecheck,ipn/routecheck: probe reachability in the background
Previously, refreshing the routecheck.Client would probe to generate a
new routecheck.Report, but this method was only wired up to the
LocalAPI and the `tailscale routecheck` command. However, waiting for
a probe to finish before choosing a router would take too long, so we
must keep a regularly updated report to be consulted as necessary.

This patch adds a Start and Close method to the routecheck.Client and
starts it in the background from features/routecheck. To enable this
feature for a given node, set both of the following node attributes:
`client-side-reachability` and `client-side-reachability-routecheck`.

This patch also wires up the RouterTracker.OnRoutersChange hook, which
fires a callback whenever a new network map includes information about
a router node, This signals to the routecheck.Client that it might
need to schedule another probe, if the shape of the routing table has
changed materially.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
d8ee47d1cf ipn/ipnext, feature/routecheck: subscribe routecheck to the IPN bus
This patch adds a new ipnext.NotifyWatcher interface that exposes
ipn.LocalBackend.WatchNotifications so that extensions inside
tailscaled can subscribe to the IPN bus, much like how the GUI
clients subscribe to it through the Local API.

This interface is used by the new feature/routecheck.RouterTracker to
watch for changes in the peer map that affect routers. RouterTracker
uses dead reckoning to incrementally maintain the set of routers. We
do this to avoid looping over the peer map repeatedly. See #17366.

RouterTracker supports two hooks:

- OnNetMapAvailable signals that the initial netmap has been received,
  so that the routecheck.Client can wake up goroutines that are
  waiting for it.

- OnRoutersChange signals that the set of routers has changed, so that
  the routecheck.Client can decide to probe a subset of the routers
  instead of all of them. Currently, this optimization hasn’t been
  implemented yet.

Updates #17366
Updates #20062
Updates tailscale/corp#33033

Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
Simon LawandSimon Law 8d830599b1 ipn/ipnstate,tailcfg: define IsRouter for PeerStatus and Node
Add consistent definitions and tests so that watchers of the IPN bus
can keep track of routers when listening for NotifyInitialStatus and
NotifyPeerChanges.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
Simon LawandSimon Law 7b2432abae net/routecheck: tweak routecheck documentation and coding style
This patch is a follow-up for PR #19639 that does some cleanups.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-07-02 20:26:27 -07:00
Brad FitzpatrickandBrad Fitzpatrick 52fdadbf8b wgengine/netstack: accept IPv4 fragments before reassembly
The netstack GRO receive path validates L4 checksums before marking
packets as RX checksum validated for gVisor. That validation is invalid
for IPv4 fragments because TCP and UDP checksums cover the complete
reassembled transport packet, not an individual fragment.

Keep validating the IPv4 header checksum, but let IPv4 fragments through
to gVisor for reassembly without pre-validating TCP or UDP.

Fixes #20320

Change-Id: I779363a5e0ac5abee6a8e2a2a44b418fbc5f5e27
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-02 14:44:00 -07:00
License UpdaterandWill Norris 74235b46c1 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-07-02 14:12:45 -07:00
Maisem AliandBrad Fitzpatrick 9013b6ec1b net/dns: simplify split DNS compile path
Cache the OS split-DNS capability while compiling DNS config and return directly for split-capable platforms that do not need the Apple base-config workaround.

This removes the base-config sentinel pointer and keeps the iOS and sandboxed macOS fallback path explicit.

Updates #1338

Change-Id: I836417c8fa775b35d3be9bc80cf6841d30cec222

Signed-off-by: Maisem Ali <maisem@bold.dev>
2026-07-02 12:21:54 -07:00
Maisem AliandBrad Fitzpatrick 10672a63f4 net/dns: support global resolvers in macOS tailscaled
The macOS tailscaled DNS configurator only wrote /etc/resolver files, which can express split DNS but not a primary resolver. Teach it to configure a global resolver through the SystemConfiguration dynamic store using scutil when OSConfig has nameservers and no match domains.

Let non-sandboxed macOS tailscaled follow Linux split DNS behavior when its OS configurator reports split DNS support. This avoids synthesizing an upstream default route from the machine's base DNS when the netmap did not provide one. Keep iOS and sandboxed macOS app builds on the existing Apple base-config path because those use NetworkExtension DNS settings rather than tailscaled's /etc/resolver configurator.

Add tests for switching between split and global DNS, including cleanup of stale Tailscale-managed resolver files, removal of the dynamic-store global DNS key, and preservation of the sandboxed macOS behavior.

RELNOTE: tailscaled on macOS now supports configuring global DNS resolvers.

Updates #1338

Change-Id: I9b2b61f89750a5529fc0add1cd37b1b9a355db12

Signed-off-by: Maisem Ali <maisem@bold.dev>
2026-07-02 12:21:54 -07:00
Fran Bull b727675a8b feature/conn25: allow ICMP packets
Allow packets with ICMPv4 or ICMPv6 proto to use the flow table and get
NATted.

Fixes tailscale/corp#40123

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-07-02 10:31:31 -07:00
Brad FitzpatrickandBrad Fitzpatrick 76eece2d15 gokrazy, Makefile: improve appliance build tooling
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>
2026-07-02 09:24:01 -07:00
Brad FitzpatrickandBrad Fitzpatrick 4c22d22df5 cmd/fbstatus: add framebuffer status display for the Tailscale appliance
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>
2026-07-02 09:24:01 -07:00
Adriano Sela AvilesandAdriano Sela Aviles 6ab63e9a85 cmd/tailscale/cli: add service list command
Updates #20166

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-07-02 08:38:11 -07:00
Brad FitzpatrickandBrad Fitzpatrick c57b917ac1 gokrazy/gafpush, Makefile: add OTA push tool for appliance development
Adds gokrazy/gafpush and a 'make tsapp-push-pi PI=<ip>' Makefile
target for pushing a freshly-built GAF to a running appliance over
the network. See the gokrazy/gafpush package doc for details.

Updates #1866

Change-Id: Id7a0bad712fcf2eddae593f71d5feacee05c5234
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-02 07:01:43 -07:00
Brad FitzpatrickandBrad Fitzpatrick f96db5e383 go.mod: update ts-gokrazy for local dev workflow
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>
2026-07-01 15:43:35 -07:00
Nick KhylandNick Khyl df40abc610 ipn/ipnlocal: fix reporting of active ipnext extensions
We borked this in 30a89ad378
and started including skipped extensions (e.g., conn25 when
TAILSCALE_USE_WIP_CODE != 1) in the list of active ones.

This doesn't have any impact other than on logging, though.

Updates #cleanup

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2026-07-01 15:35:06 -07:00
Brad FitzpatrickandBrad Fitzpatrick a8f3c861a4 util/progresstracking: add Ticker, NewWriter, and CountingWriter
Add 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>
2026-07-01 10:02:05 -07:00
Brad FitzpatrickandBrad Fitzpatrick d0fcb668d5 cmd/tailscale/cli: add 'tailscale configure flash-appliance'
Adds a CLI subcommand that downloads a signed Tailscale appliance
image (Gokrazy archive format, GAF) from pkgs.tailscale.com,
constructs a fresh GPT-partitioned disk from it (mbr.img + a
synthesized partition table + boot.img + root.img), formats /perm
as ext4 in pure Go via go-diskfs, and ejects the disk so a user
running on a regular workstation can flash an SD card or homelab
VM disk in one command without installing e2fsprogs.

On macOS the target disk is auto-discovered via diskutil, skipping
the boot disk and anything bigger than 256 GB out of paranoia. On
Linux the user passes --disk=/dev/sdX explicitly. Windows is not
supported yet and the command returns an error.

The GPT layout matches monogok's full-disk layout via the new
public github.com/bradfitz/monogok/disklayout package; a drift-
guard test inside monogok asserts the two implementations stay
byte-identical so OTA updates against monogok-built images keep
working.

Behind a ts_omit_flashappliance build tag (on by default).

Updates #1866

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ic1a8cd185e7039edccb7702ab4104544fcb58d29
2026-07-01 08:09:50 -07:00
Tom MeadowsandGitHub 64422f274d kube/certs: use Let's Encrypt's recommended retry schedule (#20292)
Replace the doubling backoff (1m, 2m, 4m, ...) with LE's recommended
1m, 10m, 100m, daily. The old schedule burned retry attempts inside
the rate-limit window without speeding recovery.

Updates #20288
Updates #19895

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-07-01 11:50:54 +01:00
Fran BullandMichael Ben-Ami 85d8644215 feature/conn25: keep mappings with active flows
Conn25 hands out dummy IP addresses for use in the connector flow from
limited address pools. When the addresses are no longer in use we expire
the corresponding entry from our table of address mappings and return
the addresses to their pools for reuse.

We currently expire addresses after the DNS TTL for the DNS response
that caused the mappings to be created.

Stop expiring mappings when there are active packet flows for the
addresses in the mappings.

Fixes tailscale/corp#43180

Co-authored-by: Fran Bull <fran@tailscale.com>
Co-authored-by: Michael Ben-Ami <mzb@tailscale.com>
Signed-off-by: Fran Bull <fran@tailscale.com>
Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-06-30 13:56:21 -07:00
Fran BullandMichael Ben-Ami b228748a22 feature/conn25: return expired addrs from index lookups
We can use them for traffic until they are actually removed from the
table.

Updates tailscale/corp#43180

Co-authored-by: Fran Bull <fran@tailscale.com>
Co-authored-by: Michael Ben-Ami <mzb@tailscale.com>
Signed-off-by: Fran Bull <fran@tailscale.com>
Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-06-30 13:56:21 -07:00
Brad FitzpatrickandBrad Fitzpatrick b6e17df646 cmd/tailscaled, util/syspolicy: add JSON syspolicy file support
Tailscaled had no way to seed device-scope syspolicy settings short of
environment variables or a custom store wired up out of tree. Add a
--syspolicy-file flag whose default points at a well-known JSON file
that, when present, is parsed as a map[string]any and registered as a
device-scope policy source. The default path is
/etc/tailscale/syspolicy.json on every non-Windows platform (Linux, the
BSDs, illumos/Solaris, and tailscaled-without-the-GUI on macOS) and
%ProgramData%\Tailscale\syspolicy.json on Windows. The flag lets users
running tailscaled by hand (development, custom installs) point it at
an alternate file, and "" disables the load entirely.

JSON values map to setting types as expected: strings to
StringValue/PreferenceOptionValue/VisibilityValue/DurationValue (e.g.
"24h" parsed by time.ParseDuration), booleans to BooleanValue, numbers
to IntegerValue, and string arrays to StringListValue. The file is
validated against the registered setting definitions at load time so
unknown keys and value/type mismatches fail startup loudly rather than
producing surprising defaults at first read.

When HuJSON support is linked into the build (default; opt out with
ts_omit_hujsonconf), the file may use HuJSON (comments, trailing
commas). With ts_omit_hujsonconf it must be pure standard JSON. This
mirrors the pattern used by ipn/conffile.

On Windows the JSON file and the existing HKLM registry store both
register at DeviceScope. rsop merges later-registered same-scope
sources over earlier ones, so per-key values in the file override the
registry while keys absent from the file fall back to the registry.

The loader is registered via a feature.Hook from a file gated by
!ts_omit_syspolicy, and called from main after flag parsing. tsnet
still does not depend on the root syspolicy package, so embedders
don't pick this up implicitly.

Fixes #20305

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ie6326461c14efb226979ac162998a9c6373ce493
2026-06-30 13:10:16 -07:00
kari-tsandGitHub 07cefc083d ipn/{ipn,ipnlocal}: add per-user policy snapshots to IPN bus (#20135)
This adds the NotifyInitialPolicy watch option and the Policy field in
Notify so that clients can receive the effective policy snapshot via IPN
bus.

This extends policyclient.Client so ipnlocal can get and watch policy
snapshots, which is used by sysPolicyChanged to notify watchers.

User-scoped policy store registration, management, and cleanup will be
added in a follow-up

Updates tailscale/corp#42259

Signed-off-by: kari <kari@tailscale.com>
2026-06-30 12:44:29 -07:00
Brad FitzpatrickandBrad Fitzpatrick fad8b9b8a9 clientupdate, cmd/tailscale: verify signed GAFs, wire up tailscale update for Gokrazy
Builds on top of the unsigned URL-based GAF update flow added previously
(see referenced issue for context). The pkgs.tailscale.com server now
publishes signed GAFs for the unstable track, with detached ed25519
signatures produced by pkgsign's signdist path (the same distsign scheme
used for every other release artifact). This change consumes them.

The URL-based path (tailscale update --gokrazy-update-from-url=URL) now
verifies the signature by default using clientupdate/distsign.Client,
which fetches distsign.pub from the root of the host serving the GAF and
checks the .sig against the root keys embedded in this binary. The
--unsigned flag stays for TestGokrazyUpdatesItselfToSameImage, whose
in-test fileserver does not publish distsign.pub.

The bare tailscale update path is now wired up for the Tailscale
appliance image. It fetches <pkgs>/<track>/?mode=json, picks the GAF
whose key matches the local device (vm-amd64, vm-arm64, or pi-arm64,
where arm64 is split via /sys/firmware/devicetree/base/model), confirms
the version with the user, and reuses the verified download path above.

To avoid wiping a user's custom Gokrazy build that happens to include
tailscaled, the bare update path is gated on hostinfo.Package == "tsapp",
which is only set when the new ts_appliance build tag is present
(mirroring the existing ts_package_container tag). The
gokrazy/tsapp*/config.json files now pass GoBuildTags ["ts_appliance"]
for the tailscale and tailscaled packages so monogok bakes the tag into
the official appliance builds. The TS_FORCE_ALLOW_TSAPP_UPDATE env var
is an escape hatch for callers who want to force the appliance update
path on a non-appliance build. The URL-based path stays ungated since it
requires explicit user intent (and is exercised by the natlab vmtest).

Updates #20002

Change-Id: I7c7856a88bf3dffb9eb8d3e9111fad0b3906743c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-30 07:09:25 -07:00
Brad FitzpatrickandBrad Fitzpatrick 66af25733c tstest/natlab/vmtest, client/web: add web client integration tests
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: Idb01486a89b53ac02c6ad3358bcfcceca90dbc36
2026-06-30 06:55:12 -07:00
Will HannahandGitHub 8b5060faf5 ipn/ipnlocal: sort profiles by date created when possible (#20223)
This adds a Created field to LoginProfile to normalize the sort order
of login profiles presented in the various client GUIs. The default
sort order for existing profiles remains unchanged and continues to be
based on Name. Newly added profiles will be stamped at creation time
and returned at the top of the list of unstamped profiles, sorted by
creation date in descending order.

The rationale is to ensure that all clients present the user's profile
list in the same order, regardless of newly added accounts, name
changes, or nickname overrides.

The Mac client was recently updated to remove various custom profile
sorting behaviors (https://github.com/tailscale/corp/pull/43847).
iOS, Android, and Windows do not currently perform GUI-level sorting,
so this change should propagate to them seamlessly.

updates tailscale/corp#43843

Signed-off-by: Will Hannah <willh@tailscale.com>
2026-06-30 09:01:32 -04:00
Tom MeadowsandGitHub ec6e598550 kube/certs: widen runCertLoop per-call timeout to 30m (#20289)
All issuances serialise through a single mutex in tailscaled. The old
300s timeout fired while a predecessor was legitimately mid-ACME,
causing the queued loop to advance retryCount on a non-failure. 30m
covers ~15 queued flows and works as a wedge detector against true
hangs.

Updates #20288
Updates #42164

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-06-30 13:39:50 +01:00
Adrian DewhurstandAdrian Dewhurst 477d5a43df ipn/ipnlocal, feature/conn25: add hook for accepting PeerAPI DNS
Currently, 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>
2026-06-29 16:33:33 -04:00
Brad FitzpatrickandBrad Fitzpatrick 1c77079fd7 ipn/ipnlocal, feature/acme: move most remaining cert code into feature/acme
f5eac39ea ("feature/acme, ipn/ipnlocal: start moving ACME/cert state
into an extension") started to move the cert code into feature/acme
but was meant as a baby step.

This goes further, moving almost everything, leaving only some hooks
in ipnlocal.

When we later move "serve" support out to feature/serve, this will
look a bit different in that the hooks currently in ipnlocal will move
to feature/serve (cert support already depends on serve).

As part of this, cert-related tests move to feaure/acme too, which
means some test infra from ipnlocal now moves to shared ipnlocaltest.
(it's not big at the moment, but I imagine it growing)

Updates #12614

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9ea89aa9754f12d54b81751b6bd830f2664241ff
2026-06-29 12:57:22 -07:00
Simon LawandGitHub 825b7c479f wgengine/magicsock: fix data race in TestNetworkSendErrors (#20261)
`TestNetworkSendErrors/network-down` causes a data race because it
tried to `tstest.Replace` the `checkNetworkDownDuringTests` global
while `wgengine.Conn.networkDown` would read from it. This patch moves
this flag into a field within the `wgengine.Conn` struct, so there’s
no chance that two tests could trample on each other.

It also renames this field to `Conn.checkNetworkUpDuringTests`,
because `Conn.networkUp` is the name of the field that gets checked.

Fixes #20260

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-29 09:38:07 -07:00
Simon LawandGitHub 28e1320906 wgengine/magicsock: fix warnings about nil health.Tracker (#20264)
Tests in magicsock_test.go would routinely emit this warning:

	## WARNING: (non-fatal) nil health.Tracker (being strict in CI):

because they would run NewConn without initializing a health.Tracker.

This patch initializes Conn correctly with a health.Tracker. It also
fixes some missing Close calls that can be handled in t.Cleanup.

Fixes #20263

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-29 09:18:39 -07:00
Brad FitzpatrickandBrad Fitzpatrick 5ebc7497ea tsnet: link in feature/acme by default
This was missing in the earlier f5eac39ea7 and meant that tsnet users weren't
getting (all of) acme support.

Thanks to @ChaosInTheCRD and @BeckyPauley for debugging.

Updates #12614
Updates #20252

Change-Id: I176a7b179b2ad3726aca484057f0aae7cc3561c8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-29 09:04:57 -07:00
Brad FitzpatrickandBrad Fitzpatrick 4bb6f35c1f ipn/ipnlocal: consolidate test-only LocalBackend methods behind ForTest
Move all the FooForTest methods on LocalBackend to instead be
methods on a new unexported forTest type which is then given out
to callers in other packages via an exported ForTest method
(panicking in non-test contexts) that returns that unexported type.

This is unusual style (exported returning unexported) but declutters
godoc and makes call sites both more explicit and easier to read
without the "ForTest" suffix polluting the symbols. Now FooForTest()
changes into ForTest().Foo().

This was motivated by a pending change moving a bunch of code out of
LocalBackend into other packages that required adding more ForTest
methods to LocalBackend to keep the tests (now in other packages)
working. Instead, do this refactor now so the future change is prettier.

Updates #12614
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ib25e6d76d48dc8622ac3a955e0b1220d582e63a8
2026-06-27 16:11:42 -07:00
1c0e833749 ipn/ipnlocal: normalize IPv6-mapped IPv4 addrs in WhoIs
WhoIs lookups for an IPv6-mapped IPv4 address such as
"::ffff:100.87.98.86" failed to match the node's canonical IPv4
address. Unmap the address before looking it up so these resolve.

Fixes #20235

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Bouke van der Bijl <i@bou.ke>
2026-06-27 11:36:30 -07:00
Brad FitzpatrickandBrad Fitzpatrick 5bd52667fb .github,.policy-tests.yml: test .policy.yml in CI
Add a .policy-tests.yml file with tests exercising the policy
that was just landed: the tailcfg/ control-protocol-owners gate,
the "policybot-override:" comment escape hatch (including
defaults-regression guards so the override rule does not
silently accept a normal review or a 👍 comment), and the
always-on "any tailscale/dev review" baseline.

Updates tailscale/corp#13972

Change-Id: I42afb06b0771658c803512cb5de4701450c8a704
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-26 15:55:15 -07:00
Brad FitzpatrickandBrad Fitzpatrick 97e7ea8b0b go.mod,tsnet,tstest/natlab/vmtest: bump prometheus/common to v0.69.0
prometheus/common v0.66/v0.67 introduced a mandatory
model.ValidationScheme on expfmt.TextParser as part of
prepping for UTF-8 metric/label names in Prometheus 3.0. The
zero value is intentionally UnsetValidation, which panics on
the first call to IsValidMetricName / IsValidLabelName with

  Invalid name validation scheme requested: unset

so the long-standing "var parser expfmt.TextParser" pattern
crashes at runtime. Several big downstreams have hit the same
sharp edge:

  https://github.com/thanos-io/thanos/issues/8823
  https://github.com/grafana/loki/pull/21401

Switch our two callers (parseMetrics in tsnet's
TestUserMetricsByteCounters and the client-metrics scraper in
tstest/natlab/vmtest) to the new expfmt.NewTextParser
constructor with model.LegacyValidation. LegacyValidation
matches the classic ASCII metric/label naming rules that
tailscaled's exporter uses today; if and when we ever emit a
metric with a UTF-8 name, we can revisit.

Goes to v0.69.0 (the latest at the time of writing) rather
than v0.67.5 so we pick up the unrelated security fixes for
cross-host redirects.

Done in advance so a follow-up change can pull in
github.com/tailscale/policybottest (which depends on
palantir/policy-bot, which transitively requires
prometheus/common at v0.67+) without dragging this debugging
into that PR.

Updates tailscale/corp#13972

Change-Id: I4b37db9ad3bebef1a32d9020bf6f8790bab25336
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-26 14:41:10 -07:00
Raj SinghandGitHub b64209b248 ipn/config: add RelayServerPort and RelayServerStaticEndpoints to config file (#18300) 2026-06-26 15:45:29 -05:00
Brad FitzpatrickandBrad Fitzpatrick 79e3bbbfa6 .policy.yml: tweak policy after testing
The override comment didn't work as expected.
(I'll be updating the policytest package to handle this)

Updates tailscale/corp#13972

Change-Id: Ic5c16eed09c8cb5fa8dab37d43cf05f8dfa75d49
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-26 12:28:02 -07:00
Brad FitzpatrickandBrad Fitzpatrick a95119a973 CODEOWNERS,.policy.yml: replace CODEOWNERS with a policy-bot policy
GitHub's built-in CODEOWNERS only supports a hard "block until a team
member reviews" rule, with no way to leave an audit trail when the
requirement is intentionally bypassed. Move review enforcement to
palantir/policy-bot (https://github.com/palantir/policy-bot) running
at https://policybot.corp.ts.net, which lets us express the same
tailcfg/ -> control-protocol-owners rule plus an explicit override:
any other @tailscale/dev member can post

    policybot-override: <reason>

as a PR comment and that comment counts as their approval, with the
reason recorded in the PR conversation as a permanent audit trail.

CODEOWNERS is kept as a one-screen comment so anyone landing on it
expecting the old behavior is directed to .policy.yml.

Updates tailscale/corp#13972

Change-Id: I2dc3619c498d4c4a6decae29aa123f6d67905eed
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-26 10:30:59 -07:00
Brad FitzpatrickandBrad Fitzpatrick f5eac39ea7 feature/acme, ipn/ipnlocal: start moving ACME/cert state into an extension
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: I520909f24ad11a9622ef33c2290fe36ad44d6f71
2026-06-26 09:48:24 -07:00
Alex ChanandAlex Chan 8379d5955f ipn: remove the last traces of Prefs.AllowSingleHosts
We stopped reading this field nearly two years ago, with a TODO comment
to remove it sometime in 2025.

It is now 2026.

Updates #12058

Change-Id: I8ddf1c2e4c3c428e8d45a6491d3899368ec52c30
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-26 15:31:38 +01:00
Jonathan NobelsandGitHub e21fd6b77a ipn/ipnlocal: add webclient support for tvOS (#20256)
updates tailscale/corp#44019

WebClient is very useful for remote management
on tvOS (which cannot do ssh).   Let's include it there.
Minimal corresponding tailscale/corp changes to follow
to add UI to set the required prefs.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2026-06-26 08:58:23 -04:00
Alex FreestoneandGitHub af999f05cf k8s-operator/dnsrecords: fix dnsRR dropping reconcile events on lock err (#19968)
On optimistic lock error, requeue the event after a short duration.

Resolves a case where a failure to acquire an optimistic lock on the
dnsrecords configmap will cause the operator to drop a reconcile event
and leave the configmap in an undesirable state.

Updates tailscale/tailscale#19946

Signed-off-by: Alex Freestone <freestone.alex@gmail.com>
2026-06-26 13:52:05 +01:00
Alex ChanandAlex Chan 6fc5290ce7 tool/gocross: retry downloading Go three times
Occasionally CI jobs will flake because downloading from GitHub fails.
Allow retrying up to 3 times to reduce CI flakiness.

Updates #cleanup

Change-Id: Ib019e89ac74b81d78f71a40099b20ff60014a81f
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-26 11:12:54 +01:00
Simon LawandGitHub 2fbd30824b tailcfg,net/routecheck: add NodeAttrClientSideReachabilityRouteCheck (#20169)
This patch adds a new `client-side-reachability-routecheck` node
attribute to allow admins to selectively enable background routecheck
probing on trial nodes. The current implementation is still
experimental.

It adds the routecheck.IsEnabled helper to check for the new
`client-side-reachability-routecheck` node attribute alongside the
existing `client-side-reachability` node attribute in this node’s self
capabilities. This allows administrators to turn on and off this
feature by editing the policy file.

It adds the `TS_DEBUG_FORCE_CLIENT_SIDE_REACHABILITY_ROUTECHECK`
environment variable which can be set to override the policy file.
When set to `true`, it forcibly enables this feature. And when set to
`false`, it forcibly disables it.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-25 18:22:15 -07:00
Alex ChanandGitHub 9169b206be Revert "control/controlclient: continue map poll during key expiry to receive extensions" (#20257)
* Revert "control/controlclient: continue map poll during key expiry to receive extensions"

This reverts commit 6a822dcc36. This commit
has caused test failures in the corp repo by unexpected changing the login
behaviour when nodes have a valid node key.

Updates tailscale/corp#43705
Updates #19326

Signed-off-by: Alex Chan <alexc@tailscale.com>

* Revert "tsnet: test key extension after server restart"

This reverts commit 317201375f. This test
relies on changes in 317201375f, which is
also being reverted because it causes test failures in corp.

Updates tailscale/corp#43705
Updates #19326

Signed-off-by: Alex Chan <alexc@tailscale.com>

---------

Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-25 15:24:12 -07:00
Tom MeadowsandGitHub 6e1de5b651 cmd/containerboot: refresh DNS config on SelfChange (#20236)
364b952d6 switched containerboot to partial netmap fetching, but
stopped refreshing `DNS.ExtraRecords`, so Tailscale Services created
after pod boot were invisible to resolveTailnetFQDN. To fix we watch
for SelfChange ipn bus notifies, and refetch dns-config via LocalAPI
to get a fresh set of `DNS.ExtraRecords`.

Fixes #20233

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-06-25 14:50:25 +01:00
Alex ChanandAlex Chan 9f92a4728e util/cmpver: add a test for comparing three-digit versions
No code changes needed; this is to rule out cmpver as the source of any
version-comparison issues.

Updates #20238

Change-Id: Ib8765dd042e994549d9e2c03859a5f769a856704
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-25 10:02:50 +01:00
Brad FitzpatrickandBrad Fitzpatrick dd1df38200 ipn/ipnlocal: pass capability set, not netmap, to two helpers
setWebClientAtomicBoolLocked and setDebugLogsByCapabilityLocked
each only need the node capabilities to decide what to do, so
take a set.Set[tailcfg.NodeCapability] directly as part of
getting rid of netmap.NetworkMap.

Updates #12542

Change-Id: If7c30b6354fd42dfe82ed6d2e2fe3439de401315
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-24 16:08:33 -07:00
Brad FitzpatrickandBrad Fitzpatrick 87cb2a8d1e wgengine: replace Engine.SetNetworkMap with SetSelfNode
The engine only used the netmap to look up self addresses and the
self node's primary routes, so pass it the self node directly
rather than the whole netmap.

Updates #12542

Change-Id: I13c0028eed65d2177baf4cf6c449f5e441845a18
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-24 15:03:55 -07:00
Michael Ben-Amiandmzbenami 1b2062f3c1 net/tstun: invoke conn25 app connector hook on injected reads
The primary purpose is that return packets from the target app get
properly SNATed on connectors with --tun=userspace-networking, matching
the NAT behavior in the kernel tun path.

This is also necessary but not sufficient for clients of connectors in
userspace networking mode. The hook will DNAT MagicIPs, but won't
actually be sent MagicIPs until conn25 app connector DNS works with
userspace networking.

Fixes tailscale/corp#43201

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-06-24 16:59:58 -04:00
Brendan CreaneandGitHub 77d2c87b17 wgengine/router/osrouter,util/linuxfw: remove orphaned tailnet addrs (#20199)
Router.Set reconciled tailscale0's addresses only against the in-memory
r.addrs map, which starts empty each run. After a restart the kernel can
still hold the addresses a previous profile put on tailscale0. With no
record of them, Set never removed them, leaving two tailnets' CGNAT
addresses on the interface. That broke connectivity, because the kernel
could source traffic from the wrong IP.

Fix this by scanning the addresses actually on the interface and, after
reconciling the desired set, removing any in Tailscale's CGNAT/ULA ranges
that aren't in the config. Non-Tailscale addresses are never touched,
and IPv6 addresses are skipped when IPv6 is unavailable, since delAddress
no-ops there. To avoid a netlink dump on every Set, the scan runs only on
the first Set and when the desired address set changes.

This also needs the iptables DelLoopbackRule to tolerate a missing rule:
an orphan left by a previous instance never went through AddLoopbackRule
here, and iptables (unlike nftables) errors when deleting an absent
rule, which would otherwise block the address delete.

Fixes #19974

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-06-24 13:41:36 -07:00
Patrick O'DohertyandGitHub 453c078baf .github: add zizmor GitHub Actions linting (#20243)
Add zizmor GitHub Actions linting on changes to .github/workflows.

Updates tailscale/corp#28760

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-06-24 13:14:54 -07:00
Brad FitzpatrickandBrad Fitzpatrick aefb1531d1 net/tsdial, ipn/ipnlocal: stop using netmap.NetworkMap in Dialer
tsdial.Dialer.SetNetMap rebuilt an O(n peers) map of MagicDNS names on
every netmap change. As we move toward per-peer incremental deltas,
this becomes quadratic. This removes it and replaces it with
SetResolveMagicDNS, a callback into LocalBackend that looks up
hostnames from nodeBackend's new nodeByName index (populated alongside
nodeByAddr/nodeByKey on both full and delta paths). The index stores
both FQDNs and short names as keys.

This is the same treatment applied to netlog (8f210454d), wglog
(988b0905b), and drive (1d6989408): stop pushing *netmap.NetworkMap
into subsystems and instead have them pull from LocalBackend's live
data via callbacks.

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I24557ab0c8a27636e08e4779bcfd3ec633db0a78
2026-06-24 13:14:45 -07:00
Brad FitzpatrickandBrad Fitzpatrick 8dde9b725b tstest/natlab/vmtest: serialize ensureDebugSSHKey across parallel boots
Env.Start boots all VM nodes in parallel; each calls
createCloudInitISO -> ensureDebugSSHKey concurrently. When
/tmp/vmtest_key doesn't yet exist, the first goroutine creates it
with os.WriteFile, which opens with O_CREATE|O_TRUNC and briefly
leaves the file existing-but-empty between the open and the
subsequent write. A concurrent goroutine that hits that window
sees ReadFile succeed with zero bytes, then fails ssh.ParsePrivateKey
with "ssh: no key found", causing boot to fail with:

  boot: creating cloud-init ISO: parse /tmp/vmtest_key: ssh: no key found

Observed in CI on TestSiteToSite (3 nodes). Wrap the function in
a package-level Mutex so the first caller fully writes the key
before any other caller reads it.

Updates #20228

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ie6399dcba0c397bb8041931d3de1c6063a11c568
2026-06-24 09:22:28 -07:00
Brad FitzpatrickandBrad Fitzpatrick 0bc0cb8131 tstest/natlab/vmtest: retry SSHExec on transient SSH failures
Add a retry loop with BatchMode=yes to absorb the race window
between Env.Start() returning (when tta reports the tailscale
backend as Running) and cloud-init finishing the user/SSH-key
setup. In CI, the second VM's tta agent has been observed
connecting only a few hundred milliseconds before the test SSHes
in, which is inside the window where /root/.ssh/authorized_keys
hasn't fully landed yet. SSH key auth then fails and ssh(1) falls
back to interactive password prompts (3x), wasting time and
producing a confusing "Permission denied (publickey,password)"
error.

BatchMode=yes makes the client fail fast on auth failure instead
of prompting, and the retry loop handles SSH transport-level
errors (exit code 255) for up to 30 seconds with 500ms backoff.
Remote command non-zero exits still pass through unchanged.

Fixes #20228

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I17f7422e9e27bf7b995f505c0184cbb2b230ed81
2026-06-24 09:22:28 -07:00
Alex ChanandAlex Chan 281404e9e3 cmd/tailscale/cli: fix capitalisation of flags
Most of our flag descriptions start with a lowercase word (except proper
nouns); fix the handful which do not.

Fixes #20230

Change-Id: I00aaac171254c050ad0b75c2cf8746590c8c4d8f
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-24 16:56:49 +01:00
Amal BansodeandGitHub c33a55737b ipn/ipnlocal: reduce excessive logging of exit node suggestions (#20237)
The logging added in 12188c0 was generating excessive spam in
backend logs. This may have been exacerbated by
tailscale GUI<->backend architecture on certain platforms like
Windows, where the GUI polls for exit node suggestions rather
than listening on the IPN bus.

Change this to log on error or if the current suggestion differs
from the previous suggestion.

Updates tailscale/corp#43691
Updates #20194

Signed-off-by: Amal Bansode <amal@tailscale.com>
2026-06-24 08:40:23 -07:00
Brad FitzpatrickandBrad Fitzpatrick d4f2917c1b wgengine, ipn/ipnlocal: route PeerForIP through LocalBackend's live data
userspaceEngine.PeerForIP read from e.netMap.Peers and
e.lastCfgFull.Peers, both of which go stale when peers arrive via
netmap deltas (which skip Engine.SetNetworkMap and Engine.Reconfig).
Every PeerForIP caller (Engine.Ping, the TSMP disco-key handler,
pendopen diagnostics, tsdial.Dialer.UseNetstackForIP, and
LocalBackend.GetPeerEndpointChanges) would report "no matching peer"
for freshly-added peers.

Fix it the same way SetPeerByIPPacketFunc fixed the outbound packet
hot path: have LocalBackend install a callback that reads the live
nodeBackend. nb.NodeByAddr is built from both SelfNode and Peers
(updateNodeByAddrLocked), so a single lookup covers the common case
with IsSelf set when the matched node ID is SelfNode's. The subnet-
route / exit-node-default-route slow path goes through a new
Engine.PeerKeyForIP that exposes the engine's AllowedIPs BART table
(the same table the outbound packet hot path already consults, with
exit-node selection honored), and resolves the matched key back to a
NodeView via the live nodeBackend.

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I0d4b0d8997c8e796b7367c46b49b61d4fdc717b0
2026-06-23 14:37:15 -07:00
Brad FitzpatrickandBrad Fitzpatrick e9ae398199 wgengine: drop userspaceEngine.peerSequence
Another baby step toward removing slices of peers from the engine.

getStatus iterated peerSequence (a key snapshot built in Reconfig
from cfg.Peers) and then asked wgdev for each peer's stats; peers
that weren't active in wgdev silently fell out. Iterate active wgdev
peers directly via RemoveMatchingPeers(returnFalse) instead.

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3abd348abc30db706db29b3a785179259e48abda
2026-06-23 14:19:22 -07:00
Jordan WhitedandJordan Whited badd0c4f93 wgengine/magicsock: consider VNI as part of peer relay handshake suppression
Otherwise we may never handshake a new peer relay server endpoint
around remote client restarts and/or disco key rotation.

Updates #20215

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-06-23 13:09:52 -07:00
James TuckerandJames Tucker b7422fa873 .gitattributes: explicitly mark text files as such with eol
I'm not keen on us having to deal with the bad side effects of the
autocrlf default, but alas, if it makes things easier.

Fixes #16175
Closes #16176

Signed-off-by: James Tucker <james@tailscale.com>
2026-06-23 13:04:07 -07:00
Brad FitzpatrickandBrad Fitzpatrick 49e060bbcb wgengine: add Engine.ProbeLocks, drop PeerForIP lock-probe overload
The watchdog (ipn/ipnlocal/watchdog.go) was abusing PeerForIP with an
invalid netip.Addr as a way to acquire and release the engine's
internal locks for deadlock detection. This does the TODO to break it out
into its own method like all the other similarly named methods.

Splitting this out as a prerequisite for a follow-up rewrite of
PeerForIP itself; not having to preserve the lock-probe overload in
the new implementation keeps that follow-up smaller.

Updates #12542
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I25cbffd11aeb65600d9128845404c4918ef88ead
2026-06-23 12:02:49 -07:00
Patrick O'DohertyandGitHub 72876a91d5 .github: pin govulncheck@1.3.0 (#20219)
Pin govulncheck to resolve panics in the most recent version.

Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-06-23 11:51:46 -07:00
Brad FitzpatrickandBrad Fitzpatrick d22bf51e57 util/cloudenv: detect Hetzner Cloud
Detect Hetzner via /sys/class/dmi/id/sys_vendor == "Hetzner" and wire
up Hetzner's public recursive DNS resolvers (185.12.64.1, 185.12.64.2)
for use as a cloud host resolver.

Fixes #20217

Change-Id: I24a4c51956adfdd5731f62c937e3c7a4a733ffc7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-23 11:10:59 -07:00
Brad FitzpatrickandBrad Fitzpatrick 1d69894084 ipn/ipnlocal, drive: stop using netmap.NetworkMap in Taildrive too
This applies the same treatment from PR #20162 (netlog) and
PR #20171 (wglog) to the local Taildrive filesystem wiring, ending the
per-netmap-update O(n) rebuild of the drive remotes list.

This moves the O(n peers) taildrive-remote list rebuild from every
peer change (which previously happened regardless of whether you were
even using taildrive) to instead happen only as needed.

That running on every netmap update and was a contributor to the
broader quadratic behavior we want to eliminate when a single peer is
added or removed.

Instead, this introduces drive.RemoteSource, a small interface the
Taildrive filesystem pulls from lazily on incoming WebDAV requests,
and caches by a generation counter. ipn/ipnlocal installs a
driveRemoteSource once at NewLocalBackend time and bumps
LocalBackend.driveGen on the three events that can actually flip the
drive-capable peer set: full netmap installs (domain + self caps),
UpdateNetmapDelta (peer add/remove or per-peer address changes), and
updatePacketFilter (since PeerCapability values are derived from the
packet filter rules, not from peer.CapMap).

The hook itself is kept but narrowed: it no longer takes a
*netmap.NetworkMap and its only remaining job is to re-notify IPN bus
listeners of the current local shares list on full installs.

This is a dependency to removing the netmap.NetworkMap type from
upstream callers, like wgengine.Engine in general.

(Also add a bunch more tests)

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I7e3d2f5b4a9c8e1d6f0a3b7c9e2d4f8a1b6c5e9d
2026-06-23 10:41:50 -07:00
Brad FitzpatrickandBrad Fitzpatrick 988b0905bb wgengine/wglog: stop using netmap.NetworkMap here too
This applies the same treatment from 8f210454dd (netlog) to wglog,
ending use of netmap.NetworkMap and instead getting the canonical data
from LocalBackend/nodeBackend.

This is a dependency to removing the netmap.NetworkMap from
upstream callers, like wgengine.Engine in general.

Updates #12542

Change-Id: Icb5af0799322def048a6f594b49f7d11273f025d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-23 09:06:37 -07:00
Brad FitzpatrickandBrad Fitzpatrick 295bf20cfd prober: deflake TestHTTPBandwidth
The test transferred only 64 KiB over loopback, which can complete
within a single clock tick on fast CI machines, causing
time.Since(start).Seconds() to return 0 and the
"transfer_time_seconds_total > 0" assertion to fail.

Increase the payload to 1 MiB so zero is genuinely implausible, and
retry up to 3 additional times. If the metric is still zero after 4
total attempts, fail hard — at that size it means the timing logic is
actually broken.

Fixes #20213

Change-Id: I3fab510ce8c567506fea5ad803d35acf40d65700
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-23 08:35:57 -07:00
Brad FitzpatrickandBrad Fitzpatrick af2f228a18 ipn/ipnlocal, types/netmap, tsnet: filter unsigned peers on delta path
aa5da2e5f2 (in the 1.99.x dev series, unstable) introduced some bugs,
only some of which were later fixed. This fixed another. As of that
change, tkaFilterNetmapLocked ran only on full netmaps through
LocalBackend.setClientStatusLocked and not peer upserts via new or
changed peers. The later ae743642d9 fixed a regression in the
Engine layer but didn't fix the tkaFilter code from re-running on
upserts.

This add a tkaFilterDeltaMutsLocked pass before
nodeBackend.UpdateNetmapDelta. For each NodeMutationUpsert whose
peer fails the same signature check tkaFilterNetmapLocked applies,
rewrite the upsert in place into a NodeMutationRemove targeting the
same node ID, so magicsock's per-mutation dispatch and
nodeBackend.peers both drop the peer, matching the prior full-netmap
semantics.

New tsnet tests added:

  - TestTailnetLockFiltersUnsignedDeltaPeer covers the new-peer
    case.
  - TestTailnetLockFiltersUnsignedDeltaPeerReplacement covers the
    existing-peer-replacement case, to an empty signature.
  - TestTailnetLockFiltersDeltaPeerWithInvalidSignature like above
    but with a bogus signature.

Updates #12542
Updates tailscale/corp#43767

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ib35d0391541fee654867c26489847dbc5b7e2ae8
2026-06-23 08:12:36 -07:00
tsushanthandGitHub 0b551986fe cmd/k8s-operator: scope HA Service hostname check per-tailnet (#20114)
The 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>
2026-06-23 14:25:11 +01:00
Brad FitzpatrickandBrad Fitzpatrick d6c8702e90 tstest/natlab/vnet: deflake TestPacketSideEffects and TestProtocolQEMU
Both tests started flaking after my 910735448 ("tstest/natlab/vnet:
send unsolicited IPv6 Router Advertisements") added background RA
traffic on v6-enabled networks.

TestPacketSideEffects races the periodic unsolicited-RA goroutine
against its synchronous packet-count assertions: when the multicast
RA fires after the test has registered its sinks, both sinks receive
it and "got 1 packet, want N" becomes "got N+2".

TestProtocolQEMU's reader was doing raw Read on the SOCK_STREAM unix
socket and comparing the whole result to the expected length-prefixed
packet. The kernel is free to coalesce the on-register RA frame and
the test packet into one Read, in which case bytes.Equal fails and
the entire chunk (including the test packet's bytes) gets discarded
as "unexpected", leading to a 5s i/o timeout. Parse the QEMU uint32
length-prefix framing with io.ReadFull instead so we read exactly one
frame per iteration regardless of how the kernel buffers them. The
SOCK_DGRAM path (TestProtocolUnixDgram) keeps the original raw Read
since datagram boundaries are preserved.

These where the top two flakes in oss on the flakes dashboards.

Updates #13038

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I32983656b692921a0f43a4a5e9a8a6ab2555ee49
2026-06-23 05:40:30 -07:00
Brad FitzpatrickandBrad Fitzpatrick e0677ccc76 net/tstun, wgengine/filter: track UDP flow state for injected packets
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: I816ef55c493a12ff4f561cd89c095559b5c2743b
2026-06-22 15:57:37 -07:00
Alex ValiushkoandGitHub 568c0bda24 go.mod: bump wireguard-go (#20203)
Fix leaking peers that failed to complete the handshake.

Updates #20183

Change-Id: I84f7ea0484f05b090d963a7d12c135a66a6a6964
Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
2026-06-22 13:45:50 -07:00
Anton TolchanovandAnton Tolchanov e9e209673e net/netcheck: ensure recent history has a full report
suggestExitNodeLocked now ranks exit node candidates using the per-region
latency tracked by the netcheck Client (RecentRegionLatency), which merges
the reports retained in c.prev. That history is only useful for far-away
regions if it contains a full netcheck report, since incremental reports
only re-probe the home region and a handful of the fastest ones.

The full-report cadence in GetReport and the c.prev retention window were
two independent 5-min constants - the way we schedule netchecks ensured
that the history always contaned a full report, but it was not a strong
contract and we did not have any checks around this.

Now full report interval and retention window are driven by the same
var, and a test confirms that the history contains a full report.

Updates tailscale/corp#17516

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2026-06-22 12:28:09 +02:00
Anton TolchanovandAnton Tolchanov f442cda999 ipn/ipnlocal: consider all DERP regions for exit node recommendations
When recommending an exit node, suggestExitNodeLocked ranks candidates by
the latency to their home DERP region, taken from the most recent netcheck
report. But netcheck alternates between full reports, which probe every
region, and incremental reports, which only re-probe the home region and a
handful of the fastest regions. When the most recent report is incremental,
the suggestion fell back to a random for exit nodes that are far away.

Now we rank candidates against the best recent latency, tracked by the
`netcheck.Client` - the same data that is used to pick the preferred
DERP. It uses a history of measurements which includes a full netcheck
report, so should cover all DERP regions.

Updates tailscale/corp#17516

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2026-06-22 12:28:09 +02:00
Samy DjemaïandGitHub 6a275c01db util/linuxfw: clamp MSS to PMTU in both forward directions (#20077)
ClampMSSToPMTU only added a rule matching the output interface (-o tun /
OIFNAME), which clamps the SYN forwarded out towards the tailnet peer but
not the SYN-ACK that arrives on tun and is forwarded back towards the
originating endpoint. As a result only one side of a forwarded handshake
had its MSS clamped; the endpoint on the other side of the proxy kept
advertising an MSS based on its own (larger) MTU.

When path MTU discovery is broken (e.g. proxies created by the Tailscale
Kubernetes operator, where tailscale0 has a 1280 MTU), the unclamped
endpoint's large segments exceed the tun MTU and are silently dropped,
causing TCP connections through proxy group pods to stall mid-stream on
large payloads. The earlier proxy-group fix (#19686) wired ClampMSSToPMTU
into the HA code paths but inherited this single-direction limitation, so
connections could still hang.

Add a second rule matching the input interface (-i tun / IIFNAME) in both
the iptables and nftables runners so both directions of the forwarded
handshake negotiate a PMTU-safe MSS.

Updates #19812

Signed-off-by: Samy Djemaï <53857555+SamyDjemai@users.noreply.github.com>
2026-06-22 11:25:15 +01:00
Mike O'DriscollandGitHub 59159d9180 prober: add HTTP bandwidth probe and dial-address override (#20185)
Add HTTPBandwidth/HTTPBandwidthWithDialAddr probe classes that download a
fixed number of bytes and record transfer time and bytes transferred as
Prometheus counters for bandwidth measurement, plus HTTPWithDialAddr and
the shared NewProbeTransport and HTTPBandwidthMetrics helpers.

The dial-address override lets a probe target a specific backend (e.g. a
single Funnel ingress node) while SNI, the Host header, and TLS cert
validation continue to derive from the URL host. HTTPBandwidthMetrics is
exported so other bandwidth probes (e.g. a receiver-reported upload probe)
emit an identical metric set and compare under a shared direction label.

Updates tailscale/corp#41587

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-06-19 15:33:29 -04:00
License UpdaterandWill Norris 07f63534b1 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-06-19 09:45:02 -07:00
Gesa StupperichandGesa Stupperich 53ef7f92cb sessionrecording: close idle connections after upload
If we don't close the connection between SSH server and recorder
explicitly once it's idle after the upload stream is closed, the
connection stays open and holds on to a port on the server. This
leads to port exhaustion on the server in the medium to long run.

To avoid this, close the idle connections explicitly. As an extra
step of precaution, set an idleConnTimeout of 30 seconds on both
the HTTP1 and HTTP2 recorder clients.

Updates tailscale/corp#43742

Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
2026-06-19 13:42:14 +01:00
Brendan CreaneandGitHub 0861dafddf net/dns: restore SELinux context on /etc/resolv.conf after rename (#20167)
In direct mode we write resolv.conf via a temp file and rename(2), which
preserves the source's generic etc_t label instead of net_conf_t, causing
AVC denials when NetworkManager later manages the file. Run restorecon
after the rename (Linux, SELinux-enforcing, best effort) to restore the
policy-default label.

Fixes #20149

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-06-18 16:36:56 -07:00
Jordan WhitedandJordan Whited 54005752a5 wgengine/magicsock: suppress TSMP disco advert when bestAddr is peer relay
Updates #20156

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-06-18 11:43:00 -07:00
Simon LawandGitHub 00b9e8d8ce ipn: add fmt.Stringer support to NotifyWatchOpt (#20072)
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>
2026-06-18 10:27:16 -07:00
Alex ChanandAlex Chan c3c2aa7093 all: don't repeat the the word "the" unnecessarily
Updates #cleanup

Change-Id: Ic1f430cd5dbf6cc1a385c59074a5d5cabe6fca57
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-18 16:32:08 +01:00
BeckyPauleyandGitHub 35a1a413f9 cmd/{containerboot,k8s-operator}: add 4via6 support in singleton egress (#19983)
Add support for configuring egress to destinations reachable via 4via6
subnet routes, using either the synthesized 4via6 address or the MagicDNS
name (in the form <IPv4-with-hyphens>-via-<siteID>[.*]).

Also update the Connector to validate and advertise 4via6 subnet routes.

Export net/netutil.ValidateViaPrefix so it can be reused by the Connector
validation logic.

This change only affects standalone egress proxies — ProxyGroup egress
requires IPv6 support before it can use 4via6.

Updates #19334

Change-Id: I6faecd6eb61ab55fc0cd97fe417af6b6a12fe7fc

Signed-off-by: Becky Pauley <becky@tailscale.com>
2026-06-18 16:13:10 +01:00
Simon LawandGitHub e3b16135b2 util/set: add iterator support to Set[T] (#20159)
This patch adds:

- Set.All which returns an iter.Seq to complement Set.Slice.

- Set.AddSeq which adds an iter.Seq.

- Set.DeleteSeq which deletes an iter.Seq to complement Set.AddSeq
  and provide the missing method for deleting multiple elements.

- Set.DeleteSlice and Set.DeleteSet to complement AddSlice and AddSet.

Updates #cleanup

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-18 00:12:56 -07:00
Jordan WhitedandJordan Whited be2f554dd3 control/controlknobs,wgengine/magicsock: disable TSMP disco advert if netmap caching is disabled
Updates #20081

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-06-17 18:45:38 -07:00
Brad FitzpatrickandBrad Fitzpatrick 8f210454dd wgengine/netlog: stop using netmap.NetworkMap type, use LocalBackend
The Logger previously took a *netmap.NetworkMap at Startup and on every
ReconfigNetworkMap call, denormalizing it into per-IP and self lookup
maps. That denormalization is O(n) over all peers and ran on every
netmap update, contributing to the broader quadratic behavior we want
to eliminate when a single peer is added or removed.

Instead, this makes netlog ask LocalBackend (well, nodeBackend) for
the info it needs, letting us remove the netmap.NetworkMap type
entirely from the netlog package.

This is a dependency to removing the netmap.NetworkMap type from
upstream callers, like wgengine.Engine in general.

Updates #12542

Change-Id: Ib5f2de96e788a667332c0a6f7ac833b3d0053b5c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-17 15:11:57 -07:00
Simon LawandGitHub 994b2c8459 tsnet: fix tests that have a ping that races its destination node (#20151)
In PR #17809, @bradfitz tried to fix tsnet_test.TestConn by making the
second tailscaled start after the first was fully set up. On slow
runners, the Ping for connectivity to the second server would race
against that server establishing a connection with its DERP home. If
the Ping arrived too soon, the DERP server would respond with
PeerGoneNotHome and the Ping would wait for its full timeout before
failing the test.

This patch introduces waitForHomeDERPConnected and makes startServer
block until the server’s home DERP has established its connection.

This patch also reduces the Ping timeout to 10 seconds for the tsnet
tests, which should be enough that a hung Ping is fast enough for
interactive debugging, but with enough headroom for a RekeyTimeout.

Fixes #12766

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-17 14:26:05 -07:00
Naman SoodandGitHub 47333e9487 feature/conn25: recreate transit IP mappings when connector loses them
Mappings from transit IPs to real IPs are stored ephemerally in the
connector, so they're lost on restart. When we send a packet to the
connector with a transit IP it does not recognize, it sends us a TSMP
message saying so (see #19883). If we (the client) know of such a
mapping, we now re-send it to the connector so that a connection can
proceed.

Fixes tailscale/corp#34256.

Signed-off-by: Naman Sood <mail@nsood.in>
2026-06-17 13:50:51 -04:00
Simon LawandGitHub 88f5206511 types/geo: add support for ScalarMarshaler and ScalarUnmarshaler (#20158)
Add support for the still pending encoding.ScalarMarshaler and
encoding.ScalarUnmarshaler interfaces, approved in golang/go#56235.

This patch deprecates geo.Point.MarshalUint64 in favour of
geo.Point.MarshalScalar and also adds an inline directive for go fix.
The same applies for the UnmarshalUint64 and UnmarshalScalar methods.

Updates #16583

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-16 16:36:43 -07:00
Simon LawandGitHub f0a1aa818f tailcfg: fix typo in doc comment for tailcfg.Node.DisplayNames (#20155)
Updates #cleanup

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-16 10:23:44 -07:00
James TuckerandJames Tucker 26b2ed0a6a net/packet: clarify minFragBlks reuse for IPv6 and test chained ext header
Follow-up cleanups to the IPv6 fragment extension header support added in
the previous commit:

- Document that minFragBlks is sized for IPv4 but intentionally reused by
  decode6 for IPv6 fragments, where it is conservative (IPv6 fragments
  carry no per-fragment IP header) and only ever rejects more later
  fragments as Unknown, never fewer.

- Add a TestDecode case for a first fragment reachable only through a
  chained extension header (base Next Header = Hop-by-Hop Options, which
  chains to Fragment). decode6 only parses the Fragment header when it is
  the base header's immediate Next Header, so this must classify as
  Unknown. The test locks in that scoping decision.

Updates #20083
Updates #20140

Change-Id: Ibece03c6baf2385b0cc399f179819b08cbe921cc
Signed-off-by: James Tucker <james@tailscale.com>
2026-06-16 10:16:06 -07:00
ca20611d11 util: add parse fallback helpers (#20022)
util/def: add def.Bool and def.Duration default parse helpers

Replace multiple instances of def.Bool and def.Duration with a new util/def
package.

Updates #20018

Co-authored-by: Bobby <boby@codelabs.co.id>
Co-authored-by: Simon Law <sfllaw@tailscale.com>
Signed-off-by: Bobby <boby@codelabs.co.id>
Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-15 15:58:51 -07:00
James ScottandGitHub 94fbb03352 logtail: add stateless generic UploadLogs (#20005)
Add UploadLogs, a stateless alternative to NewLogger for callers that
want to push a batch of log entries without the background uploader,
ring buffer, stderr echoing, or network-up gating that a Logger
provides. Entries are encoded, batched up to the server's maximum
upload size, and POSTed synchronously; unlike Logger it does not retry.

The Logger construction is split into a new unexported newLogger so the
connection/encode/upload machinery is shared without starting the
background goroutine.

Log entries are modeled as a generic LogEntry[T] whose Value is inlined
(via go-json-experiment) alongside the reserved "logtail" metadata
member. T may be a struct (or pointer), a map with a string key, or a
jsontext.Value; use jsontext.Value to mix differently-shaped payloads in
a single upload. UploadLogs fills in client_time/proc_id/proc_seq from
the Config where the caller leaves them zero.

Updates tailscale/corp#40908

Change-Id: Idbf23cd0eb8233082fbdb9abed0f6f153b9225ba

Signed-off-by: James Scott <jim@tailscale.com>
2026-06-15 13:27:49 -07:00
Simon LawandGitHub eddd019ee4 ipn/ipnlocal: protect populatePeerStatusLocked from nil Hostinfo (#20150)
ipnlocal.LocalBackend.populatePeerStatusLocked assumed that Hostinfo
was always valid, but that’s not always true, especially in tests.
ipnlocal.peerAPIPorts suffered from a similar assumption.

This patch checks for NodeView.Valid and Hostinfo.Valid; assuming the
zero value as a safe default.

Updates #8948
Updates #12542

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-15 13:14:12 -07:00
Brad FitzpatrickandBrad Fitzpatrick 6596d237a3 ipn/ipnlocal: add wireguard session state metrics + publish on IPN bus
Updates #19989
Updates tailscale/corp#42874

Change-Id: I843ed95bc7b0f5cd38ba1467332c6b022901e254
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-15 11:41:18 -07:00
Brad FitzpatrickandBrad Fitzpatrick ae743642d9 ipn/ipnlocal: revert earlier change, force Reconfig + SetNetworkMap new/removed peers
The earlier aa5da2e5f2 made peer adds and removes through a netmap
delta path that mutates only nodeBackend, on the assumption that
PeerForIP, lookupPeerByIP, the engine's wireguard config
(e.lastCfgFull), the engine BART, wgdev's PeerLookupFunc closure, and
the engine's cached netmap (e.netMap) would all stay correct without
further updates.  They don't. I'd totally forgotten that
Engine.PeerForIP has its own alternate IP-to-peer lookup codepath.

Concretely, all of these failed for a peer that arrived via
[tailcfg.MapResponse.PeersChanged] (and never via a full
[tailcfg.MapResponse.Peers] list):

  - [wgengine.Engine.PeerForIP] read from e.netMap and e.lastCfgFull
    (neither updated on the delta path) and so missed the new
    peer. The rando non-data-plane callers (Ping, TSMP, pendopen,
    debug endpoints, tsdial.Dialer.UseNetstackForIP for tsnet and
    onlyNetstack tailscaled) all returned "no matching peer".

  - The engine BART (built from e.lastCfgFull) missed the new peer's
    subnet routes / exit-node default routes.

  - wgdev's [device.PeerLookupFunc] closure (rebuilt only inside
    wgcfg.ReconfigDevice) didn't have the new peer's noise key, so
    outbound encryption to the new peer dropped the packet even when
    SetPeerByIPPacketFunc returned the right NodePublic.

  - And nothing in the delta path triggered NodeMutationRemove to
    flow through to authReconfig either, so the same stale state
    pointed at removed peers indefinitely.

So just (functionally) revert it for now, to have something easily
cherry-pickable to the 1.100 release branch. Proper fixes can come later
for the next release.

This also adds three new tests:

  - TestPingPeerLearnedViaDelta runs disco and TSMP subtests over a
    delta-added peer with only self addresses. disco exercises the
    cold PeerForIP path (magicsock); TSMP exercises the full data path
    through wgdev encryption. Both fail without this fix.

  - TestPingSubnetRouteOfDeltaPeer exercises a subnet-router peer
    arriving via delta. With s1 in --accept-routes mode, an IP
    inside the advertised CIDR must resolve to s2 and a TSMP ping
    must round-trip. Hits the BART + lastCfgFull + wgdev staleness
    in one go.

  - TestPingSelfReturnsIsLocalIP is a regression guard for the
    IsSelf early-out in Engine.Ping. Passes on main today; included
    here so future refactors of PeerForIP can't regress self
    handling without test breakage.

Updates tailscale/corp#43394

Change-Id: I7a049271359bd73e7147ae9e2554e85614c2b8d2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-15 11:41:01 -07:00
Steve AveryandJames Tucker 4c4ec3d468 net/packet,wgengine/filter: handle IPv6 fragment extension header
decode6 didn't parse the IPv6 Fragment extension header (Next Header 44),
so any source-fragmented IPv6 packet was classified as an unknown protocol
and matched no ACL rule. The filter then silently dropped it and counted it
as an "acl" drop, even on allow-all tailnets, blackholing large UDP (DNS,
WebRTC, etc.) over a tailnet's IPv6 addresses. IPv4 fragments were already
handled by decode4.

Parse the fragment header the same way: read the first fragment's transport
ports so the filter matches it like an unfragmented packet, pass later
fragments through as ipproto.Fragment, and reject overlapping-fragment
offsets (RFC 1858) and first fragments too short to hold the transport
header as unknown.

Fixes #20083

Signed-off-by: Steve Avery <hello@stevenavery.com>
2026-06-15 11:18:00 -07:00
M. J. FrombergerandGitHub f002f6bb3a ipn/ipnlocal: remove logs for peer delta cache updates (#20145)
Added in #20111, but it is too noisy under real load to be useful.

Updates #12542

Change-Id: Ib99a8966ade0bfa4281fccc057249819cdcdfe83
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-06-15 10:00:03 -07:00
Fernando SerbonciniandGitHub 4d9d8cfaa8 misc: rename install-git-hooks.go to add-git-hooks.go (#20144)
`go run` builds a manifest-less .exe, so Windows applies installer-
detection heuristics and requests admin privileges to programs that
contains "install", "setup", or "update". Rename to dodge that.

Updates #20133

Change-Id: I144d3fcb076d7a02e4a3eb9fd079ee022a035c76

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-06-15 12:08:19 -04:00
Fernando SerbonciniandGitHub 449233dd61 .github/workflows: auto-request k8s-devs review for Kubernetes/container paths (#20123)
Add a workflow that requests review from @tailscale/k8s-devs on PRs
touching Kubernetes operator, kube libraries, container build, etc.

Also cleans up check out code on k8s and dataplane workflow.

Updates #cleanup

Change-Id: I6fd7cacf71e1299f7e8f546ef52c4063fbf6bab8

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-06-15 09:31:28 -04:00
Brendan CreaneandGitHub c48f953840 cmd/tailscale/cli, ipn/conffile: accept legacy serve config in set-config (#20056)
tailscale serve set-config now also accepts the legacy raw ipn.ServeConfig
format (as emitted by `tailscale serve status --json` and consumed via
TS_SERVE_CONFIG, which has no "version" field), so the common
serve-status-edit-set workflow stops failing. Only the services-oriented
content is applied; any node-level fields are skipped with a warning to
stderr pointing users at get-config to migrate.

Fixes tailscale/corp#39793

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-06-12 18:52:17 -07:00
Alex ValiushkoandGitHub 7d18a06292 go.mod,wgengine/magicsock: pull wireguard-go fix for roaming endpoints (#20118)
Bumps wireguard-go pin to include the roaming endpoints fix, and
two internal enhancements.

Pulls stock wireguard-go for non-tailscale simulation in tests,
to use its endpoint discovery mechanism.

Updates #20082

Change-Id: I2ff282cb7fe4ab099ce5e780a1d40ae86a6a6964
Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
2026-06-12 10:50:35 -07:00
Michael Ben-Amiandmzbenami a9ea6336fa wgengine: delete Conn25 packet hooks
Package features/conn25 wires up the hooks directly on the tun wrapper
without needing to go through the userspace engine, so this codepath is
unused and not needed.

Updates #cleanup

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-06-12 13:43:55 -04:00
M. J. FrombergerandGitHub 9cb071666c ipn/ipnlocal: update netmap cache after peer deltas are applied (#20111)
Add an UpdatePeers method to the cache. This allows us to support netmap peer deltas,
by allowing just the peers to be updated in an existing cache. As a safety check, reject
an update if there was no base netmap data to apply a change to.

Then, when processing peer mutations in the backend, capture any changes that should
be applied to the cache and update it, if one is enabled.

Updates #12542

Change-Id: I2f8790a8fdc5e85fce6700ba4821a8cb10dddffa
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-06-12 09:41:00 -07:00
M. J. FrombergerandGitHub b23089a5ef wgengine/magicsock: update netmap cache flag on receipt of a delta (#20117)
Since deltas are only (at present) received from the control plane, processing
a delta signifies we are no longer operating on a netmap fully loaded from
cache, even if most of the netmap is still in the same configuration.

Updates #12542

Change-Id: I84132c4bf2dde6e5c1c57144645edb986b051dca
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-06-12 09:05:12 -07:00
Claus LensbølandGitHub 0108fb73a9 tstest/natlab/vmtest: skipe tests marked as flakey (#20122)
Flakeytest seems to not work on vmtest. We have a few PRs that will fix
the problem on these tests, so skip to unblock.

Updates #19843

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-06-12 11:03:15 -04:00
Michael Ben-Amiandmzbenami 6f281ccbcd feature/conn25: add on-remove hook for flows in FlowTable
The hook fires when a flow is removed for any reason (LRU capacity eviction,
tuple-collision displacement, or idle-time expiry). The hook is invoked
exactly once per flow, after the flow table mutex is released, so callbacks
may safely acquire other locks.

We rename the IPMapper interface to Conn25Datapath, and add
ClientFlowCreated/ClientFlowRemoved methods so *Conn25 can keep client-side
address assignments alive while traffic is in flight. Those methods are
currently stubbed for future work.

Connector flows do not currently call these methods.

Updates tailscale/corp#38630
Updates tailscale/corp#43180

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-06-12 10:44:42 -04:00
Michael Ben-Amiandmzbenami 2a0eafc20f feature/conn25: drop returned error from NewFlow signature
The returned error in the signature is left over from previous
implementations and was only returning nil.

If we know NewFlow will succeed we can fire a create hook (implemented
in a future commit) before NewFlow, which will prevent a remove hook for
a flow from firing before the create hook for the same flow.

Updates tailscale/corp#38630

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-06-12 10:44:42 -04:00
aspyn ectandGitHub da11aa54b7 words: add "cat" to scales.txt (#20106)
Updates #words

Signed-off-by: aspyn ect <96669439+aspynect@users.noreply.github.com>
2026-06-12 12:48:53 +01:00
241456ab57 ipn/ipnlocal: add metrics for inbound and outbound bytes on Serve connections (#19991)
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>
2026-06-12 05:49:00 -05:00
James TuckerandGitHub b6713e9bc8 cmd/tailscale/cli: check kubeconfig writability instead of refusing $KUBECONFIG (#20009)
When running under the macOS sandbox, "tailscale configure kubeconfig"
refused outright whenever $KUBECONFIG was set, assuming the path would
not be writable. Yet when $KUBECONFIG was unset it happily relied on the
home-relative-path entitlement to write to ~/.kube/config, so the two
paths made inconsistent assumptions about what the sandbox can reach.

Resolve the kubeconfig path first, then check whether the target file
(or the nearest existing parent directory) is actually writable. Only
report an error if it is not, and include macOS sandbox guidance in that
error since a path outside the home directory is the likely cause. This
lets a $KUBECONFIG that does point under the home directory work, rather
than being rejected unconditionally.

Fixes #20007

Change-Id: I9880363c38b981efaed7e97367851ddacf647be1

Signed-off-by: James Tucker <james@tailscale.com>
2026-06-12 10:48:07 +01:00
Mario MinardiandMario Minardi f368a96e01 ssh/tailssh: dissallow purely numeric usernames for SSH
Dissallow purely numeric usernames for SSH as these are ambiguous with
numeric UID values.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-06-11 17:52:51 -06:00
Gesa StupperichandGesa Stupperich 317201375f tsnet: test key extension after server restart
Updates #19326

Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
2026-06-11 19:21:09 +01:00
Gesa StupperichandGesa Stupperich ec8ab870a4 tstest/integration/testcontrol: expire individual node keys
This adds testcontrol support for expiring individual node keys,
in order to enable test scenarios involving to key-expiry and
 extension.

Updates #19326

Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
2026-06-11 19:21:09 +01:00
Gesa StupperichandGesa Stupperich 5be05f2c0d control/controlclient: discard stale auth results in authRoutine
authRoutine snapshots c.loginGoal, runs TryLogin without the lock,
then writes back loggedIn/loginGoal under the lock. If a concurrent
Login() or Logout() changes the goal during the in-flight request,
the write-back overwrites the new intent: the more recent login goal
is silently dropped, or a logout is reverted to logged-in.

Gate both the URL-followup and success commits on c.loginGoal still
matching the goal we were processing. Stale results are ignored and
the next iteration runs with the current goal.

Updates #19326

Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
2026-06-11 19:21:09 +01:00
6a822dcc36 control/controlclient: continue map poll during key expiry to receive extensions
When a client's node key expires and the user clicks "Login" (or runs
`tailscale up`), the Login() method was cancelling the map poll context.
This caused key extension notifications from the server to be lost,
leaving clients stuck in NeedsLogin state even after an admin extended
their key.

The fix has three parts:

1. Login(): Don't cancel mapCtx if we have valid credentials (loggedIn=true)
   or a valid node key. This allows the map poll to continue receiving
   server notifications while the auth flow proceeds in parallel.

2. mapRoutine(): Poll when we have a node key, even if !loggedIn. This
   handles the tsnet restart scenario where control returns an AuthURL
   (so loggedIn=false) but we still have a valid node key that can
   receive map updates.

3. sendStatus()/UpdateFullNetmap(): Forward netmaps when we have a node
   key, not just when loggedIn. This ensures the backend sees key expiry
   changes even when the auth flow hasn't completed.

"First successful flow wins": if a key extension arrives via map poll,
the client recovers automatically. If the auth flow completes first,
that works too. Either way, the client is no longer stuck.

This aligns with the SeamlessKeyRenewal philosophy: maintain connectivity
paths while authentication proceeds, allowing server-initiated recovery.

Fixes #19326

Change-Id: I26dbbc1fa7c1159ba075362e44d02814355d6b44
Signed-off-by: Avery Pennarun <apenwarr@tailscale.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 19:21:09 +01:00
7fb6751ddd cmd/k8s-operator: rework [unexpected] log lines (#20065)
* cmd/k8s-operator: rework [unexpected] log lines

This commit modifies several places in the operator logs where we
prepend `[unexpected]` to instead use an appropriate logging level.

The `[unexpected]` prefix is intended to be used when the program
violates some internal invariant (or for example, a database has
become corrupted). Many of these cases were simply log lines that
then fell back to a default value/behaviour. These have been releveled
to warnings.

Some of these log lines also seemed extraeneous as for the example of
service reconcilers logging when there is no proxy group annotation. As
far as I can tell we've never had any predicates for limiting the
services reconciled to ones with that annotation, so they can just
be removed to reduce log spam.

Fixes: #cleanup

Signed-off-by: David Bond <davidsbond93@gmail.com>

* Update cmd/k8s-operator/egress-services-readiness.go

Co-authored-by: BeckyPauley <64131207+BeckyPauley@users.noreply.github.com>
Signed-off-by: David Bond <davidsbond@users.noreply.github.com>

* Update cmd/k8s-operator/operator.go

Co-authored-by: BeckyPauley <64131207+BeckyPauley@users.noreply.github.com>
Signed-off-by: David Bond <davidsbond@users.noreply.github.com>

---------

Signed-off-by: David Bond <davidsbond93@gmail.com>
Signed-off-by: David Bond <davidsbond@users.noreply.github.com>
Co-authored-by: BeckyPauley <64131207+BeckyPauley@users.noreply.github.com>
2026-06-11 14:48:48 +01:00
Örjan ForsandGitHub be44e66e99 cmd/tailscale: stop defaulting ssh username to local username (#19358)
Prevent tailscale ssh from automatically adding a username when
connecting to a server, only forward one if provided. The previous
behaviour prevented username overrides in the ssh configuration, since
the provided username takes precedence to the configured one.

This also keeps the tailscale ssh a thin wrapper around ssh by not
adding any extra arguments unless required.

Fixes #19357

Signed-off-by: Örjan Fors <o@42mm.org>
2026-06-11 12:11:37 +01:00
Alex ChanandAlex Chan abe5fbbf49 all: make this spelling mistake non-existant
Updates #cleanup

Change-Id: I088aa91218354f6208190c8f6673f9c5a98e65fc
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-11 10:37:50 +01:00
Alex ChanandAlex Chan e95e2a5932 tka: use a named constant to tidy up sig_test.go
Updates #cleanup

Change-Id: Ib6ff2e678670ecc001207a0b8be02b035958cb88
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-11 10:37:48 +01:00
Brad FitzpatrickandBrad Fitzpatrick 57246f4374 go.mod: bump more things to match corp
I previously (in #20096) had only considered the tailscaled deps
and forgot about the CLI deps. This does the CLI ones too.

containerboot and k8s-operator aren't applicable because they build
from oss already.

Updates tailscale/corp#43243
Updates #20067

Change-Id: I66790f822b5d040e7fcf90feabca24669f69cf61
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-10 23:07:04 -05:00
Brad FitzpatrickandBrad Fitzpatrick 6ab5d91071 go.mod: bump some deps to match corp
Updates tailscale/corp#43243
Updaets #20067

Change-Id: I27e19f34e2216f3ac1a4e2a6b38c0ac473b8c7ad
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-10 21:29:22 -05:00
Brad FitzpatrickandBrad Fitzpatrick a31e527a0a CODEOWNERS: remove blocking reviews
We aren't supposed to be using CODEOWNERS as blocking
reviews, blocking global cleanups.

(This is why we want to move to go/policybot)

Updates tailscale/corp#13972

Change-Id: I380258e2d4ffd0720d57d891adab06c8ca388617
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-10 20:44:11 -05:00
Claus LensbølandGitHub 92ab4866d5 wgengine/magicsock: increase discoKeyAdvertisementInterval to 2 minutes (#20084)
The 1 minute timeout was hitting timers inside wireguard-go, leading
stale connections hanging forever. Increasing the timeout to 2 minutes
makes a small subset of cached connections establish direct connections
slightly slower.

Updates to wireguard-go will allow a better hook for when to send these
messages in the future. This change only makes fixes the error mode but
if we have better triggers coming in wireguard-go, we should be using
those.

Updates #20081

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-06-10 16:25:02 -04:00
Claus LensbølandGitHub 2690d58e47 wgengine/magicsock,tstest/natlab/vmtest: only send callMeMaybe with endpoints (#20088)
9be21088f4 changed sending disco pings so
a callMeMaybe would be not be gated by endpoints existing if the node
was running off of a cached netmap.

This commit partly reverts that change, but keeps in a few bug fixes in
that commit and the tests that was introduced and now skipped.

The behaviour prior to 9be21088f4 is
retained.

Updates #20085

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-06-10 16:19:51 -04:00
David BondandGitHub e4ea65d32d cmd/k8s-operator: workload identity support for multi-tailnet (#20016)
This commit modifies the reconciler for the `Tailnet` custom resource
to allow referenced secrets to specify an `audience` field. If a
referenced secret contains both an `audience` and `client_id` we assume
the user's intention is to use workload identity.

In that case, we configure the tailscale API client to authenticate
using the Kubernetes token request API against the operator's service
account. This requires the operator to be aware of its own service
account name.

A small change has also been made to the messages added to the `Tailnet`
CRD's status field in the even that it is missing scopes to make it
clearer that certain scopes may not be applied.

Closes: #19090
Updates: #19471

Signed-off-by: David Bond <davidsbond93@gmail.com>
2026-06-10 10:22:19 +01:00
Joe TsaiandGitHub 632293de7d logtail: reject absurdly large retryAfter values (#20070) (#20071)
For real, we're supposed to use min, not max.

Updates tailscale/corp#43105

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2026-06-09 15:20:22 -07:00
Joe TsaiandGitHub 3e0d89d75d logtail: reject absurdly large retryAfter values (#20070)
Updates tailscale/corp#43105

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2026-06-09 14:57:41 -07:00
Brad FitzpatrickandBrad Fitzpatrick 1deb6a8449 ipn: add no-disconnect in-process bus subscribers
Add NotifyInProcessNoDisconnect for in-process IPN bus subscribers that
must apply every bus update. When such a subscriber falls behind, block
Notify production instead of sending the terminal fell-behind message and
closing the watch.

This is intentionally not available over LocalAPI, where a slow or stuck
out-of-process client should still be disconnected rather than allowed to
stall tailscaled. In-process callers that use the bit must keep their
callbacks fast and must not call back into LocalBackend from the callback.

Updates #20062

Change-Id: I730ad61a07475243bb226fba2262c1a3ded211ae
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-09 12:51:38 -07:00
Adriano Sela AvilesandAdriano Sela Aviles 913df7e6ea cmd/tailscale/cli: unit tests for tailscale ip
Updates #20035

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-06-09 11:43:24 -07:00
Brad FitzpatrickandBrad Fitzpatrick edcc2c94d9 ipn: enforce lossless IPN bus delta streams
New-style IPN bus subscribers consume stateful delta streams. Reject
NotifyRateLimit when it is combined with those subscription bits so
tailscaled cannot merge or delay messages that clients need to apply in
order.

Also stop silently dropping notifications when a watcher falls behind.
Remove the watcher, replace its stale queue with one terminal ErrMessage
notification, and close the watch.

Updates #20062

Change-Id: Id9d402ea76f4011cd23f122adf62f30dd4b6f90b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-09 11:12:20 -07:00
BeckyPauleyandGitHub 60b935e30f net/dns/resolver: remove deprecated 4via6 magic-dns formats (#20057)
This removes deprecated magic-dns formats for 4via6 subnet routers.

These are superseded by the current format: Q-R-S-T-via-X.

Fixes #20053

Change-Id: I0eed1f057f856f248c4dc8ce3b751f6c7edcfbfd

Signed-off-by: Becky Pauley <becky@tailscale.com>
2026-06-09 18:10:47 +01:00
AnthonyandGitHub 819f3ba7c1 cmd/k8s-operator: allow custom annotations on deployment (#17143)
Fixes #17188

Signed-off-by: Anthony SCHWARTZ <antho.schwartz@gmail.com>
Signed-off-by: Anthony SCHWARTZ <anthony.schwartz@ext.ec.europa.eu>
2026-06-09 15:25:40 +01:00
Doug BryantandGitHub 2767100bc2 net/netmon: skip RTM_MISS route messages on darwin (#20050)
macOS 26.4 emits RTM_MISS on the routing socket for every failed route
lookup. skipRouteMessage never inspected the message type, so each miss
woke the monitor as a link change and triggered a netcheck. On networks
without an IPv6 default route the netcheck's IPv6 DERP probes fail and
emit more RTM_MISS messages, sustaining the loop indefinitely: netchecks
run at roughly 40x the intended rate, with sustained probe traffic and
corresponding CPU and battery cost.

RTM_MISS scales with traffic volume, not network state, and is never
the leading signal for a topology change: route withdrawals emit
RTM_DELETE synchronously before any subsequent lookup can miss, so
ignoring it loses no signal. Other routing daemons (bird, dhcpcd, frr)
ignore it as well.

Same fix as coder/tailscale@e956a95074.

Fixes #19324

Signed-off-by: Doug Bryant <dougbryant@anthropic.com>
2026-06-08 10:45:13 -07:00
Will NorrisandWill Norris 4b1408f4a5 words: June is so full of color
Did you know that Gilbert Baker used the Pantone color scale when
designing the rainbow flag? I suppose that's not too surprising. There
are also other color scales like munsell and werner. I guess the rainbow
itself is a color scale, with its seven "roygbiv" colors. (It's also
a fish, with both a tail and scales.) We have so many ways to measure
color on so many different scales. And it turns out "pride" itself is
a scale.

Updates #words

Signed-off-by: Will Norris <will@tailscale.com>
2026-06-08 09:58:54 -07:00
Mike O'DriscollandGitHub 732bde6e86 tstest/natlab: test home DERP is re-reported after a profile switch (#20051)
Add a vmtest that guards the fix in #20025: after an in-process control
client swap (profile switch / interactive re-login), magicsock's NetInfo
dedup cache (netInfoLast) must be cleared so the structurally-identical
post-switch NetInfo (same PreferredDERP, same NAT shape) is re-reported to
the new control session rather than suppressed as unchanged.

The test brings a node up, pins its home DERP so the reported NetInfo is
identical across the switch, records the home DERP the test control learned,
switches to a fresh login profile on the same control/network/NAT/DERP, and
asserts the control re-learns the same non-zero home DERP for the node's new
identity. Without ResetNetInfoLast the assertion times out at HomeDERP=0.

To support this, vnet now serves the test control on port 443 (TLS) in
addition to port 80: an immediate re-login makes a fresh noise dial, and
because the prior dial was recent the control client forces an HTTPS (443)
dial (controlhttp.Dialer.forceNoise443), which the harness previously did
not answer. The control endpoint gets its own self-signed cert (the existing
selfSignedDERPCert helper, renamed to the generic selfSignedCert); the cert
is not validated since control noise dials authenticate via the Noise
handshake, so it only needs a TLS peer to complete the forced 443 dial.

Add Env.ForcePreferredDERP and Env.Relogin helpers for the above.

Updates #20024

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-06-08 12:29:39 -04:00
Michael Ben-Amiandmzbenami 618b606b46 feature/conn25: expire idle flows from FlowTable
Track lastSeen on each cached flow and add a sweeper goroutine
that periodically removes flows idle past the idle timeout.

Introduce tunables for idle timeout, maximum flows removed per sweep (to
limit mutex hold time), and the sweeper interval.

Also cap the previously-unlimited tables: 10k client flows, 100k
connector flows.

Updates tailscale/corp#38630

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-06-08 11:03:25 -04:00
Alex ChanandAlex Chan 65a117184b all: rename NetworkLock functions/types to TailnetLock
To avoid breaking downstream code, add deprecated aliases for all the
old names.

Updates tailscale/corp#37904

Change-Id: I86d0b0d7da371946440b181c665448f91c3ef8d2
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-06-08 13:14:28 +01:00
Fernando SerbonciniandGitHub 254bb6a43c CODEOWNERS: auto-request k8s-devs review for Kubernetes/container paths (#20020)
Assign the Kubernetes operator, kube libraries, container build
commands, and related paths to @tailscale/k8s-devs.

Updates #cleanup

Change-Id: I9d8c7ebfd9a2b6401dd8cb0ff335151afe58357c

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-06-07 13:24:32 +01:00
Adriano Sela AvilesandAdriano Sela Aviles 83c8440834 cmd/tailscale/cli: add service support to tailscale ip
Fixes #20035

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-06-05 18:49:17 -07:00
Nick KhylandNick Khyl c0d0621417 logpolicy,tsnet: remove syspolicy dependency
tsnet depends on logpolicy, which in turn depended on util/syspolicy
because of a single LogTarget policy setting it uses.

In this commit, we replace that dependency with a feature.Hook,
which only tailscaled or its platform-specific alternatives should set.

Updates #20031

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2026-06-05 16:21:27 -05:00
M. J. FrombergerandGitHub eda975a9e4 wgengine/magicsock: emit first-netmap latency for uncached resets too (#20029)
This is a refinement of #19916. Previously, we would only emit a latency log
when going from a cached netmap to an uncached one (i.e., from the control
plane). We would like to know the latency in both conditions, though, so
instead use the validity of the previous self state.

Updates #12639
Updates tailscale/projects#27

Change-Id: I6bbeb5d3162f1f98cdb3dcd244f67ef31c170957
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-06-05 14:13:16 -07:00
Andrew LytvynovandGitHub c07bf57eba cmd/tailscaled: only warn about unsupported attestation when enabled (#20028)
We don't need to log if the policy doesn't actually say that hardware
attestation must be enabled.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-06-05 14:00:07 -07:00
Mike O'DriscollandGitHub 6a709216b9 ipn/ipnlocal,wgengine/magicsock: re-report NetInfo to new control client (#20025)
magicsock de-duplicates NetInfo callbacks against c.netInfoLast, a cache
that lives on the long-lived magicsock.Conn. That cache survives a control
client swap (interactive login or profile switch), where only the control
client (and its own per-client NetInfo dedup) is replaced. As a result, the
first netcheck after the swap produces a structurally-identical NetInfo
(same PreferredDERP, same NAT shape), magicsock suppresses it as unchanged,
and the new control session never learns our home DERP. Peers can't reach
the node over DERP until some unrelated NetInfo field happens to change.

Add Conn.ResetNetInfoLast to clear the dedup cache, and call it from
LocalBackend.setControlClientLocked whenever a control client is installed,
so the next netcheck re-reports the current NetInfo to the new client.

netInfoLast is only a dedup/optimization cache (all readers nil-guard, and
it is recomputed by every netcheck), so clearing it can only add a delivery,
never lose or misroute one; it is scoped to control-client lifecycle events,
not steady-state operation.

Updates #17887
Fixes #20024

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-06-05 13:36:00 -04:00
Brad FitzpatrickandBrad Fitzpatrick 26864f1302 tstest/natlab: add ACME cert vmtest
This adds a fake vnet ACME service, TXT-backed SetDNS support, and a
VM test that fetches a certificate with tailscale cert, serves it with
tailscale serve, and verifies HTTPS from a second node.

This adds coverage motivated by #19915.

Updates #13038

Change-Id: Ie1e53409509337d81c8fbceb63f59f3dfbd48207
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-05 08:57:17 -07:00
Simon LawandGitHub 84ffcd2759 cmd/tailscale/cli/jsonoutput: provide examples for jsonoutput.DNS* (#19998)
This patch adds examples for unmarshalling the JSON outputs of the
following commands:

	tailscale dns query --json
	tailscale dns status --json

It also adds an example usage of `tailscale dns` to both
jsonoutput.DNSQueryResult and jsonoutput.DNSStatusResult.

Updates #13326
Updates #18750

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-05 02:02:15 -07:00
James TuckerandJames Tucker d0b12dac75 words: they say the long tail tips the scales
Adds eailed critters and scaly ones. No anole was harmed (it was already
in the list), and the loris was turned away at the door for being
suspiciously tailless.

Removes a word that was misread/misinterpreted and starts a rejection
list in the test suite.

Updates #words

Signed-off-by: James Tucker <james@tailscale.com>
2026-06-04 22:51:30 -07:00
Will NorrisandWill Norris e8d169db8d client/systray: fix setting StatusNotifierItem ID
This was supposed to have been fixed in #18739, but either there was
a regression, or it never actually fixed it. In order for the
application title to be used as the ID by the fyne.io/systray package,
systray.SetTitle() must be called before systray.Run().

Updates #18736

Signed-off-by: Will Norris <will@tailscale.com>
2026-06-04 15:43:27 -07:00
Adriano Sela AvilesandAdriano Sela Aviles fc9b18f507 tailcfg: add ServiceActionType constants
Updates tailscale/corp#42661

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-06-04 14:53:15 -07:00
Brad FitzpatrickandBrad Fitzpatrick 6cb3852535 go.mod: bump wireguard-go for memory leak fix
Updates tailscale/corp#42776

Change-Id: I1f91fb542b3476f1a8f0964a47a742e9331f118d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-04 14:40:42 -07:00
Brad FitzpatrickandBrad Fitzpatrick 638b73a68d gokrazy: add two arm64 variants for Pi & VMs
We previously had two GOARCH variants for natlab testing
(gokrazy/natlabapp and gokrazy/natlabapp.arm64), but we didn't have
those variants for the "tsapp" (production, not testing) variant.

This adds arm64 for tsapp too. But there are two major users of arm64
for gokrazy: Raspberry Pis, and cloud VMs. So make two variants.

Updates #1866

Change-Id: Ib9efaf1255101e3cdbc31d49c76fd86dcba21bb1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-04 14:05:23 -07:00
Adriano Sela AvilesandAdriano Sela Aviles 6cd185bf31 tailcfg: add Attributes to Service Actions
Updates tailscale/corp#42661

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-06-04 13:40:05 -07:00
Brad FitzpatrickandBrad Fitzpatrick 772be1b0cc gokrazy, clientupdate: add start of Gokrazy auto-updates, tests
This adds support for Gokrazy GAF (Gokrazy Archive Format) zip
auto-updates, starting to wire up Tailscale's clientupdate mechanism
to Gokrazy's update mechanism.

Currently there's just a CLI command to update from a GAF URL,
with an --unsigned flag for use in a new natlab vmtest.

Next step would be publishing unstable track GAF files on
pkgs.tailscale.com, with detached signatures, and then making the
clientupdate mechanism also download those and check signatures.

Updates #20002

Change-Id: Ib03c56f17a57f8a4638398ef83549dac4813323d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-04 11:20:14 -07:00
Simon LawandGitHub 6ff761c5f8 cmd/tailscale/cli/jsonoutput: fix flag parsing for boolean values (#19996)
SchemaVersion didn’t actually parse boolean values properly, so
calling `tailscale lock status --json=false` would fail with:

	invalid boolean value "false" for -json: invalid integer value passed to --json: "false"

This patch makes SchemaVersion.Set delegate to flag.FlagSet for its
argument parsing, with accompanying tests that ensure that both
boolean and integer values are parsed properly.

It also removes the restriction that prevented the flag from appearing
multiple times in the arguments list. Now, the final flag clobbers all
previous ones, aligning this behaviour with the standard flag package.

We also change the SchemaVersion.String output for the zero value to
"false", so that the default help message doesn’t change when we
switch other commands over from their boolean representations:

	user@host:~$ tailscale whoami --help
	FLAGS
	  --json, --json=false
	        output in JSON format (default false)

Updates #17613

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-04 10:18:47 -07:00
Simon LawandGitHub 0bbaed6af4 cmd/tailscale/cli/jsonoutput: rename exported identifiers (#19994)
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>
2026-06-04 09:55:48 -07:00
Simon LawandGitHub f05e145d7a cmd/tailscale/cli/jsonoutput: improve doc comments and add examples (#19993)
This patch:

1. Removes hardcoded mentions of a `--json` flag from the
   documentation for JSONSchemaVersion, because the type could be used
   for anything.

2. Removes `code` formatting because Go doc comments don’t support
   this syntax.

3. Fixes [links] in doc comments so they link to the types’
   online documentation.

4. Checks that JSONSchemaVersion satisfies the flag.Value interface.

5. Adds documentation examples for using both JSONSchemaVersion and
   ResponseEnvelope.

Updates #17613
Updates #18750

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-04 09:23:49 -07:00
Brad FitzpatrickandBrad Fitzpatrick dfb605db4a cmd/ssh-auth-none-demo: update SSH demo a bit
Per chat with an SSH client author who wanted a URL in the output.

And then make it more clear what parts are banners.

Updates #cleanup

Change-Id: If5033ad9dc0dba3d833f24ea39e117a455010492
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-03 19:36:19 -07:00
Patrick O'DohertyandGitHub 3f5eb31997 go.mod: update tailscale/gliderssh (#19995) 2026-06-03 17:29:54 -07:00
croakerbctsandGitHub 66c8844fd2 VERSION.txt: this is v1.101.0 (#19992)
Signed-off-by: croakerbcts <christopher@tailscale.com>
2026-06-03 15:40:23 -04:00
BeckyPauleyandGitHub 98f1ac0880 cmd/k8s-operator, net/netutil: revert 4via6 changes (#19990)
Reverts support 4via6 in egress proxy and connector (#19863)

Updates #19334

Signed-off-by: Becky Pauley <becky@tailscale.com>
2026-06-03 20:20:36 +01:00
Mario MinardiandMario Minardi cdcb1cb07b go.toolchain.rev: bump to Go 1.26.4
Updates https://github.com/tailscale/corp/issues/42772
Updates https://github.com/tailscale/tailscale/issues/19982

Signed-off-by: Mario Minardi <mario@tailscale.com>
2026-06-03 10:24:00 -06:00
Brendan CreaneandGitHub b26dadf1b5 net/dns/resolver: skip DNS health warning when doing split DNS (#19959)
When MagicDNS is enabled but no global upstream resolvers are configured,
the forwarder only handles specific suffixes and defers other names to the
system resolver. A query it has no resolver for is expected in that case, so
don't raise the dns-forward-failing warning unless a default "." route makes
Tailscale the default resolver.

Fixes #19931

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-06-03 09:14:48 -07:00
Harry Harpham fa542426e5 ipn,ipn/localapi: require local admin to serve Unix domain sockets
This resolves a local privilege escalation (LPE). Prior to this change,
a non-admin user could utilize serve to access local Unix sockets they
otherwise should not be able to access. For example,

  tailscale serve --http 80 unix:/var/run/docker.sock

would give the user access to the Docker socket (usually root only).
This works because tailscaled has root access and implements the proxy
to the socket (see also: 'the confused deputy problem').

We resolve the problem by refusing to serve Unix targets altogether
unless instructed to by a root user.

Thanks to Tim Sageser (dtrsecurity) for this report.

Fixes tailscale/corp#41998

Signed-off-by: Harry Harpham <harry@tailscale.com>
2026-06-03 09:45:02 -06:00
Brad FitzpatrickandBrad Fitzpatrick 40c98cd267 tstest/natlab/vmtest: deflake, de-strictify TestSelfSignedDERPHashPinning
The test was asserting that a tailnet ping between two nodes traversed
DERP rather than going direct. But that wasn't really the point of the test,
and I kept forgetting ways that magicsock could find direct paths and
thus break this test.

So loosen it.

We really just want to see whether DERP worked at all and was used in the process
of getting a ping through, whether it was direct or not.

And that "tailscale debug derp" worked at all, which was what the bug
was about to begin with.

No need for all the "must be over DERP" stuff.

Updates #15579

Change-Id: I70ca63dc10919efa3d193b7af1d31a4a3b9d3950
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-03 06:00:18 -07:00
KabirandGitHub 01c59d84a0 cmd/tailscale/cli: show services in serve status (#19600)
The "tailscale serve status" human-readable output previously showed
only serve-based proxies, not services.

Fixes https://github.com/tailscale/corp/issues/34163

Change-Id: Ie48858a8d8afd7184979d0fe2ab21ebd6fd0d4a0

Signed-off-by: Kabir Sikand <kabir@tailscale.com>
2026-06-02 17:09:54 -04:00
Brad FitzpatrickandBrad Fitzpatrick 9107354488 tstest/natlab/vnet: send unsolicited IPv6 Router Advertisements
vnet 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>
2026-06-02 12:59:27 -07:00
c91b7188e8 ipn/localapi,tstest/natlab: fix debug derp TLS check for sha256-raw CertName
serveDebugDERPRegion built its TLS config with
ServerName: cmp.Or(derpNode.CertName, derpNode.HostName), which for a
"sha256-raw:<hex>" CertName passed the raw fingerprint to Go's stock
verifier as a hostname; the handshake always failed with a hostname
mismatch. This is the second half of #15579; the first half (tailscaled
itself failing with "unexpected multiple certs presented") was fixed in

Extract a tlsConfigForNode helper that mirrors derphttp.Client.tlsClient
so that sha256-raw and domain-fronting CertName values are dispatched
to tlsdial.SetConfigExpectedCertHash and tlsdial.SetConfigExpectedCert
respectively, falling back to HostName when CertName is empty.

The core fix here was originally written by @imnuke in #19965; that PR
also added a unit test in ipn/localapi/debugderp_test.go which is
replaced in this commit by a new vmtest that exercises the whole stack:
vnet now serves a self-signed cert valid for each fake DERP node's
HostName and exposes its SHA-256 fingerprint, and vmtest grows a new
SelfSignedDERPCertPinning EnvOption that swaps the test DERP map's
nodes to CertName="sha256-raw:<hex>" with InsecureForTests cleared.
TestSelfSignedDERPHashPinning then stands up two hard-NAT'd nodes, has
them communicate over DERP, and calls DebugDERPRegion on each. Before
this fix the test fails with the exact x509 hostname-mismatch error
from the original bug; after, it passes.

Updates #15579

Change-Id: I61f38ffebc7ac5abc962639db1ae88f5cd8633b1
Co-authored-by: Nuke <nuke@imnuke.dev>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-02 12:02:40 -07:00
Brad FitzpatrickandBrad Fitzpatrick 52400dc6f4 ipn/ipnlocal: add back a watchdog after earlier removal from engine
Commit 2b338dd6a8 removed watchdogEngine because it was weird
(so many methods) and increasingly unnecessary after we'd cleaned up
and simplified so much of the locking.

This adds back a watchdog, but an easier to maintain one that's more
idiomatic.

Updates #19759

Change-Id: I86c458473e126c0809f37696446ce7acf4cc4eb9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-02 11:57:12 -07:00
Jamie SinnandBrad Fitzpatrick a846665599 Add --strip option to build_dist
Add support for --strip option to strip symbols.

Building a rather custom binary with custom flags needs some additional work, and thought to contribute this back up.

Signed-off-by: Jamie Sinn <james.sinn@sinndevelopment.com>
2026-06-02 10:59:29 -07:00
Patrick O'DohertyandGitHub e69e24d224 go.mod: bump golang.org/x/image@v0.41.0 (#19970)
Bump golang.org/x/image@v0.41.0 to resolve govulncheck

Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-06-02 09:39:33 -07:00
M. J. FrombergerandGitHub a3bec699dc wgengine/magicsock,types/logger: add latency logs for initial peer contacts (#19916)
In order to allow us to measure the performance effects of client-side netmap
caching, both with and without the feature enabled, add logs to record how long
it takes after a client restart or profile switch for the node to establish
contact with peers, relative to the first uncached netmap.

We do this by keeping track of a timestamp when the connection is constructed,
and logging a record for "new" peer contacts that records how long (in
microseconds) it took from the time the peer was recorded as a candidate.  The
message includes whether the contact was via DERP or direct, and whether a
cached netmap was in use at the time.

This builds on and extends the counters from #19699, but here we include new
contacts whether or not a cached netmap is in use, so that we can establish a
baseline for comparison.

Updates #12639
Updates tailscale/projects#27

Change-Id: I4f6d050e221f3881848d05a0425c4a5d1a59294c
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-06-02 07:34:17 -07:00
Simon LawandGitHub c898aeb0d8 .github/workflows: fix -run='^$' quoting when skipping all tests (#19962)
This bug was surfaced by #19960 because benchmarks shouldn’t have run
TestListenService, but they did because PowerShell interpreted match
empty string `"^$"` as beginning of string `'^'`.

This patch has the Windows build run `./tool/go` binaries with bash
and synchronizes it with the *nix `bench all` run.

Updates #18884
Updates #19960

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-01 21:20:10 -07:00
Charlotte SomandWill Norris 7ba49cbcbb words: add 'flops' to the list of scales
floating point operations per second is a measure
of computational throughput

Signed-off-by: Charlotte Som <charlotte@som.codes>
2026-06-01 18:18:54 -07:00
Simon LawandGitHub b47dd932f3 cmd/tailscale/cli: use tstime constant for tailscale routecheck (#19957)
Updates #19928

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-01 17:42:18 -07:00
ferrumclaudepilgrimandBrad Fitzpatrick 3f70abdc6f cmd/tailscaled, version/distro: default to userspace-networking on Crostini
cros-garcon NULL-derefs on cold-boot netlink enumeration when
tailscale0 is present, preventing the Crostini container and
ChromeOS Terminal from starting cleanly. This is an upstream
ChromiumOS bug in cros-garcon; tailscaled can work around it
by defaulting to userspace-networking mode on Crostini.

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

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

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

RELNOTE: Crostini now defaults to userspace-networking.

Fixes #19488
Updates #12090

Signed-off-by: ferrumclaudepilgrim <ferrumclaudepilgrim@users.noreply.github.com>
2026-06-01 17:40:07 -07:00
Brad FitzpatrickandBrad Fitzpatrick a6ab7efa4f ipn/ipnlocal, cmd/tailscale/cli: auto-renew TLS certs and warn while pending
The Tailscale daemon only refreshed TLS certs as a side effect of inbound
TLS handshakes or "tailscale cert" CLI calls. A node that doesn't see
inbound traffic during the renewal window silently rolls past expiry.

Add a once-per-hour background loop on LocalBackend that enumerates Serve
and Funnel HTTPS hostnames (filtered against the netmap's CertDomains so
we don't poke ACME for other nodes' service hostnames) and calls the
existing GetCertPEM path. The renewal decision (ARI window, then 2/3
expiry fallback) is unchanged; the loop just guarantees it runs.

For visibility during initial issuance or restart with a long-expired
cached cert, add a "tls-cert-pending" health Warnable that's set while
ACME is in flight and no usable cached cert exists. Async renewal of a
still-valid cert intentionally doesn't fire it. And then make the CLI "cert"
subcommand print out a warning if it's blocking due to a cert fetch
in flight, using that health info.

Fixes #19911
Fixes #19912

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I144e46c40e957b2e879587decace32a523a6eade
2026-06-01 16:31:54 -07:00
Simon LawandGitHub 92bfda580c cmd/tailscale/cli: fix time in tailscale routecheck (#19956)
When running `tailscale netcheck`, the reported timestamp used to be
in UTC and formatted according to RFC 3339 with a `T` to separate the
date from the time:

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

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

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

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

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

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

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

Fixes #19928

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-01 16:12:08 -07:00
M. J. FrombergerandGitHub 8a63c023f0 tailcfg: add a node attribute to explicitly disable netmap caching (#19947)
Add a new tailcfg.NodeCapability (NodeAttrDisableCacheNetworkMaps) to allow the
policy document to override whether a node will receive the cache-network-maps
attribute by default. The client does not interpret this attribute directly, it
is used to influence decisions by the control plane.

As of 2026-06-01, cache-network-maps is only sent when explicitly requested by
the policy. In a future version, we will send it by default for clients with a
sufficient capability version (to be added in a future commit), except to
ephemeral nodes, unless the policy sets disable-cached-network-maps.

Updates #12639
Updates tailscale/projects#28

Change-Id: I6376376d7898f7da8db977e457dcd45df9deef41
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-06-01 15:16:45 -07:00
Brad FitzpatrickandBrad Fitzpatrick d64aaffc06 control/controlclient: fix map context race
Capture Auto.mapCtx while holding Auto.mu before using it for
incremental map update forwarding. Pause and restart paths can replace
the context under the same mutex, so using it after unlocking races
with those writers.

Add a race regression test for the UserProfiles path that repeatedly
cancels the map context while incremental profile updates are
forwarded.

Fixes #19953

Change-Id: Icc55c4a0dffbc16d6507a2b446b3909d4d0a0278
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-01 13:44:19 -07:00
Brad FitzpatrickandBrad Fitzpatrick c234dcc2ef go.mod: bump wireguard-go
https://github.com/WireGuard/wireguard-go/compare/e3ac4a0afb4e...b48af7099cad

Updates tailscale/tailscale#7053
Updates tailscale/corp#36989
Updates tailscale/tailscale#19820

Change-Id: I5652535bd32d3784702dcd2544abd430c2c95c96
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-01 12:53:18 -07:00
7f3bbc9865 net/netutil: add NewDefaultTransport to avoid http.DefaultTransport panics
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>
2026-06-01 12:28:36 -07:00
License UpdaterandWill Norris 5495eb7e1a licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-06-01 12:09:49 -07:00
Brad FitzpatrickandBrad Fitzpatrick 0d92a69259 cmd/tailscale/cli: add "tailscale get" command
This adds @alexwlchan's proposed "tailscale get" command that reads
current preference values, complementing "tailscale set". It uses the
same flag names as set.

  tailscale get              # show all settings as a table
  tailscale get all          # same
  tailscale get accept-dns   # show a single value
  tailscale get --json       # output as JSON object
  tailscale get --set-flags  # output as tailscale set argv

Fixes #11389
Fixes tailscale/corp#38702

Change-Id: Ie366f27f11ccc56c76fff9a94ed8a9de9c835bd0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-01 11:59:33 -07:00
Simon LawandGitHub 2d6844c565 cmd/tailscale/cli: add routecheck command (#19641)
Introduce a new `tailscale routecheck` command which prints a report
of high-availability routers that are reachable.

This command rhymes with the `tailscale netcheck` command and but
instead of reporting on local network conditions, `routecheck` reports
on remote connectivity.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-01 11:50:24 -07:00
Naman Sood da51072b98 feature/conn25: send TSMP message to client for no IP mapping on connector
When a connector receives a packet from a client on a transit IP that it
can't find a real IP mapping for, it drops the packet. This commit
starts notifying the client of this dropping over TSMP, so the client
can tell the connector to re-establish the transit IP-real IP binding.

Updates tailscale/corp#34256.

Signed-off-by: Naman Sood <mail@nsood.in>
2026-06-01 14:46:27 -04:00
Evan LowryandGitHub 4f07a071e7 client/systray: don't repeat account name for single-user tailnets (#19930)
Single-user tailnets often have the same tailnet display name as login
name.

This change omits the duplication when matching, and skips the
user-switching submenu when only one account is configured, to clean up
the account display a little bit.

Fixes #16889

Signed-off-by: Evan Lowry <evan@tailscale.com>
2026-06-01 15:25:45 -03:00
Brad FitzpatrickandBrad Fitzpatrick d961e44856 cmd/testwrapper: auto-retry every failing test
Previously, testwrapper only retried tests explicitly annotated with
flakytest.Mark. Authors don't pre-emptively mark tests that haven't
flaked yet, so the first flake of a brand-new test failed CI even
when a re-run would have passed.

testwrapper now retries every failing test within a per-test wall-clock
budget (default: 5 minute per-attempt timeout capped at 1.5x the first
failure duration, 10 minute total). A test that fails and then passes
on retry is reported as flaky; a test that never passes within the
budget remains a real failure (exit non-zero).

For flakeapp's existing log scraping, the wire format is preserved:
the "flakytest failures JSON:" line is now emitted only for tests
that ultimately flaked (passed on retry). Unmarked tests get a fake
issue URL of the form https://github.com/{owner}/{repo}/issues/UNKNOWN
where owner/repo is detected from GITHUB_REPOSITORY, the local git
remote, or falls back to tailscale/tailscale. A new "permanent test
failures JSON:" line is emitted for tests that never passed; flakeapp
ignores it for now (a follow-up can teach it to record real failures
separately).

flakytest.Mark stays as an opt-in API: still useful for tracking a
known-flaky test against a real issue and for TS_SKIP_FLAKY_TESTS.

Updates tailscale/corp#38960

Change-Id: I56dfc9b023486d239f60793a53e9690578ce8017
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-06-01 11:07:56 -07:00
Simon LawandGitHub 2ee9eacb94 client/local,ipn/localapi: add /localapi/v0/routecheck endpoint (#19640)
In order to support a `tailscale routecheck` command, we introduce the
`/localapi/v0/routecheck` endpoint to the local API. This endpoint
returns the most recent report collected by the routecheck client.
If `force=true` is an argument in the query string, then this endpoint
will actively probe before returning the report.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-06-01 11:06:14 -07:00
Simon LawandGitHub 28801674a6 net/routecheck: introduce new package for checking peer reachability (#19639)
The routecheck package parallels the netcheck package, where the
former checks routes and routers while the latter checks networks.
Like netcheck, it compiles reports for other systems to consume.

Historically, the client has never known whether a peer is actually
reachable. Most of the time this doesn’t matter, since the client will
want to establish a WireGuard tunnel to any given destination.
However, if the client needs to choose between two or more nodes,
then it should try to choose a node that it can reach.

Suggested exit nodes are one such example, where the client filters
out any nodes that aren’t connected to the control plane. Sometimes an
exit node will get disconnected from the control plane: when the
network between the two is unreliable or when the exit node is too
busy to keep its control connection alive. In these cases, Control
disables the Node.Online flag for the exit node and broadcasts this
across the tailnet. Arguably, the client should never have relied on
this flag, since it only makes sense in the admin console.

This patch implements an initial routecheck client that can probe
every node that your client knows about. You should not ping scan your
visible tailnet, this method is for debugging only.

This patch also introduces a new OnNetMapToggle hook, which fires when
the netmap transitions from nil to non-nil, or vice versa. This
happens either when the client receives its first MapResponse after
connecting to the control plane, or when it clears the netmap while it
is disconnecting. Routecheck uses this to wait for a valid netmap
so it knows which peers to probe.

Updates #17366
Updates tailscale/corp#33033

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

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

Thanks to Tim Sageser (dtrsecurity) for this report.

Updates tailscale/corp#42033

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-06-01 09:19:27 -07:00
Brad FitzpatrickandBrad Fitzpatrick 2ba426802f ipn/ipnlocal: fix 'tailscale status --peers=false' missing user profile
Fixes #19894

Change-Id: I310504987170e0742480c8a02706eb0dbf4ec3dc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-31 20:34:43 -07:00
Martin ZihlmannandBrad Fitzpatrick 3ef42d8b0b derp/derphttp: drop dial-only proxy port test
Signed-off-by: Martin Zihlmann <martizih@outlook.com>
2026-05-31 19:22:11 -07:00
Martin ZihlmannandBrad Fitzpatrick 48eba4e971 derp/derphttp: add tests for proxied CONNECT port selection
Adds two tests covering the fix in 0e4c8fc92:

TestDialNodeUsingProxyPort exercises dialNodeUsingProxy directly via a
stub CONNECT proxy, asserting the recorded target across four cases:
HTTPS/HTTP default fallback and explicit DERPPort override for each.

TestConnectThroughProxyHonorsDERPPort drives the full path end-to-end:
a real derpserver on an ephemeral TLS port, a real CONNECT proxy that
tunnels bytes bidirectionally, and a region client routed through it
via feature.HookProxyFromEnvironment. Without the fix, Connect fails
because the proxy is asked to dial :443.

Signed-off-by: Martin Zihlmann <martizih@outlook.com>
2026-05-31 19:22:11 -07:00
Martin ZihlmannandBrad Fitzpatrick 4c8c0baf2b derp/derphttp: honor DERPNode.DERPPort in proxied CONNECT dial
dialNode picks the destination port from n.DERPPort when non-zero,
falling back to 443 (or 3340 when useHTTPS is false). The proxy path,
dialNodeUsingProxy, hardcoded "443" in the CONNECT target, so a DERP
server reachable only on a custom port was unreachable through
HTTPS_PROXY: the proxy would faithfully tunnel to :443 at the DERP
hostname, and TLS would either fail cert validation or talk to the
wrong service.

Mirror dialNode's port selection so both paths behave the same.

Fixes #19748

Signed-off-by: Martin Zihlmann <martizih@outlook.com>
2026-05-31 19:22:11 -07:00
Jordan WhitedandJordan Whited 8a294e3c34 net/batching: reset Buffers len in WriteBatchTo
In case we land on this branch during a goto retry. Also, protect
Geneve offset from mutation across retries.

Fixes #19927

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-05-31 06:12:53 -07:00
Brad FitzpatrickandBrad Fitzpatrick 3e34e721e8 tsnet: add opt-in SSH support (Server.ListenSSH)
This adds tsnet.Server.ListenSSH which, if the SSH feature is linked,
returns a net.Listener whose Accept yields *tailssh.Session values (as
net.Conn). This lets tsnet apps accept incoming SSH connections to
implement custom TUI applications.

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

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

Updates tailscale/corp#37839

Change-Id: I4e7c3c96afb030cdf4da8f2d8b2253820628129a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-30 14:17:50 -07:00
Fran Bull c9333854fb appc,feature/conn25: use custom scheme resolvers for conn25
Currently we are picking a peer for the split dns routes when we get a
netmap. Use the new custom scheme resolvers, installed per app in the
config in the netmap, to allow us to choose which connector peer should
handle a DNS request at the time the request is made.

Fixes tailscale/corp#39858

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-29 12:23:47 -07:00
Simon LawandGitHub 5d935c8900 net/traffic: add fuzz test for sorting nodes by traffic score (#19893)
In PR #19682, we introduced the traffic package which provides a
traffic.Scores.SortNodes method that uses rendezvous hashing to
break ties by equally distribute the “best” node for any given client.

This PR adds a fuzzer to make sure this algorithm is not wildly unfair.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-29 11:55:49 -07:00
Jordan WhitedandJordan Whited 8b58bd6c64 net/batching: implement NodeAttrNeverGSOEqualTail
This NodeCapability works around the UDP GSO bugs introduced by
torvalds/linux@b10b446 (v7.0-rc1). These bugs were later fixed by
torvalds/linux@78effd8 and torvalds/linux@5f17ae0 (v7.1-rc5). These
Linux kernel bugs cause mangled UDP headers and UDP checksums, resulting
in high levels of packet loss.

The aforementioned bugs have already made their way downstream into
various distros, e.g. Ubuntu 26.04 LTS. Impacted users are now dealing
with poor UDP performance in tailscaled, and in any other software that
makes use of UDP GSO.

Not all users of the affected kernels are impacted as the relevant
kernel code path sits between kernel and netdev driver, and behaviors
vary by driver/device capability.

We cannot detect impact at runtime, as this would require gathering all
netdevs, and performing loopback tests. This is invasive and in many
cases impossible.

So, we are left to choose between disabling UDP GSO for all users on
affected kernels, whether they experience real impact or not, or try
and work around the bugs. Disabling UDP GSO for a user that is not
impacted can cut max throughput in half, and consume more CPU cycles.

This commit attempts to workaround the bugs by avoiding UDP GSO when
batches are small, and injecting a 1-byte sentinel tail payload when
they are large. This tail payload is smaller than "GSO size", which
sidesteps the primary trigger of all fragments in a batch being
equal in length.

The end result is slightly increased payload and packet overhead, but
functional UDP GSO for all Linux 7.0-7.1.4 users, regardless of
netdev/driver.

Updates #19777

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-05-29 11:36:35 -07:00
kari-tsandGitHub 7355116c05 ipn/store: make WriteState(id, nil) delete key instead of adding nil entry (#19920)
All StateStore implementations store a nil value in the cache map when WriteState is called with a nil byte slice instead of deleting the key. This causes ReadState to return (nil, nil) instead of (nil, ErrStateNotExist), since the key is still present in the map.

This breaks reset-auth in Windows, Linux, and Android, and the node can't log back in without manually editing the state file. (macOS uses a different state store)
DeleteProfile, DeleteAllProfilesForUser, setUnattendedModeAsConfigured are impacted but don't seem to break because the deleted keys are not reread.

This deletes the key from the cache instead.

Fixes tailscale/corp#42477

Signed-off-by: kari-ts <kari@tailscale.com>
2026-05-29 11:22:14 -07:00
Fran Bull 3d5102090f feature/conn25: use new pool nodeattr
We have been reading the pool config from the app nodeattr, but it is
global config, not per app, so it needs to be its own thing.

Updates tailscale/corp#39999

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-29 08:29:34 -07:00
Brad FitzpatrickandBrad Fitzpatrick 412c812d76 ipn/ipnlocal: use ACME ALPN for authorized Funnel non-CertDomain domains
If a user explicitly adds a non-ts.net (not a CertDomain domain) domain
like "foo.com" to their serve config as a web target that's also an allowed
funnel domain (using raw "tailscale serve set-config"), then use the new
ALPN cert fetching (from b553969b) to get certs for that domain.

This is just plumbing; there's no new product functionality to
actually enable this easily client-side, and it also has no visible
product surface to enable it server-side.

Updates tailscale/corp#41736

Change-Id: Ie2e421ac9611bce64bba3de6a454b2d505ea0e8a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-28 13:33:45 -07:00
Tom ProctorandGitHub 788a49eca5 .github/workflows: run vet on GitHub-hosted runners (#19913)
The github-ci-vm machine that runs our self-hosted CI for this repo is
only designed for the `vm` job in test.yml. That uses a different cache
dir which is causing github-ci-vm's small disk to fill up. Switch to
ubuntu 24.04 like the rest of our CI for this repo that doesn't require
anything special.

Updates tailscale/corp#40465

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2026-05-28 21:30:46 +01:00
524a374f01 tsnet: wait for peer in netmap before pinging in setupTwoClientTest
If we dispatch a ping too early (after a later patch removes a 250ms
blockage) then the ping may be lost due to the peers not yet knowing
about each other. The ping is retained in order to setup and ensure a
wireguard session prior to test flow.

Updates #19822

Change-Id: I6cfea28931646a9387b6ffc2654e72cd846f4e55
Signed-off-by: James Tucker <james@tailscale.com>
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-28 11:27:54 -07:00
Brad FitzpatrickandBrad Fitzpatrick c086992f4f cmd/tailscale/cli: add whoami subcommand
Add a "tailscale whoami" subcommand that is equivalent to running
"tailscale whois $(tailscale ip -4)" but more ergonomic. It supports
the --json flag just like whois, and shares the WhoIsResponse
rendering code with whois.

Fixes #19907

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I8f33ba7a5608bab7dffa8213303beb5f345936d3
2026-05-28 10:49:17 -07:00
Alex ChanandAlex Chan 9d126aec34 all: remove network lock references from private method names
Updates tailscale/corp#37904

Change-Id: I312d46d958209ca3d1152d1877fb91a57c91798d
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-28 18:00:36 +01:00
Brendan CreaneandGitHub 8d90a6ab1e ipn/ipnlocal: add HTTP/2 Content-Type tests for serve reverse proxy (#19905)
Adds two tests exercising the HTTP/2-inbound -> plaintext HTTP/1.1 backend
path through serve's reverseProxy and through the full serveWebHandler
entry point (with a funnel serveHTTPContext).

Updates #19866

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-05-28 09:46:36 -07:00
Alex ChanandAlex Chan f4a280cdbd all: update a few more references to network/tailnet lock
Updates tailscale/corp#37904

Change-Id: I746b06328e080fa2b9ff28a2d099f95645aa3d0b
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-28 16:44:16 +01:00
Alex ChanandAlex Chan 446ae97491 ipn: improve --exit-node hostname error during startup
When parsing the `tailscale up --exit-node=ARG` argument, we try to
resolve hostnames by searching the list of peers. However, at startup,
the peer list is empty, causing hostname lookups to trivially fail with
an unhelpful "invalid value" erorr.

Improve the error message when the peer list is empty to inform the user
that hostnames cannot be resolved during startup, and advise them to use
the exit node's Tailscale IP address instead.

Also, clarify that hostnames must be peer hostnames, not arbitrary
hostnames.

Fixes #19882

Change-Id: I9390a427c2863d657cf46c5e33b43cb3c5363764
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-28 16:43:45 +01:00
4b8115bb2c cmd/containerboot: clamp MSS to PMTU for proxy group pods (#19686)
Single-pod ingress/egress proxies already called ClampMSSToPMTU when
setting up forwarding rules, but the proxy group (HA) code paths in
egressservices.go and ingressservices.go did not. This caused TCP
connections through proxy group pods to suffer from MSS/MTU mismatch
issues in environments where path MTU discovery is not working.

Add ClampMSSToPMTU calls in the egress sync loop (alongside the existing
EnsureSNATForDst call) and in addDNATRuleForSvc (alongside the existing
EnsureDNATRuleForSvc call), mirroring what the single-pod forwarding
rules already do.

Also add MSS clamping assertions to TestSyncIngressConfigs and track
ClampMSSToPMTU calls in FakeNetfilterRunner.

Fixes issue #19812 https://github.com/tailscale/tailscale/issues/19812.
Tracking internal ticket TSS-86326.

Signed-off-by: Jay Tung <ltung@crusoeenergy.com>
Co-authored-by: Jay Tung <ltung@crusoeenergy.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:57:38 +01:00
Brad FitzpatrickandBrad Fitzpatrick 782c73bf41 cmd/containerboot: fix data race in TestContainerBoot
Parallel subtests share *ipn.Notify pointers (e.g. runningNotify).
When multiple subtests reached the same phase concurrently, they
all wrote to the shared notify's InitialStatus field without
synchronization, triggering the race detector.

Fix by shallow-copying *ipn.Notify before setting InitialStatus,
so each test iteration works on its own copy.

Updates #19380

Change-Id: I9dd40037e02146166f006f4f7c1ddcc47adba191
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 18:40:03 -07:00
James TuckerandJames Tucker 25b8ed8d9e control/controlknobs,net/{batching,tstun},wgengine: add nodecaps to disable UDP & TUN GRO/GSO
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>
2026-05-27 17:10:14 -07:00
Brad FitzpatrickandBrad Fitzpatrick 94af1b00fb cmd/testwrapper, tstest: move test sharding out of test code
Previously, sharding required tests to opt in by calling tstest.Shard,
which used a process-global counter to assign each test to a shard.
This had two problems: most tests didn't call it, so they ran on every
shard (defeating the purpose), and shard assignments were unstable
(depended on call order, so adding a test could reshuffle others).

Remove tstest.Shard and tstest.SkipOnUnshardedCI entirely. Instead,
have testwrapper implement sharding automatically for all tests: when
TS_TEST_SHARD=N/M is set, it uses "go list -json" (no compilation) to
find test source files, scans them for top-level Test/Benchmark/
Example/Fuzz function names, and filters by fnv32a(name) % M == N-1.
The filtered names are passed as an anchored -run regex to go test.

Using go list instead of "go test -list" avoids linking the test binary
twice (Go's build cache does not cache test binary linking).

Fixes #19886

Change-Id: I62ab7b3d757324d4c5fd0b5de50c1e3742681791
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 16:53:17 -07:00
James ScottandGitHub db60aa8eca logtail: gate "logtail started" behind TS_DEBUG_LOGTAIL envknob (#19891)
Gates the unnecessary "logtail started" message behind
the debug envknob TS_DEBUG_LOGTAIL. This is extra log spam that isn't
needed unless we are debugging.

Updates tailscale/corp#40908

Signed-off-by: James Scott <jim@tailscale.com>
2026-05-27 15:48:44 -07:00
kari-tsandGitHub 1a17ec1988 net/netmon: in Android, replace system/bin/ip call with cached LinkProperties gateway (#19804)
bind() on NETLINK_ROUTE sockets does not work on Android 11+ (https://developer.android.com/identity/user-data-ids#mac-11-plus) . Since system/bin/ip uses bind(), likelyHomeRouterIPHelper() always fails on Andoroid 11+, so that GatewayAndSelfIP never caches the result, causing repeated ip process spawns on every periodic ReSTUN.

This replaces the system/bin/ip fallback with a cached gateway IP pushed from Android’s ConnectivityManager via LinkProperties.getRoutes(). This is the same patterm used by UpdateLastKnownDefaultRouteInterface for the interface name (see https://github.com/tailscale/tailscale/pull/11784/). We keep the proc/net/route path as a fallback for early startup before NetworkChangeCallback has fired.

Updates tailscale/tailscale#18622
Updates tailscale/tailscale#13352

Signed-off-by: kari-ts <kari@tailscale.com>
2026-05-27 15:42:48 -07:00
Brad FitzpatrickandBrad Fitzpatrick c9fb05b6f5 ipn/ipnlocal: don't dup-suppress UserProfiles on IPNBus on profile switches
Fixes #19889

Change-Id: I324a735c13772c0c79ed7392c0baa5064b34823b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 14:47:02 -07:00
Brad FitzpatrickandBrad Fitzpatrick 364b952d62 cmd/containerboot: track peers from IPN bus updates, stop using netmap.NetworkMap
Some tests in another repo were broken by tailscale/tailscale#19607.
This fixes them, by finishing off the rest of the migration away from
netmap.NetworkMap on the IPN bus in containerboot.

Containerboot used to rebuild a full NetworkMap-shaped view while
reacting to IPN bus notifications. Now it insteads has its own
netmapState type (immutable) of exactly what it needs to track, and
sends those immutable values around, making cheap edits of new
immutable values when an IPN bus edit arrives.

This should make cmd/containerboot scale to much larger tailnets now too.

Fixes #19852
Fixes tailscale/corp#42347
Updates #12542

Change-Id: I88adaf061f85f677f954a764935e6654329d75a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 14:12:48 -07:00
Fran Bull 80dc7a8d07 feature/conn25: disallow addrs assignment overwriting.
We don't want addr assignments to be lost from the collection before
they can be returned to the IP pools, otherwise we will get orphan
addresses marked inUse in the pools that will never be returned.

Fixes tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-27 13:54:40 -07:00
Patrick O'DohertyandGitHub 8501be1990 go.mod: bump dependencies to resolve govulncheck warnings (#19884)
Bump the following:
  go get -u github.com/moby/spdystream@v0.5.1
  go get -u golang.org/x/crypto@v0.52.0
  go get -u golang.org/x/net@v0.55.0

to resolve open govulncheck warnings.

Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-05-27 12:24:59 -07:00
James TuckerandJames Tucker dea49bb4da net/batching: add envknobs to disable UDP GRO & GSO
It is sometimes useful when diagnosing subtle and specific performance
problems to rule out GRO/GSO independently and/or toggle them to
influence packet pacing.

Updates #17835
Updates tailscale/corp#31164

Signed-off-by: James Tucker <james@tailscale.com>
2026-05-27 12:05:00 -07:00
James TuckerandJames Tucker d1912167dc feature/taildrop: replace outgoing-file progress channel with synchronous reporter
serveFilePut tracked outgoing-file progress through an unbuffered
progressUpdates channel whose close was owned by the request goroutine
while writers were spread across manifest parsing, the
progresstracking.Reader callback, singleFilePut failure paths, and the
success path. That writer-closes mismatch made the
send-on-closed-channel panic effectively unfixable in place.

Replace it with a request-scoped outgoingProgress reporter. Transfer
code reports state by method call; the reporter coalesces hot-path
updates and is flushed once via defer in serveFilePut. With no
producer channel to close, the panic is structurally impossible.

Fixes #19115
Fixes #19817

Change-Id: I8f00d982d2c79880dfc1f8104c5eed06e94b5a6c
Signed-off-by: James Tucker <james@tailscale.com>
2026-05-27 12:00:34 -07:00
Brad FitzpatrickandBrad Fitzpatrick f277bfb09d release/dist/synology: add GOARM=7,softfloat mode for hi3535
Fixes #6860

Change-Id: I36f3101e75dab35d03e76693555ac93da893f8d5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 10:54:15 -07:00
Claus LensbølandGitHub 9be21088f4 wgengine/{,magicsock},tstest/natlab/vmtest: send disco on cached netmap (#19878)
Originally found when adding tests for working with cached netmaps, and
finding the added tests to be flakey.

When working off of a cached netmap, if a node exists in the cached
netmap but does not yet have any endpoints, DERP connections are
available but not direct ones. By sending callMeMaybe to nodes
without endpoints in the cached netmap, we can establish direct
connections for this edge case.

Aditionally, ensure that TSMP disco advert messages are not sent if the
endpoint does not have a valid address yet.

Fixes #19843
Updates #19597

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-05-27 13:05:12 -04:00
Brad FitzpatrickandBrad Fitzpatrick b553969b03 ipnlocal: try ACME TLS-ALPN for Funnel renewals
Use TLS-ALPN-01 for Funnel certificate renewals only when the node
already has a cached certificate, and fall back to DNS-01 with a fresh
order if the ALPN path is unavailable or fails.

Dynamically advertise acme-tls/1 only while an ACME challenge
certificate is pending, and add client metrics for DNS-01 and
TLS-ALPN-01 start/success/failure paths.

Updates tailscale/corp#41736
Fixes tailscale/corp#42320

Change-Id: I5adc6ea129237f9ef592f84fc1a8953c80bc9d5c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 09:30:23 -07:00
Jordan WhitedandJordan Whited 4aef023765 cmd/tailscaled,types/logger: remove TS_DEBUG_MEMORY and associated logger
Commit e5a8cf3b1 added feature/runtimemetrics, which emits heap bytes
and total process memory as clientmetrics when the
NodeAttrEmitRuntimeMetrics capability is set. That subsumes the job of
the TS_DEBUG_MEMORY envknob, whose only effect is to prefix every log
line with Go heap+stack and Maxrss via logger.RusagePrefixLog.

Updates tailscale/corp#39434

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-05-27 09:09:05 -07:00
Artem LeshchevandGitHub 5652b6c9c0 cmd/k8s-operator: fix token exchange for identity federation (#19845)
tailscale-client-go-v2 natively supports identity federation authentication,
and in #19010 the required authentication provider is used, but the manual
token exchange was never removed, so we were exchanging JWT token to an auth
token, and then were trying to use that auth token for exchange once again.
This commit removes the legacy mechanism, fully relying on
tailscale-client-go-v2 to handle authentication.

Fixes #19844

Signed-off-by: Artem Leshchev <matshch@avride.ai>
2026-05-27 16:45:07 +01:00
License UpdaterandWill Norris 77010351f0 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-05-27 08:38:44 -07:00
Brad FitzpatrickandBrad Fitzpatrick 2c965ab540 types/netmap, ipn/ipnlocal, control/controlclient: rename NodeMutationAdd to NodeMutationUpsert
NodeMutationAdd was a misleading name: a PeersChanged entry in a
MapResponse can represent either a truly new peer or a full
replacement for an existing peer that couldn't be expressed as a
PeerChangedPatch. Calling it "Add" implied it was always a completely
new node, which is wrong.  (I'd changed my mind on the design of
mapping add/delete events to NodeMutations halfway through #19607 and
forgot to update the name, even though I'd updated half the docs)

Rename it to NodeMutationUpsert to reflect the actual semantics: the
node should be inserted or replaced in the peer map regardless of
whether it already existed.

Updates #19607
Updates #12542

Change-Id: Iebd3daddb3318cba02e115a1b184fcb3ee8f83d6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 08:37:14 -07:00
Brad FitzpatrickandBrad Fitzpatrick a8f40a2ca5 ipn/ipnlocal: add missing bus notify of peers on full netmap
The prior aa5da2e5f2 ("process node adds/removes in constant
time") commit missed a bus notification case, where new-style
subscribers set NotifyNoNetmap and then the controlclient map routing
sends a full update (rather than a delta). Those profiles + peers
need to be put on the bus too.

I noticed this only when porting the Android app over to use the
new bus stuff.

Updates #19607
Updates #12542

Change-Id: I82c35011d2c532222ca27f7d4e790522c31bd156
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-27 08:03:47 -07:00
Jason DillinghamandGitHub 0e2b3f31af cmd/k8s-operator: stabilize StaticEndpoints order in ProxyGroup reconciles (#19755)
findStaticEndpoints built its return slice by iterating nodes.Items in
the order returned by r.List, which is not guaranteed to be stable
across calls. When the resulting set of addresses already matched the
existing config Secret, the slice could still permute between
reconciles, making the marshalled config Secret differ byte-for-byte.
That tripped the DeepEqual check on the config Secret, which rewrote
the Secret, which fired a watch event, which re-enqueued the
ProxyGroup, looping forever.

Detect this case and return the existing currAddrs slice unchanged
when the resulting set is the same, preserving the "use the currently
used IPs first" intent without spurious writes.

Fixes #19700

Signed-off-by: Jason Dillingham <jasonmdillingham@gmail.com>
2026-05-27 14:28:04 +01:00
Erisa AandGitHub e2a0d45418 cmd/tailscale/cli: fix time parsing in debug daemon-logs (#19875)
Fixes #19874

Signed-off-by: Erisa A <erisa@tailscale.com>
2026-05-27 12:30:28 +01:00
BeckyPauleyandGitHub 0ed6da2826 cmd/k8s-operator, net/netutil: support 4via6 in egress proxy and connector (#19863)
Add support for configuring egress to destinations reachable via 4via6
subnet routes. This change affects standalone egress proxy only- egress
ProxyGroup needs IPv6 support before being able to support 4via6. Egress may
be configured using either the synthesized 4via6 address or the MagicDNS
name (in the form
<IPv4-address-with-hyphens-instead-of-dots>-via-<siteid>[.*]).

Also update the Connector to validate and advertise 4via6 subnet routes.
Export net/netutil.ValidateViaPrefix so it can be reused by the Connector
validation logic.

Updates #19334

Signed-off-by: Becky Pauley <becky@tailscale.com>
2026-05-27 10:54:35 +01:00
Jordan WhitedandJordan Whited e5a8cf3b18 control/controlknobs,feature/*,ipn/ipnlocal,tailcfg: add runtimemetrics
Emit runtime metrics as clientmetrics when the
NodeAttrEmitRuntimeMetrics NodeCapability is present.

We start small with just 2 metrics: heap bytes and total process memory.

Updates tailscale/corp#39434

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-05-26 16:02:01 -07:00
Fran Bull 2eb45c2457 feature/conn25: extend assignment expiry on use
When we use assigned addresses in response to a DNS request, extend the
expiry on the assignment.

Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-26 07:28:47 -07:00
Michael Ben-Amiandmzbenami 5877809097 feature/conn25: unify FlowTable storage to prepare for expiry
Previously we had two maps keyed on a direction-specific tuple, with
distinct values containing the data (action) for that direction.
Values pointed at each other across maps to ensure they were removed
at the same time in the case of tuple overwrite, but LRU eviction
was per-map. So if LRU was turned on, it was possible for one
direction's data (action) to be evicted and leave the other direction
dangling.

NewFlow replaces the two direction-specific flow constructors, and
lookups return the direction-specific PacketAction directly.

Now the values in each map point to the same element, with data for both
directions in the element. A linked list also points to the elements to
implement LRU. The previous flowtrack.Cache is removed.

The single LRU structure will allow us to implement idle time expiration
by walking the list backward starting with the least recently used flow, and
stopping after a fixed number of flows, or at the first non-expired flow.

We add commented-out unused placeholder fields for tracking the
"last seen" timestamp, and an on-removal hook, to document the intent for
the follow-up expiry work.

Updates tailscale/corp#38630

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-05-26 10:09:48 -04:00
Yago Raña GayosoandGitHub 26952d53fa scripts/installer.sh: update KDE Linux link (#19857)
Signed-off-by: Yago Raña Gayoso <yago.rana.gayoso@gmail.com>
2026-05-24 21:40:42 +01:00
Simon LawandGitHub da8cd5cc7f ipn/ipnlocal: fix documentation typo, NodeAttrCacheNetworkMaps (#19851)
Updates #cleanup

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-22 22:19:10 -07:00
Simon LawandGitHub 988615dbad ipn/ipnlocal,tstest/integration: pause the control client consistently (#19846)
There are two places where tailscaled transitions into a paused state:
1. tailscaled’s controlclient is initially created,
2. tailscale down, or the GUI equivalent, commands it to.

This patch unifies the implementation of both scenarios into
LocalBackend.shouldPauseControlClientLocked to prevent the
implementation from drifting.

The flaky tstest/integration.TestNoControlConnWhenDown test exposed
this mismatch, but only by accident. This patch also changes
TestNode.MustDown so that it runs `tailscale down` and then waits for
the testcontrol server to finish handling any associated /machine/map
requests.

Fixes #19831

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-22 17:58:44 -07:00
Adrian DewhurstandAdrian Dewhurst 5d8f401956 net/dns: fix handling non-IP single split DNS
Fixes #19834

Change-Id: I4d48efed00cd080b14c6fd713ff21e53a5a6ee3c
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2026-05-22 20:45:58 -04:00
Brad FitzpatrickandBrad Fitzpatrick 5295e3e119 ipn/{ipnstate,ipnlocal}: add integer NodeID to PeerStatus
In aa5da2e5f2 we made the IPN bus include deltas, including the
PeersRemoved, sending a slice of integer NodeIDs that were
removed. But when updating xcode, I realized there was no way to map
those integers to the stable node IDs used in other places.

I was consdering changing the just-added ipn.Notify.PeersRemoved from
an IntID to a string StableID, but then it doesn't match the MapResponse
wire protocol, which we've tried to match so far.

Instead, just add the integer ID as well. Callers can use whichever
world they want, having both. It's a little regrettable that we still
have two worlds of IDs, but oh well. Neither is really suitable to a
hypothetical future fully federated world of control servers anyway,
so we'll need a third type later anyway, so just live with the two we
have for now.

Updates #12542

Change-Id: Ib8fd48a265e1da1f8779152f141f624a7f7260e9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-22 08:16:55 -07:00
e32b9bde1d control/controlclient: fix deadlock in map session change queue processing (#19828)
Holding an exclusive lock while writing to the unbuffered changequeue chan
is likely going to deadlock when the run() path may try to grab the same lock
before reading from the chan to drain it (on map session close). This causes
the client to stop processing new map responses and TSMP disco key advertisements.

There is a good probability of inducing this deadlock using the old code and new
test added in this commit: TestUpdateDiscoForNodeCallback/test_deadlock.

Also fix an unintentional regression in how the client responds to a mapResponse sleep
command. 85bb5f84a5 moved the processing of mapResponses into a new goroutine,
serialized via mapSession's changequeue. Thus, controlclient stopped sleeping in the
same goroutine servicing mapResponses/control connections. This commit brings us back
to sleeping synchronously in the same goroutine as controlclient.

Updates #12639

Signed-off-by: Amal Bansode <amal@tailscale.com>
Signed-off-by: Claus Lensbøl <claus@tailscale.com>
Co-authored-by: Claus Lensbøl <claus@tailscale.com>
2026-05-22 07:13:18 -07:00
Simon LawandGitHub fd2405ca8f tstest/integration: mark TestNoControlConnWhenDown as a flaky test (#19832)
Updates #19831

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-21 17:36:09 -07:00
Simon LawandGitHub 7dabebc691 net/traffic: switch rendezvous hashing from SHA256 to FNV-1a (#19821)
In PR tailscale/corp#30448, we originally decided to break ties using
SHA256 for our rendezvous hashing algorithm. Now that we’ve had some
experience with it, we think that FNV-1a is a better choice. It
distributes bits evenly, it’s much faster, and it doesn’t need to be
cryptographically secure. The FNV designers recommend FNV-1a over the
deprecated FNV-1.

This PR makes the switch and updates the related tests, since changing
the algorithm changes which stable pick gets selected. As of 2026-05,
this is the best time to make this change, since there are almost no
clients in the wild with traffic steering enabled.

Updates #17366
Updates tailscale/corp#29964
Updates tailscale/corp#29966
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-21 10:11:59 -07:00
Brad FitzpatrickandBrad Fitzpatrick aa5da2e5f2 ipn/ipnlocal, control/controlclient: process node adds/removes in constant time
For 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 in ad5436af0d,
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>
2026-05-21 09:26:19 -07:00
Brad FitzpatrickandBrad Fitzpatrick 2703f91174 wgengine/magicsock: fix data race in TestSetDERPMapDoReStun
SetDERPMap spawns a goroutine that calls ReSTUN, which logs via the
test logger. If the test returns before that goroutine logs, the
goroutine races with testing cleanup.

Use tstest.WhileTestRunningLogger so the goroutine's logf call becomes
a no-op once the test finishes.

Fixes #19829

Change-Id: I1097f98e40ffd1c5dd7fb7a715c918255853e3c6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-21 08:51:50 -07:00
Simon LawandGitHub 7ebca58042 net/traffic,ipn/ipnlocal: extract traffic steering utilities (#19682)
The traffic package contains helpers for evaluating traffic steering
scores and picking appropriate nodes. These were extracted from
ipnlocal.suggestExitNodeUsingTrafficSteering so they can be reused by
the new routecheck package to probe exit nodes in priority order.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-21 08:28:27 -07:00
Fran Bull dbe92f98b5 feature/conn25: set assignment expiry based on dns response TTL
Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-21 07:25:29 -07:00
Brad FitzpatrickandBrad Fitzpatrick f3a117e813 net/tsdial: run happy eyeballs across A and AAAA in UserDial
When tailscaled is running in userspace-networking mode behind an
exit node (e.g. as a SOCKS5 proxy), it resolves a hostname and then
dials a single resolved IP through the tunnel. If the name has both
A and AAAA, Go's net.Resolver merges them and we pick ips[0], which
on an IPv6-native host is usually AAAA. If the exit node has no IPv6
egress (or vice versa), the dial fails silently through the tunnel
and the user sees a hang.

Resolve all candidates and race connect attempts across address
families with a 300ms happy-eyeballs delay, matching Go's net.Dialer
default and the existing pattern in net/dnscache (commit ee0a03b14).
First success wins; losers are cancelled and any conns they produce
are closed. A failBoost channel wakes the launcher when a connect
fails fast (e.g. ICMP "no route" via the tunnel) so we don't sit on
the 300ms timer when the answer is already known.

userDialResolve is refactored into userDialResolveAll (returns the
full candidate list) plus a thin single-IP wrapper for callers like
UserDialPlan that don't race. UserDial's per-IP dispatch (netstack
vs peer dialer vs SystemDial vs std) is extracted to dialOneUser so
each candidate can route correctly on its own merits.

Also fix serveDial in localapi to pass the original hostname to
UserDial rather than a pre-resolved IP, so the race can fire.

This fix is single-ended: it works against any exit node, including
old ones, with no protocol changes. The trade-off versus filtering
on the exit-node side via PeerAPI DoH is that every dial through an
unreachable-family exit node costs one failed connect attempt per
cache window, rather than zero, which is acceptable given the
simplicity.

Fixes #19792
Fixes #13257

Change-Id: I9d7645d0034caf3ee22ecdd8070798353f77e94b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-20 18:35:55 -07:00
James TuckerandJames Tucker 36c52ef383 tstest/integration/testcontrol: fix serveMap read-modify-write race
serveMap cloned s.nodes[nk], mutated the clone outside the mutex,
then wrote it back via updateNodeLocked. A concurrent UpdateNode,
SetNodeCapMap, or other writer landing between the clone and the
writeback would be silently clobbered. Mutate the live node under
the mutex instead.

Surfaces in tsnet's TestListenService as a flaky ErrUntaggedServiceHost
panic: the test calls control.UpdateNode to attach a tag, a concurrent
updateRoutine map request from the host races, and the host's next
netmap arrives with Tags=[].

Updates #19822

Change-Id: I6c5ebd5e5bf79a40316f53f627157230773cb469
Signed-off-by: James Tucker <james@tailscale.com>
2026-05-20 18:29:58 -07:00
Aria StewartandJames Tucker 61277e3ad4 Construct IPv6 ingress URLs correctly
Fixes #19338

Signed-off-by: Aria Stewart <aredridel@dinhe.net>
2026-05-20 17:21:35 -07:00
M. J. FrombergerandGitHub c09407002f ipn/ipnlocal/netmapcache: add UpdateSelfOnly method (#19818)
Some netmap updates are guaranteed to affect only the "static" parts of the
netmap, and so should not require us to walk through all the peers and user
profiles when updating the cache. To support this, the new UpdateSelfOnly
method updates only the Self node and other tailnet settings that are not
dependent on the peers and profiles.

Use this when updating the cache on DERP home changes.

Updates #12542

Change-Id: Ifed522b29d579fb76e010b4ff738cc4e0a72d27f
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-05-20 16:29:04 -07:00
Simon LawandGitHub 93dbd33ef7 ipn/ipnlocal: stub system interfaces for TestShouldUseOneCGNATRoute (#19807)
The TestShouldUseOneCGNATRoute test fails when the underlying system
interfaces don’t match what the underlying assumptions of the test.
That assumption was that there would only ever be one CGNAT interface:
the Tailscale one.

This breaks on Linux when border0 is installed because border0 also
creates an interface with a CGNAT route.

This patch stubs netmon.RegisterInterfaceGetter to replace the system
interfaces and netmon.SetTailscaleInterfaceProps to identify the test
data that defines the Tailscale interface.

This patch also tests the control knob override for CGNAT for every
combination of operating system and system interfaces, instead of just
a couple of combinations.

Fixes #19731

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-20 16:00:14 -07:00
Brad FitzpatrickandBrad Fitzpatrick 04ae61fe4b tstest/integration/jswasmtest: add headless-Chromium tests for @tailscale/connect
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 commit 1d93bdce2, "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: Iff9cdee303e3b239924249b5bffb2fd04e02f391
2026-05-20 10:48:29 -07:00
Brad FitzpatrickandBrad Fitzpatrick 95d874e9b4 cmd/testwrapper: surface race reports and skip retries when detected
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>
2026-05-19 21:21:05 -07:00
Claus LensbølandGitHub ee0a03b140 net/dnscache: run happy eyeballs with more than one dest IP (#19770)
If the context given to DialContext has a shorter lifetime than the OS
TCP SYN timeout, and TCP SYNs are dropped from the path to the remote,
DialContext would never fall back to try IPv6 after IPv4.

Instead, use the normal happy eyeballs race if there is more than one
address. This does remove the implicit prioritization of IPv4 over IPv6
in cases where there is only a single IPv4 remote address.

Updates #13346

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-05-19 12:59:11 -04:00
Naman SoodandGitHub 5d56cc8512 util/linuxfw: return error instead of nil pointer dereference
Issue #19737 ran into a nil pointer dereference, the cause of which was fixed
by #19761. If we end up on this code path with a nil table again, we should
bubble that up as an error (which is logged by the health warning system)
rather than failing catastrophically.

Signed-off-by: Naman Sood <mail@nsood.in>
2026-05-19 10:01:07 -04:00
783 changed files with 66806 additions and 10772 deletions
+60 -2
View File
@@ -1,2 +1,60 @@
go.mod filter=go-mod
*.go diff=golang
go.mod filter=go-mod eol=lf text
*.go diff=golang eol=lf text
*.adml eol=lf text
*.admx eol=lf text
*.bash eol=lf text
*.c eol=lf text
*.cgi eol=lf text
*.conf eol=lf text
*.css eol=lf text
*.csv eol=lf text
*.desktop eol=lf text
*.fish eol=lf text
*.gitattributes eol=lf text
*.gitignore eol=lf text
*.gitkeep eol=lf text
*.go eol=lf text
*.h eol=lf text
*.helmignore eol=lf text
*.htaccess eol=lf text
*.html eol=lf text
*.hujson eol=lf text
*.in eol=lf text
*.init eol=lf text
*.js eol=lf text
*.json eol=lf text
*.lock eol=lf text
*.lua eol=lf text
*.md eol=lf text
*.mod eol=lf text
*.nix eol=lf text
*.openrc eol=lf text
*.pbxproj eol=lf text
*.pem eol=lf text
*.plg eol=lf text
*.plist eol=lf text
*.rc eol=lf text
*.resolved eol=lf text
*.rev eol=lf text
*.rs eol=lf text
*.sc eol=lf text
*.service eol=lf text
*.sh eol=lf text
*.socket eol=lf text
*.stignore eol=lf text
*.sum eol=lf text
*.svg eol=lf text
*.swift eol=lf text
*.tmpl eol=lf text
*.toml eol=lf text
*.ts eol=lf text
*.tsx eol=lf text
*.txt eol=lf text
*.version eol=lf text
*.xcscheme eol=lf text
*.xcsettings eol=lf text
*.xib eol=lf text
*.xml eol=lf text
*.yaml eol=lf text
*.yml eol=lf text
*.zsh eol=lf text
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
# Install a more recent Go that understands modern go.mod content.
- name: Install Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # zizmor: ignore[cache-poisoning] v6.3.0
with:
go-version-file: go.mod
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install govulncheck
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@0782b76014f15f24e22a438f30f308df42899ba1 # 1.3.0
- name: Scan source code for known vulnerabilities
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- { image: "fedora:latest", deps: "curl", version: "1.80.0" }
runs-on: ubuntu-latest
container:
image: ${{ matrix.image }}
image: ${{ matrix.image }} # zizmor: ignore[unpinned-images]
options: --user root
steps:
- name: install dependencies (pacman)
+6 -6
View File
@@ -102,15 +102,15 @@ jobs:
# single-test-per-matrix-job model. They stay runnable locally.
run: |
set -euo pipefail
exclude='^(TestGrid)$'
exclude='^(TestGrid|TestVnetPerf.*)$'
tmp=$(mktemp)
for pkg_dir in tstest/natlab/vmtest tstest/integration/nat; do
pkg="./${pkg_dir}/"
for f in "${pkg_dir}"/*_test.go; do
[ -e "$f" ] || continue
grep -hE '^func Test[A-Z][A-Za-z0-9_]*\(t \*testing\.T\)' "$f" \
{ grep -hE '^func Test[A-Z][A-Za-z0-9_]*\(t \*testing\.T\)' "$f" || true; } \
| sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' \
| grep -vE "$exclude" \
| { grep -vE "$exclude" || true; } \
| while read -r t; do
jq -nc --arg pkg "$pkg" --arg test "$t" \
'{pkg: $pkg, test: $test}' >> "$tmp"
@@ -165,13 +165,13 @@ jobs:
key: natlab-gokrazy-${{ github.sha }}
# The gokrazy-based tests boot the kernel directly from
# vmlinuz that ships in the tailscale/gokrazy-kernel module.
# vmlinuz that ships in the gokrazy/kernel.amd64 module.
# Tests look it up under GOMODCACHE via findKernelPath, so the
# module has to be present even though no Go source imports it
# in the test package itself.
- name: Download gokrazy-kernel module
- name: Download kernel.amd64 module
run: |
./tool/go mod download github.com/tailscale/gokrazy-kernel
./tool/go mod download github.com/gokrazy/kernel.amd64
- name: Run ${{ matrix.test }}
# Per-test timeout is well above the few-minute typical runtime
+45
View File
@@ -0,0 +1,45 @@
name: policybot-test
env:
HOME: ${{ github.workspace }}
GOMODCACHE: ${{ github.workspace }}/gomodcache
CMD_GO_USE_GIT_HASH: "true"
on:
push:
branches:
- main
- "release-branch/*"
paths:
- .github/workflows/policybot-test.yml
- .policy.yml
- .policy-tests.yml
- go.mod
pull_request:
paths:
- .github/workflows/policybot-test.yml
- .policy.yml
- .policy-tests.yml
- go.mod
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
policybot-test:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: src
# The version of github.com/tailscale/policybottest used here is
# pinned by go.mod via internal/tooldeps/tooldeps.go; bump it with
# "go get github.com/tailscale/policybottest@<sha> && go mod tidy".
- name: Run policy tests
working-directory: src
run: ./tool/go run github.com/tailscale/policybottest -policy .policy.yml -tests .policy-tests.yml
@@ -2,7 +2,7 @@ name: request-dataplane-review
on:
pull_request:
types: [ opened, synchronize, reopened, ready_for_review ]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/workflows/request-dataplane-review.yml"
- "**/*derp*"
@@ -15,8 +15,6 @@ jobs:
name: Request Dataplane Review
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get access token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
id: generate-token
@@ -24,6 +22,8 @@ jobs:
# Get token for app: https://github.com/apps/change-visibility-bot
app-id: ${{ secrets.VISIBILITY_BOT_APP_ID }}
private-key: ${{ secrets.VISIBILITY_BOT_APP_PRIVATE_KEY }}
# Limit the token to only requesting reviewers on pull requests.
permission-pull-requests: write
- name: Add reviewers
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
+39
View File
@@ -0,0 +1,39 @@
name: request-k8s-review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/workflows/request-k8s-review.yml"
- "k8s-operator/**"
- "kube/**"
- "cmd/k8s-operator/**"
- "cmd/k8s-proxy/**"
- "cmd/k8s-nameserver/**"
- "cmd/containerboot/**"
- "cmd/sync-containers/**"
- "ipn/store/kubestore/**"
- "docs/k8s/**"
- "!**/depaware.txt"
jobs:
request-k8s-review:
if: github.event.pull_request.draft == false
name: Request K8s Review
runs-on: ubuntu-latest
steps:
- name: Get access token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
id: generate-token
with:
# Get token for app: https://github.com/apps/change-visibility-bot
app-id: ${{ secrets.VISIBILITY_BOT_APP_ID }}
private-key: ${{ secrets.VISIBILITY_BOT_APP_PRIVATE_KEY }}
# Limit the token to only requesting reviewers on pull requests.
permission-pull-requests: write
- name: Add reviewers
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
url: ${{ github.event.pull_request.html_url }}
run: |
gh pr edit "$url" --add-reviewer tailscale/k8s-devs
+13 -31
View File
@@ -70,7 +70,7 @@ jobs:
run: go mod download
- name: Cache Go modules
if: steps.check-cache.outputs.cache-hit != 'true'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # zizmor: ignore[cache-poisoning] v5.0.4
with:
path: gomodcache # relative to workspace; see env note at top of file
key: ${{ steps.hash.outputs.key }}
@@ -183,7 +183,7 @@ jobs:
TS_TEST_SHARD: ${{ matrix.shard }}
- name: bench all
working-directory: src
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run='^$' $(for x in $(git grep -l '^func Benchmark' | xargs dirname | sort | uniq); do echo "./$x"; done)
env:
GOARCH: ${{ matrix.goarch }}
- name: check that no tracked files changed
@@ -261,6 +261,7 @@ jobs:
cigocached-host: ${{ vars.CIGOCACHED_AZURE_HOST }}
- name: test
shell: bash
if: matrix.key != 'win-bench' # skip on bench builder
working-directory: src
run: ./tool/go run ./cmd/testwrapper sharded:${{ matrix.shard }}
@@ -268,9 +269,10 @@ jobs:
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
- name: bench all
shell: bash
if: matrix.key == 'win-bench'
working-directory: src
run: ./tool/go test ./... -bench=. -benchtime=1x -run="^$"
run: ./tool/go test -bench=. -benchtime=1x -run='^$' $(for x in $(git grep -l '^func Benchmark' | xargs dirname | sort | uniq); do echo "./$x"; done)
env:
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
@@ -343,7 +345,7 @@ jobs:
needs: gomod-cache
runs-on: ubuntu-24.04
container:
image: golang:latest
image: golang:latest # zizmor: ignore[unpinned-images]
options: --privileged
steps:
- name: checkout
@@ -363,30 +365,6 @@ jobs:
working-directory: src
run: ./tool/go test $(./tool/go run ./tool/listpkgs --has-root-tests)
vm:
needs: gomod-cache
runs-on: ["self-hosted", "linux", "vm"]
# VM tests run with some privileges, don't let them run on 3p PRs.
if: github.repository == 'tailscale/tailscale'
steps:
- name: checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: Run VM tests
working-directory: src
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2404
env:
HOME: "/var/lib/ghrunner/home"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
cross: # cross-compile checks, build only.
needs: gomod-cache
strategy:
@@ -642,6 +620,13 @@ jobs:
run: |
./tool/go run ./cmd/tsconnect --fast-compression build
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
- name: verify Google Chrome is available
run: |
which google-chrome
google-chrome --version
- name: tsconnect js/wasm headless-browser tests
working-directory: src
run: ./tool/go test ./tstest/integration/jswasmtest/ -v -timeout 180s --run-headless-browser-tests
- name: Tidy cache
working-directory: src
shell: bash
@@ -903,7 +888,6 @@ jobs:
- test
- windows
- macos
- vm
- cross
- ios
- wasm
@@ -949,7 +933,6 @@ jobs:
- test
- windows
- macos
- vm
- cross
- ios
- wasm
@@ -999,7 +982,6 @@ jobs:
- test
- windows
- macos
- vm
- wasm
- fuzz
- race-root-integration
+3
View File
@@ -33,6 +33,9 @@ jobs:
# Get token for app: https://github.com/apps/tailscale-code-updater
app-id: ${{ secrets.CODE_UPDATER_APP_ID }}
private-key: ${{ secrets.CODE_UPDATER_APP_PRIVATE_KEY }}
# Limit the token to only pushing a branch and opening a pull request.
permission-contents: write
permission-pull-requests: write
- name: Send pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0
@@ -29,6 +29,9 @@ jobs:
# Get token for app: https://github.com/apps/tailscale-code-updater
app-id: ${{ secrets.CODE_UPDATER_APP_ID }}
private-key: ${{ secrets.CODE_UPDATER_APP_PRIVATE_KEY }}
# Limit the token to only pushing a branch and opening a pull request.
permission-contents: write
permission-pull-requests: write
- name: Send pull request
id: pull-request
+4 -2
View File
@@ -14,15 +14,17 @@ on:
- main
- "release-branch/*"
paths:
- .github/workflows/vet.yml
- "**.go"
pull_request:
paths:
- .github/workflows/vet.yml
- "**.go"
jobs:
vet:
runs-on: [ self-hosted, linux ]
timeout-minutes: 5
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Check out code
+32
View File
@@ -0,0 +1,32 @@
name: security lint GitHub Actions with zizmor
on:
push:
branches: ["main"]
paths:
- ".github/workflows/**"
pull_request:
branches: ["**"]
paths:
- ".github/workflows/**"
permissions: {}
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
min-severity: high
advanced-security: false
annotations: true
+2
View File
@@ -58,3 +58,5 @@ client/web/build/assets
# Ignore syncthing state directory.
/.stfolder
fbstatus
gafpush
+258
View File
@@ -0,0 +1,258 @@
# Tests for testdata/tailscale.com.policy.yml.
#
# Run from the parent directory with:
# go run . -policy testdata/tailscale.com.policy.yml \
# -tests testdata/tailscale.com.policy-test.yml
teams:
# Members are picked solely so tests below can refer to them.
# `alice` and `bob` are control-protocol-owners *and* dev. `carol`,
# `dave`, and `eve` are dev only. `outsider` is in nothing.
tailscale/control-protocol-owners:
- alice
- bob
tailscale/dev:
- alice
- bob
- carol
- dave
- eve
tests:
# ------------------------------------------------------------------
# Baseline: every PR (touching tailcfg/ or not) requires a +1 from a
# tailscale/dev member.
# ------------------------------------------------------------------
- name: non-tailcfg PR with no review is pending
pull_request:
author: carol
changed_files:
- README.md
- cmd/tailscale/main.go
expect:
status: pending
rules:
"tailcfg changes approved by control-protocol-owners": skipped
"tailcfg changes overridden by another tailscale/dev": skipped
"any tailscale/dev review": pending
- name: non-tailcfg PR with dev review is approved
pull_request:
author: carol
changed_files:
- README.md
reviews:
- user: dave
state: approved
expect:
status: approved
rules:
"any tailscale/dev review": approved
- name: non-tailcfg PR with thumbs-up comment from dev is approved
pull_request:
author: carol
changed_files:
- README.md
comments:
- user: dave
body: ":+1:"
expect:
status: approved
rules:
"any tailscale/dev review": approved
- name: non-tailcfg PR with non-dev review is pending
pull_request:
author: carol
changed_files:
- README.md
reviews:
- user: outsider
state: approved
expect:
status: pending
rules:
"any tailscale/dev review": pending
- name: PR with no files at all is pending without a dev review
pull_request:
author: carol
changed_files: []
expect:
status: pending
rules:
"any tailscale/dev review": pending
# ------------------------------------------------------------------
# tailcfg/ requires BOTH a control-protocol-owners review (or an
# override comment) AND a dev +1. Owners are in tailscale/dev too,
# so their single review satisfies both gates.
# ------------------------------------------------------------------
- name: tailcfg change without anything is pending
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
expect:
status: pending
rules:
"tailcfg changes approved by control-protocol-owners": pending
"tailcfg changes overridden by another tailscale/dev": pending
"any tailscale/dev review": pending
- name: a single owner review approves both the owner rule and the dev rule
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: alice
state: approved
expect:
status: approved
rules:
"tailcfg changes approved by control-protocol-owners": approved
"any tailscale/dev review": approved
- name: owner cannot self-approve their own tailcfg PR
pull_request:
author: alice
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: alice
state: approved
expect:
status: pending
- name: non-owner dev review alone leaves the tailcfg gate pending
pull_request:
author: alice
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: carol
state: approved
expect:
status: pending
rules:
"tailcfg changes approved by control-protocol-owners": pending
"tailcfg changes overridden by another tailscale/dev": pending
"any tailscale/dev review": approved
# ------------------------------------------------------------------
# policybot-override flow on a tailcfg/ PR.
# ------------------------------------------------------------------
# An override comment by itself only satisfies the override rule.
# The baseline dev-review rule still needs a separate +1.
- name: override comment alone leaves baseline dev review pending
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override: emergency rollback"
expect:
status: pending
rules:
"tailcfg changes overridden by another tailscale/dev": approved
"any tailscale/dev review": pending
- name: override comment plus a separate dev review approves
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override: emergency rollback"
reviews:
- user: eve
state: approved
expect:
status: approved
# One dev doing BOTH (post the override AND a regular +1) works too.
- name: same dev posts both the override and a +1 review
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override: shipping a typo fix"
reviews:
- user: dave
state: approved
expect:
status: approved
- name: override comment from the author does not approve the override
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: carol
body: "policybot-override: please let me ship this"
expect:
status: pending
rules:
"tailcfg changes overridden by another tailscale/dev": pending
- name: override comment from a non-dev does not approve the override
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: outsider
body: "policybot-override: I am not on the dev team"
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
- name: empty-reason policybot-override does not approve
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override:"
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
# Defaults regression guards: a normal review approval from a dev
# member must NOT silently approve the override rule. Same for a
# plain :+1: comment. Without the explicit `methods: github_review:
# false` / `comments: []` on the override rule, both would.
- name: a regular dev review does not silently satisfy the override rule
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: dave
state: approved
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
- name: a thumbs-up comment from a dev does not silently satisfy the override rule
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: ":+1:"
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
+84
View File
@@ -0,0 +1,84 @@
# Approval policy for this repository, enforced by policy-bot
# (https://github.com/palantir/policy-bot) running at
# https://policybot.corp.ts.net.
#
# This file replaces the role GitHub's CODEOWNERS played: when a pull
# request touches a path covered by a rule below, policy-bot posts a
# status check that blocks merging until the required reviewers approve.
#
# Policy and rule syntax reference:
# https://github.com/palantir/policy-bot/blob/develop/README.md
# Example policy files (team-approval, disapproval, remote, etc.):
# https://github.com/palantir/policy-bot/tree/develop/config/policy-examples
#
# Do not add to this policy without wide discussion.
# See https://github.com/tailscale/corp/issues/13972.
policy:
approval:
# tailcfg/ has an extra gate: either a control-protocol-owners
# review or an explicit policybot-override: comment from a dev.
# The block is skipped on PRs that don't touch tailcfg/.
- or:
- tailcfg changes approved by control-protocol-owners
- tailcfg changes overridden by another tailscale/dev
# And every PR, regardless of files, needs a +1 from a dev.
# This rule also keeps the policy from collapsing into "all rules
# skipped" (which policy-bot treats as a failure) on non-tailcfg
# PRs.
- any tailscale/dev review
approval_rules:
- name: tailcfg changes approved by control-protocol-owners
if:
changed_files:
paths:
- "^tailcfg/"
requires:
count: 1
teams:
- "tailscale/control-protocol-owners"
- name: tailcfg changes overridden by another tailscale/dev
description: |
Any member of @tailscale/dev (other than the PR author) can
override the control-protocol-owners requirement by leaving a
comment of the form
policybot-override: <reason>
on the pull request. The reason can be anything but should
explain why the override is appropriate; it stays in the PR
conversation as a record. The override comment also counts as
that developer's approval.
if:
changed_files:
paths:
- "^tailcfg/"
requires:
count: 1
teams:
- "tailscale/dev"
options:
methods:
# Explicitly turn off the defaults (github_review: true,
# comments: [":+1:", "👍"]) so the ONLY way to satisfy this
# rule is a "policybot-override:" comment. Otherwise a normal
# review approval or thumbs-up from any tailscale/dev member
# would silently pass the rule.
github_review: false
comments: []
comment_patterns:
- '^policybot-override: \S.*'
- name: any tailscale/dev review
description: |
Every PR needs at least one approval from a member of
@tailscale/dev. policy-bot's default approval methods count
a GitHub review approval, a ":+1:" comment, or a "👍"
comment as approval. The PR author cannot approve their
own PR.
requires:
count: 1
teams:
- "tailscale/dev"
+7 -1
View File
@@ -1 +1,7 @@
/tailcfg/ @tailscale/control-protocol-owners
# This repository does NOT use GitHub's CODEOWNERS for review enforcement.
# Approval policies live in .policy.yml at the repository root and are
# enforced by policy-bot (https://github.com/palantir/policy-bot).
#
# To change required reviewers for a path, edit .policy.yml.
#
# See https://github.com/tailscale/corp/issues/13972.
+49
View File
@@ -148,6 +148,55 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container
generate: ## Generate code
./tool/go generate ./...
.PHONY: tsapp-build-and-flash-pi
tsapp-build-and-flash-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and flash a local SD card (macOS auto-detects the disk; pass DISK=/dev/sdX on Linux)
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
./tool/go run --exec=sudo ./cmd/tailscale configure flash-appliance \
--variant=pi-arm64 \
--gaf=gokrazy/tsapp-pi.arm64.gaf \
$(if $(DISK),--disk=$(DISK)) \
$(if $(wildcard $(HOME)/.ssh/id_ed25519.pub),--add-ssh-authorized-keys=$(HOME)/.ssh/id_ed25519.pub)
.PHONY: tsapp-qemu-pi
tsapp-qemu-pi: ## Build tsapp-pi.arm64 and boot it under qemu-system-aarch64 with a framebuffer GUI window and working network (requires mtools, dtc, qemu-efi-aarch64)
cd gokrazy && ../tool/go run build.go --build --app=tsapp-pi.arm64
# Extract the kernel from the FAT boot partition for direct -kernel boot.
rm -f gokrazy/tsapp-pi.arm64.vmlinuz
mcopy -i gokrazy/tsapp-pi.arm64.img@@4194304 ::vmlinuz gokrazy/tsapp-pi.arm64.vmlinuz
# Use the "virt" machine (not raspi3b) because it provides working
# PCI e1000 networking and, with UEFI firmware, an EFI framebuffer
# via the ramfb device. The raspi3b machine's USB NIC emulation is
# too broken for DHCP and its SoC watchdog reboots the guest.
#
# Find the UEFI firmware. Common paths:
# Debian/Ubuntu: /usr/share/qemu-efi-aarch64/QEMU_EFI.fd
# Homebrew: /opt/homebrew/share/qemu/edk2-aarch64-code.fd
# Fedora: /usr/share/edk2/aarch64/QEMU_EFI.fd
QEMU_EFI=$$(for f in \
/usr/share/qemu-efi-aarch64/QEMU_EFI.fd \
/opt/homebrew/share/qemu/edk2-aarch64-code.fd \
/usr/share/edk2/aarch64/QEMU_EFI.fd \
$$(dirname $$(which qemu-system-aarch64))/../share/qemu/edk2-aarch64-code.fd; do \
[ -f "$$f" ] && echo "$$f" && break; \
done) && \
[ -n "$$QEMU_EFI" ] || { echo "error: cannot find QEMU EFI firmware (install qemu-efi-aarch64)"; exit 1; } && \
qemu-system-aarch64 \
-M virt -cpu cortex-a53 -m 1G \
-bios "$$QEMU_EFI" \
-device ramfb \
-device e1000,netdev=net0 -netdev user,id=net0 \
-kernel gokrazy/tsapp-pi.arm64.vmlinuz \
-append "console=ttyAMA0,115200 nowatchdog gokrazy.log_to_serial=1 root=PARTUUID=60c24cc1-f3f9-427a-8199-dd02023b0001/PARTNROFF=1 ro init=/gokrazy/init rootwait" \
-drive file=gokrazy/tsapp-pi.arm64.img,format=raw,if=none,id=disk0 \
-device virtio-blk-device,drive=disk0 \
-serial mon:stdio
.PHONY: tsapp-push-pi
tsapp-push-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and push it to a running Pi over the network (pass PI=<ip>)
@[ -n "$(PI)" ] || { echo "usage: make tsapp-push-pi PI=<ip-address>"; exit 1; }
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
./tool/go run ./gokrazy/gafpush --gaf=gokrazy/tsapp-pi.arm64.gaf --pi=$(PI)
.PHONY: pin-github-actions
pin-github-actions:
./tool/go tool github.com/stacklok/frizbee actions .github/workflows
+1 -1
View File
@@ -1 +1 @@
1.99.0
1.103.0
+19 -57
View File
@@ -5,13 +5,14 @@ package appc
import (
"cmp"
"fmt"
"slices"
"strings"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/appctype"
"tailscale.com/util/mak"
"tailscale.com/types/dnstype"
"tailscale.com/util/set"
)
@@ -54,71 +55,32 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
return matches
}
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
// want to be connectors for which domains.
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView, isSelfEligibleConnector bool) map[string][]tailcfg.NodeView {
var m map[string][]tailcfg.NodeView
// DNSAddrScheme is the custom URI scheme used for conn25-managed split DNS
// entries to determine the destination at query time rather than configuration
// time.
const DNSAddrScheme = "tailscale-app"
func AppDNSRoutes(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView) map[string][]*dnstype.Resolver {
if !hasCap(AppConnectorsExperimentalAttrName) {
return m
return nil
}
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorAttr](self.CapMap(), AppConnectorsExperimentalAttrName)
if err != nil {
return m
return nil
}
// We strip the leading *. from any domains because the OS treats all domains
// that we pass to it as wildcard domains, and the OS would treat the * character
// as a literal domain component instead of treating it as a wildcard.
// We also use a Set to deduplicate the domains we pass to the OS in case removing
// the *. prefix resulted in duplicate entries.
tagToDomain := make(map[string]set.Set[string])
selfTags := set.SetOf(self.Tags().AsSlice())
selfRoutedDomains := set.Set[string]{}
appNamesByDomain := map[string]string{}
for _, app := range apps {
domains := make(set.Set[string])
for _, domain := range app.Domains {
domains.Add(strings.ToLower(strings.TrimPrefix(domain, "*.")))
}
for _, tag := range app.Connectors {
if tagToDomain[tag] == nil {
tagToDomain[tag] = set.Set[string]{}
}
tagToDomain[tag].AddSet(domains)
if isSelfEligibleConnector && selfTags.Contains(tag) {
selfRoutedDomains.AddSet(domains)
}
domain, _ = strings.CutPrefix(domain, "*.")
domain = strings.ToLower(domain)
// in the case of multiple apps specifying the same domain (which is misconfiguration
// that should be validated at point of input) last write wins.
appNamesByDomain[domain] = app.Name
}
}
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
var work map[string]set.Set[tailcfg.NodeID]
for _, peer := range peers {
if !isPeerEligibleConnector(peer) {
continue
}
for _, t := range peer.Tags().All() {
domains := tagToDomain[t]
for domain := range domains {
if selfRoutedDomains.Contains(domain) {
continue
}
if work[domain] == nil {
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
}
work[domain].Add(peer.ID())
}
}
}
// Populate m. Make a []tailcfg.NodeView from []tailcfg.NodeID using the peers map.
// And sort it to our preference.
for domain, ids := range work {
nodes := make([]tailcfg.NodeView, 0, ids.Len())
for id := range ids {
nodes = append(nodes, peers[id])
}
sortByPreference(nodes)
mak.Set(&m, domain, nodes)
m := make(map[string][]*dnstype.Resolver, len(appNamesByDomain))
for domain, appName := range appNamesByDomain {
m[domain] = []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
}
return m
}
+65 -174
View File
@@ -5,17 +5,18 @@ package appc
import (
"encoding/json"
"reflect"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/types/opt"
)
func TestPickSplitDNSPeers(t *testing.T) {
func TestAppDNSRoutes(t *testing.T) {
getBytesForAttr := func(name string, domains []string, tags []string) []byte {
attr := appctype.AppConnectorAttr{
Name: name,
@@ -35,206 +36,102 @@ func TestPickSplitDNSPeers(t *testing.T) {
appFiveBytes := getBytesForAttr("app5", []string{"*.example.com", "example.com"}, []string{"tag:one"})
appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"})
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
return (&tailcfg.Node{
ID: id,
Name: name,
Tags: tags,
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
}).View()
resolver := func(appName string) []*dnstype.Resolver {
return []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
}
nvp1 := makeNodeView(1, "p1", []string{"tag:one"})
nvp2 := makeNodeView(2, "p2", []string{"tag:four1", "tag:four2"})
nvp3 := makeNodeView(3, "p3", []string{"tag:two", "tag:three1"})
nvp4 := makeNodeView(4, "p4", []string{"tag:two", "tag:three2", "tag:four2"})
for _, tt := range []struct {
name string
peers []tailcfg.NodeView
config []tailcfg.RawMessage
isEligibleConnector bool
selfTags []string
want map[string][]tailcfg.NodeView
name string
hasCap bool
config []tailcfg.RawMessage
want map[string][]*dnstype.Resolver
}{
{
name: "empty",
name: "no-capability", // hasCap false should return nil regardless of config.
hasCap: false,
},
{
name: "bad-config", // bad config should return a nil map rather than error.
name: "no-apps", // hasCap true but no configured apps returns an empty map.
hasCap: true,
want: map[string][]*dnstype.Resolver{},
},
{
name: "bad-config", // bad config should return nil rather than error.
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(`hey`)},
},
{
name: "no-peers",
name: "single-app",
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
},
{
name: "peers-that-are-not-connectors",
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
peers: []tailcfg.NodeView{
(&tailcfg.Node{
ID: 5,
Name: "p5",
Tags: []string{"tag:one"},
}).View(),
(&tailcfg.Node{
ID: 6,
Name: "p6",
Tags: []string{"tag:one"},
}).View(),
want: map[string][]*dnstype.Resolver{
"example.com": resolver("app1"),
},
},
{
name: "peers-that-dont-match-tags",
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
peers: []tailcfg.NodeView{
makeNodeView(5, "p5", []string{"tag:seven"}),
makeNodeView(6, "p6", nil),
name: "single-app-multi-domain",
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(appThreeBytes)},
want: map[string][]*dnstype.Resolver{
"woo.b.example.com": resolver("app3"),
"hoo.b.example.com": resolver("app3"),
},
},
{
name: "matching-tagged-connector-peers",
name: "multi-app-no-overlap",
hasCap: true,
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appOneBytes),
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appThreeBytes),
tailcfg.RawMessage(appFourBytes),
},
peers: []tailcfg.NodeView{
nvp1,
nvp2,
nvp3,
nvp4,
makeNodeView(5, "p5", nil),
},
want: map[string][]tailcfg.NodeView{
// p5 has no matching tags and so doesn't appear
"example.com": {nvp1},
"a.example.com": {nvp3, nvp4},
"woo.b.example.com": {nvp2, nvp3, nvp4},
"hoo.b.example.com": {nvp3, nvp4},
"c.example.com": {nvp2, nvp4},
want: map[string][]*dnstype.Resolver{
"example.com": resolver("app1"),
"a.example.com": resolver("app2"),
},
},
{
name: "self-connector-exclude-self-domains",
name: "domain-collision-last-write-wins",
hasCap: true,
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appOneBytes),
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appThreeBytes),
tailcfg.RawMessage(appFourBytes),
tailcfg.RawMessage(appThreeBytes), // app3: woo.b.example.com, hoo.b.example.com
tailcfg.RawMessage(appFourBytes), // app4: woo.b.example.com, c.example.com
},
peers: []tailcfg.NodeView{
nvp1,
nvp2,
nvp3,
nvp4,
},
isEligibleConnector: true,
selfTags: []string{"tag:three1"},
want: map[string][]tailcfg.NodeView{
// woo.b.example.com and hoo.b.example.com are covered
// by tag:three1, and so is this self-node.
// So those domains should not be routed to peers.
// woo.b.example.com is also covered by another tag,
// but still not included since this connector can route to it.
"example.com": {nvp1},
"a.example.com": {nvp3, nvp4},
"c.example.com": {nvp2, nvp4},
want: map[string][]*dnstype.Resolver{
// app4 overwrites app3 for the shared domain
"woo.b.example.com": resolver("app4"),
"hoo.b.example.com": resolver("app3"),
"c.example.com": resolver("app4"),
},
},
{
name: "self-eligible-connector-no-matching-tag-include-all-domains",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appOneBytes),
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appThreeBytes),
tailcfg.RawMessage(appFourBytes),
},
peers: []tailcfg.NodeView{
nvp1,
nvp2,
nvp3,
nvp4,
},
isEligibleConnector: true,
selfTags: []string{"tag:unrelated"},
want: map[string][]tailcfg.NodeView{
// Self has prefs set but no tags matching any app,
// so no domains are self-routed and all appear.
"example.com": {nvp1},
"a.example.com": {nvp3, nvp4},
"woo.b.example.com": {nvp2, nvp3, nvp4},
"hoo.b.example.com": {nvp3, nvp4},
"c.example.com": {nvp2, nvp4},
name: "wildcards-are-stripped-and-deduped",
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(appFiveBytes)},
want: map[string][]*dnstype.Resolver{
// *.example.com and example.com should both normalize to example.com.
"example.com": resolver("app5"),
},
},
{
name: "self-not-eligible-connector-but-tagged-include-all-domains",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appOneBytes),
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appThreeBytes),
tailcfg.RawMessage(appFourBytes),
},
peers: []tailcfg.NodeView{
nvp1,
nvp2,
nvp3,
nvp4,
},
selfTags: []string{"tag:three1"},
want: map[string][]tailcfg.NodeView{
// Even though this self node has a tag for an app
// the prefs don't advertise as connector, so
// should still route through other connectors.
"example.com": {nvp1},
"a.example.com": {nvp3, nvp4},
"woo.b.example.com": {nvp2, nvp3, nvp4},
"hoo.b.example.com": {nvp3, nvp4},
"c.example.com": {nvp2, nvp4},
name: "domains-are-normalized-and-deduped",
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(appSixBytes)},
want: map[string][]*dnstype.Resolver{
// *.Example.com, EXAMPLE.com, EXAMPLE.COM should all normalize to example.com.
"example.com": resolver("app6"),
},
},
{
name: "wildcards-are-stripped-and-deduped",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appOneBytes),
tailcfg.RawMessage(appFiveBytes),
},
peers: []tailcfg.NodeView{
nvp1,
},
want: map[string][]tailcfg.NodeView{
// All the domains should be normalized to example.com
"example.com": {nvp1},
},
},
{
name: "domains-are-normalized-and-deduped",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appSixBytes),
},
peers: []tailcfg.NodeView{
nvp1,
},
want: map[string][]tailcfg.NodeView{
// All the domains should be normalized to example.com
"example.com": {nvp1},
},
},
{
name: "sub-domains-and-top-domains-do-not-collide",
name: "sub-domains-and-top-domains-do-not-collide",
hasCap: true,
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appFiveBytes),
},
peers: []tailcfg.NodeView{
nvp1,
nvp3,
},
want: map[string][]tailcfg.NodeView{
// The sub.example.com should remain distinct from example.com
"example.com": {nvp1},
"a.example.com": {nvp3},
want: map[string][]*dnstype.Resolver{
// *.example.com normalizes to example.com; a.example.com remains distinct.
"a.example.com": resolver("app2"),
"example.com": resolver("app5"),
},
},
} {
@@ -245,18 +142,12 @@ func TestPickSplitDNSPeers(t *testing.T) {
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
}
}
selfNode.Tags = append(selfNode.Tags, tt.selfTags...)
selfView := selfNode.View()
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
for _, p := range tt.peers {
peers[p.ID()] = p
}
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
return true
}, selfView, peers, tt.isEligibleConnector)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
got := AppDNSRoutes(func(_ tailcfg.NodeCapability) bool {
return tt.hasCap
}, selfView)
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("AppDNSRoutes (-want, +got):\n%s", diff)
}
})
}
+7
View File
@@ -51,6 +51,13 @@ while [ "$#" -gt 1 ]; do
ldflags="$ldflags -w -s"
tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min)"
;;
--strip)
# --min overrides your flags, when you're using custom tags and want to
# additionally strip symbols to help reduce the size, this is the easiest
# way to do it.
shift
ldflags="$ldflags -w -s"
;;
--box)
if [ ! -z "${TAGS:-}" ]; then
echo "set either --box or \$TAGS, but not both"
+54
View File
@@ -10,13 +10,55 @@ import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go4.org/mem"
)
// rateLimitedError is returned from cert-fetching methods when the
// upstream ACME CA reported a rate limit. Callers should unpack it via
// [RateLimitRetryAfter].
type rateLimitedError struct {
retryAfter time.Duration
underlying error
}
func (e rateLimitedError) Error() string { return e.underlying.Error() }
func (e rateLimitedError) Unwrap() error { return e.underlying }
// RateLimitRetryAfter reports whether err was a rate-limit failure from
// the upstream ACME CA and, if so, returns the CA's suggested wait
// (zero if none was provided).
func RateLimitRetryAfter(err error) (retryAfter time.Duration, ok bool) {
var rl rateLimitedError
if errors.As(err, &rl) {
return rl.retryAfter, true
}
return 0, false
}
// retryAfterFromHeader parses a Retry-After header, matching the
// delta-seconds + HTTP-date pattern in tempfork/acme/http.go.
func retryAfterFromHeader(h http.Header) time.Duration {
v := h.Get("Retry-After")
if i, err := strconv.Atoi(v); err == nil {
return time.Duration(i) * time.Second
}
t, err := http.ParseTime(v)
if err != nil {
return 0
}
d := time.Until(t)
if d < 0 {
return 0
}
return d
}
// SetDNS adds a DNS TXT record for the given domain name, containing
// the provided TXT value. The intended use case is answering
// LetsEncrypt/ACME dns-01 challenges.
@@ -43,6 +85,8 @@ func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
//
// It returns a cached certificate from disk if it's still valid.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// Deprecated: use [Client.CertPair].
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return defaultClient.CertPair(ctx, domain)
@@ -52,6 +96,8 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
//
// It returns a cached certificate from disk if it's still valid.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return lc.CertPairWithValidity(ctx, domain, 0)
@@ -65,10 +111,18 @@ func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM
// least the given duration, if permitted by the CA. If the certificate is
// valid, but for less than minValidity, it will be synchronously renewed.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
if err != nil {
if hse, ok := errors.AsType[httpStatusError](err); ok && hse.HTTPStatus == http.StatusTooManyRequests {
return nil, nil, rateLimitedError{
retryAfter: retryAfterFromHeader(hse.Header),
underlying: err,
}
}
return nil, nil, err
}
// with ?type=pair, the response PEM is first the one private
+3
View File
@@ -50,6 +50,9 @@ type DebugPortmapOpts struct {
// process.
//
// opts can be nil; if so, default values will be used.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
vals := make(url.Values)
if opts == nil {
+206 -13
View File
@@ -2,6 +2,12 @@
// SPDX-License-Identifier: BSD-3-Clause
// Package local contains a Go client for the Tailscale LocalAPI.
//
// The APIs in this package vary in maturity: some methods are considered
// stable APIs and are documented as such, while others are not necessarily
// stable and are subject to change between releases. Methods without an
// explicit "API maturity" note in their documentation should be assumed
// to be unstable.
package local
import (
@@ -135,6 +141,9 @@ func (lc *Client) defaultDialer(ctx context.Context, network, addr string) (net.
// authenticating to the local Tailscale daemon vary by platform.
//
// DoLocalRequest may mutate the request to add Authorization headers.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DoLocalRequest(req *http.Request) (*http.Response, error) {
req.Header.Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion)))
lc.tsClientOnce.Do(func() {
@@ -280,7 +289,7 @@ func (lc *Client) sendWithHeaders(
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode, res.Header}
}
return slurp, res.Header, nil
}
@@ -288,6 +297,7 @@ func (lc *Client) sendWithHeaders(
type httpStatusError struct {
error
HTTPStatus int
Header http.Header
}
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
@@ -316,6 +326,8 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
// For connections proxied by tailscaled, this looks up the owner of the given
// address as TCP first, falling back to UDP; if you want to only check a
// specific address family, use WhoIsProto.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
@@ -330,6 +342,8 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
// WhoIsForService is like [Client.WhoIs] but scopes the returned CapMap to
// capabilities that apply to the named VIP service. This enables per-service
// capability resolution on hosts that advertise multiple VIP services.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsForService(ctx context.Context, remoteAddr string, svcName tailcfg.ServiceName) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&svc_name="+url.QueryEscape(string(svcName)))
if err != nil {
@@ -345,6 +359,8 @@ func (lc *Client) WhoIsForService(ctx context.Context, remoteAddr string, svcNam
// capabilities that apply to the given destination IP. The IP may be a
// VIP service address, the node's own tailnet address, or any other
// routable IP the node handles.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsForIP(ctx context.Context, remoteAddr string, dst netip.Addr) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&dst_ip="+url.QueryEscape(dst.String()))
if err != nil {
@@ -363,6 +379,8 @@ var ErrPeerNotFound = errors.New("peer not found")
// WhoIsNodeKey returns the owner of the given wireguard public key.
//
// If not found, the error is ErrPeerNotFound.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
if err != nil {
@@ -378,6 +396,8 @@ func (lc *Client) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apityp
// IP:port, for the given protocol (tcp or udp).
//
// If not found, the error is [ErrPeerNotFound].
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
if err != nil {
@@ -454,6 +474,9 @@ func (lc *Client) SetGauge(ctx context.Context, name string, value int) error {
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
// Close the context to stop the stream.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/logtap", nil)
if err != nil {
@@ -470,12 +493,18 @@ func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
}
// EventBusGraph returns a graph of active publishers and subscribers in the eventbus
// as a [eventbus.DebugTopics]
// as a [eventbus.DebugTopics].
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) EventBusGraph(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/debug-bus-graph")
}
// EventBusQueues returns a JSON snapshot of event bus queue depths per client.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) EventBusQueues(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/debug-bus-queues")
}
@@ -484,6 +513,9 @@ func (lc *Client) EventBusQueues(ctx context.Context) ([]byte, error) {
// Each pair is a valid event and a nil error, or a zero event a non-nil error.
// In case of error, the iterator ends after the pair reporting the error.
// Iteration stops if ctx ends.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) StreamBusEvents(ctx context.Context) iter.Seq2[eventbus.DebugEvent, error] {
return func(yield func(eventbus.DebugEvent, error) bool) {
req, err := http.NewRequestWithContext(ctx, "GET",
@@ -552,6 +584,8 @@ type BugReportOpts struct {
//
// The opts type specifies options to pass to the Tailscale daemon when
// generating this bug report.
//
// API maturity: this is considered a stable API.
func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
qparams := make(url.Values)
if opts.Note != "" {
@@ -597,12 +631,17 @@ func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (st
//
// This is the same as calling [Client.BugReportWithOpts] and only specifying the Note
// field.
//
// API maturity: this is considered a stable API.
func (lc *Client) BugReport(ctx context.Context, note string) (string, error) {
return lc.BugReportWithOpts(ctx, BugReportOpts{Note: note})
}
// DebugAction invokes a debug action, such as "rebind" or "restun".
// These are development tools and subject to change or removal over time.
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
func (lc *Client) DebugAction(ctx context.Context, action string) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
@@ -613,7 +652,10 @@ func (lc *Client) DebugAction(ctx context.Context, action string) error {
// DebugActionBody invokes a debug action with a body parameter, such as
// "debug-force-prefer-derp".
// These are development tools and subject to change or removal over time.
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
func (lc *Client) DebugActionBody(ctx context.Context, action string, rbody io.Reader) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, rbody)
if err != nil {
@@ -623,7 +665,10 @@ func (lc *Client) DebugActionBody(ctx context.Context, action string, rbody io.R
}
// DebugResultJSON invokes a debug action and returns its result as something JSON-able.
// These are development tools and subject to change or removal over time.
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
@@ -641,7 +686,10 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
// callers of [Client.DebugResultJSON] otherwise need to do to get a typed
// value.
//
// These are development tools and subject to change or removal over time.
// These are development tools.
//
// API maturity: this function is not considered a stable API and is
// subject to change or removal between releases.
func GetDebugResultJSON[T any](ctx context.Context, lc *Client, action string) (T, error) {
var v T
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
@@ -683,6 +731,9 @@ func (lc *Client) SetDevStoreKeyValue(ctx context.Context, key, value string) er
// SetComponentDebugLogging sets component's debug logging enabled for
// the provided duration. If the duration is in the past, the debug logging
// is disabled.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
if !buildfeatures.HasDebug {
return feature.ErrUnavailable
@@ -711,6 +762,8 @@ func Status(ctx context.Context) (*ipnstate.Status, error) {
}
// Status returns the Tailscale daemon's status.
//
// API maturity: this is considered a stable API.
func (lc *Client) Status(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "")
}
@@ -721,6 +774,8 @@ func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
}
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
//
// API maturity: this is considered a stable API.
func (lc *Client) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "?peers=false")
}
@@ -825,6 +880,9 @@ func (lc *Client) PushFile(ctx context.Context, target tailcfg.StableNodeID, siz
// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
// machine is properly configured to forward IP packets as a subnet router
// or exit node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckIPForwarding(ctx context.Context) error {
if !buildfeatures.HasAdvertiseRoutes {
return nil
@@ -848,6 +906,9 @@ func (lc *Client) CheckIPForwarding(ctx context.Context) error {
// CheckUDPGROForwarding asks the local Tailscale daemon whether it looks like
// the machine is optimally configured to forward UDP packets as a subnet router
// or exit node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-udp-gro-forwarding")
if err != nil {
@@ -897,6 +958,9 @@ func (lc *Client) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
return err
}
// GetPrefs returns the [ipn.Prefs] of the current Tailscale profile.
//
// API maturity: this is considered a stable API.
func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs")
if err != nil {
@@ -914,6 +978,8 @@ func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
// or a policy restriction. An optional reason or justification for the request can be
// provided as a context value using [apitype.RequestReasonKey]. If permitted by policy,
// access may be granted, and the reason will be logged for auditing purposes.
//
// API maturity: this is considered a stable API.
func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, jsonBody(mp))
if err != nil {
@@ -924,6 +990,9 @@ func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Pref
// GetDNSOSConfig returns the system DNS configuration for the current device.
// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
if !buildfeatures.HasDNS {
return nil, feature.ErrUnavailable
@@ -957,7 +1026,26 @@ func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (
return res.Bytes, res.Resolvers, nil
}
// StartLoginInteractive starts an interactive login.
// StartLoginInteractive starts an interactive login, requesting a new
// auth URL from the control plane if a login flow is not already in
// progress. If one is, the existing auth URL is re-sent.
//
// The auth URL is not returned by this method; it is delivered
// asynchronously to IPN bus watchers (see [Client.WatchIPNBus]) as an
// [ipn.Notify] with a non-empty BrowseToURL field. StartLoginInteractive
// returns as soon as the login has been requested; it does not wait for
// the login to complete.
//
// Calling StartLoginInteractive does not itself change the node's
// desired run state, but successfully completing the login does: the
// node's WantRunning pref is set to true, so a stopped node
// ("tailscale down") starts once the login finishes. If the login is
// completed as a different user or node identity than the current
// profile's, the node switches to an existing profile matching the new
// identity if one exists, or else updates the current profile to the
// new identity.
//
// API maturity: this is considered a stable API.
func (lc *Client) StartLoginInteractive(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
return err
@@ -982,6 +1070,8 @@ func (lc *Client) Logout(ctx context.Context) error {
// tailscaled), a FQDN, or an IP address.
//
// The ctx is only used for the duration of the call, not the lifetime of the [net.Conn].
//
// API maturity: this is considered a stable API.
func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
return lc.UserDial(ctx, "tcp", host, port)
}
@@ -993,6 +1083,8 @@ func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Co
//
// The ctx is only used for the duration of the call, not the lifetime of the
// [net.Conn].
//
// API maturity: this is considered a stable API.
func (lc *Client) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
@@ -1057,6 +1149,10 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
// It is intended to be used with netcheck to see availability of DERPs.
//
// API maturity: this is considered a stable API, though the returned
// [tailcfg.DERPMap] type is subject to minor changes over time; its
// general shape is stable.
func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
var derpMap tailcfg.DERPMap
res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
@@ -1073,6 +1169,8 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
// fetch TLS certificates, equivalent to the DNS.CertDomains field of the
// current netmap. The returned list is sorted in ascending order, and is
// empty if no netmap has been received yet.
//
// API maturity: this is considered a stable API.
func (lc *Client) CertDomains(ctx context.Context) ([]string, error) {
body, err := lc.get200(ctx, "/localapi/v0/cert-domains")
if err != nil {
@@ -1094,11 +1192,13 @@ func (lc *Client) DNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
}
// PeerByID returns a peer's current full [tailcfg.Node] looked up by its
// [tailcfg.NodeID], in O(1) time on the daemon side. It returns an error
// if no peer with that NodeID is in the current netmap.
// [tailcfg.NodeID]. It returns an error if no peer with that NodeID is in the
// current netmap.
//
// It is intended for callers that need the latest state of a single peer
// without fetching the entire netmap.
// It is intended for callers that observed a peer-mutation signal (e.g.
// [ipn.Notify.PeerChangedPatch] or [ipn.Notify.PeersChanged]) and want the
// latest state of the affected node without having to apply the patch
// themselves.
func (lc *Client) PeerByID(ctx context.Context, id tailcfg.NodeID) (*tailcfg.Node, error) {
body, err := lc.get200(ctx, "/localapi/v0/peer-by-id?id="+strconv.FormatInt(int64(id), 10))
if err != nil {
@@ -1107,6 +1207,24 @@ func (lc *Client) PeerByID(ctx context.Context, id tailcfg.NodeID) (*tailcfg.Nod
return decodeJSON[*tailcfg.Node](body)
}
// UserProfile returns the current [tailcfg.UserProfile] for the given
// [tailcfg.UserID]. It returns an error if no user with that UserID is in the
// current netmap.
//
// It is the LocalAPI fallback for IPN-bus consumers that see a UserID
// referenced by a peer Node and want to resolve it to a UserProfile. Sessions
// opted in to [ipn.NotifyPeerChanges] / [ipn.NotifyPeerPatches] also receive
// UserProfiles automatically via [ipn.Notify.UserProfiles].
//
// API maturity: this is considered a stable API.
func (lc *Client) UserProfile(ctx context.Context, id tailcfg.UserID) (*tailcfg.UserProfile, error) {
body, err := lc.get200(ctx, "/localapi/v0/user-profile?id="+strconv.FormatInt(int64(id), 10))
if err != nil {
return nil, err
}
return decodeJSON[*tailcfg.UserProfile](body)
}
// PingOpts contains options for the ping request.
//
// The zero value is valid, which means to use defaults.
@@ -1143,6 +1261,8 @@ func (lc *Client) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.Ping
// DisconnectControl shuts down all connections to control, thus making control consider this node inactive. This can be
// run on HA subnet router or app connector replicas before shutting them down to ensure peers get told to switch over
// to another replica whilst there is still some grace period for the existing connections to terminate.
//
// API maturity: this is considered a stable API.
func (lc *Client) DisconnectControl(ctx context.Context) error {
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/disconnect-control", 200, nil, nil)
if err != nil {
@@ -1238,13 +1358,18 @@ func (lc *Client) ReloadConfig(ctx context.Context) (ok bool, err error) {
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
// profile is not assigned an ID until it is persisted after a successful login.
// In order to login to the new profile, the user must call LoginInteractive.
// In order to login to the new profile, the user must call
// [Client.StartLoginInteractive].
//
// API maturity: this is considered a stable API.
func (lc *Client) SwitchToEmptyProfile(ctx context.Context) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/profiles/", http.StatusCreated, nil)
return err
}
// SwitchProfile switches to the given profile.
//
// API maturity: this is considered a stable API.
func (lc *Client) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), 204, nil)
return err
@@ -1279,6 +1404,11 @@ func (lc *Client) QueryFeature(ctx context.Context, feature string) (*tailcfg.Qu
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
}
// DebugDERPRegion reports diagnostic information about the DERP region with
// the given ID or code.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
v := url.Values{"region": {regionIDOrCode}}
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)
@@ -1289,6 +1419,9 @@ func (lc *Client) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*
}
// DebugPacketFilterRules returns the packet filter rules for the current device.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)
if err != nil {
@@ -1300,6 +1433,9 @@ func (lc *Client) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterR
// DebugSetExpireIn marks the current node key to expire in d.
//
// This is meant primarily for debug and testing.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
v := url.Values{"expiry": {fmt.Sprint(time.Now().Add(d).Unix())}}
_, err := lc.send(ctx, "POST", "/localapi/v0/set-expiry-sooner?"+v.Encode(), 200, nil)
@@ -1308,6 +1444,9 @@ func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
// DebugPeerRelaySessions returns debug information about the current peer
// relay sessions running through this node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugPeerRelaySessions(ctx context.Context) (*status.ServerStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/debug-peer-relay-sessions", 200, nil)
if err != nil {
@@ -1320,6 +1459,9 @@ func (lc *Client) DebugPeerRelaySessions(ctx context.Context) (*status.ServerSta
//
// The provided context does not determine the lifetime of the
// returned [io.ReadCloser].
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-capture", nil)
if err != nil {
@@ -1346,9 +1488,16 @@ func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error)
// resources.
//
// A default set of ipn.Notify messages are returned but the set can be modified by mask.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IPNBusWatcher, error) {
m, err := mask.MarshalText()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET",
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+fmt.Sprint(mask),
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+string(m),
nil)
if err != nil {
return nil, err
@@ -1372,6 +1521,8 @@ func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IP
// CheckUpdate returns a [*tailcfg.ClientVersion] indicating whether or not an update is available
// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a
// ClientVersion that says that we are up to date.
//
// API maturity: this is considered a stable API.
func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
body, err := lc.get200(ctx, "/localapi/v0/update/check")
if err != nil {
@@ -1388,6 +1539,8 @@ func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, erro
// To turn it on, there must have been a previously used exit node.
// The most previously used one is reused.
// This is a convenience method for GUIs. To select an actual one, update the prefs.
//
// API maturity: this is considered a stable API.
func (lc *Client) SetUseExitNode(ctx context.Context, on bool) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/set-use-exit-node-enabled?enabled="+strconv.FormatBool(on), http.StatusOK, nil)
return err
@@ -1396,6 +1549,9 @@ func (lc *Client) SetUseExitNode(ctx context.Context, on bool) error {
// DriveSetServerAddr instructs Taildrive to use the server at addr to access
// the filesystem. This is used on platforms like Windows and MacOS to let
// Taildrive know to use the file server running in the GUI app.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveSetServerAddr(ctx context.Context, addr string) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
return err
@@ -1404,6 +1560,9 @@ func (lc *Client) DriveSetServerAddr(ctx context.Context, addr string) error {
// DriveShareSet adds or updates the given share in the list of shares that
// Taildrive will serve to remote nodes. If a share with the same name already
// exists, the existing share is replaced/updated.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareSet(ctx context.Context, share *drive.Share) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
return err
@@ -1411,6 +1570,9 @@ func (lc *Client) DriveShareSet(ctx context.Context, share *drive.Share) error {
// DriveShareRemove removes the share with the given name from the list of
// shares that Taildrive will serve to remote nodes.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
_, err := lc.send(
ctx,
@@ -1422,6 +1584,9 @@ func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
}
// DriveShareRename renames the share from old to new name.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string) error {
_, err := lc.send(
ctx,
@@ -1434,6 +1599,9 @@ func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string)
// DriveShareList returns the list of shares that drive is currently serving
// to remote nodes.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
if err != nil {
@@ -1490,8 +1658,25 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
// SuggestExitNodeWithProbe requests an exit node suggestion based on an immediate routecheck probe,
// waits for the probe to finish, and returns the exit node's details.
func (lc *Client) SuggestExitNodeWithProbe(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
if !buildfeatures.HasRouteCheck {
return apitype.ExitNodeSuggestionResponse{}, feature.ErrUnavailable
}
v := url.Values{"probe": {"true"}}
body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node?"+v.Encode(), 200, nil)
if err != nil {
return apitype.ExitNodeSuggestionResponse{}, err
}
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
// CheckSOMarkInUse reports whether the socket mark option is in use. This will only
// be true if tailscale is running on Linux and tailscaled uses SO_MARK.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
body, err := lc.get200(ctx, "/localapi/v0/check-so-mark-in-use")
if err != nil {
@@ -1508,11 +1693,19 @@ func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
}
// ShutdownTailscaled requests a graceful shutdown of tailscaled.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)
return err
}
// GetAppConnectorRouteInfo returns the current [appctype.RouteInfo] for this
// node's app connector.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteInfo, error) {
body, err := lc.get200(ctx, "/localapi/v0/appc-route-info")
if err != nil {
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_routecheck
package local
import (
"context"
"errors"
"fmt"
"net/http"
"tailscale.com/net/routecheck"
)
// ErrReportPending is returned by [Client.RouteCheck] and [Client.RouteCheckProbe]
// when the report is pending.
var ErrRouteCheckReportUnavailable = errors.New("report pending")
// RouteCheckProbe performs a routecheck probe and waits for its report.
func (lc *Client) RouteCheckProbe(ctx context.Context) (*routecheck.Report, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck?probe=true", http.StatusOK, nil)
if err != nil {
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
return nil, ErrRouteCheckReportUnavailable
}
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*routecheck.Report](body)
}
// RouteCheck requests the report compiled by the latest routecheck probe.
func (lc *Client) RouteCheck(ctx context.Context) (*routecheck.Report, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck", http.StatusOK, nil)
if err != nil {
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
return nil, ErrRouteCheckReportUnavailable
}
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*routecheck.Report](body)
}
+3
View File
@@ -17,6 +17,9 @@ import (
// GetServeConfig return the current serve config.
//
// If the serve config is empty, it returns (nil, nil).
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
body, h, err := lc.sendWithHeaders(ctx, "GET", "/localapi/v0/serve-config", 200, nil, nil)
if err != nil {
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serviceclientprefs
package local
import (
"context"
"net/http"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/feature/serviceclientprefs/serviceclient"
)
// GetServiceClientPrefs returns all of the current profile's [serviceclient.Prefs].
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) GetServiceClientPrefs(ctx context.Context) (serviceclient.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs/service-clients")
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
// SetServiceClientPref merges the non-empty fields from an [apitype.ServiceClientPrefRequest] into the
// saved service client prefs for the current profile and returns the full updated set.
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) SetServiceClientPref(ctx context.Context, req apitype.ServiceClientPrefRequest) (serviceclient.Prefs, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/prefs/service-clients", http.StatusOK, jsonBody(req))
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
+3
View File
@@ -13,6 +13,9 @@ import (
)
// GetEffectivePolicy returns the effective policy for the specified scope.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
+94 -29
View File
@@ -18,17 +18,22 @@ import (
"tailscale.com/types/tkatype"
)
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
// TailnetLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) TailnetLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.NetworkLockStatus](body)
return decodeJSON[*ipnstate.TailnetLockStatus](body)
}
// NetworkLockInit initializes the tailnet key authority.
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
// Deprecated: use [Client.TailnetLockStatus] instead.
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, error) {
return lc.TailnetLockStatus(ctx)
}
// TailnetLockInit initializes the tailnet key authority.
func (lc *Client) TailnetLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.TailnetLockStatus, error) {
var b bytes.Buffer
type initRequest struct {
Keys []tka.Key
@@ -44,12 +49,17 @@ func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disableme
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.NetworkLockStatus](body)
return decodeJSON[*ipnstate.TailnetLockStatus](body)
}
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// Deprecated: use [Client.TailnetLockInit] instead.
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.TailnetLockStatus, error) {
return lc.TailnetLockInit(ctx, keys, disablementValues, supportDisablement)
}
// TailnetLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
func (lc *Client) TailnetLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
@@ -71,8 +81,13 @@ func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey stri
return string(body), nil
}
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
// Deprecated: use [Client.TailnetLockWrapPreauthKey] instead.
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
return lc.TailnetLockWrapPreauthKey(ctx, preauthKey, tkaKey)
}
// TailnetLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *Client) TailnetLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
var b bytes.Buffer
type modifyRequest struct {
AddKeys []tka.Key
@@ -89,9 +104,14 @@ func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []t
return nil
}
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
// Deprecated: use [Client.TailnetLockModify] instead.
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
return lc.TailnetLockModify(ctx, addKeys, removeKeys)
}
// TailnetLockSign signs the specified node-key and transmits that signature to the control plane.
// rotationPublic, if specified, must be an ed25519 public key.
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
func (lc *Client) TailnetLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
var b bytes.Buffer
type signRequest struct {
NodeKey key.NodePublic
@@ -108,8 +128,13 @@ func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, r
return nil
}
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
// Deprecated: use [Client.TailnetLockSign] instead.
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
return lc.TailnetLockSign(ctx, nodeKey, rotationPublic)
}
// TailnetLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) TailnetLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
if err != nil {
return nil, fmt.Errorf("error: %w", err)
@@ -117,19 +142,29 @@ func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.Key
return decodeJSON[[]tkatype.MarshaledSignature](body)
}
// NetworkLockLog returns up to maxEntries number of changes to tailnet-lock state.
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
// Deprecated: use [Client.TailnetLockAffectedSigs] instead.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
return lc.TailnetLockAffectedSigs(ctx, keyID)
}
// TailnetLockLog returns up to maxEntries number of changes to tailnet-lock state.
func (lc *Client) TailnetLockLog(ctx context.Context, maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
v := url.Values{}
v.Set("limit", fmt.Sprint(maxEntries))
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
return decodeJSON[[]ipnstate.TailnetLockUpdate](body)
}
// NetworkLockForceLocalDisable forcibly shuts down tailnet lock on this node.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
// Deprecated: use [Client.TailnetLockLog] instead.
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
return lc.TailnetLockLog(ctx, maxEntries)
}
// TailnetLockForceLocalDisable forcibly shuts down tailnet lock on this node.
func (lc *Client) TailnetLockForceLocalDisable(ctx context.Context) error {
// This endpoint expects an empty JSON stanza as the payload.
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
@@ -142,9 +177,14 @@ func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
return nil
}
// NetworkLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
// Deprecated: use [Client.TailnetLockForceLocalDisable] instead.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
return lc.TailnetLockForceLocalDisable(ctx)
}
// TailnetLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
// in url and returns information extracted from it.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
func (lc *Client) TailnetLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
@@ -157,8 +197,13 @@ func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url stri
return decodeJSON[*tka.DeeplinkValidationResult](body)
}
// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
// Deprecated: use [Client.TailnetLockVerifySigningDeeplink] instead.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
return lc.TailnetLockVerifySigningDeeplink(ctx, url)
}
// TailnetLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *Client) TailnetLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
@@ -172,8 +217,13 @@ func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tk
return body, nil
}
// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
// Deprecated: use [Client.TailnetLockGenRecoveryAUM] instead.
func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
return lc.TailnetLockGenRecoveryAUM(ctx, removeKeys, forkFrom)
}
// TailnetLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *Client) TailnetLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
@@ -183,8 +233,13 @@ func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM)
return body, nil
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
// Deprecated: use [Client.TailnetLockCosignRecoveryAUM] instead.
func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
return lc.TailnetLockCosignRecoveryAUM(ctx, aum)
}
// TailnetLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) TailnetLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
@@ -193,10 +248,20 @@ func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
return nil
}
// NetworkLockDisable shuts down tailnet-lock across the tailnet.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
// Deprecated: use [Client.TailnetLockSubmitRecoveryAUM] instead.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
return lc.TailnetLockSubmitRecoveryAUM(ctx, aum)
}
// TailnetLockDisable shuts down tailnet-lock across the tailnet.
func (lc *Client) TailnetLockDisable(ctx context.Context, secret []byte) error {
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// Deprecated: use [Client.TailnetLockDisable] instead.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
return lc.TailnetLockDisable(ctx, secret)
}
+40 -26
View File
@@ -69,6 +69,11 @@ func (menu *Menu) Run(client *local.Client) {
go menu.lc.SetGauge(menu.bgCtx, "systray_running", 1)
defer menu.lc.SetGauge(menu.bgCtx, "systray_running", 0)
// set initial title, which is used by the systray package as the ID of the StatusNotifierItem.
// This value will get overwritten later as the client status changes.
// This must be called before systray.Run.
systray.SetTitle("tailscale")
systray.Run(menu.onReady, menu.onExit)
}
@@ -172,10 +177,6 @@ See https://tailscale.com/kb/1597/linux-systray for more information.`)
}
setAppIcon(disconnected)
// set initial title, which is used by the systray package as the ID of the StatusNotifierItem.
// This value will get overwritten later as the client status changes.
systray.SetTitle("tailscale")
menu.rebuild()
menu.mu.Lock()
@@ -292,21 +293,23 @@ func (menu *Menu) rebuild() {
accounts := systray.AddMenuItem(account, "")
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
time.Sleep(newMenuDelay)
for _, profile := range menu.allProfiles {
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == menu.curProfile.ID {
item = accounts.AddSubMenuItemCheckbox(title, "", true)
} else {
item = accounts.AddSubMenuItem(title, "")
}
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
onClick(ctx, item, func(ctx context.Context) {
select {
case <-ctx.Done():
case menu.accountsCh <- profile.ID:
if len(menu.allProfiles) > 1 {
for _, profile := range menu.allProfiles {
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == menu.curProfile.ID {
item = accounts.AddSubMenuItemCheckbox(title, "", true)
} else {
item = accounts.AddSubMenuItem(title, "")
}
})
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
onClick(ctx, item, func(ctx context.Context) {
select {
case <-ctx.Done():
case menu.accountsCh <- profile.ID:
}
})
}
}
}
@@ -352,16 +355,27 @@ func (menu *Menu) rebuild() {
// profileTitle returns the title string for a profile menu item.
func profileTitle(profile ipn.LoginProfile) string {
title := profile.Name
tailnet := ""
if profile.NetworkProfile.DomainName != "" {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
// windows and mac don't support multi-line menu
title += " (" + profile.NetworkProfile.DisplayNameOrDefault() + ")"
} else {
title += "\n" + profile.NetworkProfile.DisplayNameOrDefault()
}
tailnet = profile.NetworkProfile.DisplayNameOrDefault()
}
return title
// windows and mac don't support multi-line menu items.
multiline := runtime.GOOS != "windows" && runtime.GOOS != "darwin"
return formatProfileTitle(profile.Name, tailnet, multiline)
}
// formatProfileTitle builds a profile menu label from a login name and an
// optional tailnet name. The tailnet portion is omitted when it matches the
// login name, so single-user tailnets don't show the same string twice.
func formatProfileTitle(name, tailnet string, multiline bool) string {
if tailnet == "" || strings.EqualFold(name, tailnet) {
return name
}
if multiline {
return name + "\n" + tailnet
}
return name + " (" + tailnet + ")"
}
var (
+27
View File
@@ -13,6 +13,33 @@ import (
"tailscale.com/types/key"
)
func TestProfileTitleMultiline(t *testing.T) {
t.Parallel()
tests := []struct {
name string
login string
tailnet string
multiline bool
want string
}{
{"no_tailnet", "alice@example.com", "", true, "alice@example.com"},
{"dup_exact", "example.com", "example.com", true, "example.com"},
{"dup_casefold", "Example.com", "example.com", false, "Example.com"},
{"distinct_multiline", "alice@example.com", "example.com", true, "alice@example.com\nexample.com"},
{"distinct_singleline", "alice@example.com", "example.com", false, "alice@example.com (example.com)"},
{"empty", "", "", true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := formatProfileTitle(tt.login, tt.tailnet, tt.multiline); got != tt.want {
t.Errorf("profileTitleMultiline; got %v, want %v", got, tt.want)
}
})
}
}
func TestRecommendedIsActive(t *testing.T) {
t.Parallel()
+18 -1
View File
@@ -76,7 +76,7 @@ type ReloadConfigResponse struct {
type ExitNodeSuggestionResponse struct {
ID tailcfg.StableNodeID
Name string
Location tailcfg.LocationView `json:",omitempty"`
Location tailcfg.LocationView `json:",omitzero"`
}
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
@@ -104,3 +104,20 @@ type OptionalFeatures struct {
// are not guaranteed to be present.)
Features map[string]bool
}
// ServiceClientPrefRequest is the body POSTed to the LocalAPI endpoint /localapi/v0/prefs/service-clients.
// Empty values for Client, Username, and DatabaseName mean "don't change this value".
type ServiceClientPrefRequest struct {
// Key is the identifier for the service client pref. Required. Format is "<serviceName>:<port>"
// where serviceName is a [tailcfg.ServiceName], e.g. "svc:my-db:5432".
Key string
// Client is the name of the client that the user picked in the service launch. Optional.
Client string `json:",omitzero"`
// Username is the username that the user entered in the service launch. Optional.
Username string `json:",omitzero"`
// DatabaseName is the database name that the user entered in the service launch. Optional.
DatabaseName string `json:",omitzero"`
}
+1 -1
View File
@@ -22,7 +22,7 @@ type Key struct {
// KeyCapabilities are the capabilities of a Key.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
Devices KeyDeviceCapabilities `json:"devices"`
}
// KeyDeviceCapabilities are the device-related capabilities of a Key.
+2 -1
View File
@@ -199,7 +199,8 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
if err != nil {
return true
}
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
return strings.HasSuffix(controlURL.Host, ".tailscale.com") ||
controlURL.Host == "control.tailscale" // for natlab tests
}
// awaitUserAuth blocks until the given session auth has been completed
@@ -61,7 +61,7 @@ export default function ExitNodeSelector({
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
offline, // selected exit node node is offline
offline, // selected exit node is offline
] = useMemo(
() => [
selected.ID === noExitNode.ID,
+95 -6
View File
@@ -11,6 +11,7 @@ import (
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
@@ -37,6 +38,25 @@ import (
"tailscale.com/version/distro"
)
// GokrazyUpdateArgs contains arguments for updating a Gokrazy appliance from a
// GAF fetched from a URL.
type GokrazyUpdateArgs struct {
// URL is the GAF download URL.
URL string
// AllowUnsigned permits installing a GAF without signature verification.
// It is intended for tests that serve a GAF from a fileserver that does
// not publish distsign.pub.
AllowUnsigned bool
// Logf is optional; nil discards log messages.
Logf logger.Logf
}
// GokrazyUpdateFromURL updates a Gokrazy appliance from a GAF fetched from a
// URL, if Gokrazy update support is linked into the binary.
var GokrazyUpdateFromURL feature.Hook[func(context.Context, GokrazyUpdateArgs) error]
const (
StableTrack = "stable"
UnstableTrack = "unstable"
@@ -197,6 +217,17 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
// release cadence with Synology Package Center and use their
// auto-update mechanism.
return up.updateSynology, false
case distro.Gokrazy:
// Only the official Tailscale appliance image (built with the
// ts_appliance build tag, which causes hostinfo to report
// Package="tsapp") is auto-updatable. A user running a custom
// Gokrazy build that happens to include tailscaled must not be
// updated with our stock GAFs. TS_FORCE_ALLOW_TSAPP_UPDATE is an
// escape hatch for callers who know what they're doing.
if hi.Package != "tsapp" && !envknob.Bool("TS_FORCE_ALLOW_TSAPP_UPDATE") {
return nil, false
}
return up.updateGokrazy, true
case distro.Debian: // includes Ubuntu
return up.updateDebLike, true
case distro.Arch:
@@ -330,7 +361,7 @@ func (up *Updater) updateSynology() error {
if err != nil {
return err
}
latest, err := latestPackages(up.Track)
latest, err := LatestPackages(up.Track)
if err != nil {
return err
}
@@ -864,6 +895,56 @@ func (up *Updater) updateFreeBSD() (err error) {
return nil
}
// updateGokrazy fetches the latest signed GAF for this gokrazy device variant
// (vm-amd64, vm-arm64, or pi-arm64) from up.PkgsAddr and applies it via the
// local gokrazy init update API.
func (up *Updater) updateGokrazy() error {
if !GokrazyUpdateFromURL.IsSet() {
return errors.New("gokrazy update support is not linked into this binary")
}
variant, err := gokrazyDeviceVariant()
if err != nil {
return err
}
latest, err := LatestPackages(up.Track)
if err != nil {
return err
}
gafName, ok := latest.GAFs[variant]
if !ok {
return fmt.Errorf("no GAF for device %q on %q track", variant, up.Track)
}
if latest.GAFsVersion == "" {
return fmt.Errorf("no GAF version on %q track", up.Track)
}
if !up.confirm(latest.GAFsVersion) {
return nil
}
gafURL := fmt.Sprintf("%s/%s/%s", strings.TrimRight(up.PkgsAddr, "/"), up.Track, gafName)
up.Logf("Updating to %s (%s)", latest.GAFsVersion, gafURL)
return GokrazyUpdateFromURL.Get()(context.Background(), GokrazyUpdateArgs{
URL: gafURL,
Logf: up.Logf,
})
}
// gokrazyDeviceVariant returns the GAFs JSON key for the current gokrazy
// device, e.g. "vm-amd64", "vm-arm64", or "pi-arm64". On arm64, it reads the
// device-tree model to tell a Raspberry Pi apart from a VM.
func gokrazyDeviceVariant() (string, error) {
switch runtime.GOARCH {
case "amd64":
return "vm-amd64", nil
case "arm64":
b, _ := os.ReadFile("/sys/firmware/devicetree/base/model")
if strings.HasPrefix(strings.Trim(string(b), "\x00\r\n\t "), "Raspberry Pi") {
return "pi-arm64", nil
}
return "vm-arm64", nil
}
return "", fmt.Errorf("unsupported gokrazy GOARCH %q", runtime.GOARCH)
}
func (up *Updater) updateLinuxBinary() error {
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
@@ -1224,7 +1305,7 @@ func LatestTailscaleVersion(track string) (string, error) {
track = CurrentTrack
}
latest, err := latestPackages(track)
latest, err := LatestPackages(track)
if err != nil {
return "", err
}
@@ -1236,8 +1317,11 @@ func LatestTailscaleVersion(track string) (string, error) {
ver = latest.MacZipsVersion
case "linux":
ver = latest.TarballsVersion
if distro.Get() == distro.Synology {
switch distro.Get() {
case distro.Synology:
ver = latest.SPKsVersion
case distro.Gokrazy:
ver = latest.GAFsVersion
}
}
@@ -1247,7 +1331,8 @@ func LatestTailscaleVersion(track string) (string, error) {
return ver, nil
}
type trackPackages struct {
// TrackPackages is the JSON shape served at <pkgs>/<track>/?mode=json.
type TrackPackages struct {
Version string
Tarballs map[string]string
TarballsVersion string
@@ -1255,6 +1340,8 @@ type trackPackages struct {
ExesVersion string
MSIs map[string]string
MSIsVersion string
GAFs map[string]string
GAFsVersion string
MacZips map[string]string
MacZipsVersion string
SPKs map[string]map[string]string
@@ -1263,14 +1350,16 @@ type trackPackages struct {
var tailscaleHTTPEndpoint = "https://pkgs.tailscale.com"
func latestPackages(track string) (*trackPackages, error) {
// LatestPackages fetches the package manifest served at
// <pkgs>/<track>/?mode=json for the current runtime.GOOS.
func LatestPackages(track string) (*TrackPackages, error) {
url := fmt.Sprintf("%s/%s/?mode=json&os=%s", tailscaleHTTPEndpoint, track, runtime.GOOS)
res, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
}
defer res.Body.Close()
var latest trackPackages
var latest TrackPackages
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
}
+239
View File
@@ -0,0 +1,239 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package clientupdate
import (
"archive/zip"
"context"
"fmt"
"hash/crc32"
"io"
"net"
"net/http"
"os"
"strings"
"time"
"tailscale.com/clientupdate/distsign"
"tailscale.com/types/logger"
"tailscale.com/util/progresstracking"
)
const (
gokrazyUpdateSocket = "/run/gokrazy-http.sock"
gokrazyUpdateBaseURL = "http://gokrazy-local-unixsock"
)
// GokrazyUpdateFromURL downloads a Gokrazy archive format file from args.URL,
// installs its partitions using the local gokrazy init update API, switches to
// the new root partition, and asks gokrazy to reboot.
//
// The local gokrazy API is reached over gokrazyUpdateSocket. The
// gokrazyUpdateBaseURL host is only a net/http URL sentinel; it is not resolved
// with DNS.
func init() {
GokrazyUpdateFromURL.Set(gokrazyUpdateFromURL)
}
func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
logf := args.Logf
if logf == nil {
logf = logger.Discard
}
tmp, err := os.CreateTemp("", "tailscale-gokrazy-*.gaf")
if err != nil {
return err
}
tmpName := tmp.Name()
tmp.Close()
defer os.Remove(tmpName)
logf("downloading %s", args.URL)
if args.AllowUnsigned {
if err := downloadUnverified(ctx, logf, args.URL, tmpName); err != nil {
return err
}
} else {
if err := distsign.DownloadVerified(ctx, logf, args.URL, tmpName); err != nil {
return err
}
}
zr, err := zip.OpenReader(tmpName)
if err != nil {
return err
}
defer zr.Close()
logf("download complete")
gokClient := gokrazyHTTPClient()
for _, part := range []struct {
name string
path string
}{
{"root.img", "/update/root"},
{"boot.img", "/update/boot"},
{"mbr.img", "/update/mbr"},
} {
logf("writing %s...", part.name)
if err := putGokrazyGAFMember(ctx, gokClient, zr.File, part.name, part.path); err != nil {
return err
}
logf("wrote %s", part.name)
}
if err := postGokrazy(ctx, gokClient, "/update/switch"); err != nil {
return err
}
logf("switched boot target")
if err := postGokrazy(ctx, gokClient, "/reboot?async=true&kexec_merge_cmdline=true"); err != nil {
return err
}
logf("reboot requested")
return nil
}
// downloadUnverified saves the GAF at srcURL to dstPath without verifying
// a signature. It is used only when args.AllowUnsigned is set, for tests
// that serve the GAF from a fileserver that does not publish distsign.pub
// and for the gafpush "sftp the GAF onto the appliance and update from a
// local path" flow, which uses a "file://" URL.
func downloadUnverified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
if after, ok := strings.CutPrefix(srcURL, "file://"); ok {
return copyLocalFile(after, dstPath, logf)
}
req, err := http.NewRequestWithContext(ctx, "GET", srcURL, nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("download GAF: %s", res.Status)
}
f, err := os.Create(dstPath)
if err != nil {
return err
}
total := res.ContentLength
pw := progresstracking.NewWriter(io.Discard, total, time.Second, func(done int64) {
if total > 0 {
logf("downloading: %d / %d MB (%.0f%%)", done>>20, total>>20, float64(done)/float64(total)*100)
}
})
if _, err := io.Copy(f, io.TeeReader(res.Body, pw)); err != nil {
f.Close()
return err
}
return f.Close()
}
// copyLocalFile copies the GAF at src to dst. Used by the "file://" branch
// of downloadUnverified. The source file is left in place; callers that
// staged it (e.g. gafpush) clean up after the update completes.
func copyLocalFile(src, dst string, logf logger.Logf) error {
sf, err := os.Open(src)
if err != nil {
return err
}
defer sf.Close()
df, err := os.Create(dst)
if err != nil {
return err
}
fi, err := sf.Stat()
if err != nil {
df.Close()
return err
}
total := fi.Size()
logf("copying local GAF %s (%d MB)", src, total>>20)
pw := progresstracking.NewWriter(io.Discard, total, time.Second, func(done int64) {
if total > 0 {
logf("copying: %d / %d MB (%.0f%%)", done>>20, total>>20, float64(done)/float64(total)*100)
}
})
if _, err := io.Copy(df, io.TeeReader(sf, pw)); err != nil {
df.Close()
return err
}
return df.Close()
}
func gokrazyHTTPClient() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", gokrazyUpdateSocket)
}
return &http.Client{
Transport: tr,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func putGokrazyGAFMember(ctx context.Context, hc *http.Client, files []*zip.File, name, path string) error {
var zf *zip.File
for _, f := range files {
if f.Name == name {
zf = f
break
}
}
if zf == nil {
return fmt.Errorf("GAF is missing %s", name)
}
rc, err := zf.Open()
if err != nil {
return err
}
defer rc.Close()
h := crc32.NewIEEE()
body := io.TeeReader(rc, h)
req, err := http.NewRequestWithContext(ctx, "PUT", gokrazyUpdateBaseURL+path, body)
if err != nil {
return err
}
req.ContentLength = int64(zf.UncompressedSize64)
req.Header.Set("X-Gokrazy-Update-Hash", "crc32")
res, err := hc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
resBody, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK {
return fmt.Errorf("PUT %s: %s: %s", path, res.Status, strings.TrimSpace(string(resBody)))
}
if got, want := strings.TrimSpace(string(resBody)), fmt.Sprintf("%08x", h.Sum32()); got != want {
return fmt.Errorf("PUT %s: gokrazy checksum = %q; want %q", path, got, want)
}
return nil
}
func postGokrazy(ctx context.Context, hc *http.Client, path string) error {
req, err := http.NewRequestWithContext(ctx, "POST", gokrazyUpdateBaseURL+path, nil)
if err != nil {
return err
}
res, err := hc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
return fmt.Errorf("POST %s: %s: %s", path, res.Status, strings.TrimSpace(string(body)))
}
return nil
}
+1 -1
View File
@@ -373,7 +373,7 @@ func TestCheckOutdatedAlpineRepo(t *testing.T) {
testServ := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
version := trackPackages{
version := TrackPackages{
MSIsVersion: tt.latestHTTPVersion,
MacZipsVersion: tt.latestHTTPVersion,
TarballsVersion: tt.latestHTTPVersion,
+7 -23
View File
@@ -56,9 +56,11 @@ import (
"github.com/hdevalence/ed25519consensus"
"golang.org/x/crypto/blake2s"
"tailscale.com/feature"
"tailscale.com/net/netutil"
"tailscale.com/types/logger"
"tailscale.com/util/httpm"
"tailscale.com/util/must"
"tailscale.com/util/progresstracking"
)
const (
@@ -329,7 +331,7 @@ func fetch(url string, limit int64) ([]byte, error) {
// download writes the response body of url into a local file at dst, up to
// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]byte, int64, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr := netutil.NewDefaultTransport()
tr.Proxy = feature.HookProxyFromEnvironment.GetOrNil()
defer tr.CloseIdleConnections()
hc := &http.Client{
@@ -372,7 +374,10 @@ func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]
return nil, 0, err
}
defer of.Close()
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
total := res.ContentLength
pw := progresstracking.NewWriter(io.Discard, total, 2*time.Second, func(done int64) {
c.logf("Downloaded %v/%v (%.1f%%)", done, total, float64(done)/float64(total)*100)
})
h := NewPackageHash()
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
if err != nil {
@@ -387,31 +392,10 @@ func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]
if err := of.Close(); err != nil {
return nil, n, err
}
pw.print()
return h.Sum(nil), h.Len(), nil
}
type progressWriter struct {
done int64
total int64
lastPrint time.Time
logf logger.Logf
}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
pw.done += int64(len(p))
if time.Since(pw.lastPrint) > 2*time.Second {
pw.print()
}
return len(p), nil
}
func (pw *progressWriter) print() {
pw.lastPrint = time.Now()
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
}
func parsePrivateKey(data []byte, typeTag string) (ed25519.PrivateKey, error) {
b, rest := pem.Decode(data)
if b == nil {
+42
View File
@@ -0,0 +1,42 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import (
"context"
"fmt"
"net/url"
"strings"
"tailscale.com/types/logger"
)
// DownloadVerified is a convenience wrapper around [Client.Download]
// for callers that have a full URL (e.g.
// https://pkgs.tailscale.com/unstable/foo.gaf) rather than a base URL
// plus path. It splits srcURL into a base ("scheme://host") and a path,
// constructs a [Client] for the base, and downloads with signature
// verification to dstPath.
func DownloadVerified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
if logf == nil {
logf = logger.Discard
}
u, err := url.Parse(srcURL)
if err != nil {
return fmt.Errorf("parsing URL %q: %w", srcURL, err)
}
if u.Scheme == "" || u.Host == "" {
return fmt.Errorf("URL %q is missing scheme or host", srcURL)
}
base := &url.URL{Scheme: u.Scheme, User: u.User, Host: u.Host}
path := strings.TrimPrefix(u.Path, "/")
if path == "" {
return fmt.Errorf("URL %q has no path component", srcURL)
}
c, err := NewClient(logf, base.String())
if err != nil {
return err
}
return c.Download(ctx, path, dstPath)
}
+40 -7
View File
@@ -143,19 +143,32 @@ func main() {
log.Printf("Using cigocached at %s", *srvURL)
}
c.remote = &cachers.HTTPClient{
BaseURL: *srvURL,
Disk: c.disk,
HTTPClient: httpClient(srvHost, *srvHostDial),
AccessToken: *token,
Verbose: *verbose,
BestEffortHTTP: true,
BaseURL: *srvURL,
Disk: c.disk,
HTTPClient: httpClient(srvHost, *srvHostDial),
AccessToken: *token,
Verbose: *verbose,
BestEffortHTTP: true,
AsyncPutTimeout: asyncPutTimeout,
AsyncPutMaxConcurrent: 10,
}
}
var p *cacheproc.Process
p = &cacheproc.Process{
Close: func() error {
if c.remote != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !c.remote.Shutdown(ctx) {
log.Printf("cigocacher: timed out waiting for background PUTs to drain")
}
// Always surface dropped PUTs.
if timedOut, canceled := c.remote.PutsTimedOut.Load(), c.remote.PutsCanceled.Load(); timedOut+canceled > 0 {
log.Printf("cigocacher: %d background PUTs timed out, %d canceled", timedOut, canceled)
}
}
if c.verbose {
log.Printf("gocacheprog: closing; %d gets (%d hits, %d misses, %d errors); %d puts (%d errors)",
log.Printf("cigocacher: closing; %d gets (%d hits, %d misses, %d errors); %d puts (%d errors)",
p.Gets.Load(), p.GetHits.Load(), p.GetMisses.Load(), p.GetErrors.Load(), p.Puts.Load(), p.PutErrors.Load())
}
return c.close()
@@ -338,3 +351,23 @@ func fetchStats(cl *http.Client, baseURL, accessToken string) (string, error) {
}
return string(b), nil
}
const (
// minPutTimeout is the floor we clamp to for small objects where the time is
// dominated by fixed overheads like connection establishment, waiting for a
// busy server to service the request etc.
minPutTimeout = 5 * time.Second
// maxPutTimeout is the ceiling we clamp to for large objects.
maxPutTimeout = 30 * time.Second
// minAverageBandwidth is the minimum average bandwidth (2MiB/s) we require
// for PUTs to complete within the timeout in its linear scaling region.
minAverageBandwidth = 2 * 1 << 20 / float64(time.Second)
)
// asyncPutTimeout returns a size-dependent timeout for async PUTs to the remote
// gocached server. It returns 5s for size <= 10MiB, 30s for size >= 60MiB and
// scales linearly in between.
func asyncPutTimeout(size int64) time.Duration {
timeout := time.Duration(float64(size) / minAverageBandwidth)
return min(max(minPutTimeout, timeout), maxPutTimeout)
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"testing"
"time"
)
func TestAsyncPutTimeout(t *testing.T) {
for size, expected := range map[int64]time.Duration{
0: 5 * time.Second,
10: 5 * time.Second,
10 * 1 << 20: 5 * time.Second,
20 * 1 << 20: 10 * time.Second,
40 * 1 << 20: 20 * time.Second,
60 * 1 << 20: 30 * time.Second,
10 * 1 << 30: 30 * time.Second,
} {
if actual := asyncPutTimeout(size); actual != expected {
t.Errorf("for size %d, expected %v, but got %v", size, expected, actual)
}
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("}")
case *types.Map:
elem := ft.Elem()
if sliceType, isSlice := elem.(*types.Slice); isSlice {
if sliceType, isSlice := elem.Underlying().(*types.Slice); isSlice {
n := it.QualifiedName(sliceType.Elem())
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
+10
View File
@@ -283,3 +283,13 @@ func TestDeeplyNestedMap(t *testing.T) {
t.Errorf("Clone() aliased FourLevels map: new nested key appeared in original")
}
}
func TestMapWithNamedSliceValues(t *testing.T) {
orig := &clonerex.MapWithNamedSliceValues{
M: map[string]clonerex.NamedSlice{"k": {"foo", "bar"}},
}
cloned := orig.Clone()
if diff := cmp.Diff(orig, cloned); diff != "" {
t.Errorf("Clone() mismatch (-orig +cloned):\n%s", diff)
}
}
+13 -4
View File
@@ -1,11 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer,MapWithNamedSliceValues
// Package clonerex is an example package for the cloner tool.
package clonerex
import "maps"
type SliceContainer struct {
Slice []*int
}
@@ -49,9 +51,7 @@ func (m NamedMap) Clone() NamedMap {
return nil
}
m2 := make(NamedMap, len(m))
for k, v := range m {
m2[k] = v
}
maps.Copy(m2, m)
return m2
}
@@ -72,3 +72,12 @@ type DeeplyNestedMap struct {
ThreeLevels map[string]map[string]map[string]int
FourLevels map[string]map[string]map[string]map[string]*SliceContainer
}
// MapWithNamedSliceValues has a map with a named slice type for values. This
// tests that the generator treats these values like any other slice and not a
// struct.
type MapWithNamedSliceValues struct {
M map[string]NamedSlice
}
type NamedSlice []string
+32 -1
View File
@@ -209,9 +209,31 @@ var _MapSlicePointerContainerCloneNeedsRegeneration = MapSlicePointerContainer(s
Routes map[string][]*SliceContainer
}{})
// Clone makes a deep copy of MapWithNamedSliceValues.
// The result aliases no memory with the original.
func (src *MapWithNamedSliceValues) Clone() *MapWithNamedSliceValues {
if src == nil {
return nil
}
dst := new(MapWithNamedSliceValues)
*dst = *src
if dst.M != nil {
dst.M = map[string]NamedSlice{}
for k := range src.M {
dst.M[k] = append([]string{}, src.M[k]...)
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _MapWithNamedSliceValuesCloneNeedsRegeneration = MapWithNamedSliceValues(struct {
M map[string]NamedSlice
}{})
// Clone duplicates src into dst and reports whether it succeeded.
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer.
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer,MapWithNamedSliceValues.
func Clone(dst, src any) bool {
switch src := src.(type) {
case *SliceContainer:
@@ -268,6 +290,15 @@ func Clone(dst, src any) bool {
*dst = src.Clone()
return true
}
case *MapWithNamedSliceValues:
switch dst := dst.(type) {
case *MapWithNamedSliceValues:
*dst = *src.Clone()
return true
case **MapWithNamedSliceValues:
*dst = src.Clone()
return true
}
}
return false
}
+68 -41
View File
@@ -27,7 +27,7 @@ import (
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
"tailscale.com/util/linuxfw"
"tailscale.com/util/mak"
@@ -55,9 +55,10 @@ type egressProxy struct {
tsClient *local.Client // never nil
netmapChan chan *netmap.NetworkMap // chan to receive netmap updates on
netmapChan chan netmapState // chan to receive netmap state updates on
podIPv4 string // never empty string, currently only IPv4 is supported
podIPv4 string // empty if Pod does not have IPv4 address
podIPv6 string // empty if Pod does not have IPv6 address
// tailnetFQDNs is the egress service FQDN to tailnet IP mappings that
// were last used to configure firewall rules for this proxy.
@@ -87,7 +88,7 @@ type httpClient interface {
// - the mounted egress config has changed
// - the proxy's tailnet IP addresses have changed
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
func (ep *egressProxy) run(ctx context.Context, nm *netmap.NetworkMap, opts egressProxyRunOpts) error {
func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxyRunOpts) error {
ep.configure(opts)
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
@@ -136,8 +137,9 @@ type egressProxyRunOpts struct {
kc kubeclient.Client
tsClient *local.Client
stateSecret string
netmapChan chan *netmap.NetworkMap
netmapChan chan netmapState
podIPv4 string
podIPv6 string
tailnetAddrs []netip.Prefix
}
@@ -150,6 +152,7 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
ep.stateSecret = opts.stateSecret
ep.netmapChan = opts.netmapChan
ep.podIPv4 = opts.podIPv4
ep.podIPv6 = opts.podIPv6
ep.tailnetAddrs = opts.tailnetAddrs
ep.client = &http.Client{} // default HTTP client
sleepDuration := time.Second
@@ -165,7 +168,7 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
// as failed firewall update
func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
func (ep *egressProxy) sync(ctx context.Context, nm netmapState) error {
cfgs, err := ep.getConfigs()
if err != nil {
return fmt.Errorf("error retrieving egress service configs: %w", err)
@@ -186,16 +189,15 @@ func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
return nil
}
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
// Netmap must not be nil.
func (ep *egressProxy) addrsHaveChanged(nm *netmap.NetworkMap) bool {
return !reflect.DeepEqual(ep.tailnetAddrs, nm.SelfNode.Addresses())
// addrsHaveChanged returns true if the provided netmap state contains tailnet address change for this proxy node.
func (ep *egressProxy) addrsHaveChanged(nm netmapState) bool {
return !views.SliceEqual(views.SliceOf(ep.tailnetAddrs), nm.self.Addresses())
}
// syncEgressConfigs adds and deletes firewall rules to match the desired
// configuration. It uses the provided status to determine what is currently
// applied and updates the status after a successful sync.
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm *netmap.NetworkMap) (*egressservices.Status, error) {
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm netmapState) (*egressservices.Status, error) {
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
return nil, nil
}
@@ -234,7 +236,7 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
// family.
for _, t := range tailnetTargetIPs {
var local netip.Addr
for _, pfx := range nm.SelfNode.Addresses().All() {
for _, pfx := range nm.self.Addresses().All() {
if !pfx.IsSingleIP() {
continue
}
@@ -250,6 +252,9 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
if err := ep.nfr.EnsureSNATForDst(local, t); err != nil {
return nil, fmt.Errorf("error setting up SNAT rule: %w", err)
}
if err := ep.nfr.ClampMSSToPMTU(tailscaleTunInterface, t); err != nil {
return nil, fmt.Errorf("error clamping MSS to PMTU: %w", err)
}
}
}
// Update the status. Status will be written back to the state Secret by the caller.
@@ -416,7 +421,7 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
if err := json.Unmarshal([]byte(raw), status); err != nil {
return nil, fmt.Errorf("error unmarshalling previous config: %w", err)
}
if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) {
if status.PodIPv4 == ep.podIPv4 && status.PodIPv6 == ep.podIPv6 {
return status, nil
}
return nil, nil
@@ -424,12 +429,13 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
// setStatus writes egress proxy's currently configured firewall to the state
// Secret and updates proxy's tailnet addresses.
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm *netmap.NetworkMap) error {
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm netmapState) error {
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
if status == nil {
status = &egressservices.Status{}
}
status.PodIPv4 = ep.podIPv4
status.PodIPv6 = ep.podIPv6
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
if err != nil {
return fmt.Errorf("error retrieving state Secret: %w", err)
@@ -447,7 +453,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
return fmt.Errorf("error patching state Secret: %w", err)
}
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
return nil
}
@@ -457,7 +463,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
// doesn't.
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *netmap.NetworkMap) (addrs []netip.Addr, err error) {
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netmapState) (addrs []netip.Addr, err error) {
if svc.TailnetTarget.IP != "" {
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
if err != nil {
@@ -473,8 +479,8 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *net
if svc.TailnetTarget.FQDN == "" {
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
}
if nm == nil {
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
if !nm.self.Valid() {
log.Printf("netmap state is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
return addrs, nil
}
egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
@@ -501,26 +507,26 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *net
return addrs, nil
}
// shouldResync parses netmap update and returns true if the update contains
// shouldResync parses netmap state update and returns true if the update contains
// changes for which the egress proxy's firewall should be reconfigured.
func (ep *egressProxy) shouldResync(nm *netmap.NetworkMap) bool {
if nm == nil {
func (ep *egressProxy) shouldResync(nm netmapState) bool {
if !nm.self.Valid() {
return false
}
// If proxy's tailnet addresses have changed, resync.
if !reflect.DeepEqual(nm.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
if !views.SliceEqual(nm.self.Addresses(), views.SliceOf(ep.tailnetAddrs)) {
log.Printf("node addresses have changed, trigger egress config resync")
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
return true
}
// If the IPs for any of the egress services configured via FQDN have
// changed, resync.
for fqdn, ips := range ep.targetFQDNs {
for _, nn := range nm.Peers {
for nn := range nm.peers() {
if equalFQDNs(nn.Name(), fqdn) {
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
if !views.SliceEqual(views.SliceOf(ips), nn.Addresses()) {
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
return true
}
@@ -620,6 +626,8 @@ func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
}
st.PodIPv4 = ""
st1.PodIPv4 = ""
st.PodIPv6 = ""
st1.PodIPv6 = ""
return reflect.DeepEqual(*st, *st1)
}
@@ -671,24 +679,29 @@ func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs egressse
continue
}
svc := s
// TODO(beckypauley): In dual-stack clusters, this is a best-effort check as we do not control which IP family is used.
// This confirms removal from routing on this node for one family only. The other IP family then relies on the longSleep below.
wg.Go(func() {
log.Printf("Ensuring that cluster traffic is no longer routed to %q via this Pod...", svc)
podIP, header := ep.podIPv4, kubetypes.PodIPv4Header
if podIP == "" {
podIP, header = ep.podIPv6, kubetypes.PodIPv6Header
}
if ep.podDrained(ctx, svc, hep, podIP, header, hp) {
return
}
ticker := time.NewTicker(ep.shortSleep)
defer ticker.Stop()
for {
if ctx.Err() != nil { // kubelet's HTTP request timeout
select {
case <-ctx.Done(): // kubelet's HTTP request timeout
log.Printf("Cluster traffic for %s did not stop being routed to this Pod.", svc)
return
case <-ticker.C:
if ep.podDrained(ctx, svc, hep, podIP, header, hp) {
return
}
}
found, err := lookupPodRoute(ctx, hep, ep.podIPv4, hp, ep.client)
if err != nil {
log.Printf("unable to reach endpoint %q, assuming the routing rules for this Pod have been deleted: %v", hep, err)
break
}
if !found {
log.Printf("service %q is no longer routed through this Pod", svc)
break
}
log.Printf("service %q is still routed through this Pod, waiting...", svc)
time.Sleep(ep.shortSleep)
}
})
}
@@ -702,9 +715,9 @@ func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs egressse
// lookupPodRoute calls the healthcheck endpoint repeat times and returns true if the endpoint returns with the podIP
// header at least once.
func lookupPodRoute(ctx context.Context, hep, podIP string, repeat int, client httpClient) (bool, error) {
func lookupPodRoute(ctx context.Context, hep, podIP, podIPHeader string, repeat int, client httpClient) (bool, error) {
for range repeat {
f, err := lookup(ctx, hep, podIP, client)
f, err := lookup(ctx, hep, podIP, podIPHeader, client)
if err != nil {
return false, err
}
@@ -716,7 +729,7 @@ func lookupPodRoute(ctx context.Context, hep, podIP string, repeat int, client h
}
// lookup calls the healthcheck endpoint and returns true if the response contains the podIP header.
func lookup(ctx context.Context, hep, podIP string, client httpClient) (bool, error) {
func lookup(ctx context.Context, hep, podIP, podIPHeader string, client httpClient) (bool, error) {
req, err := http.NewRequestWithContext(ctx, httpm.GET, hep, nil)
if err != nil {
return false, fmt.Errorf("error creating new HTTP request: %v", err)
@@ -731,7 +744,7 @@ func lookup(ctx context.Context, hep, podIP string, client httpClient) (bool, er
return true, nil
}
defer resp.Body.Close()
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
gotIP := resp.Header.Get(podIPHeader)
return strings.EqualFold(podIP, gotIP), nil
}
@@ -760,3 +773,17 @@ func (ep *egressProxy) getHEPPings() (int, error) {
}
return hp, nil
}
func (ep *egressProxy) podDrained(ctx context.Context, svc, hep, podIP, header string, hp int) bool {
found, err := lookupPodRoute(ctx, hep, podIP, header, hp, ep.client)
if err != nil {
log.Printf("unable to reach endpoint %q, assuming the routing rules for this Pod have been deleted: %v", hep, err)
return true
}
if !found {
log.Printf("service %q is no longer routed through this Pod", svc)
return true
}
log.Printf("service %q is still routed through this Pod, waiting...", svc)
return false
}
+3 -1
View File
@@ -15,6 +15,7 @@ import (
"strings"
"sync"
"testing"
"time"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubetypes"
@@ -269,7 +270,8 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
}
ep := &egressProxy{
podIPv4: podIP,
podIPv4: podIP,
shortSleep: time.Millisecond,
client: &mockHTTPClient{
podIP: podIP,
anotherIP: anotherIP,
+7 -1
View File
@@ -265,7 +265,13 @@ func ensureIngressRulesAdded(cfgs map[string]ingressservices.Config, nfr linuxfw
func addDNATRuleForSvc(nfr linuxfw.NetfilterRunner, serviceName string, tsIP, clusterIP netip.Addr) error {
log.Printf("adding DNAT rule for Tailscale Service %s with IP %s to Kubernetes Service IP %s", serviceName, tsIP, clusterIP)
return nfr.EnsureDNATRuleForSvc(serviceName, tsIP, clusterIP)
if err := nfr.EnsureDNATRuleForSvc(serviceName, tsIP, clusterIP); err != nil {
return err
}
if err := nfr.ClampMSSToPMTU(tailscaleTunInterface, clusterIP); err != nil {
return fmt.Errorf("error clamping MSS to PMTU: %w", err)
}
return nil
}
// ensureIngressRulesDeleted takes a map of Tailscale Services and rules and ensures that the firewall rules are deleted.
+39 -3
View File
@@ -7,6 +7,7 @@ package main
import (
"net/netip"
"slices"
"testing"
"tailscale.com/kube/ingressservices"
@@ -22,6 +23,7 @@ func TestSyncIngressConfigs(t *testing.T) {
TailscaleServiceIP netip.Addr
ClusterIP netip.Addr
}
wantClampedAddrs []netip.Addr // cluster IPs that should have MSS clamping applied
}{
{
name: "add_new_rules_when_no_existing_config",
@@ -35,6 +37,7 @@ func TestSyncIngressConfigs(t *testing.T) {
}{
"svc:foo": makeWantService("100.64.0.1", "10.0.0.1"),
},
wantClampedAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
},
{
name: "add_multiple_services",
@@ -52,6 +55,11 @@ func TestSyncIngressConfigs(t *testing.T) {
"svc:bar": makeWantService("100.64.0.2", "10.0.0.2"),
"svc:baz": makeWantService("100.64.0.3", "10.0.0.3"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
netip.MustParseAddr("10.0.0.3"),
},
},
{
name: "add_both_ipv4_and_ipv6_rules",
@@ -65,6 +73,10 @@ func TestSyncIngressConfigs(t *testing.T) {
}{
"svc:foo": makeWantService("2001:db8::1", "2001:db8::2"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("2001:db8::2"),
},
},
{
name: "add_ipv6_only_rules",
@@ -78,6 +90,7 @@ func TestSyncIngressConfigs(t *testing.T) {
}{
"svc:ipv6": makeWantService("2001:db8::10", "2001:db8::20"),
},
wantClampedAddrs: []netip.Addr{netip.MustParseAddr("2001:db8::20")},
},
{
name: "delete_all_rules_when_config_removed",
@@ -94,6 +107,7 @@ func TestSyncIngressConfigs(t *testing.T) {
TailscaleServiceIP netip.Addr
ClusterIP netip.Addr
}{},
wantClampedAddrs: nil, // no rules added, no clamping
},
{
name: "add_remove_modify",
@@ -117,6 +131,10 @@ func TestSyncIngressConfigs(t *testing.T) {
"svc:foo": makeWantService("100.64.0.1", "10.0.0.2"),
"svc:new": makeWantService("100.64.0.4", "10.0.0.4"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.2"),
netip.MustParseAddr("10.0.0.4"),
},
},
{
name: "update_with_outdated_status",
@@ -152,12 +170,17 @@ func TestSyncIngressConfigs(t *testing.T) {
"svc:web-ipv6": makeWantService("2001:db8::10", "2001:db8::20"),
"svc:api": makeWantService("100.64.0.20", "10.0.0.20"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.10"),
netip.MustParseAddr("10.0.0.20"),
netip.MustParseAddr("2001:db8::20"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var nfr linuxfw.NetfilterRunner = linuxfw.NewFakeNetfilterRunner()
nfr := linuxfw.NewFakeNetfilterRunner()
ep := &ingressProxy{
nfr: nfr,
@@ -170,8 +193,7 @@ func TestSyncIngressConfigs(t *testing.T) {
t.Fatalf("syncIngressConfigs failed: %v", err)
}
fake := nfr.(*linuxfw.FakeNetfilterRunner)
gotServices := fake.GetServiceState()
gotServices := nfr.GetServiceState()
if len(gotServices) != len(tt.wantServices) {
t.Errorf("got %d services, want %d", len(gotServices), len(tt.wantServices))
}
@@ -188,6 +210,20 @@ func TestSyncIngressConfigs(t *testing.T) {
t.Errorf("service %s: got ClusterIP %v, want %v", svc, got.ClusterIP, want.ClusterIP)
}
}
gotClamped := nfr.GetClampedAddrs()
slices.SortFunc(gotClamped, func(a, b netip.Addr) int { return a.Compare(b) })
slices.SortFunc(tt.wantClampedAddrs, func(a, b netip.Addr) int { return a.Compare(b) })
if len(gotClamped) != len(tt.wantClampedAddrs) {
t.Errorf("ClampMSSToPMTU: got %v, want %v", gotClamped, tt.wantClampedAddrs)
} else {
for i := range gotClamped {
if gotClamped[i] != tt.wantClampedAddrs[i] {
t.Errorf("ClampMSSToPMTU: got %v, want %v", gotClamped, tt.wantClampedAddrs)
break
}
}
}
})
}
}
+212 -55
View File
@@ -120,6 +120,7 @@ import (
"errors"
"fmt"
"io/fs"
"iter"
"log"
"math"
"net"
@@ -135,11 +136,13 @@ import (
"syscall"
"time"
"github.com/benbjohnson/immutable"
"golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
kubeutils "tailscale.com/k8s-operator"
"tailscale.com/kube/authkey"
healthz "tailscale.com/kube/health"
@@ -149,21 +152,170 @@ import (
"tailscale.com/kube/services"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/deephash"
"tailscale.com/util/def"
"tailscale.com/util/dnsname"
"tailscale.com/util/linuxfw"
)
func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
if def.Bool(os.Getenv("TS_TEST_FAKE_NETFILTER"), false) {
return linuxfw.NewFakeIPTablesRunner(), nil
}
return linuxfw.New(logf, "")
}
func getAutoAdvertiseBool() bool {
return defaultBool("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT", true)
return def.Bool(os.Getenv("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT"), true)
}
const containerbootWatchMask = ipn.NotifyInitialStatus |
ipn.NotifyPeerChanges |
ipn.NotifyNoNetMap
func notifyState(n ipn.Notify) (_ ipn.State, ok bool) {
if n.State != nil {
return *n.State, true
}
if n.InitialStatus != nil && n.InitialStatus.BackendState != "" {
if state, ok := ipn.StateFromString(n.InitialStatus.BackendState); ok {
return state, true
}
}
return ipn.NoState, false
}
var netmapStatePeerIDHasher = immutable.NewHasher(tailcfg.NodeID(0))
type netmapState struct {
self tailcfg.NodeView
peersByID *immutable.Map[tailcfg.NodeID, tailcfg.NodeView]
peersByName *immutable.Map[string, tailcfg.NodeView] // keyed by tailcfg.Node.Name when NodeID is unavailable
certDomains views.Slice[string]
dnsExtraRecords views.Slice[tailcfg.DNSRecord]
}
func (s netmapState) updateFromNotify(n ipn.Notify) netmapState {
if n.InitialStatus != nil {
s = s.updateFromStatus(n.InitialStatus)
}
if n.SelfChange != nil {
s.self = n.SelfChange.View()
}
for _, p := range n.PeersChanged {
s = s.upsertPeer(p.View())
}
for _, id := range n.PeersRemoved {
if s.peersByID != nil {
s.peersByID = s.peersByID.Delete(id)
}
}
return s
}
// processNotify updates the netmap state from an IPN bus Notify. On
// SelfChange it also refetches DNS via the LocalAPI dns-config
// endpoint; the bus carries no DNS delta.
func (s netmapState) processNotify(ctx context.Context, client *local.Client, n ipn.Notify) netmapState {
s = s.updateFromNotify(n)
if n.SelfChange != nil {
dns, err := client.DNSConfig(ctx)
if err != nil {
log.Printf("error refreshing DNS config from tailscaled: %v", err)
} else if dns != nil {
s.dnsExtraRecords = views.SliceOf(dns.ExtraRecords)
s.certDomains = views.SliceOf(dns.CertDomains)
}
}
return s
}
func (s netmapState) updateFromStatus(st *ipnstate.Status) netmapState {
s.certDomains = views.SliceOf(st.CertDomains)
s.dnsExtraRecords = views.SliceOf(st.ExtraRecords)
if st.Self != nil {
s.self = nodeFromPeerStatus(st.Self).View()
}
if len(st.Peer) != 0 {
s.peersByID = nil
s.peersByName = nil
for _, ps := range st.Peer {
s = s.upsertPeer(nodeFromPeerStatus(ps).View())
}
}
return s
}
func (s netmapState) upsertPeer(n tailcfg.NodeView) netmapState {
if !n.Valid() {
return s
}
if s.peersByID == nil {
s.peersByID = immutable.NewMap[tailcfg.NodeID, tailcfg.NodeView](netmapStatePeerIDHasher)
}
if s.peersByName == nil {
s.peersByName = immutable.NewMap[string, tailcfg.NodeView](nil)
}
if n.ID() != 0 {
s.peersByID = s.peersByID.Set(n.ID(), n)
if name := n.Name(); name != "" {
s.peersByName = s.peersByName.Delete(name)
}
return s
}
if n.Name() != "" {
s.peersByName = s.peersByName.Set(n.Name(), n)
}
return s
}
func nodeFromPeerStatus(ps *ipnstate.PeerStatus) *tailcfg.Node {
if ps == nil {
return nil
}
n := &tailcfg.Node{
ID: ps.NodeID,
StableID: ps.ID,
Name: ps.DNSName,
Key: ps.PublicKey,
}
for _, ip := range ps.TailscaleIPs {
n.Addresses = append(n.Addresses, netip.PrefixFrom(ip, ip.BitLen()))
}
if ps.AllowedIPs != nil {
n.AllowedIPs = ps.AllowedIPs.AsSlice()
}
return n
}
func (s netmapState) peers() iter.Seq[tailcfg.NodeView] {
return func(yield func(tailcfg.NodeView) bool) {
if s.peersByID != nil {
it := s.peersByID.Iterator()
for {
_, p, ok := it.Next()
if !ok {
break
}
if !yield(p) {
return
}
}
}
if s.peersByName != nil {
it := s.peersByName.Iterator()
for {
_, p, ok := it.Next()
if !ok {
break
}
if !yield(p) {
return
}
}
}
}
}
func main() {
@@ -272,7 +424,7 @@ func run() error {
mux := http.NewServeMux()
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.HealthCheckAddrPort)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
close := runHTTPServer(mux, cfg.HealthCheckAddrPort)
defer close()
@@ -288,7 +440,7 @@ func run() error {
if cfg.localHealthEnabled() {
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.LocalAddrPort)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
}
if cfg.egressSvcsTerminateEPEnabled() {
@@ -306,7 +458,7 @@ func run() error {
}
}
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState|ipn.NotifyInitialHealthState|ipn.NotifyRateLimit)
w, err := client.WatchIPNBus(bootCtx, containerbootWatchMask|ipn.NotifyInitialPrefs|ipn.NotifyInitialHealthState)
if err != nil {
return fmt.Errorf("failed to watch tailscaled for updates: %w", err)
}
@@ -346,7 +498,7 @@ func run() error {
if err := tailscaleUp(bootCtx, cfg); err != nil {
return fmt.Errorf("failed to auth tailscale: %w", err)
}
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
w, err = client.WatchIPNBus(bootCtx, containerbootWatchMask)
if err != nil {
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
}
@@ -366,8 +518,8 @@ authLoop:
return fmt.Errorf("failed to read from tailscaled: %w", err)
}
if n.State != nil {
switch *n.State {
if state, ok := notifyState(n); ok {
switch state {
case ipn.NeedsLogin:
if isOneStepConfig(cfg) {
// This could happen if this is the first time tailscaled was run for this
@@ -403,7 +555,7 @@ authLoop:
// deadline to continue monitoring for changes.
break authLoop
default:
log.Printf("tailscaled in state %q, waiting", *n.State)
log.Printf("tailscaled in state %q, waiting", state)
}
}
@@ -458,7 +610,7 @@ authLoop:
}
}
w, err = client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
w, err = client.WatchIPNBus(ctx, containerbootWatchMask)
if err != nil {
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
}
@@ -537,7 +689,7 @@ authLoop:
failedResolveAttempts++
}
var egressSvcsNotify chan *netmap.NetworkMap
var egressSvcsNotify chan netmapState
notifyChan := make(chan ipn.Notify)
errChan := make(chan error)
go func() {
@@ -551,12 +703,7 @@ authLoop:
}
}
}()
// Peer set changes (Add/Remove) no longer ride on the IPN bus; poll
// periodically so egress FQDN resolution and peer-aware work picks
// them up. SelfChange covers prompt self changes.
const peerPollInterval = 15 * time.Second
peerPoll := time.NewTicker(peerPollInterval)
defer peerPoll.Stop()
var nmState netmapState
var wg sync.WaitGroup
runLoop:
@@ -574,19 +721,17 @@ runLoop:
return fmt.Errorf("failed to read from tailscaled: %w", err)
case err := <-cfgWatchErrChan:
return fmt.Errorf("failed to watch tailscaled config: %w", err)
case <-peerPoll.C:
processNetmap = true
case n := <-notifyChan:
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
if n.State != nil && *n.State != ipn.Running {
nmState = nmState.processNotify(ctx, client, n)
if state, ok := notifyState(n); ok && state != ipn.Running {
// Something's gone wrong and we've left the authenticated state.
// Our container image never recovered gracefully from this, and the
// control flow required to make it work now is hard. So, just crash
// the container and rely on the container runtime to restart us,
// whereupon we'll go through initial auth again.
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", state)
}
if n.SelfChange != nil {
if n.InitialStatus != nil || n.SelfChange != nil || len(n.PeersChanged) != 0 || len(n.PeersRemoved) != 0 || len(n.PeerChangedPatch) != 0 {
processNetmap = true
}
case <-tc:
@@ -616,13 +761,12 @@ runLoop:
if !processNetmap {
continue
}
nm, err := fetchNetMap(ctx, client)
if err != nil {
log.Printf("error fetching netmap: %v", err)
self := nmState.self
if !self.Valid() {
continue
}
if nm != nil {
addrs = nm.SelfNode.Addresses().AsSlice()
{
addrs = self.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
@@ -634,14 +778,14 @@ runLoop:
// Kubernetes Secret to clean up tailnet nodes
// for proxies whose route setup continuously
// fails.
deviceID := nm.SelfNode.StableID()
deviceID := self.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := kc.storeDeviceID(ctx, nm.SelfNode.StableID()); err != nil {
if err := kc.storeDeviceID(ctx, deviceID); err != nil {
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
}
}
if cfg.TailnetTargetFQDN != "" {
egressAddrs, err := resolveTailnetFQDN(nm, cfg.TailnetTargetFQDN)
egressAddrs, err := resolveTailnetFQDN(nmState, cfg.TailnetTargetFQDN)
if err != nil {
log.Print(err.Error())
break
@@ -697,7 +841,10 @@ runLoop:
backendAddrs = newBackendAddrs
}
if cfg.ServeConfigPath != "" {
cd := certDomainFromNetmap(nm)
var cd string
if nmState.certDomains.Len() != 0 {
cd = nmState.certDomains.At(0)
}
if cd == "" {
cd = kubetypes.ValueNoHTTPS
}
@@ -740,9 +887,9 @@ runLoop:
// set up ensures that the operator does not
// advertize endpoints of broken proxies.
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
deviceEndpoints := []any{nm.SelfNode.Name(), nm.SelfNode.Addresses()}
deviceEndpoints := []any{self.Name(), self.Addresses()}
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) {
if err := kc.storeDeviceEndpoints(ctx, nm.SelfNode.Name(), nm.SelfNode.Addresses().AsSlice()); err != nil {
if err := kc.storeDeviceEndpoints(ctx, self.Name(), addrs); err != nil {
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
}
}
@@ -771,7 +918,7 @@ runLoop:
}
if egressSvcsNotify != nil {
egressSvcsNotify <- nm
egressSvcsNotify <- nmState
}
}
if !startupTasksDone {
@@ -793,7 +940,7 @@ runLoop:
// will crash this node.
if cfg.EgressProxiesCfgPath != "" {
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
egressSvcsNotify = make(chan *netmap.NetworkMap)
egressSvcsNotify = make(chan netmapState)
opts := egressProxyRunOpts{
cfgPath: cfg.EgressProxiesCfgPath,
nfr: nfr,
@@ -802,10 +949,11 @@ runLoop:
stateSecret: cfg.KubeSecret,
netmapChan: egressSvcsNotify,
podIPv4: cfg.PodIPv4,
podIPv6: cfg.PodIPv6,
tailnetAddrs: addrs,
}
go func() {
if err := ep.run(ctx, nm, opts); err != nil {
if err := ep.run(ctx, nmState, opts); err != nil {
egressSvcsErrorChan <- err
}
}()
@@ -985,44 +1133,53 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
}
}
// fetchNetMap fetches the current netmap from tailscaled via the
// "current-netmap" localapi debug action. The debug action's payload
// shape is intentionally not part of any stable API; containerboot
// reads its own internal-package types out of it. New external consumers
// should not rely on this — see [local.Client.Status] and friends.
func fetchNetMap(ctx context.Context, lc *local.Client) (*netmap.NetworkMap, error) {
return local.GetDebugResultJSON[*netmap.NetworkMap](ctx, lc, "current-netmap")
}
// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
// can be either a peer device or a Tailscale Service.
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
// can be either a peer device, a Tailscale Service, or a 4via6 synthesized
// DNS name (e.g. "10-1-0-5-via-7.tailnet.ts.net").
func resolveTailnetFQDN(nm netmapState, fqdn string) ([]netip.Prefix, error) {
dnsFQDN, err := dnsname.ToFQDN(fqdn)
if err != nil {
return nil, fmt.Errorf("error parsing %q as FQDN: %w", fqdn, err)
}
// Check all peer devices first.
for _, p := range nm.Peers {
var ret []netip.Prefix
for p := range nm.peers() {
if strings.EqualFold(p.Name(), dnsFQDN.WithTrailingDot()) {
return p.Addresses().AsSlice(), nil
ret = p.Addresses().AsSlice()
break
}
}
if ret != nil {
return ret, nil
}
// If not found yet, check for a matching Tailscale Service.
if svcIPs := serviceIPsFromNetMap(nm, dnsFQDN); len(svcIPs) != 0 {
return svcIPs, nil
}
// If not found yet, check for a matching 4via6 DNS name.
if addr, ok := kubeutils.ResolveViaDomain(dnsFQDN.WithTrailingDot()); ok {
prefix := netip.PrefixFrom(addr, addr.BitLen())
for nn := range nm.peers() {
for _, allowedIP := range nn.AllowedIPs().All() {
if allowedIP.Contains(addr) {
return []netip.Prefix{prefix}, nil
}
}
}
return nil, fmt.Errorf("resolved 4via6 address %v for %q but no peer advertises a route containing it", addr, fqdn)
}
return nil, fmt.Errorf("could not find Tailscale node or service %q; it either does not exist, or not reachable because of ACLs", fqdn)
return nil, fmt.Errorf("could not find Tailscale node, service or 4via6 address %q; it either does not exist, or not reachable because of ACLs", fqdn)
}
// serviceIPsFromNetMap returns all IPs of a Tailscale Service if its FQDN is
// found in the netmap. Note that Tailscale Services are not a first-class
// object in the netmap, so we guess based on DNS ExtraRecords and AllowedIPs.
func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Prefix {
func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
var extraRecords []tailcfg.DNSRecord
for _, rec := range nm.DNS.ExtraRecords {
for _, rec := range nm.dnsExtraRecords.All() {
recFQDN, err := dnsname.ToFQDN(rec.Name)
if err != nil {
continue
@@ -1044,7 +1201,7 @@ func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Pref
continue
}
ipPrefix := netip.PrefixFrom(ip, ip.BitLen())
for _, ps := range nm.Peers {
for ps := range nm.peers() {
for _, allowedIP := range ps.AllowedIPs().All() {
if allowedIP == ipPrefix {
prefixes = append(prefixes, ipPrefix)
+123 -80
View File
@@ -7,6 +7,7 @@ package main
import (
"bytes"
"context"
_ "embed"
"encoding/base64"
"encoding/json"
@@ -32,15 +33,18 @@ import (
"github.com/google/go-cmp/cmp"
"golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/netmap"
"tailscale.com/types/key"
)
const configFileAuthKey = "some-auth-key"
@@ -52,6 +56,7 @@ func TestContainerBoot(t *testing.T) {
t.Fatalf("Building containerboot: %v", err)
}
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.2")
egressStatusUpdated := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.3")
metricsURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
@@ -71,12 +76,6 @@ func TestContainerBoot(t *testing.T) {
// Waits below to be true before proceeding to the next phase.
Notify *ipn.Notify
// If non-nil, install this NetMap on the fake LocalAPI before
// sending Notify. This is the replacement for the old
// Notify.NetMap field; reactive consumers fetch the current
// netmap via /localapi/v0/netmap on their own.
NetMap *netmap.NetworkMap
// WantCmds is the commands that containerboot should run in this phase.
WantCmds []string
@@ -392,19 +391,12 @@ func TestContainerBoot(t *testing.T) {
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
PeersChanged: []*tailcfg.Node{
{
StableID: tailcfg.StableNodeID("ipv6ID"),
Name: "ipv6-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
}).View(),
},
},
},
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
@@ -646,13 +638,6 @@ func TestContainerBoot(t *testing.T) {
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"),
Name: "new-name.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net.",
@@ -1114,19 +1099,12 @@ func TestContainerBoot(t *testing.T) {
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
PeersChanged: []*tailcfg.Node{
{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
}).View(),
},
},
},
WantKubeSecret: map[string]string{
@@ -1141,6 +1119,23 @@ func TestContainerBoot(t *testing.T) {
egressSvcTerminateURL(env.localAddrPort): 200,
},
},
{
Notify: &ipn.Notify{
PeersChanged: []*tailcfg.Node{{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")},
}},
},
WantKubeSecret: map[string]string{
"egress-services": string(mustJSON(t, egressStatusUpdated)),
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
},
},
},
}
},
@@ -1295,17 +1290,11 @@ func TestContainerBoot(t *testing.T) {
t.Fatalf("phase %d: updating mtime for %q: %v", i, path, err)
}
}
nmForFake := p.NetMap
if nmForFake == nil && p.Notify != nil && p.Notify.SelfChange != nil {
// Synthesize a minimal netmap from SelfChange so
// containerboot's NetMap() fetch returns
// something usable when the test only set Notify.
nmForFake = &netmap.NetworkMap{
SelfNode: p.Notify.SelfChange.View(),
}
}
if nmForFake != nil {
env.lapi.SetNetMap(nmForFake)
if p.Notify != nil && p.Notify.InitialStatus == nil {
// Shallow-copy before mutating to avoid a race with
// parallel subtests that share the same *ipn.Notify.
p.Notify = new(*p.Notify)
p.Notify.InitialStatus = statusFromNotify(p.Notify)
}
env.lapi.Notify(p.Notify)
if p.Signal != nil {
@@ -1499,7 +1488,6 @@ type localAPI struct {
sync.Mutex
cond *sync.Cond
notify *ipn.Notify
netmap *netmap.NetworkMap // served by /localapi/v0/netmap
}
func (lc *localAPI) Start() error {
@@ -1536,44 +1524,45 @@ func (lc *localAPI) Notify(n *ipn.Notify) {
lc.cond.Broadcast()
}
// SetNetMap installs the netmap that the fake /localapi/v0/netmap endpoint
// will return.
func (lc *localAPI) SetNetMap(nm *netmap.NetworkMap) {
lc.Lock()
defer lc.Unlock()
lc.netmap = nm
func statusFromNotify(n *ipn.Notify) *ipnstate.Status {
st := new(ipnstate.Status)
if n.State != nil {
st.BackendState = n.State.String()
}
if n.SelfChange != nil {
st.Self = peerStatusFromNode(n.SelfChange.View())
}
if len(n.PeersChanged) != 0 {
st.Peer = map[key.NodePublic]*ipnstate.PeerStatus{}
for _, p := range n.PeersChanged {
pv := p.View()
st.Peer[pv.Key()] = peerStatusFromNode(pv)
}
}
return st
}
func peerStatusFromNode(n tailcfg.NodeView) *ipnstate.PeerStatus {
ps := &ipnstate.PeerStatus{
ID: n.StableID(),
NodeID: n.ID(),
PublicKey: n.Key(),
DNSName: n.Name(),
}
for _, p := range n.Addresses().All() {
if p.IsSingleIP() {
ps.TailscaleIPs = append(ps.TailscaleIPs, p.Addr())
}
}
if n.AllowedIPs().Len() != 0 {
v := n.AllowedIPs()
ps.AllowedIPs = &v
}
return ps
}
func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/netmap":
w.Header().Set("Content-Type", "application/json")
lc.Lock()
nm := lc.netmap
lc.Unlock()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(nm)
return
case "/localapi/v0/debug":
// containerboot fetches the netmap via the "current-netmap"
// debug action; serve it like /localapi/v0/netmap above.
if r.URL.Query().Get("action") != "current-netmap" {
http.Error(w, "unsupported debug action", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
lc.Lock()
nm := lc.netmap
lc.Unlock()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(nm)
return
case "/localapi/v0/serve-config":
switch r.Method {
case "GET":
@@ -1959,3 +1948,57 @@ func newTestEnv(t *testing.T) testEnv {
healthAddrPort: healthAddrPort,
}
}
// TestProcessNotifyRefreshesDNSOnSelfChange verifies that a SelfChange
// notification triggers a DNS refresh; without it, VIPServices created
// after pod boot are invisible to resolveTailnetFQDN.
func TestProcessNotifyRefreshesDNSOnSelfChange(t *testing.T) {
extraRec := tailcfg.DNSRecord{
Name: "my-ingress.tailnet.ts.net.",
Type: "A",
Value: "100.99.10.20",
}
dnsCfg := &tailcfg.DNSConfig{
ExtraRecords: []tailcfg.DNSRecord{extraRec},
CertDomains: []string{"node.tailnet.ts.net"},
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
mux := http.NewServeMux()
mux.HandleFunc("/localapi/v0/dns-config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(dnsCfg); err != nil {
t.Errorf("encoding dns config: %v", err)
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(lal)
t.Cleanup(func() { srv.Shutdown(context.Background()) })
client := &local.Client{Dial: lal.Dial}
// Empty starting state, as if the InitialStatus captured at pod
// boot carried no ExtraRecords because the VIPService didn't exist
// yet at that time.
var s netmapState
n := ipn.Notify{
SelfChange: &tailcfg.Node{
ID: 1,
Name: "self.tailnet.ts.net.",
},
}
got := s.processNotify(context.Background(), client, n)
if got.dnsExtraRecords.Len() != 1 {
t.Fatalf("dnsExtraRecords.Len() = %d, want 1", got.dnsExtraRecords.Len())
}
if rec := got.dnsExtraRecords.At(0); rec.Name != extraRec.Name {
t.Errorf("dnsExtraRecords[0].Name = %q, want %q", rec.Name, extraRec.Name)
}
if got.certDomains.Len() != 1 || got.certDomains.At(0) != "node.tailnet.ts.net" {
t.Errorf("certDomains = %v, want [node.tailnet.ts.net]", got.certDomains.AsSlice())
}
}
-8
View File
@@ -24,7 +24,6 @@ import (
"tailscale.com/kube/kubetypes"
klc "tailscale.com/kube/localclient"
"tailscale.com/kube/services"
"tailscale.com/types/netmap"
)
// watchServeConfigChanges watches path for changes, and when it sees one, reads
@@ -142,13 +141,6 @@ func refreshAdvertiseServices(ctx context.Context, sc *ipn.ServeConfig, lc klc.L
return nil
}
func certDomainFromNetmap(nm *netmap.NetworkMap) string {
if len(nm.DNS.CertDomains) == 0 {
return ""
}
return nm.DNS.CertDomains[0]
}
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc klc.LocalClient) error {
if !isValidHTTPSConfig(certDomain, sc) {
return nil
+40 -64
View File
@@ -6,6 +6,7 @@
package main
import (
"cmp"
"context"
"errors"
"fmt"
@@ -18,6 +19,7 @@ import (
"tailscale.com/ipn/conffile"
"tailscale.com/kube/kubeclient"
"tailscale.com/util/def"
)
// settings is all the configuration for containerboot.
@@ -89,47 +91,50 @@ type settings struct {
func configFromEnv() (*settings, error) {
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
ClientID: defaultEnv("TS_CLIENT_ID", ""),
ClientSecret: defaultEnv("TS_CLIENT_SECRET", ""),
IDToken: defaultEnv("TS_ID_TOKEN", ""),
Audience: defaultEnv("TS_AUDIENCE", ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
AuthKey: cmp.Or(os.Getenv("TS_AUTHKEY"), os.Getenv("TS_AUTH_KEY")),
ClientID: os.Getenv("TS_CLIENT_ID"),
ClientSecret: os.Getenv("TS_CLIENT_SECRET"),
IDToken: os.Getenv("TS_ID_TOKEN"),
Audience: os.Getenv("TS_AUDIENCE"),
Hostname: os.Getenv("TS_HOSTNAME"),
Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
ServeConfigPath: os.Getenv("TS_SERVE_CONFIG"),
ProxyTargetIP: os.Getenv("TS_DEST_IP"),
ProxyTargetDNSName: os.Getenv("TS_EXPERIMENTAL_DEST_DNS_NAME"),
TailnetTargetIP: os.Getenv("TS_TAILNET_TARGET_IP"),
TailnetTargetFQDN: os.Getenv("TS_TAILNET_TARGET_FQDN"),
DaemonExtraArgs: os.Getenv("TS_TAILSCALED_EXTRA_ARGS"),
ExtraArgs: os.Getenv("TS_EXTRA_ARGS"),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
UserspaceMode: def.Bool(os.Getenv("TS_USERSPACE"), true),
StateDir: os.Getenv("TS_STATE_DIR"),
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
KubeSecret: func() string {
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
return defaultEnv("TS_KUBE_SECRET", "tailscale")
if os.Getenv("KUBERNETES_SERVICE_HOST") == "" {
return os.Getenv("TS_KUBE_SECRET")
}
return defaultEnv("TS_KUBE_SECRET", "")
// An explicitly empty TS_KUBE_SECRET disables Secret storage, so
// unset and empty must stay distinguishable: def.LookupEnv keeps
// an explicit "" rather than falling back to the default.
return def.LookupEnv("TS_KUBE_SECRET", "tailscale")
}(),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
SOCKSProxyAddr: os.Getenv("TS_SOCKS5_SERVER"),
HTTPProxyAddr: os.Getenv("TS_OUTBOUND_HTTP_PROXY_LISTEN"),
Socket: cmp.Or(os.Getenv("TS_SOCKET"), "/tmp/tailscaled.sock"),
AuthOnce: def.Bool(os.Getenv("TS_AUTH_ONCE"), false),
Root: cmp.Or(os.Getenv("TS_TEST_ONLY_ROOT"), "/"),
TailscaledConfigFilePath: tailscaledConfigFilePath(),
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
LocalAddrPort: defaultEnv("TS_LOCAL_ADDR_PORT", "[::]:9002"),
MetricsEnabled: defaultBool("TS_ENABLE_METRICS", false),
HealthCheckEnabled: defaultBool("TS_ENABLE_HEALTH_CHECK", false),
DebugAddrPort: defaultEnv("TS_DEBUG_ADDR_PORT", ""),
EgressProxiesCfgPath: defaultEnv("TS_EGRESS_PROXIES_CONFIG_PATH", ""),
IngressProxiesCfgPath: defaultEnv("TS_INGRESS_PROXIES_CONFIG_PATH", ""),
PodUID: defaultEnv("POD_UID", ""),
AllowProxyingClusterTrafficViaIngress: def.Bool(os.Getenv("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS"), false),
PodIP: os.Getenv("POD_IP"),
EnableForwardingOptimizations: def.Bool(os.Getenv("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS"), false),
HealthCheckAddrPort: os.Getenv("TS_HEALTHCHECK_ADDR_PORT"),
LocalAddrPort: cmp.Or(os.Getenv("TS_LOCAL_ADDR_PORT"), "[::]:9002"),
MetricsEnabled: def.Bool(os.Getenv("TS_ENABLE_METRICS"), false),
HealthCheckEnabled: def.Bool(os.Getenv("TS_ENABLE_HEALTH_CHECK"), false),
DebugAddrPort: os.Getenv("TS_DEBUG_ADDR_PORT"),
EgressProxiesCfgPath: os.Getenv("TS_EGRESS_PROXIES_CONFIG_PATH"),
IngressProxiesCfgPath: os.Getenv("TS_INGRESS_PROXIES_CONFIG_PATH"),
PodUID: os.Getenv("POD_UID"),
}
podIPs, ok := os.LookupEnv("POD_IPS")
@@ -153,7 +158,7 @@ func configFromEnv() (*settings, error) {
// If cert share is enabled, set the replica as read or write. Only 0th
// replica should be able to write.
isInCertShareMode := defaultBool("TS_EXPERIMENTAL_CERT_SHARE", false)
isInCertShareMode := def.Bool(os.Getenv("TS_EXPERIMENTAL_CERT_SHARE"), false)
if isInCertShareMode {
cfg.CertShareMode = "ro"
podName := os.Getenv("POD_NAME")
@@ -454,15 +459,6 @@ func (cfg *settings) egressSvcsTerminateEPEnabled() bool {
return cfg.LocalAddrPort != "" && cfg.EgressProxiesCfgPath != ""
}
// defaultEnv returns the value of the given envvar name, or defVal if
// unset.
func defaultEnv(name, defVal string) string {
if v, ok := os.LookupEnv(name); ok {
return v
}
return defVal
}
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being set to empty string vs unset.
@@ -484,23 +480,3 @@ func defaultEnvBoolPointer(name string) *bool {
}
return &ret
}
func defaultEnvs(names []string, defVal string) string {
for _, name := range names {
if v, ok := os.LookupEnv(name); ok {
return v
}
}
return defVal
}
// defaultBool returns the boolean value of the given envvar name, or
// defVal if unset or not a bool.
func defaultBool(name string, defVal bool) bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return defVal
}
return ret
}
+73
View File
@@ -7,6 +7,7 @@ package main
import (
"net/netip"
"os"
"strings"
"testing"
)
@@ -228,6 +229,78 @@ func TestValidateAuthMethods(t *testing.T) {
}
}
func TestConfigFromEnvEmptyDefaults(t *testing.T) {
tests := []struct {
env string
get func(*settings) string
want string
}{
{
env: "TS_SOCKET",
get: func(c *settings) string { return c.Socket },
want: "/tmp/tailscaled.sock",
},
{
env: "TS_LOCAL_ADDR_PORT",
get: func(c *settings) string { return c.LocalAddrPort },
want: "[::]:9002",
},
{
env: "TS_TEST_ONLY_ROOT",
get: func(c *settings) string { return c.Root },
want: "/",
},
}
for _, tt := range tests {
t.Run(tt.env, func(t *testing.T) {
t.Setenv(tt.env, "")
cfg, err := configFromEnv()
if err != nil {
t.Fatal(err)
}
if got := tt.get(cfg); got != tt.want {
t.Errorf(`%s set to empty "": got %q, want default %q`, tt.env, got, tt.want)
}
})
}
}
func TestConfigFromEnvKubeSecret(t *testing.T) {
tests := []struct {
name string
inKubernetes bool
unset bool
value string
want string
}{
{name: "in_kubernetes_unset", inKubernetes: true, unset: true, want: "tailscale"},
{name: "in_kubernetes_empty", inKubernetes: true, value: "", want: ""},
{name: "in_kubernetes_set", inKubernetes: true, value: "custom", want: "custom"},
{name: "not_in_kubernetes_unset", inKubernetes: false, unset: true, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// t.Setenv registers a t.Cleanup to restore the original value, so
// route the unset cases through it rather than a bare os.Unsetenv.
t.Setenv("KUBERNETES_SERVICE_HOST", "10.96.0.1")
if !tt.inKubernetes {
os.Unsetenv("KUBERNETES_SERVICE_HOST")
}
t.Setenv("TS_KUBE_SECRET", tt.value)
if tt.unset {
os.Unsetenv("TS_KUBE_SECRET")
}
cfg, err := configFromEnv()
if err != nil {
t.Fatal(err)
}
if cfg.KubeSecret != tt.want {
t.Errorf("KubeSecret = %q, want %q", cfg.KubeSecret, tt.want)
}
})
}
}
func TestHandlesKubeIPV6(t *testing.T) {
t.Setenv("TS_LOCAL_ADDR_PORT", "fd7a:115c:a1e0::6c34:352:9002")
t.Setenv("POD_IPS", "fd7a:115c:a1e0::6c34:352")
+14 -2
View File
@@ -150,7 +150,15 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale up failed: %v", err)
if ctxErr := ctx.Err(); ctxErr != nil {
// A canceled context kills the command, and cmd.Run can
// report the subprocess's death ("signal: killed") rather
// than the context error that caused it. Return the
// context error so that callers (and ultimately main) can
// recognize a graceful shutdown with errors.Is.
return fmt.Errorf("tailscale up failed: %w", ctxErr)
}
return fmt.Errorf("tailscale up failed: %w", err)
}
return nil
}
@@ -180,7 +188,11 @@ func tailscaleSet(ctx context.Context, cfg *settings) error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale set failed: %v", err)
if ctxErr := ctx.Err(); ctxErr != nil {
// See the equivalent check in tailscaleUp.
return fmt.Errorf("tailscale set failed: %w", ctxErr)
}
return fmt.Errorf("tailscale set failed: %w", err)
}
return nil
}
+27 -11
View File
@@ -23,6 +23,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"time"
"golang.org/x/crypto/acme"
@@ -35,21 +36,34 @@ var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`)
type certProvider interface {
// TLSConfig creates a new TLS config suitable for net/http.Server servers.
//
// The returned Config must have a GetCertificate function set and that
// function must return a unique *tls.Certificate for each call. The
// returned *tls.Certificate will be mutated by the caller to append to the
// (*tls.Certificate).Certificate field.
// The returned Config must have a GetCertificate function set. The
// *tls.Certificate values it returns may be shared and cached, so
// callers must not mutate them.
TLSConfig() *tls.Config
// HTTPHandler handle ACME related request, if any.
HTTPHandler(fallback http.Handler) http.Handler
}
func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey, email string) (certProvider, error) {
func certProviderByCertMode(mode, dir, hostname string, ipCerts bool, eabKID, eabKey, email string) (certProvider, error) {
if dir == "" {
return nil, errors.New("missing required --certdir flag")
}
if ipCerts && mode != "letsencrypt" {
return nil, errors.New("--acme-ip-certs requires --certmode=letsencrypt")
}
switch mode {
case "letsencrypt", "gcp":
if net.ParseIP(hostname) != nil {
if mode == "gcp" {
return nil, errors.New("--certmode=gcp requires --hostname to be a DNS name, not an IP address")
}
if !ipCerts {
return nil, errors.New("--hostname is an IP address; use --certmode=manual for a self-signed cert, or set --acme-ip-certs to get LetsEncrypt IP address certs")
}
// IP-only server: certs are issued on demand per
// connection, so there is no hostname cert provider.
return newIPCertManager(dir, email, "", nil)
}
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(hostname),
@@ -82,6 +96,9 @@ func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey, email string) (
} else if hostname == "derp.tailscale.com" {
certManager.Email = "security@tailscale.com"
}
if ipCerts {
return newIPCertManager(dir, email, "", certManager)
}
return certManager, nil
case "manual":
return NewManualCertManager(dir, hostname)
@@ -157,12 +174,11 @@ func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certif
return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName)
}
// Return a shallow copy of the cert so the caller can append to its
// Certificate field.
certCopy := new(tls.Certificate)
*certCopy = *m.cert
certCopy.Certificate = certCopy.Certificate[:len(certCopy.Certificate):len(certCopy.Certificate)]
return certCopy, nil
// Return a shallow copy of the cert with a capacity-clamped chain
// so callers can never mutate the manager's long-lived certificate.
certCopy := *m.cert
certCopy.Certificate = slices.Clip(certCopy.Certificate)
return &certCopy, nil
}
func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler {
+6 -6
View File
@@ -91,7 +91,7 @@ func TestCertIP(t *testing.T) {
t.Fatalf("Error closing key.pem: %v", err)
}
cp, err := certProviderByCertMode("manual", dir, hostname, "", "", "")
cp, err := certProviderByCertMode("manual", dir, hostname, false, "", "", "")
if err != nil {
t.Fatal(err)
}
@@ -174,25 +174,25 @@ func TestGCPCertMode(t *testing.T) {
dir := t.TempDir()
// Missing EAB credentials
_, err := certProviderByCertMode("gcp", dir, "test.example.com", "", "", "test@example.com")
_, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "", "", "test@example.com")
if err == nil {
t.Fatal("expected error when EAB credentials are missing")
}
// Missing email
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "")
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "")
if err == nil {
t.Fatal("expected error when email is missing")
}
// Invalid base64
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "not-valid!", "test@example.com")
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "not-valid!", "test@example.com")
if err == nil {
t.Fatal("expected error for invalid base64")
}
// Valid base64url (no padding)
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "test@example.com")
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "test@example.com")
if err != nil {
t.Fatalf("base64url: %v", err)
}
@@ -201,7 +201,7 @@ func TestGCPCertMode(t *testing.T) {
}
// Valid standard base64 (with padding, gcloud format)
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk=", "test@example.com")
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk=", "test@example.com")
if err != nil {
t.Fatalf("base64: %v", err)
}
+21 -16
View File
@@ -6,10 +6,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
github.com/axiomhq/hyperloglog from tailscale.com/derp/derpserver
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
github.com/coder/websocket from tailscale.com/cmd/derper+
github.com/coder/websocket from tailscale.com/derp/derpserver+
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
github.com/creachadair/msync/throttle from github.com/tailscale/setec/client/setec
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
github.com/dgryski/go-metro from github.com/axiomhq/hyperloglog
@@ -20,6 +19,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/hdevalence/ed25519consensus from tailscale.com/tka
@@ -91,6 +91,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/feature from tailscale.com/tsweb+
tailscale.com/feature/buildfeatures from tailscale.com/feature+
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/ipn from tailscale.com/client/local
@@ -107,19 +108,23 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/local
tailscale.com/net/netx from tailscale.com/net/dnscache+
tailscale.com/net/routecheck from tailscale.com/client/local
tailscale.com/net/routecheck/peernode from tailscale.com/net/routecheck
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun from tailscale.com/net/stunserver
tailscale.com/net/stunserver from tailscale.com/cmd/derper
L tailscale.com/net/tcpinfo from tailscale.com/derp/derpserver
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/net/routecheck
tailscale.com/net/tsaddr from tailscale.com/ipn+
tailscale.com/net/udprelay/status from tailscale.com/client/local
tailscale.com/net/wsconn from tailscale.com/cmd/derper
tailscale.com/net/wsconn from tailscale.com/derp/derpserver
tailscale.com/paths from tailscale.com/client/local
💣 tailscale.com/safesocket from tailscale.com/client/local
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/local+
tailscale.com/tempfork/acme from tailscale.com/cmd/derper
tailscale.com/tka from tailscale.com/client/local+
tailscale.com/tsconst from tailscale.com/net/netmon+
tailscale.com/tstime from tailscale.com/derp+
@@ -135,7 +140,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/netmap from tailscale.com/ipn+
tailscale.com/types/opt from tailscale.com/envknob+
tailscale.com/types/persist from tailscale.com/ipn+
tailscale.com/types/preftype from tailscale.com/ipn
@@ -164,7 +169,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/syspolicy/pkey from tailscale.com/ipn+
tailscale.com/util/syspolicy/policyclient from tailscale.com/ipn
tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy/policyclient+
tailscale.com/util/syspolicy/setting from tailscale.com/client/local
tailscale.com/util/syspolicy/setting from tailscale.com/client/local+
tailscale.com/util/testenv from tailscale.com/net/bakedroots+
tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+
@@ -244,22 +249,22 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/internal/boring/bbig from crypto/ecdsa+
crypto/internal/boring/sig from crypto/internal/boring
crypto/internal/constanttime from crypto/internal/fips140/edwards25519+
crypto/internal/fips140 from crypto/internal/fips140/aes+
crypto/internal/fips140 from crypto/fips140+
crypto/internal/fips140/aes from crypto/aes+
crypto/internal/fips140/aes/gcm from crypto/cipher+
crypto/internal/fips140/alias from crypto/cipher+
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/check from crypto/internal/fips140/aes+
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
crypto/internal/fips140/check from crypto/fips140+
crypto/internal/fips140/drbg from crypto/hpke+
crypto/internal/fips140/ecdh from crypto/ecdh
crypto/internal/fips140/ecdsa from crypto/ecdsa
crypto/internal/fips140/ed25519 from crypto/ed25519
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
crypto/internal/fips140/hkdf from crypto/hkdf+
crypto/internal/fips140/hmac from crypto/hmac+
crypto/internal/fips140/mlkem from crypto/mlkem
crypto/internal/fips140/nistec from crypto/elliptic+
crypto/internal/fips140/nistec from crypto/ecdsa+
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
crypto/internal/fips140/rsa from crypto/rsa
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
@@ -310,7 +315,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
go/token from google.golang.org/protobuf/internal/strs
hash from crypto+
hash/crc32 from compress/gzip+
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/fnv from google.golang.org/protobuf/internal/detrand+
hash/maphash from go4.org/mem+
html from net/http/pprof+
html/template from tailscale.com/cmd/derper+
@@ -325,13 +330,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
internal/filepathlite from os+
internal/fmtsort from fmt+
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from crypto/internal/fips140deps/godebug+
internal/godebug from crypto/ed25519+
internal/godebugs from internal/godebug+
internal/goexperiment from net/http/pprof+
internal/goos from crypto/x509+
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from io/fs+
internal/oserror from internal/syscall/windows+
internal/poll from net+
internal/profile from net/http/pprof
internal/profilerecord from runtime+
@@ -341,9 +346,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
internal/runtime/atomic from internal/runtime/exithook+
L internal/runtime/cgroup from runtime
internal/runtime/exithook from runtime
internal/runtime/gc from runtime+
internal/runtime/gc from internal/runtime/gc/scan+
internal/runtime/gc/scan from runtime
internal/runtime/maps from reflect+
internal/runtime/maps from hash/maphash+
internal/runtime/math from internal/runtime/maps+
internal/runtime/pprof/label from runtime+
internal/runtime/sys from crypto/subtle+
@@ -357,7 +362,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
internal/synctest from sync
internal/syscall/execenv from os+
LD internal/syscall/unix from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/fips140deps/time+
W internal/syscall/windows/registry from mime+
W internal/syscall/windows/sysdll from internal/syscall/windows+
internal/testlog from os
+5 -12
View File
@@ -62,10 +62,11 @@ var (
configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt, gcp")
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store ACME (e.g. LetsEncrypt) certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "TLS host name for certs, if addr's port is :443. When --certmode=manual, this can be an IP address to avoid SNI checks")
hostname = flag.String("hostname", "derp.tailscale.com", "TLS host name for certs, if addr's port is :443. It can be an IP address when --certmode=manual (to avoid SNI checks) or when --acme-ip-certs is set (to run an IP-only server with no hostname cert)")
acmeEABKid = flag.String("acme-eab-kid", "", "ACME External Account Binding (EAB) Key ID (required for --certmode=gcp)")
acmeEABKey = flag.String("acme-eab-key", "", "ACME External Account Binding (EAB) HMAC key, base64-encoded (required for --certmode=gcp)")
acmeEmail = flag.String("acme-email", "", "ACME account contact email address (required for --certmode=gcp, optional for letsencrypt)")
acmeIPCerts = flag.Bool("acme-ip-certs", false, "whether to serve LetsEncrypt certs for the server's IP addresses: when a client connects by IP address (sending no TLS SNI, or an IP address SNI matching the connection's destination IP), get and serve a LetsEncrypt cert for that IP, using the short-lived (~6 day) ACME certificate profile. This works for both IPv4 and IPv6 with no per-address configuration. It requires --certmode=letsencrypt and the ACME server must be able to reach port 80 at each such IP for the HTTP-01 challenge.")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
flagHome = flag.String("home", "", "what to serve at the root path. It may be left empty (the default, for a default homepage), \"blank\" for a blank page, or a URL to redirect to")
@@ -262,7 +263,7 @@ func main() {
mux := http.NewServeMux()
if *runDERP {
derpHandler := derpserver.Handler(s)
derpHandler = addWebSocketSupport(s, derpHandler)
derpHandler = derpserver.AddWebSocketSupport(s, derpHandler)
mux.Handle("/derp", derpHandler)
} else {
mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -349,20 +350,12 @@ func main() {
if serveTLS {
log.Printf("derper: serving on %s with TLS", *addr)
var certManager certProvider
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeEABKid, *acmeEABKey, *acmeEmail)
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeIPCerts, *acmeEABKid, *acmeEABKey, *acmeEmail)
if err != nil {
log.Fatalf("derper: can not start cert provider: %v", err)
}
httpsrv.TLSConfig = certManager.TLSConfig()
getCert := httpsrv.TLSConfig.GetCertificate
httpsrv.TLSConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := getCert(hi)
if err != nil {
return nil, err
}
cert.Certificate = append(cert.Certificate, s.MetaCert())
return cert, nil
}
s.ModifyTLSConfigToAddMetaCert(httpsrv.TLSConfig)
// Disable TLS 1.0 and 1.1, which are obsolete and have security issues.
httpsrv.TLSConfig.MinVersion = tls.VersionTLS12
httpsrv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+497
View File
@@ -0,0 +1,497 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"tailscale.com/atomicfile"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/acme"
)
// shortlivedProfile is the ACME certificate profile required by
// LetsEncrypt for IP address certificates. Certificates issued under
// it are valid for about six days.
// See https://letsencrypt.org/docs/profiles/.
const shortlivedProfile = "shortlived"
// ipCertManager is a certProvider that obtains and renews LetsEncrypt
// TLS certificates for the server's IP addresses on demand, using the
// short-lived ACME certificate profile and the HTTP-01 challenge
// served on the derper's plaintext HTTP port.
//
// Clients connecting to an IP address usually send no SNI, so the
// requested IP address is taken from the TCP connection's local
// address. That works for however many IPv4 and IPv6 addresses the
// server has, with no configuration. Clients that do send an IP
// address in the SNI get a certificate only if it matches the
// connection's local address, so a client can never make us request a
// certificate for an address that isn't ours.
//
// Connections with a DNS name in the SNI are passed through to the
// optional next provider (the regular autocert manager for the
// --hostname certificate), if any.
type ipCertManager struct {
certDir string
email string // optional ACME account contact
client *acme.Client
next certProvider // provider for DNS hostname connections, or nil
nextTLS *tls.Config // next.TLSConfig(), or nil
mu sync.Mutex
certs map[netip.Addr]*ipCertEntry
tokens map[string]string // HTTP-01 challenge URL path => response body
}
// ipCertEntry is the issuance state for one IP address.
// All fields are guarded by ipCertManager.mu.
type ipCertEntry struct {
cert *tls.Certificate // current cert with Leaf set, or nil if not yet issued
flight chan struct{} // non-nil while an issuance is running; closed when it finishes
flightErr error // result of the last finished issuance
nextAttempt time.Time // earliest time of the next issuance attempt, after a failure
retryDelay time.Duration // backoff to apply after the next failure
}
// newIPCertManager returns an ipCertManager storing its ACME account
// key and issued certificates in certdir.
//
// If directoryURL is empty, the LetsEncrypt production directory is
// used; tests point it at a fake ACME server. If next is non-nil,
// connections with a DNS name in the SNI are served by it.
func newIPCertManager(certdir, email, directoryURL string, next certProvider) (*ipCertManager, error) {
if err := os.MkdirAll(certdir, 0700); err != nil {
return nil, err
}
accountKey, err := loadOrCreateAccountKey(filepath.Join(certdir, "acme-account.key"))
if err != nil {
return nil, fmt.Errorf("ACME account key: %w", err)
}
m := &ipCertManager{
certDir: certdir,
email: email,
client: &acme.Client{
Key: accountKey,
DirectoryURL: directoryURL,
UserAgent: "tailscale-derper",
},
next: next,
certs: make(map[netip.Addr]*ipCertEntry),
tokens: make(map[string]string),
}
if next != nil {
m.nextTLS = next.TLSConfig()
}
go m.renewLoop()
return m, nil
}
func loadOrCreateAccountKey(path string) (*ecdsa.PrivateKey, error) {
if pemBytes, err := os.ReadFile(path); err == nil {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("invalid PEM in %s", path)
}
return x509.ParseECPrivateKey(block.Bytes)
} else if !os.IsNotExist(err) {
return nil, err
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, err
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
if err := atomicfile.WriteFile(path, pemBytes, 0600); err != nil {
return nil, err
}
return key, nil
}
// certPaths returns the cert and key file paths for ip in m.certDir.
// Colons in IPv6 addresses are replaced with dots to keep the names
// filesystem-safe.
func (m *ipCertManager) certPaths(ip netip.Addr) (crtPath, keyPath string) {
base := strings.ReplaceAll(ip.String(), ":", ".")
return filepath.Join(m.certDir, base+".crt"), filepath.Join(m.certDir, base+".key")
}
func (m *ipCertManager) TLSConfig() *tls.Config {
var conf *tls.Config
if m.nextTLS != nil {
conf = m.nextTLS.Clone()
} else {
conf = &tls.Config{
NextProtos: []string{
"http/1.1",
},
}
}
conf.GetCertificate = m.getCertificate
return conf
}
// connLocalIP returns the local (server side) IP address of the
// connection that sent the ClientHello.
func connLocalIP(hi *tls.ClientHelloInfo) (netip.Addr, bool) {
if hi.Conn == nil {
return netip.Addr{}, false
}
ta, ok := hi.Conn.LocalAddr().(*net.TCPAddr)
if !ok {
return netip.Addr{}, false
}
ip := ta.AddrPort().Addr().Unmap()
return ip, ip.IsValid()
}
func (m *ipCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
connIP, connIPOK := connLocalIP(hi)
if hi.ServerName != "" {
sniIP, err := netip.ParseAddr(hi.ServerName)
if err != nil {
// The SNI is a DNS name; let the hostname provider handle it.
if m.nextTLS != nil && m.nextTLS.GetCertificate != nil {
return m.nextTLS.GetCertificate(hi)
}
return nil, fmt.Errorf("no certificate for hostname %q; this server only serves IP address certificates", hi.ServerName)
}
if !connIPOK || sniIP.Unmap() != connIP {
return nil, fmt.Errorf("requested certificate for IP %v does not match the connection's IP address", sniIP)
}
}
if !connIPOK {
return nil, errors.New("unable to determine the connection's local IP address")
}
ctx := hi.Context()
if ctx == nil {
ctx = context.Background()
}
return m.certForIP(ctx, connIP)
}
// certForIP returns the current certificate for ip, obtaining one
// first if there is no unexpired certificate for it. Concurrent
// callers for the same IP share a single issuance.
func (m *ipCertManager) certForIP(ctx context.Context, ip netip.Addr) (*tls.Certificate, error) {
m.mu.Lock()
e := m.entryLocked(ip)
if e.cert != nil && time.Now().Before(e.cert.Leaf.NotAfter) {
defer m.mu.Unlock()
return clipCert(e.cert), nil
}
if e.flight == nil && time.Now().Before(e.nextAttempt) {
m.mu.Unlock()
return nil, fmt.Errorf("cert issuance for %v failed recently; next attempt no earlier than %v", ip, e.nextAttempt.Format(time.RFC3339))
}
flight := m.startFlightLocked(ip, e)
m.mu.Unlock()
select {
case <-flight:
case <-ctx.Done():
return nil, ctx.Err()
}
m.mu.Lock()
defer m.mu.Unlock()
if e.cert == nil {
return nil, e.flightErr
}
return clipCert(e.cert), nil
}
func (m *ipCertManager) entryLocked(ip netip.Addr) *ipCertEntry {
e, ok := m.certs[ip]
if !ok {
e = &ipCertEntry{}
m.certs[ip] = e
}
return e
}
// clipCert returns a shallow copy of cert with a capacity-clamped
// chain so callers can never mutate the manager's long-lived
// certificate.
func clipCert(cert *tls.Certificate) *tls.Certificate {
certCopy := *cert
certCopy.Certificate = slices.Clip(certCopy.Certificate)
return &certCopy
}
// startFlightLocked starts an issuance for ip if none is running and
// returns a channel that is closed when it finishes.
func (m *ipCertManager) startFlightLocked(ip netip.Addr, e *ipCertEntry) chan struct{} {
if e.flight != nil {
return e.flight
}
done := make(chan struct{})
e.flight = done
go func() {
err := m.issue(ip)
m.mu.Lock()
defer m.mu.Unlock()
e.flight = nil
e.flightErr = err
if err != nil {
if e.retryDelay == 0 {
e.retryDelay = time.Minute
}
e.nextAttempt = time.Now().Add(e.retryDelay)
e.retryDelay = min(e.retryDelay*2, 30*time.Minute)
log.Printf("derper: acme: getting cert for %v: %v (next attempt in %v)", ip, err, time.Until(e.nextAttempt).Round(time.Second))
} else {
e.retryDelay = 0
e.nextAttempt = time.Time{}
}
close(done)
}()
return done
}
// issue obtains a certificate for ip, preferring a still-fresh one
// cached on disk over a new ACME order.
func (m *ipCertManager) issue(ip netip.Addr) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if cert, err := m.loadCachedCert(ip); err == nil && !certNeedsRenewal(cert.Leaf) {
m.mu.Lock()
m.entryLocked(ip).cert = cert
m.mu.Unlock()
log.Printf("derper: acme: loaded cached cert for %v (expires %v)", ip, cert.Leaf.NotAfter)
return nil
}
return m.obtainCert(ctx, ip)
}
// loadCachedCert loads a previously issued certificate for ip from
// disk, if present and still valid.
func (m *ipCertManager) loadCachedCert(ip netip.Addr) (*tls.Certificate, error) {
crtPath, keyPath := m.certPaths(ip)
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
if err != nil {
return nil, err
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil, err
}
now := time.Now()
if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
return nil, fmt.Errorf("cached cert is expired or not yet valid (NotAfter %v)", leaf.NotAfter)
}
if err := leaf.VerifyHostname(ip.String()); err != nil {
return nil, err
}
cert.Leaf = leaf
return &cert, nil
}
// HTTPHandler returns a handler serving HTTP-01 challenge responses on
// the derper's plaintext HTTP port, sending all other requests to the
// next provider's handler, if any, and otherwise to fallback.
func (m *ipCertManager) HTTPHandler(fallback http.Handler) http.Handler {
if m.next != nil {
fallback = m.next.HTTPHandler(fallback)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
fallback.ServeHTTP(w, r)
return
}
m.mu.Lock()
response, ok := m.tokens[r.URL.Path]
m.mu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
io.WriteString(w, response)
})
}
func (m *ipCertManager) setToken(path, response string) {
m.mu.Lock()
defer m.mu.Unlock()
m.tokens[path] = response
}
func (m *ipCertManager) deleteToken(path string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.tokens, path)
}
// certNeedsRenewal reports whether leaf has less than a third of its
// lifetime remaining. LetsEncrypt short-lived certs are valid for
// about six days, so renewal happens roughly every four.
func certNeedsRenewal(leaf *x509.Certificate) bool {
total := leaf.NotAfter.Sub(leaf.NotBefore)
return time.Until(leaf.NotAfter) < total/3
}
// renewLoop runs for the lifetime of the process, renewing each issued
// certificate as it approaches expiry.
func (m *ipCertManager) renewLoop() {
for {
time.Sleep(time.Hour)
m.mu.Lock()
now := time.Now()
for ip, e := range m.certs {
if e.cert != nil && certNeedsRenewal(e.cert.Leaf) && e.flight == nil && now.After(e.nextAttempt) {
m.startFlightLocked(ip, e)
}
}
m.mu.Unlock()
}
}
// obtainCert does one ACME issuance flow for ip: it registers the
// account if needed, orders a short-lived profile certificate for the
// IP address identifier, fulfills the HTTP-01 challenges, and installs
// and caches the issued certificate.
func (m *ipCertManager) obtainCert(ctx context.Context, ip netip.Addr) error {
ipStr := ip.String()
var contact []string
if m.email != "" {
contact = []string{"mailto:" + m.email}
}
_, err := m.client.Register(ctx, &acme.Account{Contact: contact}, acme.AcceptTOS)
if err != nil && !errors.Is(err, acme.ErrAccountAlreadyExists) {
return fmt.Errorf("register: %w", err)
}
order, err := m.client.AuthorizeOrder(ctx, acme.IPIDs(ipStr), acme.WithOrderProfile(shortlivedProfile))
if err != nil {
return fmt.Errorf("new order: %w", err)
}
for _, authzURL := range order.AuthzURLs {
if err := m.fulfillAuthz(ctx, authzURL); err != nil {
return err
}
}
order, err = m.client.WaitOrder(ctx, order.URI)
if err != nil {
return fmt.Errorf("waiting for order: %w", err)
}
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
IPAddresses: []net.IP{ip.AsSlice()},
}, certKey)
if err != nil {
return err
}
der, _, err := m.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
if err != nil {
return fmt.Errorf("finalizing order: %w", err)
}
leaf, err := x509.ParseCertificate(der[0])
if err != nil {
return fmt.Errorf("parsing issued cert: %w", err)
}
if err := leaf.VerifyHostname(ipStr); err != nil {
return fmt.Errorf("issued cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(certKey)
if err != nil {
return err
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
var chainPEM []byte
for _, b := range der {
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b})...)
}
crtPath, keyPath := m.certPaths(ip)
if err := atomicfile.WriteFile(keyPath, keyPEM, 0600); err != nil {
return err
}
if err := atomicfile.WriteFile(crtPath, chainPEM, 0644); err != nil {
return err
}
m.mu.Lock()
m.entryLocked(ip).cert = &tls.Certificate{
Certificate: der,
PrivateKey: certKey,
Leaf: leaf,
}
m.mu.Unlock()
dn := &tailcfg.DERPNode{
Name: "custom",
RegionID: 900,
HostName: ipStr,
}
dnJSON, _ := json.Marshal(dn)
log.Printf("derper: acme: got cert for %v (expires %v). Configure it in DERPMap using (https://tailscale.com/s/custom-derp):\n %s", ip, leaf.NotAfter, dnJSON)
return nil
}
// fulfillAuthz completes the HTTP-01 challenge for one authorization,
// if it is still pending.
func (m *ipCertManager) fulfillAuthz(ctx context.Context, authzURL string) error {
authz, err := m.client.GetAuthorization(ctx, authzURL)
if err != nil {
return fmt.Errorf("getting authorization: %w", err)
}
if authz.Status != acme.StatusPending {
return nil
}
var challenge *acme.Challenge
for _, c := range authz.Challenges {
if c.Type == "http-01" {
challenge = c
break
}
}
if challenge == nil {
return errors.New("authorization offers no http-01 challenge")
}
response, err := m.client.HTTP01ChallengeResponse(challenge.Token)
if err != nil {
return err
}
path := m.client.HTTP01ChallengePath(challenge.Token)
m.setToken(path, response)
defer m.deleteToken(path)
if _, err := m.client.Accept(ctx, challenge); err != nil {
return fmt.Errorf("accepting challenge: %w", err)
}
if _, err := m.client.WaitAuthorization(ctx, authz.URI); err != nil {
return fmt.Errorf("waiting for authorization: %w", err)
}
return nil
}
+486
View File
@@ -0,0 +1,486 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeIPACME is a minimal fake ACME (RFC 8555) certificate authority
// for testing ipCertManager. It implements just enough of the protocol
// for http-01 order flows with IP address identifiers and the
// "shortlived" profile: one order at a time, no JWS signature
// verification, no nonce tracking.
type fakeIPACME struct {
t *testing.T
srv *httptest.Server
challengeBase string // base URL at which http-01 challenges are fetched
caKey *ecdsa.PrivateKey
caCert *x509.Certificate
mu sync.Mutex
orders int // number of orders created
gotProfile string // profile of the last order
gotIDType string // identifier type of the last order
gotIDValue string // identifier value of the last order
authzStatus string // "pending" or "valid"
orderStatus string // "pending", "ready", or "valid"
token string
certPEM []byte
}
func newFakeIPACME(t *testing.T) *fakeIPACME {
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "fake IP ACME root"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatal(err)
}
f := &fakeIPACME{
t: t,
caKey: caKey,
caCert: caCert,
}
f.srv = httptest.NewServer(http.HandlerFunc(f.serveHTTP))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeIPACME) directoryURL() string { return f.srv.URL + "/directory" }
func (f *fakeIPACME) numOrders() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.orders
}
func (f *fakeIPACME) serveHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "test-nonce")
writeJSON := func(v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
orderJSON := func() any {
return map[string]any{
"status": f.orderStatus,
"identifiers": []map[string]string{{"type": f.gotIDType, "value": f.gotIDValue}},
"authorizations": []string{f.srv.URL + "/authz/1"},
"finalize": f.srv.URL + "/finalize/1",
"certificate": f.srv.URL + "/cert/1",
}
}
switch {
case r.URL.Path == "/directory":
writeJSON(map[string]any{
"newNonce": f.srv.URL + "/new-nonce",
"newAccount": f.srv.URL + "/new-account",
"newOrder": f.srv.URL + "/new-order",
"revokeCert": f.srv.URL + "/revoke-cert",
"keyChange": f.srv.URL + "/key-change",
"meta": map[string]any{
"profiles": map[string]string{
"classic": "the default profile",
shortlivedProfile: "six day certificates",
},
},
})
case r.URL.Path == "/new-nonce":
// The Replay-Nonce header was already set above.
case r.URL.Path == "/new-account":
w.Header().Set("Location", f.srv.URL+"/account/1")
w.WriteHeader(http.StatusCreated)
writeJSON(map[string]any{"status": "valid"})
case r.URL.Path == "/new-order":
var req struct {
Identifiers []struct{ Type, Value string } `json:"identifiers"`
Profile string `json:"profile"`
}
if err := decodeJWSPayload(r, &req); err != nil {
f.t.Errorf("new-order payload: %v", err)
}
f.mu.Lock()
f.orders++
f.gotProfile = req.Profile
if len(req.Identifiers) == 1 {
f.gotIDType = req.Identifiers[0].Type
f.gotIDValue = req.Identifiers[0].Value
}
f.authzStatus = "pending"
f.orderStatus = "pending"
f.token = fmt.Sprintf("tok-%d", f.orders)
f.mu.Unlock()
w.Header().Set("Location", f.srv.URL+"/order/1")
w.WriteHeader(http.StatusCreated)
writeJSON(orderJSON())
case r.URL.Path == "/authz/1":
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(map[string]any{
"status": f.authzStatus,
"identifier": map[string]string{"type": f.gotIDType, "value": f.gotIDValue},
"challenges": []map[string]string{{
"type": "http-01",
"url": f.srv.URL + "/chal/1",
"token": f.token,
"status": f.authzStatus,
}},
})
case r.URL.Path == "/chal/1":
if err := f.validateChallenge(); err != nil {
f.t.Errorf("challenge validation: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
f.mu.Lock()
f.authzStatus = "valid"
f.orderStatus = "ready"
f.mu.Unlock()
writeJSON(map[string]any{"type": "http-01", "status": "valid", "token": f.token})
case r.URL.Path == "/order/1":
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Location", f.srv.URL+"/order/1")
writeJSON(orderJSON())
case r.URL.Path == "/finalize/1":
var req struct {
CSR string `json:"csr"`
}
if err := decodeJWSPayload(r, &req); err != nil {
f.t.Errorf("finalize payload: %v", err)
}
if err := f.issueCert(req.CSR); err != nil {
f.t.Errorf("issuing cert: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
f.mu.Lock()
f.orderStatus = "valid"
f.mu.Unlock()
w.Header().Set("Location", f.srv.URL+"/order/1")
writeJSON(orderJSON())
case r.URL.Path == "/cert/1":
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/pem-certificate-chain")
w.Write(f.certPEM)
default:
f.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}
// validateChallenge fetches the http-01 challenge response from the
// server under test, standing in for the CA dialing port 80 at the IP
// address being validated.
func (f *fakeIPACME) validateChallenge() error {
f.mu.Lock()
token := f.token
base := f.challengeBase
f.mu.Unlock()
res, err := http.Get(base + "/.well-known/acme-challenge/" + token)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if !strings.HasPrefix(string(body), token+".") {
return fmt.Errorf("challenge response %q does not start with %q", body, token+".")
}
return nil
}
// issueCert signs a certificate for the CSR (base64url DER), valid for
// six days like a LetsEncrypt shortlived profile certificate.
func (f *fakeIPACME) issueCert(csrB64 string) error {
csrDER, err := base64.RawURLEncoding.DecodeString(csrB64)
if err != nil {
return err
}
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
return err
}
if len(csr.IPAddresses) != 1 {
return fmt.Errorf("CSR has %d IP addresses; want 1", len(csr.IPAddresses))
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(2),
IPAddresses: csr.IPAddresses,
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(6 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, f.caCert, csr.PublicKey, f.caKey)
if err != nil {
return err
}
var buf []byte
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.caCert.Raw})...)
f.mu.Lock()
f.certPEM = buf
f.mu.Unlock()
return nil
}
// decodeJWSPayload decodes the payload of a JWS-encoded ACME request
// without verifying its signature.
func decodeJWSPayload(r *http.Request, v any) error {
var req struct{ Payload string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return err
}
b, err := base64.RawURLEncoding.DecodeString(req.Payload)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
// ipConn is a stub net.Conn whose LocalAddr is the given IP, standing
// in for the accepted TLS connection whose destination address decides
// which certificate to serve.
type ipConn struct {
net.Conn
local net.Addr
}
func (c ipConn) LocalAddr() net.Addr { return c.local }
// helloFor returns a ClientHelloInfo as ipCertManager.getCertificate
// would see it for a connection to localIP with the given SNI value.
func helloFor(t *testing.T, localIP, sni string) *tls.ClientHelloInfo {
t.Helper()
ip := net.ParseIP(localIP)
if ip == nil {
t.Fatalf("bad IP %q", localIP)
}
return &tls.ClientHelloInfo{
ServerName: sni,
Conn: ipConn{local: &net.TCPAddr{IP: ip, Port: 443}},
}
}
// stubCertProvider is a certProvider returning a fixed certificate,
// standing in for the autocert manager handling DNS hostname
// connections.
type stubCertProvider struct {
cert *tls.Certificate
}
func (p *stubCertProvider) TLSConfig() *tls.Config {
return &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return p.cert, nil
},
}
}
func (p *stubCertProvider) HTTPHandler(fallback http.Handler) http.Handler { return fallback }
// TestIPCertManager exercises the on-demand issuance flow of
// ipCertManager against a fake ACME CA: certs are ordered for whatever
// IP address a connection arrives on (IPv4 and IPv6), with the
// shortlived profile, answering the http-01 challenge, and reusing the
// on-disk cache.
func TestIPCertManager(t *testing.T) {
const ip4 = "203.0.113.7"
const ip6 = "2001:db8::7"
dir := t.TempDir()
ca := newFakeIPACME(t)
m, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil)
if err != nil {
t.Fatal(err)
}
// Serve the manager's HTTP-01 challenge handler like derper's port
// 80 listener does.
challengeSrv := httptest.NewServer(m.HTTPHandler(http.NotFoundHandler()))
defer challengeSrv.Close()
ca.challengeBase = challengeSrv.URL
// A connection to the IPv4 address with no SNI mints a cert for it.
cert, err := m.getCertificate(helloFor(t, ip4, ""))
if err != nil {
t.Fatal(err)
}
if err := cert.Leaf.VerifyHostname(ip4); err != nil {
t.Errorf("issued cert not valid for %v: %v", ip4, err)
}
if got, want := ca.gotProfile, shortlivedProfile; got != want {
t.Errorf("order profile = %q; want %q", got, want)
}
if ca.gotIDType != "ip" || ca.gotIDValue != ip4 {
t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip4)
}
if n := ca.numOrders(); n != 1 {
t.Errorf("orders created = %d; want 1", n)
}
// A connection to the IPv6 address mints a second, separate cert.
cert6, err := m.getCertificate(helloFor(t, ip6, ""))
if err != nil {
t.Fatal(err)
}
if err := cert6.Leaf.VerifyHostname(ip6); err != nil {
t.Errorf("issued cert not valid for %v: %v", ip6, err)
}
if ca.gotIDType != "ip" || ca.gotIDValue != ip6 {
t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip6)
}
if n := ca.numOrders(); n != 2 {
t.Errorf("orders created = %d; want 2", n)
}
// An SNI containing the connection's own IP address is served from
// the cache.
if _, err := m.getCertificate(helloFor(t, ip4, ip4)); err != nil {
t.Errorf("getCertificate with matching IP SNI: %v", err)
}
if n := ca.numOrders(); n != 2 {
t.Errorf("orders created after cached hit = %d; want 2", n)
}
// An SNI naming some other IP address is rejected.
if _, err := m.getCertificate(helloFor(t, ip4, "203.0.113.8")); err == nil {
t.Error("getCertificate with mismatched IP SNI succeeded; want error")
}
// A DNS name SNI has no provider to go to here.
if _, err := m.getCertificate(helloFor(t, ip4, "derp.example.com")); err == nil {
t.Error("getCertificate with DNS SNI and no next provider succeeded; want error")
}
// A second manager over the same cert directory must use the
// on-disk cache rather than creating more orders.
m2, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil)
if err != nil {
t.Fatal(err)
}
cachedCert, err := m2.getCertificate(helloFor(t, ip4, ""))
if err != nil {
t.Fatal(err)
}
if err := cachedCert.Leaf.VerifyHostname(ip4); err != nil {
t.Errorf("cached cert not valid for %v: %v", ip4, err)
}
if n := ca.numOrders(); n != 2 {
t.Errorf("orders created after cache reuse = %d; want 2", n)
}
// Non-challenge requests go to the fallback handler.
rec := httptest.NewRecorder()
m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "fallback")
})).ServeHTTP(rec, httptest.NewRequest("GET", "/other", nil))
if got := rec.Body.String(); got != "fallback" {
t.Errorf("fallback body = %q; want %q", got, "fallback")
}
}
// TestIPCertManagerNextProvider verifies that connections with a DNS
// name in the SNI are passed through to the next provider.
func TestIPCertManagerNextProvider(t *testing.T) {
dir := t.TempDir()
ca := newFakeIPACME(t)
stubCert := &tls.Certificate{}
m, err := newIPCertManager(dir, "", ca.directoryURL(), &stubCertProvider{cert: stubCert})
if err != nil {
t.Fatal(err)
}
got, err := m.getCertificate(helloFor(t, "203.0.113.7", "derp.example.com"))
if err != nil {
t.Fatal(err)
}
if got != stubCert {
t.Errorf("DNS SNI returned %p; want the next provider's cert %p", got, stubCert)
}
if n := ca.numOrders(); n != 0 {
t.Errorf("orders created = %d; want 0", n)
}
}
// TestCertModeIPCertsGating verifies the flag validation around
// --acme-ip-certs and IP address hostnames.
func TestCertModeIPCertsGating(t *testing.T) {
tests := []struct {
name string
mode string
host string
ipCerts bool
wantErr string // or empty to expect success
}{
{"letsencrypt_ip_no_flag", "letsencrypt", "1.2.3.4", false, "--acme-ip-certs"},
{"gcp_ip", "gcp", "1.2.3.4", false, "--certmode=gcp requires --hostname to be a DNS name"},
{"gcp_flag", "gcp", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"},
{"manual_flag", "manual", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"},
{"letsencrypt_ip_flag", "letsencrypt", "1.2.3.4", true, ""},
{"letsencrypt_hostname_flag", "letsencrypt", "derp.example.com", true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cp, err := certProviderByCertMode(tt.mode, t.TempDir(), tt.host, tt.ipCerts, "", "", "")
if tt.wantErr == "" {
if err != nil {
t.Fatalf("certProviderByCertMode(%q, %q, ipCerts=%v) = %v; want success", tt.mode, tt.host, tt.ipCerts, err)
}
m, ok := cp.(*ipCertManager)
if !ok {
t.Fatalf("provider type = %T; want *ipCertManager", cp)
}
wantNext := net.ParseIP(tt.host) == nil
if gotNext := m.next != nil; gotNext != wantNext {
t.Errorf("has next provider = %v; want %v", gotNext, wantNext)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("certProviderByCertMode(%q, %q, ipCerts=%v) error = %v; want contains %q",
tt.mode, tt.host, tt.ipCerts, err, tt.wantErr)
}
})
}
}
+821
View File
@@ -0,0 +1,821 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
// fbstatus is a Linux framebuffer status display for the Tailscale
// appliance. It draws the Tailscale logo, the tailscaled backend state,
// the device's tailnet IP addresses, and (when the device needs to be
// logged in) a QR code containing the login URL so a user can enroll
// the appliance into a tailnet by pointing their phone camera at the
// screen.
//
// fbstatus accesses the framebuffer via the Linux UAPI in
// include/uapi/linux/fb.h: FBIOGET_VSCREENINFO and FBIOGET_FSCREENINFO
// ioctls plus an mmap of /dev/fb0. Only 32-bit truecolor framebuffers
// (the Raspberry Pi default) are supported.
package main
import (
"bytes"
"context"
_ "embed"
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"unsafe"
"github.com/skip2/go-qrcode"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
"golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/util/cloudenv"
)
//go:embed tailscale.png
var tailscalePNG []byte
// Linux framebuffer ioctl numbers, from include/uapi/linux/fb.h.
const (
fbioGetVScreenInfo = 0x4600
fbioGetFScreenInfo = 0x4602
)
// Linux VT ioctl numbers and KD_* modes, from include/uapi/linux/kd.h
// and include/uapi/linux/vt.h.
const (
kdSetMode = 0x4B3A
kdGraphics = 1
kdText = 0
vtActivate = 0x5606
vtWaitActive = 0x5607
)
// Byte offsets into the raw fb_var_screeninfo struct returned by
// FBIOGET_VSCREENINFO. All fields we read are little-endian uint32.
const (
vsOffXres = 0
vsOffYres = 4
vsOffBitsPerPixel = 24
vsOffRedOffset = 32 // start of struct fb_bitfield red
vsOffGreenOffset = 44 // start of struct fb_bitfield green
vsOffBlueOffset = 56 // start of struct fb_bitfield blue
)
// Byte offsets into the raw fb_fix_screeninfo struct returned by
// FBIOGET_FSCREENINFO. Layout assumes a 64-bit kernel (the gokrazy
// appliance targets — arm64/amd64 — are both 64-bit). smem_start and
// mmio_start are "unsigned long", which is 8 bytes on 64-bit.
const (
fsOffSmemLen = 24
fsOffLineLength = 48
)
var flagFB = flag.String("fb", "/dev/fb0", "framebuffer device to draw to")
// noFramebufferReason reports whether this host lacks a usable Linux
// framebuffer, along with a short human-readable explanation. If the
// framebuffer device is missing, or we're running on a cloud (currently
// only AWS) whose instances don't expose one, we return true.
func noFramebufferReason(fbPath string) (string, bool) {
if cloudenv.Get() == cloudenv.AWS {
return "running on AWS (no framebuffer)", true
}
if _, err := os.Stat(fbPath); err != nil {
return fmt.Sprintf("no framebuffer at %s: %v", fbPath, err), true
}
return "", false
}
func main() {
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
// Bail out early on cloud VMs that don't ship a framebuffer. We
// still kick off breakglass once DHCP succeeds (otherwise the
// appliance is unreachable — breakglass declares DontStartOnBoot
// and only runs when fbstatus pokes the supervisor), then exit
// 125 so the gokrazy supervisor stops respawning us. See
// https://gokrazy.org/development/process-interface/.
if reason, ok := noFramebufferReason(*flagFB); ok {
log.Printf("%s; starting breakglass after DHCP then exiting 125", reason)
startBreakglassAfterDHCP(&uiState{})
log.Printf("breakglass started; exiting 125 so gokrazy won't respawn fbstatus")
os.Exit(125)
}
if restore, err := claimVTGraphics(); err != nil {
log.Printf("could not put VT into graphics mode (fbcon may overdraw): %v", err)
} else {
defer restore()
}
fb, err := openFramebuffer(*flagFB)
if err != nil {
return fmt.Errorf("open framebuffer: %w", err)
}
defer fb.Close()
log.Printf("framebuffer %s: %dx%d, %d bpp, line=%d, RGB offsets %d/%d/%d",
*flagFB, fb.width, fb.height, fb.bpp, fb.lineLength,
fb.redShift, fb.greenShift, fb.blueShift)
logo, err := png.Decode(bytes.NewReader(tailscalePNG))
if err != nil {
return fmt.Errorf("decoding embedded logo: %w", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
var lc local.Client
st := &uiState{fb: fb, logo: logo}
st.updateLAN()
st.render()
go st.pollLAN(ctx)
go startBreakglassAfterDHCP(st)
go watchKeyboardForConsole(ctx, st)
for ctx.Err() == nil {
if err := watchBusOnce(ctx, &lc, st); err != nil && ctx.Err() == nil {
log.Printf("ipn watch: %v; retrying in 2s", err)
select {
case <-ctx.Done():
case <-time.After(2 * time.Second):
}
}
}
return nil
}
func watchBusOnce(ctx context.Context, lc *local.Client, st *uiState) error {
w, err := lc.WatchIPNBus(ctx,
ipn.NotifyInitialState|ipn.NotifyInitialPrefs|ipn.NotifyInitialStatus)
if err != nil {
return err
}
defer w.Close()
loginRequested := false
for ctx.Err() == nil {
n, err := w.Next()
if err != nil {
return err
}
if n.State != nil {
st.state = *n.State
// On a fresh appliance, tailscaled enters NeedsLogin but
// does not generate a login URL until someone asks. Trigger
// an interactive login so the control server sends us a URL
// (and thus a QR code appears on the display).
if *n.State == ipn.NeedsLogin && !loginRequested {
loginRequested = true
go func() {
if err := lc.StartLoginInteractive(ctx); err != nil {
log.Printf("StartLoginInteractive: %v", err)
}
}()
}
}
if n.BrowseToURL != nil {
st.loginURL = *n.BrowseToURL
}
if n.InitialStatus != nil {
st.ips = append(st.ips[:0], n.InitialStatus.TailscaleIPs...)
}
if n.SelfChange != nil {
st.ips = st.ips[:0]
for _, p := range n.SelfChange.Addresses {
st.ips = append(st.ips, p.Addr())
}
}
st.render()
}
return ctx.Err()
}
// updateLAN scans network interfaces for a non-loopback interface with a
// hardware address, updating st.lanIP and st.lanMAC. Shows the MAC even
// if DHCP hasn't assigned an IP yet.
func (st *uiState) updateLAN() {
ifaces, err := net.Interfaces()
if err != nil {
return
}
var bestMAC string
var bestIP string
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if len(iface.HardwareAddr) == 0 {
continue
}
if bestMAC == "" {
bestMAC = iface.HardwareAddr.String()
}
if iface.Flags&net.FlagUp == 0 {
continue
}
// Prefer the first UP interface with a MAC.
if bestMAC != iface.HardwareAddr.String() && bestIP == "" {
bestMAC = iface.HardwareAddr.String()
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
bestMAC = iface.HardwareAddr.String()
bestIP = ipnet.IP.String()
}
}
}
st.lanMAC = bestMAC
st.lanIP = bestIP
}
// startBreakglassAfterDHCP waits until a LAN IP is assigned (meaning DHCP
// succeeded), then restarts breakglass. This ensures breakglass sees the
// real LAN address in PrivateInterfaceAddrs and binds to it, rather than
// only binding to 127.0.0.1.
func startBreakglassAfterDHCP(st *uiState) {
for {
st.updateLAN()
if st.lanIP != "" {
break
}
time.Sleep(time.Second)
}
startBreakglass()
}
// startBreakglass asks the gokrazy init HTTP API (over its unix socket) to
// restart the breakglass service so it actually runs. By default breakglass
// calls DontStartOnBoot and exits on the first launch attempt; this poke
// tells the supervisor to try again (without GOKRAZY_FIRST_START=1).
func startBreakglass() {
const sock = "/run/gokrazy-http.sock"
hc := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", sock)
},
},
}
form := url.Values{
"path": {"/user/breakglass"},
"xsrftoken": {"1"},
}
req, err := http.NewRequest("POST", "http://gokrazy/restart", strings.NewReader(form.Encode()))
if err != nil {
log.Printf("startBreakglass: %v", err)
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "gokrazy_xsrf", Value: "1"})
resp, err := hc.Do(req)
if err != nil {
log.Printf("startBreakglass: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode < 300 || resp.StatusCode == http.StatusSeeOther {
log.Printf("startBreakglass: restarted (status %s)", resp.Status)
} else {
log.Printf("startBreakglass: unexpected status %s", resp.Status)
}
}
// watchKeyboardForConsole monitors keyboard input devices for VT-switching
// accelerators.
// - Ctrl-Alt-F2 (or plain Esc — easier to type in NoVNC where the
// Ctrl-Alt-Fn sequence doesn't always propagate) switches to VT2, a
// text-mode busybox shell. When that shell exits, we switch back
// automatically.
// - Ctrl-Alt-F1 switches back to VT1 (fbstatus graphics mode).
//
// This mirrors standard Linux VT switching conventions plus a NoVNC-
// friendly shortcut.
func watchKeyboardForConsole(ctx context.Context, st *uiState) {
kbdPath := findKeyboard()
if kbdPath == "" {
log.Printf("no keyboard found for VT switching")
return
}
kbd, err := os.Open(kbdPath)
if err != nil {
log.Printf("open keyboard %s: %v", kbdPath, err)
return
}
defer kbd.Close()
ttyFile, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
if err != nil {
log.Printf("open /dev/tty0 for VT switch: %v", err)
return
}
defer ttyFile.Close()
ttyFd := int(ttyFile.Fd())
log.Printf("watching %s for Ctrl-Alt-F1/F2 and Esc (VT switching)", kbdPath)
// Linux input_event has the same layout on both arm64 and amd64
// (24 bytes: two uint64 timestamps + uint16 type + uint16 code +
// int32 value), so this parser handles both the Pi and Proxmox VM.
const evSize = 24
const evKey = 1 // EV_KEY
const keyEsc = 1 // KEY_ESC
const keyF1 = 59 // KEY_F1
const keyF2 = 60 // KEY_F2
const keyLeftCtrl = 29
const keyLeftAlt = 56
const keyRightCtrl = 97
const keyRightAlt = 100
const keyPress = 1
buf := make([]byte, evSize)
var ctrlHeld, altHeld bool
switchToFbstatus := func() {
st.paused.Store(false)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 1)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 1)
ioctlSetInt(ttyFile, kdSetMode, kdGraphics)
st.render()
}
switchToShell := func(reason string) {
st.paused.Store(true)
ioctlSetInt(ttyFile, kdSetMode, kdText)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 2)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 2)
go ensureShellOnVT2(switchToFbstatus)
log.Printf("%s: switched to text console", reason)
}
for ctx.Err() == nil {
n, err := kbd.Read(buf)
if err != nil || n < evSize {
continue
}
evType := binary.LittleEndian.Uint16(buf[16:18])
evCode := binary.LittleEndian.Uint16(buf[18:20])
evValue := int32(binary.LittleEndian.Uint32(buf[20:24]))
if evType != evKey {
continue
}
pressed := evValue == keyPress
released := evValue == 0
switch evCode {
case keyLeftCtrl, keyRightCtrl:
if pressed {
ctrlHeld = true
} else if released {
ctrlHeld = false
}
case keyLeftAlt, keyRightAlt:
if pressed {
altHeld = true
} else if released {
altHeld = false
}
case keyEsc:
if pressed && !ctrlHeld && !altHeld {
// Bare Esc: NoVNC-friendly shortcut to the shell.
switchToShell("Esc")
}
case keyF1:
if pressed && ctrlHeld && altHeld {
switchToFbstatus()
log.Printf("Ctrl-Alt-F1: switched to fbstatus")
}
case keyF2:
if pressed && ctrlHeld && altHeld {
switchToShell("Ctrl-Alt-F2")
}
}
}
}
// ensureShellOnVT2 spawns a busybox ash shell on /dev/tty2 if one isn't
// already running. The shell gets the VT2 tty as its controlling terminal
// so keyboard input on VT2 goes to it. When the shell exits, onExit is
// called (typically to switch back to VT1 / fbstatus graphics mode).
var shellOnVT2Running atomic.Bool
func ensureShellOnVT2(onExit func()) {
if !shellOnVT2Running.CompareAndSwap(false, true) {
return
}
go func() {
defer shellOnVT2Running.Store(false)
defer func() {
if onExit != nil {
onExit()
}
}()
shell := "/tmp/serial-busybox/ash"
if _, err := os.Stat(shell); err != nil {
log.Printf("no shell at %s for VT2", shell)
return
}
tty, err := os.OpenFile("/dev/tty2", os.O_RDWR, 0)
if err != nil {
log.Printf("open /dev/tty2: %v", err)
return
}
defer tty.Close()
cmd := exec.Command(shell)
cmd.Stdin = tty
cmd.Stdout = tty
cmd.Stderr = tty
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
Ctty: 0, // index into cmd's file descriptors (stdin = tty)
}
cmd.Env = append(os.Environ(), "TERM=linux", "HOME=/tmp", "PATH=/tmp/serial-busybox:/user:/gokrazy")
log.Printf("starting shell on VT2")
if err := cmd.Run(); err != nil {
log.Printf("shell on VT2 exited: %v", err)
}
}()
}
// findKeyboard looks for a keyboard among /dev/input/event* devices by
// checking that the device's key capability bitmap has KEY_ESC set. The
// alternative "any non-zero key bitmap" check picks up the ACPI power
// button (which advertises KEY_POWER but no Esc) and misses the real
// keyboard on amd64 Proxmox VMs, where the AT keyboard is event1 but
// event0 is Power Button.
func findKeyboard() string {
matches, _ := filepath.Glob("/dev/input/event*")
for _, path := range matches {
name := filepath.Base(path)
capData, err := os.ReadFile("/sys/class/input/" + name + "/device/capabilities/key")
if err != nil {
continue
}
fields := strings.Fields(strings.TrimSpace(string(capData)))
if len(fields) == 0 {
continue
}
// The kernel prints capability bitmaps as space-separated 64-bit
// hex chunks, most-significant chunk first. The last chunk holds
// bits 0..63. KEY_ESC = 1, so its bit-mask is 1<<1 == 0x2.
low, err := strconv.ParseUint(fields[len(fields)-1], 16, 64)
if err != nil {
continue
}
const keyEscBit = 1 << 1
if low&keyEscBit != 0 {
return path
}
}
return ""
}
// pollLAN periodically refreshes LAN info and re-renders.
func (st *uiState) pollLAN(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
st.updateLAN()
st.render()
}
}
}
// uiState is the most-recently-known view of the appliance state that
// gets rendered to the framebuffer on each notify.
type uiState struct {
fb *framebuffer
logo image.Image
state ipn.State
loginURL string
ips []netip.Addr
lanIP string // LAN IPv4 address (from DHCP)
lanMAC string // MAC address of the primary interface
paused atomic.Bool // when true, render() is a no-op (VT switched away)
}
var (
bgColor = color.RGBA{0x10, 0x12, 0x20, 0xff} // near-black slate
fgColor = color.RGBA{0xff, 0xff, 0xff, 0xff}
dimColor = color.RGBA{0xa0, 0xa6, 0xb8, 0xff}
stateOK = color.RGBA{0x4a, 0xc8, 0x82, 0xff} // green for Running
stateWait = color.RGBA{0xf0, 0xc8, 0x60, 0xff} // amber for NeedsLogin/Starting
)
// render composes the current state into an in-memory image and blits
// it to the framebuffer.
func (st *uiState) render() {
if st.paused.Load() {
return
}
w, h := st.fb.width, st.fb.height
img := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(img, img.Bounds(), &image.Uniform{C: bgColor}, image.Point{}, draw.Src)
shortSide := min(w, h)
// Logo, scaled to ~25% of the shorter dimension, centered
// horizontally near the top.
logoSize := shortSide / 4
logoRect := image.Rect(0, 0, logoSize, logoSize).Add(image.Point{
X: (w - logoSize) / 2,
Y: shortSide / 16,
})
xdraw.ApproxBiLinear.Scale(img, logoRect, st.logo, st.logo.Bounds(), xdraw.Over, nil)
lineH := basicfont.Face7x13.Metrics().Height.Ceil()
textTop := logoRect.Max.Y + shortSide/24
// Hide the state line when the QR code is visible (the "Scan to
// enroll" label is clear enough context).
showState := !(st.state == ipn.NeedsLogin && st.loginURL != "")
if showState {
stateColor := dimColor
switch st.state {
case ipn.Running:
stateColor = stateOK
case ipn.NeedsLogin, ipn.Starting, ipn.NoState:
stateColor = stateWait
}
drawCenteredScaled(img, fmt.Sprintf("State: %s", stateLabel(st.state)),
stateColor, w/2, textTop, 3)
}
y := textTop + 3*lineH + shortSide/40
if len(st.ips) > 0 {
drawCenteredScaled(img, "Tailscale IPs:", dimColor, w/2, y, 2)
y += 2 * lineH
for _, a := range st.ips {
drawCenteredScaled(img, a.String(), fgColor, w/2, y, 2)
y += 2*lineH + 4
}
}
// QR code with the login URL when enrollment is needed.
if st.state == ipn.NeedsLogin && st.loginURL != "" {
qrSize := shortSide / 2
q, err := qrcode.New(st.loginURL, qrcode.Medium)
if err != nil {
log.Printf("qr encode %q: %v", st.loginURL, err)
} else {
q.DisableBorder = false
qrImg := q.Image(qrSize)
qrRect := qrImg.Bounds().Add(image.Point{
X: (w - qrSize) / 2,
Y: h - qrSize - shortSide/16,
})
draw.Draw(img, qrRect, qrImg, qrImg.Bounds().Min, draw.Src)
drawCenteredScaled(img, "Scan to enroll this device",
fgColor, w/2, qrRect.Min.Y-lineH*2-8, 2)
}
}
// LAN status pinned to the bottom-left corner.
{
lanY := h - lineH - 4
var lanText string
if st.lanIP != "" {
lanText = "LAN IP: " + st.lanIP
} else if st.lanMAC != "" {
lanText = "Waiting for DHCP (" + st.lanMAC + ")"
}
if lanText != "" {
face := basicfont.Face7x13
textW := font.MeasureString(face, lanText).Ceil()
small := image.NewRGBA(image.Rect(0, 0, textW, lineH))
d := font.Drawer{
Dst: small,
Src: &image.Uniform{C: dimColor},
Face: face,
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
}
d.DrawString(lanText)
dstRect := image.Rect(4, lanY, 4+textW, lanY+lineH)
draw.Draw(img, dstRect, small, image.Point{}, draw.Over)
}
}
st.fb.blit(img)
}
// drawCenteredScaled draws s with basicfont.Face7x13 at the given
// integer pixel scale, centered horizontally on x at top y, in col.
func drawCenteredScaled(dst *image.RGBA, s string, col color.Color, x, y, scale int) {
if s == "" {
return
}
face := basicfont.Face7x13
width := font.MeasureString(face, s).Ceil()
height := face.Metrics().Height.Ceil()
small := image.NewRGBA(image.Rect(0, 0, width, height))
d := font.Drawer{
Dst: small,
Src: &image.Uniform{C: col},
Face: face,
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
}
d.DrawString(s)
scaledW, scaledH := width*scale, height*scale
dstRect := image.Rect(0, 0, scaledW, scaledH).Add(image.Point{
X: x - scaledW/2,
Y: y,
})
xdraw.NearestNeighbor.Scale(dst, dstRect, small, small.Bounds(), xdraw.Over, nil)
}
func stateLabel(s ipn.State) string {
switch s {
case ipn.NoState, ipn.Starting:
return "starting"
case ipn.NeedsLogin:
return "needs login"
case ipn.NeedsMachineAuth:
return "needs machine auth"
case ipn.Stopped:
return "stopped"
case ipn.Running:
return "running"
}
return strings.ToLower(s.String())
}
// framebuffer is an mmap'd Linux framebuffer device.
type framebuffer struct {
f *os.File
mem []byte
width int
height int
bpp int
lineLength int
// Bit offsets into a 32-bit pixel for each channel, from the
// fb_bitfield values returned by FBIOGET_VSCREENINFO.
redShift uint32
greenShift uint32
blueShift uint32
}
// openFramebuffer opens path, queries dimensions and pixel format via
// the FBIOGET_* ioctls, and mmaps the framebuffer memory.
//
// Only 32-bits-per-pixel framebuffers are supported. Raspberry Pi 3/4/5
// default to that.
func openFramebuffer(path string) (*framebuffer, error) {
f, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, err
}
var (
vbuf [160]byte // fb_var_screeninfo
fbuf [80]byte // fb_fix_screeninfo
)
if err := ioctlGet(f, fbioGetVScreenInfo, vbuf[:]); err != nil {
f.Close()
return nil, fmt.Errorf("FBIOGET_VSCREENINFO: %w", err)
}
if err := ioctlGet(f, fbioGetFScreenInfo, fbuf[:]); err != nil {
f.Close()
return nil, fmt.Errorf("FBIOGET_FSCREENINFO: %w", err)
}
fb := &framebuffer{
f: f,
width: int(binary.LittleEndian.Uint32(vbuf[vsOffXres:])),
height: int(binary.LittleEndian.Uint32(vbuf[vsOffYres:])),
bpp: int(binary.LittleEndian.Uint32(vbuf[vsOffBitsPerPixel:])),
lineLength: int(binary.LittleEndian.Uint32(fbuf[fsOffLineLength:])),
redShift: binary.LittleEndian.Uint32(vbuf[vsOffRedOffset:]),
greenShift: binary.LittleEndian.Uint32(vbuf[vsOffGreenOffset:]),
blueShift: binary.LittleEndian.Uint32(vbuf[vsOffBlueOffset:]),
}
if fb.bpp != 32 {
f.Close()
return nil, fmt.Errorf("unsupported framebuffer bpp %d (only 32 is supported)", fb.bpp)
}
memLen := int(binary.LittleEndian.Uint32(fbuf[fsOffSmemLen:]))
mem, err := unix.Mmap(int(f.Fd()), 0, memLen,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
f.Close()
return nil, fmt.Errorf("mmap %s: %w", path, err)
}
fb.mem = mem
return fb, nil
}
func (fb *framebuffer) Close() error {
if fb.mem != nil {
unix.Munmap(fb.mem)
fb.mem = nil
}
return fb.f.Close()
}
// blit copies img into the mapped framebuffer, packing each
// image.RGBA pixel into the framebuffer's per-channel bit layout.
func (fb *framebuffer) blit(img *image.RGBA) {
srcStride := img.Stride
for y := 0; y < fb.height; y++ {
srcRow := img.Pix[y*srcStride : y*srcStride+fb.width*4]
dstRow := fb.mem[y*fb.lineLength:]
for x := 0; x < fb.width; x++ {
r := uint32(srcRow[x*4+0])
g := uint32(srcRow[x*4+1])
b := uint32(srcRow[x*4+2])
px := r<<fb.redShift | g<<fb.greenShift | b<<fb.blueShift
binary.LittleEndian.PutUint32(dstRow[x*4:], px)
}
}
}
// claimVTGraphics puts the active virtual terminal into KD_GRAPHICS so
// the kernel's framebuffer console (fbcon) stops drawing on /dev/fb0
// while fbstatus owns it. It returns a function that restores KD_TEXT.
//
// The Linux kernel applies VT mode to whatever VT is current; the open
// path /dev/tty0 always refers to the foreground VT, which on a
// headless gokrazy appliance is the only VT.
func claimVTGraphics() (restore func(), err error) {
f, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
if err != nil {
return nil, err
}
if err := ioctlSetInt(f, kdSetMode, kdGraphics); err != nil {
f.Close()
return nil, fmt.Errorf("KDSETMODE KD_GRAPHICS: %w", err)
}
return func() {
if err := ioctlSetInt(f, kdSetMode, kdText); err != nil {
log.Printf("KDSETMODE KD_TEXT on shutdown: %v", err)
}
f.Close()
}, nil
}
// ioctlSetInt runs an ioctl with a single integer arg, like KDSETMODE.
func ioctlSetInt(f *os.File, req uintptr, arg uintptr) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req, arg)
if errno != 0 {
return errno
}
return nil
}
// ioctlGet runs an ioctl that fills a struct of len(buf) bytes in buf.
// Used for the FBIOGET_* ioctls; on success buf holds the kernel's
// fb_*_screeninfo struct.
func ioctlGet(f *os.File, req uintptr, buf []byte) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req,
uintptr(unsafe.Pointer(&buf[0])))
if errno != 0 {
return errno
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package main
import (
"fmt"
"os"
"runtime"
)
func main() {
fmt.Fprintf(os.Stderr, "fbstatus is only supported on Linux (got %s)\n", runtime.GOOS)
os.Exit(1)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+5 -1
View File
@@ -255,7 +255,11 @@ func getCredentials() (*http.Client, string) {
} else if idok && idToken != "" && oiok && oauthId != "" {
if exchangeJWTForToken, ok := tailscale.HookExchangeJWTForTokenViaWIF.GetOk(); ok {
var err error
apiKeyEnv, err = exchangeJWTForToken(context.Background(), fmt.Sprintf("https://%s", *apiServer), oauthId, idToken)
apiKeyEnv, err = exchangeJWTForToken(context.Background(), tailscale.ExchangeJWTForTokenWIFArgs{
BaseURL: fmt.Sprintf("https://%s", *apiServer),
ClientID: oauthId,
IDToken: idToken,
})
if err != nil {
log.Fatal(err)
}
+1 -1
View File
@@ -29,7 +29,7 @@ import (
const (
// tsNetDomain is the domain that this DNS nameserver has registered a handler for.
tsNetDomain = "ts.net"
// addr is the the address that the UDP and TCP listeners will listen on.
// addr is the address that the UDP and TCP listeners will listen on.
addr = ":1053"
// defaultTTL is the default TTL for DNS records in seconds.
// Set to 0 to disable caching. Can be increased when usage patterns are better understood.
+9 -3
View File
@@ -436,14 +436,16 @@ func exclusiveOwnerAnnotations(pg *tsapi.ProxyGroup, operatorID string, svc *tai
}
if svc == nil {
c := ownerAnnotationValue{OwnerRefs: []OwnerRef{ref}}
json, err := json.Marshal(c)
data, err := json.Marshal(c)
if err != nil {
return nil, fmt.Errorf("[unexpected] unable to marshal Tailscale Service's owner annotation contents: %w, please report this", err)
return nil, fmt.Errorf("failed to marshal Tailscale Service's owner annotation contents: %w", err)
}
return map[string]string{
ownerAnnotation: string(json),
ownerAnnotation: string(data),
}, nil
}
o, err := parseOwnerAnnotation(svc)
if err != nil {
return nil, err
@@ -451,15 +453,19 @@ func exclusiveOwnerAnnotations(pg *tsapi.ProxyGroup, operatorID string, svc *tai
if o == nil || len(o.OwnerRefs) == 0 {
return nil, fmt.Errorf("Tailscale Service %s exists, but does not contain owner annotation with owner references; not proceeding as this is likely a resource created by something other than the Tailscale Kubernetes operator", svc.Name)
}
if len(o.OwnerRefs) > 1 || o.OwnerRefs[0].OperatorID != operatorID {
return nil, fmt.Errorf("Tailscale Service %s is already owned by other operator(s) and cannot be shared across multiple clusters; configure a difference Service name to continue", svc.Name)
}
if o.OwnerRefs[0].Resource == nil {
return nil, fmt.Errorf("Tailscale Service %s exists, but does not reference an owning resource; not proceeding as this is likely a Service already owned by an Ingress", svc.Name)
}
if o.OwnerRefs[0].Resource.Kind != "ProxyGroup" || o.OwnerRefs[0].Resource.UID != string(pg.UID) {
return nil, fmt.Errorf("Tailscale Service %s is already owned by another resource: %#v; configure a difference Service name to continue", svc.Name, o.OwnerRefs[0].Resource)
}
if o.OwnerRefs[0].Resource.Name != pg.Name {
// ProxyGroup name can be updated in place.
o.OwnerRefs[0].Resource.Name = pg.Name
+7
View File
@@ -29,6 +29,8 @@ import (
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
@@ -356,6 +358,11 @@ func validateRoutes(routes tsapi.Routes) error {
if pfx.Masked() != pfx {
errs = append(errs, fmt.Errorf("route %s has non-address bits set; expected %s", pfx, pfx.Masked()))
}
if tsaddr.IsViaPrefix(pfx) {
if err := netutil.ValidateViaPrefix(pfx); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
+16
View File
@@ -145,6 +145,22 @@ func TestConnector(t *testing.T) {
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
// Set an invalid 4via6 route (site ID too large).
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:1:0:a2c:0/116"}
})
expectReconciled(t, cr, "", "test")
// STS should still have the previous valid route, unchanged.
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
// Set a valid 4via6 route.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:0:1:a2c:0/116"}
})
opts.subnetRoutes = "fd7a:115c:a1e0:b1a:0:1:a2c:0/116"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
t.Fatalf("error deleting Connector: %v", err)
+36 -29
View File
@@ -12,7 +12,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/coder/websocket from tailscale.com/util/eventbus
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
github.com/creachadair/msync/trigger from tailscale.com/logtail
💣 github.com/davecgh/go-spew/spew from k8s.io/apimachinery/pkg/util/dump
W 💣 github.com/dblohm7/wingoes from tailscale.com/net/tshttpproxy+
@@ -42,6 +41,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
github.com/go-logr/logr from github.com/go-logr/logr/slogr+
github.com/go-logr/logr/slogr from github.com/go-logr/zapr
github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+
@@ -730,15 +730,18 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/acme from tailscale.com/tsnet
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/c2n from tailscale.com/tsnet
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
tailscale.com/feature/condregister/netlog from tailscale.com/tsnet
tailscale.com/feature/condregister/oauthkey from tailscale.com/tsnet
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
tailscale.com/feature/netlog from tailscale.com/feature/condregister/netlog
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
@@ -752,16 +755,18 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/localapi from tailscale.com/tsnet
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/store from tailscale.com/ipn/store/kubestore+
tailscale.com/ipn/store/kubestore from tailscale.com/cmd/k8s-operator
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
tailscale.com/k8s-operator from tailscale.com/cmd/k8s-operator+
tailscale.com/k8s-operator/api-proxy from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/apis from tailscale.com/k8s-operator/apis/v1alpha1
tailscale.com/k8s-operator/apis/v1alpha1 from tailscale.com/cmd/k8s-operator+
tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet
tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet+
tailscale.com/k8s-operator/reconciler/peerrelay from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/reconciler/proxygrouppolicy from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/reconciler/tailnet from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/reconciler/tailscaled from tailscale.com/k8s-operator/reconciler/peerrelay
tailscale.com/k8s-operator/sessionrecording from tailscale.com/k8s-operator/api-proxy
tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording
tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+
@@ -782,7 +787,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/metrics from tailscale.com/tsweb+
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
tailscale.com/net/dns/resolvconffile from tailscale.com/cmd/k8s-operator+
@@ -793,7 +797,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+
tailscale.com/net/memnet from tailscale.com/tsnet
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
tailscale.com/net/netknob from tailscale.com/logpolicy+
@@ -807,12 +811,16 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/ipn/localapi+
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal+
tailscale.com/net/tsaddr from tailscale.com/client/web+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
@@ -826,7 +834,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/sessionrecording from tailscale.com/k8s-operator/sessionrecording+
tailscale.com/syncs from tailscale.com/control/controlknobs+
tailscale.com/tailcfg from tailscale.com/client/local+
tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal
tailscale.com/tempfork/acme from tailscale.com/feature/acme
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
tailscale.com/tempfork/httprec from tailscale.com/feature/c2n
tailscale.com/tka from tailscale.com/client/local+
@@ -848,7 +856,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+
tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
tailscale.com/types/mapx from tailscale.com/ipn/ipnext+
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
tailscale.com/types/netlogtype from tailscale.com/wgengine/netlog
tailscale.com/types/netmap from tailscale.com/control/controlclient+
@@ -870,6 +878,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
tailscale.com/util/def from tailscale.com/ipn/localapi
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/eventbus from tailscale.com/tsd+
@@ -892,16 +901,15 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/set from tailscale.com/cmd/k8s-operator+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
tailscale.com/util/syspolicy/pkey from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy/policyclient from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/ptype from tailscale.com/ipn/ipnlocal+
tailscale.com/util/syspolicy/rsop from tailscale.com/ipn/localapi
tailscale.com/util/syspolicy/setting from tailscale.com/client/local+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy/rsop
tailscale.com/util/testenv from tailscale.com/control/controlclient+
tailscale.com/util/truncate from tailscale.com/logtail
tailscale.com/util/usermetric from tailscale.com/health+
@@ -918,12 +926,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap+
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/netlog from tailscale.com/wgengine
tailscale.com/wgengine/netlog from tailscale.com/feature/netlog
tailscale.com/wgengine/netstack from tailscale.com/tsnet
tailscale.com/wgengine/netstack/gro from tailscale.com/net/tstun+
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
tailscale.com/wgengine/wglog from tailscale.com/wgengine
golang.org/x/crypto/argon2 from tailscale.com/tka
@@ -961,7 +968,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
D golang.org/x/net/route from tailscale.com/net/netmon+
golang.org/x/net/websocket from tailscale.com/k8s-operator/sessionrecording/ws
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials+
golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/k8s-operator+
golang.org/x/oauth2/clientcredentials from tailscale.com/client/tailscale/v2+
golang.org/x/oauth2/internal from golang.org/x/oauth2+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
golang.org/x/sys/cpu from github.com/tailscale/certstore+
@@ -1018,22 +1025,22 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
crypto/internal/boring/bbig from crypto/ecdsa+
crypto/internal/boring/sig from crypto/internal/boring
crypto/internal/constanttime from crypto/internal/fips140/edwards25519+
crypto/internal/fips140 from crypto/internal/fips140/aes+
crypto/internal/fips140 from crypto/fips140+
crypto/internal/fips140/aes from crypto/aes+
crypto/internal/fips140/aes/gcm from crypto/cipher+
crypto/internal/fips140/alias from crypto/cipher+
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/check from crypto/internal/fips140/aes+
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
crypto/internal/fips140/check from crypto/fips140+
crypto/internal/fips140/drbg from crypto/hpke+
crypto/internal/fips140/ecdh from crypto/ecdh
crypto/internal/fips140/ecdsa from crypto/ecdsa
crypto/internal/fips140/ed25519 from crypto/ed25519
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
crypto/internal/fips140/hkdf from crypto/hkdf+
crypto/internal/fips140/hmac from crypto/hmac+
crypto/internal/fips140/mlkem from crypto/mlkem
crypto/internal/fips140/nistec from crypto/elliptic+
crypto/internal/fips140/nistec from crypto/ecdsa+
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
crypto/internal/fips140/rsa from crypto/rsa
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
@@ -1098,7 +1105,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
hash from compress/zlib+
hash/adler32 from compress/zlib
hash/crc32 from compress/gzip+
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/fnv from google.golang.org/protobuf/internal/detrand+
hash/maphash from go4.org/mem
html from html/template+
html/template from tailscale.com/util/eventbus
@@ -1113,14 +1120,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/filepathlite from os+
internal/fmtsort from fmt+
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from crypto/internal/fips140deps/godebug+
internal/godebug from crypto/ed25519+
internal/godebugs from internal/godebug+
internal/goexperiment from net/http/pprof+
internal/goos from crypto/x509+
internal/lazyregexp from go/doc
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from io/fs+
internal/oserror from internal/syscall/windows+
internal/poll from net+
internal/profile from net/http/pprof
internal/profilerecord from runtime+
@@ -1130,9 +1137,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/runtime/atomic from internal/runtime/exithook+
L internal/runtime/cgroup from runtime
internal/runtime/exithook from runtime
internal/runtime/gc from runtime+
internal/runtime/gc from internal/runtime/gc/scan+
internal/runtime/gc/scan from runtime
internal/runtime/maps from reflect+
internal/runtime/maps from hash/maphash+
internal/runtime/math from internal/runtime/maps+
internal/runtime/pprof/label from runtime+
internal/runtime/sys from crypto/subtle+
@@ -1146,7 +1153,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/synctest from sync
internal/syscall/execenv from os+
LD internal/syscall/unix from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/fips140deps/time+
W internal/syscall/windows/registry from mime+
W internal/syscall/windows/sysdll from internal/syscall/windows+
internal/testlog from os
@@ -1154,7 +1161,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/unsafeheader from internal/reflectlite+
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/godbus/dbus/v5+
io/ioutil from github.com/google/gnostic-models/compiler+
iter from go/ast+
log from expvar+
log/internal from log+
@@ -1191,7 +1198,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
regexp from github.com/davecgh/go-spew/spew+
regexp/syntax from regexp
runtime from crypto/internal/fips140+
runtime/debug from github.com/coder/websocket/internal/xsync+
runtime/debug from github.com/klauspost/compress/zstd+
runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof+
runtime/trace from net/http/pprof
@@ -10,3 +10,4 @@
/recorder.yaml
/tailnet.yaml
/proxygrouppolicy.yaml
/peerrelay.yaml
@@ -6,6 +6,9 @@ kind: Deployment
metadata:
name: operator
namespace: {{ .Release.Namespace }}
{{- if .Values.annotations }}
annotations: {{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
spec:
replicas: 1
strategy:
@@ -78,6 +81,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OPERATOR_SERVICE_ACCOUNT_NAME
valueFrom:
fieldRef:
fieldPath: spec.serviceAccountName
- name: OPERATOR_LOGIN_SERVER
value: {{ .Values.loginServer }}
- name: OPERATOR_INGRESS_CLASS_NAME
@@ -117,6 +124,8 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.uid
- name: OPERATOR_SHARED_ACME_ACCOUNT_KEY
value: {{ .Values.operatorConfig.sharedACMEAccountKey | quote }}
{{- with .Values.operatorConfig.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -40,6 +40,9 @@ rules:
- apiGroups: ["tailscale.com"]
resources: ["tailnets", "tailnets/status"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["tailscale.com"]
resources: ["peerrelays", "peerrelays/status"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["tailscale.com"]
resources: ["proxygrouppolicies", "proxygrouppolicies/status"]
verbs: ["get", "list", "watch", "update"]
@@ -76,6 +79,10 @@ rules:
- apiGroups: [""]
resources: ["secrets", "serviceaccounts", "configmaps"]
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
- apiGroups: [""]
resources: ["serviceaccounts/token"]
resourceNames: ["operator"]
verbs: ["create"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","list","watch", "update"]
+10
View File
@@ -62,6 +62,9 @@ operatorConfig:
resources: {}
# Specifies annotations for deployment
annotations: {}
podAnnotations: {}
podLabels: {}
@@ -84,6 +87,13 @@ operatorConfig:
# - name: EXTRA_VAR2
# value: "value2"
# Default for the tailscale.com/share-acme-account annotation on new
# ProxyGroups. When true, the operator provisions a shared per-tailnet
# ACME account key Secret and configures proxies to use it, preserving
# Let's Encrypt's ARI "replaces" renewal exemption across pod restarts
# and ProxyGroup recreation. See #18251.
sharedACMEAccountKey: false
# In the case that you already have a tailscale ingressclass in your cluster (or vcluster), you can disable the creation here
ingressClass:
# Allows for customization of the ingress class name used by the operator to identify ingresses to reconcile. This does
@@ -0,0 +1,264 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
name: peerrelays.tailscale.com
spec:
group: tailscale.com
names:
kind: PeerRelay
listKind: PeerRelayList
plural: peerrelays
shortNames:
- pr
singular: peerrelay
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
- description: Status of the deployed PeerRelay resources.
jsonPath: .status.conditions[?(@.type == "PeerRelayReady")].reason
name: Status
type: string
- description: Public addresses the peer relay replicas are reachable on.
jsonPath: .status.endpoints[*].address
name: Endpoints
type: string
name: v1alpha1
schema:
openAPIV3Schema:
type: object
required:
- metadata
- spec
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
Spec describes the desired state of the PeerRelay.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
type: object
properties:
aws:
description: |-
AWS contains configuration for pinning each replica to a specific AWS Elastic IP and subnet. Only meaningful
when running on EKS with the AWS Load Balancer Controller. When set, the per-replica values override any
aws-load-balancer-eip-allocations or aws-load-balancer-subnets values supplied via spec.service.annotations.
type: object
required:
- elasticIPs
properties:
elasticIPs:
description: |-
ElasticIPs pins each replica to a specific AWS EIP allocation and subnet. Only meaningful when Network Load
Balancers are provisioned by the AWS Load Balancer Controller. ElasticIPs supplies one allocation-subnet pair
per replica: replica N uses ElasticIPs[N]. The list must be at least as long as spec.replicas so every replica
has a distinct EIP; extra entries are permitted so that scale-up doesn't immediately trip validation.
When set, the reconciler stamps
service.beta.kubernetes.io/aws-load-balancer-eip-allocations and
service.beta.kubernetes.io/aws-load-balancer-subnets on each per-replica Service, overriding any values in
spec.service.annotations.
type: array
minItems: 1
items:
description: PeerRelayAWSElasticIP pairs an EIP allocation with the subnet in the same AZ.
type: object
required:
- allocationID
- subnetID
properties:
allocationID:
description: |-
AllocationID is the AWS EIP allocation ID (e.g. eipalloc-0123abcd) whose public IP this replica is reachable
on. Stamped as service.beta.kubernetes.io/aws-load-balancer-eip-allocations on the replica's Service.
type: string
pattern: ^eipalloc-[0-9a-f]+$
subnetID:
description: |-
SubnetID is the AWS subnet in the same availability zone as AllocationID (e.g. subnet-0123abcd). Stamped as
service.beta.kubernetes.io/aws-load-balancer-subnets on the replica's Service so the NLB is provisioned in
the same AZ as the EIP.
type: string
pattern: ^subnet-[0-9a-f]+$
x-kubernetes-list-type: atomic
hostnamePrefix:
description: |-
HostnamePrefix specifies the hostname prefix for each
replica. Each device will have the integer number
from its StatefulSet pod appended to this prefix to form the full hostname.
HostnamePrefix can contain lower case letters, numbers and dashes, it
must not start with a dash and must be between 1 and 62 characters long.
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,61}$
proxyClass:
description: |-
ProxyClass is the name of the ProxyClass custom resource that
contains configuration options that should be applied to the
resources created for this PeerRelay. If unset, the operator will
create resources with the default configuration.
type: string
replicas:
description: |-
Replicas specifies how many devices to create. Set this to enable
high availability for peer relays.
https://tailscale.com/kb/1115/high-availability. Defaults to 1.
type: integer
format: int32
default: 1
minimum: 0
service:
description: Service contains configuration values to modify the LoadBalancer service used to expose the peer relay.
type: object
properties:
annotations:
description: |-
Annotations to apply to the LoadBalancer service. Any annotations that conflict with those used by known
cloud providers to ensure IP addresses rather than DNS names are ignored.
type: object
additionalProperties:
type: string
tags:
description: |-
Tags that the Tailscale node will be tagged with.
Defaults to [tag:k8s].
To autoapprove the device defined by a PeerRelay,
you can configure Tailscale ACLs to give these tags the necessary
permissions.
See https://tailscale.com/kb/1337/acl-syntax#autoapprovers.
If you specify custom tags here, you must also make the operator an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a PeerRelay node has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
type: array
items:
type: string
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
tailnet:
description: |-
Tailnet specifies the tailnet this PeerRelay should join. If blank, the default tailnet is used. When set, this
name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
type: string
x-kubernetes-validations:
- rule: self == oldSelf
message: PeerRelay tailnet is immutable
x-kubernetes-validations:
- rule: '!has(self.aws) || !has(self.aws.elasticIPs) || self.aws.elasticIPs.size() >= self.replicas'
message: spec.aws.elasticIPs must contain at least one entry per replica
status:
description: |-
Status describes the status of the PeerRelay. This is set
and managed by the Tailscale operator.
type: object
properties:
conditions:
type: array
items:
description: Condition contains details for one aspect of the current state of this API Resource.
type: object
required:
- lastTransitionTime
- message
- reason
- status
- type
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
type: string
format: date-time
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
type: string
maxLength: 32768
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
type: integer
format: int64
minimum: 0
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
type: string
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
status:
description: status of the condition, one of True, False, Unknown.
type: string
enum:
- "True"
- "False"
- Unknown
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
endpoints:
description: |-
Endpoints lists the public address:port pairs each peer relay replica is reachable on. There is one entry
per replica whose LoadBalancer Service has been assigned a public address; entries appear as the underlying
cloud provisions each Service.
type: array
items:
type: object
required:
- address
- port
- replica
properties:
address:
description: |-
Address is the public IP or hostname the cloud has allocated for this replica's LoadBalancer Service.
Peers reach this relay by connecting to Address:Port over UDP.
type: string
port:
description: Port is the UDP port the peer relay listens on.
type: integer
format: int32
replica:
description: Replica is the zero-based index of the peer relay replica this endpoint targets.
type: integer
format: int32
x-kubernetes-list-map-keys:
- replica
x-kubernetes-list-type: map
served: true
storage: true
subresources:
status: {}
@@ -58,15 +58,18 @@ spec:
- credentials
properties:
credentials:
description: Denotes the location of the OAuth credentials to use for authenticating with this Tailnet.
description: Denotes the location of the credentials to use for authenticating with this Tailnet.
type: object
required:
- secretName
properties:
secretName:
description: |-
The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and
"client_secret".
The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always
contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate
via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth
client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API
token. "client_secret" and "audience" are mutually exclusive.
type: string
loginUrl:
description: URL of the control plane to be used by all resources managed by the operator using this Tailnet.
+295 -3
View File
@@ -1463,6 +1463,271 @@ spec:
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
name: peerrelays.tailscale.com
spec:
group: tailscale.com
names:
kind: PeerRelay
listKind: PeerRelayList
plural: peerrelays
shortNames:
- pr
singular: peerrelay
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
- description: Status of the deployed PeerRelay resources.
jsonPath: .status.conditions[?(@.type == "PeerRelayReady")].reason
name: Status
type: string
- description: Public addresses the peer relay replicas are reachable on.
jsonPath: .status.endpoints[*].address
name: Endpoints
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
Spec describes the desired state of the PeerRelay.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
properties:
aws:
description: |-
AWS contains configuration for pinning each replica to a specific AWS Elastic IP and subnet. Only meaningful
when running on EKS with the AWS Load Balancer Controller. When set, the per-replica values override any
aws-load-balancer-eip-allocations or aws-load-balancer-subnets values supplied via spec.service.annotations.
properties:
elasticIPs:
description: |-
ElasticIPs pins each replica to a specific AWS EIP allocation and subnet. Only meaningful when Network Load
Balancers are provisioned by the AWS Load Balancer Controller. ElasticIPs supplies one allocation-subnet pair
per replica: replica N uses ElasticIPs[N]. The list must be at least as long as spec.replicas so every replica
has a distinct EIP; extra entries are permitted so that scale-up doesn't immediately trip validation.
When set, the reconciler stamps
service.beta.kubernetes.io/aws-load-balancer-eip-allocations and
service.beta.kubernetes.io/aws-load-balancer-subnets on each per-replica Service, overriding any values in
spec.service.annotations.
items:
description: PeerRelayAWSElasticIP pairs an EIP allocation with the subnet in the same AZ.
properties:
allocationID:
description: |-
AllocationID is the AWS EIP allocation ID (e.g. eipalloc-0123abcd) whose public IP this replica is reachable
on. Stamped as service.beta.kubernetes.io/aws-load-balancer-eip-allocations on the replica's Service.
pattern: ^eipalloc-[0-9a-f]+$
type: string
subnetID:
description: |-
SubnetID is the AWS subnet in the same availability zone as AllocationID (e.g. subnet-0123abcd). Stamped as
service.beta.kubernetes.io/aws-load-balancer-subnets on the replica's Service so the NLB is provisioned in
the same AZ as the EIP.
pattern: ^subnet-[0-9a-f]+$
type: string
required:
- allocationID
- subnetID
type: object
minItems: 1
type: array
x-kubernetes-list-type: atomic
required:
- elasticIPs
type: object
hostnamePrefix:
description: |-
HostnamePrefix specifies the hostname prefix for each
replica. Each device will have the integer number
from its StatefulSet pod appended to this prefix to form the full hostname.
HostnamePrefix can contain lower case letters, numbers and dashes, it
must not start with a dash and must be between 1 and 62 characters long.
pattern: ^[a-z0-9][a-z0-9-]{0,61}$
type: string
proxyClass:
description: |-
ProxyClass is the name of the ProxyClass custom resource that
contains configuration options that should be applied to the
resources created for this PeerRelay. If unset, the operator will
create resources with the default configuration.
type: string
replicas:
default: 1
description: |-
Replicas specifies how many devices to create. Set this to enable
high availability for peer relays.
https://tailscale.com/kb/1115/high-availability. Defaults to 1.
format: int32
minimum: 0
type: integer
service:
description: Service contains configuration values to modify the LoadBalancer service used to expose the peer relay.
properties:
annotations:
additionalProperties:
type: string
description: |-
Annotations to apply to the LoadBalancer service. Any annotations that conflict with those used by known
cloud providers to ensure IP addresses rather than DNS names are ignored.
type: object
type: object
tags:
description: |-
Tags that the Tailscale node will be tagged with.
Defaults to [tag:k8s].
To autoapprove the device defined by a PeerRelay,
you can configure Tailscale ACLs to give these tags the necessary
permissions.
See https://tailscale.com/kb/1337/acl-syntax#autoapprovers.
If you specify custom tags here, you must also make the operator an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a PeerRelay node has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
items:
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
type: string
type: array
tailnet:
description: |-
Tailnet specifies the tailnet this PeerRelay should join. If blank, the default tailnet is used. When set, this
name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
type: string
x-kubernetes-validations:
- message: PeerRelay tailnet is immutable
rule: self == oldSelf
type: object
x-kubernetes-validations:
- message: spec.aws.elasticIPs must contain at least one entry per replica
rule: '!has(self.aws) || !has(self.aws.elasticIPs) || self.aws.elasticIPs.size() >= self.replicas'
status:
description: |-
Status describes the status of the PeerRelay. This is set
and managed by the Tailscale operator.
properties:
conditions:
items:
description: Condition contains details for one aspect of the current state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
endpoints:
description: |-
Endpoints lists the public address:port pairs each peer relay replica is reachable on. There is one entry
per replica whose LoadBalancer Service has been assigned a public address; entries appear as the underlying
cloud provisions each Service.
items:
properties:
address:
description: |-
Address is the public IP or hostname the cloud has allocated for this replica's LoadBalancer Service.
Peers reach this relay by connecting to Address:Port over UDP.
type: string
port:
description: Port is the UDP port the peer relay listens on.
format: int32
type: integer
replica:
description: Replica is the zero-based index of the peer relay replica this endpoint targets.
format: int32
type: integer
required:
- address
- port
- replica
type: object
type: array
x-kubernetes-list-map-keys:
- replica
x-kubernetes-list-type: map
type: object
required:
- metadata
- spec
type: object
served: true
storage: true
subresources:
status: {}
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
@@ -6151,12 +6416,15 @@ spec:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
properties:
credentials:
description: Denotes the location of the OAuth credentials to use for authenticating with this Tailnet.
description: Denotes the location of the credentials to use for authenticating with this Tailnet.
properties:
secretName:
description: |-
The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and
"client_secret".
The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always
contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate
via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth
client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API
token. "client_secret" and "audience" are mutually exclusive.
type: string
required:
- secretName
@@ -6332,6 +6600,16 @@ rules:
- list
- watch
- update
- apiGroups:
- tailscale.com
resources:
- peerrelays
- peerrelays/status
verbs:
- get
- list
- watch
- update
- apiGroups:
- tailscale.com
resources:
@@ -6409,6 +6687,14 @@ rules:
- patch
- update
- watch
- apiGroups:
- ""
resourceNames:
- operator
resources:
- serviceaccounts/token
verbs:
- create
- apiGroups:
- ""
resources:
@@ -6560,6 +6846,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OPERATOR_SERVICE_ACCOUNT_NAME
valueFrom:
fieldRef:
fieldPath: spec.serviceAccountName
- name: OPERATOR_LOGIN_SERVER
value: null
- name: OPERATOR_INGRESS_CLASS_NAME
@@ -6584,6 +6874,8 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.uid
- name: OPERATOR_SHARED_ACME_ACCOUNT_KEY
value: "false"
image: tailscale/k8s-operator:stable
imagePullPolicy: Always
name: operator
+25 -13
View File
@@ -22,6 +22,7 @@ import (
"k8s.io/utils/net"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/util/mak"
@@ -106,6 +107,7 @@ func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.
if err := dnsRR.maybeProvision(ctx, proxySvc, logger); err != nil {
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
logger.Infof("optimistic lock error, retrying: %s", err)
return reconcile.Result{RequeueAfter: shortRequeue}, nil
} else {
return reconcile.Result{}, err
}
@@ -281,19 +283,26 @@ func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, proxySv
if err := dnsRR.Get(ctx, parentName, ing); err != nil {
return "", err
}
if len(ing.Status.LoadBalancer.Ingress) == 0 {
return "", nil
}
return ing.Status.LoadBalancer.Ingress[0].Hostname, nil
}
if isManagedByType(proxySvc, serviceTypeSvc) {
svc := new(corev1.Service)
if err := dnsRR.Get(ctx, parentName, svc); apierrors.IsNotFound(err) {
logger.Infof("[unexpected] parent Service for egress proxy %s not found", proxySvc.Name)
var svc corev1.Service
err := dnsRR.Get(ctx, parentName, &svc)
switch {
case apierrors.IsNotFound(err):
logger.Warnf("parent Service for egress proxy %q not found", proxySvc.Name)
return "", nil
} else if err != nil {
case err != nil:
return "", err
}
return svc.Annotations[AnnotationTailnetTargetFQDN], nil
}
return "", nil
@@ -303,28 +312,31 @@ func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, proxySv
// ConfigMap. At this point the in-cluster ts.net nameserver is expected to be
// successfully created together with the ConfigMap.
func (dnsRR *dnsRecordsReconciler) updateDNSConfig(ctx context.Context, update func(*operatorutils.Records)) error {
cm := &corev1.ConfigMap{}
err := dnsRR.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, cm)
if apierrors.IsNotFound(err) {
dnsRR.logger.Info("[unexpected] dnsrecords ConfigMap not found in cluster. Not updating DNS records. Please open an issue and attach operator logs.")
var cm corev1.ConfigMap
err := dnsRR.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, &cm)
switch {
case apierrors.IsNotFound(err):
dnsRR.logger.Warn("dnsrecords ConfigMap not found in cluster. Not updating DNS records. Please open an issue and attach operator logs.")
return nil
case err != nil:
return fmt.Errorf("failed to retrieve dnsrecords ConfigMap: %w", err)
}
if err != nil {
return fmt.Errorf("error retrieving dnsrecords ConfigMap: %w", err)
}
dnsRecords := operatorutils.Records{Version: operatorutils.Alpha1Version, IP4: map[string][]string{}}
if cm.Data != nil && cm.Data[operatorutils.DNSRecordsCMKey] != "" {
if err := json.Unmarshal([]byte(cm.Data[operatorutils.DNSRecordsCMKey]), &dnsRecords); err != nil {
if err = json.Unmarshal([]byte(cm.Data[operatorutils.DNSRecordsCMKey]), &dnsRecords); err != nil {
return err
}
}
update(&dnsRecords)
dnsRecordsBs, err := json.Marshal(dnsRecords)
if err != nil {
return fmt.Errorf("error marshalling DNS records: %w", err)
}
mak.Set(&cm.Data, operatorutils.DNSRecordsCMKey, string(dnsRecordsBs))
return dnsRR.Update(ctx, cm)
return dnsRR.Update(ctx, &cm)
}
// isSvcForFQDNEgressProxy returns true if the Service is a headless Service
+85
View File
@@ -8,6 +8,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
@@ -21,6 +22,8 @@ import (
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
@@ -290,6 +293,88 @@ func TestDNSRecordsReconcilerErrorCases(t *testing.T) {
}
}
func TestDNSRecordsReconcilerOptimisticLockError(t *testing.T) {
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
funcs := interceptor.Funcs{
Update: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error {
return errors.New(optimisticLockErrorMsg)
},
}
dnsCfg := &tsapi.DNSConfig{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"},
Spec: tsapi.DNSConfigSpec{Nameserver: &tsapi.Nameserver{}},
}
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{
Type: string(tsapi.NameserverReady),
Status: metav1.ConditionTrue,
})
egressSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "lock-service",
Namespace: "default",
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: "lock-service.example.ts.net",
},
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeExternalName,
ExternalName: "unused",
},
}
proxyGroupEgressSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "ts-proxygroup-egress-abcd1",
Namespace: "tailscale",
Labels: map[string]string{
kubetypes.LabelManaged: "true",
LabelParentName: "lock-service",
LabelParentNamespace: "default",
LabelParentType: "svc",
labelProxyGroup: "test-proxy-group",
labelSvcType: typeEgress,
},
},
}
f := fake.NewClientBuilder().
WithInterceptorFuncs(funcs).
WithScheme(tsapi.GlobalScheme).
WithObjects(dnsCfg, proxyGroupEgressSvc, egressSvc).
WithStatusSubresource(dnsCfg).
Build()
dnsRR := &dnsRecordsReconciler{
Client: f,
tsNamespace: "tailscale",
logger: zl.Sugar(),
}
namespacedName := types.NamespacedName{
Namespace: proxyGroupEgressSvc.GetNamespace(),
Name: proxyGroupEgressSvc.GetName(),
}
res, err := dnsRR.Reconcile(t.Context(), reconcile.Request{
NamespacedName: namespacedName,
})
if err != nil {
t.Errorf("expected requeueAfter in result, got error: %s", err)
}
if res.RequeueAfter == 0 {
t.Errorf("exptected requeueAfter in result to be > 0, got %d", res.RequeueAfter)
}
}
func TestDNSRecordsReconcilerDualStack(t *testing.T) {
// Test dual-stack (IPv4 and IPv6) scenarios
zl, err := zap.NewDevelopment()
+51 -28
View File
@@ -91,9 +91,10 @@ func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Requ
lg.Debugf("No egress config found, likely because ProxyGroup has not been created")
return res, nil
}
cfg, ok := cfgs[tailnetSvc]
if !ok {
lg.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc)
lg.Warnf("configuration for tailnet service %q not found", tailnetSvc)
return res, nil
}
@@ -105,16 +106,19 @@ func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Requ
}
newEndpoints := make([]discoveryv1.Endpoint, 0)
for _, pod := range podList.Items {
ready, err := er.podIsReadyToRouteTraffic(ctx, pod, &cfg, tailnetSvc, lg)
ready, err := er.podIsReadyToRouteTraffic(ctx, pod, &cfg, tailnetSvc, eps.AddressType, lg)
if err != nil {
return res, fmt.Errorf("error verifying if Pod is ready to route traffic: %w", err)
}
if !ready {
continue // maybe next time
}
podIP, err := podIPv4(&pod) // we currently only support IPv4
podIP, err := podIPForFamily(&pod, eps.AddressType)
if err != nil {
return res, fmt.Errorf("error determining IPv4 address for Pod: %w", err)
return res, fmt.Errorf("error determining Pod IP for %s EndpointSlice: %w", eps.AddressType, err)
}
if podIP == "" {
continue // Pod doesn't have an IP for this address family
}
newEndpoints = append(newEndpoints, discoveryv1.Endpoint{
Hostname: (*string)(&pod.UID),
@@ -130,21 +134,25 @@ func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Requ
// run a cleanup for deleted Pods etc.
eps.Endpoints = newEndpoints
if !reflect.DeepEqual(eps, oldEps) {
lg.Infof("Updating EndpointSlice to ensure traffic is routed to ready proxy Pods")
if err := er.Update(ctx, eps); err != nil {
lg.Info("Updating EndpointSlice to ensure traffic is routed to ready proxy Pods")
if err = er.Update(ctx, eps); err != nil {
return res, fmt.Errorf("error updating EndpointSlice: %w", err)
}
}
return res, nil
}
func podIPv4(pod *corev1.Pod) (string, error) {
func podIPForFamily(pod *corev1.Pod, addrType discoveryv1.AddressType) (string, error) {
for _, ip := range pod.Status.PodIPs {
parsed, err := netip.ParseAddr(ip.IP)
if err != nil {
return "", fmt.Errorf("error parsing IP address %s: %w", ip, err)
}
if parsed.Is4() {
switch {
case addrType == discoveryv1.AddressTypeIPv4 && parsed.Is4():
return parsed.String(), nil
case addrType == discoveryv1.AddressTypeIPv6 && parsed.Is6():
return parsed.String(), nil
}
}
@@ -154,61 +162,76 @@ func podIPv4(pod *corev1.Pod) (string, error) {
// podIsReadyToRouteTraffic returns true if it appears that the proxy Pod has configured firewall rules to be able to
// route traffic to the given tailnet service. It retrieves the proxy's state Secret and compares the tailnet service
// status written there to the desired service configuration.
func (er *egressEpsReconciler) podIsReadyToRouteTraffic(ctx context.Context, pod corev1.Pod, cfg *egressservices.Config, tailnetSvcName string, lg *zap.SugaredLogger) (bool, error) {
func (er *egressEpsReconciler) podIsReadyToRouteTraffic(ctx context.Context, pod corev1.Pod, cfg *egressservices.Config, tailnetSvcName string, addrType discoveryv1.AddressType, lg *zap.SugaredLogger) (bool, error) {
lg = lg.With("proxy_pod", pod.Name)
lg.Debugf("checking whether proxy is ready to route to egress service")
lg.Debug("checking whether proxy is ready to route to egress service")
if !pod.DeletionTimestamp.IsZero() {
lg.Debugf("proxy Pod is being deleted, ignore")
lg.Debug("proxy Pod is being deleted, ignore")
return false, nil
}
podIP, err := podIPv4(&pod)
if err != nil {
podIP, err := podIPForFamily(&pod, addrType)
switch {
case err != nil:
return false, fmt.Errorf("error determining Pod IP address: %v", err)
}
if podIP == "" {
lg.Infof("[unexpected] Pod does not have an IPv4 address, and IPv6 is not currently supported")
case podIP == "":
lg.Debugf("Pod does not have an address for family %s", addrType)
return false, nil
}
stateS := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pod.Name,
Namespace: pod.Namespace,
},
}
err = er.Get(ctx, client.ObjectKeyFromObject(stateS), stateS)
if apierrors.IsNotFound(err) {
lg.Debugf("proxy does not have a state Secret, waiting...")
switch {
case apierrors.IsNotFound(err):
lg.Debug("proxy does not yet have a state Secret, waiting...")
return false, nil
case err != nil:
return false, fmt.Errorf("error retrieving state Secret: %w", err)
}
if err != nil {
return false, fmt.Errorf("error getting state Secret: %w", err)
}
svcStatusBS := stateS.Data[egressservices.KeyEgressServices]
if len(svcStatusBS) == 0 {
lg.Debugf("proxy's state Secret does not contain egress services status, waiting...")
lg.Debug("proxy's state Secret does not contain egress services status, waiting...")
return false, nil
}
svcStatus := &egressservices.Status{}
if err := json.Unmarshal(svcStatusBS, svcStatus); err != nil {
if err = json.Unmarshal(svcStatusBS, svcStatus); err != nil {
return false, fmt.Errorf("error unmarshalling egress service status: %w", err)
}
if !strings.EqualFold(podIP, svcStatus.PodIPv4) {
lg.Infof("proxy's egress service status is for Pod IP %s, current proxy's Pod IP %s, waiting for the proxy to reconfigure...", svcStatus.PodIPv4, podIP)
var statusIP string
switch addrType {
case discoveryv1.AddressTypeIPv4:
statusIP = svcStatus.PodIPv4
case discoveryv1.AddressTypeIPv6:
statusIP = svcStatus.PodIPv6
}
if !strings.EqualFold(podIP, statusIP) {
lg.Infof("proxy's egress service status is for Pod IP %q, current proxy's Pod IP %q, waiting for the proxy to reconfigure...", statusIP, podIP)
return false, nil
}
st, ok := (*svcStatus).Services[tailnetSvcName]
st, ok := svcStatus.Services[tailnetSvcName]
if !ok {
lg.Infof("proxy's state Secret does not have egress service status, waiting...")
return false, nil
}
if !reflect.DeepEqual(cfg.TailnetTarget, st.TailnetTarget) {
lg.Infof("proxy has configured egress service for tailnet target %v, current target is %v, waiting for proxy to reconfigure...", st.TailnetTarget, cfg.TailnetTarget)
lg.Infof("proxy has configured egress service for tailnet target %q, current target is %q, waiting for proxy to reconfigure...", st.TailnetTarget, cfg.TailnetTarget)
return false, nil
}
if !reflect.DeepEqual(cfg.Ports, st.Ports) {
lg.Debugf("proxy has configured egress service for ports %#+v, wants ports %#+v, waiting for proxy to reconfigure", st.Ports, cfg.Ports)
return false, nil
}
lg.Debugf("proxy is ready to route traffic to egress service")
lg.Debug("proxy is ready to route traffic to egress service")
return true, nil
}
+117 -5
View File
@@ -98,7 +98,7 @@ func TestTailscaleEgressEndpointSlices(t *testing.T) {
t.Run("pods_are_ready_to_route_traffic", func(t *testing.T) {
pod, stateS := podAndSecretForProxyGroup("foo")
stBs := serviceStatusForPodIP(t, svc, pod.Status.PodIPs[0].IP, port)
stBs := serviceStatusForPodIPs(t, svc, pod.Status.PodIPs[0].IP, "", port)
mustUpdate(t, fc, "operator-ns", stateS.Name, func(s *corev1.Secret) {
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
})
@@ -115,8 +115,8 @@ func TestTailscaleEgressEndpointSlices(t *testing.T) {
expectEqual(t, fc, eps)
})
t.Run("status_does_not_match_pod_ip", func(t *testing.T) {
_, stateS := podAndSecretForProxyGroup("foo") // replica Pod has IP 10.0.0.1
stBs := serviceStatusForPodIP(t, svc, "10.0.0.2", port) // status is for a Pod with IP 10.0.0.2
_, stateS := podAndSecretForProxyGroup("foo") // replica Pod has IP 10.0.0.1
stBs := serviceStatusForPodIPs(t, svc, "10.0.0.2", "", port) // status is for a Pod with IP 10.0.0.2
mustUpdate(t, fc, "operator-ns", stateS.Name, func(s *corev1.Secret) {
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
})
@@ -124,6 +124,117 @@ func TestTailscaleEgressEndpointSlices(t *testing.T) {
eps.Endpoints = []discoveryv1.Endpoint{}
expectEqual(t, fc, eps)
})
// Dual-stack.
epsV6 := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "foo-ipv6",
Namespace: "operator-ns",
Labels: map[string]string{
LabelParentName: "test",
LabelParentNamespace: "default",
labelSvcType: typeEgress,
labelProxyGroup: "foo",
},
},
AddressType: discoveryv1.AddressTypeIPv6,
}
mustCreate(t, fc, epsV6)
t.Run("dual_stack_pod_ready_to_route", func(t *testing.T) {
mustDeleteAll(t, fc, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "foo-0", Namespace: "operator-ns"}})
dualPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "foo-0",
Namespace: "operator-ns",
Labels: pgLabels("foo", nil),
UID: "foo",
},
Status: corev1.PodStatus{
PodIPs: []corev1.PodIP{{IP: "10.0.0.1"}, {IP: "fd00::1"}},
},
}
mustCreate(t, fc, dualPod)
stBs := serviceStatusForPodIPs(t, svc, "10.0.0.1", "fd00::1", port)
mustUpdate(t, fc, "operator-ns", "foo-0", func(s *corev1.Secret) {
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
})
expectReconciled(t, er, "operator-ns", "foo")
eps.Endpoints = []discoveryv1.Endpoint{{
Addresses: []string{"10.0.0.1"},
Hostname: new("foo"),
Conditions: discoveryv1.EndpointConditions{
Serving: new(true),
Ready: new(true),
Terminating: new(false),
},
}}
expectEqual(t, fc, eps)
expectReconciled(t, er, "operator-ns", "foo-ipv6")
epsV6.Endpoints = []discoveryv1.Endpoint{{
Addresses: []string{"fd00::1"},
Hostname: new("foo"),
Conditions: discoveryv1.EndpointConditions{
Serving: new(true),
Ready: new(true),
Terminating: new(false),
},
}}
expectEqual(t, fc, epsV6)
})
// IPv6-only.
t.Run("ipv4_only_pod_skipped_for_ipv6_slice", func(t *testing.T) {
mustDeleteAll(t, fc, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "foo-0", Namespace: "operator-ns"}})
ipv4Pod, _ := podAndSecretForProxyGroup("foo")
mustCreate(t, fc, ipv4Pod)
stBs := serviceStatusForPodIPs(t, svc, "10.0.0.1", "", port)
mustUpdate(t, fc, "operator-ns", "foo-0", func(s *corev1.Secret) {
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
})
expectReconciled(t, er, "operator-ns", "foo-ipv6")
// IPv4-only pod should not appear in the IPv6 EndpointSlice.
epsV6.Endpoints = []discoveryv1.Endpoint{}
expectEqual(t, fc, epsV6)
})
ipv6Pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "foo-0",
Namespace: "operator-ns",
Labels: pgLabels("foo", nil),
UID: "foo",
},
Status: corev1.PodStatus{
PodIPs: []corev1.PodIP{{IP: "fd00::1"}},
},
}
t.Run("ipv6_status_does_not_match_pod_ip", func(t *testing.T) {
mustDeleteAll(t, fc, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "foo-0", Namespace: "operator-ns"}})
mustCreate(t, fc, ipv6Pod)
stBs := serviceStatusForPodIPs(t, svc, "", "fd00::99", port)
mustUpdate(t, fc, "operator-ns", "foo-0", func(s *corev1.Secret) {
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
})
expectReconciled(t, er, "operator-ns", "foo-ipv6")
epsV6.Endpoints = []discoveryv1.Endpoint{}
expectEqual(t, fc, epsV6)
})
t.Run("ipv6_pod_ready_to_route", func(t *testing.T) {
stBs := serviceStatusForPodIPs(t, svc, "", ipv6Pod.Status.PodIPs[0].IP, port)
mustUpdate(t, fc, "operator-ns", "foo-0", func(s *corev1.Secret) {
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
})
expectReconciled(t, er, "operator-ns", "foo-ipv6")
epsV6.Endpoints = append(epsV6.Endpoints, discoveryv1.Endpoint{
Addresses: []string{"fd00::1"},
Hostname: new("foo"),
Conditions: discoveryv1.EndpointConditions{
Serving: new(true),
Ready: new(true),
Terminating: new(false),
},
})
expectEqual(t, fc, epsV6)
})
}
func configMapForSvc(t *testing.T, svc *corev1.Service, p uint16) *corev1.ConfigMap {
@@ -157,7 +268,7 @@ func configMapForSvc(t *testing.T, svc *corev1.Service, p uint16) *corev1.Config
return cm
}
func serviceStatusForPodIP(t *testing.T, svc *corev1.Service, ip string, p uint16) []byte {
func serviceStatusForPodIPs(t *testing.T, svc *corev1.Service, ipv4, ipv6 string, p uint16) []byte {
t.Helper()
ports := make(map[egressservices.PortMap]struct{})
for _, port := range svc.Spec.Ports {
@@ -172,7 +283,8 @@ func serviceStatusForPodIP(t *testing.T, svc *corev1.Service, ip string, p uint1
}
svcName := tailnetSvcName(svc)
st := egressservices.Status{
PodIPv4: ip,
PodIPv4: ipv4,
PodIPv6: ipv6,
Services: map[string]*egressservices.ServiceStatus{svcName: &svcSt},
}
bs, err := json.Marshal(st)
+24 -8
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"net/http"
"net/netip"
"slices"
"strings"
"sync/atomic"
@@ -23,6 +24,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstime"
@@ -87,8 +89,9 @@ func (er *egressPodsReconciler) Reconcile(ctx context.Context, req reconcile.Req
lg.Debugf("Pod is being deleted, do nothing")
return res, nil
}
if pod.Labels[LabelParentType] != proxyTypeProxyGroup {
lg.Infof("[unexpected] reconciler called for a Pod that is not a ProxyGroup Pod")
lg.Warn("reconciler called for a Pod that is not a ProxyGroup Pod")
return res, nil
}
@@ -106,10 +109,12 @@ func (er *egressPodsReconciler) Reconcile(ctx context.Context, req reconcile.Req
if err := er.Get(ctx, types.NamespacedName{Name: proxyGroupName}, pg); err != nil {
return res, fmt.Errorf("error getting ProxyGroup %q: %w", proxyGroupName, err)
}
if pg.Spec.Type != typeEgress {
lg.Infof("[unexpected] reconciler called for %q ProxyGroup Pod", pg.Spec.Type)
lg.Warnf("reconciler called for %q ProxyGroup Pod", pg.Spec.Type)
return res, nil
}
// Get all ClusterIP Services for all egress targets exposed to cluster via this ProxyGroup.
lbls := map[string]string{
kubetypes.LabelManaged: "true",
@@ -223,13 +228,24 @@ func (er *egressPodsReconciler) lookupPodRouteViaSvc(ctx context.Context, pod *c
lg.Debugf("Pod does not have health check enabled, unable to verify if it is currently routable via Service")
return cannotVerify, nil
}
wantsIP, err := podIPv4(pod)
if err != nil {
return -1, fmt.Errorf("error determining Pod's IP address: %w", err)
}
if wantsIP == "" {
// Use the Pod's primary IP (PodIPs[0]) to identify this Pod in the health check
// response. The primary IP family is determined by the cluster's IP family configuration.
// Note: we do not control which IP family the request uses, so on a dual-stack
// cluster either IPv4 or IPv6 could be used. In either case, a matching IP header
// comfirms the request reached this Pod.
if len(pod.Status.PodIPs) == 0 || pod.Status.PodIPs[0].IP == "" {
return podNotReady, nil
}
wantsIP := pod.Status.PodIPs[0].IP
parsed, err := netip.ParseAddr(wantsIP)
if err != nil {
return -1, fmt.Errorf("error parsing Pod IP %q: %w", wantsIP, err)
}
header := kubetypes.PodIPv4Header
if parsed.Is6() {
header = kubetypes.PodIPv6Header
}
ctx, cancel := context.WithTimeout(ctx, time.Second*3)
defer cancel()
@@ -246,7 +262,7 @@ func (er *egressPodsReconciler) lookupPodRouteViaSvc(ctx context.Context, pod *c
return unreachable, nil
}
defer resp.Body.Close()
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
gotIP := resp.Header.Get(header)
if gotIP == "" {
lg.Debugf("Health check does not return Pod's IP header, unable to verify if Pod is currently routable via Service")
return cannotVerify, nil
+51 -1
View File
@@ -420,6 +420,44 @@ func TestEgressPodReadiness(t *testing.T) {
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
t.Run("ipv6_only_pod_already_routed_to", func(t *testing.T) {
pod := podTemplate.DeepCopy()
pod.Status.PodIPs = []corev1.PodIP{{IP: "fd00::2"}}
svc, hep := newSvc("svc", 9002)
mustCreateAll(t, fc, svc, pod)
resp := readyRespsV6("fd00::2", 1)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{hep: resp},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("dual_stack_pod", func(t *testing.T) {
pod := podTemplate.DeepCopy()
pod.Status.PodIPs = []corev1.PodIP{{IP: "10.0.0.2"}, {IP: "fd00::2"}}
svc, hep := newSvc("svc", 9002)
mustCreateAll(t, fc, svc, pod)
// Dual-stack pod: the reconciler uses PodIPs[0] (the primary IP),
// which in this case is IPv4.
resp := readyResps("10.0.0.2", 1)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{hep: resp},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
}
func readyResps(ip string, num int) (resps []fakeResponse) {
@@ -429,6 +467,13 @@ func readyResps(ip string, num int) (resps []fakeResponse) {
return resps
}
func readyRespsV6(ip string, num int) (resps []fakeResponse) {
for range num {
resps = append(resps, fakeResponse{statusCode: 200, podIP: ip, header: kubetypes.PodIPv6Header})
}
return resps
}
func unreadyResps(ip string, num int) (resps []fakeResponse) {
for range num {
resps = append(resps, fakeResponse{statusCode: 503, podIP: ip})
@@ -513,7 +558,11 @@ func (f *fakeHTTPClient) Do(req *http.Request) (*http.Response, error) {
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader([]byte{})),
}
r.Header.Add(kubetypes.PodIPv4Header, resp.podIP)
h := kubetypes.PodIPv4Header
if resp.header != "" {
h = resp.header
}
r.Header.Add(h, resp.podIP)
return &r, nil
}
@@ -521,4 +570,5 @@ type fakeResponse struct {
err error
statusCode int
podIP string // for the Pod IP header
header string // header key to use; defaults to PodIPv4Header
}
+67 -21
View File
@@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"go.uber.org/zap"
@@ -20,9 +21,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstime"
"tailscale.com/util/set"
)
const (
@@ -71,19 +74,57 @@ func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req re
}()
crl := egressSvcChildResourceLabels(svc)
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, esrr.Client, esrr.tsNamespace, crl)
if err != nil {
err = fmt.Errorf("error getting EndpointSlice: %w", err)
epsList := &discoveryv1.EndpointSliceList{}
if err = esrr.List(ctx, epsList, client.InNamespace(esrr.tsNamespace), client.MatchingLabels(crl)); err != nil {
err = fmt.Errorf("error listing EndpointSlices: %w", err)
reason = reasonReadinessCheckFailed
msg = err.Error()
return res, err
}
if eps == nil {
lg.Infof("EndpointSlice for Service does not yet exist, waiting...")
if len(epsList.Items) == 0 {
lg.Infof("EndpointSlices for Service do not yet exist, waiting...")
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
st = metav1.ConditionFalse
return res, nil
}
// If an EndpointSlice for an expected family is missing, we mark the Service as NotReady.
//
// Setting the NotReady condition here is also used for best-effort recovery. The
// egress-svcs-reconciler does not watch EndpointSlices, so a deleted EndpointSlice is only
// recreated when this status change re-triggers a Service reconcile.
//
// TODO(beckypauley): refactor so EndpointSlice recovery is not dependent on Service status.
clusterIPSvc, err := getSingleObject[corev1.Service](ctx, esrr.Client, esrr.tsNamespace, crl)
if err != nil {
err = fmt.Errorf("error retrieving ClusterIP Service: %w", err)
reason = reasonReadinessCheckFailed
msg = err.Error()
return res, err
}
if clusterIPSvc == nil {
lg.Infof("ClusterIP Service for egress Service does not yet exist, waiting...")
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
st = metav1.ConditionFalse
return res, nil
}
gotAddrTypes := make(set.Set[discoveryv1.AddressType], len(epsList.Items))
for _, eps := range epsList.Items {
gotAddrTypes.Add(eps.AddressType)
}
wantAddrTypes, err := addrTypesForClusterIPSvc(clusterIPSvc)
if err != nil {
reason = reasonReadinessCheckFailed
msg = err.Error()
return res, err
}
for _, wantAddrType := range wantAddrTypes {
if !gotAddrTypes.Contains(wantAddrType) {
lg.Infof("EndpointSlice for %s is missing, waiting...", wantAddrType)
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
st = metav1.ConditionFalse
return res, nil
}
}
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: svc.Annotations[AnnotationProxyGroup],
@@ -118,6 +159,7 @@ func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req re
}
podLabels := pgLabels(pg.Name, nil)
var readyReplicas int32
nextReplica:
for i := range replicas {
podLabels[appsv1.PodIndexLabel] = fmt.Sprintf("%d", i)
pod, err := getSingleObject[corev1.Pod](ctx, esrr.Client, esrr.tsNamespace, podLabels)
@@ -127,24 +169,24 @@ func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req re
msg = err.Error()
return res, err
}
if pod == nil {
lg.Warnf("[unexpected] ProxyGroup is ready, but replica %d was not found", i)
lg.Warnf("ProxyGroup is ready, but replica %d was not found", i)
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
return res, nil
}
lg.Debugf("looking at Pod with IPs %v", pod.Status.PodIPs)
ready := false
for _, ep := range eps.Endpoints {
lg.Debugf("looking at endpoint with addresses %v", ep.Addresses)
if endpointReadyForPod(&ep, pod, lg) {
lg.Debugf("endpoint is ready for Pod")
ready = true
break
for _, eps := range epsList.Items {
lg.Debugf("looking at %s EndpointSlice %s", eps.AddressType, eps.Name)
if !slices.ContainsFunc(eps.Endpoints, func(ep discoveryv1.Endpoint) bool {
return endpointReadyForPod(&ep, pod, eps.AddressType, lg)
}) {
continue nextReplica
}
}
if ready {
readyReplicas++
}
lg.Debugf("endpoint is ready for Pod")
readyReplicas++
}
msg = fmt.Sprintf(msgReadyToRouteTemplate, readyReplicas, replicas)
if readyReplicas == 0 {
@@ -161,14 +203,18 @@ func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req re
return res, nil
}
// endpointReadyForPod returns true if the endpoint is for the Pod's IPv4 address and is ready to serve traffic.
// Endpoint must not be nil.
func endpointReadyForPod(ep *discoveryv1.Endpoint, pod *corev1.Pod, lg *zap.SugaredLogger) bool {
podIP, err := podIPv4(pod)
// endpointReadyForPod returns true if the endpoint is for the Pod's address (for the given address family)
// and is ready to serve traffic. Endpoint must not be nil.
func endpointReadyForPod(ep *discoveryv1.Endpoint, pod *corev1.Pod, addrType discoveryv1.AddressType, lg *zap.SugaredLogger) bool {
podIP, err := podIPForFamily(pod, addrType)
if err != nil {
lg.Warnf("[unexpected] error retrieving Pod's IPv4 address: %v", err)
lg.Warnf("error retrieving Pod's %s address: %v", addrType, err)
return false
}
if podIP == "" {
return false
}
// Currently we only ever set a single address on and Endpoint and nothing else is meant to modify this.
if len(ep.Addresses) != 1 {
return false
@@ -47,7 +47,14 @@ func TestEgressServiceReadiness(t *testing.T) {
},
},
}
fakeClusterIPSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "operator-ns"}}
fakeClusterIPSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "operator-ns",
Labels: egressSvcChildResourceLabels(egressSvc),
},
Spec: corev1.ServiceSpec{ClusterIPs: []string{"10.0.0.1"}},
}
labels := egressSvcEpsLabels(egressSvc, fakeClusterIPSvc)
eps := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
@@ -63,6 +70,7 @@ func TestEgressServiceReadiness(t *testing.T) {
},
}
mustCreate(t, fc, egressSvc)
mustCreate(t, fc, fakeClusterIPSvc)
setClusterNotReady(egressSvc, cl, zl.Sugar())
t.Run("endpointslice_does_not_exist", func(t *testing.T) {
expectReconciled(t, rec, "dev", "my-app")
@@ -117,6 +125,212 @@ func TestEgressServiceReadiness(t *testing.T) {
})
}
func TestEgressServiceReadinessDualStack(t *testing.T) {
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithStatusSubresource(&tsapi.ProxyGroup{}).
Build()
zl, _ := zap.NewDevelopment()
cl := tstest.NewClock(tstest.ClockOpts{})
rec := &egressSvcsReadinessReconciler{
tsNamespace: "operator-ns",
Client: fc,
logger: zl.Sugar(),
clock: cl,
}
tailnetFQDN := "my-app.tailnetxyz.ts.net"
egressSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "dev",
Annotations: map[string]string{
AnnotationProxyGroup: "dev",
AnnotationTailnetTargetFQDN: tailnetFQDN,
},
},
}
fakeClusterIPSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "operator-ns",
Labels: egressSvcChildResourceLabels(egressSvc),
},
Spec: corev1.ServiceSpec{ClusterIPs: []string{"10.0.0.1", "fd00::1"}},
}
labels := egressSvcEpsLabels(egressSvc, fakeClusterIPSvc)
epsV4 := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-ipv4",
Namespace: "operator-ns",
Labels: labels,
},
AddressType: discoveryv1.AddressTypeIPv4,
}
labelsV6 := egressSvcEpsLabels(egressSvc, fakeClusterIPSvc)
epsV6 := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-ipv6",
Namespace: "operator-ns",
Labels: labelsV6,
},
AddressType: discoveryv1.AddressTypeIPv6,
}
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "dev",
},
Spec: tsapi.ProxyGroupSpec{
Replicas: new(int32(1)),
Type: tsapi.ProxyGroupTypeEgress,
},
}
mustCreate(t, fc, egressSvc)
mustCreate(t, fc, fakeClusterIPSvc)
mustCreate(t, fc, epsV4)
mustCreate(t, fc, epsV6)
mustCreate(t, fc, pg)
setPGReady(pg, cl, zl.Sugar())
mustUpdateStatus(t, fc, pg.Namespace, pg.Name, func(p *tsapi.ProxyGroup) {
p.Status = pg.Status
})
// Create a dual-stack pod.
p := pod(pg, 0)
p.Status.PodIPs = append(p.Status.PodIPs, corev1.PodIP{IP: "fd00::0"})
mustCreate(t, fc, p)
mustUpdateStatus(t, fc, p.Namespace, p.Name, func(existing *corev1.Pod) {
existing.Status.PodIPs = p.Status.PodIPs
})
t.Run("not_ready_missing_from_ipv6_slice", func(t *testing.T) {
setEndpointForReplicaWithIP("10.0.0.0", epsV4)
mustUpdate(t, fc, epsV4.Namespace, epsV4.Name, func(e *discoveryv1.EndpointSlice) {
e.Endpoints = epsV4.Endpoints
})
expectReconciled(t, rec, "dev", "my-app")
setNotReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg))
expectEqual(t, fc, egressSvc)
})
t.Run("ready_in_both_slices", func(t *testing.T) {
setEndpointForReplicaWithIP("fd00::", epsV6)
mustUpdate(t, fc, epsV6.Namespace, epsV6.Name, func(e *discoveryv1.EndpointSlice) {
e.Endpoints = epsV6.Endpoints
})
expectReconciled(t, rec, "dev", "my-app")
setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), pgReplicas(pg))
expectEqual(t, fc, egressSvc)
})
t.Run("not_ready_when_ipv6_slice_missing", func(t *testing.T) {
// Delete the IPv6 EndpointSlice while the ClusterIP Service still
// wants an IPv6 family; the Service should report NotReady even though
// the IPv4 EndpointSlice is healthy.
if err := fc.Delete(t.Context(), epsV6); err != nil {
t.Fatalf("error deleting IPv6 EndpointSlice: %v", err)
}
expectReconciled(t, rec, "dev", "my-app")
setClusterNotReady(egressSvc, cl, zl.Sugar())
expectEqual(t, fc, egressSvc)
})
}
func TestEgressServiceReadinessIPv6Only(t *testing.T) {
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithStatusSubresource(&tsapi.ProxyGroup{}).
Build()
zl, _ := zap.NewDevelopment()
cl := tstest.NewClock(tstest.ClockOpts{})
rec := &egressSvcsReadinessReconciler{
tsNamespace: "operator-ns",
Client: fc,
logger: zl.Sugar(),
clock: cl,
}
egressSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "dev",
Annotations: map[string]string{
AnnotationProxyGroup: "dev",
AnnotationTailnetTargetFQDN: "my-app.tailnetxyz.ts.net",
},
},
}
fakeClusterIPSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "operator-ns",
Labels: egressSvcChildResourceLabels(egressSvc),
},
Spec: corev1.ServiceSpec{ClusterIPs: []string{"fd00::1"}},
}
labels := egressSvcEpsLabels(egressSvc, fakeClusterIPSvc)
eps := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-ipv6",
Namespace: "operator-ns",
Labels: labels,
},
AddressType: discoveryv1.AddressTypeIPv6,
}
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "dev",
},
}
mustCreate(t, fc, egressSvc)
mustCreate(t, fc, fakeClusterIPSvc)
mustCreate(t, fc, eps)
mustCreate(t, fc, pg)
setPGReady(pg, cl, zl.Sugar())
mustUpdateStatus(t, fc, pg.Namespace, pg.Name, func(p *tsapi.ProxyGroup) {
p.Status = pg.Status
})
// Create IPv6-only pods.
for i := range pgReplicas(pg) {
p := ipv6OnlyPod(pg, i)
mustCreate(t, fc, p)
mustUpdateStatus(t, fc, p.Namespace, p.Name, func(existing *corev1.Pod) {
existing.Status.PodIPs = p.Status.PodIPs
})
}
t.Run("no_ready_replicas", func(t *testing.T) {
expectReconciled(t, rec, "dev", "my-app")
setNotReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg))
expectEqual(t, fc, egressSvc)
})
t.Run("all_replicas_ready", func(t *testing.T) {
for i := range pgReplicas(pg) {
p := ipv6OnlyPod(pg, i)
setEndpointForReplicaWithIP(p.Status.PodIPs[0].IP, eps)
}
mustUpdate(t, fc, eps.Namespace, eps.Name, func(e *discoveryv1.EndpointSlice) {
e.Endpoints = eps.Endpoints
})
setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), pgReplicas(pg))
expectReconciled(t, rec, "dev", "my-app")
expectEqual(t, fc, egressSvc)
})
}
func ipv6OnlyPod(pg *tsapi.ProxyGroup, ordinal int32) *corev1.Pod {
labels := pgLabels(pg.Name, nil)
labels[appsv1.PodIndexLabel] = fmt.Sprintf("%d", ordinal)
ip := fmt.Sprintf("fd00::%d", ordinal+1) // +1 to avoid fd00::0 normalization issues
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-%d", pg.Name, ordinal),
Namespace: "operator-ns",
Labels: labels,
},
Status: corev1.PodStatus{
PodIPs: []corev1.PodIP{{IP: ip}},
},
}
}
func setClusterNotReady(svc *corev1.Service, cl tstime.Clock, lg *zap.SugaredLogger) {
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, metav1.ConditionFalse, reasonClusterResourcesNotReady, reasonClusterResourcesNotReady, cl, lg)
}
@@ -166,3 +380,14 @@ func pod(pg *tsapi.ProxyGroup, ordinal int32) *corev1.Pod {
},
}
}
func setEndpointForReplicaWithIP(ip string, eps *discoveryv1.EndpointSlice) {
eps.Endpoints = append(eps.Endpoints, discoveryv1.Endpoint{
Addresses: []string{ip},
Conditions: discoveryv1.EndpointConditions{
Ready: new(true),
Serving: new(true),
Terminating: new(false),
},
})
}
+59 -23
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"math/rand/v2"
"net/netip"
"reflect"
"slices"
"strings"
@@ -202,6 +203,10 @@ func (esr *egressSvcsReconciler) maybeProvision(ctx context.Context, svc *corev1
return nil
}
if err := esr.ensureEndpointSlices(ctx, svc, clusterIPSvc, lg); err != nil {
return err
}
// Update ExternalName Service to point at the ClusterIP Service.
clusterDomain := retrieveClusterDomain(esr.tsNamespace, lg)
clusterIPSvcFQDN := fmt.Sprintf("%s.%s.svc.%s", clusterIPSvc.Name, clusterIPSvc.Namespace, clusterDomain)
@@ -218,6 +223,60 @@ func (esr *egressSvcsReconciler) maybeProvision(ctx context.Context, svc *corev1
return nil
}
// addrTypesForClusterIPSvc returns the EndpointSlice address types (IP families)
// that the given ClusterIP Service supports, derived from its ClusterIPs.
// TODO(beckypauley): this could read Spec.IPFamilies directly instead of parsing
// ClusterIPs to determine the family.
func addrTypesForClusterIPSvc(clusterIPSvc *corev1.Service) ([]discoveryv1.AddressType, error) {
addrTypes := make([]discoveryv1.AddressType, 0, len(clusterIPSvc.Spec.ClusterIPs))
for _, clusterIP := range clusterIPSvc.Spec.ClusterIPs {
ip, err := netip.ParseAddr(clusterIP)
if err != nil {
return nil, fmt.Errorf("error parsing ClusterIP %q: %w", clusterIP, err)
}
addrType := discoveryv1.AddressTypeIPv4
if ip.Is6() {
addrType = discoveryv1.AddressTypeIPv6
}
addrTypes = append(addrTypes, addrType)
}
return addrTypes, nil
}
// ensureEndpointSlices ensures that EndpointSlices exist for the egress service
// for each IP family supported by the cluster, and that their ports are up to
// date.
func (esr *egressSvcsReconciler) ensureEndpointSlices(ctx context.Context, svc, clusterIPSvc *corev1.Service, lg *zap.SugaredLogger) error {
crl := egressSvcEpsLabels(svc, clusterIPSvc)
// Only create EndpointSlices for IP families supported by the cluster.
addrTypes, err := addrTypesForClusterIPSvc(clusterIPSvc)
if err != nil {
return err
}
for _, addrType := range addrTypes {
eps := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-%s", clusterIPSvc.Name, strings.ToLower(string(addrType))),
Namespace: esr.tsNamespace,
Labels: crl,
},
AddressType: addrType,
Ports: epsPortsFromSvc(clusterIPSvc),
}
if _, err := createOrUpdate(ctx, esr.Client, esr.tsNamespace, eps, func(e *discoveryv1.EndpointSlice) {
e.Labels = eps.Labels
e.AddressType = eps.AddressType
e.Ports = eps.Ports
for _, p := range e.Endpoints {
p.Conditions.Ready = nil
}
}); err != nil {
return fmt.Errorf("error ensuring %s EndpointSlice: %w", addrType, err)
}
}
return nil
}
func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName string, svc, clusterIPSvc *corev1.Service, lg *zap.SugaredLogger) (*corev1.Service, bool, error) {
lg.Infof("updating configuration...")
usedPorts, err := esr.usedPortsForPG(ctx, proxyGroupName)
@@ -316,29 +375,6 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
}
}
crl := egressSvcEpsLabels(svc, clusterIPSvc)
// TODO(irbekrm): support IPv6, but need to investigate how kube proxy
// sets up Service -> Pod routing when IPv6 is involved.
eps := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-ipv4", clusterIPSvc.Name),
Namespace: esr.tsNamespace,
Labels: crl,
},
AddressType: discoveryv1.AddressTypeIPv4,
Ports: epsPortsFromSvc(clusterIPSvc),
}
if eps, err = createOrUpdate(ctx, esr.Client, esr.tsNamespace, eps, func(e *discoveryv1.EndpointSlice) {
e.Labels = eps.Labels
e.AddressType = eps.AddressType
e.Ports = eps.Ports
for _, p := range e.Endpoints {
p.Conditions.Ready = nil
}
}); err != nil {
return nil, false, fmt.Errorf("error ensuring EndpointSlice: %w", err)
}
cm, cfgs, err := egressSvcsConfigs(ctx, esr.Client, proxyGroupName, esr.tsNamespace)
if err != nil {
return nil, false, fmt.Errorf("error retrieving egress services configuration: %w", err)
+172 -5
View File
@@ -21,6 +21,7 @@ import (
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/egressservices"
@@ -50,6 +51,9 @@ func TestTailscaleEgressServices(t *testing.T) {
WithScheme(tsapi.GlobalScheme).
WithObjects(pg, cm).
WithStatusSubresource(pg).
WithInterceptorFuncs(interceptor.Funcs{
Create: clusterIPInterceptor("10.96.0.1"),
}).
Build()
zl, err := zap.NewDevelopment()
if err != nil {
@@ -117,6 +121,23 @@ func TestTailscaleEgressServices(t *testing.T) {
validateReadyService(t, fc, esr, svc, clock, zl, cm)
})
t.Run("endpointslice_deletion_recovery", func(t *testing.T) {
name := findGenNameForEgressSvcResources(t, fc, svc)
epsName := fmt.Sprintf("%s-ipv4", name)
// Delete the EndpointSlice and verify it is recreated.
eps := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: epsName,
Namespace: "operator-ns",
},
}
if err := fc.Delete(t.Context(), eps); err != nil {
t.Fatalf("error deleting EndpointSlice: %v", err)
}
expectMissing[discoveryv1.EndpointSlice](t, fc, "operator-ns", epsName)
validateReadyService(t, fc, esr, svc, clock, zl, cm)
})
t.Run("delete_external_name_service", func(t *testing.T) {
name := findGenNameForEgressSvcResources(t, fc, svc)
if err := fc.Delete(context.Background(), svc); err != nil {
@@ -135,10 +156,10 @@ func validateReadyService(t *testing.T, fc client.WithWatch, esr *egressSvcsReco
expectReconciled(t, esr, "default", "test")
// Verify that a ClusterIP Service has been created.
name := findGenNameForEgressSvcResources(t, fc, svc)
expectEqual(t, fc, clusterIPSvc(name, svc), removeTargetPortsFromSvc)
expectEqual(t, fc, clusterIPSvc(name, svc), removeTargetPortsFromSvc, removeClusterIPsFromSvc)
clusterSvc := mustGetClusterIPSvc(t, fc, name)
// Verify that an EndpointSlice has been created.
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc))
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc, discoveryv1.AddressTypeIPv4))
// Verify that ConfigMap contains configuration for the new egress service.
mustHaveConfigForSvc(t, fc, svc, clusterSvc, cm, zl)
r := svcConfiguredReason(svc, true, zl.Sugar())
@@ -224,18 +245,22 @@ func mustGetClusterIPSvc(t *testing.T, cl client.Client, name string) *corev1.Se
return svc
}
func endpointSlice(name string, extNSvc, clusterIPSvc *corev1.Service) *discoveryv1.EndpointSlice {
func endpointSlice(name string, extNSvc, clusterIPSvc *corev1.Service, addrType discoveryv1.AddressType) *discoveryv1.EndpointSlice {
labels := egressSvcChildResourceLabels(extNSvc)
labels[discoveryv1.LabelManagedBy] = "tailscale.com"
labels[discoveryv1.LabelServiceName] = name
suffix := "ipv4"
if addrType == discoveryv1.AddressTypeIPv6 {
suffix = "ipv6"
}
return &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-ipv4", name),
Name: fmt.Sprintf("%s-%s", name, suffix),
Namespace: "operator-ns",
Labels: labels,
},
Ports: portsForEndpointSlice(clusterIPSvc),
AddressType: discoveryv1.AddressTypeIPv4,
AddressType: addrType,
}
}
@@ -295,3 +320,145 @@ func configFromCM(t *testing.T, cm *corev1.ConfigMap, svcName string) *egressser
}
return nil
}
func TestTailscaleEgressServicesDualStack(t *testing.T) {
pg := &tsapi.ProxyGroup{
TypeMeta: metav1.TypeMeta{Kind: "ProxyGroup", APIVersion: "tailscale.com/v1alpha1"},
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
UID: types.UID("1234-UID"),
},
Spec: tsapi.ProxyGroupSpec{
Replicas: pointer.To[int32](3),
Type: tsapi.ProxyGroupTypeEgress,
},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: pgEgressCMName("foo"),
Namespace: "operator-ns",
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pg, cm).
WithStatusSubresource(pg).
WithInterceptorFuncs(interceptor.Funcs{
Create: clusterIPInterceptor("10.96.0.1", "fd00::1"),
}).
Build()
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
esr := &egressSvcsReconciler{
Client: fc,
logger: zl.Sugar(),
clock: clock,
tsNamespace: "operator-ns",
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: "foo.bar.ts.net.",
AnnotationProxyGroup: "foo",
},
},
Spec: corev1.ServiceSpec{
ExternalName: "placeholder",
Type: corev1.ServiceTypeExternalName,
Selector: nil,
Ports: []corev1.ServicePort{
{
Protocol: "TCP",
Port: 80,
},
},
},
}
t.Run("dual_stack_creates_both_endpoint_slices", func(t *testing.T) {
mustCreate(t, fc, svc)
expectReconciled(t, esr, "default", "test")
validateReadyService(t, fc, esr, svc, clock, zl, cm)
// Also verify the IPv6 EndpointSlice was created.
name := findGenNameForEgressSvcResources(t, fc, svc)
clusterSvc := mustGetClusterIPSvc(t, fc, name)
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc, discoveryv1.AddressTypeIPv6))
})
t.Run("dual_stack_endpointslice_deletion_recovery", func(t *testing.T) {
name := findGenNameForEgressSvcResources(t, fc, svc)
// Delete both IPv4 and IPv6 EndpointSlices.
for _, suffix := range []string{"ipv4", "ipv6"} {
epsName := fmt.Sprintf("%s-%s", name, suffix)
eps := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: epsName,
Namespace: "operator-ns",
},
}
if err := fc.Delete(t.Context(), eps); err != nil {
t.Fatalf("error deleting EndpointSlice %s: %v", epsName, err)
}
expectMissing[discoveryv1.EndpointSlice](t, fc, "operator-ns", epsName)
}
// Reconcile should recreate both.
validateReadyService(t, fc, esr, svc, clock, zl, cm)
clusterSvc := mustGetClusterIPSvc(t, fc, name)
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc, discoveryv1.AddressTypeIPv6))
})
t.Run("dual_stack_single_endpointslice_deletion_recovery", func(t *testing.T) {
name := findGenNameForEgressSvcResources(t, fc, svc)
// Delete only the IPv6 EndpointSlice.
epsName := fmt.Sprintf("%s-ipv6", name)
eps := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: epsName,
Namespace: "operator-ns",
},
}
if err := fc.Delete(t.Context(), eps); err != nil {
t.Fatalf("error deleting EndpointSlice %s: %v", epsName, err)
}
expectMissing[discoveryv1.EndpointSlice](t, fc, "operator-ns", epsName)
// Reconcile should recreate the missing IPv6 EndpointSlice while leaving
// the IPv4 one untouched.
validateReadyService(t, fc, esr, svc, clock, zl, cm)
clusterSvc := mustGetClusterIPSvc(t, fc, name)
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc, discoveryv1.AddressTypeIPv6))
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc, discoveryv1.AddressTypeIPv4))
})
t.Run("delete_dual_stack_service", func(t *testing.T) {
name := findGenNameForEgressSvcResources(t, fc, svc)
if err := fc.Delete(context.Background(), svc); err != nil {
t.Fatalf("error deleting ExternalName Service: %v", err)
}
expectReconciled(t, esr, "default", "test")
expectMissing[corev1.Service](t, fc, "operator-ns", name)
expectMissing[discoveryv1.EndpointSlice](t, fc, "operator-ns", fmt.Sprintf("%s-ipv4", name))
expectMissing[discoveryv1.EndpointSlice](t, fc, "operator-ns", fmt.Sprintf("%s-ipv6", name))
mustNotHaveConfigForSvc(t, fc, svc, cm)
})
}
// clusterIPInterceptor returns an interceptor.Funcs Create function that
// simulates the API server assigning ClusterIPs to ClusterIP Services.
// This is required because the reconciler iterates ClusterIPs to create
// per-family EndpointSlices but the fake client does not assign ClusterIPs.
func clusterIPInterceptor(clusterIPs ...string) func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
return func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
if svc, ok := obj.(*corev1.Service); ok && svc.Spec.Type == corev1.ServiceTypeClusterIP {
svc.Spec.ClusterIPs = clusterIPs
svc.Spec.ClusterIP = clusterIPs[0]
}
return c.Create(ctx, obj, opts...)
}
}
+4
View File
@@ -28,6 +28,7 @@ const (
proxyGroupCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxygroups.yaml"
tailnetCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_tailnets.yaml"
proxyGroupPolicyCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxygrouppolicies.yaml"
peerRelayCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_peerrelays.yaml"
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
@@ -36,6 +37,7 @@ const (
proxyGroupCRDHelmTemplatePath = helmTemplatesPath + "/proxygroup.yaml"
tailnetCRDHelmTemplatePath = helmTemplatesPath + "/tailnet.yaml"
proxyGroupPolicyCRDHelmTemplatePath = helmTemplatesPath + "/proxygrouppolicy.yaml"
peerRelayCRDHelmTemplatePath = helmTemplatesPath + "/peerrelay.yaml"
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
helmConditionalEnd = "{{- end -}}"
@@ -160,6 +162,7 @@ func generate(baseDir string) error {
{proxyGroupCRDPath, proxyGroupCRDHelmTemplatePath},
{tailnetCRDPath, tailnetCRDHelmTemplatePath},
{proxyGroupPolicyCRDPath, proxyGroupPolicyCRDHelmTemplatePath},
{peerRelayCRDPath, peerRelayCRDHelmTemplatePath},
} {
if err := addCRDToHelm(crd.crdPath, crd.templatePath); err != nil {
return fmt.Errorf("error adding %s CRD to Helm templates: %w", crd.crdPath, err)
@@ -178,6 +181,7 @@ func cleanup(baseDir string) error {
proxyGroupCRDHelmTemplatePath,
tailnetCRDHelmTemplatePath,
proxyGroupPolicyCRDHelmTemplatePath,
peerRelayCRDHelmTemplatePath,
} {
if err := os.Remove(filepath.Join(baseDir, path)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up %s: %w", path, err)
+93 -67
View File
@@ -173,14 +173,14 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
logger.Infof("error validating tailscale IngressClass: %v.", err)
return false, nil
}
// Get and validate ProxyGroup readiness
// We only act on services that are annotated as using a proxy group.
pgName := ing.Annotations[AnnotationProxyGroup]
if pgName == "" {
logger.Infof("[unexpected] no ProxyGroup annotation, skipping Tailscale Service provisioning")
return false, nil
}
logger = logger.With("ProxyGroup", pgName)
logger = logger.With("ProxyGroup", pgName)
if !tsoperator.ProxyGroupAvailable(pg) {
logger.Infof("ProxyGroup is not (yet) ready")
return false, nil
@@ -455,8 +455,10 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger
if err := r.List(ctx, ingList); err != nil {
return false, fmt.Errorf("listing Ingresses: %w", err)
}
serveConfigChanged := false
// For each Tailscale Service in serve config...
// Collect orphans first so we are not mutating cfg.Services during
// iteration.
var orphans []tailcfg.ServiceName
for tsSvcName := range cfg.Services {
// ...check if there is currently an Ingress with this hostname
found := false
@@ -469,40 +471,23 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger
}
if !found {
logger.Infof("Tailscale Service %q is not owned by any Ingress, cleaning up", tsSvcName)
tsService, err := tsClient.VIPServices().Get(ctx, tsSvcName.String())
switch {
case tailscale.IsNotFound(err):
return false, nil
case err != nil:
return false, fmt.Errorf("getting Tailscale Service %q: %w", tsSvcName, err)
}
// Delete the Tailscale Service from control if necessary.
svcsChanged, err = r.cleanupTailscaleService(ctx, tsService, logger, tsClient)
if err != nil {
return false, fmt.Errorf("deleting Tailscale Service %q: %w", tsSvcName, err)
}
// Make sure the Tailscale Service is not advertised in tailscaled or serve config.
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, tsSvcName, serviceAdvertisementOff, pg); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
_, ok := cfg.Services[tsSvcName]
if ok {
logger.Infof("Removing Tailscale Service %q from serve config", tsSvcName)
delete(cfg.Services, tsSvcName)
serveConfigChanged = true
}
if err = cleanupCertResources(ctx, r.Client, r.tsNamespace, tsSvcName, pg); err != nil {
return false, fmt.Errorf("failed to clean up cert resources: %w", err)
}
orphans = append(orphans, tsSvcName)
}
}
if serveConfigChanged {
// 1. Remove all orphans from serve config in a single ConfigMap Update
// so the proxy cancels every cert loop before we start deleting
// VIPServices, and we only pay one fsnotify propagation window.
updated := false
for _, tsSvcName := range orphans {
logger.Infof("Tailscale Service %q is not owned by any Ingress, cleaning up", tsSvcName)
_, ok := cfg.Services[tsSvcName]
if ok {
delete(cfg.Services, tsSvcName)
updated = true
}
}
if updated {
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return false, fmt.Errorf("marshaling serve config: %w", err)
@@ -511,7 +496,37 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger
if err := r.Update(ctx, cm); err != nil {
return false, fmt.Errorf("updating serve config: %w", err)
}
logger.Infof("Removed Tailscale Services from serve config: %v", orphans)
}
for _, tsSvcName := range orphans {
// 2. Unadvertise the Tailscale Service in tailscaled config.
if err := r.maybeUpdateAdvertiseServicesConfig(ctx, tsSvcName, serviceAdvertisementOff, pg); err != nil {
return svcsChanged, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
// 3. Delete the Tailscale Service from the control plane.
tsService, err := tsClient.VIPServices().Get(ctx, tsSvcName.String())
switch {
case tailscale.IsNotFound(err):
// Already gone at the control plane; continue with cluster
// cleanup rather than aborting the sweep.
case err != nil:
return svcsChanged, fmt.Errorf("getting Tailscale Service %q: %w", tsSvcName, err)
default:
updated, err := r.cleanupTailscaleService(ctx, tsService, logger, tsClient)
if err != nil {
return svcsChanged, fmt.Errorf("deleting Tailscale Service %q: %w", tsSvcName, err)
}
svcsChanged = svcsChanged || updated
}
// 4. Clean up cluster cert resources.
if err := cleanupCertResources(ctx, r.Client, r.tsNamespace, tsSvcName, pg); err != nil {
return svcsChanged, fmt.Errorf("failed to clean up cert resources: %w", err)
}
}
return svcsChanged, nil
}
@@ -519,6 +534,10 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger
// Ingress is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup- the Tailscale Service is only
// deleted if it does not contain any other owner references. If it does the cleanup only removes the owner reference
// corresponding to this Ingress.
//
// Steps are ordered so the proxy cancels its cert loop (via serve config
// removal) before the VIPService is deleted; otherwise the loop retries
// against a domain the control plane no longer recognises.
func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger, tsClient tsclient.Client, pg *tsapi.ProxyGroup) (svcChanged bool, err error) {
logger.Debugf("Ensuring any resources for Ingress are cleaned up")
ix := slices.Index(ing.Finalizers, FinalizerNamePG)
@@ -543,49 +562,53 @@ func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string,
err = r.deleteFinalizer(ctx, ing, logger)
}()
// 1. Check if there is a Tailscale Service associated with this Ingress.
cm, cfg, err := r.proxyGroupServeConfig(ctx, pg.Name)
if err != nil {
return false, fmt.Errorf("error getting ProxyGroup serve config: %w", err)
}
// Tailscale Service is always first added to serve config and only then created in the Tailscale API, so if it is not
// found in the serve config, we can assume that there is no Tailscale Service. (If the serve config does not exist at
// all, it is possible that the ProxyGroup has been deleted before cleaning up the Ingress, so carry on with
// cleanup).
if cfg != nil && cfg.Services != nil && cfg.Services[serviceName] == nil {
return false, nil
// 1. Remove the Tailscale Service from the proxy's serve config. The proxy
// picks up the change via fsnotify on the mounted ConfigMap and cancels
// its cert loop for this domain before we proceed to delete the
// VIPService.
if cfg != nil && cfg.Services != nil {
if _, ok := cfg.Services[serviceName]; ok {
logger.Infof("Removing TailscaleService %q from serve config for ProxyGroup %q", hostname, pg.Name)
delete(cfg.Services, serviceName)
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return false, fmt.Errorf("error marshaling serve config: %w", err)
}
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
if err := r.Update(ctx, cm); err != nil {
return false, fmt.Errorf("error updating serve config: %w", err)
}
}
}
// 2. Clean up the Tailscale Service resources.
// 2. Unadvertise the Tailscale Service in each proxy's tailscaled config.
// Skipped if the ProxyGroup itself has been deleted (no config Secrets to
// update).
if cfg != nil {
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, serviceName, serviceAdvertisementOff, pg); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
}
// 3. Delete the Tailscale Service from the control plane. By now the
// proxy has stopped serving HTTPS for the domain and stopped trying to
// renew its cert.
svcChanged, err = r.cleanupTailscaleService(ctx, svc, logger, tsClient)
if err != nil {
return false, fmt.Errorf("error deleting Tailscale Service: %w", err)
}
// 3. Clean up any cluster resources
// 4. Clean up cluster cert resources (TLS Secret + RBAC).
if err = cleanupCertResources(ctx, r.Client, r.tsNamespace, serviceName, pg); err != nil {
return false, fmt.Errorf("failed to clean up cert resources: %w", err)
}
if cfg == nil || cfg.Services == nil { // user probably deleted the ProxyGroup
return svcChanged, nil
}
// 4. Unadvertise the Tailscale Service in tailscaled config.
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, serviceName, serviceAdvertisementOff, pg); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
// 5. Remove the Tailscale Service from the serve config for the ProxyGroup.
logger.Infof("Removing TailscaleService %q from serve config for ProxyGroup %q", hostname, pg.Name)
delete(cfg.Services, serviceName)
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return false, fmt.Errorf("error marshaling serve config: %w", err)
}
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
return svcChanged, r.Update(ctx, cm)
return svcChanged, nil
}
func (r *HAIngressReconciler) deleteFinalizer(ctx context.Context, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
@@ -685,9 +708,10 @@ func (r *HAIngressReconciler) validateIngress(ctx context.Context, ing *networki
// It is invalid to have multiple Ingress resources for the same Tailscale Service in one cluster.
ingList := &networkingv1.IngressList{}
if err := r.List(ctx, ingList); err != nil {
errs = append(errs, fmt.Errorf("[unexpected] error listing Ingresses: %w", err))
errs = append(errs, fmt.Errorf("failed to list ingresses: %w", err))
return errors.Join(errs...)
}
for _, i := range ingList.Items {
if r.shouldExpose(&i) && hostnameForIngress(&i) == hostname && i.UID != ing.UID {
errs = append(errs, fmt.Errorf("found duplicate Ingress %q for hostname %q - multiple Ingresses for the same hostname in the same cluster are not allowed", client.ObjectKeyFromObject(&i), hostname))
@@ -876,14 +900,16 @@ func ownerAnnotations(operatorID string, svc *tailscale.VIPService) (map[string]
}
if svc == nil {
c := ownerAnnotationValue{OwnerRefs: []OwnerRef{ref}}
json, err := json.Marshal(c)
data, err := json.Marshal(c)
if err != nil {
return nil, fmt.Errorf("[unexpected] unable to marshal Tailscale Service's owner annotation contents: %w, please report this", err)
return nil, fmt.Errorf("failed to marshal Tailscale Service's owner annotation contents: %w", err)
}
return map[string]string{
ownerAnnotation: string(json),
ownerAnnotation: string(data),
}, nil
}
o, err := parseOwnerAnnotation(svc)
if err != nil {
return nil, err
+2 -1
View File
@@ -8,6 +8,7 @@ package main
import (
"context"
"fmt"
"net"
"slices"
"strings"
"sync"
@@ -364,7 +365,7 @@ func handlersForIngress(ctx context.Context, ing *networkingv1.Ingress, cl clien
proto = "https+insecure://"
}
mak.Set(&handlers, path, &ipn.HTTPHandler{
Proxy: proto + svc.Spec.ClusterIP + ":" + fmt.Sprint(port) + path,
Proxy: proto + net.JoinHostPort(svc.Spec.ClusterIP, fmt.Sprint(port)) + path,
})
}
addIngressBackend(ing.Spec.DefaultBackend, "/")
+88
View File
@@ -942,3 +942,91 @@ func TestTailscaleIngressWithHTTPRedirect(t *testing.T) {
t.Errorf("incorrect status ports after removing redirect: got %v, want %v", ing.Status.LoadBalancer.Ingress[0].Ports, wantPorts)
}
}
func TestTailscaleIngressIPv6(t *testing.T) {
fc := fake.NewFakeClient(ingressClass())
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
// Create a Service with an IPv6 ClusterIP
ipv6Svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ipv6",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "fda9:e575:6e22:2::25",
Ports: []corev1.ServicePort{
{
Port: 2283,
Name: "http",
},
},
},
}
mustCreate(t, fc, ipv6Svc)
// Create an Ingress that routes to the IPv6 service
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test-ipv6",
Namespace: "default",
UID: "1234-UID-IPV6",
},
Spec: networkingv1.IngressSpec{
IngressClassName: new("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test-ipv6",
Port: networkingv1.ServiceBackendPort{
Number: 2283,
},
},
},
},
}
mustCreate(t, fc, ing)
ingR := &IngressReconciler{
Client: fc,
ingressClassName: "tailscale",
ssr: &tailscaleSTSReconciler{
Client: fc,
clients: tsclient.NewProvider(&fakeTSClient{}),
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
defaultTags: []string{"tag:test"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
expectReconciled(t, ingR, "default", "test-ipv6")
// Verify the generated serveConfig has properly bracketed IPv6 address
fullName, _ := findGenName(t, fc, "default", "test-ipv6", "ingress")
opts := configOpts{
replicas: new(int32(1)),
stsName: "tailscale-ipv6-ingress-test-ipv6",
secretName: fullName,
namespace: "default",
parentType: "ingress",
hostname: "default-test-ipv6-ingress",
app: kubetypes.AppIngressResource,
serveConfig: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://[fda9:e575:6e22:2::25]:2283/"},
}},
},
},
}
// expectedSecret hardcodes the parent-resource label to "test", so fix it for our IPv6 test
secret := expectedSecret(t, fc, opts)
secret.Labels[LabelParentName] = "test-ipv6"
expectEqual(t, fc, secret)
}
+1 -1
View File
@@ -55,7 +55,7 @@ type ServiceMonitorSpec struct {
JobLabel string `json:"jobLabel"`
// NamespaceSelector selects the namespace of Service(s) that this ServiceMonitor allows to scrape.
// https://github.com/prometheus-operator/prometheus-operator/blob/bb4514e0d5d69f20270e29cfd4ad39b87865ccdf/pkg/apis/monitoring/v1/servicemonitor_types.go#L88
NamespaceSelector ServiceMonitorNamespaceSelector `json:"namespaceSelector,omitempty"`
NamespaceSelector ServiceMonitorNamespaceSelector `json:"namespaceSelector"`
// Selector is the label selector for Service(s) that this ServiceMonitor allows to scrape.
// https://github.com/prometheus-operator/prometheus-operator/blob/bb4514e0d5d69f20270e29cfd4ad39b87865ccdf/pkg/apis/monitoring/v1/servicemonitor_types.go#L85
Selector metav1.LabelSelector `json:"selector"`
+84 -12
View File
@@ -55,6 +55,7 @@ import (
"tailscale.com/ipn/store/kubestore"
apiproxy "tailscale.com/k8s-operator/api-proxy"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/k8s-operator/reconciler/peerrelay"
"tailscale.com/k8s-operator/reconciler/proxygrouppolicy"
"tailscale.com/k8s-operator/reconciler/tailnet"
"tailscale.com/k8s-operator/tsclient"
@@ -95,8 +96,10 @@ func main() {
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
defaultProxyClass = defaultEnv("PROXY_DEFAULT_CLASS", "")
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
sharedACMEAccountKey = defaultBool("OPERATOR_SHARED_ACME_ACCOUNT_KEY", false)
loginServer = strings.TrimSuffix(defaultEnv("OPERATOR_LOGIN_SERVER", ""), "/")
ingressClassName = defaultEnv("OPERATOR_INGRESS_CLASS_NAME", "tailscale")
operatorSAName = defaultEnv("OPERATOR_SERVICE_ACCOUNT_NAME", "operator")
)
var opts []kzap.Opts
@@ -157,6 +160,7 @@ func main() {
tsServer: s,
tsClient: tsc,
tailscaleNamespace: tsNamespace,
operatorSAName: operatorSAName,
restConfig: restConfig,
proxyImage: image,
k8sProxyImage: k8sProxyImage,
@@ -167,6 +171,7 @@ func main() {
defaultProxyClass: defaultProxyClass,
loginServer: loginServer,
ingressClassName: ingressClassName,
sharedACMEAccountKey: sharedACMEAccountKey,
})
}
@@ -349,6 +354,7 @@ func runReconcilers(opts reconcilerOpts) {
tailnetOptions := tailnet.ReconcilerOptions{
Client: mgr.GetClient(),
TailscaleNamespace: opts.tailscaleNamespace,
OperatorSAName: opts.operatorSAName,
Clock: tstime.DefaultClock{},
Logger: opts.log,
Registry: clients,
@@ -366,6 +372,19 @@ func runReconcilers(opts reconcilerOpts) {
startlog.Fatalf("could not register proxygrouppolicy reconciler: %v", err)
}
peerRelayOptions := peerrelay.ReconcilerOptions{
Client: mgr.GetClient(),
TailscaleNamespace: opts.tailscaleNamespace,
ProxyImage: opts.proxyImage,
DefaultTags: strings.Split(opts.proxyTags, ","),
Clients: clients,
Logger: opts.log,
}
if err = peerrelay.NewReconciler(peerRelayOptions).Register(mgr); err != nil {
startlog.Fatalf("could not register peerrelay reconciler: %v", err)
}
svcFilter := handler.EnqueueRequestsFromMapFunc(serviceHandler)
svcChildFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("svc"))
// If a ProxyClass changes, enqueue all Services labeled with that
@@ -735,6 +754,7 @@ func runReconcilers(opts reconcilerOpts) {
proxyClassFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForProxyGroup(mgr.GetClient(), startlog))
nodeFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(nodeHandlerForProxyGroup(mgr.GetClient(), opts.defaultProxyClass, startlog))
saFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(serviceAccountHandlerForProxyGroup(mgr.GetClient(), startlog))
acmeSecretFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(acmeAccountsSecretHandlerForProxyGroup(mgr.GetClient(), opts.tailscaleNamespace, opts.sharedACMEAccountKey, startlog))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.ProxyGroup{}).
Named("proxygroup-reconciler").
@@ -743,6 +763,9 @@ func runReconcilers(opts reconcilerOpts) {
Watches(&corev1.ConfigMap{}, ownedByProxyGroupFilter).
Watches(&corev1.ServiceAccount{}, saFilterForProxyGroup).
Watches(&corev1.Secret{}, ownedByProxyGroupFilter).
// The shared ACME accounts Secret has no ProxyGroup owner ref, so
// watch it by name to react to its deletion/recreation.
Watches(&corev1.Secret{}, acmeSecretFilterForProxyGroup).
Watches(&rbacv1.Role{}, ownedByProxyGroupFilter).
Watches(&rbacv1.RoleBinding{}, ownedByProxyGroupFilter).
Watches(&tsapi.ProxyClass{}, proxyClassFilterForProxyGroup).
@@ -763,6 +786,8 @@ func runReconcilers(opts reconcilerOpts) {
loginServer: opts.tsServer.ControlURL,
authKeyRateLimits: make(map[string]*rate.Limiter),
authKeyReissuing: make(map[string]bool),
sharedACMEAccountKey: opts.sharedACMEAccountKey,
})
if err != nil {
startlog.Fatalf("could not create ProxyGroup reconciler: %v", err)
@@ -818,6 +843,17 @@ type reconcilerOpts struct {
// ingressClassName is the name of the ingress class used by reconcilers of Ingress resources. This defaults
// to "tailscale" but can be customised.
ingressClassName string
// sharedACMEAccountKey is the operator-wide default for the
// shared-ACME-account feature. When true, every ProxyGroup uses the
// shared per-tailnet account key unless the ProxyGroup explicitly
// opts out via tailscale.com/share-acme-account=false. When false,
// ProxyGroups opt in individually via
// tailscale.com/share-acme-account=true.
sharedACMEAccountKey bool
// operatorSAName is the name of the ServiceAccount that the operator pod runs as. It is used as the target
// ServiceAccount when minting tokens via the Kubernetes TokenRequest API for Tailnets that authenticate using
// workload identity federation.
operatorSAName string
}
// enqueueAllIngressEgressProxySvcsinNS returns a reconcile request for each
@@ -1209,6 +1245,30 @@ func serviceAccountHandlerForProxyGroup(cl client.Client, logger *zap.SugaredLog
}
}
// acmeAccountsSecretHandlerForProxyGroup enqueues ProxyGroups that use the
// shared ACME account when the shared ACME accounts Secret changes. The
// Secret carries no owner reference, so the owner-based Secret watch never
// matches it.
func acmeAccountsSecretHandlerForProxyGroup(cl client.Client, tsNamespace string, sharedACMEAccountDefault bool, logger *zap.SugaredLogger) handler.MapFunc {
return func(ctx context.Context, o client.Object) []reconcile.Request {
if o.GetName() != kubetypes.ACMEAccountsSecretName || o.GetNamespace() != tsNamespace {
return nil
}
pgList := new(tsapi.ProxyGroupList)
if err := cl.List(ctx, pgList); err != nil {
logger.Debugf("error listing ProxyGroups for shared ACME accounts Secret: %v", err)
return nil
}
reqs := make([]reconcile.Request, 0, len(pgList.Items))
for _, pg := range pgList.Items {
if sharedACMEAccountEnabled(&pg, sharedACMEAccountDefault) {
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&pg)})
}
}
return reqs
}
}
// serviceHandlerForIngress returns a handler for Service events for ingress
// reconciler that ensures that if the Service associated with an event is of
// interest to the reconciler, the associated Ingress(es) gets be reconciled.
@@ -1470,9 +1530,10 @@ func HAIngressesFromSecret(cl client.Client, logger *zap.SugaredLogger) handler.
return func(ctx context.Context, o client.Object) []reconcile.Request {
secret, ok := o.(*corev1.Secret)
if !ok {
logger.Infof("[unexpected] Secret handler triggered for an object that is not a Secret")
logger.Warn("Secret handler triggered for an object that is not a Secret")
return nil
}
if isTLSSecret(secret) {
return []reconcile.Request{
{
@@ -1509,15 +1570,16 @@ func HAIngressesFromSecret(cl client.Client, logger *zap.SugaredLogger) handler.
}
}
// HAServiceFromSecret returns a handler that returns reconcile requests for
// HAServicesFromSecret returns a handler that returns reconcile requests for
// all HA Services that should be reconciled in response to a Secret event.
func HAServicesFromSecret(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
return func(ctx context.Context, o client.Object) []reconcile.Request {
secret, ok := o.(*corev1.Secret)
if !ok {
logger.Infof("[unexpected] Secret handler triggered for an object that is not a Secret")
logger.Warn("Secret handler triggered for an object that is not a Secret")
return nil
}
if !isPGStateSecret(secret) {
return nil
}
@@ -1549,9 +1611,10 @@ func kubeAPIServerPGsFromSecret(cl client.Client, logger *zap.SugaredLogger) han
return func(ctx context.Context, o client.Object) []reconcile.Request {
secret, ok := o.(*corev1.Secret)
if !ok {
logger.Infof("[unexpected] Secret handler triggered for an object that is not a Secret")
logger.Warn("Secret handler triggered for an object that is not a Secret")
return nil
}
if secret.ObjectMeta.Labels[kubetypes.LabelManaged] != "true" ||
secret.ObjectMeta.Labels[LabelParentType] != "proxygroup" {
return nil
@@ -1587,9 +1650,10 @@ func egressSvcsFromEgressProxyGroup(cl client.Client, logger *zap.SugaredLogger)
return func(ctx context.Context, o client.Object) []reconcile.Request {
pg, ok := o.(*tsapi.ProxyGroup)
if !ok {
logger.Infof("[unexpected] ProxyGroup handler triggered for an object that is not a ProxyGroup")
logger.Warn("ProxyGroup handler triggered for an object that is not a ProxyGroup")
return nil
}
if pg.Spec.Type != tsapi.ProxyGroupTypeEgress {
return nil
}
@@ -1617,9 +1681,10 @@ func ingressesFromIngressProxyGroup(cl client.Client, logger *zap.SugaredLogger)
return func(ctx context.Context, o client.Object) []reconcile.Request {
pg, ok := o.(*tsapi.ProxyGroup)
if !ok {
logger.Infof("[unexpected] ProxyGroup handler triggered for an object that is not a ProxyGroup")
logger.Warn("ProxyGroup handler triggered for an object that is not a ProxyGroup")
return nil
}
if pg.Spec.Type != tsapi.ProxyGroupTypeIngress {
return nil
}
@@ -1647,9 +1712,10 @@ func epsFromExternalNameService(cl client.Client, logger *zap.SugaredLogger, ns
return func(ctx context.Context, o client.Object) []reconcile.Request {
svc, ok := o.(*corev1.Service)
if !ok {
logger.Infof("[unexpected] Service handler triggered for an object that is not a Service")
logger.Warn("Service handler triggered for an object that is not a Service")
return nil
}
if !isEgressSvcForProxyGroup(svc) {
return nil
}
@@ -1676,9 +1742,10 @@ func podsFromEgressEps(cl client.Client, logger *zap.SugaredLogger, ns string) h
return func(ctx context.Context, o client.Object) []reconcile.Request {
eps, ok := o.(*discoveryv1.EndpointSlice)
if !ok {
logger.Infof("[unexpected] EndpointSlice handler triggered for an object that is not a EndpointSlice")
logger.Warn("EndpointSlice handler triggered for an object that is not a EndpointSlice")
return nil
}
if eps.Labels[labelProxyGroup] == "" {
return nil
}
@@ -1715,18 +1782,21 @@ func proxyClassesWithServiceMonitor(cl client.Client, logger *zap.SugaredLogger)
return func(ctx context.Context, o client.Object) []reconcile.Request {
crd, ok := o.(*apiextensionsv1.CustomResourceDefinition)
if !ok {
logger.Debugf("[unexpected] ServiceMonitor CRD handler received an object that is not a CustomResourceDefinition")
logger.Warn("ServiceMonitor CRD handler received an object that is not a CustomResourceDefinition")
return nil
}
if crd.Name != serviceMonitorCRD {
logger.Debugf("[unexpected] ServiceMonitor CRD handler received an unexpected CRD %q", crd.Name)
logger.Warnf("ServiceMonitor CRD handler received an unexpected CRD %q", crd.Name)
return nil
}
pcl := &tsapi.ProxyClassList{}
if err := cl.List(ctx, pcl); err != nil {
logger.Debugf("[unexpected] error listing ProxyClasses: %v", err)
logger.Errorf("failed to list ProxyClass resources: %v", err)
return nil
}
reqs := make([]reconcile.Request, 0)
for _, pc := range pcl.Items {
if pc.Spec.Metrics != nil && pc.Spec.Metrics.ServiceMonitor != nil && pc.Spec.Metrics.ServiceMonitor.Enable {
@@ -1735,6 +1805,7 @@ func proxyClassesWithServiceMonitor(cl client.Client, logger *zap.SugaredLogger)
})
}
}
return reqs
}
}
@@ -1744,9 +1815,10 @@ func crdTransformer(log *zap.SugaredLogger) toolscache.TransformFunc {
return func(o any) (any, error) {
crd, ok := o.(*apiextensionsv1.CustomResourceDefinition)
if !ok {
log.Infof("[unexpected] CRD transformer called for a non-CRD type")
log.Warn("CRD transformer called for a non-CRD type")
return crd, nil
}
crd.Spec = apiextensionsv1.CustomResourceDefinitionSpec{}
return crd, nil
}
+3 -1
View File
@@ -26,6 +26,7 @@ import (
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstime"
@@ -170,10 +171,11 @@ func (pcr *ProxyClassReconciler) validate(ctx context.Context, pc *tsapi.ProxyCl
}
}
}
if pc.Spec.Metrics != nil && pc.Spec.Metrics.ServiceMonitor != nil && pc.Spec.Metrics.ServiceMonitor.Enable {
found, err := hasServiceMonitorCRD(ctx, pcr.Client)
if err != nil {
pcr.logger.Infof("[unexpected]: error retrieving %q CRD: %v", serviceMonitorCRD, err)
pcr.logger.Errorf("error retrieving %q CRD: %v", serviceMonitorCRD, err)
// best effort validation - don't error out here
} else if !found {
msg := fmt.Sprintf("ProxyClass defines that a ServiceMonitor custom resource should be created, but %q CRD was not found", serviceMonitorCRD)
+79 -2
View File
@@ -56,6 +56,7 @@ const (
reasonProxyGroupCreating = "ProxyGroupCreating"
reasonProxyGroupInvalid = "ProxyGroupInvalid"
reasonProxyGroupTailnetUnavailable = "ProxyGroupTailnetUnavailable"
reasonACMEAccountsPendingDeletion = "ACMEAccountsPendingDeletion"
// Copied from k8s.io/apiserver/pkg/registry/generic/registry/store.go@cccad306d649184bf2a0e319ba830c53f65c445c
optimisticLockErrorMsg = "the object has been modified; please apply your changes to the latest version and try again"
@@ -102,6 +103,14 @@ type ProxyGroupReconciler struct {
apiServerProxyGroups set.Slice[types.UID] // for kube-apiserver proxygroups gauge
authKeyRateLimits map[string]*rate.Limiter // per-ProxyGroup rate limiters for auth key re-issuance.
authKeyReissuing map[string]bool
// sharedACMEAccountKey is the operator-wide default for the
// shared-ACME-account feature. When true, every ProxyGroup uses the
// shared per-tailnet account key unless the ProxyGroup explicitly
// opts out via tailscale.com/share-acme-account=false. When false,
// only ProxyGroups annotated with tailscale.com/share-acme-account=true
// use it.
sharedACMEAccountKey bool
}
func (r *ProxyGroupReconciler) logger(name string) *zap.SugaredLogger {
@@ -354,7 +363,7 @@ func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, tsClient tscl
}
}
role := pgRole(pg, r.tsNamespace)
role := pgRole(pg, r.tsNamespace, r.sharedACMEAccountEnabledFor(pg))
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, func(r *rbacv1.Role) {
r.ObjectMeta.Labels = role.ObjectMeta.Labels
r.ObjectMeta.Annotations = role.ObjectMeta.Annotations
@@ -394,13 +403,36 @@ func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, tsClient tscl
}); err != nil {
return r.notReadyErrf(pg, logger, "error provisioning ingress ConfigMap %q: %w", cm.Name, err)
}
// Ensure the shared ACME accounts Secret exists (with finalizer)
// when this ProxyGroup opts into the feature. Proxy pods
// populate its fields on first cert issuance. See #18251.
if r.sharedACMEAccountEnabledFor(pg) {
acmeSecret := pgACMEAccountSecret(r.tsNamespace)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, acmeSecret, func(existing *corev1.Secret) {
if !existing.DeletionTimestamp.IsZero() {
// Deletion can't be undone; warn so the account keys
// get backed up before the finalizer is removed.
msg := fmt.Sprintf("shared ACME accounts Secret %q is marked for deletion but retained by the %q finalizer. Its data remains readable until the finalizer is removed - back it up first to preserve the ACME account keys.", existing.Name, kubetypes.ACMEAccountsFinalizer)
r.recorder.Event(existing, corev1.EventTypeWarning, reasonACMEAccountsPendingDeletion, msg)
logger.Warn(msg)
return
}
existing.Labels = acmeSecret.Labels
if !slices.Contains(existing.Finalizers, kubetypes.ACMEAccountsFinalizer) {
existing.Finalizers = append(existing.Finalizers, kubetypes.ACMEAccountsFinalizer)
}
}); err != nil {
return r.notReadyErrf(pg, logger, "error provisioning shared ACME accounts Secret %q: %w", acmeSecret.Name, err)
}
}
}
defaultImage := r.tsProxyImage
if pg.Spec.Type == tsapi.ProxyGroupTypeKubernetesAPIServer {
defaultImage = r.k8sProxyImage
}
ss, err := pgStatefulSet(pg, r.tsNamespace, defaultImage, r.tsFirewallMode, tailscaledPort, proxyClass)
ss, err := pgStatefulSet(pg, r.tsNamespace, defaultImage, r.tsFirewallMode, tailscaledPort, proxyClass, r.sharedACMEAccountEnabledFor(pg))
if err != nil {
return r.notReadyErrf(pg, logger, "error generating StatefulSet spec: %w", err)
}
@@ -1104,9 +1136,35 @@ func (r *ProxyGroupReconciler) findStaticEndpoints(ctx context.Context, existing
return nil, &FindStaticEndpointErr{msg: fmt.Sprintf("failed to find any `status.addresses` of type %q on nodes using configured Selectors on `spec.staticEndpoints.nodePort.selectors` for ProxyClass %q", corev1.NodeExternalIP, proxyClass.Name)}
}
// If we ended up selecting the same set of addresses already in use, keep
// the existing order. nodes.Items from r.List is not guaranteed to be in
// a stable order across calls, so without this the slice can permute on
// each reconcile, making the marshalled config Secret differ byte-for-byte
// even though nothing has effectively changed. That trips the DeepEqual
// check on the config Secret, which writes the Secret, which fires a
// watch event, which re-enqueues the ProxyGroup, and so on.
if len(currAddrs) > 0 && sameAddrPortSet(endpoints, currAddrs) {
return currAddrs, nil
}
return endpoints, nil
}
// sameAddrPortSet reports whether a and b contain the same AddrPorts,
// ignoring order. Both slices are assumed to be free of duplicates, which
// holds for callers in this package.
func sameAddrPortSet(a, b []netip.AddrPort) bool {
if len(a) != len(b) {
return false
}
for _, x := range a {
if !slices.Contains(b, x) {
return false
}
}
return true
}
func getStaticEndpointAddress(a *corev1.NodeAddress, port uint16) *netip.AddrPort {
addr, err := netip.ParseAddr(a.Address)
if err != nil {
@@ -1321,6 +1379,25 @@ func notReady(reason, msg string) (map[string][]netip.AddrPort, *notReadyReason,
}, nil
}
// sharedACMEAccountEnabledFor reports whether the shared-ACME-account
// feature should be applied to pg. The per-PG
// tailscale.com/share-acme-account annotation wins when set; otherwise
// the operator's OPERATOR_SHARED_ACME_ACCOUNT_KEY setting is the default
// for every ProxyGroup.
func (r *ProxyGroupReconciler) sharedACMEAccountEnabledFor(pg *tsapi.ProxyGroup) bool {
return sharedACMEAccountEnabled(pg, r.sharedACMEAccountKey)
}
// sharedACMEAccountEnabled reports whether pg should use the shared ACME
// account, with the tailscale.com/share-acme-account annotation overriding
// the operator-wide default.
func sharedACMEAccountEnabled(pg *tsapi.ProxyGroup, operatorDefault bool) bool {
if v, ok := pg.Annotations[AnnotationShareACMEAccount]; ok {
return v == "true"
}
return operatorDefault
}
func (r *ProxyGroupReconciler) notReadyErrf(pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, format string, a ...any) (map[string][]netip.AddrPort, *notReadyReason, error) {
err := fmt.Errorf(format, a...)
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
+68 -13
View File
@@ -19,6 +19,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/yaml"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/ingressservices"
@@ -63,8 +64,12 @@ func pgNodePortService(pg *tsapi.ProxyGroup, name string, namespace string) *cor
}
// Returns the base StatefulSet definition for a ProxyGroup. A ProxyClass may be
// applied over the top after.
func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string, port *uint16, proxyClass *tsapi.ProxyClass) (*appsv1.StatefulSet, error) {
// applied over the top after. shareACMEAccount, when true, injects the env
// vars that route the pod's ACME account key to the shared per-tailnet
// Secret and drops TS_DEBUG_ACME_FORCE_RENEWAL so ARI-based renewals are
// attempted; the caller is responsible for checking the operator setting
// and the PG opt-in annotation.
func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string, port *uint16, proxyClass *tsapi.ProxyClass, shareACMEAccount bool) (*appsv1.StatefulSet, error) {
if pg.Spec.Type == tsapi.ProxyGroupTypeKubernetesAPIServer {
return kubeAPIServerStatefulSet(pg, namespace, image, port)
}
@@ -74,10 +79,10 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
}
// Validate some base assumptions.
if len(ss.Spec.Template.Spec.InitContainers) != 1 {
return nil, fmt.Errorf("[unexpected] base proxy config had %d init containers instead of 1", len(ss.Spec.Template.Spec.InitContainers))
return nil, fmt.Errorf("base proxy config had %d init containers instead of 1", len(ss.Spec.Template.Spec.InitContainers))
}
if len(ss.Spec.Template.Spec.Containers) != 1 {
return nil, fmt.Errorf("[unexpected] base proxy config had %d containers instead of 1", len(ss.Spec.Template.Spec.Containers))
return nil, fmt.Errorf("base proxy config had %d containers instead of 1", len(ss.Spec.Template.Spec.Containers))
}
// StatefulSet config.
@@ -186,14 +191,6 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR",
Value: "/etc/tsconfig/$(POD_NAME)",
},
{
// This ensures that cert renewals can succeed if ACME account
// keys have changed since issuance. We cannot guarantee or
// validate that the account key has not changed, see
// https://github.com/tailscale/tailscale/issues/18251
Name: "TS_DEBUG_ACME_FORCE_RENEWAL",
Value: "true",
},
}
if port != nil {
@@ -251,6 +248,29 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
Value: "true",
},
)
if shareACMEAccount {
envs = append(envs,
corev1.EnvVar{
Name: "TS_ACME_ACCOUNT_SECRET_NAME",
Value: kubetypes.ACMEAccountsSecretName,
},
corev1.EnvVar{
Name: "TS_ACME_ACCOUNT_FIELD",
Value: pgACMEAccountField(pg),
},
)
} else {
// Without a shared account key we cannot guarantee that
// the account key that issued the previous cert is the
// same one attempting renewal. Force plain new-order flow
// so renewals do not silently fail on rejected ARI
// "replaces" claims. See
// https://github.com/tailscale/tailscale/issues/18251.
envs = append(envs, corev1.EnvVar{
Name: "TS_DEBUG_ACME_FORCE_RENEWAL",
Value: "true",
})
}
}
return append(c.Env, envs...)
}()
@@ -406,7 +426,7 @@ func pgServiceAccount(pg *tsapi.ProxyGroup, namespace string) *corev1.ServiceAcc
}
}
func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
func pgRole(pg *tsapi.ProxyGroup, namespace string, shareACMEAccount bool) *rbacv1.Role {
return &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: pg.Name,
@@ -438,6 +458,12 @@ func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
pgPodName(pg.Name, i), // State.
)
}
// Ingress ProxyGroup write replicas need access to the
// shared ACME account Secret so they can read the
// per-tailnet account key and write it on first use.
if pg.Spec.Type == tsapi.ProxyGroupTypeIngress && shareACMEAccount {
secrets = append(secrets, kubetypes.ACMEAccountsSecretName)
}
return secrets
}(),
},
@@ -476,6 +502,35 @@ func pgRoleBinding(pg *tsapi.ProxyGroup, namespace string) *rbacv1.RoleBinding {
}
}
// pgACMEAccountField returns the field name used inside the shared
// tailscale-acme-accounts Secret for this ProxyGroup's tailnet. The blank
// tailnet (operator-default credentials) is represented by a reserved
// identifier so it gets a stable, unique field.
func pgACMEAccountField(pg *tsapi.ProxyGroup) string {
tn := pg.Spec.Tailnet
if tn == "" {
tn = kubetypes.ACMEAccountDefaultKey
}
return tn + kubetypes.ACMEAccountKeySuffix
}
// pgACMEAccountSecret returns the shared per-tailnet ACME account key
// Secret, keyed by tailnet inside its data. Not owned by any ProxyGroup
// so it outlives ProxyGroup deletion.
func pgACMEAccountSecret(namespace string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: kubetypes.ACMEAccountsSecretName,
Namespace: namespace,
Labels: map[string]string{
kubetypes.LabelManaged: "true",
},
// Block accidental deletion.
Finalizers: []string{kubetypes.ACMEAccountsFinalizer},
},
}
}
// kube-apiserver proxies in auth mode use a static ServiceAccount. Everything
// else uses a per-ProxyGroup ServiceAccount.
func pgServiceAccountName(pg *tsapi.ProxyGroup) string {
+192 -11
View File
@@ -811,6 +811,90 @@ func TestProxyGroupWithStaticEndpoints(t *testing.T) {
}
}
// TestFindStaticEndpointsStableOrder verifies that findStaticEndpoints returns
// the existing endpoint order from the config Secret when the resulting set of
// addresses is unchanged. nodes.Items from r.List is not order-stable across
// calls, so without this guarantee the slice can permute on each reconcile,
// triggering a spurious config Secret rewrite which fires a watch event that
// re-enqueues the ProxyGroup, looping forever (issue #19700).
func TestFindStaticEndpointsStableOrder(t *testing.T) {
const (
addrA = "10.0.0.1"
addrB = "10.0.0.2"
port = uint16(30001)
)
pc := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{Name: "test-pc"},
Spec: tsapi.ProxyClassSpec{
StaticEndpoints: &tsapi.StaticEndpointsConfig{
NodePort: &tsapi.NodePortConfig{
Ports: []tsapi.PortRange{{Port: port}},
Selector: map[string]string{"foo/bar": "baz"},
},
},
},
}
// Existing config Secret already pins the order [B, A]. The fake client
// lists nodes in name order ([node-a, node-b]) so without the stable-order
// guard findStaticEndpoints would return [A, B], differing from currAddrs
// and causing a spurious Secret rewrite.
currAddrs := []netip.AddrPort{
netip.MustParseAddrPort(addrB + ":30001"),
netip.MustParseAddrPort(addrA + ":30001"),
}
cfg := ipn.ConfigVAlpha{StaticEndpoints: currAddrs}
cfgJSON, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
existingSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test-0-config", Namespace: tsNamespace},
Data: map[string][]byte{tsoperator.TailscaledConfigFileName(106): cfgJSON},
}
nodes := []*corev1.Node{
{
ObjectMeta: metav1.ObjectMeta{Name: "node-a", Labels: map[string]string{"foo/bar": "baz"}},
Status: corev1.NodeStatus{Addresses: []corev1.NodeAddress{
{Type: corev1.NodeExternalIP, Address: addrA},
}},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "node-b", Labels: map[string]string{"foo/bar": "baz"}},
Status: corev1.NodeStatus{Addresses: []corev1.NodeAddress{
{Type: corev1.NodeExternalIP, Address: addrB},
}},
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pc, nodes[0], nodes[1], existingSecret).
Build()
zl, _ := zap.NewDevelopment()
r := &ProxyGroupReconciler{Client: fc}
got, err := r.findStaticEndpoints(t.Context(), existingSecret, pc, port, zl.Sugar())
if err != nil {
t.Fatalf("findStaticEndpoints: %v", err)
}
if !slices.Equal(got, currAddrs) {
t.Errorf("findStaticEndpoints returned %v, want %v (order must match currAddrs to avoid reconcile churn)", got, currAddrs)
}
// Repeat to confirm the result is stable across calls.
got2, err := r.findStaticEndpoints(t.Context(), existingSecret, pc, port, zl.Sugar())
if err != nil {
t.Fatalf("findStaticEndpoints (2nd call): %v", err)
}
if !slices.Equal(got, got2) {
t.Errorf("findStaticEndpoints not stable across calls: first=%v second=%v", got, got2)
}
}
func TestProxyGroup(t *testing.T) {
pc := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{
@@ -1052,14 +1136,15 @@ func TestProxyGroupTypes(t *testing.T) {
zl, _ := zap.NewDevelopment()
reconciler := &ProxyGroupReconciler{
tsNamespace: tsNamespace,
tsProxyImage: testProxyImage,
Client: fc,
log: zl.Sugar(),
clients: tsclient.NewProvider(&fakeTSClient{}),
clock: tstest.NewClock(tstest.ClockOpts{}),
authKeyRateLimits: make(map[string]*rate.Limiter),
authKeyReissuing: make(map[string]bool),
tsNamespace: tsNamespace,
tsProxyImage: testProxyImage,
Client: fc,
log: zl.Sugar(),
clients: tsclient.NewProvider(&fakeTSClient{}),
clock: tstest.NewClock(tstest.ClockOpts{}),
authKeyRateLimits: make(map[string]*rate.Limiter),
authKeyReissuing: make(map[string]bool),
sharedACMEAccountKey: true,
}
t.Run("egress_type", func(t *testing.T) {
@@ -1179,6 +1264,9 @@ func TestProxyGroupTypes(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
UID: "test-ingress-uid",
Annotations: map[string]string{
AnnotationShareACMEAccount: "true",
},
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
@@ -1199,6 +1287,44 @@ func TestProxyGroupTypes(t *testing.T) {
verifyEnvVar(t, sts, "TS_INTERNAL_APP", kubetypes.AppProxyGroupIngress)
verifyEnvVar(t, sts, "TS_SERVE_CONFIG", "/etc/proxies/serve-config.json")
verifyEnvVar(t, sts, "TS_EXPERIMENTAL_CERT_SHARE", "true")
verifyEnvVar(t, sts, "TS_ACME_ACCOUNT_SECRET_NAME", kubetypes.ACMEAccountsSecretName)
// pg.Spec.Tailnet is empty here so the default tailnet field is used.
verifyEnvVar(t, sts, "TS_ACME_ACCOUNT_FIELD", kubetypes.ACMEAccountDefaultKey+kubetypes.ACMEAccountKeySuffix)
// TS_DEBUG_ACME_FORCE_RENEWAL must NOT be set when the PG is
// opted in to the shared ACME account.
for _, e := range sts.Spec.Template.Spec.Containers[0].Env {
if e.Name == "TS_DEBUG_ACME_FORCE_RENEWAL" {
t.Errorf("TS_DEBUG_ACME_FORCE_RENEWAL must not be set on ingress ProxyGroup pods that share an ACME account")
}
}
// Verify the shared ACME accounts Secret exists and has the
// deletion finalizer (see tailscale/tailscale#18251).
acmeSecret := &corev1.Secret{}
if err := fc.Get(t.Context(), client.ObjectKey{Namespace: tsNamespace, Name: kubetypes.ACMEAccountsSecretName}, acmeSecret); err != nil {
t.Errorf("failed to get shared ACME accounts Secret: %v", err)
}
if !slices.Contains(acmeSecret.Finalizers, kubetypes.ACMEAccountsFinalizer) {
t.Errorf("shared ACME accounts Secret missing finalizer %q (got %v)", kubetypes.ACMEAccountsFinalizer, acmeSecret.Finalizers)
}
// Verify the per-ProxyGroup Role grants access to the shared
// ACME accounts Secret (write replicas need it to read/write the
// per-tailnet account key).
role := &rbacv1.Role{}
if err := fc.Get(t.Context(), client.ObjectKey{Namespace: tsNamespace, Name: pg.Name}, role); err != nil {
t.Fatalf("failed to get ProxyGroup Role: %v", err)
}
var sawACMEAccess bool
for _, rule := range role.Rules {
if slices.Contains(rule.Verbs, "patch") && slices.Contains(rule.ResourceNames, kubetypes.ACMEAccountsSecretName) {
sawACMEAccess = true
break
}
}
if !sawACMEAccess {
t.Errorf("ProxyGroup Role does not grant patch access to %q", kubetypes.ACMEAccountsSecretName)
}
// Verify ConfigMap volume mount
cmName := fmt.Sprintf("%s-ingress-config", pg.Name)
@@ -1228,6 +1354,60 @@ func TestProxyGroupTypes(t *testing.T) {
}
})
t.Run("ingress_type_shared_acme_opt_out", func(t *testing.T) {
// The reconciler has sharedACMEAccountKey=true, so ingress PGs
// default to shared. Explicit tailscale.com/share-acme-account=false
// must opt this PG out: no shared-Secret env vars, no Role
// access to the shared Secret, and TS_DEBUG_ACME_FORCE_RENEWAL
// must still be set so ARI "replaces" doesn't silently fail.
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress-optout",
UID: "test-ingress-optout-uid",
Annotations: map[string]string{
AnnotationShareACMEAccount: "false",
},
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
Replicas: new(int32(0)),
},
}
if err := fc.Create(t.Context(), pg); err != nil {
t.Fatal(err)
}
expectReconciled(t, reconciler, "", pg.Name)
sts := &appsv1.StatefulSet{}
if err := fc.Get(t.Context(), client.ObjectKey{Namespace: tsNamespace, Name: pg.Name}, sts); err != nil {
t.Fatalf("failed to get StatefulSet: %v", err)
}
for _, e := range sts.Spec.Template.Spec.Containers[0].Env {
switch e.Name {
case "TS_ACME_ACCOUNT_SECRET_NAME", "TS_ACME_ACCOUNT_FIELD":
t.Errorf("env %q unexpectedly present on opt-out PG", e.Name)
}
}
var sawForceRenewal bool
for _, e := range sts.Spec.Template.Spec.Containers[0].Env {
if e.Name == "TS_DEBUG_ACME_FORCE_RENEWAL" {
sawForceRenewal = true
}
}
if !sawForceRenewal {
t.Errorf("TS_DEBUG_ACME_FORCE_RENEWAL must be set on opt-out PG (avoids silent ARI \"replaces\" rejection)")
}
role := &rbacv1.Role{}
if err := fc.Get(t.Context(), client.ObjectKey{Namespace: tsNamespace, Name: pg.Name}, role); err != nil {
t.Fatalf("failed to get ProxyGroup Role: %v", err)
}
for _, rule := range role.Rules {
if slices.Contains(rule.ResourceNames, kubetypes.ACMEAccountsSecretName) {
t.Errorf("opt-out PG Role must not grant access to %q", kubetypes.ACMEAccountsSecretName)
}
}
})
t.Run("kubernetes_api_server_type", func(t *testing.T) {
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
@@ -1247,7 +1427,7 @@ func TestProxyGroupTypes(t *testing.T) {
}
expectReconciled(t, reconciler, "", pg.Name)
verifyProxyGroupCounts(t, reconciler, 1, 2, 1)
verifyProxyGroupCounts(t, reconciler, 2, 2, 1)
sts := &appsv1.StatefulSet{}
if err := fc.Get(t.Context(), client.ObjectKey{Namespace: tsNamespace, Name: pg.Name}, sts); err != nil {
@@ -1952,10 +2132,11 @@ func verifyEnvVarNotPresent(t *testing.T, sts *appsv1.StatefulSet, name string)
func expectProxyGroupResources(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyGroup, shouldExist bool, proxyClass *tsapi.ProxyClass) {
t.Helper()
role := pgRole(pg, tsNamespace)
shareACMEAccount := pg.Annotations[AnnotationShareACMEAccount] == "true"
role := pgRole(pg, tsNamespace, shareACMEAccount)
roleBinding := pgRoleBinding(pg, tsNamespace)
serviceAccount := pgServiceAccount(pg, tsNamespace)
statefulSet, err := pgStatefulSet(pg, tsNamespace, testProxyImage, "auto", nil, proxyClass)
statefulSet, err := pgStatefulSet(pg, tsNamespace, testProxyImage, "auto", nil, proxyClass, shareACMEAccount)
if err != nil {
t.Fatal(err)
}
+9 -3
View File
@@ -68,6 +68,12 @@ const (
AnnotationProxyGroup = "tailscale.com/proxy-group"
// AnnotationShareACMEAccount opts a single ProxyGroup into ("true")
// or out of ("false") using the shared per-tailnet ACME account key.
// When absent, OPERATOR_SHARED_ACME_ACCOUNT_KEY on the operator is
// the default. See tailscale/tailscale#18251.
AnnotationShareACMEAccount = "tailscale.com/share-acme-account"
// Annotations settable by users on ingresses.
AnnotationFunnel = "tailscale.com/funnel"
AnnotationHTTPRedirect = "tailscale.com/http-redirect"
@@ -783,7 +789,7 @@ func (r *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
// No need to error out if now or in future we end up in a
// situation where app info cannot be determined for one of the
// many proxy configurations that the operator can produce.
logger.Error("[unexpected] unable to determine proxy type")
logger.Error("unable to determine proxy type")
} else {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_INTERNAL_APP",
@@ -993,7 +999,7 @@ func enableEndpoints(ss *appsv1.StatefulSet, metrics, debug bool) {
if isMainContainer(&c) {
if debug {
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env,
// Serve tailscaled's debug metrics on on
// Serve tailscaled's debug metrics on
// <pod-ip>:9001/debug/metrics. If we didn't specify Pod IP
// here, the proxy would, in some cases, also listen to its
// Tailscale IP- we don't want folks to start relying on this
@@ -1321,7 +1327,7 @@ func proxyCapVer(sec *corev1.Secret, podUID string, log *zap.SugaredLogger) tail
}
capVer, err := strconv.Atoi(string(sec.Data[kubetypes.KeyCapVer]))
if err != nil {
log.Infof("[unexpected]: unexpected capability version in proxy's state Secret, expected an integer, got %q", string(sec.Data[kubetypes.KeyCapVer]))
log.Warnf("unexpected capability version in proxy's state Secret, expected an integer, got %q", string(sec.Data[kubetypes.KeyCapVer]))
return tailcfg.CapabilityVersion(-1)
}
if !strings.EqualFold(podUID, string(sec.Data[kubetypes.KeyPodUID])) {

Some files were not shown because too many files have changed in this diff Show More