216 Commits

Author SHA1 Message Date
Brad Fitzpatrick 2b338dd6a8 wgengine, cmd/tailscaled, control/controlclient: remove Engine watchdog
The Engine watchdog wrapped every wgengine.Engine method call in a
goroutine with a 45s timeout and crashed the process on timeout. It
was added years ago to surface deadlocks during development, but the
underlying deadlocks have long since been fixed, and even when it did
fire it produced obscure stack traces (from inside the watchdog
goroutine, not the original caller) without buying much.

Audit of userspaceEngine's methods shows none have cyclic locking or
unbounded blocking now that ResetAndStop no longer loops waiting for
DERPs to drain (fa49009ee). The watchdog is dead weight; remove it
along with the TS_DEBUG_DISABLE_WATCHDOG escape hatch.

Updates #19759

Change-Id: Iba9d718fe1f8718a6631296e336b138c31b99ff1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-15 16:49:28 -07:00
Simon Law 5d1bf80597 feature/routecheck: add ts_omit_routecheck feature flag (#19638)
RouteCheck, which checks that overlapping routers are reachable, is
enabled by default for both tailscaled and tsnet.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-15 15:50:50 -07:00
Noel O'Brien 894ff5d8ee cmd/hello: split css and js into separate files (#19771)
Move the inline CSS and JS into separate files to be more friendly
to Content Security Policies. ServeHTTP is updated to serve these
assets from the '/static/' path.

Updates tailscale/corp#32398

Signed-off-by: Noel O'Brien <noel@tailscale.com>
2026-05-15 09:37:22 -07:00
Alex Chan 0cb432ed84 all: update more references to Tailnet/Network Lock
Updates tailscale/corp#37904

Change-Id: I09e73b3248b9ddf86dafe33dfb621bd560f6596d
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-15 16:23:50 +01:00
Fernando Serboncini c355618e73 wgengine/router/osrouter: skip netfilter add-ons when chain setup fails (#19757)
linuxRouter has two blocks (connmark rules and the CGNAT drop rule) that
gate on cfg.NetfilterMode, the requested config state. This may cause an
error when setNetfilterModeLocked fails, since it may keep assuming this
config is valid.

We now gate both blocks on r.netfilterMode, matching the pattern used by
SNAT, stateful, and loopback paths.

Fixes #19737

Change-Id: Ia6003a082db99c376e662132d725661afbac0ee9

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-05-15 09:32:30 -04:00
License Updater 1d3562b314 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-05-14 21:04:41 -07:00
Brad Fitzpatrick ef1bb5ac16 util/cibuild, cache_key_test: skip TestTsgoRevInCacheKey outside Tailscale CI
cibuild.On() returns true for any CI environment that sets CI=true,
including Alpine Linux's package build CI. TestTsgoRevInCacheKey was
guarded by cibuild.On() (or use of tsgo), so it ran under Alpine's CI
with stock Go, where go.toolchain.rev isn't blended into build cache
keys, and unsurprisingly failed.

Add cibuild.OnTailscaleCI, which keys off GITHUB_REPOSITORY_OWNER to
distinguish tailscale/tailscale's own GitHub Actions CI from arbitrary
downstream CI, and use it in TestTsgoRevInCacheKey.

Fixes #19754

Change-Id: Id31cfe71903a235f1460dca1e2fdf334e3ba1ee5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-14 15:55:05 -07:00
Brad Fitzpatrick fa49009eee wgengine: simplify ResetAndStop, drop drain loop
Since f343b496c3 ("wgengine, all: remove LazyWG, use wireguard-go
callback API for on-demand peers"), Reconfig is fully synchronous:
magicConn.UpdatePeers, wgdev.RemovePeer, router.Set, and dns.Set all
return when the work is done, and the peer list is updated under
wgLock before Reconfig returns. So after Reconfig with empty configs,
len(st.Peers) is already 0.

The old loop also waited for st.DERPs to drain to 0, but UpdatePeers
only edits maps; active DERP connections idle out on their own
timeout. The sole caller (LocalBackend.stopEngineAndWait) doesn't
inspect st.DERPs anyway; it just hands the Status to
setWgengineStatusLocked. So the drain-wait was for nothing observable
and could theoretically (or at least appear to readers to) loop
forever holding b.mu. Remove that reader confusion by removing
the backoff loop entirely.

Updates #19759

Change-Id: Ibfac3f0baabcad7604b713c934a8fc37932e0a50
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-14 15:45:38 -07:00
Brad Fitzpatrick 93440604e0 tstest/natlab/vmtest: add TestPeerRelay
Add a VM-based natlab test that exercises the peer-relay feature
(feature/relayserver) end-to-end across three Tailscale nodes whose
network topology makes a direct A<->B UDP path impossible: both peers
are behind HardNAT (FreeBSD/pfSense-style endpoint-dependent NAT) with
no port-mapping services, while the relay node is behind One2OneNAT so
its STUN-discovered WAN endpoint is reachable from both peers. The
test enables the relay server via EditPrefs, then waits for an a->b
PingDisco whose PingResult.PeerRelay is set (proving magicsock chose
the peer-relay path, not DERP), and finally asserts that the relay's
DebugPeerRelaySessions LocalAPI reports the session.

The existing TestPeerRelayPing in tstest/integration runs three
tailscaled processes on the loopback interface with no NATs; this new
vmtest covers peer relay through real per-VM kernels and NATs.

To wire control-server capabilities into vmtest, also add a
PeerRelayGrants() EnvOption (sibling of AllOnline,
SameTailnetUser) that flips testcontrol.Server.PeerRelayGrants so the
wildcard packet filter grants tailcfg.PeerCapabilityRelay and
PeerCapabilityRelayTarget; without those caps magicsock won't consider
any peer a candidate relay.

Updates #13038

Change-Id: Ib3440b83ec442da0d3b89ffa48ceea9398ea9062
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-14 14:47:29 -07:00
Andrew Lytvynov 9437a634e6 scripts/installer.sh: handle Zorin OS versions separately from Ubuntu (#19758)
Their version scheme is different, even though the OS is based on
Ubuntu. We need to check Zorin's version numbers to pick the right
APT_KEY_TYPE.

Updates #18925

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-05-14 14:04:04 -07:00
M. J. Fromberger 4eb977413a tstest/natlab/vmtest: add helpers for fatal step errors (#19753)
In a lot of places, we construct an error to End a step, then immediately log
it to the governing test as test fatal. Save ourselves a bit of boilerplate by
putting methods on Step for that.

There are a couple cases this doesn't cover, e.g., where we construct the Step
outside a subtest that wants to fail individually, but it helps enough to pay
for its lines.

Updates #13038

Change-Id: I71f9900942962de16609b6b198d3ba13d6958a5f
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-05-14 09:24:47 -07:00
Claus Lensbøl 8203edc099 .github/workflows: change natlab test trigger label (#19750)
The label "natlab" is a bit confusing and also used for other things.
Instead, change the trigger label to "run-natlab-tests".

Updates #13038

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-05-14 11:53:13 -04:00
Fernando Serboncini 2a06fb66d0 cmd/cloner: preserve nil-valued entries when cloning map (#19749)
The codegen path for map-of-slice-of-pointer fields, skipped
nil-valued entries. That dropped the key from the map.

This broke how dns.Config.Routes uses nil values sentinels.

Fixes #19730
Fixes #19732
Fixes #19746
Fixes #19744

Change-Id: Ic6400227f4ab21b3ca0e8c0eeecf9b83d145a9ab

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-05-14 10:30:59 -04:00
Mike O'Driscoll 48919f708b util/linuxfw: fix nftables endianness and add connmark conditional check (#19725)
Fix the following issues:

1. Endianness Bug: The nftables runner used hardcoded
   big-endian byte arrays for firewall mark values (0xff0000, etc.), breaking
   bitwise operations on little-endian systems (all x86/x64, ARM). This caused
   connmark save/restore rules to silently fail. Fixed by using
   binary.NativeEndian to generate correct byte order for the host system.

2. Connmark Restore Conditional Check: The connmark restore
   mechanism unconditionally overwrote packet marks, even when Tailscale
   hadn't set any mark bits in conntrack. This destroyed mark bits set by
   other systems (VPNs, policy routing, vendor flags), breaking coexistence.
   Fixed by adding a conditional check to only restore when (ct mark &
   0xff0000) != 0, preventing the worst case of wiping all marks to zero.

Changes:
- util/linuxfw/linuxfw.go: Added nativeEndianUint32() helper and updated
  all mask functions to use native byte order instead of hardcoded bytes
- util/linuxfw/nftables_runner.go: Added conditional check in
  makeConnmarkRestoreExprs() to only restore when ct mark has Tailscale
  bits set; added detailed comment about bit preservation limitations
- util/linuxfw/iptables_runner.go: Added conditional check using -m
  connmark ! --mark to match nftables behavior
- Tests updated: Fixed byte-level regression tests to expect little-endian
  byte sequences and verify the new conditional check

Note: Perfect bit preservation in nftables remains challenging
due to nftables expression VM limitations. The current implementation
prevents the critical case of wiping marks with zero.

Updates #3310
Fixes #11803
Related to #8555

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-05-14 09:11:24 -04:00
James Tucker e7415e6393 util/eventbus: unify Subscriber/SubscriberFunc cores; structural symmetry
Brings Subscriber[T] in line with the same non-generic-core pattern already
applied to SubscriberFunc[T] and Publisher[T]:

  - Renames subscriberFuncCore to subscriberCore and shares it between
    Subscriber[T] and SubscriberFunc[T]. Both typed facades hold a
    *subscriberCore plus their respective per-T delivery state
    (Subscriber: chan T; SubscriberFunc: nothing, the user callback is
    captured in the dispatch closure).

  - The bus's outputs map and subscriber-interface itab key on
    *subscriberCore for both subscriber kinds, so adding a new Subscribe[T]
    call site no longer pays a per-T itab, dictionary, or equality function
    for the subscriber-interface side.

  - Subscribe[T] now hoists the non-generic constructor portion into
    newSubscriberCore (timer setup, core allocation, cached type/typeName,
    unregister method-value), matching SubscribeFunc.

The dispatch loop is intentionally NOT extracted to a non-generic helper for
Subscriber[T], unlike SubscriberFunc[T]. The reason is the typed channel send
'case s.read <- t:' must appear lexically inside the select; the only way to
lift it into a non-generic loop is to bridge typed and untyped via a per-event
goroutine, which costs ~2.7x throughput on BenchmarkBasicThroughput. We keep
dispatchTyped on the generic facade and accept the per-shape stencil cost as
the cheaper alternative.

Symbol-level effect on tailscaled (linux/amd64, measured via
`go tool nm -size`):

  Before:
    (*Subscriber[T]).dispatch
      2 shape stencils:        1,682 + 1,549 = 3,231 B
      3 thin per-T wrappers:   124 B each   =   372 B
      2 deferwrap1 helpers:    62 B each    =   124 B
      total:                                 3,727 B

  After:
    (*Subscriber[T]).dispatchTyped
      2 shape stencils:        1,678 + 1,582 = 3,260 B
      0 per-T wrappers (replaced by closure stored on core)
      2 deferwrap1 helpers:    62 B each    =   124 B
      total:                                 3,384 B

  dispatch path .text delta:                   -343 B (-9.2%)

Per-shape stencils are ~1,600 B (.text body) + ~1,100 B (pclntab) =
~2,700 B each on production tailscaled. The shape count matches before/after
(two distinct GC shapes for the Subscriber[T] event types in this binary).
What changes is that the per-T thin wrappers are eliminated because
Subscriber[T] no longer implements the subscriber interface directly.

Whole-binary section deltas:

  .text:        -2,304 B  (includes the dispatch savings plus other
                            small downstream effects)
  .rodata:        +512 B  (additional closure-type metadata)
  .gopclntab:   -2,981 B  (fewer per-T compiled functions => less metadata)

Stripped tailscaled (linux/amd64): no change at the file level (the savings
fall below the linker's section-alignment boundary). Unstripped builds shrink
by ~2,900 B.

Behavior is unchanged:
  BenchmarkBasicThroughput:       2,161 ns/op,  0 B/op,  0 allocs/op
  BenchmarkBasicFuncThroughput:   2,493 ns/op, 144 B/op, 2 allocs/op
  BenchmarkSubsThroughput:        3,727 ns/op,  0 B/op,  0 allocs/op

Updates #12614

Change-Id: I97918ec68bd2cdb15958bbfd7687592b39663efe
Signed-off-by: James Tucker <james@tailscale.com>
2026-05-13 17:36:30 -07:00
Brad Fitzpatrick dc323b1351 derp/derpserver: collapse clients and clientsAtomic into one hashtriemap
Server.clientsAtomic was introduced in 6b729795c3 as a lock-free
mirror of Server.clients to skip Server.mu on the packet send hot
path. This drops the non-concurrent map and makes all the existing
callers of the old plain map just use the concurrent map, but still
holding Server.mu.

BenchmarkLookupDestHashTrie is unchanged at ~2ns/op.

Fixes #19726

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I0894e4d86914d152b9b5fef969a3184bcb96f678
2026-05-13 16:57:26 -07:00
Nick Khyl 4d68493144 health: avoid publishing health.Change when warnable visibility remains unchanged
Warnables with a non-zero TimeToVisible are only published on the eventbus when
they remain unhealthy long enough to become visible.

However, we still publish a health.Change when a warning that was never visible
(and was never published to the eventbus) becomes healthy.

This PR fixes that and reduces churn when there is no actual state change. In
particular, it avoids unnecessary IPN bus notifications sent to GUI/CLI clients,
captive portal detection, etc.

Updates tailscale/corp#39759 (noticed while working on it)

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2026-05-13 17:02:35 -05:00
Adriano Sela Aviles 41286c2b56 ipn/ipnlocal,tsd: add NoiseRoundTripper to tsd.Sys
Adds a new NoiseRoundTripper field to tsd.Sys
to expose an http.RoundTripper to make requests
over the control plane Noise connection.

This will be used in PAM use cases soon.

Updates tailscale/corp#41800

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-05-13 14:56:28 -07:00
Nick Khyl 32f984f54c net/dns: create a new hosts file if it doesn't exist on Windows
A missing hosts file is not a fatal error. We should log it, but still proceed
and create a new one instead of failing the DNS reconfiguration completely.

Fixes #19733

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2026-05-13 16:10:36 -05:00
Claus Lensbøl bb47ea2c6b tstest/natlab/vmtest: start migrating old natlab tests to vmtest (#19727)
Instead of having two entry points for running natlab tests, start
converting the connectivity tests to use the vmtest framework.

Grid and pair tests have yet to be moved over.

Updates #13038

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-05-13 16:44:53 -04:00
Fran Bull 3a6261b79b feature/conn25: keep addrAssignments through pool reconfig
Fixes tailscale/corp#40250

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-13 11:00:47 -07:00
Simon Law e4e59a2af0 wgengine/netstack: stop inject goroutine from leaking in Impl.Start (#19721)
This patch fixes a data race in wgengine/netstack that surfaced while
running both TestTCPForwardLimits and TestTCPForwardLimits_PerClient.
Because these two tests both setup the TS_DEBUG_NETSTACK envknob, a
race happens because netstack.Impl.Close leaked its inject goroutine.
The inject goroutine also reads the TS_DEBUG_NETSTACK envknob, so if
it is still running when the next test starts, then it will break.

This patch also cleans up the tests a bit, ensuring that neither of
them run in T.Parallel. It also adds a T.Cleanup call to clear the
envknob.

Fixes #19720

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-13 08:13:40 -07:00
Simon Law 6467f0d067 ipn/ipnlocal: fix minor typo in shouldUseOneCGNATRoute (#19719)
This fixes a log message where ipn/ipnlocal.shouldUseOneCGNATRoute
would claim that an android machines was actually macOS.

Updates #cleanup
Updates #19652

Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-05-12 21:55:29 -07:00
Brad Fitzpatrick 6b729795c3 derp/derpserver: use hashtriemap for peer lookup
Replace the process-global Server.mu lookup in the packet send hot path
with a global hashtriemap mirror of local clientSet entries. The
authoritative clients map remains guarded by Server.mu; clientsAtomic is
only a lock-free fast path for active local clients.

Misses, stale inactive client sets, duplicate accounting, and mesh
forwarding still fall back to lookupDestUncached. This avoids taking
Server.mu for the common local active-client send path, at the cost of
adding one global concurrent map that mirrors Server.clients for local
peers.

The benchmark uses four destination peers. The before run sets
TS_DEBUG_DERP_DISABLE_PEER_HASHTRIE=true to force the old mutex lookup
path; the after run uses the hashtrie fast path.

    goos: linux
    goarch: amd64
    pkg: tailscale.com/derp/derpserver
    cpu: Intel(R) Xeon(R) 6975P-C
                          │    before     │                after                │
                          │    sec/op     │   sec/op     vs base                │
    LookupDestHashTrie-16   176.050n ± 1%   1.904n ± 6%  -98.92% (p=0.000 n=10)

                          │   before   │             after              │
                          │    B/op    │    B/op     vs base            │
    LookupDestHashTrie-16   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=10) ¹
    ¹ all samples are equal

                          │   before   │             after              │
                          │ allocs/op  │ allocs/op   vs base            │
    LookupDestHashTrie-16   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=10) ¹
    ¹ all samples are equal

Updates #3560 (very indirectly, historically)
Updates #19713 (as an alternative to that PR)

Change-Id: Ifb72e5c9854ad00e938cd24c6ab9c27312f297e8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-12 16:08:16 -07:00
Adriano Sela Aviles 72578de033 ipn/{ipnlocal,localapi},client/local: add per-dst cap resolution for services
Adds two new cap resolution methods alongside the existing PeerCaps:

PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) resolves
the service name to its VIP addresses via the node's service IP mappings
and returns caps scoped to that service. Exposed on /v0/whois via the
svc_name query parameter and on client/local.Client as WhoIsForService.

PeerCapsForIP(src, dst netip.Addr) resolves caps against an arbitrary
destination IP. Exposed on /v0/whois via the svc_addr query parameter
and on client/local.Client as WhoIsForIP.

svc_name takes priority over svc_addr when both are present. Invalid
values for either return 400. The existing PeerCaps/WhoIs path is
unchanged: without a service parameter, WhoIs returns only host-level
caps.

Updates tailscale/corp#41632

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-05-12 15:50:39 -07:00
DeedleFake ad8ead9c94 cmd/tailscale/cli: add RunWithContext
Fixes #12778

Change-Id: If9f8b299cef0cb68f93b344845b5c6a5b7554d2c
Signed-off-by: DeedleFake <deedlefake@users.noreply.github.com>
2026-05-12 12:27:55 -07:00
M. J. Fromberger 9f48567bf1 ipn/ipnlocal,wgengine/magicsock: add basic counters for cached peer connectivity (#19699)
Add new clientmetric counters for establishing contact with peers while using
cached network map data. To do this, instrument the magicsock.Conn with a bit
to indicate whether its peer data came from a cached netmap. If so, there are
two conditions we will count as establishing connectivity to a peer:

  - Receipt of a CallMeMaybe from a peer via disco.
  - Establishing a valid endpoint address for a peer.

In vmtest, add Env.ClientMetrics to scrape metrics from the specified node.
Use this to check that counters were updated in caching tests.

Updates https://github.com/tailscale/projects/issues/13
Updates #12639

Change-Id: Ie8cf3244ac8af4f5bcfe4d0d944078da2ba08990
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-05-12 12:01:05 -07:00
James Tucker 120bfcf1cc util/eventbus: extract non-generic SubscriberFunc constructor body and cache type name
Two changes that share the same intent of reducing per-T duplication
in code that doesn't actually depend on T:

1. Hoist the non-generic portion of newSubscriberFunc[T] into a
   newSubscriberFuncCore() helper. The hoisted work is the time
   timer setup, the subscriberFuncCore allocation, and the
   unregister closure (which captures only the non-generic
   reflect.Type and *subscribeState). The generic body now does
   only the two T-bound things it has to: compute reflect.TypeFor[T]
   and create the dispatch closure.

   Effect on the per-shape-stencil body of newSubscriberFunc[T]:
     before: 523 B per shape (in synthetic test)
     after:  293 B per shape (-230 B per shape; -56% on this body)

2. Cache reflect.Type.String() once at construction (in core.typeName)
   instead of recomputing it every time the dispatch closure runs.
   The dispatch closure also now takes the *subscriberFuncCore directly
   rather than building an intermediate dispatchFuncState struct on
   every call.

   Effect on the dispatch closure body (newSubscriberFunc[T].func1):
     before: 581 B per shape
     after:  480 B per shape (-101 B per shape; -17%)

Combined effect on tailscaled (linux/amd64):
  named-symbol savings via symcost: ~7 KB
  stripped binary delta:            -8 KB (page-quantized)
  arm64 binary delta:                0 (page-quantized)

  cumulative reduction from baseline (5167ff412):
    linux/amd64:  -110,592 bytes (-0.391%)
    linux/arm64:  -131,072 bytes (-0.499%)

Throughput is also improved by the typeName cache: BenchmarkBasic
goes from 2018 ns/op to 1864 ns/op (-7.6%) because the dispatch hot
path no longer allocates a string on every event.

Updates #12614

Change-Id: Ib3a3d6796785e16506330ec034e1144580d467a3
Signed-off-by: James Tucker <james@tailscale.com>
2026-05-12 11:16:04 -07:00
Brad Fitzpatrick 758ebe9839 tstest/natlab/vmtest: use short paths for Unix sockets
macOS limits Unix socket paths to 104 bytes. The Go test TempDir
path (e.g. /var/folders/.../TestDirectConnection...679197086/001/)
easily exceeds that, causing "bind: invalid argument". Create a
short /tmp/vmtest* directory for all socket files (vnet, QMP,
dgram) so the paths stay well under the limit on every platform.

Updates #13038

Change-Id: I721d24561d1766aaa964692bc77f40a131aa9455
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-11 21:54:27 -07:00
Brad Fitzpatrick f4c5613156 tstest/natlab/vmtest: don't require KVM; use TCG on macOS
startCloudQEMU hardcoded -machine q35,accel=kvm and -cpu host,
which fails on any host without KVM (notably macOS). Replace
with a qemuAccelArgs helper that probes /dev/kvm and falls back
to QEMU's TCG software emulation, matching the pattern already
used by tstest/integration/nat. Also wire the helper into
startGokrazyQEMU so gokrazy VMs pick up KVM when available.

Updates #13038

Change-Id: I7745518db823279b1880957bb14ca2ffdaab4c50
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-11 19:18:17 -07:00
Brad Fitzpatrick e062b46984 tstest/natlab, .github/workflows: add opt-in natlab CI workflow
The natlab vmtest suite (tstest/natlab/vmtest) and the integration nat
tests are gated behind --run-vm-tests because they need KVM and are
slow. Until now nothing in CI exercised them apart from a single
canary TestEasyEasy run on every PR.

Add .github/workflows/natlab-test.yml that runs the full opt-in suite
on demand (workflow_dispatch), on PRs labeled "natlab", and on main
every 12 hours via cron. The workflow has two phases:

  - "prepare" builds the gokrazy VM image, downloads the Ubuntu and
    FreeBSD cloud images once via the new natlabprep tool, and emits
    a dynamic JSON matrix of every TestX function it finds in the two
    opt-in packages.
  - "test" is a per-test matrix that depends on prepare. Each matrix
    job restores the shared caches and runs a single test, so adding
    a new TestFoo is automatically picked up on the next run without
    any workflow edits.

Rename the existing natlab-integrationtest.yml to natlab-basic.yml
since it's the small smoke variant (just TestEasyEasy on every PR);
the new natlab-test.yml is the bigger suite. The job inside is
renamed to EasyEasy for the same reason.

Move the macOS arm64 host check from vmtest.Env.Start into
vmtest.Env.AddNode so a test that adds a vmtest.MacOS node skips
immediately on a non-macOS host, and add an explicit
skipIfNotMacOSArm64 helper at the top of the two macOS-only tests
so the platform requirement is obvious to readers.

Quiet the takeAgentConnOne miss log in tstest/natlab/vnet by default
(it was the overwhelming majority of bytes in CI logs, with no signal
in healthy runs) and replace it with a periodic "still waiting" line
that only fires after 10s, so a truly stuck agent connection still
surfaces.

Updates #13038

Change-Id: I4582098d8865200fd5a73a9b696942319ccf3bf0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-11 17:14:46 -07:00
James Tucker 4eec4423b4 util/eventbus: move Publisher publisher-interface impl to a non-generic core
Mirrors the same refactor previously applied to SubscriberFunc:

  - Publisher[T]: a thin user-facing facade. Holds a pointer to a
    non-generic publisherCore and exposes Publish/Close/ShouldPublish.
  - publisherCore: a non-generic struct that owns the *Client back-
    pointer, stop flag, and cached reflect.Type. It implements the
    package-private publisher interface (publishType, Close).
    The bus's per-Client publisher set is set.Set[publisher] keyed
    on this single non-generic type.

The publisher interface only exists to support diagnostic
introspection (Debugger.PublishTypes returning the list of types a
client publishes). Previously, satisfying that diagnostic-only
interface forced *Publisher[T] to be the implementor and cost a
per-T itab, generic dictionary, and equality function on every
event type ever passed through Publish[T]. Moving the
implementation to a non-generic core lets the diagnostic surface
work unchanged while charging zero per-T cost for the
diagnostic-driven generic interface.

Publisher[T].Publish is also slimmed: the channel/select/stopFlag
loop is now a non-generic publish() helper that takes the value as
'any'. The per-T body is reduced to forwarding the boxed value to
the helper.

Measured impact (util/eventbus/sizetest):

  total per-flow binary cost:
    linux/amd64:  2252.8 B/flow -> 1900.5 B/flow  (-352.3 B / -15.6%)
    linux/arm64:  2228.2 B/flow -> 1835.0 B/flow  (-393.2 B / -17.6%)

  Publisher per-receiver attribution:
    linux/amd64:   635.2 B/flow ->  369.6 B/flow  (-265.6 B / -41.8%)
    linux/arm64:   751.7 B/flow ->  373.2 B/flow  (-378.5 B / -50.4%)

Cumulative reduction from the original baseline (5167ff412):
    linux/amd64:  3096.6 B/flow -> 1900.5 B/flow  (-1196.1 B / -38.6%)
    linux/arm64:  3145.7 B/flow -> 1835.0 B/flow  (-1310.7 B / -41.7%)

Dropped per-T symbols (200-flow eventbus binary):

  - .dict.Publisher[T]                   was 14,400 B (72 B/T)
  - type:.eq.Publisher[T]                was 11,832 B (58 B/T)
  - go:itab.*Publisher[T],publisher      was  8,000 B (40 B/T)
  - (*Publisher[T]).Close shape stencils collapsed to 1

Behavior is unchanged: BenchmarkBasicThroughput is within noise
(2018 -> 2038 ns/op at -benchtime=2s) and all eventbus tests pass.

Updates #12614

Change-Id: I61979c2bf95d2a711c2321e6e0b4b7d15980e9f5
Signed-off-by: James Tucker <james@tailscale.com>
2026-05-11 14:39:42 -07:00
James Tucker d72cde1a6b util/eventbus: move SubscriberFunc subscriber-interface impl to a non-generic core
Splits SubscriberFunc[T] into:

  - SubscriberFunc[T]: a thin user-facing facade that holds only a
    pointer to a non-generic core. It exposes Close() to user code,
    which forwards to the core.
  - subscriberFuncCore: a non-generic struct that owns all the
    subscriber state (stop flag, unregister, logf, slow timer,
    cached reflect.Type) and implements the bus's package-private
    subscriber interface. Its dispatch() invokes a closure
    captured at construction time that performs the
    vals.Peek().Event.(T) type assertion and runs the user
    callback on the unboxed value.

The bus's outputs map and subscriber-interface itab are
parameterized only by *subscriberFuncCore, not by T, eliminating
both the per-T itab and the per-T generic dictionary that
previously scaled with the number of subscribed event types.

Measured impact (util/eventbus/sizetest):

  total per-flow binary cost:
    linux/amd64:  3039.2 B/flow -> 2252.8 B/flow  (-786.4 B / -25.9%)
    linux/arm64:  3145.7 B/flow -> 2228.2 B/flow  (-917.5 B / -29.2%)

  SubscriberFunc per-receiver attribution:
    linux/amd64:   840.8 B/flow ->  300.8 B/flow  (-540.0 B / -64.2%)
    linux/arm64:   849.9 B/flow ->  303.8 B/flow  (-546.1 B / -64.3%)

Dropped per-T symbols (200-flow eventbus binary):

  - (*SubscriberFunc[T]).dispatch     was 26,639 B total (130 B/T)
  - (*SubscriberFunc[T]).subscribeType was  3,600 B total ( 18 B/T)
  - .dict.SubscriberFunc[T]            was 14,400 B total ( 72 B/T)
  - go:itab.*SubscriberFunc[T],...     was  9,600 B total ( 48 B/T)

Of the original 913 B/flow attributed to SubscriberFunc, 540 B/flow
is now gone, dropping the receiver to 300 B/flow.

Behavior is unchanged: BenchmarkBasicThroughput is within noise
(1955 -> 1941 ns/op on the test box) and all eventbus tests pass.

Updates #12614

Change-Id: I646b3b05fd8d95f9afead59bfd0f69cd18b7a709
Signed-off-by: James Tucker <james@tailscale.com>
2026-05-11 12:14:05 -07:00
Francois Marier ead5ce65a3 cmd/pgproxy: fix client TLS handshake timeout
There is a 30-second timeout set on client TLS connections but the handshake was
called on the wrong connection and so the timeout was never used in practice.

Signed-off-by: Francois Marier <francois@fmarier.org>
2026-05-11 11:12:11 -07:00
Fran Bull 2f45a6a9d8 feature/conn25: return expired assignments to address pools
Make it possible to remove the least recently used expired address
assignment from addrAssignments.
Before checking out a new address from the IP pools, return a handful of
expired addresses.

Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-08 14:33:06 -07:00
Fran Bull 82346f3882 feature/conn25: move addrAssignments to their own file
Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-08 14:33:06 -07:00
Claus Lensbøl 469d356ed8 tstest/natlab/vmtest: add test for direct conn with cached netmap (#19660)
When a peer is not able to connect to control after a restart and is
using a cached netmap, that nodes should be able to connect to another
peer in its tailnet (given that the home DERP of that peer has not
changed in the meantime).

Add test that starts two peers and connects them to a tailnet with
caching enabled. Then blackhole traffic to control from one peer and
restart it. Verify that the connection between the two ends up direct.

Adds facilities for expecting a certain path type between nodes.

Updates: #19597

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-05-08 16:57:27 -04:00
Fran Bull ee2378b141 feature/conn25: follow CNAMEs when rewriting DNS response
If a DNS query for a domain that should be routed through a connector
results in CNAME records in the response, collapse the CNAME chain to an
A/AAAA record for the domain -> magic IP.

Fixes tailscale/corp#39978

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-08 08:12:24 -07:00
Brad Fitzpatrick 24eb157448 go.toolchain.rev: bump to Go 1.26.3
Updates tailscale/corp#41490

Change-Id: I35b67bdbcd71468fea03b033b17aeefe1319dc45
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-07 15:33:05 -07:00
Alex Chan d6ffc0d986 tka,ipn: reduce boilerplate in Tailnet Lock tests
The `CreateStateForTest` helper reduces boilerplate in cases where the test
only cares about the trusted keys and not the disablement values (and makes
it more obvious where the disablement values are meaningful).

The `setupChonkStorage` helper reduces the boilerplate when creating on-disk
TKA storage in tests.

The `fakeLocalBackend` helper reduces the boilerplate when setting up a
`LocalBackend` instance in the IPN tests.

Updates #cleanup

Change-Id: Iacfba1be5f7fab208eec11e4369d63c7d7519da5
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-07 21:49:27 +01:00
Fernando Serboncini 495d3acc7b tstest/natlab/vmtest: kill QEMU when test process dies (#19676)
Re-exec the test binary as a thin wrapper that holds a pipe inherited
from the test. When the test goes away (any reason, including SIGKILL,
panic, or OOM), the kernel closes the pipe write end; the wrapper sees
EOF and SIGKILLs itself, taking QEMU and its children with it.

Updates #13038

Change-Id: Ib2151098193551396c1d7bb51b07da3bd6b2cfb4

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-05-07 16:14:27 -04:00
Claus Lensbøl 76248a68b2 tstest/natlab/vnet: close gonet sockets when test is done (#19677)
Running all vmtests in tstest/natlab/vmtest locally was breaking later
tasks in the queue. The goroutine dump on timeout had goroutines hanging
around for 9 minutes, meaning that something was not getting cleaned up.

  goroutine 262 [select, 9 minutes]:
  gvisor.dev/gvisor/pkg/tcpip/adapters/gonet.commonRead({...})

Add a timeout of Now() to gonet TCP connections when the test ends
(inspired by ServeUnixConn()), and wait for them to shut down before
exiting the test.

Updates #13038

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-05-07 14:57:07 -04:00
Hazel T 33b9579c21 scripts/installer.sh: add openSUSE Slowroll as a Tumbleweed derivative (#19662)
Fixes: #14927

Signed-off-by: Hazel T <hazel@tailscale.com>
2026-05-07 12:43:55 +01:00
Erisa A 76712b32d9 .github: install ca-certificates on Kali to fix installer tests (#19673)
Updates #cleanup

Signed-off-by: Erisa A <erisa@tailscale.com>
2026-05-07 12:20:09 +01:00
James Tucker 0def0f19bd util/eventbus: extract SubscriberFunc.dispatch loop to a non-generic helper
The (*SubscriberFunc[T]).dispatch method body — a ~40-line select
loop with slow-subscriber timer, snapshot handling, ctx-cancel
draining, and a CI stack-dump branch — was previously fully
duplicated by the Go compiler for every distinct GC shape of T.
None of that body actually depends on T except for the type
assertion and the user callback invocation.

This change moves the loop body into a non-generic dispatchFunc()
helper, leaving (*SubscriberFunc[T]).dispatch as a tiny wrapper
that:

  - performs the vals.Peek().Event.(T) type assertion
  - spawns the callback goroutine via `go runFuncCallback(s.read,
    t, callDone)` — a regular generic function call, not a closure,
    so that `go` binds the args to the goroutine's frame instead of
    allocating a closure on the heap. This preserves the
    zero-extra-allocation behavior of the original
    (*SubscriberFunc[T]).runCallback method.
  - resolves T's name via reflect.TypeFor[T]().String() (cached on
    the stack rather than recomputed on each %T formatting)
  - calls dispatchFunc with the callDone channel

The %T formatting in the original logf calls is replaced with %s
on the resolved name string, removing per-T fmt instantiations.

A new BenchmarkBasicFuncThroughput is added alongside the existing
BenchmarkBasicThroughput so per-event allocation behavior on the
SubscribeFunc dispatch path is covered by the benchmark suite.

Measured impact (util/eventbus/sizetest):

  SubscriberFunc per-flow attribution:
    linux/amd64:  912.5 B/flow -> 840.8 B/flow  (-71.7 B/flow)
    linux/arm64:  917.5 B/flow -> 849.9 B/flow  (-67.6 B/flow)

The total per-flow size delta on amd64 dropped from 3,096.6 B to
3,039.2 B (-57 B/flow). The arm64 total stayed at 3,145.7 B
because the linker's page-aligned section sizing absorbed the
improvement on this binary; the symcost-attributed per-receiver
number is the real signal.

Behavior is unchanged: BenchmarkBasicThroughput stays at 0
allocs/op and BenchmarkBasicFuncThroughput holds at the same 2
allocs/op, 144 B/op as the prior eventbus implementation. All
eventbus tests pass.

Updates #12614

Change-Id: I85f933f50f58cd25bbfe5cc46bdda7aab22f0bf7
Signed-off-by: James Tucker <james@tailscale.com>
2026-05-06 18:56:09 -07:00
Brad Fitzpatrick 87a74c3aa2 tsnet: make workload identity federation opt-in
The tailscale.com/wif package brings in the AWS SDK
(github.com/aws/aws-sdk-go-v2/{config,sts,...} and github.com/aws/smithy-go)
to support fetching ID tokens from AWS IMDS for workload identity
federation. Until now, tsnet pulled this in unconditionally via
feature/condregister/identityfederation, costing ~70 unwanted deps for
every tsnet program whether or not it uses workload identity federation.

These AWS SDK deps were originally removed from tsnet on 2025-09-29 by
commit 69c79cb9f ("ipn/store, feature/condregister: move AWS + Kube
store registration to condregister"). They were then accidentally added
back on 2026-01-14 by commit 6a6aa805d ("cmd,feature: add identity
token auto generation for workload identity", PR #18373) when the new
wif package was wired into tsnet via feature/identityfederation.

Drop the blanket import. tsnet programs that want workload identity
federation now opt in with:

    import _ "tailscale.com/feature/identityfederation"

The hook lookup in resolveAuthKey already uses GetOk and degrades
gracefully when the feature isn't linked, so existing programs that
don't use workload identity federation see no behavior change. The
tailscale CLI still imports the condregister wrapper directly, so its
behavior is also unchanged.

Lock this in with TestDeps additions: tailscale.com/wif as a BadDep,
plus substring checks in OnDep that fail on any github.com/aws/ or
k8s.io/ dependency creeping back in.

Also, switch cmd/gitops-pusher from the condregister wrapper to a
direct import of feature/identityfederation: gitops-pusher's auth flow
calls HookExchangeJWTForTokenViaWIF directly, so it shouldn't be
subject to the ts_omit_identityfederation build tag.

Updates #12614

Change-Id: I70599f2bdd4d3666b26a859d5b76caa5d6b94507
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-06 18:43:45 -07:00
Adriano Sela Aviles daddb14b8f control/controlhttp: use ws:// when HTTPSPort is NoPort in JS dialer
When HTTPS is explicitly disabled (HTTPSPort == NoPort), the JS WebSocket
dialer should use ws:// instead of wss://. This matches the behavior of
the non-JS client and fixes connections to development control servers
e.g. http://localhost:31544.

Updates tailscale/corp#40944

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-05-06 15:58:58 -07:00
Brad Fitzpatrick d06cc56987 wgengine/magicsock: add more docs, checks to Test32bitAlignment
Per recent chat with @raggi about all this, I went and looked at this
test again.

Updates #cleanup

Change-Id: Icb7d87b1ed2cebf481ee4e358a3aa603e63fb8a4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-06 15:29:44 -07:00
Brad Fitzpatrick 15bb10dbce tsnet: ban awsstore and kubestore as deps in TestDeps
Commit 69c79cb9f (Sep 2025) moved awsstore and kubestore registration
behind condregister build tags so tsnet wouldn't pull in the AWS SDK
and Kubernetes client by default. The accompanying TestDeps BadDeps
entry was missed, so PR #19667 (which re-added those imports) wasn't
caught by the test.

Add the two packages to BadDeps so future regressions fail the test.

Updates #19667
Updates #12614

Change-Id: I903b7c976e5e122cc0c0b956dc73740f5d474fac
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-06 14:57:47 -07:00
Tom Proctor b74eeda055 cmd/testwrapper: print unit for package duration (#19663)
Include the unit (s) when printing the time taken to test each package.

Updates #cleanup

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2026-05-06 22:31:48 +01:00
kari-ts c721189cef ipn/ipnlocal: prefer one CGNAT route on Android (#19652)
Android rebuilds its VpnService interface when the VPN route
configuration changes, which tears down long lived TCP connections
through the tunnel. Use the same automatic OneCGNATRoute behavior as
macOS on Android, and prefer the single CGNAT route when no other
interface is using the CGNAT, falling back to fine grained peer routes
otherwise.

Updates tailscale/tailscale#19591

Signed-off-by: kari <kari@tailscale.com>
2026-05-05 19:11:17 -07:00
Brad Fitzpatrick f844c8bc32 util/winutil/gp: deflake TestGroupPolicyReadLockClose
The test goroutine read lockCnt immediately after Lock returned, racing
with Close: close(lk.closing) wakes lockSlow's select, whose deferred
Add(-2) on lockCnt can run before Close's CAS clears the LSB. When that
happens, lockCnt is briefly 1 (3 - 2) instead of 0 (1 + 2 - 2 - 1),
producing "lockCnt: got 1; want 0".

Move the lockCnt assertion into the main test goroutine, after both
Close has returned and the Lock goroutine has finished, so both updates
have settled before we read.

Fixes #19647

Change-Id: Ia67036ff73a1beb528cbd621460db9048f3066ad
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-05 14:02:35 -07:00
Jonathan Nobels 872d79089e VERSION.txt: this is v1.99.0 (#19645)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2026-05-05 15:07:20 -04:00
Evan Lowry aa21b0c008 client/systray: fix recommended exit node not showing as selected (#19627)
When an exit node was set before launching systray, the recommended row
in exit nodes rendered as not selected even when the active exit node
was at the same location.

This looks to be two different things:

- suggestExitNode takes its own suggestion into account, and not the
  users active exit node. When a mullvad city is reached via the picker
  rather than the recommended row, the suggester's pick and
  prefs.ExitNodeID end up as distinct peers in the same city, resulting
  in an ID-only equality check missing the match.
- Toggle state was constructed and mutated via .Check(), which for newly
  created elements may be cached (such as when launching systray, with
  an already active node).

Fixes #19626

Signed-off-by: Evan Lowry <evan@tailscale.com>
2026-05-05 10:49:38 -03:00
Alex Chan eac531da8e cmd/tailscale/cli: unhide --report posture flag in up
This was originally hidden during the beta period in both `up` and `set`,
then when device posture went GA we unhid the flag in `set` but not in
`up`.

This is confusing for users, because an error message can direct them to
run `tailscale up` with this flag if they've set it previously, but the
help text won't tell them what it does.

Updates #5902
Updates #17972

Change-Id: I9a31946f4b3bb411feed0f5a6449d7ff9a5ba9d3
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-05 10:12:36 +01:00
Brad Fitzpatrick 883d4fd2cd wgengine/netstack, net/ping: stop using pro-bing and use our net/ping instead
Fixes #19633
Fixes #13760

Change-Id: I0fa9423523a3a0fb1dfcde57de0f26e51723ff97
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-04 14:05:24 -07:00
Brad Fitzpatrick 81569e891f tstest/iosdeps: update import list to mirror ipn-go-bridge
The purpose of this package is to test the iOS dependency closure, but
it had drifted from the actual import list of the ipn-go-bridge package
in the corp repo (the Go side of the iOS / macOS app).

Update the imports to match ipn-go-bridge's GOOS=ios import list,
adding many missing packages including wgengine/netstack,
feature/{taildrop,syspolicy,condregister}, the util/syspolicy/*
subpackages, types/{key,lazy,logid,netmap}, tsd, safesocket,
util/{eventbus,must,set}, and several net/* and ipn/* packages.

Drop two now-stale BadDeps entries (for now!): database/sql/driver and
github.com/google/uuid are reached via wgengine/netstack ->
github.com/prometheus-community/pro-bing, which netstack imports on
darwin || ios for ICMP user-ping, so the iOS app already ships them.
But we should fix that later.

Updates #19633

Change-Id: Ic50779fdb195685a2e8ccd7c513eee91b0feeaf8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-04 14:05:24 -07:00
Brad Fitzpatrick 9bb7ca6116 cmd/vet/lowerell, drive/driveimpl: forbid variables named "l" or "I"
Add a new vet checker that rejects variables, parameters, named
return values, receivers, range/type-switch bindings, type
parameters, struct fields, and constants named "l" (lowercase ell)
or "I" (uppercase i). Both are hard to distinguish from the digit
"1" and from each other in too many fonts.

Rename the two pre-existing struct fields named "l" (both of type
net.Listener) in drive/driveimpl/drive_test.go to "ln", matching the
convention used elsewhere for net.Listener locals.

Rename the test-fixture struct fields "I" (single int label) to
"Int" in metrics/multilabelmap_test.go and util/deephash/deephash_test.go,
preserving the "first letters of types" convention used alongside
neighboring fields like I8/I16/U/U8.

Also teach pkgdoc_test.go to skip testdata/ directories, which
the go tool ignores; they are not real packages.

Fixes #19631

Change-Id: I71ad2fa990705f7a070406ebcdb8cefa7487d849
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-04 14:03:28 -07:00
Andrew Lytvynov 0cf899610c util/linuxfw/linuxfwtest: remove unused package (#19520)
Added in 2022, this appears to be unused now.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-05-04 12:33:12 -07:00
License Updater ca2317439d licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-05-04 10:34:27 -07:00
Jordan Whited ce76f44df2 derp/derpserver: remove global rate limiter
Which can be unfair around varying packet sizes.

Updates tailscale/corp#40962

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-05-04 09:41:14 -07:00
Fernando Serboncini 29122506be misc/git_hook: propagate shared HOOK_VERSION (#19476)
Move HOOK_VERSION into the githook package and export it as
githook.HookVersion, so tailscale/corp can reference it via
the shared-code bump instead of having to bump HOOK_VERSION
by hand.

New launcher.sh composes the wanted version from 2 sources:
the shared HOOK_VERSION and an optional repo local version,
misc/git_hook/HOOK_VERSION, for repo-specific config bumps.

Updates tailscale/corp#40381

Change-Id: I7cf16889ba53cb564cc2df7dfd7588748f542c55

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-05-04 12:38:28 -04:00
George Jones 290a6cc03c appc, feature/conn25: handle exact and wildcard domains correctly (#19202)
Installed SplitDNS routes are always treated as wildcard domains,
so the domains that we pass to the local resolver should be normalized
and have any leading *. wildcard prefix removed.

When looking at DNS responses to see if the domain matches, we need to
consider both exact matches and wildcard matches. We now keep separate
maps of exact-match domains and wildcard domains, and when we match we
check to see if there's a match in the exact-match map, otherwise we
check against the wild card match map until we find a match, removing
a label after each check.

Rather than looking for matching self-hosted domains (domains serviced
by the connector being run on the self-node), the apps that are being
serviced by the connector on the self-node are tracked instead. When
checking to see if a DNS response should be rewritten, it is ignored
if any of the matching apps for the domain are in the self-hosted apps set.

Fixes tailscale/corp#39272

Signed-off-by: George Jones <george@tailscale.com>
2026-05-01 17:33:21 -04:00
Fran Bull bdf3419e7d net/dns: add custom scheme resolvers
If another part of the client code registers a custom scheme with the
forwarder, the forwarder will check resolver addresses to see if they
match the scheme. If they do, the corresponding custom scheme handler
will be called to find the actual address for the resolver at this
moment. If the handler returns the empty string then that resolver will
be ignored.

This is useful if you want to dynamically determine where to send
certain DNS requests. It is being added to support new app connector
(conn25) work that would like to make sure it sends DNS requests to the
current connector peer in a high availability configuration.

Updates tailscale/corp#39858

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-05-01 14:01:10 -07:00
Rollie Ma 78126c5d9f tailcfg: add node capability for services in desktop clients (#19605)
Add a node capability to help determine if the desktop clients should
show services list/menu/section

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

Change-Id: Ie34b3362f921d710173b2a0dd190354352bb26f0

Signed-off-by: Rollie Ma <rollie@tailscale.com>
2026-05-01 12:07:33 -07:00
Tom Meadows ee10f9881c cmd/k8s-operator: add authkey reissuing to recorder reconciler (#19556)
also fixes memory leak with authKeyReissuing map on ProxyGroup
reconciler authkey reissue.

Updates #19311

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-05-01 18:26:55 +01:00
Alex Chan 3ced30b0b6 tka: clarify that this limit is on disablement *values* not *secrets*
Values get written into TKA state; secrets don't.

Updates #cleanup

Change-Id: Ief9831dcb1102f584a33b2e71b611b38ca463724
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-01 18:25:39 +01:00
Andrew Lytvynov f15a4f4416 client/web: move API permission checks into handlers (#19576)
There are only a couple endpoints that check peer capabilities. Keeping
permission checks with the code that assumes they were performed, rather
than with the routing layer, feels easier to reason about.

Check that the caller is actually a peer and pass their capabilities via
a context value for handlers that want to check them.

Along with this, simplify the helper handler wrappers that are not
needed for most of the endpoints.

Updates #40851

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-05-01 09:01:53 -07:00
Brad Fitzpatrick bbcb8650d4 cmd/tailscale/cli: fetch netmap via current-netmap debug action
Stop opening an IPN bus subscription with NotifyInitialNetMap purely to
read the current netmap once. Use the LocalAPI debug current-netmap
action (added in 159cf8707) instead, which returns the current netmap
synchronously without subscribing to the bus.

Updates #12542

Change-Id: I8aa2096d65aaea4dfe62634f03ce06b5470e0e51
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-01 07:53:51 -07:00
Brad Fitzpatrick 4c3ed5ab32 all: migrate code off Notify.NetMap to Notify.SelfChange
Move tailscaled's in-tree reactive users from of IPN bus Notify.NetMap
updates to the narrower Notify.SelfChange signal introduced earlier in
this series. Consumers that need additional state (peers, DNS config,
etc.) fetch it on demand via the LocalAPI.

It is a step toward the larger goal of not fanning Notify.NetMap out
to every bus watcher on Linux/non-GUI hosts.

A future change stops sending Notify.NetMap entirely on Linux and
non-GUI platforms. (eventually once macOS/iOS/Windows migrate to the
upcoming new Notify APIs, we'll remove ipn.Notify.NetMap entirely)

Updates #12542

Change-Id: I51ea9d86bdca1909d6ac0e7d5bd3934a3a4e8516
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-01 06:51:40 -07:00
Claus Lensbøl ff9c3f0e00 tstest/natlab/vmtest: add test loading netmap cache from disk (#19598)
For testing the loading of netmap cache from disk, the cache needs to
exist. The simple solution is to start two nodes and connect them to
control, with the netmap caching capability set. Then cut the connection
to control, restart the nodes, and ping between them.

This tests that we can start from a cache and get to running state, but
also that we are able to establish a connection between the nodes.

For now this is not testing how the nodes are able to talk to each other
(DERP vs direct).

Updates #19597

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-05-01 09:46:19 -04:00
Brad Fitzpatrick 89a78dc9b7 client/local, ipn/localapi, ipn/ipnlocal: add PeerByID
Add a narrow LocalAPI accessor and matching client/LocalBackend method
to look up a single peer's current full [tailcfg.Node] by NodeID, in
O(1) time on the daemon side, without fetching the entire netmap.

Useful for callers that need the latest state of a single peer (e.g.
in response to a peer-mutation event on the IPN bus) without paying
for a full netmap fetch.

Updates #12542

Change-Id: I1cb2d350e6ad846a5dabc1f5368dfc8121387f7c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-01 06:20:46 -07:00
Alex Chan cac94f51cc ipn/ipnlocal: don't compact TKA state on startup
Compacting on startup means nodes may compact at a different cadence
based on whether they're long-running or restarting frequently.

We already compact after every sync, which only occurs when the TKA
state has changed. Waiting for TKA changes to trigger compaction on
nodes means compaction will occur more consistently across a tailnet.

Updates tailscale/corp#33537

Change-Id: Ia0aa6d9e5e362e9ab08450fde69772841790d5b5
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-01 13:27:12 +01:00
Brad Fitzpatrick a6c5d23742 ipn, ipn/ipnlocal: add Notify.SelfChange
Add a new bus signal that lets reactive consumers (containerboot, kube
agents, sniproxy, tsconsensus, etc.) react to self-node updates without
having to subscribe to the full netmap. Today those consumers either
watch Notify.NetMap (which on large tailnets is expensive to encode and
ship per watcher) or poll. SelfChange is a cheap, narrow alternative:
addresses, name, key expiry, capabilities, etc.

Consumers that need additional state can react to SelfChange and then
fetch the relevant bits on demand via existing LocalClient methods.

Producer-side, every netmap-bearing setControlClientStatus call now
also publishes SelfChange. Future changes will migrate individual
in-tree consumers off Notify.NetMap to this signal, and eventually
gate the legacy NetMap emission to platforms whose host GUIs still
require it.

Updates #12542

Change-Id: I4441650b0e085d663eb6bf26a03748b7d961ca49
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-30 14:47:03 -07:00
Brad Fitzpatrick 9f343fdc0c client/local, ipn/localapi, all: add CertDomains and DNSConfig accessors
Add two narrow LocalAPI accessors so callers don't have to subscribe to
the IPN bus and pull a full *netmap.NetworkMap just to read DNS-shaped
fields:

  - GET /localapi/v0/cert-domains returns DNS.CertDomains.
  - GET /localapi/v0/dns-config returns the full tailcfg.DNSConfig.

Migrate in-tree callers off the netmap-on-the-bus pattern:

  - kube/certs.waitForCertDomain still wakes on the IPN bus but now
    queries CertDomains via LocalClient.CertDomains rather than
    reading n.NetMap.DNS.CertDomains. The kube LocalClient interface
    and FakeLocalClient gain a CertDomains method.
  - cmd/tailscale dns status calls LocalClient.DNSConfig directly
    instead of opening a NotifyInitialNetMap watcher.
  - cmd/tailscale configure kubeconfig switches from a netmap watcher
    + serviceDNSRecordFromNetMap to LocalClient.DNSConfig +
    serviceDNSRecordFromDNSConfig.

This is part of a series moving callers away from depending on the
netmap traveling on the IPN bus, so the bus payload can shrink in a
later change.

Updates #12542

Change-Id: Ie10204e141d085fbac183b4cfe497226b670ad6c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-30 13:50:46 -07:00
Michael Ben-Ami 822299642b feature/conn25: centralize config on Conn25 with atomic access
We have two sources of truth for configuration state: the node view
(from the netmap/policy) and prefs (the --advertise-connector option).
These come with two independent update paths: onSelfChange for node view
changes and profileStateChange for pref changes.

Centralize config on Conn25 so that onSelfChange and profileStateChange
can update their independent parts without bundling changes together.
The old bundled approach required read-modify-write, which opened the
door to potential TOCTOU bugs. The node view config is
stored as an atomic.Pointer[config] and the prefs-derived field
(advertise-connector) becomes an independent atomic.Bool. onSelfChange
creates a fresh config and stores it atomically. profileStateChange sets
the bool.

This also establishes clearer lines of responsibility:

 - Configuration state lives on Conn25. Methods that need to read
   config (isConnectorDomain, mapDNSResponse, the IPMapper methods)
   are on Conn25, and use the atomics for synchronization.

 - "Active" state (address allocations, transit IP mappings) lives on
   client and connector, and use a mutex for synchronization on that
   state, without conflicting with configuration synchronization.
   It's fine for active state to be out of sync with config — e.g. a
   transit IP allocated for an app should still be tracked, and gracefully
   expired, even if the app is removed from the node view.
   Removing config responsibility from client/connector makes these
   cases clearer to handle.

 - In cases where the client or connector does need access to
   config-derived state, e.g. a client reconfiguring its IP pools from
   the IPSets in the config, we can use closures for the
   client or connector to get just the latest state it needs from the
   config. See getIPSets() in this commit.

 - As of this commit, the connector doesn't need config-derived state at
   all.

Fixes tailscale/corp#40872

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-04-30 16:29:56 -04:00
Brad Fitzpatrick 159cf8707a ipn/ipnlocal, all: split LocalBackend.NetMap into NetMapNoPeers / NetMapWithPeers
Add two narrower accessors alongside the existing
[LocalBackend.NetMap], with docs that distinguish their semantics:

  - NetMapNoPeers: cheap (returns the cached *netmap.NetworkMap with
    a possibly-stale Peers slice). For callers that only read non-Peers
    fields like SelfNode, DNS, PacketFilter, capabilities.
  - NetMapWithPeers: documented as returning an up-to-date Peers slice.
    For callers that genuinely need to iterate Peers or call
    PeerByXxx.

Mark the existing NetMap deprecated and point readers at the two new
accessors. NetMap, NetMapNoPeers, and NetMapWithPeers all currently
return the same value (b.currentNode().NetMap()): this commit is a
no-op behaviorally, just a renaming and migration of in-tree callers.
A subsequent change in the same series will switch
NetMapWithPeers to actually rebuild the Peers slice from the live
per-node-backend peers map (O(N) per call), at which point the
distinction between the two new accessors becomes load-bearing.

Migrate in-tree callers to the appropriate accessor based on what
fields they read:

  - NetMapNoPeers (most common): localapi handlers, peerapi accept,
    GetCertPEMWithValidity, web client noise request, doctor DNS
    resolver check, tsnet CertDomains/TailscaleIPs, ssh/tailssh
    SSH-policy/cap reads, several LocalBackend internals
    (isLocalIP, allowExitNodeDNSProxyToServeName, pauseForNetwork
    nil-check, serve config).
  - NetMapWithPeers: writeNetmapToDiskLocked (persist full netmap to
    disk for fast restart), PeerByTailscaleIP lookup.

Tests still call the legacy NetMap; they'll see the deprecation
warning but otherwise behave identically.

Also add two pieces of plumbing the next change in this series will
need, but which are already useful on their own:

  - [client/local.GetDebugResultJSON]: a generic [Client.DebugResultJSON]
    that decodes directly into a target type T, avoiding the
    marshal/unmarshal roundtrip callers otherwise need.
  - localapi "current-netmap" debug action: returns the current
    netmap (with peers) as JSON. Documented as debug-only — the
    netmap.NetworkMap shape is internal and may change without notice.

This commit is part of a series breaking up a larger change for
review; on its own it is a no-op refactor.

Updates #12542

Change-Id: Idbb30707414f8da3149c44ca0273262708375b02
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-30 11:14:06 -07:00
Brad Fitzpatrick 92179b1fc7 cmd/hello: split server into helloserver package
Move the template, request handler, and HTTP/HTTPS server wiring out
of package main and into a new cmd/hello/helloserver package so the
server can be embedded in other binaries. The main package now only
constructs a helloserver.Server with the production addresses and
calls Run.

While here, drop the -http, -https, and -test-ip flags along with the
dev-mode template and fake-data fallbacks they enabled; the binary is
only run in production.

Updates tailscale/corp#32398

Change-Id: Id1d38b981733334cafc596021130f36e1c1eed67
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-30 08:40:55 -07:00
David Bond 644c3224e9 cmd/{containerboot,k8s-operator}: don't return pointers to maps (#19593)
This commit modifies the usage of the `egressservices.Configs` type
within containerboot and the k8s operator.

Originally it was being thrown around as a pointer which is not required
as maps are already pointers under the hood.

Signed-off-by: David Bond <davidsbond93@gmail.com>
2026-04-30 16:11:00 +01:00
Brad Fitzpatrick 815bb291c9 cmd/tailscale/cli: allow tag without "tag:" prefix in 'tailscale up'
If a user passes --advertise-tags=foo,bar (with no colons in any
segment), automatically prepend "tag:" client-side so it goes on the
wire as "tag:foo,tag:bar". Segments that already contain a colon are
left untouched and must be fully-qualified ("tag:foo"), which keeps
the door open for future colon-bearing syntax.

This was originally added in cd07437ad (2020-10-28) and then reverted
in 1be01ddc6 (2020-11-10) over forward-compatibility concerns. But
then it was realized in 2026-04-29 that this was always safe for
future extensiblity anyway (tags can't contain colons-- tag:foo:bar is
invalid anyway, per the 2020 CheckTag restrictions). So if we wanted
to perhaps some hypothetical --advertise-tags=tagset:setfoo or "group:foo",
we'd still have syntax to do, as it can't conflict with tag:group:foo.

Avery signed off on this on Slack: "Ok, I withdraw my objection to
auto-qualifying tag names in advertise-tags and I hope I won't regret
it :)"

Updates #861

Change-Id: I06935b0d3ae909894c95c9c2e185b7d6a219ff32
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-30 07:13:48 -07:00
Brad Fitzpatrick f343b496c3 wgengine, all: remove LazyWG, use wireguard-go callback API for on-demand peers
Replace the UAPI text protocol-based wireguard configuration with
wireguard-go's new direct callback API (SetPeerLookupFunc,
SetPeerByIPPacketFunc, RemoveMatchingPeers, SetPrivateKey).

Instead of computing a trimmed wireguard config ahead of time upon
control plane updates and pushing it via UAPI, install callbacks so
wireguard-go creates peers on demand when packets arrive. This removes
all the LazyWG trimming machinery: idle peer tracking, activity maps,
noteRecvActivity callbacks, the KeepFullWGConfig control knob, and the
ts_omit_lazywg build tag.

For incoming packets, PeerLookupFunc answers wireguard-go's questions
about unknown public keys by looking up the peer in the full config.
For outgoing packets, PeerByIPPacketFunc (installed from
LocalBackend.lookupPeerByIP) maps destination IPs to node public keys
using the existing nodeByAddr index.

Updates tailscale/corp#12345

Change-Id: I4cba80979ac49a1231d00a01fdba5f0c2af95dd8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 19:46:19 -07:00
Brad Fitzpatrick b313bffbe7 control/tsp, tstest/integration/testcontrol: deflake TestMapAgainstTestControl
The test was flaky under stress with "AddRawMapResponse N: node not
connected" failures. The root cause was in testcontrol's addDebugMessage:
it conflated "no streaming poll registered" with "wake-up channel buffer
momentarily full". The single-slot updatesCh is just a lossy wake-up
signal, but the streaming serveMap loop has fast paths
(takeRawMapMessage and the hasPendingRawMapMessage continue) that don't
drain it. A stale notification could remain buffered, causing the next
sendUpdate to fail even though msgToSend had been queued and the
streaming poll would still pick it up.

Detect the real failure case (no streaming poll) by checking
s.updates[nodeID] directly, and treat sendUpdate's buffer-full result as
benign — the message is in msgToSend, which is the source of truth.

Also plumb an optional *health.Tracker through tsp.ClientOpts to the
underlying ts2021.Client and supply one in the tests, eliminating the
"## WARNING: (non-fatal) nil health.Tracker (being strict in CI)" stack
dumps emitted by controlhttp.(*Dialer).forceNoise443 under CI.

Fixes #19583

Change-Id: Ib2334376585e8d6562f000a0b71dea0117acb0ff
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 16:11:00 -07:00
Claus Lensbøl 978b6a81b2 ipn/ipnlocal: always ReSTUN when starting up without a cache (#19586)
78627c1 introduced starting up and preserving the DERP server from
cache, but also changed it so the initial ReSTUN would not fire when
setting the DERPMap.

Change this so when not working from a cache, the ReSTUN will always
fire during startup.

Updates #19585

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-04-29 18:56:57 -04:00
Jordan Whited c0a9728fe2 derp/derpserver: fix Server.UpdateRateLimits docs
As of 0e9f9e2bd it is possible to have an infinity per-client limit,
with finite global.

Updates tailscale/corp#40962

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-04-29 14:43:12 -07:00
Jordan Whited 0e9f9e2bd8 derp/derpserver: support global rate limiting independent of per-client
This commit enables the operator to set a global rate limit without any
per-client.

Updates tailscale/corp#40962

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-04-29 14:15:53 -07:00
Brad Fitzpatrick 15cba0a3f6 tstest/natlab/vmtest: add TestDiscoKeyChange
Add a vmtest that brings up two gokrazy nodes A and B behind two
One2OneNAT networks (so direct UDP works in both directions and any
slowness can't be blamed on NAT traversal), establishes a WireGuard
tunnel A → B with TSMP, then rotates B's disco key four times and
asserts that the data plane recovers in both directions after each
rotation. All pings are TSMP (the data-plane ping; disco pings would
not exercise the WireGuard tunnel itself).

The five pings:

  1. A → B  (initial; brings up the tunnel; 30s budget)
  2. B → A  after rotate (LocalAPI rotate-disco-key debug action)
  3. A → B  after rotate (LocalAPI)
  4. B → A  after restart (SIGKILL; gokrazy supervisor respawns)
  5. A → B  after restart (SIGKILL)

Each post-rotation ping gets a 15-second budget. Two unavoidable
multi-second waits dominate today:

  - The rotate-then-a→b phase takes ~10s on main because of LazyWG.
    After B's WantRunning bounce, B's wgengine resets its
    sentActivityAt/recvActivityAt maps and trims A out of the
    wireguard-go config as an "idle peer"; B only re-adds A on
    inbound activity, by which point A's first few TSMP packets
    have been silently dropped at B's tundev. The
    bradfitz/rm_lazy_wg branch removes that trimming entirely
    (verified locally: this phase drops to <100ms there).

  - The restart phases take ~5s for wireguard-go's RekeyTimeout
    handshake retry. After SIGKILL+respawn the first WG handshake
    init from the restarted node sometimes goes into the void
    (likely the brief peer-removed window in the receiver's
    two-step maybeReconfigWireguardLocked reconfig during which
    the peer is absent from wireguard-go), and wg-go's 5s+jitter
    retransmit timer is the next opportunity to retry. That retry
    succeeds and the staged TSMP packet flushes. Intrinsic to the
    protocol's retransmit policy.

Once LazyWG is removed and the first-handshake-after-reconfig race
is fixed, the budget should drop to 5s.

Supporting changes:

  ipn/ipnlocal: DebugRotateDiscoKey now toggles WantRunning off and
  back on after rotating the disco key. magicsock.Conn.RotateDiscoKey
  only resets local disco state; without also dropping wireguard-go
  session keys, peers keep encrypting with their stale per-peer
  session against us until their rekey timer fires (WireGuard has no
  data-plane signaling to invalidate sessions). Bouncing WantRunning
  runs the engine through Reconfig(empty) → authReconfig, which
  drops every peer's WG session so the next packet either way
  triggers a fresh handshake.

  ipn/ipnlocal, ipn/localapi: add a debug-only "peer-disco-keys"
  LocalAPI action ([LocalBackend.DebugPeerDiscoKeys]) that returns
  a map[NodePublic]DiscoPublic from the current netmap. Tests reach
  it via [local.Client.DebugResultJSON]. We do not surface disco
  keys via [ipnstate.PeerStatus] because adding a non-comparable
  [key.DiscoPublic] field there breaks reflect-based test helpers
  (e.g. TestFilterFormatAndSortExitNodes' use of cmp.Diff), and
  general LocalAPI clients have no need for disco keys. Since the
  debug LocalAPI is gated behind the ts_omit_debug build tag, this
  endpoint is automatically stripped from small binaries.

  cmd/tta: add /restart-tailscaled handler (Linux-only, via /proc walk)
  to drive the SIGKILL phase. On gokrazy the supervisor respawns
  tailscaled within a second.

  tstest/integration/testcontrol: add Server.AllOnline. When set,
  every peer entry in MapResponses is marked Online=true. Several
  disco-key handling fast paths in controlclient and wgengine
  (removeUnwantedDiscoUpdates, removeUnwantedDiscoUpdatesFromFull
  NetmapUpdate, the wgengine tsmpLearnedDisco fast path) only fire
  for online peers; without this flag, tests exercising disco-key
  rotation only hit the offline-peer code paths, which mask issues
  and are several seconds slower in this scenario. Finer-grained
  per-node online tracking can be added later.

  tstest/natlab/vmtest: add Env.RotateDiscoKey,
  Env.RestartTailscaled, Env.PeerDiscoKey, Node.Name, an
  [AllOnline] EnvOption that plumbs through to
  testcontrol.Server.AllOnline, and an exported
  Env.Ping(from, to, type, timeout). Ping replaces the unexported
  helper so callers can specify both a ping type (PingDisco for
  warming peer state, PingTSMP for asserting end-to-end
  connectivity) and a deadline. PeerDiscoKey returns its LocalAPI
  error so callers inside tstest.WaitFor can retry transient
  failures rather than fataling the test.

Updates #12639
Updates #13038

Change-Id: I3644f27fc30e52990ba25a3983498cc582ddb958
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 12:58:00 -07:00
Brad Fitzpatrick 22ff402da9 wgengine/magicsock: restore SetDERPMap signature, add SetDERPMapWithoutReSTUN
Commit 78627c132f changed the signature of magicsock.Conn.SetDERPMap to
take an additional bool doReStun parameter. Avoid both the boolean
parameter and the API signature change by restoring SetDERPMap to its
original single-argument form and adding a new SetDERPMapWithoutReSTUN
method for the cache-loading caller that wants to skip the post-set
ReSTUN.

Updates #19490

Change-Id: I97d9e82156bfc546ccf59756d1ea52f039b5de06
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 12:46:15 -07:00
Adriano Sela Aviles 1cd8bcc827 tailcfg: extend services model for client application actions
Updates: tailscale/corp#40648
Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-04-29 11:33:13 -07:00
Brad Fitzpatrick 70f0b261b6 go.mod, gokrazy: bump to fork of gokrazy/gokrazy init process for syslog change
When we switched to monogok in 371d6369cd, we lost our gokrazy fork's
change to let the syslog be configured from the Linux cmdline.

That's sent upstream in gokrazy/gokrazy#275 but still in review. Meanwhile,
revert to a fork, while still keeping monogok. Monogok was updated to
support an alternate init package, which is now hosted temporarily at
https://github.com/tailscale/ts-gokrazy

This means we can rip out the log polling loop out of pending PR #19568
and go ack to using syslog.

Updates #13038

Change-Id: I36931ee8eecc40d6165ad036c6181dfb07b86ba2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 11:27:41 -07:00
Alex Valiushko 01d0bdd253 cmd/derper,derp: add metrics for rate limit hits (#19560)
Expvars track count of rate limiters exceeding their threshold.
Covers (1) global rate limiter and (2) total of local rate limiters.

Also publish optional rate-limit metrics during ExpVar() call
if -rate-config is specified. Fixes current rate-limit metrics
being published outside of "derp" in /debug/vars.

Updates tailscale/corp#38509

Change-Id: Ic7f5a1e890d0d7d3d7b679daa4b5f8926a6a6964
Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
2026-04-29 10:29:09 -07:00
Claus Lensbøl be7cce74ba wgengine/userspace: do not fall back to old key on tsmpLearned mismatch (#19575)
The mismatch behaviour of falling back to a previous key could end up
breaking connections when the netmap update took longer than the 2
seconds allowed in controlClient.auto for netmap updates, or if the
controlClient context was canceled. This could end up breaking
legitimate updates to the netmap for disco keys coming from control.

Instead, log the event, and let the connection be reset to that of the
key as that is safer.

Issue found by @bradfitz.

Updates #19574

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-04-29 13:23:04 -04:00
Brad Fitzpatrick fd6ae2fad4 tstest/natlab/vmtest: serialize per-platform setup with sync.Once
Two cloud-platform nodes (e.g. sr-a and sr-b in TestSiteToSite) boot in
parallel via errgroup and both call ensureCompiled and the inline image
preparation block, racing to Begin() the same shared *Step (which is
deduped by name in Env.Step). The second goroutine panics:

    panic: Step "Compile linux_amd64 binaries": Begin called in state running
    panic: Step "Prepare ubuntu-24.04 image": Begin called in state done

ensureCompiled had a TOCTOU dedup attempt (released compileMu before
doing the work, only added to the compiled set at the end), and image
preparation had no dedup at all.

Replace the compiled set with a per-key map[string]*sync.Once for each
of compile and image preparation, so concurrent callers serialize on
the Once and only the first executes Begin/work/End.

Fixes commit 02ffe5baa8.

Updates #13038

Change-Id: If710bcc9e0aafebf0ad5b61553bae11458d976d7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 09:54:58 -07:00
Brad Fitzpatrick 02ffe5baa8 tstest/natlab/vmtest: add macOS VM snapshot caching for fast test starts
Cache a pre-booted macOS VM snapshot on disk so subsequent test runs
restore from the snapshot instead of cold-booting. The snapshot is keyed
by the Tart base image digest and a code version constant
(macOSSnapshotCodeVersion); bumping either invalidates the cache.

Snapshot preparation (one-time):
- Boot the Tart base image with a NAT NIC (--nat-nic flag)
- Wait for SSH, compile and install cmd/tta as a LaunchDaemon
- TTA polls the host via AF_VSOCK for an IP assignment; during prep
  the host replies "wait"
- Disconnect NIC, save VM state via SIGINT

Test fast path (cached, ~7s to agent connected):
- APFS clone the snapshot, write test-specific config.json
- Launch Host.app with --disconnected-nic --attach-network --assign-ip
- VZ restores from SaveFile.vzvmsave (~5s with 4GB RAM)
- TTA's vsock poll gets the IP config, sets static IP via ifconfig
  (bypasses DHCP entirely), switches driver addr to the IP directly
  (bypasses DNS), and resets the dial context so the reverse-dial
  reconnects immediately
- TTA agent connects to test driver within ~2s of IP assignment

Key optimizations:
- 4GB RAM instead of 8GB: halves SaveFile.vzvmsave (1.4GB vs 2.4GB),
  halves restore time (5.5s vs 11s)
- AF_VSOCK IP assignment: bypasses macOS DHCP (~5-7s saved)
- Direct IP dial: bypasses DNS resolution for test-driver.tailscale
- Dial context reset: cancels stale in-flight dials from snapshot
- Kill instead of SIGINT for test VM cleanup (no state save needed)
- Parallel VM launches

Also:
- Add TestDriverIPv4/TestDriverPort constants to vnet
- Add --nat-nic and --assign-ip flags to Host.app
- Fix SIGINT handler: retain DispatchSource globally, use dispatchMain()
- Add vsock listener (port 51011) to Host.app for IP config protocol
- Add disconnectNetwork() to VMController for clean snapshot state
- Fix Makefile: set -o pipefail so xcodebuild failures aren't swallowed

Updates #13038

Change-Id: Icbab73b57af7df3ae96136fb49cda2536310f31b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 08:17:13 -07:00
M. J. Fromberger 7b53550fe6 control/controlclient: fix a nil-indirection bug in DERP key pruning (#19565)
Upon deciding to update the LastSeen timestamp, we weren't checking that the
field we are replacing into was non-nil. Rather than add an additional check,
just allocate a fresh pointer for the updated time.

Updates #19564

Change-Id: I589ebe65175fc7677c04a31dd6c4670e2531ee62
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-04-29 07:57:38 -07:00
David Bond a29e42135b cmd/k8s-operator: add nodeSelector to DNSConfig resource (#19429)
This commit modifies the `DNSConfig` resource to allow customisation of
the `spec.nodeSelector` field in the nameserver pods.

Closes: https://github.com/tailscale/tailscale/issues/19419

Signed-off-by: David Bond <davidsbond93@gmail.com>
2026-04-29 15:56:33 +01:00
Brad Fitzpatrick 4cec06b8f2 tstest/natlab/vmtest: add macOS VM screenshot streaming to web UI
When --vmtest-web is set, Host.app is launched with --screenshot-port 0
to start a localhost HTTP server that captures the VZVirtualMachineView
display. The Go test harness parses the SCREENSHOT_PORT=<port> line from
stdout, then polls every 2 seconds for JPEG thumbnails and pushes them
over WebSocket to the web dashboard.

Clicking a screenshot thumbnail opens a full-resolution image proxied
through the web UI's /screenshot/{node} endpoint.

Screenshot events are excluded from the EventBus history (they're large
and only the latest matters, stored in NodeStatus.Screenshot).

Updates #13038

Change-Id: I9bc67ddd1cc72948b33c555d4be3d8db06a41f6d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 07:48:26 -07:00
Claus Lensbøl 78627c132f wgengine/magicsock,ipn/ipnlocal: store and load homeDERP from cache (#19491)
With netmap caching, the home DERP of the self node was neither saved to
the cache or loaded from it, making nodes not stick to a DERP when
starting without a connection to control.

Instead, make sure that when a cache is available, load that cache,
before looking for DERP servers. This is implemented by allowing a skip
of ReSTUN in setting the DERP map (we must have a DERP map before
setting the home DERP), so the DERP from cache will set itself and be
sticky until a connection to control is established.

Making DERP only change when connected to control is handled by existing
code from f072d017bd.

Updates #19490

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-04-29 10:24:09 -04:00
Alex Chan 1841a93ab2 ssh/tailssh: mark TestSSHRecordingCancelsSessionsOnUploadFailure as flaky (again)
This test is still flaking on macOS, so mark it as such so we can track
and investigate further.

Updates #7707

Change-Id: I640da3c1068a90a9815caab2df9431bceb01f846
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-29 14:22:09 +01:00
Alex Chan bb91bb842c all: remove everything related to non-seamless key renewal
Seamless key renewal has been the default in all clients since 1.90.
We retained the ability to disable it from the control plane as a
precaution, but we haven't seen any issues that require us to disable it.

We're now removing all the code for non-seamless key renewal, because we
don't expect to turn it on again, and indeed it's been untested in the
field for three releases so might contain latent bugs!

Updates tailscale/corp#33042

Change-Id: I4b80bf07a3a50298d1c303743484169accc8844b
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-29 10:03:26 +01:00
Noel O'Brien 40088602c9 cmd/hello: remove hello.ipn.dev (#19567)
Fixes #19566

Signed-off-by: Noel O'Brien <noel@tailscale.com>
2026-04-28 17:54:29 -07:00
Brad Fitzpatrick b2d4ba04b6 tstest/natlab/vmtest: add macOS VM support using Tart base images
Add macOS VM support to the vmtest framework using Tart's pre-built
macOS images (ghcr.io/cirruslabs/macos-tahoe-base) instead of building
from IPSW. The Tart image has SIP disabled and SSH enabled.

At test time, the Tart base image's disk, NVRAM, and hardware identity
are APFS-cloned into a tailmac-compatible directory layout, and the VM
is booted headlessly via tailmac's Host.app (Virtualization.framework)
with its NIC connected to vnet's dgram socket.

New features:
- tailmac.go: ensureTartImage (auto-pull), cloneTartToTailmac (format
  conversion), startTailMacVM (launch + cleanup)
- NoAgent() node option for VMs without TTA installed
- LANPing() for ICMP reachability testing via TTA's /ping endpoint
- IsMacOS field on OSImage, with GOOS/GOARCH support
- Dgram socket listener in Start() for macOS VMs
- Fix ReadFromUnix error spam on dgram socket close in vnet

TestMacOSAndLinuxCanPing verifies a macOS Tart VM and a gokrazy Linux
VM can ping each other on the same vnet LAN.

Updates #13038

Change-Id: I5e73a27878abf009f780fdf11a346fc857711cff
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-28 12:51:40 -07:00
Brad Fitzpatrick ec7b11d986 tstest/natlab/vmtest, cmd/tta: add TestTaildrop
Add a vmtest that brings up two Ubuntu nodes, each behind its own
EasyNAT, joined to the tailnet. The sender pushes a small file via
"tailscale file cp" and the receiver fetches it via "tailscale file
get --wait", asserting that the filename and contents round-trip
unchanged.

To make Taildrop work in vmtest, three small pieces were needed:

The Linux/FreeBSD cloud-init now starts tailscaled with --statedir as
well as --state=mem:, so the daemon has a VarRoot to host Taildrop's
incoming-files directory. State itself remains in-memory (so nothing
persists across reboots); only the var-root scratch space is on disk.

vmtest.New grows a variadic EnvOption parameter and a SameTailnetUser
helper. When the option is passed, Start sets AllNodesSameUser=true
on the embedded testcontrol.Server. Cross-node Taildrop requires the
sender and receiver to share a Tailnet user (or have an explicit
PeerCapabilityFileSharingTarget granted between them, which we don't
plumb here), so TestTaildrop opts in. Existing tests don't.

cmd/tta gains /taildrop-send and /taildrop-recv handlers that wrap
"tailscale file cp" and "tailscale file get --wait", plus
Env.SendTaildropFile and Env.RecvTaildropFile helpers in vmtest that
drive them.

Updates #13038

Change-Id: I8f5f70f88106e6e2ee07780dd46fe00f8efcfdf1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-28 12:27:55 -07:00
Brad Fitzpatrick 4b8e0ede6d tstest/natlab/{vmtest,vnet}, cmd/tta: add TestMullvadExitNode
Add a vmtest that brings up a Tailscale client, an Ubuntu VM acting
as a Mullvad-style plain-WireGuard exit node, and a non-Tailscale
webserver, each on its own NAT'd vnet network with a distinct WAN
IP. The test exercises Tailscale's IsWireGuardOnly peer code path:
the way the control plane wires Mullvad exit nodes into a client's
netmap, including the per-client SelfNodeV4MasqAddrForThisPeer
source-IP rewrite that lets a Tailscale CGNAT IP egress through a
plain-WireGuard tunnel that has no idea what Tailscale is.

The mullvad VM doesn't run wireguard-tools or kernel WireGuard;
instead, a new TTA endpoint /wg-server-up creates a real Linux TUN
named wg0, drives it with wireguard-go (already vendored), and
configures the kernel side (ip addr/up, ip_forward, iptables NAT
MASQUERADE) so decrypted traffic from the peer egresses with the
mullvad VM's WAN IP. Userspace vs kernel WireGuard makes no
difference on the wire — what's being tested is Tailscale's
plain-WireGuard exit-node code path, not the kernel module — and
this lets the test avoid downloading and installing .deb packages
inside the VM.

Adds Env.BringUpMullvadWGServer (calls /wg-server-up, returns the
generated WG public key as a key.NodePublic), Env.SetExitNodeIP
(EditPrefs ExitNodeIP directly, for exit nodes whose IPs aren't
discoverable via TTA), Env.ControlServer (exposes the underlying
testcontrol.Server so tests can UpdateNode / SetMasqueradeAddresses
to inject custom peers), and Env.Status (fetches a node's tailscale
status, used to read the client's pubkey so we can pin it as the
WG server's only allowed peer).

The test verifies that the webserver's echoed source IP is the
client's WAN with no exit node selected, the mullvad VM's WAN with
the WG-only peer selected as exit, and the client's WAN again after
clearing.

Updates #13038

Change-Id: I5bac4e0d832f05929f12cb77fa9946d7f5fb5ef1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-28 11:31:48 -07:00
Andrew Lytvynov da0a277565 client/web: fail /api/routes requests with empty flags (#19548)
If both ExitNode and AdvertiseRoutes flags are empty, then the request
is invalid and should fail. Previously it would wipe out any existing
values configured for these prefs because of the assumption in the
handler that exactly one of them is set.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-28 11:16:47 -07:00
Brad Fitzpatrick f7f8b0a0a5 cmd/tailscale/cli: drive "file cp" progress and offline warning from peerAPI
The Online bit in PeerStatus comes from control's last-known state and
can lag reality, so gating "tailscale file cp" on it is both unreliable
and pushes correctness onto the server. Just try the push directly.

In runCp, when the target's PeerStatus says it's offline, no longer
fail upfront; getTargetStableID returns the StableID anyway. Replace
the static "is offline" warning with a 3-second timer armed for the
first file: if the timer fires before peerAPI bytes have flowed, we
print a warning to stderr. The wording depends on whether control
reported the peer offline ("is reportedly offline; trying anyway") or
online ("is not replying; trying anyway"). The warning is printed with
a leading vt100 clear-line and a trailing newline so it doesn't get
painted over by the progress redraw and so the next progress redraw
lands on a fresh line below it.

Both the timer disarm and the progress display now read from
tailscaled's OutgoingFile.Sent (subscribed via WatchIPNBus) instead of
the local-body counter. That's the difference between bytes-acked-by-
local-tailscaled (what countingReader.n was measuring; useless for
detecting an unreachable peer because for small files net/http buffers
the entire body into the unix-socket conn before the peerAPI dial has
even started) and bytes-pulled-toward-peerAPI (what tailscaled is
actually doing, reflected in OutgoingFile.Sent). The previous code
reported 100% within milliseconds for a 3 KiB file even when the peer
was unreachable.

Add --update-interval (default 250ms) to control the progress repaint
cadence; zero or negative disables the progress display entirely. The
printer now also stops repainting once it observes Sent at full size
with a near-zero rate for >2s, so a stuck transfer doesn't keep
clobbering whatever the rest of runCp is trying to print.

Updates #18740

Change-Id: I189bd1c2cd8e094d372c4fee23114b1d2f8024b4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-28 11:03:58 -07:00
Brad Fitzpatrick 88cb6f58f8 tool/updateflakes, cmd/nardump: replace update-flake.sh with Go tool
Consolidate go.mod.sri and go.toolchain.rev.sri into a single
flakehashes.json file at the repo root, owned by a new Go program at
tool/updateflakes. The JSON is consumed by flake.nix via
builtins.fromJSON and by any future Go code via the FlakeHashes
struct that defines its schema.

Each block records its input fingerprint alongside the SRI it
produced: the goModSum (a sha256 over go.mod and go.sum) for the
vendor block, and the literal rev string from go.toolchain.rev for
the toolchain block. updateflakes regenerates a block only when its
recorded fingerprint disagrees with the current input.

Doing the gating by content rather than file mtimes avoids the usual
mtime hazards across git checkouts, clones, and merges. It also
means re-runs with no input changes are essentially free, and a
re-run that touches only one input pays only for that one block.

The two blocks have no shared state -- vendor invokes go mod vendor
into one tempdir, toolchain fetches and extracts a tarball into
another -- so they run concurrently via errgroup. Cold time is
bounded by the slower of the two rather than their sum.

Also takes the opportunity to fold the toolchain fetch into a single
curl|tar pipeline (no intermediate .tar.gz on disk).

Split cmd/nardump into a thin package main and a new package nardump
library at cmd/nardump/nardump that holds the NAR encoder and SRI
helper. tool/updateflakes imports the library directly rather than
building and exec'ing the nardump binary at runtime. The library
uses fs.ReadLink (Go 1.25+) instead of os.Readlink, so it no longer
requires the caller to chdir into the FS root for symlink targets to
resolve. WriteNAR now wraps its writer in a bufio.Writer internally
(unless the caller already passed one) and flushes on return, so
callers don't pay for tiny writes against slow underlying writers.

The cache-busting line in flake.nix and shell.nix is known to live
at end of file, so updateCacheBust walks the lines in reverse.

make tidy timings on this machine, before: ~14s every run.
After:

  warm (no input changes):       0.05s
  vendor block stale only:       1.4s
  toolchain block stale only:    5.0s
  cold (no flakehashes.json):    5.0s

Updates #6845

Change-Id: I0340608798f1614abf147a491bf7c68a198a0db4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-28 10:18:32 -07:00
Andrew Dunham 33714211c8 net/dns: use os.Root to prevent path traversal in darwin resolver
The darwinConfigurator writes split DNS resolver files to
/etc/resolver/$SUFFIX using os.WriteFile with string concatenation.
A crafted MatchDomain value containing path traversal sequences
(e.g. "../evil") could write files outside the resolver directory.

Use os.OpenRoot to confine all file operations in SetDNS and
removeResolverFiles to the resolver directory. os.Root rejects any
path component that escapes the root, returning an error instead of
following the traversal.

Also parametrize the resolver directory path on the struct to enable
testing with t.TempDir(), and add tests.

As far as I can tell, this would require a malicious controlplane to
exploit, but still worth fixing.

Updates tailscale/corp#39751

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2026-04-28 11:08:22 -04:00
Brad Fitzpatrick b9eac14ef9 tstest/natlab/vmtest: add web UI for watching VM tests live
Add an optional --vmtest-web flag that starts an HTTP server showing a
live dashboard for vmtest runs. The dashboard includes:

- Step progress tracker showing all test phases (compile, image prep,
  QEMU launch, agent connect, tailscale up, test-specific steps)
  with status icons and elapsed times
- Per-VM "virtual monitor" cards showing serial console output
  streamed in realtime via WebSocket
- Per-NIC DHCP status (supporting multi-homed VMs like subnet routers)
- Per-node Tailscale status (hidden for non-tailnet VMs)
- Test status badge (Running/Passed/Failed) with live elapsed timer
- Event log showing all lifecycle events chronologically

Architecture follows the existing util/eventbus HTMX+WebSocket pattern:
the server pushes HTML fragments with hx-swap-oob attributes over a
WebSocket, and HTMX routes them to the correct DOM elements by ID.

Key components:
- vmstatus.go: Step tracker (Begin/End lifecycle), EventBus (pub/sub
  with history for late joiners), VMEvent types, NodeStatus tracking
- web.go: HTTP server, WebSocket handler, template loading, ANSI-to-HTML
  conversion via robert-nix/ansihtml, deterministic port selection
- assets/: HTML templates, CSS, HTMX library (copied from eventbus)
- vnet/vnet.go: DHCP event callback on Server for observing DHCP lifecycle
- qemu.go: Console log file tailing with manual offset-based reading

Usage:
  go test ./tstest/natlab/vmtest/ --run-vm-tests --vmtest-web=:0 -v

When using :0, a deterministic port based on the test name is tried
first so re-runs get the same URL, falling back to OS-assigned on
conflict.

Updates #13038

Change-Id: I45281347b3d7af78ed9f4ff896033984f84dcb4d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-28 07:46:04 -07:00
Alex Chan 0ac09721df tka: reduce boilerplate code in the tests
Updates #cleanup

Change-Id: Id69d509f5e470fb5fb50b5c5c4ca61f000389c53
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-28 16:42:48 +02:00
Brad Fitzpatrick cb239808a6 tstest/natlab/vmtest: add --test-version flag
Add a --test-version flag to run the natlab VM tests against
released tailscale/tailscaled binaries downloaded from
pkgs.tailscale.com instead of building from the source tree.

The value can be a concrete release like "1.97.255", or "stable" /
"unstable" which resolve to the latest TarballsVersion on that track
via pkgs.tailscale.com/<track>/?mode=json. The track for a concrete
version is derived from its minor (even=stable, odd=unstable). The
host architecture (amd64 or arm64) selects the tarball.

Tarballs are cached + extracted under
~/.cache/tailscale-vmtest/builds/<version>_<arch>/ so they are not
re-fetched per test. tta is still always built from the local tree.
Cloud VMs (Ubuntu, Debian) pick up the downloaded binaries via the
existing files.tailscale file server. Non-Linux GOOS (FreeBSD) falls
back to building from source since pkgs.tailscale.com only ships
Linux tarballs. Gokrazy nodes continue to use binaries baked into
the gokrazy image; --test-version is a no-op for them.

Updates #13038

Change-Id: I213ef7db362dd17bf69d2685cbf2ab0ec5a3fee1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-28 06:59:26 -07:00
Daniel Pañeda 7735b15de3 cmd/k8s-operator: truncate long label values in metrics resources (#18895)
* cmd/k8s-operator: truncate long label values in metrics resources

Kubernetes label values have a 63-character limit, but resource names
can be up to 253 characters. When a Service or Ingress with a long
name is exposed via Tailscale, the operator fails to reconcile because
it uses the parent resource name directly as label values on metrics
Services.

Truncate label values that may exceed the limit by keeping the first
54 characters and appending a SHA256-based hash suffix to preserve
uniqueness.

Fixes #18894

Signed-off-by: Daniel Pañeda <daniel.paneda@clickhouse.com>
Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

* cmd/k8s-operator: move TruncateLabelValue to shared k8s-operator package

Move the label truncation helper to k8s-operator/utils.go so it can be
reused by other components that need to produce valid Kubernetes labels.

Signed-off-by: Daniel Pañeda <daniel.paneda@clickhouse.com>
Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

* cmd/k8s-operator: truncate long domain label values in cert resources

Applies TruncateLabelValue to certResourceLabels in order to prevent API
server validation failures. This covers both the HA Ingress and kube-apiserver
proxy reconcilers, as both flow through certResourceLabels.

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

* cmd/k8s-operator: remove empty metrics_resources_test.go, use hyphens in test names to satisfy go vet

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

---------

Signed-off-by: Daniel Pañeda <daniel.paneda@clickhouse.com>
Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
Co-authored-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-04-28 14:11:59 +01:00
Kristoffer Dalby 384b7fb561 release/dist/qnap: preserve .codesigning files as build artifacts
Stop deleting .qpkg.codesigning files in build-qpkg.sh and include
them in the returned artifact list from buildQPKG.

These files contain the last 32 characters of the base64-encoded CMS
signature produced by QDK code signing. They are consumed by pkgserve
to populate <signature> entries in the QNAP repository XML, matching
the format used by myqnap.org and qnapclub.eu.

Updates corp#33203

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-04-28 12:29:56 +01:00
Will Norris 2d85f37f39 client/systray: support several different color themes
Currently we only have a dark theme icon with white and grey dots over
a black background. For some desktops, a logo with black and grey dots
over a white background might be preferable. And for desktops where the
bar is *almost* black or white, but not quite, an option to render the
logo with dots only and no background can look really nice.

Add a new -theme flag to the systray command with the default staying
the same as it is today.

Updates #18303

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
2026-04-27 18:54:14 -07:00
License Updater 325f52c654 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-04-27 18:38:06 -07:00
Brad Fitzpatrick d0ae993334 tstest/natlab/vmtest: add more subnet router tests
Add two tests building on TestExitNode's framework:

TestSubnetRouterPublicIP brings up a client, a subnet router, and a
webserver, each on its own NAT'd network with distinct WAN IPs. The
subnet router advertises the webserver's network as a route. The test
toggles the client's --accept-routes preference and asserts that the
webserver's echoed source IP switches between the client's own WAN
(direct dial) and the subnet router's WAN (forwarded through the
router and SNAT'd).

TestSubnetRouterAndExitNode adds a fourth node, an exit node that
advertises 0.0.0.0/0 + ::/0, and uses a table-driven layout with
subtests to cover the four combinations of (exit on/off, subnet
on/off). The case where both are on confirms longest-prefix match
wins: the subnet router's /24 takes precedence over the exit node's
/0. The exit node itself is configured with --accept-routes=off so
that, in the exit-only case, it forwards directly to the simulated
internet rather than re-routing the forwarded traffic via the subnet
router (which would otherwise mask the exit node's WAN as the
observed source).

Adds an Env.SetAcceptRoutes helper for toggling the RouteAll pref via
EditPrefs, used by both tests.

Updates #13038

Change-Id: Ifc2726db1df2f039c477c222484f535bebc40445
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-27 17:06:17 -07:00
Brad Fitzpatrick c0e6ffed0d tstest/tailmac: add NIC hot-swap, disconnected NIC, and screenshot server
Add NIC attachment hot-swap support to Host.app: VZNetworkDevice.attachment
is writable at runtime, so --disconnected-nic creates a NIC with no
attachment, and --attach-network hot-swaps it to a vnet dgram socket
after boot/restore. macOS detects link-up and does DHCP.

Refactor TailMacConfigHelper: extract createDgramAttachment() and
createDisconnectedNetworkDeviceConfiguration() from the monolithic
createSocketNetworkDeviceConfiguration().

Add --screenshot-port flag for headless mode. Host.app serves GET
/screenshot as JPEG via a localhost HTTP server, capturing the
VZVirtualMachineView via CGWindowListCreateImage. The Go test harness
polls these to push live thumbnails to the web dashboard.

Also: SIGINT handler in headless mode for clean VM state save.

Updates #13038

Change-Id: I42fba0ecd760371b4ec5b26a0557e3dd0ba9ecae
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-27 17:03:09 -07:00
Brad Fitzpatrick 5c1738fd56 tstest/natlab/{vmtest,vnet}, cmd/tta: add TestExitNode
Add a vmtest TestExitNode that brings up a client, two exit nodes, and a
non-Tailscale webserver, each on its own NAT'd vnet network with a
distinct WAN IP. The test cycles the client's exit node setting between
off, exit1, and exit2 and asserts that the webserver echoes the expected
post-NAT source IP for each.

Three pieces were needed to make this work:

vnet now forwards TCP between simulated networks at the packet level,
mirroring the existing UDP path. When a guest VM sends TCP to another
simulated network's WAN IP, the source network's gateway rewrites src
via doNATOut and routeTCPPacket hands the packet off to the destination
network, which rewrites dst via doNATIn and writes the rewritten frame
onto the destination LAN. The TCP stacks of the two guest VM kernels
talk end-to-end; vnet just NATs the IP/port headers in flight, so all
TCP semantics (handshakes, options, sequence numbers, payload) are
preserved without a gvisor TCP termination in the middle. Adds a
focused TestInterNetworkTCP that exercises this path without any
Tailscale machinery.

cmd/tta binds its outbound dial to the default route's interface using
SO_BINDTODEVICE. Without that, the moment tailscaled installs
0.0.0.0/0 → tailscale0 in response to setting an exit node, TTA's
existing TCP connection to test-driver gets rerouted through the exit
node. From the test driver's perspective the connection's packets then
arrive with the exit node's WAN IP as the source rather than the
client's, so they don't match the existing flow and the connection is
dead — manifesting in the test as a hang on EditPrefs (which had
actually completed in milliseconds on the daemon side, but whose
response never made it back). Pinning the socket to the underlying NIC
keeps TTA's agent connection on a real interface regardless of any
policy routing tailscaled installs later. We bind rather than carry the
Tailscale bypass fwmark because the fwmark approach is conditional on
tailscaled having configured SO_MARK-based policy routing, while
binding is unconditional.

vmtest grows an Env.SetExitNode helper that sets ExitNodeIP via
EditPrefs through the agent, used by the new test.

Updates #13038

Change-Id: I9fc8f91848b7aa2297ef3eaf71fed9d96056a024
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-27 16:54:20 -07:00
Alex Chan 10b63f27ce tstest/clock: explain what happens if you don't set a Start time
While working on #19444, I assumed that omitting `Start` would return a
clock that started at January 1, year 1, because that's the zero value
for a `time.Time`, but actually it uses the current UTC time instead.

This behaviour is non-obvious, so document it.

Updates #cleanup

Change-Id: Id91400778578655953ff3e1671ce470db97cfe91
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-28 00:15:46 +02:00
Brad Fitzpatrick ad5436af0d tstest/largetailnet, tstest/integration/testcontrol: add in-process large-tailnet benchmark
Add a Go benchmark that exercises a single tailnet client (a [tsnet.Server]
running in the test process) against a synthetic large initial netmap and
a stream of caller-driven peer add/remove deltas, all in-process.

The harness is split in two parts:

  - tstest/largetailnet, a reusable package containing a [Streamer]
    that hijacks the map long-poll on a [testcontrol.Server] via the new
    AltMapStream hook, sends one initial MapResponse with N synthetic
    peers, and forwards caller-supplied delta MapResponses on the same
    stream. Helpers like MakePeer / AllocPeer build synthetic peers with
    unique IDs and addresses derived from the Tailscale ULA range.

  - tstest/largetailnet/largetailnet_test.go, BenchmarkGiantTailnet
    (headless tailscaled workload, no IPN bus subscriber) and
    BenchmarkGiantTailnetBusWatcher (GUI-client workload with one
    Notify subscriber attached). Both are gated on
    --actually-test-giant-tailnet (skipped by default), stand up an
    in-process testcontrol + tsnet.Server, let Up block until the
    initial N-peer netmap has been processed, then ResetTimer and run
    add+remove pairs via b.Loop. Per-delta sync is via a test-only
    [ipnlocal.LocalBackend.AwaitNodeKeyForTest] channel that closes
    once the just-added peer key appears in the netmap (no-watcher
    variant) or via bus-Notify drain (bus-watcher variant).

To support the hijack, [testcontrol.Server] grows an AltMapStream hook
and a small MapStreamWriter interface for benchmarks/stress tests that
need to drive a controlled MapResponse sequence; the normal serveMap
path is untouched when AltMapStream is nil. The streamer answers
non-streaming "lite" map polls (which controlclient issues before the
streaming long-poll to push HostInfo) with an empty MapResponse and
returns immediately, so the streaming poll that follows is the one
that gets the initial netmap.

The benchmark is intended for before/after comparisons of netmap- and
delta-handling changes targeted at large tailnets. CPU profiles on
unmodified main show the expected O(N) hotspots:
setControlClientStatusLocked / authReconfigLocked /
userspaceEngine.Reconfig / setNetMapLocked, plus JSON encoding of the
full Notify.NetMap to bus watchers (which dominates the BusWatcher
variant).

Median ms/op over 10 runs on unmodified main, by tailnet size N:

       N      no-watcher   bus-watcher
   10000          32          166
   50000         222          865
  100000         504         1765
  250000        1551         4696

Recommended invocation:

	go test ./tstest/largetailnet/ -run=^$ \
	    -bench='BenchmarkGiantTailnet(BusWatcher)?$' \
	    -benchtime=2000x -timeout=10m \
	    --actually-test-giant-tailnet \
	    --giant-tailnet-n=250000 \
	    -cpuprofile=/tmp/giant.cpu.pprof

Updates #12542

Change-Id: I4f5b2bb271a36ba853d5a0ffe82054ef2b15c585
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-27 11:47:12 -07:00
Mike O'Driscoll 33342aec32 The connmark save/restore rules in mangle/PREROUTING restore the Tailscale bypass fwmark (0x80000) onto reply packets so that rp_filter's reverse-path check routes through the main table instead of table 52. However, the kernel only uses the packet's fwmark during the rp_filter lookup when net.ipv4.conf.all.src_valid_mark=1. (#19537)
On systems where this sysctl defaults to 0 (including GCP VMs), rp_filter performs its lookup with fwmark=0, hits rule 5270 then table 52 and routes to 0.0.0.0/0 dev tailscale0, and drops every reply packet arriving on the physical interface as a martian. This breaks all connectivity when using an exit node: DERP, DNS, control plane, and even the cloud metadata service.

Set src_valid_mark=1 when enabling the connmark rules so the rp_filter workaround actually works in these cases.

Updates #3310
Updates tailscale/corp#37846

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-04-27 13:52:45 -04:00
Brad Fitzpatrick 0e10a3f580 net/tsdial, ipn/localapi, client/local: let clients dial non-Tailscale addresses directly
Add a tsdial.Dialer.UserDialPlan method that resolves an address and
reports whether the dialer would route it via Tailscale. The LocalAPI
/dial handler now uses this to skip proxying for addresses that aren't
Tailscale routes (e.g. localhost), returning a Dial-Self response with
the resolved address so the client can dial it directly. This avoids
an unnecessary round-trip through the daemon for local connections.

The client's UserDial handles the new response by dialing the resolved
address itself, and the server passes the pre-resolved IP:port for
Tailscale dials to avoid redundant DNS lookups.

Thanks to giacomo and Moyao for pointing this out!

Updates tailscale/corp#39702

Change-Id: I78d640f11ccd92f43ddd505cbb0db8fee19f43a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-27 09:33:27 -07:00
Andrew Lytvynov 649781df84 util/pidowner: remove unused package (#19521)
Added in 2020, this appears to be unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-27 09:25:46 -07:00
Andrew Lytvynov a70629eae3 util/topk: remove unsued package (#19524)
Added in 2024 and appears unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-27 09:13:40 -07:00
Andrew Lytvynov 346d6bb04c util/sysresources: remove unused package (#19523)
Added a few years ago and appears to be unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-27 09:13:30 -07:00
Andrew Lytvynov 64bb40b45b util/pool: remove unused package (#19522)
Added in 2024 and appears to be unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-27 09:13:14 -07:00
BeckyPauley 7477a6ee47 cmd/k8s-operator: use dynamic resource names in e2e ingress tests (#19536)
Replace hardcoded resource names with dynamically generated names in
k8s-operator-e2e ingress tests to avoid collisions with stale resources.

Updates #tailscale/corp#40612

Signed-off-by: Becky Pauley <becky@tailscale.com>
2026-04-27 13:40:46 +01:00
Evan Lowry 3a05c450ce posture: add HealthTracker for serial number retrieval (#19181)
Device posture checking can fail while enabled if tailscaled does not
have access to smbios. Previously, this was only observable by looking
in the tailscaled logs.

Fixes tailscale/corp#39314

Signed-off-by: Evan Lowry <evan@tailscale.com>
2026-04-25 15:42:47 -03:00
Brad Fitzpatrick f3b2f9b0ef all: fix duplicate package docs and tighten TestPackageDocs
TestPackageDocs walked into directories starting with "." (such as
.claude worktrees) and only logged warnings on duplicate package docs
across files in a directory. Skip dot-directories (which covers the
old .git but also .claude), ignore files with "//go:build ignore" so
command files don't falsely trip the duplicate check, and promote the
duplicate-doc warning to a t.Errorf.

While here, deduplicate the package docs that were previously only
logged: drop the redundant comment from client/systray/startup-creator.go,
move the comprehensive taildrop doc into feature/taildrop/doc.go, and
remove a leftover doc fragment from feature/condlite/expvar/omit.go.

The tstest/integration/vms allowlist is no longer needed since the
//go:build ignore filter now handles its dns_tester.go and udp_tester.go
files generically.

Fixes #19526

Change-Id: Id794d96bd728826a1883a054e4a244f90fa05d3d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-24 19:01:43 -07:00
Andrew Lytvynov 873b8b8e2e maths: remove unused package (#19516)
Added in 2025 and appears to be unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-24 16:17:10 -07:00
Andrew Lytvynov d64ed4af89 util/expvarx: remove unused package (#19519)
Added in 2024 and appears to be unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-24 16:16:42 -07:00
Andrew Lytvynov 4195e34f79 util/cstruct: remove unused package (#19518)
Added in 2022 and appears to be unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-24 16:09:54 -07:00
Andrew Lytvynov 323198b348 envknob/logknob: remove unused package (#19515)
Added in 2023 and appears to be unused.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-24 15:48:06 -07:00
James Tucker 1b40911611 wgengine/netstack: absorb all quad-100 traffic locally, never leak to peers
Previously, handleLocalPackets intercepted traffic to the Tailscale
service IP (100.100.100.100 / fd7a:115c:a1e0::53) only for an allow-list
of ports: TCP 53/80/8080 and UDP 53. Any other port returned
filter.Accept, letting the packet fall through to the ACL filter and
wireguard-go, which would attempt a peer lookup. No peer owns the
quad-100 AllowedIP, so after ~5s pendopen.go would log:

    open-conn-track: timeout opening ...; no associated peer node

This is the common "conntrack error no peer found for 100.100.100.100:853"
log spam seen in the wild (e.g. from systemd-resolved or another
resolver speculatively trying DoT on quad-100). It also leaks quad-100
packets onto the tailnet.

Remove the port allow-list so handleLocalPackets absorbs every quad-100
packet into netstack regardless of IP protocol or port. Traffic never
reaches the conntrack / peer-routing layers.

With the allow-list gone, acceptTCP needs a corresponding guard: on a
quad-100 TCP port we don't serve, execution used to fall through to the
isTailscaleIP case (quad-100 is in the tailscale IP range), which
rewrote the dial target to 127.0.0.1:<port> and forwardTCP'd the
connection to whatever happened to be listening on the host's loopback
at that port. Add a hittingServiceIP case that RSTs cleanly instead,
placed before the isTailscaleIP fallthrough.

TestQuad100UnservedTCPPortDoesNotForward is a new integration test that
injects a TCP SYN to 100.100.100.100:853 via handleLocalPackets, stubs
forwardDialFunc, and asserts the dialer is not invoked; it catches
regressions of the acceptTCP recursion/loopback-redirection case.

Fixes #15796
Fixes #19421
Updates #3261
Updates #11305

Signed-off-by: James Tucker <james@tailscale.com>
2026-04-24 12:42:16 -07:00
Brad Fitzpatrick 006d7e180e version: use debug.ReadBuildInfo in CmdName on non-Windows
CmdName was re-opening the running executable and scanning it in
64KiB chunks for the Go modinfo markers on every call. The same
modinfo is already parsed at startup and exposed via
runtime/debug.ReadBuildInfo, so prefer that on non-Windows. Windows
still takes the scanning path because its GUI-binary override keys
off the on-disk executable name.

benchstat of BenchmarkCmdName (Linux, before vs after):

    goos: linux
    goarch: amd64
    pkg: tailscale.com/version
    cpu: Intel(R) Xeon(R) 6975P-C
               │  /tmp/old.txt  │            /tmp/new.txt             │
               │     sec/op     │   sec/op     vs base                │
    CmdName-16   556045.5n ± 1%   825.6n ± 1%  -99.85% (p=0.000 n=10)

               │ /tmp/old.txt  │             /tmp/new.txt             │
               │     B/op      │     B/op      vs base                │
    CmdName-16   64.587Ki ± 0%   1.156Ki ± 0%  -98.21% (p=0.000 n=10)

               │ /tmp/old.txt │            /tmp/new.txt            │
               │  allocs/op   │ allocs/op   vs base                │
    CmdName-16     8.000 ± 0%   7.000 ± 0%  -12.50% (p=0.000 n=10)

Fixes #19486

Change-Id: I925c5e28b64815a602459beb6c8dab8779339a6c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-24 09:48:11 -07:00
Fran Bull 306fab796c feature/conn25: add the ability to return addresses to the IP Pools
This will be used as part of the address assignment expiry work.

Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-04-24 08:48:48 -07:00
kari-ts aa740cb393 ipnlocal/drive: reduce noisey per-peer remote logs (#19493)
This drops the per peer "appending remote" log while constructing the remote list, which can get noisy on big tailnets, and keeps logs around remote availability checks, including whether a peer is missing, offline, lacks PeerAPI reachability, lacks sharing permission, or is available.

Updates tailscale/corp#40580

Signed-off-by: kari-ts <kari@tailscale.com>
2026-04-24 08:26:33 -07:00
Andrew Lytvynov ad9e6c1925 go.mod: bump github.com/google/go-containerregistry (#19500)
This drops an indirect dependency on the old github.com/docker/docker
(which was replaced with github.com/moby/moby) and fixes a couple recent
CVEs.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2026-04-23 10:39:27 -07:00
Claus Lensbøl ee76a7d3f8 wgengine/magicsock: do not send TSMP disco when connected (#19497)
When there is an active connection between devices, do not send new
disco keys via TSMP.

Updates #12639

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-04-23 12:23:57 -04:00
Brad Fitzpatrick a7d8aeb8ae misc/genreadme,tempfork/pkgdoc,tsnet: generate README.md files from godoc
Adds a CI check to keep opted-in directories' README.md files in sync
with their package godoc. For now tsnet (and its sub-packages under
tsnet/example) is the only opted-in tree. The list of directories
lives in misc/genreadme/genreadme.go as defaultRoots, so CI and humans
both just run `./tool/go run ./misc/genreadme` with no arguments.

The check piggybacks on the existing go_generate job in test.yml and
fails if any README.md is out of date, pointing the user at the same
command.

Along the way:

 - tempfork/pkgdoc now emits Markdown instead of plain text: headings
   become level-2 with no {#hdr-...} anchors, and [Symbol] doc links
   resolve to pkg.go.dev URLs, including for symbols in the current
   package (which the default Printer would otherwise emit as bare
   #Name fragments with no backing anchor in a README). Parsing no
   longer uses parser.ImportsOnly, so doc.Package knows the package's
   symbols and can resolve [Symbol] links at all.

 - genreadme also emits a pkg.go.dev Go Reference badge at the top of
   a library package's README; suppressed for package main.

 - tsnet/tsnet.go's package godoc is expanded in idiomatic godoc
   syntax — [Type], [Type.Method], reference-style [link]: URL
   definitions — rather than Markdown-flavored [text](url) or
   backtick-quoted identifiers, so that both pkg.go.dev and the
   generated README.md render cleanly from a single source.

Fixes #19431
Fixes #19483
Fixes #19470

Change-Id: I8ca37e9e7b3bd446b8bfa7a91ac548f142688cb1
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Walter Poupore <walterp@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-22 15:13:09 -07:00
Brad Fitzpatrick 311dd3839d wgengine/magicsock: replace peers slice with peersByID map; add Upsert/RemovePeer
Replace Conn.peers (sorted views.Slice) with peersByID, a
map[tailcfg.NodeID]tailcfg.NodeView. The only caller that needed
the sorted slice (the disco message receive path's binary search)
becomes a single map lookup. Drop nodesEqual.

Add Conn.UpsertPeer / Conn.RemovePeer for O(1) single-peer endpoint
work. RemovePeer also performs a targeted single-disco-key cleanup
(previously that scan was O(discoInfo)).

Extract the shared per-peer upsert body as upsertPeerLocked; still
used by SetNetworkMap's bulk path. SetNetworkMap is documented as
the bulk / initial / self-change path; UpsertPeer and RemovePeer
are preferred for single-peer changes.

Make the relay server set update O(1) per peer: add serverUpsertCh
/ serverRemoveCh to relayManager with matching run-loop handlers.
UpsertPeer / RemovePeer evaluate the per-peer relay predicate
locally and dispatch upsert or remove. The full-rebuild
updateRelayServersSet stays for the initial netmap, filter
changes, and fallback.

Move the hasPeerRelayServers atomic from Conn onto relayManager,
next to the serversByNodeKey map it summarizes. The run loop is
now the single writer and needs no back-pointer to Conn;
endpoint's two hot-path readers take one extra hop to
de.c.relayManager.hasPeerRelayServers but the cost is the same
atomic load.

No callers use UpsertPeer/RemovePeer yet; a subsequent change will
plumb per-peer add/remove through the incremental map update path.

Updates #12542

Change-Id: If6a3442fe29ccbd77890ea61b754a4d1ad6ef225
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-22 15:07:11 -07:00
Brad Fitzpatrick f289f7e77c tstest/natlab/vmtest,cmd/tta: add TestSiteToSite
Verifies that site-to-site Tailscale subnet routing with
--snat-subnet-routes=false preserves the original source IP
end-to-end.

Topology: two sites, each with a Linux subnet router on a NATted WAN
plus an internal LAN, and a non-Tailscale backend on each LAN. Backends
are given static routes pointing to their local subnet router for the
remote site's prefix; an HTTP GET from backend-a to backend-b over
Tailscale returns a body containing backend-a's LAN IP.

Adds the supporting vmtest.SNATSubnetRoutes NodeOption and plumbs
snat-subnet-routes through TTA's /up handler. The webserver started by
vmtest.WebServer now also echoes the remote IP, for the preservation
assertion.

Adds a /add-route TTA endpoint (Linux-only for now) and a vmtest
Env.AddRoute helper so the test can install the backend static routes
through TTA rather than needing a host SSH key and debug NIC.

ensureGokrazy now always rebuilds the natlab qcow2 (once per test
process, via sync.Once) so the test picks up the new TTA and webserver
behavior.

This is pulled out of a larger pending change that adds FreeBSD
site-to-site subnet routing support; figured we should have at least
the Linux test covering what works today.

Updates #5573

Change-Id: I881c55b0f118ac9094546b5fbe68dddf179bb042
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-22 12:11:30 -07:00
Fernando Serboncini 81fbcc1ac8 cmd/tsnet-proxy: add tsnet-based port proxy tool (#19468)
Exposes a local port on the tailnet under a chosen hostname. Raw TCP by
default; --http or --https reverse-proxy with Tailscale-User-* identity
headers from WhoIs, matching tailscaled's serve header conventions.

Useful as a one-shot to put a dev server on the tailnet.

Fixes #19467

Change-Id: I79f63cfbbedf7e40cf0f1f51cbae8df86ae90cdf

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-04-22 13:34:18 -04:00
James 'zofrex' Sanderson 36f094ea3b ipn/ipnlocal: deflake TestStateMachine{,Seamless} (#19475)
Remove the remaining known sources of flakiness in TestStateMachine and
TestStateMachineSeamless.

Updates tailscale/corp#36230
Updates #19377

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2026-04-22 10:22:47 +01:00
Brad Fitzpatrick 12813dee02 tool/listpkgs: add --has-go-generate filter flag too
For use in parallelizing go:generate up-to-date checks.

Updates tailscale/corp#28679

Change-Id: Ifc31c56de4225ba2e0fc048b0f18974dc2f2fc82
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-21 17:51:13 -07:00
Fran Bull d7916d4369 feature/conn25: add expiresAt field to addrs
And use it to allow overwrites of old address assignments in the conn25 client.

The magic and transit address pools from which the addresses come are limited
resources and we want to reuse them. This commit is a small part of that bigger
need.

We expect to follow soon:
 * Extending expiry if assignments are still in use.
 * Returning expired addresses back to the pools so they can be reallocated.

Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-04-21 14:22:39 -07:00
Fran Bull 19544b4b81 feature/conn25: move byConnKey from addrAssignments to client
addrAssignments is a table of addrs with lookup indices, representing
the assignments of magic+destination+transit IP addresses the client has
made dut to the domain being routed because of an app
.
byConnKey is a map of node public key to prefixes of transit IPs, so it
is associated with, but not that data itself, and can be its own thing.

Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-04-21 14:22:39 -07:00
Walter Poupore 04415b8177 misc/genreadme: port from corp (#19477)
also port pkgdoc, into the tempfork folder

git rev from corp at the time this copy was made:

-  e909fc93595414c90ff1339cece7c84500ab3c36

Updates #19470

Change-Id: I3d98d82020a2b336647b795210dcb7065dfa44d7


Change-Id: Ie63141860b76dd2d5ae3ff52f8a4bcdf6106421e

Signed-off-by: Walter Poupore <walterp@tailscale.com>
2026-04-21 12:18:37 -07:00
Fernando Serboncini 1669b0d3d4 misc/git_hook: fix building git_hook in a nested worktree (#19473)
When the repo is checked out as a nested worktree, a go.work in the
outer tree hijacks module resolution, which makes the rebuild fails
with "main module does not contain package." Set GOWORK=off for the
build since the hook is self-contained.

Bumps HOOK_VERSION so existing installs pick up the fix.

Updates #cleanup

Change-Id: Ibd14849efc26e4e1893c5b8e300caa71573f54bd

Signed-off-by: Fernando Serboncini <fserb@fserb.com.br>
2026-04-21 11:42:53 -04:00
Brad Fitzpatrick 1e68a11721 logtail: run HTTP tests in-memory with memnet + synctest
TestEncodeAndUploadMessages waited on the default 2s FlushDelay,
making the logtail package the slowest non-integration test in
the tree (~2s real time). Switch the shared harness from an
httptest.Server-on-loopback to a memnet.Listener-backed *http.Server
and run the tests inside synctest.Test, so fake time advances the
flush timer instantly.

Drops the net/http/httptest dependency from these tests. Combined
with the TestMain non-localhost dial guard added in the previous
commit, no test in this package can accidentally reach the real
log.tailscale.com server. Whole package now runs in ~7ms.

Updates tailscale/corp#28679

Change-Id: Ie0e7a6a79641384ed0eecb99d767e17cda8bb944
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-20 13:33:10 -07:00
Brad Fitzpatrick 5b06e32f33 logtail: add Config.Disabled to suppress the startup banner
NewLogger unconditionally writes a "logtail started" banner before
it returns, which callers that later call Logger.SetEnabled(false)
have no way to suppress: the banner is already buffered for upload
by the time the caller gets the logger back.

Add Config.Disabled so callers that know up front they want the
logger to start disabled (e.g. Android's remote-logging opt-out)
can seed the state before NewLogger's internal Write. The process-
wide Disable kill switch still takes precedence; SetEnabled can
still flip the state at runtime.

Updates #13174
Updates tailscale/tailscale-android#695

Change-Id: Icc4fa88c198447cf0faa707264dac84e359fe52c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-20 13:33:10 -07:00
Adriano Sela Aviles 4a832d8d0f types/netmap,client/local: modify services format in local api
Reverting back to the previous format (including
the "svc:" prefix in the map's keys).

Note that the /services endpoint in localapi, along
with any software that relies on this is unreleased
so this does not break any clients.

Updates tailscale/corp#40052

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-04-20 09:22:23 -07:00
James 'zofrex' Sanderson ffae275d4d ipn/ipnlocal,tailcfg: add /debug/tka c2n endpoint (#19198)
Updates tailscale/corp#35015

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2026-04-20 16:00:03 +01:00
James 'zofrex' Sanderson ec86f0ff93 ipn/ipnlocal: make TestStateMachine less flaky (#19434)
TestStateMachine & TestStateMachineSeamless both flake a lot asserting the
"Shutdown" call on cc after a Logout. This is because Shutdown is called on
a goroutine to avoid a deadlock if it's called while holding the
LocalBackend lock (#18052).

This fixes that cause of flakes by waiting for LocalBackend's goroutine
tracker to have no goroutines running (so the goroutine that calls Shutdown
must have finished).

This does not make TestStateMachine non-flaky because it can flake later in
the test, too: the assertion on "unpause" after clearing the netmap between
"Start4" and "Start4 -> netmap" sometimes fails.

Updates tailscale/corp#36230
Updates #19377
Updates #18052

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2026-04-20 15:58:21 +01:00
Brad Fitzpatrick dfc2667f8f tstest/integration/testcontrol: make Stream w/ capver >= 68 match docs, prod
testcontrol wasn't following the document specs (and prod behavior) breaking
a WIP integration test elsewhere.

Updates tailscale/corp#40088

Change-Id: I02cf70894346bad7c85940b617d99c21c5310664
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-20 07:34:04 -07:00
Alex Chan cf76202aa3 ipn/ipnlocal: log the local and remote TKA HEADs during sync
Update this log message to show both the local and remote TKA HEAD; this
is useful for debugging issues on nodes that have fallen behind the
remote TKA HEAD.

Updates tailscale/corp#39455

Change-Id: Ia62ce15756180d2fbac4a898fb94d6143df08b54
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-19 16:52:48 +01:00
Scott Graham cb5a53c424 ipn/ipnlocal: preserve b.loginFlags in auto-login cc.Login calls
LocalBackend stores loginFlags at construction so that per-instance
properties (e.g. LoginEphemeral set by tsnet.Server.Ephemeral) persist
for the session. StartLoginInteractiveAs already merges b.loginFlags
into its cc.Login call, but the two auto-login call sites pass bare
controlclient.LoginDefault, silently dropping any stored flags.

Merge b.loginFlags at both auto-login call sites to match the existing
StartLoginInteractiveAs pattern. LoginDefault is zero so this is a
no-op when loginFlags is empty, and restores the documented behavior
when it isn't.

Fixes #15852

Signed-off-by: Scott Graham <scott.github@h4ck3r.net>
2026-04-17 23:31:18 -05:00
Adriano Sela Aviles 618dfd4081 client/local,types/netmap: modify services format in local api
Updates the format of the service map that is served over
the local api to be keyed without the "svc:" prefix. This
change is backwards incompatible, this is OK because there
is only one tailnet with the services-in-nodecapmap feature
flag enabled, and the client side changes that start showing
services over local api have not been released. (These were
added in 4fcce6000d).

Updates tailscale/corp#40052

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-04-17 14:14:03 -07:00
Fernando Serboncini 514d7d28e7 misc/git_hook: extract shared githook package; auto-rebuild on version bump (#19440)
Pull the hook logic into a reusable githook library package so
tailscale/corp can share it via a thin wrapper main instead of
keeping a forked copy in sync.

The install flow also changes: a wrapper scripts now build the
binary and reinstall the git hooks. Pulling new shared code no
longer requires re-running the installer.

Updates tailscale/corp#39860

Change-Id: I4d606d11c8c883015c190c54e3387a7f9fe4dd32

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-04-17 16:24:39 -04:00
Brad Fitzpatrick 1fbb834dc3 logtail: add Logger.SetEnabled to toggle uploads at runtime
Callers that need to turn logtail uploads on and off in response to
user preference or policy changes previously had no choice: the
package-level Disable is a one-way kill switch intended for the
controlplane DisableLogTail debug message, and requires a process
restart to undo.

Add a per-Logger disabled flag, toggled via SetEnabled, that drops
incoming entries without buffering while disabled. The process-wide
Disable still takes precedence, so a controlplane-issued kill switch
cannot be overridden by a client setting it back on.

To simplify https://github.com/tailscale/tailscale-android/pull/695

Updates #13174

Change-Id: I06e75bd719c851f5f837ca5b2d1e17f7c68355f0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-17 12:19:39 -07:00
kari-ts 8dda62cc24 feature/clientupdate: windows update should use tailscale.exe update (#19438)
Currently, clientupdate.NewUpdater().Update() is called directly inside tailscaled, which fatals. There is also a failure that doesn't return, causing a panic.

This fix allows us to use the same approach as startAutoUpdate, which is to find tailscale.exe and run tailscale.exe --update, though since it's calling the updater library directly, we get progress messages.

Fixes tailscale/corp#40430s

Signed-off-by: kari-ts <kari@tailscale.com>
2026-04-17 10:28:35 -07:00
BeckyPauley b239e92eb6 cmd/k8s-operator: add e2e test setup and l7 ingress test for multi-tailnet (#19426)
This change adds setup for a second tailnet to enable multi-tailnet e2e
tests. When running against devcontrol, a second tailnet is created via the
API. Otherwise, credentials are read from SECOND_TS_API_CLIENT_SECRET.

Also adds an l7 HA Ingress test for multi-tailnet.

Fixes tailscale/corp#37498

Signed-off-by: Becky Pauley <becky@tailscale.com>
2026-04-17 17:03:25 +01:00
Andrew Dunham d52ae45e9b cmd/cloner: deep-clone pointer elements in map-of-slice values
The cloner's codegen for map[K][]*V fields was doing a shallow
append (copying pointer values) instead of cloning each element.
This meant that cloned structs aliased the original's pointed-to
values through the map's slice entries.

Mirror the existing standalone-slice logic that checks
ContainsPointers(sliceType.Elem()) and generates per-element
cloning for pointer, interface, and struct types.

Regenerate net/dns and tailcfg which both had affected
map[...][]*dnstype.Resolver fields.

Fixes #19284

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2026-04-17 11:36:05 -04:00
Bjorn Stange 47ecbe5845 cmd/k8s-operator: add priorityClassName support to helm chart (#19236)
Expose priorityClassName in the operator Helm chart values so that
users can configure the operator deployment with a Kubernetes
PriorityClass. This prevents the operator pods from being preempted
by lower-priority workloads.

Fixes #19235

Signed-off-by: Bjorn Stange <bjorn.stange@expel.io>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:57:12 +01:00
Brad Fitzpatrick 00a08ea86d control/tsp: add lite map update support
Updates #12542
Updates tailscale/corp#40088

Change-Id: Idb4526f1bf1f3f424d6fb3d7e34ebe89a474b57b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-17 04:19:50 -07:00
Tom Proctor c2da563fef tstest/integration/vms: skip cloud-init package updates (#19443)
The package updates started getting really slow yesterday. We can do
better, but attempt a band aid fix for now, as the test is failing about
a third of the time on PR CI.

Updates tailscale/corp#40465

Change-Id: Icf53292ba83dd1ed76b9bdf9fb94a8f6fb448c07

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2026-04-17 10:39:47 +01:00
Brad Fitzpatrick 50d7176333 control/tsp, cmd/tsp: add low-level Tailscale protocol client and tool
Add a new control/tsp package providing a client for speaking the
Tailscale protocol to a coordination server over Noise, along with a
cmd/tsp binary exposing it as a low-level composable tool for
generating keys, registering nodes, and issuing map requests.

Previously developed out-of-tree at github.com/bradfitz/tsp; imported
here without git history.

Updates #12542

Change-Id: I6ad21143c4aefe8939d4a46ae65b2184173bf69f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-16 20:00:25 -07:00
Jordan Whited 69572c7435 derp/derpserver: add rate limit config metrics
Updates tailscale/corp#40421

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-04-16 12:48:41 -07:00
Michael Ben-Ami 1dc08f4d41 appc,feature/conn25: prevent clients from forwarding DNS requests and
modifying DNS responses for domains they are also connectors for

For Connectors 2025, determine if a client is configured as a
connector and what domains it is a connector for. When acting as a
client, don't install Split DNS routes to other connectors for those
domains, and don't alter DNS responses for those domains. The responses
are forwarded back to the original client, which in turn does the alteration,
swapping the real IP for a Magic IP.

A client is also a connector for a domain if it has tags that overlap
with tags in the configured policy, and --advertise-connector=true
in the prefs (not in the self-node Hostinfo from the netmap). We use the prefs
as the source of truth because control only gets a copy from the prefs, and
may drift. And the AppConnector field is currently zeroed out in the
self-node Hostinfo from control.

The extension adds a ProfileStateChange hook to process prefs changes,
and the config type is split into prefs and nodeview sub-configs.

Fixes tailscale/corp#39317

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-04-16 09:41:54 -04:00
Alex Chan 4f47c3c93d ipn/ipnlocal: log AUM hash on startup as base32, not hex
Before:

    tka initialized at head 325557575a59525354484e4a534f494b4c4e56575435583737564b5036584c4d4c335534554255344c344c36484c5a444a323341

After:

    tka initialized at head 2UWWZYRSTHNJSOIKLNVWT5X77VKP6XLML3U4UBU4L4L6HLZDJ23A

Printing the AUM hash as hex makes it difficult to compare to other AUM
hashes; stringifying it will make it consistent with other printing.

Updates #cleanup

Change-Id: Ic1e23a9ce6a71a53cff7d2190f9fa06eb838ab89
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-16 13:45:29 +01:00
Alex Valiushko d3ba1480f5 magicsock: invalidate endpoint on trust timeout (#19415)
Endpoint's best address was cleared on trustBestAddrUntil expiry
only if it was a udprelay connection. This generalizes invalidation
to also cover direct UDP.

Trust deadline is checked in two cases:

On disco ping timeout from the endpoint's best address.
Traffic goes DERP-only, heartbeats to the old address stop.
The discovery pings are still in flight, handled by the following.

On disco ping success from an alternative. BestAddr switches to the
working path, trust refreshed, eager discovery stops. The still
in flight pongs are handled by betterAddr().

Updates #19407


Change-Id: Ic41ed18edb4a6e4350a2d49271ba01566a6a6964

Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
2026-04-15 19:22:07 -07:00
Brad Fitzpatrick b39ee0445d util/httpm: open .git/index to defeat Go test caching
TestUsedConsistently shells out to git grep to find forbidden
http.Method* uses across the repo. Since the test itself doesn't
open any repo files, Go's test cache considers it unchanged
between commits and serves stale passing results even when new
violations are introduced.

Fix by opening .git/index, which makes Go's test cache track it
as an input. The index file changes on git reset, checkout, pull,
etc., so the cache is properly invalidated when moving between
commits.

Updates tailscale/corp#40359

Change-Id: If1497b992a545351bdd68cff279d60f5591fe70b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-15 15:44:19 -07:00
David Bond eea39eaf52 cmd/k8s-operator: add affinity rules to DNSConfig (#19360)
This commit modifies the `DNSConfig` custom resource to allow the
user to specify affinity rules on the nameserver pods.

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

Signed-off-by: David Bond <davidsbond93@gmail.com>
2026-04-15 22:39:04 +01:00
Jonathan Nobels acc43356c6 control/controlclient: enable request signatures on macOS (#19317)
fixes tailscale/corp#39422

Updates tailscale/certstore for properly macOS support and
builds the request signing support into macOS builds.  iOS and builds
that do not use cGo are omitted.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2026-04-15 14:11:14 -04:00
M. J. Fromberger 1e4934659b ipn/ipnlocal: discard cached netmaps upon panic during SetNetworkMap (#19414)
For debugging purposes, unstable builds will sometimes intentionally panic for
unexpected behaviours. We observed such a panic after loading a cached netmap,
but because we had a valid cached map, the client was unable to recover on its
own and the operator had to manually reset the cache.

As a defensive hedge, when netmap caching is enabled, check for a panic during
installation of a net network map: If one occurs, discard any cached netmaps
before letting the panic unwind, so that we do not lose the panic itself, but
reduce the need for manual intervention.

Updates #12639
Updates tailscale/corp#27300

Change-Id: I0436889c6bdc2fa728c9cb83630cd7b00a72ce68
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2026-04-15 11:07:42 -07:00
Anton Tolchanov 958bcda5bf control/controlclient: handle 429 responses during node registration
If we get a 429 response during node registration, use the `Retry-After`
header for backoff instead of the regular exponential backoff.

The rate limiter error is propagated to the user, just like other
registration errors are, e.g.

```
$ tailscale up
backend error: node registration rate limited; will retry after 57s
exit status 1
```

Updates tailscale/corp#39533

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2026-04-15 18:54:08 +01:00
Jordan Whited d8190e0de5 derp/derpserver: implement hierarchical token bucket rate limiting
By adding a server-global parent bucket. Per-client rate limiting is
subject to the parent bucket if global rate limiting is enabled.

This implementation is experimental, and all related APIs should be
considered unstable.

Updates tailscale/corp#40291

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-04-15 09:06:03 -07:00
Tom Meadows 5eb0b4be31 cmd/containerboot,cmd/k8s-proxy,kube: add authkey renewal to k8s-proxy (#19221)
* kube/authkey,cmd/containerboot: extract shared auth key reissue package

Move auth key reissue logic (set marker, wait for new key, clear marker,
read config) into a shared kube/authkey package and update containerboot
to use it. No behaviour change.

Updates #14080

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

* kube/authkey,kube/state,cmd/containerboot: preserve device_id across restarts

Stop clearing device_id, device_fqdn, and device_ips from state on startup.
These keys are now preserved across restarts so the operator can track
device identity. Expand ClearReissueAuthKey to clear device state and
tailscaled profile data when performing a full auth key reissue.

Updates #14080

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

* cmd/containerboot: use root context for auth key reissue wait

Pass the root context instead of bootCtx to setAndWaitForAuthKeyReissue.
The 60-second bootCtx timeout was cancelling the reissue wait before the
operator had time to respond, causing the pod to crash-loop.

Updates #14080

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

* cmd/k8s-proxy: add auth key renewal support

Add auth key reissue handling to k8s-proxy, mirroring containerboot.
When the proxy detects an auth failure (login-state health warning or
NeedsLogin state), it disconnects from control, signals the operator
via the state Secret, waits for a new key, clears stale state, and
exits so Kubernetes restarts the pod with the new key.

A health watcher goroutine runs alongside ts.Up() to short-circuit
the startup timeout on terminal auth failures.

Updates #14080

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

---------

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-04-15 16:13:46 +01:00
Brad Fitzpatrick dbf468740b control/controlclient: add patchify miss stats
Add an opt-in metrics.LabelMap tracking why patchifyPeer fails to
convert a PeersChanged entry into a PeersChangedPatch. The stats are
gated behind the TS_DEBUG_PATCHIFY_PEER_MISS envknob so there is zero
overhead in normal operation.

peerChangeDiff now takes an optional onFalse callback that is called
with the field name on every non-patchable return path. When the
envknob is off, nil is passed and replaced with a no-op at the top of
peerChangeDiff.

The resulting metric renders as:

    counter_patchify_miss{why="Hostinfo"} 2
    counter_patchify_miss{why="peer_not_found"} 1170

Updates tailscale/corp#40088

Change-Id: I2d4b9074bf42ec03ab296c0629a54106bafa873e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-15 08:05:57 -07:00
Claus Lensbøl 61c95f409c control/controlclient: accept key if last seen on exist node is absent (#19402)
On some nodes (found via natlab), the existing nodes last seen could be
unset. For these cases, we would want to accept the key and write a last
seen. This was breaking the cached netmap natlab tests.

Updates #12639

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-04-15 03:53:40 -04:00
Avery Pennarun effbe67fe3 wgengine/magicsock: remove pickPort, use port 0 to avoid TOCTOU race
pickPort would bind a UDP socket on :0 to get a free port, close
the socket, then hope to rebind to the same port in NewConn. This
is a TOCTOU race that can cause flaky test failures when another
process grabs the port in between.

Instead, pass Port: 0 to NewConn and let the OS assign the port
atomically, then read back the assigned port via conn.LocalPort().

Fixes #19409

Change-Id: Ie44b599fb93c361e29a05f2171ad747c46f82b7a
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Avery Pennarun <apenwarr@tailscale.com>
2026-04-14 18:08:47 -07:00
Naman Sood 6301a6ce4b util/linuxfw,wgengine/router: allow incoming CGNAT range traffic with nodeattr
Clients with the newly added node attribute
`"disable-linux-cgnat-drop-rule"` will not automatically drop inbound
traffic on non-Tailscale network interfaces with the source IP in the
CGNAT IP range. This is an initial proof-of-concept for enabling
connectivity with off-Tailnet CGNAT endpoints.

Fixes tailscale/corp#36270.

Signed-off-by: Naman Sood <mail@nsood.in>
2026-04-14 16:45:06 -04:00
Fernando Serboncini 5834058269 wgengine: replace reflect.DeepEqual with typed Equal for maybeReconfigInputs (#19365)
reflect.DeepEqual is expensive and allocates heavily. Replace it with
a field-by-field comparison that does zero allocations.

Adds tests and benchmarks for the new Equal method.

Fixes #19363

Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
2026-04-14 13:16:21 -04:00
Brad Fitzpatrick 943b426038 util/linuxfw: fix nil deref in nftables chain check
Fix a panic in getOrCreateChain when the kernel lacks nftables support
(CONFIG_NF_TABLES). When the nftables netlink connection fails, chain
objects returned by getChainFromTable can have nil Hooknum and Priority
fields. Dereferencing these caused tailscaled to SIGSEGV during router
configuration, which manifested as tailscaled silently crashing ~13
seconds after "tailscale up" on arm64 gokrazy (whose kernel.arm64
build doesn't include nftables).

Updates #13038

Change-Id: I14433616da5ed57895cad37038921fb4f79c3534
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-14 07:45:01 -07:00
Brad Fitzpatrick a0a8fae856 tstest/integration: use linkat to hardlink test binaries on Linux
Use linkat via /proc/self/fd with AT_SYMLINK_FOLLOW to create a
hardlink of the test binary instead of copying it. This avoids
copying ~50MB+ binaries into each test's temp directory, making
test setup faster and reducing disk I/O.

The simpler os.Link(b.Path, ret.Path) can't be used here because
the source binary lives in the first test's TempDir, which may be
cleaned up before later tests call CopyTo. The open FD keeps the
inode alive after the path is deleted, but os.Link needs a valid
path. (See also b9f468240f which tried os.Link but is racy for
this reason.)

The /proc/self/fd approach works without elevated privileges,
unlike AT_EMPTY_PATH which requires CAP_DAC_READ_SEARCH. If the
linkat fails for any reason (e.g. cross-filesystem temp dirs), it
falls back to the existing full-copy path.

Fixes #19397

Change-Id: I4b1f97f7e63a9ae9e09dce36dfbdd1f6cff92320
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-14 07:13:10 -07:00
Avery Pennarun 621dc9cf1b tstest: fix kernel version parsing for Debian-style version strings
The kernel version parser used strings.Cut with "-" to handle versions
like "5.4.0-76-generic", but Debian uses "+" in versions like
"6.12.41+deb13-amd64".

Use strings.IndexAny to find the first "-" or "+" and truncate there.

Fixes TestKernelVersion on Debian systems.

Fixes #19395

Change-Id: I70e5f95682d54baf908e51f9f4b51c130b00aaaa
Co-Authored-By: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Avery Pennarun <apenwarr@tailscale.com>
2026-04-14 07:11:44 -07:00
Brad Fitzpatrick 6aa10576c9 wgengine/magicsock: deflake TestTwoDevicePing compare-metrics-stats
The compare-metrics-stats subtest reset two independent counting
systems (physical connection counters and expvar.Int user metrics)
non-atomically. Background WireGuard keepalives arriving between the
resets could increment one system but not the other, causing
off-by-one packet/byte mismatches in either direction.

Replace the reset-then-compare pattern with snapshot-and-delta:
snapshot both systems before pings, snapshot again after, and compare
the deltas. This eliminates the non-atomic reset window entirely.
As a belt-and-suspenders safety net, tolerate a difference of exactly
one packet (and corresponding bytes) from a stray keepalive that
could still arrive in the narrow window between the two snapshots.

flakestress passes with ~5900 runs (~2800 without -race, ~3100 with
-race) but it also passed previously too. This is an annoying one to
repro.

Fixes #11762

Change-Id: I3447ad67e71c8146e85eed38b7a665033ef9e284
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-14 06:57:24 -07:00
Brad Fitzpatrick 49eb1b5d26 net/dns: fix TestDNSTrampleRecovery failure under flakestress
The test had two problems:

1. runFileWatcher passed hardcoded "/etc/" to the inotify watcher,
   but the test filesystem uses a temp directory prefix. The watcher
   was watching the real /etc/, never seeing the test's file writes.

2. The test's watchFile used gonotify.NewDirWatcher which creates
   goroutines that block on real inotify syscalls. These don't work
   inside synctest's fake-time bubble. The test only passed standalone
   by accident: gonotify walks /etc/ on startup producing fake events
   that happened to trigger trample detection at the right time.

Fix the path issue by adding ActualPath to the wholeFileFS interface,
which translates logical paths (like "/etc/resolv.conf") to real
filesystem paths (respecting any test prefix). Use it in
runFileWatcher so the inotify watch targets the correct directory.

Replace gonotify in the test with a one-shot timer that synctest can
advance through fake time, reliably triggering the trample check.

Fixes #19400

Change-Id: Idb252881ec24d0ab3b3c1d154dbdaf532db837d4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-14 06:55:35 -07:00
Claus Lensbøl 27f1d4c15d control/controlclient: improve filter on netmap updates (#19308)
The previous filters would allow for a handful of subtle issues such as
updating the last seen date when the key or online status had not
changed, and making online keys unconditionally make an engine update.

These have been fixed along side making no change updates from TSMP into
a no-op for the engine so we don't have to reconfigure.

A bunch of additional testing has been added as well.

Updates #12639

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
2026-04-14 08:43:07 -04:00
Patrick O'Doherty 0afaa29503 go.mod: upgrade go-git to v5.17.1
Partially resolve govulncheck warnings in OSS and corp.

Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2026-04-13 21:10:57 -07:00
Jordan Whited 75819aeed0 derp/derpserver: increase minimum token bucket size
And cap WaitN calls to prevent token bucket errors. Frame length is
inclusive of DERP key for FrameSendPacket frames.

Updates tailscale/corp#40171

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-04-13 19:30:31 -07:00
Avery Pennarun ab74ea0a67 tstest/integration: clear SSH_CLIENT env to prevent false positive detection
When running integration tests over SSH (e.g., in remote development
environments), the SSH_CLIENT environment variable is set. This causes
isSSHOverTailscale() to incorrectly detect an SSH session and change
behavior.

Clear SSH_CLIENT in the test node environment to prevent these false
positives.

Fixes #19393

Change-Id: I1411abf0be9704cce37051476efb04d59beed386
Signed-off-by: Avery Pennarun <apenwarr@tailscale.com>
2026-04-13 18:53:07 -07:00
Brad Fitzpatrick 9fbe4b3ed2 all: fix six tests that failed with -count=2
Avery found a bunch of tests that fail with -count=2.

Updates tailscale/corp#40176 (tracks making our CI detect them)

Change-Id: Ie3e4398070dd92e4fe0146badddf1254749cca20
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Co-authored-by: Avery Pennarun <apenwarr@tailscale.com>
2026-04-13 18:52:57 -07:00
James Tucker 13d5370951 .gitignore: explicitly include tool/go.exe
Updates #19255

Signed-off-by: James Tucker <james@tailscale.com>
2026-04-13 18:44:59 -07:00
Brad Fitzpatrick a97850f7e2 cmd/derper: fix TestLookupMetric to pass when run alone
TestLookupMetric was added in e8d140654 (2023-08-17) without
initializing the dnsCache and dnsCacheBytes globals. When run in
isolation, handleBootstrapDNS writes a nil body (from the
uninitialized dnsCacheBytes), causing getBootstrapDNS to fail
decoding an empty response with EOF.

Add a setDNSCache test helper that stores the dnsEntryMap, marshals
dnsCacheBytes, and registers a t.Cleanup to nil both out, so tests
that forget to call it will hit the dnsCache-nil fatal in
getBootstrapDNS rather than silently depending on prior test state.

Also add AssertNotParallel and a dnsCache-nil fatal check to
getBootstrapDNS, the central helper all bootstrap DNS tests flow
through, to prevent future tests from running in parallel (they
all mutate package-level DNS caches and metrics) and to give a
clear error if a test forgets to initialize the DNS caches.

Fixes #19388

Change-Id: I8ad454ec6026c71f13ecfa14d25925df5478b908
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Co-authored-by: Avery Pennarun <apenwarr@tailscale.com>
2026-04-13 17:20:43 -07:00
Brad Fitzpatrick 7dcb378875 tstest/integration/nat, tstest/natlab/vnet: fix natlab test flake
The natlab-integrationtest CI job frequently flakes by exhausting its
3m go test timeout. The root cause is that the QEMU VMs run under
pure software emulation (TCG) with no KVM. Under TCG, the guest
kernel's timer calibration busy-loops are at the mercy of host CPU
scheduling. When two VMs boot simultaneously on a 2-core CI runner,
one VM's calibration gets starved and produces wrong results, leaving
the kernel with broken timers that prevent it from ever completing
boot — even after the other VM finishes and frees up CPU.

Additionally, the microvm machine type doesn't provide HPET hardware,
but the kernel command line specified clocksource=hpet. And the VM
image build (make natlab) ran inside the test itself, consuming most
of the 3m timeout budget before the actual test started.

Fix by:

 - Enabling KVM when /dev/kvm is available, so timer calibration
   uses real hardware timers unaffected by host CPU scheduling.

 - Adding a CI step to set /dev/kvm permissions on the GitHub
   Actions runner (ubuntu-latest provides KVM but needs a udev rule).

 - Pre-building the VM image in a separate CI step so it doesn't
   cut into the go test -timeout budget.

 - Replacing the hardcoded 60s context timeout with one derived from
   t.Deadline(), so the test uses the full -timeout budget.

 - Adding VM boot progress detection (AwaitFirstPacket) and QMP
   diagnostics, so boot failures produce clear errors instead of
   opaque "context deadline exceeded" messages.

With KVM enabled, the test passes reliably even on a single CPU core
with 3 parallel workers — a scenario that was 100% broken under TCG.

Fixes #18906

Change-Id: I4c87631a9c9678d185b9f30cb05c0f7bfa9f5c62
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 16:34:15 -07:00
Brad Fitzpatrick dbd19e4b65 tstest: add AssertNotParallel helper
For tests to loudly declare (and panic on violation) when they're doing
something that's not safe in a parallel test.

Fixes #19385

Change-Id: If79693b0c235c146871a05ed74fa9ea75bb500f9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 16:14:33 -07:00
Brad Fitzpatrick 50b8cfbde2 wgengine/netstack: fix data race on in-flight connection test globals
The maxInFlightConnectionAttemptsForTest and
maxInFlightConnectionAttemptsPerClientForTest globals were plain ints
read by background gVisor TCP handler goroutines (via
wrapTCPProtocolHandler) and written by tstest.Replace cleanup in
TestTCPForwardLimits_PerClient. When a gVisor goroutine outlived the
test cleanup window, the race detector caught the unsynchronized
access.

The race-prone code was introduced in c5abbcd4b4 (2024-02-26,
"wgengine/netstack: add a per-client limit for in-flight TCP
forwards") which added both the plain int globals and the
TestTCPForwardLimits_PerClient test that writes them via
tstest.Replace. It is not obvious why this has only recently started
being detected as a data race; likely some combination of gVisor
version bumps, Go toolchain scheduler changes, and additional
TCP-injecting subtests (e.g. 03461ea7f, 2026-01-30) increased
goroutine churn enough to hit the window.

Change both globals to atomic.Int32 and replace tstest.Replace (which
does non-atomic *target = old on cleanup) with explicit Store/Cleanup
pairs.

Fixes #19118

Change-Id: Id26ba6fbfb2e4ade319976db80af8e16c7c8778e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 15:24:35 -07:00
Brad Fitzpatrick 6500d3c3f8 cmd/containerboot: mark TestContainerBoot as flaky
Updates #19380

Change-Id: Ib1be53836e37224265d10abd0c2213644ea54d64
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 15:21:42 -07:00
Brad Fitzpatrick 9dfe7875fd version: show tailscale/go toolchain git hash in version output
When built with the Tailscale Go toolchain, include the toolchain's
git revision in the version output. The non-JSON output shows the
first 10 hex digits:

  go version: go1.26.2 (tailscale/go dfe2a5fd8e)

The JSON output includes the full hash as "tailscaleGoGitHash", or
omits the field when not using tsgo.

The toolchain rev is read via a separate sync.OnceValue rather than
piggybacking on getEmbeddedInfo, because that function discards all
data when VCS fields are absent (e.g. in test binaries), while the
tailscale.toolchain.rev setting is still present.

Also add a CI-only test verifying tailscaleToolchainRev is non-empty
when built with the tailscale_go build tag.

Fixes #19374

Change-Id: Ied0b16d7aead5471d8c614c30cba8b0dcf80c691
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 15:20:56 -07:00
Brad Fitzpatrick 5a7ef4a533 ipn/ipnlocal: mark TestStateMachineSeamless as flaky
Updates #19377

Change-Id: I7dbf5b954effbfa821339e79d02d8a6e46d2862a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 15:19:42 -07:00
Adriano Sela Aviles 4ce1643929 types/netmap,tailcfg: update documentation for Services cap
Updates tailscale/corp#40052

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-04-13 14:36:48 -07:00
Brad Fitzpatrick e2fa9ff140 ssh/tailssh: speed up SSH integration tests
Parallelize the SSH integration tests across OS targets and reduce
per-container overhead:

- CI: use GitHub Actions matrix strategy to run all 4 OS containers
  (ubuntu:focal, ubuntu:jammy, ubuntu:noble, alpine:latest) in parallel
  instead of sequentially (~4x wall-clock improvement)

- Makefile: run docker builds in parallel for local dev too

- Dockerfile: consolidate ~20 separate RUN commands into 5 (one per
  test phase), eliminating Docker layer overhead. Combine test binary
  invocations where no state mutation is needed between them. Fix a bug
  where TestDoDropPrivileges was silently not being run (was passed as a
  second positional arg to -test.run instead of using regex alternation).

- TestMain: replace tail -F + 2s sleep with synchronous log read,
  eliminating 2s overhead per test binary invocation. Set debugTest once
  in TestMain instead of redundantly in each test function.

- session.read(): close channel on EOF so non-shell tests return
  immediately instead of waiting for the 1s silence timeout.

Updates #19244

Change-Id: I2cc8588964fbce0dd7b654fb94e7ff33440b8584
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 14:18:27 -07:00
License Updater cfed69f3ed licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2026-04-13 12:47:58 -07:00
Jordan Whited 929ad51be0 cmd/derper: mark rate-config flag as experimental and unstable
Updates tailscale/corp#38509

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2026-04-13 12:24:59 -07:00
Adriano Sela Aviles 21880457eb ipn/localapi,client/local: add services over localapi
Updates tailscale/corp#40052

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-04-13 11:47:23 -07:00
Brad Fitzpatrick aa9a76cf30 ssh/tailssh: gofmt
I'm not sure how this file got into the repo without gofmt.

Maybe gofmt rules changed in some Go release?

Updates #cleanup

Change-Id: Ia8bd46e29f116f7fbfca11be80c8ef48699cd9f2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 11:09:13 -07:00
Brad Fitzpatrick d5341fd60c tailscaleroot: add test that tsgo rev is in Go build cache keys
Verify that GODEBUG=gocachehash=1 output from ./tool/go includes the
git revision from go.toolchain.rev, ensuring that bumping the Tailscale
Go fork (without a Go version number change) properly invalidates the
build cache.

The test only runs in CI or when the current Go binary is the Tailscale
toolchain (GOROOT contains /.cache/tsgo/), so open source contributors
using stock Go aren't forced to download tsgo.

Fixes tailscale/corp#36589

Change-Id: Ia98d3a3aa8c7fa67f9a0293066fa02a1997dcb95
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-13 10:17:22 -07:00
Adriano Sela Aviles 4fcce6000d tailcfg,types/netmap: add (visible) Services to SelfNode Caps (#19335)
Updates #40052

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
2026-04-13 08:48:02 -07:00
Brad Fitzpatrick 674f866ecc tstest/tailmac: add headless mode for automated VM testing
Add a --headless flag to the Host.app Run subcommand for running
macOS VMs without a GUI, enabling use from test frameworks.

Key changes:

  - HostCli.swift: When --headless is set, run the VM via VMController
    + RunLoop.main.run() instead of NSApplicationMain. Using the
    RunLoop (not dispatchMain) is required because VZ framework
    callbacks depend on RunLoop sources.

  - VMController.swift: Add headless parameter to createVirtualMachine
    that configures a single socket-based NIC (no NAT NIC). This
    matches the NIC configuration used when creating/saving VMs, so
    saved state restoration works correctly. A NIC count mismatch
    causes VZ to silently fail to execute guest code.

  - TailMacConfigHelper.swift: Clean up socket network device logging.

  - Config.swift: Move VM storage from ~/VM.bundle to
    ~/.cache/tailscale/vmtest/macos/.

  - TailMac.swift: Fix dispatchMain→RunLoop.main.run() in the create
    command (same VZ RunLoop requirement).

Updates #13038

Change-Id: Iea51c043aa92e8fc6257139b9f0e2e7677072fa2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-11 12:50:53 -07:00
Brad Fitzpatrick 0e8ae9d60c gokrazy: add arm64 natlab appliance image support
Add natlabapp.arm64 config and gokrazydeps.go for building a gokrazy
natlab appliance image targeting arm64 (Apple Silicon). This is the
arm64 counterpart to the existing natlabapp (amd64) used by vmtest.

The arm64 image uses github.com/gokrazy/kernel.arm64 and is built
with "make natlab-arm64" in the gokrazy directory.

Updates #13038

Change-Id: I0e1f8e5840083a5de5954f2cf46e3babec129d96
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-10 16:57:19 -07:00
Brad Fitzpatrick cf59a6fb23 .github, tool/listpkgs: automatically find tests which use tstest.RequireRoot
Updates tailscale/corp#40007

Change-Id: I677d3d9e276cb6633a14ac07e4b58ea08e52fac4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-10 16:22:05 -07:00
Mike O'Driscoll ca5db865b4 cmd/derper,derp: add --rate-config file with SIGHUP reload (#19314)
Add a --rate-config flag pointing to a JSON file for per-client receive
rate limits (bytes/sec and burst bytes). The config is reloaded on SIGHUP,
updating all existing client connections live. The --per-client-rate-limit
and --per-client-rate-burst flags are removed in favor of the config file.

In derpserver, rate limiting uses an atomic.Pointer[xrate.Limiter] per
client: nil when unlimited or mesh (zero overhead), non-nil when
rate-limited.

Document that clientSet.activeClient Store operations require Server.mu.

Updates tailscale/corp#38509

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-04-10 18:37:54 -04:00
Amal Bansode b4c0d67f8b wgengine/router/osrouter: fix privileged tests missing fake netfilter runner
These test failures were never caught by CI because the package in question
was missing from our privileged tests list. tailscale/corp#40007 covers improving
our process around this.

Fixes #19316

Signed-off-by: Amal Bansode <amal@tailscale.com>
2026-04-10 14:51:55 -07:00
Brad Fitzpatrick 5e81840b57 tstest: add RequireRoot helper
Start using a common helper for tests to declare that they require root.

This is step 1. A later step will then make this helper track which tests were
skipped so a subsequent pass will run these test as root.

Updates tailscale/corp#40007

Change-Id: I4979e1def0fa3691d38c83f48c89aaa443e7f62e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-10 10:48:50 -07:00
Alex Chan 399f048332 tka: Revert "improve logging for Compact and Commit operations"
This reverts commit b25920dfc0.

The `log.Printf` messages are causing panics in corp, in particular:

> panic: please use tailscale.com/logger.Logf instead of the log package

Fixing the TKA code to plumb through a logger properly is going to be
a hassle, so for now remove these logs to unblock merges to corp.

Updates tailscale/corp#39455

Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-10 17:13:23 +01:00
Alex Chan 1ff369a261 tka: keep the CompactionDefaults alongside the other limits
Updates #cleanup

Change-Id: Ib5e481d5a9c7ec7ac3e6b3913909ab1bf21d7a4d
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-04-10 16:06:23 +01:00
416 changed files with 27130 additions and 9418 deletions
+3 -2
View File
@@ -37,8 +37,6 @@ jobs:
- "elementary/docker:stable" - "elementary/docker:stable"
- "elementary/docker:unstable" - "elementary/docker:unstable"
- "parrotsec/core:latest" - "parrotsec/core:latest"
- "kalilinux/kali-rolling"
- "kalilinux/kali-dev"
- "oraclelinux:9" - "oraclelinux:9"
- "oraclelinux:8" - "oraclelinux:8"
- "fedora:latest" - "fedora:latest"
@@ -61,6 +59,9 @@ jobs:
- { image: "debian:stable-slim", deps: "curl" } - { image: "debian:stable-slim", deps: "curl" }
- { image: "ubuntu:24.04", deps: "curl" } - { image: "ubuntu:24.04", deps: "curl" }
- { image: "fedora:latest", deps: "curl" } - { image: "fedora:latest", deps: "curl" }
# Kali doesn't have ca-certificates installed by default anymore
- { image: "kalilinux/kali-dev", "deps": "curl ca-certificates"}
- { image: "kalilinux/kali-rolling", "deps": "curl ca-certificates"}
# Test TAILSCALE_VERSION pinning on a subset of distros. # Test TAILSCALE_VERSION pinning on a subset of distros.
# Skip Alpine as community repos don't reliably keep old versions. # Skip Alpine as community repos don't reliably keep old versions.
- { image: "debian:stable-slim", deps: "curl", version: "1.80.0" } - { image: "debian:stable-slim", deps: "curl", version: "1.80.0" }
@@ -1,6 +1,7 @@
# Run some natlab integration tests. # Run a single natlab smoke test on every PR. The full natlab suite
# is opt-in and lives in .github/workflows/natlab-test.yml.
# See https://github.com/tailscale/tailscale/issues/13038 # See https://github.com/tailscale/tailscale/issues/13038
name: "natlab-integrationtest" name: "natlab-basic"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -17,17 +18,28 @@ on:
branches: branches:
- "main" - "main"
jobs: jobs:
natlab-integrationtest: EasyEasy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install qemu - name: Install qemu
run: | run: |
sudo rm -f /var/lib/man-db/auto-update sudo rm -f /var/lib/man-db/auto-update
sudo apt-get -y update sudo apt-get -y update
sudo apt-get -y remove man-db sudo apt-get -y remove man-db
sudo apt-get install -y qemu-system-x86 qemu-utils sudo apt-get install -y qemu-system-x86 qemu-utils
- name: Build VM image
# The test will build this if missing, but we do it explicitly
# to avoid cutting into the go test -timeout budget, and to
# fail earlier with a clearer error if the image build breaks.
run: |
make -C gokrazy natlab
- name: Run natlab integration tests - name: Run natlab integration tests
run: | run: |
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests ./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/natlab/vmtest --run-vm-tests
+182
View File
@@ -0,0 +1,182 @@
# Run the full natlab/vmtest opt-in test suite. These tests boot QEMU VMs
# (gokrazy, Ubuntu, FreeBSD) and exercise vnet-driven networking scenarios.
# They are gated behind --run-vm-tests because they need KVM and are slow.
#
# This workflow runs:
# - on demand (workflow_dispatch)
# - on PRs that carry the "run-natlab-tests" label
# - on main, every 12 hours, via cron
#
# Layout:
# - "prepare" builds the gokrazy VM image, downloads the cloud images
# (Ubuntu, FreeBSD), and discovers every Test* function in the two
# opt-in packages.
# - "test" is a per-TestFoo matrix that depends on prepare. Each matrix
# job restores the shared caches and runs a single test. Adding a new
# TestFoo automatically gets its own job — no workflow edits needed.
#
# A separate workflow (.github/workflows/natlab-basic.yml) runs a single
# canary natlab test on every PR; this one runs the full suite.
name: "natlab-test"
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
types: [labeled, synchronize, reopened]
schedule:
# Every 12 hours, off-the-hour to avoid GitHub's :00 cron-stampede window.
- cron: "23 3,15 * * *"
jobs:
# prepare warms the per-workflow-run caches (gokrazy image, cloud VM
# images) and emits the dynamic matrix of test names. By doing the work
# once here, the matrix test jobs never race to rebuild or re-download
# the same artifacts on a cold cache.
prepare:
if: |
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-natlab-tests'))
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
matrix: ${{ steps.list.outputs.matrix }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The cloud VM image cache is keyed only on images.go (image URLs and
# SHAs), so it survives across workflow runs and is invalidated only
# when a new image source is added.
- name: Cache cloud VM images
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/tailscale/vmtest/images
key: natlab-vmimages-${{ hashFiles('tstest/natlab/vmtest/images.go') }}
# The gokrazy VM image is keyed by github.sha. That means we rebuild
# it once per commit but matrix test jobs in the same run all share
# the result. Per-PR re-runs of the same sha (e.g. a rerun-failed)
# also get the cache.
- name: Cache gokrazy VM image
id: gokrazy-cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: gokrazy/natlabapp.qcow2
key: natlab-gokrazy-${{ github.sha }}
# qemu-utils provides qemu-img, which the gokrazy Makefile uses to
# convert natlabapp.img to qcow2. Only install if we need it (cache
# miss); the test matrix jobs install qemu separately for the runtime.
- name: Install qemu-utils
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
run: |
sudo rm -f /var/lib/man-db/auto-update
sudo apt-get -y update
sudo apt-get -y remove man-db
sudo apt-get install -y qemu-utils
- name: Download cloud VM images
# natlabprep is idempotent: it checks the cache before downloading.
run: |
./tool/go run ./tstest/natlab/vmtest/cmd/natlabprep
- name: Build gokrazy VM image
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
run: |
make -C gokrazy natlab
- name: Discover tests
id: list
# Grep the test files directly rather than invoking `go test -list`
# so we don't pay the cost of compiling the test binaries here. The
# only test functions in these packages use the canonical
# `func TestFoo(t *testing.T)` signature.
#
# exclude is the set of tests that need special invocation
# (extra flags, a specific environment) and don't fit the
# single-test-per-matrix-job model. They stay runnable locally.
run: |
set -euo pipefail
exclude='^(TestGrid)$'
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" \
| sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' \
| grep -vE "$exclude" \
| while read -r t; do
jq -nc --arg pkg "$pkg" --arg test "$t" \
'{pkg: $pkg, test: $test}' >> "$tmp"
done
done
done
matrix=$(jq -s -c . "$tmp")
echo "matrix=${matrix}" >> "$GITHUB_OUTPUT"
echo "Discovered tests:"
jq . "$tmp"
test:
needs: prepare
runs-on: ubuntu-latest
timeout-minutes: 20
name: "${{ matrix.test }}"
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.prepare.outputs.matrix) }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install qemu
run: |
sudo rm -f /var/lib/man-db/auto-update
sudo apt-get -y update
sudo apt-get -y remove man-db
sudo apt-get install -y qemu-system-x86 qemu-utils
# restore-only: prepare is the single writer of these caches, so
# matrix jobs don't write back. fail-on-cache-miss would be too
# strict for the gokrazy cache (e.g. a non-fatal cache eviction
# between prepare and us); we just rebuild on miss instead.
- name: Restore cloud VM images
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/tailscale/vmtest/images
key: natlab-vmimages-${{ hashFiles('tstest/natlab/vmtest/images.go') }}
- name: Restore gokrazy VM image
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: gokrazy/natlabapp.qcow2
key: natlab-gokrazy-${{ github.sha }}
# The gokrazy-based tests boot the kernel directly from
# vmlinuz that ships in the tailscale/gokrazy-kernel 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
run: |
./tool/go mod download github.com/tailscale/gokrazy-kernel
- name: Run ${{ matrix.test }}
# Per-test timeout is well above the few-minute typical runtime
# but small enough that a stuck test fails fast instead of holding
# the runner for the job's 20-minute budget.
run: |
./tool/go test -v -timeout=15m -count=1 ${{ matrix.pkg }} \
-run='^${{ matrix.test }}$' --run-vm-tests
+20 -4
View File
@@ -1,5 +1,5 @@
# Run the ssh integration tests with `make sshintegrationtest`. # Run the ssh integration tests in various Docker containers.
# These tests can also be running locally. # These tests can also be run locally via `make sshintegrationtest`.
name: "ssh-integrationtest" name: "ssh-integrationtest"
concurrency: concurrency:
@@ -15,9 +15,25 @@ on:
jobs: jobs:
ssh-integrationtest: ssh-integrationtest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- base: "ubuntu:focal"
tag: "ssh-ubuntu-focal"
- base: "ubuntu:jammy"
tag: "ssh-ubuntu-jammy"
- base: "ubuntu:noble"
tag: "ssh-ubuntu-noble"
- base: "alpine:latest"
tag: "ssh-alpine-latest"
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run SSH integration tests - name: Build test binaries
run: | run: |
make sshintegrationtest GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled
- name: Run SSH integration tests (${{ matrix.base }})
run: |
docker build --build-arg="BASE=${{ matrix.base }}" -t "${{ matrix.tag }}" ssh/tailssh/testcontainers
+9 -1
View File
@@ -361,7 +361,7 @@ jobs:
run: chown -R $(id -u):$(id -g) $PWD run: chown -R $(id -u):$(id -g) $PWD
- name: privileged tests - name: privileged tests
working-directory: src working-directory: src
run: ./tool/go test ./util/linuxfw ./derp/xdp run: ./tool/go test $(./tool/go run ./tool/listpkgs --has-root-tests)
vm: vm:
needs: gomod-cache needs: gomod-cache
@@ -787,6 +787,14 @@ jobs:
echo echo
echo echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1) git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
- name: check that 'genreadme' is clean
working-directory: src
run: |
./tool/go run ./misc/genreadme
git add -N . # ensure untracked files are noticed
echo
echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run './tool/go run ./misc/genreadme'."; exit 1)
make_tidy: make_tidy:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
+4 -4
View File
@@ -23,8 +23,8 @@ jobs:
- name: Check out code - name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run update-flakes - name: Run updateflakes
run: ./update-flake.sh run: ./tool/go run ./tool/updateflakes
- name: Get access token - name: Get access token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
@@ -41,8 +41,8 @@ jobs:
author: Flakes Updater <noreply+flakes-updater@tailscale.com> author: Flakes Updater <noreply+flakes-updater@tailscale.com>
committer: Flakes Updater <noreply+flakes-updater@tailscale.com> committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
branch: flakes branch: flakes
commit-message: "go.mod.sri: update SRI hash for go.mod changes" commit-message: "flakehashes.json: update SRI hash for go.mod changes"
title: "go.mod.sri: update SRI hash for go.mod changes" title: "flakehashes.json: update SRI hash for go.mod changes"
body: Triggered by ${{ github.repository }}@${{ github.sha }} body: Triggered by ${{ github.repository }}@${{ github.sha }}
signoff: true signoff: true
delete-branch: true delete-branch: true
+4 -1
View File
@@ -1,12 +1,15 @@
# Binaries for programs and plugins # Binaries for programs and plugins
*~ *~
*.tmp *.tmp
*.exe
*.dll *.dll
*.so *.so
*.dylib *.dylib
*.spk *.spk
*.exe
# tool/go.exe is built specially and committed.
!/tool/go.exe
cmd/tailscale/tailscale cmd/tailscale/tailscale
cmd/tailscaled/tailscaled cmd/tailscaled/tailscaled
ssh/tailssh/testcontainers/tailscaled ssh/tailssh/testcontainers/tailscaled
+7 -5
View File
@@ -10,7 +10,7 @@ vet: ## Run go vet
tidy: ## Run go mod tidy and update nix flake hashes tidy: ## Run go mod tidy and update nix flake hashes
./tool/go mod tidy ./tool/go mod tidy
./update-flake.sh ./tool/go run ./tool/updateflakes
lint: ## Run golangci-lint lint: ## Run golangci-lint
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run ./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
@@ -137,10 +137,12 @@ publishdevproxy: check-image-repo ## Build and publish k8s-proxy image to locati
sshintegrationtest: ## Run the SSH integration tests in various Docker containers sshintegrationtest: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \ @GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \ echo "Testing on ubuntu:focal, ubuntu:jammy, ubuntu:noble, alpine:latest (in parallel)" && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \ docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers & \
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \ docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers & \
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers & \
docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers & \
wait
.PHONY: generate .PHONY: generate
generate: ## Generate code generate: ## Generate code
+1 -1
View File
@@ -1 +1 @@
1.97.0 1.99.0
+1
View File
@@ -736,6 +736,7 @@ func TestRateLogger(t *testing.T) {
} }
func TestRouteStoreMetrics(t *testing.T) { func TestRouteStoreMetrics(t *testing.T) {
clientmetric.ResetForTest(t)
metricStoreRoutes(1, 1) metricStoreRoutes(1, 1)
metricStoreRoutes(1, 1) // the 1 buckets value should be 2 metricStoreRoutes(1, 1) // the 1 buckets value should be 2
metricStoreRoutes(5, 5) // the 5 buckets value should be 1 metricStoreRoutes(5, 5) // the 5 buckets value should be 1
+29 -7
View File
@@ -6,6 +6,7 @@ package appc
import ( import (
"cmp" "cmp"
"slices" "slices"
"strings"
"tailscale.com/ipn/ipnext" "tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@@ -16,7 +17,7 @@ import (
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental" const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
func isEligibleConnector(peer tailcfg.NodeView) bool { func isPeerEligibleConnector(peer tailcfg.NodeView) bool {
if !peer.Valid() || !peer.Hostinfo().Valid() { if !peer.Valid() || !peer.Hostinfo().Valid() {
return false return false
} }
@@ -39,7 +40,7 @@ func sortByPreference(ns []tailcfg.NodeView) {
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView { func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
appTagsSet := set.SetOf(app.Connectors) appTagsSet := set.SetOf(app.Connectors)
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool { matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
if !isEligibleConnector(n) { if !isPeerEligibleConnector(n) {
return false return false
} }
for _, t := range n.Tags().All() { for _, t := range n.Tags().All() {
@@ -55,7 +56,7 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers // PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
// want to be connectors for which domains. // want to be connectors for which domains.
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView) map[string][]tailcfg.NodeView { 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 var m map[string][]tailcfg.NodeView
if !hasCap(AppConnectorsExperimentalAttrName) { if !hasCap(AppConnectorsExperimentalAttrName) {
return m return m
@@ -64,22 +65,43 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
if err != nil { if err != nil {
return m return m
} }
tagToDomain := make(map[string][]string)
// 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]{}
for _, app := range apps { 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 { for _, tag := range app.Connectors {
tagToDomain[tag] = append(tagToDomain[tag], app.Domains...) if tagToDomain[tag] == nil {
tagToDomain[tag] = set.Set[string]{}
}
tagToDomain[tag].AddSet(domains)
if isSelfEligibleConnector && selfTags.Contains(tag) {
selfRoutedDomains.AddSet(domains)
}
} }
} }
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so // 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. // use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
var work map[string]set.Set[tailcfg.NodeID] var work map[string]set.Set[tailcfg.NodeID]
for _, peer := range peers { for _, peer := range peers {
if !isEligibleConnector(peer) { if !isPeerEligibleConnector(peer) {
continue continue
} }
for _, t := range peer.Tags().All() { for _, t := range peer.Tags().All() {
domains := tagToDomain[t] domains := tagToDomain[t]
for _, domain := range domains { for domain := range domains {
if selfRoutedDomains.Contains(domain) {
continue
}
if work[domain] == nil { if work[domain] == nil {
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{}) mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
} }
+130 -2
View File
@@ -32,6 +32,8 @@ func TestPickSplitDNSPeers(t *testing.T) {
appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"}) appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"})
appThreeBytes := getBytesForAttr("app3", []string{"woo.b.example.com", "hoo.b.example.com"}, []string{"tag:three1", "tag:three2"}) appThreeBytes := getBytesForAttr("app3", []string{"woo.b.example.com", "hoo.b.example.com"}, []string{"tag:three1", "tag:three2"})
appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"}) appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"})
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 { makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
return (&tailcfg.Node{ return (&tailcfg.Node{
@@ -48,9 +50,11 @@ func TestPickSplitDNSPeers(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string name string
want map[string][]tailcfg.NodeView
peers []tailcfg.NodeView peers []tailcfg.NodeView
config []tailcfg.RawMessage config []tailcfg.RawMessage
isEligibleConnector bool
selfTags []string
want map[string][]tailcfg.NodeView
}{ }{
{ {
name: "empty", name: "empty",
@@ -111,6 +115,128 @@ func TestPickSplitDNSPeers(t *testing.T) {
"c.example.com": {nvp2, nvp4}, "c.example.com": {nvp2, nvp4},
}, },
}, },
{
name: "self-connector-exclude-self-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: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},
},
},
{
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: "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: "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",
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},
},
},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
selfNode := &tailcfg.Node{} selfNode := &tailcfg.Node{}
@@ -119,6 +245,7 @@ func TestPickSplitDNSPeers(t *testing.T) {
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config, tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
} }
} }
selfNode.Tags = append(selfNode.Tags, tt.selfTags...)
selfView := selfNode.View() selfView := selfNode.View()
peers := map[tailcfg.NodeID]tailcfg.NodeView{} peers := map[tailcfg.NodeID]tailcfg.NodeView{}
for _, p := range tt.peers { for _, p := range tt.peers {
@@ -126,7 +253,8 @@ func TestPickSplitDNSPeers(t *testing.T) {
} }
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool { got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
return true return true
}, selfView, peers) }, selfView, peers, tt.isEligibleConnector)
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want) t.Fatalf("got %v, want %v", got, tt.want)
} }
+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tailscaleroot
import (
"os"
"os/exec"
"strings"
"testing"
"tailscale.com/util/cibuild"
)
// TestTsgoRevInCacheKey verifies that the Tailscale Go toolchain's git
// revision (from go.toolchain.rev) is blended into Go build cache keys.
// Without this, bumping the toolchain to a new commit that doesn't change
// the Go version number would silently reuse stale cached build artifacts.
//
// See https://github.com/tailscale/tailscale/issues/36589.
func TestTsgoRevInCacheKey(t *testing.T) {
goRoot := goEnv(t, "GOROOT")
isTsgo := strings.Contains(goRoot, "/.cache/tsgo/")
if !cibuild.OnTailscaleCI() && !isTsgo {
t.Skip("skipping; not in Tailscale CI and not using the Tailscale Go toolchain")
}
rev := strings.TrimSpace(GoToolchainRev)
if rev == "" {
t.Fatal("go.toolchain.rev is empty")
}
// Build the small stdlib "errors" package with GODEBUG=gocachehash=1,
// which causes cmd/go to log its cache key computations to stderr.
cmd := exec.Command("go", "build", "errors")
cmd.Env = append(os.Environ(), "GODEBUG=gocachehash=1")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go build errors failed: %v\n%s", err, out)
}
// The cache key output should contain the toolchain rev alongside the
// Go version, e.g.:
// HASH[moduleIndex]: "go1.26.2 dfe2a5fd8ee2e68b08ce5ff259269f50ecadf2f4"
if !strings.Contains(string(out), rev) {
t.Errorf("go.toolchain.rev %q not found in GODEBUG=gocachehash=1 output:\n%s", rev, out)
}
}
func goEnv(t *testing.T, key string) string {
t.Helper()
out, err := exec.Command("go", "env", key).Output()
if err != nil {
t.Fatalf("go env %s: %v", key, err)
}
return strings.TrimSpace(string(out))
}
+108
View File
@@ -327,6 +327,35 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
return decodeJSON[*apitype.WhoIsResponse](body) return decodeJSON[*apitype.WhoIsResponse](body)
} }
// 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.
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 {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsForIP is like [Client.WhoIs] but scopes the returned CapMap to
// 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.
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 {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// ErrPeerNotFound is returned by [Client.WhoIs], [Client.WhoIsNodeKey] and // ErrPeerNotFound is returned by [Client.WhoIs], [Client.WhoIsNodeKey] and
// [Client.WhoIsProto] when a peer is not found. // [Client.WhoIsProto] when a peer is not found.
var ErrPeerNotFound = errors.New("peer not found") var ErrPeerNotFound = errors.New("peer not found")
@@ -607,6 +636,24 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
return x, nil return x, nil
} }
// GetDebugResultJSON invokes a debug action and decodes the JSON response
// into a value of type T. It avoids the marshal/unmarshal roundtrip that
// 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.
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)
if err != nil {
return v, fmt.Errorf("error %w: %s", err, body)
}
if err := json.Unmarshal(body, &v); err != nil {
return v, err
}
return v, nil
}
// QueryOptionalFeatures queries the optional features supported by the Tailscale daemon. // QueryOptionalFeatures queries the optional features supported by the Tailscale daemon.
func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) { func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil) body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil)
@@ -972,6 +1019,19 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
if res.StatusCode != http.StatusSwitchingProtocols { if res.StatusCode != http.StatusSwitchingProtocols {
body, _ := io.ReadAll(res.Body) body, _ := io.ReadAll(res.Body)
res.Body.Close() res.Body.Close()
if res.StatusCode == http.StatusOK && res.Header.Get("Dial-Self") == "true" {
// Server told us to dial the address ourselves rather than
// proxying through the daemon. This happens for non-Tailscale
// addresses where the daemon shouldn't dial as root on the
// client's behalf. The server provides the resolved address
// to avoid a TOCTOU race with DNS re-resolution.
addr := res.Header.Get("Dial-Addr")
if addr == "" {
return nil, errors.New("server returned Dial-Self without Dial-Addr")
}
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body) return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
} }
// From here on, the underlying net.Conn is ours to use, but there // From here on, the underlying net.Conn is ours to use, but there
@@ -1009,6 +1069,44 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
return &derpMap, nil return &derpMap, nil
} }
// CertDomains returns the list of domains for which the local tailscaled can
// 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.
func (lc *Client) CertDomains(ctx context.Context) ([]string, error) {
body, err := lc.get200(ctx, "/localapi/v0/cert-domains")
if err != nil {
return nil, err
}
return decodeJSON[[]string](body)
}
// DNSConfig returns the [tailcfg.DNSConfig] from the current netmap.
// It returns an error if no netmap has been received yet.
// It is intended for callers that need fields like ExtraRecords or CertDomains
// without pulling the rest of the netmap.
func (lc *Client) DNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
body, err := lc.get200(ctx, "/localapi/v0/dns-config")
if err != nil {
return nil, err
}
return decodeJSON[*tailcfg.DNSConfig](body)
}
// 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.
//
// It is intended for callers that need the latest state of a single peer
// without fetching the entire netmap.
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 {
return nil, err
}
return decodeJSON[*tailcfg.Node](body)
}
// PingOpts contains options for the ping request. // PingOpts contains options for the ping request.
// //
// The zero value is valid, which means to use defaults. // The zero value is valid, which means to use defaults.
@@ -1422,3 +1520,13 @@ func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteI
} }
return decodeJSON[appctype.RouteInfo](body) return decodeJSON[appctype.RouteInfo](body)
} }
// GetServices returns the Services visible to this node,
// including their names, IP addresses, and ports, keyed by service name.
func (lc *Client) GetServices(ctx context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error) {
body, err := lc.get200(ctx, "/localapi/v0/services")
if err != nil {
return nil, err
}
return decodeJSON[map[tailcfg.ServiceName]tailcfg.ServiceDetails](body)
}
+51
View File
@@ -61,6 +61,57 @@ func TestWhoIsPeerNotFound(t *testing.T) {
} }
} }
func TestUserDialSelf(t *testing.T) {
// Start a real TCP listener that the client should dial directly
// when the server tells it to dial-self.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Write([]byte("hello"))
c.Close()
}
}()
targetAddr := ln.Addr().(*net.TCPAddr)
// Mock LocalAPI server that returns Dial-Self response.
nw := nettest.GetNetwork(t)
ts := nettest.NewHTTPServer(nw, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Dial-Self", "true")
w.Header().Set("Dial-Addr", targetAddr.String())
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
lc := &Client{
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
return nw.Dial(ctx, network, ts.Listener.Addr().String())
},
}
conn, err := lc.UserDial(context.Background(), "tcp", targetAddr.IP.String(), uint16(targetAddr.Port))
if err != nil {
t.Fatalf("UserDial: %v", err)
}
defer conn.Close()
buf := make([]byte, 5)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read: %v", err)
}
if got := string(buf[:n]); got != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
func TestDeps(t *testing.T) { func TestDeps(t *testing.T) {
deptest.DepChecker{ deptest.DepChecker{
BadDeps: map[string]string{ BadDeps: map[string]string{
+4 -4
View File
@@ -117,7 +117,7 @@ func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.Key
return decodeJSON[[]tkatype.MarshaledSignature](body) return decodeJSON[[]tkatype.MarshaledSignature](body)
} }
// NetworkLockLog returns up to maxEntries number of changes to network-lock state. // NetworkLockLog returns up to maxEntries number of changes to tailnet-lock state.
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) { func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
v := url.Values{} v := url.Values{}
v.Set("limit", fmt.Sprint(maxEntries)) v.Set("limit", fmt.Sprint(maxEntries))
@@ -128,7 +128,7 @@ func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstat
return decodeJSON[[]ipnstate.NetworkLockUpdate](body) return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
} }
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node. // NetworkLockForceLocalDisable forcibly shuts down tailnet lock on this node.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error { func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
// This endpoint expects an empty JSON stanza as the payload. // This endpoint expects an empty JSON stanza as the payload.
var b bytes.Buffer var b bytes.Buffer
@@ -142,7 +142,7 @@ func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
return nil return nil
} }
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained // NetworkLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
// in url and returns information extracted from it. // in url and returns information extracted from it.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) { func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct { vr := struct {
@@ -193,7 +193,7 @@ func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
return nil return nil
} }
// NetworkLockDisable shuts down network-lock across the tailnet. // NetworkLockDisable shuts down tailnet-lock across the tailnet.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error { func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil { if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
return fmt.Errorf("error: %w", err) return fmt.Errorf("error: %w", err)
+41 -3
View File
@@ -11,6 +11,7 @@ import (
"image" "image"
"image/color" "image/color"
"image/png" "image/png"
"log"
"runtime" "runtime"
"sync" "sync"
"time" "time"
@@ -204,12 +205,49 @@ var (
) )
var ( var (
bg = color.NRGBA{0, 0, 0, 255} black = color.NRGBA{0, 0, 0, 255}
fg = color.NRGBA{255, 255, 255, 255} white = color.NRGBA{255, 255, 255, 255}
gray = color.NRGBA{255, 255, 255, 102} darkGray = color.NRGBA{102, 102, 102, 255}
lightGray = color.NRGBA{153, 153, 153, 255}
red = color.NRGBA{229, 111, 74, 255} red = color.NRGBA{229, 111, 74, 255}
transparent = color.NRGBA{}
// default values to dark theme
bg = black
fg = white
gray = darkGray
) )
// SetTheme sets the color theme of the systray icon.
//
// Supported themes are:
// - dark - white and gray dots over black background
// - dark:nobg - white and grey dots over transparent background
// - light - black and gray dots over white background
// - light:nobg - black and grey dots over transparent background
func SetTheme(theme string) {
switch theme {
case "dark":
bg = black
fg = white
gray = darkGray
case "dark:nobg":
bg = transparent
fg = white
gray = darkGray
case "light":
bg = white
fg = black
gray = lightGray
case "light:nobg":
bg = transparent
fg = black
gray = lightGray
default:
log.Printf("unknown theme: %q", theme)
}
}
// render returns a PNG image of the logo. // render returns a PNG image of the logo.
func (logo tsLogo) render() *bytes.Buffer { func (logo tsLogo) render() *bytes.Buffer {
const borderUnits = 1 const borderUnits = 1
-1
View File
@@ -3,7 +3,6 @@
//go:build cgo || !darwin //go:build cgo || !darwin
// Package systray provides a minimal Tailscale systray application.
package systray package systray
import ( import (
+28 -8
View File
@@ -621,11 +621,9 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
title += strings.Split(sugg.Name, ".")[0] title += strings.Split(sugg.Name, ".")[0]
} }
menu.exitNodes.AddSeparator() menu.exitNodes.AddSeparator()
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false) active := recommendedIsActive(status, sugg.ID, sugg.Location.CountryCode(), sugg.Location.City())
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", active)
setExitNodeOnClick(rm, sugg.ID) setExitNodeOnClick(rm, sugg.ID)
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
rm.Check()
}
} }
} }
@@ -647,13 +645,11 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
if !ps.Online { if !ps.Online {
name += " (offline)" name += " (offline)"
} }
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false) active := status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", active)
if !ps.Online { if !ps.Online {
sm.Disable() sm.Disable()
} }
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
sm.Check()
}
setExitNodeOnClick(sm, ps.ID) setExitNodeOnClick(sm, ps.ID)
} }
} }
@@ -743,6 +739,30 @@ func (mc *mvCountry) sortedCities() []*mvCity {
return cities return cities
} }
// recommendedIsActive reports whether the suggested exit node corresponds to
// the currently active exit node in status.
func recommendedIsActive(status *ipnstate.Status, suggID tailcfg.StableNodeID, suggCountry, suggCity string) bool {
if status == nil || status.ExitNodeStatus == nil || status.ExitNodeStatus.ID.IsZero() {
return false
}
if suggID == status.ExitNodeStatus.ID {
return true
}
if suggCountry == "" || suggCity == "" {
return false
}
for _, p := range status.Peer {
if p.ID != status.ExitNodeStatus.ID {
continue
}
if loc := p.Location; loc != nil && loc.CountryCode == suggCountry && loc.City == suggCity {
return true
}
return false
}
return false
}
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag. // countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
// It returns the empty string on error. // It returns the empty string on error.
func countryFlag(code string) string { func countryFlag(code string) string {
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build cgo || !darwin
package systray
import (
"testing"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func TestRecommendedIsActive(t *testing.T) {
t.Parallel()
const (
activeID = tailcfg.StableNodeID("active")
suggID = tailcfg.StableNodeID("suggestion")
)
usNYC := &tailcfg.Location{CountryCode: "US", City: "New York"}
usCHI := &tailcfg.Location{CountryCode: "US", City: "Chicago"}
seSTO := &tailcfg.Location{CountryCode: "SE", City: "Stockholm"}
statusWith := func(activePeer *ipnstate.PeerStatus) *ipnstate.Status {
s := &ipnstate.Status{
ExitNodeStatus: &ipnstate.ExitNodeStatus{ID: activeID},
}
if activePeer != nil {
s.Peer = map[key.NodePublic]*ipnstate.PeerStatus{{}: activePeer}
}
return s
}
tests := []struct {
name string
status *ipnstate.Status
suggID tailcfg.StableNodeID
suggCountry string
suggCity string
isActive bool
}{
{
name: "nil_status",
status: nil,
suggID: suggID,
},
{
name: "no_exit_node",
status: &ipnstate.Status{},
suggID: suggID,
},
{
name: "exit_node_id_is_zero",
status: &ipnstate.Status{ExitNodeStatus: &ipnstate.ExitNodeStatus{}},
suggID: suggID,
},
{
name: "exact_id_match_short-circuits",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usCHI}),
suggID: activeID,
suggCountry: "US",
suggCity: "New York",
isActive: true,
},
{
name: "id_mismatch_but_same_city",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usNYC}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
isActive: true,
},
{
name: "different_city",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usCHI}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
{
name: "different_country",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: seSTO}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
{
name: "id_mismatch_suggestion_has_no_location",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usNYC}),
suggID: suggID,
},
{
name: "id_mismatch_active_peer_has_no_location",
status: statusWith(&ipnstate.PeerStatus{ID: activeID}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
{
name: "active_peer_not_in_status",
status: statusWith(nil),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
isExitNodeActive := recommendedIsActive(tt.status, tt.suggID, tt.suggCountry, tt.suggCity)
if isExitNodeActive != tt.isActive {
t.Errorf("recommendedIsActive; got %v, want %v", isExitNodeActive, tt.isActive)
}
})
}
}
+79 -120
View File
@@ -35,8 +35,10 @@ import (
"tailscale.com/net/netutil" "tailscale.com/net/netutil"
"tailscale.com/net/tsaddr" "tailscale.com/net/tsaddr"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tsweb"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/views" "tailscale.com/types/views"
"tailscale.com/util/ctxkey"
"tailscale.com/util/httpm" "tailscale.com/util/httpm"
"tailscale.com/util/syspolicy/policyclient" "tailscale.com/util/syspolicy/policyclient"
"tailscale.com/version" "tailscale.com/version"
@@ -527,45 +529,40 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
} }
} }
type apiHandler[data any] struct { // handleJSON manages decoding the request's body JSON as data and passing it
s *Server // on to the provided handler function.
w http.ResponseWriter func handleJSON[data any](h func(ctx context.Context, data data) error) http.HandlerFunc {
r *http.Request return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
// permissionCheck allows for defining whether a requesting peer's var body data
// capabilities grant them access to make the given data update. if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
// If permissionCheck reports false, the request fails as unauthorized. http.Error(w, err.Error(), http.StatusInternalServerError)
permissionCheck func(data data, peer peerCapabilities) bool return
} }
if err := h(r.Context(), body); err != nil {
// newHandler constructs a new api handler which restricts the given request if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok {
// to the specified permission check. If the permission check fails for tsweb.WriteHTTPError(w, r, httpErr)
// the peer associated with the request, an unauthorized error is returned } else {
// to the client. http.Error(w, err.Error(), http.StatusInternalServerError)
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] { }
return &apiHandler[data]{ return
s: s, }
w: w, w.WriteHeader(http.StatusOK)
r: r,
permissionCheck: permissionCheck,
} }
} }
// alwaysAllowed can be passed as the permissionCheck argument to newHandler var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
// for requests that are always allowed to complete regardless of a peer's
// capabilities.
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) { func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and // TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
// WhoIs when originally checking for a session from authorizeRequest. // WhoIs when originally checking for a session from authorizeRequest.
// Would be nice if we could pipe those through to here so we don't end // Would be nice if we could pipe those through to here so we don't end
// up having to re-call them to grab the peer capabilities. // up having to re-call them to grab the peer capabilities.
status, err := a.s.lc.StatusWithoutPeers(a.r.Context()) status, err := s.lc.StatusWithoutPeers(r.Context())
if err != nil { if err != nil {
return nil, err return nil, err
} }
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr) whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -573,56 +570,11 @@ func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return peer, nil return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
} }
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body func (s *Server) getPeer(ctx context.Context) peerCapabilities {
return contextKeyPeer.Value(ctx)
// handle runs the given handler if the source peer satisfies the
// constraints for running this request.
//
// handle is expected for use when `data` type is empty, or set to
// `noBodyData` in practice. For requests that expect JSON body data
// to be attached, use handleJSON instead.
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
var body data // not used
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
h(a.w, a.r)
}
// handleJSON manages decoding the request's body JSON and passing
// it on to the provided function if the source peer satisfies the
// constraints for running this request.
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
defer a.r.Body.Close()
var body data
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
if err := h(a.r.Context(), body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
a.w.WriteHeader(http.StatusOK)
} }
// serveAPI serves requests for the web client api. // serveAPI serves requests for the web client api.
@@ -637,67 +589,44 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
} }
} }
var err error
r, err = s.setPeer(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api") path := strings.TrimPrefix(r.URL.Path, "/api")
switch { switch {
case path == "/data" && r.Method == httpm.GET: case path == "/data" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed). s.serveGetNodeData(w, r)
handle(s.serveGetNodeData)
return return
case path == "/exit-nodes" && r.Method == httpm.GET: case path == "/exit-nodes" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed). s.serveGetExitNodes(w, r)
handle(s.serveGetExitNodes)
return return
case path == "/routes" && r.Method == httpm.POST: case path == "/routes" && r.Method == httpm.POST:
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool { handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
return false
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
return false
}
return true
}
newHandler[postRoutesRequest](s, w, r, peerAllowed).
handleJSON(s.servePostRoutes)
return return
case path == "/device-details-click" && r.Method == httpm.POST: case path == "/device-details-click" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed). s.serveDeviceDetailsClick(w, r)
handle(s.serveDeviceDetailsClick)
return return
case path == "/local/v0/logout" && r.Method == httpm.POST: case path == "/local/v0/logout" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool { s.proxyRequestToLocalAPI(w, r)
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return return
case path == "/local/v0/prefs" && r.Method == httpm.PATCH: case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool { handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
return false
}
return true
}
newHandler[maskedPrefs](s, w, r, peerAllowed).
handleJSON(s.serveUpdatePrefs)
return return
case path == "/local/v0/update/check" && r.Method == httpm.GET: case path == "/local/v0/update/check" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed). s.proxyRequestToLocalAPI(w, r)
handle(s.proxyRequestToLocalAPI)
return return
case path == "/local/v0/update/check" && r.Method == httpm.POST: case path == "/local/v0/update/check" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool { s.proxyRequestToLocalAPI(w, r)
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return return
case path == "/local/v0/update/progress" && r.Method == httpm.POST: case path == "/local/v0/update/progress" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed). s.proxyRequestToLocalAPI(w, r)
handle(s.proxyRequestToLocalAPI)
return return
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST: case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed). s.proxyRequestToLocalAPI(w, r)
handle(s.proxyRequestToLocalAPI)
return return
} }
http.Error(w, "invalid endpoint", http.StatusNotFound) http.Error(w, "invalid endpoint", http.StatusNotFound)
@@ -1122,6 +1051,11 @@ type maskedPrefs struct {
} }
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error { func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
peer := s.getPeer(ctx)
if prefs.RunSSHSet && !peer.canEdit(capFeatureSSH) {
return tsweb.Error(http.StatusUnauthorized, "RunSSHSet not allowed", nil)
}
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{ _, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
RunSSHSet: prefs.RunSSHSet, RunSSHSet: prefs.RunSSHSet,
Prefs: ipn.Prefs{ Prefs: ipn.Prefs{
@@ -1140,6 +1074,17 @@ type postRoutesRequest struct {
} }
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error { func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
if !data.SetExitNode && !data.SetRoutes {
return tsweb.Error(http.StatusBadRequest, "must specify SetExitNode or SetRoutes", nil)
}
peer := s.getPeer(ctx)
if data.SetExitNode && !peer.canEdit(capFeatureExitNodes) {
return tsweb.Error(http.StatusUnauthorized, "SetExitNode not allowed", nil)
}
if data.SetRoutes && !peer.canEdit(capFeatureSubnets) {
return tsweb.Error(http.StatusUnauthorized, "SetRoutes not allowed", nil)
}
prefs, err := s.lc.GetPrefs(ctx) prefs, err := s.lc.GetPrefs(ctx)
if err != nil { if err != nil {
return err return err
@@ -1153,13 +1098,14 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
} }
currNonExitRoutes = append(currNonExitRoutes, r.String()) currNonExitRoutes = append(currNonExitRoutes, r.String())
} }
// Set non-edited fields to their current values. // For each group of fields not being set, preserve the current prefs.
if data.SetExitNode { if !data.SetExitNode {
data.AdvertiseRoutes = currNonExitRoutes
} else if data.SetRoutes {
data.AdvertiseExitNode = currAdvertisingExitNode data.AdvertiseExitNode = currAdvertisingExitNode
data.UseExitNode = prefs.ExitNodeID data.UseExitNode = prefs.ExitNodeID
} }
if !data.SetRoutes {
data.AdvertiseRoutes = currNonExitRoutes
}
// Calculate routes. // Calculate routes.
routesStr := strings.Join(data.AdvertiseRoutes, ",") routesStr := strings.Join(data.AdvertiseRoutes, ",")
@@ -1336,6 +1282,19 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
return return
} }
switch path {
case "/v0/logout":
if !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
http.Error(w, "not allowed", http.StatusUnauthorized)
return
}
case "/v0/update/check":
if r.Method == httpm.POST && !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
http.Error(w, "not allowed", http.StatusUnauthorized)
return
}
}
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body) req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
if err != nil { if err != nil {
+148 -2
View File
@@ -191,7 +191,7 @@ func TestServeAPI(t *testing.T) {
reqBody: "{\"setExitNode\":true}", reqBody: "{\"setExitNode\":true}",
tests: []requestTest{{ tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities, remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed", wantResponse: "SetExitNode not allowed",
wantStatus: http.StatusUnauthorized, wantStatus: http.StatusUnauthorized,
}, { }, {
remoteIP: remoteIPWithAllCapabilities, remoteIP: remoteIPWithAllCapabilities,
@@ -204,7 +204,7 @@ func TestServeAPI(t *testing.T) {
reqContentType: "application/json", reqContentType: "application/json",
tests: []requestTest{{ tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities, remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed", wantResponse: "RunSSHSet not allowed",
wantStatus: http.StatusUnauthorized, wantStatus: http.StatusUnauthorized,
}, { }, {
remoteIP: remoteIPWithAllCapabilities, remoteIP: remoteIPWithAllCapabilities,
@@ -1604,3 +1604,149 @@ func TestCSRFProtect(t *testing.T) {
}) })
} }
} }
func TestServePostRoutes(t *testing.T) {
existingExitNodeID := tailcfg.StableNodeID("existing-exit-node")
existingRoute := netip.MustParsePrefix("192.168.1.0/24")
existingPrefs := &ipn.Prefs{
ExitNodeID: existingExitNodeID,
AdvertiseRoutes: []netip.Prefix{existingRoute},
}
tests := []struct {
name string
data postRoutesRequest
peerCaps peerCapabilities
wantErr bool
wantEditPrefs bool // whether EditPrefs (PATCH /prefs) should be called
wantExitNodeID tailcfg.StableNodeID
wantRoutes []netip.Prefix
}{
{
name: "empty-request",
data: postRoutesRequest{},
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
wantErr: true,
wantEditPrefs: false,
},
{
name: "SetExitNode-only",
data: postRoutesRequest{
SetExitNode: true,
UseExitNode: "new-exit-node",
},
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
wantEditPrefs: true,
wantExitNodeID: "new-exit-node",
wantRoutes: []netip.Prefix{existingRoute},
},
{
name: "SetExitNode-not-allowed",
data: postRoutesRequest{
SetExitNode: true,
UseExitNode: "new-exit-node",
},
peerCaps: peerCapabilities{capFeatureSubnets: true},
wantErr: true,
},
{
name: "SetRoutes-only",
data: postRoutesRequest{
SetRoutes: true,
AdvertiseRoutes: []string{"10.0.0.0/8"},
},
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
wantEditPrefs: true,
wantExitNodeID: existingExitNodeID,
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
},
{
name: "SetRoutes-not-allowed",
data: postRoutesRequest{
SetRoutes: true,
AdvertiseRoutes: []string{"10.0.0.0/8"},
},
peerCaps: peerCapabilities{capFeatureExitNodes: true},
wantErr: true,
},
{
name: "SetExitNode-and-SetRoutes",
data: postRoutesRequest{
SetExitNode: true,
SetRoutes: true,
UseExitNode: "new-exit-node",
AdvertiseRoutes: []string{"10.0.0.0/8"},
},
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
wantEditPrefs: true,
wantExitNodeID: "new-exit-node",
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotPrefs *ipn.MaskedPrefs
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/localapi/v0/prefs" {
t.Errorf("unexpected localapi call to %q", r.URL.Path)
http.Error(w, "unexpected localapi call", http.StatusInternalServerError)
return
}
switch r.Method {
case httpm.GET:
writeJSON(w, existingPrefs)
case httpm.PATCH:
var mp ipn.MaskedPrefs
if err := json.NewDecoder(r.Body).Decode(&mp); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gotPrefs = &mp
writeJSON(w, gotPrefs.Prefs)
default:
t.Errorf("unexpected method %q on /prefs", r.Method)
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
}
})}
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &local.Client{Dial: lal.Dial},
}
ctx := contextKeyPeer.WithValue(t.Context(), tt.peerCaps)
err := s.servePostRoutes(ctx, tt.data)
if tt.wantErr {
if err == nil {
t.Error("wanted error, got nil")
}
if gotPrefs != nil {
t.Error("EditPrefs should not have been called on error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPrefs == nil {
t.Fatal("expected EditPrefs to be called")
}
if diff := cmp.Diff(tt.wantExitNodeID, gotPrefs.ExitNodeID); diff != "" {
t.Errorf("ExitNodeID mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantRoutes, gotPrefs.AdvertiseRoutes, cmp.Comparer(func(a, b netip.Prefix) bool { return a.Compare(b) == 0 })); diff != "" {
t.Errorf("AdvertiseRoutes mismatch (-want +got):\n%s", diff)
}
})
}
}
+25 -7
View File
@@ -38,12 +38,12 @@ const (
updaterPrefix = "tailscale-updater" updaterPrefix = "tailscale-updater"
) )
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) { func makeCmdTailscaleCopy() (origPathExe, tmpPathExe string, err error) {
selfExe, err := os.Executable() srcExe, err := findCmdTailscale()
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
f, err := os.Open(selfExe) f, err := os.Open(srcExe)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -59,7 +59,25 @@ func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
f2.Close() f2.Close()
return "", "", err return "", "", err
} }
return selfExe, f2.Name(), f2.Close() return srcExe, f2.Name(), f2.Close()
}
// findCmdTailscale returns the path to the binary that should be copied for the update
// re-execution. The copy is re-executed with "update" as a subcommand, so it must be
// a binary that handles "update" (ie tailscale.exe, not tailscaled.exe)
func findCmdTailscale() (string, error) {
selfExe, err := os.Executable()
if err != nil {
return "", err
}
if strings.EqualFold(filepath.Base(selfExe), "tailscale.exe") {
return selfExe, nil
}
ts := filepath.Join(filepath.Dir(selfExe), "tailscale.exe")
if _, err := os.Stat(ts); err != nil {
return "", fmt.Errorf("cannot find tailscale.exe alongside %s: %w", selfExe, err)
}
return ts, nil
} }
func markTempFileWindows(name string) error { func markTempFileWindows(name string) error {
@@ -159,14 +177,14 @@ you can run the command prompt as Administrator one of these ways:
up.Logf("making tailscale.exe copy to switch to...") up.Logf("making tailscale.exe copy to switch to...")
up.cleanupOldDownloads(filepath.Join(os.TempDir(), updaterPrefix+"-*.exe")) up.cleanupOldDownloads(filepath.Join(os.TempDir(), updaterPrefix+"-*.exe"))
_, selfCopy, err := makeSelfCopy() _, cmdTailscaleCopy, err := makeCmdTailscaleCopy()
if err != nil { if err != nil {
return err return err
} }
defer os.Remove(selfCopy) defer os.Remove(cmdTailscaleCopy)
up.Logf("running tailscale.exe copy for final install...") up.Logf("running tailscale.exe copy for final install...")
cmd := exec.Command(selfCopy, "update") cmd := exec.Command(cmdTailscaleCopy, "update")
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winVersionEnv+"="+ver) cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winVersionEnv+"="+ver)
cmd.Stdout = up.Stderr cmd.Stdout = up.Stderr
cmd.Stderr = up.Stderr cmd.Stderr = up.Stderr
+45 -19
View File
@@ -143,25 +143,9 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("if src.%s != nil {", fname) writef("if src.%s != nil {", fname)
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname) writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
writef("for i := range dst.%s {", fname) writef("for i := range dst.%s {", fname)
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr { writeSliceElemClone(writef, ft.Elem(),
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname) fmt.Sprintf("src.%s[i]", fname),
if codegen.ContainsPointers(ptr.Elem()) { fmt.Sprintf("dst.%s[i]", fname))
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
writef("\tdst.%s[i] = new((*src.%s[i]).Clone())", fname, fname)
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
}
} else {
writef("\tdst.%s[i] = new(*src.%s[i])", fname, fname)
}
writef("}")
} else if ft.Elem().String() == "encoding/json.RawMessage" {
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
} else if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
} else {
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
}
writef("}") writef("}")
writef("}") writef("}")
} else { } else {
@@ -189,11 +173,28 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
n := it.QualifiedName(sliceType.Elem()) n := it.QualifiedName(sliceType.Elem())
writef("if dst.%s != nil {", fname) writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem)) writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
if codegen.ContainsPointers(sliceType.Elem()) {
writef("\tfor k, sv := range src.%s {", fname)
writef("\t\tif sv == nil {")
writef("\t\t\tdst.%s[k] = nil", fname)
writef("\t\t\tcontinue")
writef("\t\t}")
writef("\t\tdst.%s[k] = make([]%s, len(sv))", fname, n)
writef("\t\tfor i := range sv {")
innerWritef := func(format string, args ...any) {
writef("\t\t"+format, args...)
}
writeSliceElemClone(innerWritef, sliceType.Elem(),
"sv[i]", fmt.Sprintf("dst.%s[k][i]", fname))
writef("\t\t}")
writef("\t}")
} else {
writef("\tfor k := range src.%s {", fname) writef("\tfor k := range src.%s {", fname)
// use zero-length slice instead of nil to ensure // use zero-length slice instead of nil to ensure
// the key is always copied. // the key is always copied.
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname) writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
writef("\t}") writef("\t}")
}
writef("}") writef("}")
} else if codegen.IsViewType(elem) || !codegen.ContainsPointers(elem) { } else if codegen.IsViewType(elem) || !codegen.ContainsPointers(elem) {
// If the map values are view types (which are // If the map values are view types (which are
@@ -242,6 +243,31 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it)) buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
} }
// writeSliceElemClone generates code to deep-clone a single slice element
// from srcExpr to dstExpr. It handles pointer, json.RawMessage, interface,
// and named struct element types.
func writeSliceElemClone(writef func(string, ...any), elemType types.Type, srcExpr, dstExpr string) {
if ptr, isPtr := elemType.(*types.Pointer); isPtr {
writef("if %s == nil { %s = nil } else {", srcExpr, dstExpr)
if codegen.ContainsPointers(ptr.Elem()) {
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
writef("\t%s = new((*%s).Clone())", dstExpr, srcExpr)
} else {
writef("\t%s = %s.Clone()", dstExpr, srcExpr)
}
} else {
writef("\t%s = new(*%s)", dstExpr, srcExpr)
}
writef("}")
} else if elemType.String() == "encoding/json.RawMessage" {
writef("%s = append(%s[:0:0], %s...)", dstExpr, srcExpr, srcExpr)
} else if _, isIface := elemType.Underlying().(*types.Interface); isIface {
writef("%s = %s.Clone()", dstExpr, srcExpr)
} else {
writef("%s = *%s.Clone()", dstExpr, srcExpr)
}
}
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map. // hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
func hasBasicUnderlying(typ types.Type) bool { func hasBasicUnderlying(typ types.Type) bool {
switch typ.Underlying().(type) { switch typ.Underlying().(type) {
+41
View File
@@ -7,6 +7,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/cmd/cloner/clonerex" "tailscale.com/cmd/cloner/clonerex"
) )
@@ -182,6 +183,46 @@ func TestNamedMapContainer(t *testing.T) {
} }
} }
func TestMapSlicePointerContainer(t *testing.T) {
num := 42
orig := &clonerex.MapSlicePointerContainer{
Routes: map[string][]*clonerex.SliceContainer{
"route1": {
{Slice: []*int{&num}},
{Slice: []*int{&num, &num}},
},
"route2": {
{Slice: []*int{&num}},
},
},
}
cloned := orig.Clone()
if !reflect.DeepEqual(orig, cloned) {
t.Errorf("Clone() = %v, want %v", cloned, orig)
}
// Mutate cloned.Routes pointer values
*cloned.Routes["route1"][0].Slice[0] = 999
if *orig.Routes["route1"][0].Slice[0] == 999 {
t.Errorf("Clone() aliased memory in Routes: original was modified")
}
}
func TestMapSlicePointerContainerNilValue(t *testing.T) {
num := 7
orig := &clonerex.MapSlicePointerContainer{
Routes: map[string][]*clonerex.SliceContainer{
"nil-value": nil,
"non-nil": {{Slice: []*int{&num}}},
},
}
cloned := orig.Clone()
if diff := cmp.Diff(orig.Routes, cloned.Routes); diff != "" {
t.Errorf("Clone() Routes mismatch (-orig +cloned):\n%s", diff)
}
}
func TestDeeplyNestedMap(t *testing.T) { func TestDeeplyNestedMap(t *testing.T) {
num := 123 num := 123
orig := &clonerex.DeeplyNestedMap{ orig := &clonerex.DeeplyNestedMap{
+8 -1
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors // Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer //go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer
// Package clonerex is an example package for the cloner tool. // Package clonerex is an example package for the cloner tool.
package clonerex package clonerex
@@ -60,6 +60,13 @@ type NamedMapContainer struct {
Attrs NamedMap Attrs NamedMap
} }
// MapSlicePointerContainer has a map whose values are slices of pointers.
// This tests that the cloner deep-clones the pointer elements in the slice,
// not just the slice itself (which would leave aliased pointers).
type MapSlicePointerContainer struct {
Routes map[string][]*SliceContainer
}
// DeeplyNestedMap tests arbitrary depth of map nesting (3+ levels) // DeeplyNestedMap tests arbitrary depth of map nesting (3+ levels)
type DeeplyNestedMap struct { type DeeplyNestedMap struct {
ThreeLevels map[string]map[string]map[string]int ThreeLevels map[string]map[string]map[string]int
+43 -1
View File
@@ -176,9 +176,42 @@ var _NamedMapContainerCloneNeedsRegeneration = NamedMapContainer(struct {
Attrs NamedMap Attrs NamedMap
}{}) }{})
// Clone makes a deep copy of MapSlicePointerContainer.
// The result aliases no memory with the original.
func (src *MapSlicePointerContainer) Clone() *MapSlicePointerContainer {
if src == nil {
return nil
}
dst := new(MapSlicePointerContainer)
*dst = *src
if dst.Routes != nil {
dst.Routes = map[string][]*SliceContainer{}
for k, sv := range src.Routes {
if sv == nil {
dst.Routes[k] = nil
continue
}
dst.Routes[k] = make([]*SliceContainer, len(sv))
for i := range sv {
if sv[i] == nil {
dst.Routes[k][i] = nil
} else {
dst.Routes[k][i] = sv[i].Clone()
}
}
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _MapSlicePointerContainerCloneNeedsRegeneration = MapSlicePointerContainer(struct {
Routes map[string][]*SliceContainer
}{})
// Clone duplicates src into dst and reports whether it succeeded. // Clone duplicates src into dst and reports whether it succeeded.
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>, // To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer. // where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer.
func Clone(dst, src any) bool { func Clone(dst, src any) bool {
switch src := src.(type) { switch src := src.(type) {
case *SliceContainer: case *SliceContainer:
@@ -226,6 +259,15 @@ func Clone(dst, src any) bool {
*dst = src.Clone() *dst = src.Clone()
return true return true
} }
case *MapSlicePointerContainer:
switch dst := dst.(type) {
case *MapSlicePointerContainer:
*dst = *src.Clone()
return true
case **MapSlicePointerContainer:
*dst = src.Clone()
return true
}
} }
return false return false
} }
+38 -37
View File
@@ -22,11 +22,12 @@ import (
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"tailscale.com/client/local" "tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/kube/egressservices" "tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient" "tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes" "tailscale.com/kube/kubetypes"
"tailscale.com/types/netmap"
"tailscale.com/util/httpm" "tailscale.com/util/httpm"
"tailscale.com/util/linuxfw" "tailscale.com/util/linuxfw"
"tailscale.com/util/mak" "tailscale.com/util/mak"
@@ -54,7 +55,7 @@ type egressProxy struct {
tsClient *local.Client // never nil tsClient *local.Client // never nil
netmapChan chan ipn.Notify // chan to receive netmap updates on netmapChan chan *netmap.NetworkMap // chan to receive netmap updates on
podIPv4 string // never empty string, currently only IPv4 is supported podIPv4 string // never empty string, currently only IPv4 is supported
@@ -86,7 +87,7 @@ type httpClient interface {
// - the mounted egress config has changed // - the mounted egress config has changed
// - the proxy's tailnet IP addresses have changed // - the proxy's tailnet IP addresses have changed
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN // - tailnet IPs have changed for any backend targets specified by tailnet FQDN
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRunOpts) error { func (ep *egressProxy) run(ctx context.Context, nm *netmap.NetworkMap, opts egressProxyRunOpts) error {
ep.configure(opts) ep.configure(opts)
var tickChan <-chan time.Time var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event var eventChan <-chan fsnotify.Event
@@ -105,7 +106,7 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
eventChan = w.Events eventChan = w.Events
} }
if err := ep.sync(ctx, n); err != nil { if err := ep.sync(ctx, nm); err != nil {
return err return err
} }
for { for {
@@ -116,14 +117,14 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
log.Printf("periodic sync, ensuring firewall config is up to date...") log.Printf("periodic sync, ensuring firewall config is up to date...")
case <-eventChan: case <-eventChan:
log.Printf("config file change detected, ensuring firewall config is up to date...") log.Printf("config file change detected, ensuring firewall config is up to date...")
case n = <-ep.netmapChan: case nm = <-ep.netmapChan:
shouldResync := ep.shouldResync(n) shouldResync := ep.shouldResync(nm)
if !shouldResync { if !shouldResync {
continue continue
} }
log.Printf("netmap change detected, ensuring firewall config is up to date...") log.Printf("netmap change detected, ensuring firewall config is up to date...")
} }
if err := ep.sync(ctx, n); err != nil { if err := ep.sync(ctx, nm); err != nil {
return fmt.Errorf("error syncing egress service config: %w", err) return fmt.Errorf("error syncing egress service config: %w", err)
} }
} }
@@ -135,7 +136,7 @@ type egressProxyRunOpts struct {
kc kubeclient.Client kc kubeclient.Client
tsClient *local.Client tsClient *local.Client
stateSecret string stateSecret string
netmapChan chan ipn.Notify netmapChan chan *netmap.NetworkMap
podIPv4 string podIPv4 string
tailnetAddrs []netip.Prefix tailnetAddrs []netip.Prefix
} }
@@ -164,7 +165,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 // 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 // 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 // as failed firewall update
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error { func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
cfgs, err := ep.getConfigs() cfgs, err := ep.getConfigs()
if err != nil { if err != nil {
return fmt.Errorf("error retrieving egress service configs: %w", err) return fmt.Errorf("error retrieving egress service configs: %w", err)
@@ -173,12 +174,12 @@ func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
if err != nil { if err != nil {
return fmt.Errorf("error retrieving current egress proxy status: %w", err) return fmt.Errorf("error retrieving current egress proxy status: %w", err)
} }
newStatus, err := ep.syncEgressConfigs(cfgs, status, n) newStatus, err := ep.syncEgressConfigs(cfgs, status, nm)
if err != nil { if err != nil {
return fmt.Errorf("error syncing egress service configs: %w", err) return fmt.Errorf("error syncing egress service configs: %w", err)
} }
if !servicesStatusIsEqual(newStatus, status) { if !servicesStatusIsEqual(newStatus, status) {
if err := ep.setStatus(ctx, newStatus, n); err != nil { if err := ep.setStatus(ctx, newStatus, nm); err != nil {
return fmt.Errorf("error setting egress proxy status: %w", err) return fmt.Errorf("error setting egress proxy status: %w", err)
} }
} }
@@ -187,14 +188,14 @@ func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node. // addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
// Netmap must not be nil. // Netmap must not be nil.
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool { func (ep *egressProxy) addrsHaveChanged(nm *netmap.NetworkMap) bool {
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses()) return !reflect.DeepEqual(ep.tailnetAddrs, nm.SelfNode.Addresses())
} }
// syncEgressConfigs adds and deletes firewall rules to match the desired // syncEgressConfigs adds and deletes firewall rules to match the desired
// configuration. It uses the provided status to determine what is currently // configuration. It uses the provided status to determine what is currently
// applied and updates the status after a successful sync. // applied and updates the status after a successful sync.
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) { func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm *netmap.NetworkMap) (*egressservices.Status, error) {
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) { if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
return nil, nil return nil, nil
} }
@@ -212,8 +213,8 @@ func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *e
// Add new services, update rules for any that have changed. // Add new services, update rules for any that have changed.
rulesPerSvcToAdd := make(map[string][]rule, 0) rulesPerSvcToAdd := make(map[string][]rule, 0)
rulesPerSvcToDelete := make(map[string][]rule, 0) rulesPerSvcToDelete := make(map[string][]rule, 0)
for svcName, cfg := range *cfgs { for svcName, cfg := range cfgs {
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n) tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, nm)
if err != nil { if err != nil {
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err) return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
} }
@@ -228,12 +229,12 @@ func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *e
if len(rulesToDelete) != 0 { if len(rulesToDelete) != 0 {
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete) mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
} }
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) { if len(rulesToAdd) != 0 || ep.addrsHaveChanged(nm) {
// For each tailnet target, set up SNAT from the local tailnet device address of the matching // For each tailnet target, set up SNAT from the local tailnet device address of the matching
// family. // family.
for _, t := range tailnetTargetIPs { for _, t := range tailnetTargetIPs {
var local netip.Addr var local netip.Addr
for _, pfx := range n.NetMap.SelfNode.Addresses().All() { for _, pfx := range nm.SelfNode.Addresses().All() {
if !pfx.IsSingleIP() { if !pfx.IsSingleIP() {
continue continue
} }
@@ -352,7 +353,7 @@ func updatesForCfg(svcName string, cfg egressservices.Config, status *egressserv
// deleteUnneccessaryServices ensure that any services found on status, but not // deleteUnneccessaryServices ensure that any services found on status, but not
// present in config are deleted. // present in config are deleted.
func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error { func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, status *egressservices.Status) error {
if !hasServicesConfigured(status) { if !hasServicesConfigured(status) {
return nil return nil
} }
@@ -367,7 +368,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, s
} }
for svcName, svc := range status.Services { for svcName, svc := range status.Services {
if _, ok := (*cfgs)[svcName]; !ok { if _, ok := cfgs[svcName]; !ok {
log.Printf("service %s is no longer required, deleting", svcName) log.Printf("service %s is no longer required, deleting", svcName)
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil { if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
return fmt.Errorf("error deleting service %s: %w", svcName, err) return fmt.Errorf("error deleting service %s: %w", svcName, err)
@@ -379,7 +380,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, s
} }
// getConfigs gets the mounted egress service configuration. // getConfigs gets the mounted egress service configuration.
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) { func (ep *egressProxy) getConfigs() (egressservices.Configs, error) {
svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices) svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices)
j, err := os.ReadFile(svcsCfg) j, err := os.ReadFile(svcsCfg)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -391,7 +392,7 @@ func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
if len(j) == 0 || string(j) == "" { if len(j) == 0 || string(j) == "" {
return nil, nil return nil, nil
} }
cfg := &egressservices.Configs{} cfg := egressservices.Configs{}
if err := json.Unmarshal(j, &cfg); err != nil { if err := json.Unmarshal(j, &cfg); err != nil {
return nil, err return nil, err
} }
@@ -423,7 +424,7 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
// setStatus writes egress proxy's currently configured firewall to the state // setStatus writes egress proxy's currently configured firewall to the state
// Secret and updates proxy's tailnet addresses. // Secret and updates proxy's tailnet addresses.
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error { func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm *netmap.NetworkMap) error {
// Pod IP is used to determine if a stored status applies to THIS proxy Pod. // Pod IP is used to determine if a stored status applies to THIS proxy Pod.
if status == nil { if status == nil {
status = &egressservices.Status{} status = &egressservices.Status{}
@@ -446,7 +447,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 { if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
return fmt.Errorf("error patching state Secret: %w", err) return fmt.Errorf("error patching state Secret: %w", err)
} }
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice() ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
return nil return nil
} }
@@ -456,7 +457,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the // 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 // netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
// doesn't. // doesn't.
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) { func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *netmap.NetworkMap) (addrs []netip.Addr, err error) {
if svc.TailnetTarget.IP != "" { if svc.TailnetTarget.IP != "" {
addr, err := netip.ParseAddr(svc.TailnetTarget.IP) addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
if err != nil { if err != nil {
@@ -472,11 +473,11 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
if svc.TailnetTarget.FQDN == "" { if svc.TailnetTarget.FQDN == "" {
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set") return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
} }
if n.NetMap == nil { if nm == nil {
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN) log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
return addrs, nil return addrs, nil
} }
egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN) egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
if err != nil { if err != nil {
log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err) log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err)
return addrs, nil return addrs, nil
@@ -502,22 +503,22 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
// shouldResync parses netmap update and returns true if the update contains // shouldResync parses netmap update and returns true if the update contains
// changes for which the egress proxy's firewall should be reconfigured. // changes for which the egress proxy's firewall should be reconfigured.
func (ep *egressProxy) shouldResync(n ipn.Notify) bool { func (ep *egressProxy) shouldResync(nm *netmap.NetworkMap) bool {
if n.NetMap == nil { if nm == nil {
return false return false
} }
// If proxy's tailnet addresses have changed, resync. // If proxy's tailnet addresses have changed, resync.
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) { if !reflect.DeepEqual(nm.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
log.Printf("node addresses have changed, trigger egress config resync") log.Printf("node addresses have changed, trigger egress config resync")
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice() ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
return true return true
} }
// If the IPs for any of the egress services configured via FQDN have // If the IPs for any of the egress services configured via FQDN have
// changed, resync. // changed, resync.
for fqdn, ips := range ep.targetFQDNs { for fqdn, ips := range ep.targetFQDNs {
for _, nn := range n.NetMap.Peers { for _, nn := range nm.Peers {
if equalFQDNs(nn.Name(), fqdn) { if equalFQDNs(nn.Name(), fqdn) {
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) { if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
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()) 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())
@@ -602,8 +603,8 @@ type rule struct {
protocol string protocol string
} }
func wantsServicesConfigured(cfgs *egressservices.Configs) bool { func wantsServicesConfigured(cfgs egressservices.Configs) bool {
return cfgs != nil && len(*cfgs) != 0 return cfgs != nil && len(cfgs) != 0
} }
func hasServicesConfigured(status *egressservices.Status) bool { func hasServicesConfigured(status *egressservices.Status) bool {
@@ -657,13 +658,13 @@ func (ep *egressProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// would normally be this Pod. When this Pod is being deleted, the operator should have removed it from the Service // would normally be this Pod. When this Pod is being deleted, the operator should have removed it from the Service
// backends and eventually kube proxy routing rules should be updated to no longer route traffic for the Service to this // backends and eventually kube proxy routing rules should be updated to no longer route traffic for the Service to this
// Pod. // Pod.
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs *egressservices.Configs, hp int) { func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs egressservices.Configs, hp int) {
if cfgs == nil || len(*cfgs) == 0 { // avoid sleeping if no services are configured if cfgs == nil || len(cfgs) == 0 { // avoid sleeping if no services are configured
return return
} }
log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...") log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...")
var wg sync.WaitGroup var wg sync.WaitGroup
for s, cfg := range *cfgs { for s, cfg := range cfgs {
hep := cfg.HealthCheckEndpoint hep := cfg.HealthCheckEndpoint
if hep == "" { if hep == "" {
log.Printf("Tailnet target %q does not have a cluster healthcheck specified, unable to verify if cluster traffic for the target is still routed via this Pod", s) log.Printf("Tailnet target %q does not have a cluster healthcheck specified, unable to verify if cluster traffic for the target is still routed via this Pod", s)
+2 -2
View File
@@ -255,13 +255,13 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfgs := &egressservices.Configs{} cfgs := egressservices.Configs{}
switches := make(map[string]int) switches := make(map[string]int)
for svc, callsToSwitch := range tt.services { for svc, callsToSwitch := range tt.services {
endpoint := fmt.Sprintf("http://%s.local", svc) endpoint := fmt.Sprintf("http://%s.local", svc)
if tt.healthCheckSet { if tt.healthCheckSet {
(*cfgs)[svc] = egressservices.Config{ cfgs[svc] = egressservices.Config{
HealthCheckEndpoint: endpoint, HealthCheckEndpoint: endpoint,
} }
} }
+25 -82
View File
@@ -21,6 +21,7 @@ import (
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"tailscale.com/client/local" "tailscale.com/client/local"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/kube/authkey"
"tailscale.com/kube/egressservices" "tailscale.com/kube/egressservices"
"tailscale.com/kube/ingressservices" "tailscale.com/kube/ingressservices"
"tailscale.com/kube/kubeapi" "tailscale.com/kube/kubeapi"
@@ -32,7 +33,6 @@ import (
) )
const fieldManager = "tailscale-container" const fieldManager = "tailscale-container"
const kubeletMountedConfigLn = "..data"
// kubeClient is a wrapper around Tailscale's internal kube client that knows how to talk to the kube API server. We use // kubeClient is a wrapper around Tailscale's internal kube client that knows how to talk to the kube API server. We use
// this rather than any of the upstream Kubernetes client libaries to avoid extra imports. // this rather than any of the upstream Kubernetes client libaries to avoid extra imports.
@@ -127,6 +127,9 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
// resetContainerbootState resets state from previous runs of containerboot to // resetContainerbootState resets state from previous runs of containerboot to
// ensure the operator doesn't use stale state when a Pod is first recreated. // ensure the operator doesn't use stale state when a Pod is first recreated.
//
// Device identity keys (device_id, device_fqdn, device_ips) are preserved so
// the operator can clean up the old device from the control plane.
func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string, tailscaledConfigAuthkey string) error { func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string, tailscaledConfigAuthkey string) error {
existingSecret, err := kc.GetSecret(ctx, kc.stateSecret) existingSecret, err := kc.GetSecret(ctx, kc.stateSecret)
switch { switch {
@@ -140,11 +143,6 @@ func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string
s := &kubeapi.Secret{ s := &kubeapi.Secret{
Data: map[string][]byte{ Data: map[string][]byte{
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion), kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
// TODO(tomhjp): Perhaps shouldn't clear device ID and use a different signal, as this could leak tailnet devices.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil, kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil, egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil, ingressservices.IngressConfigKey: nil,
@@ -169,47 +167,18 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
return fmt.Errorf("error disconnecting from control: %w", err) return fmt.Errorf("error disconnecting from control: %w", err)
} }
err = kc.setReissueAuthKey(ctx, tailscaledConfigAuthKey) err = authkey.SetReissueAuthKey(ctx, kc.Client, kc.stateSecret, tailscaledConfigAuthKey, authkey.TailscaleContainerFieldManager)
if err != nil { if err != nil {
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err) return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
} }
err = kc.waitForAuthKeyReissue(ctx, cfg.TailscaledConfigFilePath, tailscaledConfigAuthKey, 10*time.Minute) clearFn := func(ctx context.Context) error {
if err != nil { return authkey.ClearReissueAuthKey(ctx, kc.Client, kc.stateSecret, authkey.TailscaleContainerFieldManager)
return fmt.Errorf("failed to receive new auth key: %w", err)
} }
return nil getAuthKey := func() string { return authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath) }
} tailscaledCfgDir := filepath.Dir(cfg.TailscaledConfigFilePath)
var notify <-chan struct{}
func (kc *kubeClient) setReissueAuthKey(ctx context.Context, authKey string) error {
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyReissueAuthkey: []byte(authKey),
},
}
log.Printf("Requesting a new auth key from operator")
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
}
func (kc *kubeClient) waitForAuthKeyReissue(ctx context.Context, configPath string, oldAuthKey string, maxWait time.Duration) error {
log.Printf("Waiting for operator to provide new auth key (max wait: %v)", maxWait)
ctx, cancel := context.WithTimeout(ctx, maxWait)
defer cancel()
tailscaledCfgDir := filepath.Dir(configPath)
toWatch := filepath.Join(tailscaledCfgDir, kubeletMountedConfigLn)
var (
pollTicker <-chan time.Time
eventChan <-chan fsnotify.Event
)
pollInterval := 5 * time.Second
// Try to use fsnotify for faster notification
if w, err := fsnotify.NewWatcher(); err != nil { if w, err := fsnotify.NewWatcher(); err != nil {
log.Printf("auth key reissue: fsnotify unavailable, using polling: %v", err) log.Printf("auth key reissue: fsnotify unavailable, using polling: %v", err)
} else if err := w.Add(tailscaledCfgDir); err != nil { } else if err := w.Add(tailscaledCfgDir); err != nil {
@@ -217,54 +186,28 @@ func (kc *kubeClient) waitForAuthKeyReissue(ctx context.Context, configPath stri
log.Printf("auth key reissue: fsnotify watch failed, using polling: %v", err) log.Printf("auth key reissue: fsnotify watch failed, using polling: %v", err)
} else { } else {
defer w.Close() defer w.Close()
log.Printf("auth key reissue: watching for config changes via fsnotify") ch := make(chan struct{}, 1)
eventChan = w.Events toWatch := filepath.Join(tailscaledCfgDir, "..data")
} go func() {
for ev := range w.Events {
// still keep polling if using fsnotify, for logging and in case fsnotify fails if ev.Name == toWatch {
pt := time.NewTicker(pollInterval)
defer pt.Stop()
pollTicker = pt.C
start := time.Now()
for {
select { select {
case <-ctx.Done(): case ch <- struct{}{}:
return fmt.Errorf("timeout waiting for auth key reissue after %v", maxWait) default:
case <-pollTicker: // Waits for polling tick, continues when received
case event := <-eventChan:
if event.Name != toWatch {
continue
} }
} }
}
}()
notify = ch
log.Printf("auth key reissue: watching for config changes via fsnotify")
}
newAuthKey := authkeyFromTailscaledConfig(configPath) err = authkey.WaitForAuthKeyReissue(ctx, tailscaledConfigAuthKey, 10*time.Minute, getAuthKey, clearFn, notify)
if newAuthKey != "" && newAuthKey != oldAuthKey { if err != nil {
log.Printf("New auth key received from operator after %v", time.Since(start).Round(time.Second)) return fmt.Errorf("failed to receive new auth key: %w", err)
if err := kc.clearReissueAuthKeyRequest(ctx); err != nil {
log.Printf("Warning: failed to clear reissue request: %v", err)
} }
return nil return nil
}
if eventChan == nil && pollTicker != nil {
log.Printf("Waiting for new auth key from operator (%v elapsed)", time.Since(start).Round(time.Second))
}
}
}
// clearReissueAuthKeyRequest removes the reissue_authkey marker from the Secret
// to signal to the operator that we've successfully received the new key.
func (kc *kubeClient) clearReissueAuthKeyRequest(ctx context.Context) error {
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyReissueAuthkey: nil,
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
} }
// waitForConsistentState waits for tailscaled to finish writing state if it // waitForConsistentState waits for tailscaled to finish writing state if it
-20
View File
@@ -259,10 +259,6 @@ func TestResetContainerbootState(t *testing.T) {
expected: map[string][]byte{ expected: map[string][]byte{
kubetypes.KeyCapVer: capver, kubetypes.KeyCapVer: capver,
kubetypes.KeyPodUID: []byte("1234"), kubetypes.KeyPodUID: []byte("1234"),
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil, kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil, egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil, ingressservices.IngressConfigKey: nil,
@@ -272,10 +268,6 @@ func TestResetContainerbootState(t *testing.T) {
initial: map[string][]byte{}, initial: map[string][]byte{},
expected: map[string][]byte{ expected: map[string][]byte{
kubetypes.KeyCapVer: capver, kubetypes.KeyCapVer: capver,
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil, kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil, egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil, ingressservices.IngressConfigKey: nil,
@@ -303,9 +295,6 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver, kubetypes.KeyCapVer: capver,
kubetypes.KeyPodUID: []byte("1234"), kubetypes.KeyPodUID: []byte("1234"),
// Cleared keys. // Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil, kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil, egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil, ingressservices.IngressConfigKey: nil,
@@ -321,9 +310,6 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver, kubetypes.KeyCapVer: capver,
kubetypes.KeyReissueAuthkey: nil, kubetypes.KeyReissueAuthkey: nil,
// Cleared keys. // Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil, kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil, egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil, ingressservices.IngressConfigKey: nil,
@@ -338,9 +324,6 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver, kubetypes.KeyCapVer: capver,
// reissue_authkey not cleared. // reissue_authkey not cleared.
// Cleared keys. // Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil, kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil, egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil, ingressservices.IngressConfigKey: nil,
@@ -355,9 +338,6 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver, kubetypes.KeyCapVer: capver,
// reissue_authkey not cleared. // reissue_authkey not cleared.
// Cleared keys. // Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil, kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil, egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil, ingressservices.IngressConfigKey: nil,
+70 -47
View File
@@ -137,10 +137,11 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/health" "tailscale.com/health"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/conffile"
kubeutils "tailscale.com/k8s-operator" kubeutils "tailscale.com/k8s-operator"
"tailscale.com/kube/authkey"
healthz "tailscale.com/kube/health" healthz "tailscale.com/kube/health"
"tailscale.com/kube/kubetypes" "tailscale.com/kube/kubetypes"
klc "tailscale.com/kube/localclient" klc "tailscale.com/kube/localclient"
@@ -209,7 +210,7 @@ func run() error {
var tailscaledConfigAuthkey string var tailscaledConfigAuthkey string
if isOneStepConfig(cfg) { if isOneStepConfig(cfg) {
tailscaledConfigAuthkey = authkeyFromTailscaledConfig(cfg.TailscaledConfigFilePath) tailscaledConfigAuthkey = authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath)
} }
var kc *kubeClient var kc *kubeClient
@@ -374,7 +375,7 @@ authLoop:
if hasKubeStateStore(cfg) { if hasKubeStateStore(cfg) {
log.Printf("Auth key missing or invalid (NeedsLogin state), disconnecting from control and requesting new key from operator") log.Printf("Auth key missing or invalid (NeedsLogin state), disconnecting from control and requesting new key from operator")
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey) err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
if err != nil { if err != nil {
return fmt.Errorf("failed to get a reissued authkey: %w", err) return fmt.Errorf("failed to get a reissued authkey: %w", err)
} }
@@ -414,7 +415,7 @@ authLoop:
if isOneStepConfig(cfg) && hasKubeStateStore(cfg) { if isOneStepConfig(cfg) && hasKubeStateStore(cfg) {
log.Printf("Auth key failed to authenticate (may be expired or single-use), disconnecting from control and requesting new key from operator") log.Printf("Auth key failed to authenticate (may be expired or single-use), disconnecting from control and requesting new key from operator")
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey) err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
if err != nil { if err != nil {
return fmt.Errorf("failed to get a reissued authkey: %w", err) return fmt.Errorf("failed to get a reissued authkey: %w", err)
} }
@@ -536,7 +537,7 @@ authLoop:
failedResolveAttempts++ failedResolveAttempts++
} }
var egressSvcsNotify chan ipn.Notify var egressSvcsNotify chan *netmap.NetworkMap
notifyChan := make(chan ipn.Notify) notifyChan := make(chan ipn.Notify)
errChan := make(chan error) errChan := make(chan error)
go func() { go func() {
@@ -550,10 +551,17 @@ 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 wg sync.WaitGroup var wg sync.WaitGroup
runLoop: runLoop:
for { for {
var processNetmap bool
select { select {
case <-ctx.Done(): case <-ctx.Done():
// Although killTailscaled() is deferred earlier, if we // Although killTailscaled() is deferred earlier, if we
@@ -566,6 +574,8 @@ runLoop:
return fmt.Errorf("failed to read from tailscaled: %w", err) return fmt.Errorf("failed to read from tailscaled: %w", err)
case err := <-cfgWatchErrChan: case err := <-cfgWatchErrChan:
return fmt.Errorf("failed to watch tailscaled config: %w", err) return fmt.Errorf("failed to watch tailscaled config: %w", err)
case <-peerPoll.C:
processNetmap = true
case n := <-notifyChan: case n := <-notifyChan:
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn // TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
if n.State != nil && *n.State != ipn.Running { if n.State != nil && *n.State != ipn.Running {
@@ -576,8 +586,43 @@ runLoop:
// whereupon we'll go through initial auth again. // 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", *n.State)
} }
if n.NetMap != nil { if n.SelfChange != nil {
addrs = n.NetMap.SelfNode.Addresses().AsSlice() processNetmap = true
}
case <-tc:
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
if err != nil {
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
resetTimer(true)
continue
}
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
}))
if backendsHaveChanged && len(addrs) != 0 {
log.Printf("Backend address change detected, installing proxy rules for backends %v", newBackendAddrs)
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
return fmt.Errorf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
}
}
backendAddrs = newBackendAddrs
resetTimer(false)
continue
case e := <-egressSvcsErrorChan:
return fmt.Errorf("egress proxy failed: %v", e)
case e := <-ingressSvcsErrorChan:
return fmt.Errorf("ingress proxy failed: %v", e)
}
if !processNetmap {
continue
}
nm, err := fetchNetMap(ctx, client)
if err != nil {
log.Printf("error fetching netmap: %v", err)
continue
}
if nm != nil {
addrs = nm.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs) newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs ipsHaveChanged := newCurrentIPs != currentIPs
@@ -589,14 +634,14 @@ runLoop:
// Kubernetes Secret to clean up tailnet nodes // Kubernetes Secret to clean up tailnet nodes
// for proxies whose route setup continuously // for proxies whose route setup continuously
// fails. // fails.
deviceID := n.NetMap.SelfNode.StableID() deviceID := nm.SelfNode.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) { if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := kc.storeDeviceID(ctx, n.NetMap.SelfNode.StableID()); err != nil { if err := kc.storeDeviceID(ctx, nm.SelfNode.StableID()); err != nil {
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err) return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
} }
} }
if cfg.TailnetTargetFQDN != "" { if cfg.TailnetTargetFQDN != "" {
egressAddrs, err := resolveTailnetFQDN(n.NetMap, cfg.TailnetTargetFQDN) egressAddrs, err := resolveTailnetFQDN(nm, cfg.TailnetTargetFQDN)
if err != nil { if err != nil {
log.Print(err.Error()) log.Print(err.Error())
break break
@@ -652,7 +697,7 @@ runLoop:
backendAddrs = newBackendAddrs backendAddrs = newBackendAddrs
} }
if cfg.ServeConfigPath != "" { if cfg.ServeConfigPath != "" {
cd := certDomainFromNetmap(n.NetMap) cd := certDomainFromNetmap(nm)
if cd == "" { if cd == "" {
cd = kubetypes.ValueNoHTTPS cd = kubetypes.ValueNoHTTPS
} }
@@ -695,9 +740,9 @@ runLoop:
// set up ensures that the operator does not // set up ensures that the operator does not
// advertize endpoints of broken proxies. // 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'. // TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()} deviceEndpoints := []any{nm.SelfNode.Name(), nm.SelfNode.Addresses()}
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) { if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) {
if err := kc.storeDeviceEndpoints(ctx, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil { if err := kc.storeDeviceEndpoints(ctx, nm.SelfNode.Name(), nm.SelfNode.Addresses().AsSlice()); err != nil {
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err) return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
} }
} }
@@ -726,7 +771,7 @@ runLoop:
} }
if egressSvcsNotify != nil { if egressSvcsNotify != nil {
egressSvcsNotify <- n egressSvcsNotify <- nm
} }
} }
if !startupTasksDone { if !startupTasksDone {
@@ -748,7 +793,7 @@ runLoop:
// will crash this node. // will crash this node.
if cfg.EgressProxiesCfgPath != "" { if cfg.EgressProxiesCfgPath != "" {
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath) log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
egressSvcsNotify = make(chan ipn.Notify) egressSvcsNotify = make(chan *netmap.NetworkMap)
opts := egressProxyRunOpts{ opts := egressProxyRunOpts{
cfgPath: cfg.EgressProxiesCfgPath, cfgPath: cfg.EgressProxiesCfgPath,
nfr: nfr, nfr: nfr,
@@ -760,7 +805,7 @@ runLoop:
tailnetAddrs: addrs, tailnetAddrs: addrs,
} }
go func() { go func() {
if err := ep.run(ctx, n, opts); err != nil { if err := ep.run(ctx, nm, opts); err != nil {
egressSvcsErrorChan <- err egressSvcsErrorChan <- err
} }
}() }()
@@ -806,29 +851,6 @@ runLoop:
go reaper() go reaper()
} }
} }
case <-tc:
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
if err != nil {
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
resetTimer(true)
continue
}
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
}))
if backendsHaveChanged && len(addrs) != 0 {
log.Printf("Backend address change detected, installing proxy rules for backends %v", newBackendAddrs)
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
return fmt.Errorf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
}
}
backendAddrs = newBackendAddrs
resetTimer(false)
case e := <-egressSvcsErrorChan:
return fmt.Errorf("egress proxy failed: %v", e)
case e := <-ingressSvcsErrorChan:
return fmt.Errorf("ingress proxy failed: %v", e)
}
} }
wg.Wait() wg.Wait()
@@ -963,6 +985,15 @@ 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 // resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
// can be either a peer device or a Tailscale Service. // can be either a peer device or a Tailscale Service.
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) { func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
@@ -1024,11 +1055,3 @@ func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Pref
return prefixes return prefixes
} }
func authkeyFromTailscaledConfig(path string) string {
if cfg, err := conffile.Load(path); err == nil && cfg.Parsed.AuthKey != nil {
return *cfg.Parsed.AuthKey
}
return ""
}
+76 -6
View File
@@ -32,6 +32,7 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/health" "tailscale.com/health"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/kube/egressservices" "tailscale.com/kube/egressservices"
@@ -45,6 +46,7 @@ import (
const configFileAuthKey = "some-auth-key" const configFileAuthKey = "some-auth-key"
func TestContainerBoot(t *testing.T) { func TestContainerBoot(t *testing.T) {
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/19380")
boot := filepath.Join(t.TempDir(), "containerboot") boot := filepath.Join(t.TempDir(), "containerboot")
if err := exec.Command("go", "build", "-ldflags", "-X main.testSleepDuration=1ms", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil { if err := exec.Command("go", "build", "-ldflags", "-X main.testSleepDuration=1ms", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
t.Fatalf("Building containerboot: %v", err) t.Fatalf("Building containerboot: %v", err)
@@ -69,6 +71,12 @@ func TestContainerBoot(t *testing.T) {
// Waits below to be true before proceeding to the next phase. // Waits below to be true before proceeding to the next phase.
Notify *ipn.Notify 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 is the commands that containerboot should run in this phase.
WantCmds []string WantCmds []string
@@ -103,12 +111,10 @@ func TestContainerBoot(t *testing.T) {
} }
runningNotify := &ipn.Notify{ runningNotify := &ipn.Notify{
State: new(ipn.Running), State: new(ipn.Running),
NetMap: &netmap.NetworkMap{ SelfChange: &tailcfg.Node{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"), StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.", Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
}, },
} }
type testCase struct { type testCase struct {
@@ -381,6 +387,12 @@ func TestContainerBoot(t *testing.T) {
{ {
Notify: &ipn.Notify{ Notify: &ipn.Notify{
State: new(ipn.Running), State: new(ipn.Running),
SelfChange: &tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{ NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{ SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"), StableID: tailcfg.StableNodeID("myID"),
@@ -395,7 +407,6 @@ func TestContainerBoot(t *testing.T) {
}).View(), }).View(),
}, },
}, },
},
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false", WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
WantExitCode: new(1), WantExitCode: new(1),
}, },
@@ -629,6 +640,12 @@ func TestContainerBoot(t *testing.T) {
{ {
Notify: &ipn.Notify{ Notify: &ipn.Notify{
State: new(ipn.Running), State: new(ipn.Running),
SelfChange: &tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"),
Name: "new-name.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{ NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{ SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"), StableID: tailcfg.StableNodeID("newID"),
@@ -636,7 +653,6 @@ func TestContainerBoot(t *testing.T) {
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(), }).View(),
}, },
},
WantKubeSecret: map[string]string{ WantKubeSecret: map[string]string{
"authkey": "tskey-key", "authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net.", "device_fqdn": "new-name.test.ts.net.",
@@ -1093,6 +1109,12 @@ func TestContainerBoot(t *testing.T) {
{ {
Notify: &ipn.Notify{ Notify: &ipn.Notify{
State: new(ipn.Running), State: new(ipn.Running),
SelfChange: &tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},
NetMap: &netmap.NetworkMap{ NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{ SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"), StableID: tailcfg.StableNodeID("myID"),
@@ -1107,7 +1129,6 @@ func TestContainerBoot(t *testing.T) {
}).View(), }).View(),
}, },
}, },
},
WantKubeSecret: map[string]string{ WantKubeSecret: map[string]string{
"egress-services": string(mustJSON(t, egressStatus)), "egress-services": string(mustJSON(t, egressStatus)),
"authkey": "tskey-key", "authkey": "tskey-key",
@@ -1274,6 +1295,18 @@ func TestContainerBoot(t *testing.T) {
t.Fatalf("phase %d: updating mtime for %q: %v", i, path, err) 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)
}
env.lapi.Notify(p.Notify) env.lapi.Notify(p.Notify)
if p.Signal != nil { if p.Signal != nil {
cmd.Process.Signal(*p.Signal) cmd.Process.Signal(*p.Signal)
@@ -1466,6 +1499,7 @@ type localAPI struct {
sync.Mutex sync.Mutex
cond *sync.Cond cond *sync.Cond
notify *ipn.Notify notify *ipn.Notify
netmap *netmap.NetworkMap // served by /localapi/v0/netmap
} }
func (lc *localAPI) Start() error { func (lc *localAPI) Start() error {
@@ -1502,8 +1536,44 @@ func (lc *localAPI) Notify(n *ipn.Notify) {
lc.cond.Broadcast() 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 (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { 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": case "/localapi/v0/serve-config":
switch r.Method { switch r.Method {
case "GET": case "GET":
+28 -6
View File
@@ -41,8 +41,28 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {} func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
// setDNSCache sets the published DNS cache for tests.
func setDNSCache(tb testing.TB, m *dnsEntryMap) {
tb.Helper()
j, err := json.Marshal(m.IPs)
if err != nil {
tb.Fatal(err)
}
tstest.AssertNotParallel(tb)
dnsCache.Store(m)
dnsCacheBytes.Store(j)
tb.Cleanup(func() {
dnsCache.Store(nil)
dnsCacheBytes.Store(nil)
})
}
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP { func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
t.Helper() t.Helper()
tstest.AssertNotParallel(t)
if dnsCache.Load() == nil {
t.Fatal("dnsCache not initialized; call setDNSCache before getBootstrapDNS")
}
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil) req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handleBootstrapDNS(w, req) handleBootstrapDNS(w, req)
@@ -100,7 +120,8 @@ func TestUnpublishedDNS(t *testing.T) {
} }
} }
func resetMetrics() { func resetMetrics(tb testing.TB) {
tstest.AssertNotParallel(tb)
publishedDNSHits.Set(0) publishedDNSHits.Set(0)
publishedDNSMisses.Set(0) publishedDNSMisses.Set(0)
unpublishedDNSHits.Set(0) unpublishedDNSHits.Set(0)
@@ -114,8 +135,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
pub := &dnsEntryMap{ pub := &dnsEntryMap{
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}}, IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
} }
dnsCache.Store(pub) setDNSCache(t, pub)
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
unpublishedDNSCache.Store(&dnsEntryMap{ unpublishedDNSCache.Store(&dnsEntryMap{
IPs: map[string][]net.IP{ IPs: map[string][]net.IP{
@@ -131,7 +151,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
t.Run("CacheMiss", func(t *testing.T) { t.Run("CacheMiss", func(t *testing.T) {
// One domain in map but empty, one not in map at all // One domain in map but empty, one not in map at all
for _, q := range []string{"log.tailscale.com", "login.tailscale.com"} { for _, q := range []string{"log.tailscale.com", "login.tailscale.com"} {
resetMetrics() resetMetrics(t)
ips := getBootstrapDNS(t, q) ips := getBootstrapDNS(t, q)
// Expected our public map to be returned on a cache miss // Expected our public map to be returned on a cache miss
@@ -149,7 +169,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
// Verify that we do get a valid response and metric. // Verify that we do get a valid response and metric.
t.Run("CacheHit", func(t *testing.T) { t.Run("CacheHit", func(t *testing.T) {
resetMetrics() resetMetrics(t)
ips := getBootstrapDNS(t, "controlplane.tailscale.com") ips := getBootstrapDNS(t, "controlplane.tailscale.com")
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}} want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
if !reflect.DeepEqual(ips, want) { if !reflect.DeepEqual(ips, want) {
@@ -166,8 +186,10 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
} }
func TestLookupMetric(t *testing.T) { func TestLookupMetric(t *testing.T) {
setDNSCache(t, &dnsEntryMap{})
d := []string{"a.io", "b.io", "c.io", "d.io", "e.io", "e.io", "e.io", "a.io"} d := []string{"a.io", "b.io", "c.io", "d.io", "e.io", "e.io", "e.io", "a.io"}
resetMetrics() resetMetrics(t)
for _, q := range d { for _, q := range d {
_ = getBootstrapDNS(t, q) _ = getBootstrapDNS(t, q)
} }
+2 -1
View File
@@ -20,6 +20,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/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/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/jsontext from github.com/go-json-experiment/json+
💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/hdevalence/ed25519consensus from tailscale.com/tka github.com/hdevalence/ed25519consensus from tailscale.com/tka
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
@@ -310,7 +311,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
hash from crypto+ hash from crypto+
hash/crc32 from compress/gzip+ 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 hash/maphash from go4.org/mem+
html from net/http/pprof+ html from net/http/pprof+
html/template from tailscale.com/cmd/derper+ html/template from tailscale.com/cmd/derper+
internal/abi from crypto/x509/internal/macos+ internal/abi from crypto/x509/internal/macos+
+27 -8
View File
@@ -87,8 +87,7 @@ var (
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection") acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection") acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
perClientRateLimit = flag.Uint("per-client-rate-limit", 0, "per-client receive rate limit in bytes/sec; 0 means unlimited. Mesh peers are exempt.") rateConfigPath = flag.String("rate-config", "", "if non-empty, path to JSON rate limit config file. Rate limiting is experimental and subject to change. Configuration is reloaded on SIGHUP.")
perClientRateBurst = flag.Uint("per-client-rate-burst", 0, "per-client receive rate burst in bytes; 0 defaults to 2x the rate limit (only relevant when using nonzero --per-client-rate-limit)")
// tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule. // tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule.
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time") tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
@@ -195,12 +194,11 @@ func main() {
s.SetVerifyClientURL(*verifyClientURL) s.SetVerifyClientURL(*verifyClientURL)
s.SetVerifyClientURLFailOpen(*verifyFailOpen) s.SetVerifyClientURLFailOpen(*verifyFailOpen)
s.SetTCPWriteTimeout(*tcpWriteTimeout) s.SetTCPWriteTimeout(*tcpWriteTimeout)
if *perClientRateLimit > 0 { if *rateConfigPath != "" {
burst := *perClientRateBurst if err := s.LoadAndApplyRateConfig(*rateConfigPath); err != nil {
if burst < 1 { log.Fatalf("derper: loading rate config: %v", err)
burst = *perClientRateLimit * 2
} }
s.SetPerClientRateLimit(*perClientRateLimit, burst) go watchRateConfig(ctx, s, *rateConfigPath)
} }
var meshKey string var meshKey string
@@ -254,7 +252,7 @@ func main() {
if err := startMesh(s); err != nil { if err := startMesh(s); err != nil {
log.Fatalf("startMesh: %v", err) log.Fatalf("startMesh: %v", err)
} }
expvar.Publish("derp", s.ExpVar()) expvar.Publish("derp", s.ExpVar(*rateConfigPath != ""))
handleHome, ok := getHomeHandler(*flagHome) handleHome, ok := getHomeHandler(*flagHome)
if !ok { if !ok {
@@ -436,6 +434,27 @@ func main() {
} }
} }
// watchRateConfig listens for SIGHUP signals and reloads the rate config
// file on each signal, applying it to the server. It returns when ctx is done.
func watchRateConfig(ctx context.Context, s *derpserver.Server, path string) {
sighup := make(chan os.Signal, 1)
signal.Notify(sighup, syscall.SIGHUP)
defer signal.Stop(sighup)
for {
select {
case <-ctx.Done():
return
case <-sighup:
log.Printf("derper: received SIGHUP, reloading rate config from %s", path)
if err := s.LoadAndApplyRateConfig(path); err != nil {
log.Printf("derper: rate config reload failed: %v", err)
continue
}
log.Printf("derper: rate config reloaded successfully")
}
}
}
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`) var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
func prodAutocertHostPolicy(_ context.Context, host string) error { func prodAutocertHostPolicy(_ context.Context, host string) error {
+1 -1
View File
@@ -26,7 +26,7 @@ import (
"github.com/tailscale/hujson" "github.com/tailscale/hujson"
"golang.org/x/oauth2/clientcredentials" "golang.org/x/oauth2/clientcredentials"
tsclient "tailscale.com/client/tailscale" tsclient "tailscale.com/client/tailscale"
_ "tailscale.com/feature/condregister/identityfederation" _ "tailscale.com/feature/identityfederation"
"tailscale.com/internal/client/tailscale" "tailscale.com/internal/client/tailscale"
"tailscale.com/util/httpm" "tailscale.com/util/httpm"
) )
+5 -201
View File
@@ -5,212 +5,16 @@
package main // import "tailscale.com/cmd/hello" package main // import "tailscale.com/cmd/hello"
import ( import (
"context"
"crypto/tls"
_ "embed"
"encoding/json"
"errors"
"flag"
"html/template"
"log" "log"
"net/http"
"os"
"strings"
"time"
"tailscale.com/client/local" "tailscale.com/cmd/hello/helloserver"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
) )
var (
httpAddr = flag.String("http", ":80", "address to run an HTTP server on, or empty for none")
httpsAddr = flag.String("https", ":443", "address to run an HTTPS server on, or empty for none")
testIP = flag.String("test-ip", "", "if non-empty, look up IP and exit before running a server")
)
//go:embed hello.tmpl.html
var embeddedTemplate string
var localClient local.Client
func main() { func main() {
flag.Parse() s := &helloserver.Server{
if *testIP != "" { HTTPAddr: ":80",
res, err := localClient.WhoIs(context.Background(), *testIP) HTTPSAddr: ":443",
if err != nil {
log.Fatal(err)
} }
e := json.NewEncoder(os.Stdout)
e.SetIndent("", "\t")
e.Encode(res)
return
}
if devMode() {
// Parse it optimistically
var err error
tmpl, err = template.New("home").Parse(embeddedTemplate)
if err != nil {
log.Printf("ignoring template error in dev mode: %v", err)
}
} else {
if embeddedTemplate == "" {
log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+")
}
tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
}
http.HandleFunc("/", root)
log.Printf("Starting hello server.") log.Printf("Starting hello server.")
log.Fatal(s.Run())
errc := make(chan error, 1)
if *httpAddr != "" {
log.Printf("running HTTP server on %s", *httpAddr)
go func() {
errc <- http.ListenAndServe(*httpAddr, nil)
}()
}
if *httpsAddr != "" {
log.Printf("running HTTPS server on %s", *httpsAddr)
go func() {
hs := &http.Server{
Addr: *httpsAddr,
TLSConfig: &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
switch hi.ServerName {
case "hello.ts.net":
return localClient.GetCertificate(hi)
case "hello.ipn.dev":
c, err := tls.LoadX509KeyPair(
"/etc/hello/hello.ipn.dev.crt",
"/etc/hello/hello.ipn.dev.key",
)
if err != nil {
return nil, err
}
return &c, nil
}
return nil, errors.New("invalid SNI name")
},
},
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 20 * time.Second,
MaxHeaderBytes: 10 << 10,
}
errc <- hs.ListenAndServeTLS("", "")
}()
}
log.Fatal(<-errc)
}
func devMode() bool { return *httpsAddr == "" && *httpAddr != "" }
func getTmpl() (*template.Template, error) {
if devMode() {
tmplData, err := os.ReadFile("hello.tmpl.html")
if os.IsNotExist(err) {
log.Printf("using baked-in template in dev mode; can't find hello.tmpl.html in current directory")
return tmpl, nil
}
return template.New("home").Parse(string(tmplData))
}
return tmpl, nil
}
// tmpl is the template used in prod mode.
// In dev mode it's only used if the template file doesn't exist on disk.
// It's initialized by main after flag parsing.
var tmpl *template.Template
type tmplData struct {
DisplayName string // "Foo Barberson"
LoginName string // "foo@bar.com"
ProfilePicURL string // "https://..."
MachineName string // "imac5k"
MachineOS string // "Linux"
IP string // "100.2.3.4"
}
func tailscaleIP(who *apitype.WhoIsResponse) string {
if who == nil {
return ""
}
vals, err := tailcfg.UnmarshalNodeCapJSON[string](who.Node.CapMap, tailcfg.NodeAttrNativeIPV4)
if err == nil && len(vals) > 0 {
return vals[0]
}
for _, nodeIP := range who.Node.Addresses {
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
return nodeIP.Addr().String()
}
}
for _, nodeIP := range who.Node.Addresses {
if nodeIP.IsSingleIP() {
return nodeIP.Addr().String()
}
}
return ""
}
func root(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil && *httpsAddr != "" {
host := r.Host
if strings.Contains(r.Host, "100.101.102.103") ||
strings.Contains(r.Host, "hello.ipn.dev") {
host = "hello.ts.net"
}
http.Redirect(w, r, "https://"+host, http.StatusFound)
return
}
if r.RequestURI != "/" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
if r.TLS != nil && *httpsAddr != "" && strings.Contains(r.Host, "hello.ipn.dev") {
http.Redirect(w, r, "https://hello.ts.net", http.StatusFound)
return
}
tmpl, err := getTmpl()
if err != nil {
w.Header().Set("Content-Type", "text/plain")
http.Error(w, "template error: "+err.Error(), 500)
return
}
who, err := localClient.WhoIs(r.Context(), r.RemoteAddr)
var data tmplData
if err != nil {
if devMode() {
log.Printf("warning: using fake data in dev mode due to whois lookup error: %v", err)
data = tmplData{
DisplayName: "Taily Scalerson",
LoginName: "taily@scaler.son",
ProfilePicURL: "https://placekitten.com/200/200",
MachineName: "scaled",
MachineOS: "Linux",
IP: "100.1.2.3",
}
} else {
log.Printf("whois(%q) error: %v", r.RemoteAddr, err)
http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
return
}
} else {
data = tmplData{
DisplayName: who.UserProfile.DisplayName,
LoginName: who.UserProfile.LoginName,
ProfilePicURL: who.UserProfile.ProfilePicURL,
MachineName: firstLabel(who.Node.ComputedName),
MachineOS: who.Node.Hostinfo.OS(),
IP: tailscaleIP(who),
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
}
// firstLabel s up until the first period, if any.
func firstLabel(s string) string {
s, _, _ = strings.Cut(s, ".")
return s
} }
-438
View File
@@ -1,438 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<title>Hello from Tailscale</title>
<style>
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
main {
height: 100%;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: #dad6d5;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-size: 1rem;
font-weight: inherit;
}
a {
color: inherit;
}
p {
margin: 0;
}
main {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
max-width: 24rem;
width: 95%;
margin-left: auto;
margin-right: auto;
}
.p-2 {
padding: 0.5rem;
}
.p-4 {
padding: 1rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.pl-3 {
padding-left: 0.75rem;
}
.pr-3 {
padding-right: 0.75rem;
}
.pt-4 {
padding-top: 1rem;
}
.mr-2 {
margin-right: 0.5rem;
;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.mb-12 {
margin-bottom: 3rem;
}
.width-full {
width: 100%;
}
.min-width-0 {
min-width: 0;
}
.rounded-lg {
border-radius: 0.5rem;
}
.relative {
position: relative;
}
.flex {
display: flex;
}
.justify-between {
justify-content: space-between;
}
.items-center {
align-items: center;
}
.border {
border-width: 1px;
}
.border-t-1 {
border-top-width: 1px;
}
.border-gray-100 {
border-color: #f7f5f4;
}
.border-gray-200 {
border-color: #eeebea;
}
.border-gray-300 {
border-color: #dad6d5;
}
.bg-white {
background-color: white;
}
.bg-gray-0 {
background-color: #faf9f8;
}
.bg-gray-100 {
background-color: #f7f5f4;
}
.text-green-600 {
color: #0d4b3b;
}
.text-blue-600 {
color: #3f5db3;
}
.hover\:text-blue-800:hover {
color: #253570;
}
.text-gray-600 {
color: #444342;
}
.text-gray-700 {
color: #2e2d2d;
}
.text-gray-800 {
color: #232222;
}
.text-center {
text-align: center;
}
.text-sm {
font-size: 0.875rem;
}
.font-title {
font-size: 1.25rem;
letter-spacing: -0.025em;
}
.font-semibold {
font-weight: 600;
}
.font-medium {
font-weight: 500;
}
.font-regular {
font-weight: 400;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-hidden {
overflow: hidden;
}
.profile-pic {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background-size: cover;
margin-right: 0.5rem;
flex-shrink: 0;
}
.panel {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.animate .panel {
transform: translateY(10%);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
}
.animate .panel-interior {
opacity: 0.0;
transition: opacity 1200ms ease;
}
.animate .logo {
transform: translateY(2rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animate .header-title {
transform: translateY(1.6rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animate .header-text {
transform: translateY(1.2rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animate .footer {
transform: translateY(-0.5rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animating .panel {
transform: translateY(0);
opacity: 1.0;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.animating .panel-interior {
opacity: 1.0;
}
.animating .spinner {
opacity: 0.0;
}
.animating .logo,
.animating .header-title,
.animating .header-text,
.animating .footer {
transform: translateY(0);
opacity: 1.0;
}
.spinner {
display: inline-flex;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
align-items: center;
transition: opacity 200ms ease;
}
.spinner span {
display: inline-block;
background-color: currentColor;
border-radius: 9999px;
animation-name: loading-dots-blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
width: 0.35em;
height: 0.35em;
margin: 0 0.15em;
}
.spinner span:nth-child(2) {
animation-delay: 200ms;
}
.spinner span:nth-child(3) {
animation-delay: 400ms;
}
.spinner {
display: none;
}
.animate .spinner {
display: inline-flex;
}
@keyframes loading-dots-blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
@media (prefers-reduced-motion) {
* {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
transition-delay: 0ms !important;
}
}
</style>
</head>
<body class="bg-gray-100">
<script>
(function() {
var lastSeen = localStorage.getItem("lastSeen");
if (!lastSeen) {
document.body.classList.add("animate");
window.addEventListener("load", function () {
setTimeout(function () {
document.body.classList.add("animating");
localStorage.setItem("lastSeen", Date.now());
}, 100);
});
}
})();
</script>
<main class="text-gray-800">
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
</svg>
<header class="mb-8 text-center">
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
<p class="header-text">This device is signed in as…</p>
</header>
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
<div class="spinner text-gray-600">
<span></span>
<span></span>
<span></span>
</div>
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
<div class="profile-pic bg-gray-100" style="background-image: url({{.ProfilePicURL}});"></div>
<div class="overflow-hidden">
{{ with .DisplayName }}
<h4 class="font-semibold truncate">{{.}}</h4>
{{ end }}
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
</div>
</div>
<div
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
<div class="flex items-center min-width-0">
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
</div>
<h5>{{.IP}}</h5>
</div>
</div>
<footer class="footer text-gray-600 text-center mb-12">
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
target="_blank">what you can do next &rarr;</a></p>
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
target="_blank">the Hello service &rarr;</a></p>
</footer>
</main>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<title>Hello from Tailscale</title>
<link type="text/css" rel="stylesheet" href="/static/style.css">
<script src="/static/script.js" defer></script>
</head>
<body class="bg-gray-100">
<main class="text-gray-800">
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
</svg>
<header class="mb-8 text-center">
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
<p class="header-text">This device is signed in as…</p>
</header>
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
<div class="spinner text-gray-600">
<span></span>
<span></span>
<span></span>
</div>
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
<div class="profile-pic bg-gray-100">
<img
src="{{.ProfilePicURL}}"
alt="Profile picture"
class="profile-pic-img"
>
</div>
<div class="overflow-hidden">
{{ with .DisplayName }}
<h4 class="font-semibold truncate">{{.}}</h4>
{{ end }}
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
</div>
</div>
<div
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
<div class="flex items-center min-width-0">
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
</div>
<h5>{{.IP}}</h5>
</div>
</div>
<footer class="footer text-gray-600 text-center mb-12">
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
target="_blank">what you can do next &rarr;</a></p>
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
target="_blank">the Hello service &rarr;</a></p>
</footer>
</main>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package helloserver implements the HTTP server behind hello.ts.net.
package helloserver
import (
"crypto/tls"
"embed"
"html/template"
"log"
"net/http"
"strings"
"time"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
)
//go:embed hello.tmpl.html
var embeddedTemplate string
//go:embed static/*
var staticFiles embed.FS
var staticHandler = http.FileServerFS(staticFiles)
var tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
// Server is an HTTP server for hello.ts.net.
//
// The zero value is not valid; populate at least one of HTTPAddr or HTTPSAddr
// before calling Run.
type Server struct {
// HTTPAddr is the address to run an HTTP server on, or empty for none.
HTTPAddr string
// HTTPSAddr is the address to run an HTTPS server on, or empty for none.
HTTPSAddr string
// LocalClient is used to look up the identity of incoming requests and
// to obtain TLS certificates. If nil, the zero value of local.Client is
// used.
LocalClient *local.Client
}
func (s *Server) localClient() *local.Client {
if s.LocalClient != nil {
return s.LocalClient
}
return &local.Client{}
}
// Run starts the configured HTTP and HTTPS servers and blocks until one of
// them returns an error.
func (s *Server) Run() error {
errc := make(chan error, 1)
if s.HTTPAddr != "" {
log.Printf("running HTTP server on %s", s.HTTPAddr)
go func() {
errc <- http.ListenAndServe(s.HTTPAddr, s)
}()
}
if s.HTTPSAddr != "" {
log.Printf("running HTTPS server on %s", s.HTTPSAddr)
go func() {
hs := &http.Server{
Addr: s.HTTPSAddr,
Handler: s,
TLSConfig: &tls.Config{
GetCertificate: s.localClient().GetCertificate,
},
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 20 * time.Second,
MaxHeaderBytes: 10 << 10,
}
errc <- hs.ListenAndServeTLS("", "")
}()
}
return <-errc
}
type tmplData struct {
DisplayName string // "Foo Barberson"
LoginName string // "foo@bar.com"
ProfilePicURL string // "https://..."
MachineName string // "imac5k"
MachineOS string // "Linux"
IP string // "100.2.3.4"
}
func tailscaleIP(who *apitype.WhoIsResponse) string {
if who == nil {
return ""
}
vals, err := tailcfg.UnmarshalNodeCapJSON[string](who.Node.CapMap, tailcfg.NodeAttrNativeIPV4)
if err == nil && len(vals) > 0 {
return vals[0]
}
for _, nodeIP := range who.Node.Addresses {
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
return nodeIP.Addr().String()
}
}
for _, nodeIP := range who.Node.Addresses {
if nodeIP.IsSingleIP() {
return nodeIP.Addr().String()
}
}
return ""
}
// ServeHTTP implements http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil && s.HTTPSAddr != "" {
host := r.Host
if strings.Contains(r.Host, "100.101.102.103") {
host = "hello.ts.net"
}
http.Redirect(w, r, "https://"+host, http.StatusFound)
return
}
if strings.HasPrefix(r.RequestURI, "/static/") {
staticHandler.ServeHTTP(w, r)
return
}
if r.RequestURI != "/" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
who, err := s.localClient().WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
log.Printf("whois(%q) error: %v", r.RemoteAddr, err)
http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
return
}
data := tmplData{
DisplayName: who.UserProfile.DisplayName,
LoginName: who.UserProfile.LoginName,
ProfilePicURL: who.UserProfile.ProfilePicURL,
MachineName: firstLabel(who.Node.ComputedName),
MachineOS: who.Node.Hostinfo.OS(),
IP: tailscaleIP(who),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
}
// firstLabel returns s up until the first period, if any.
func firstLabel(s string) string {
s, _, _ = strings.Cut(s, ".")
return s
}
+12
View File
@@ -0,0 +1,12 @@
(function () {
var lastSeen = localStorage.getItem("lastSeen");
if (!lastSeen) {
document.body.classList.add("animate");
window.addEventListener("load", function () {
setTimeout(function () {
document.body.classList.add("animating");
localStorage.setItem("lastSeen", Date.now());
}, 100);
});
}
})();
+366
View File
@@ -0,0 +1,366 @@
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
main {
height: 100%;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: #dad6d5;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-size: 1rem;
font-weight: inherit;
}
a {
color: inherit;
}
p {
margin: 0;
}
main {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
max-width: 24rem;
width: 95%;
margin-left: auto;
margin-right: auto;
}
.p-2 {
padding: 0.5rem;
}
.p-4 {
padding: 1rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.pl-3 {
padding-left: 0.75rem;
}
.pr-3 {
padding-right: 0.75rem;
}
.pt-4 {
padding-top: 1rem;
}
.mr-2 {
margin-right: 0.5rem;
;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.mb-12 {
margin-bottom: 3rem;
}
.width-full {
width: 100%;
}
.min-width-0 {
min-width: 0;
}
.rounded-lg {
border-radius: 0.5rem;
}
.relative {
position: relative;
}
.flex {
display: flex;
}
.justify-between {
justify-content: space-between;
}
.items-center {
align-items: center;
}
.border {
border-width: 1px;
}
.border-t-1 {
border-top-width: 1px;
}
.border-gray-100 {
border-color: #f7f5f4;
}
.border-gray-200 {
border-color: #eeebea;
}
.border-gray-300 {
border-color: #dad6d5;
}
.bg-white {
background-color: white;
}
.bg-gray-0 {
background-color: #faf9f8;
}
.bg-gray-100 {
background-color: #f7f5f4;
}
.text-green-600 {
color: #0d4b3b;
}
.text-blue-600 {
color: #3f5db3;
}
.hover\:text-blue-800:hover {
color: #253570;
}
.text-gray-600 {
color: #444342;
}
.text-gray-700 {
color: #2e2d2d;
}
.text-gray-800 {
color: #232222;
}
.text-center {
text-align: center;
}
.text-sm {
font-size: 0.875rem;
}
.font-title {
font-size: 1.25rem;
letter-spacing: -0.025em;
}
.font-semibold {
font-weight: 600;
}
.font-medium {
font-weight: 500;
}
.font-regular {
font-weight: 400;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-hidden {
overflow: hidden;
}
.profile-pic {
width: 2.5rem;
height: 2.5rem;
background-size: cover;
margin-right: 0.5rem;
flex-shrink: 0;
}
.profile-pic-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 9999px;
}
.panel {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.animate .panel {
transform: translateY(10%);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
}
.animate .panel-interior {
opacity: 0.0;
transition: opacity 1200ms ease;
}
.animate .logo {
transform: translateY(2rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animate .header-title {
transform: translateY(1.6rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animate .header-text {
transform: translateY(1.2rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animate .footer {
transform: translateY(-0.5rem);
opacity: 0.0;
transition: transform 1200ms ease, opacity 1200ms ease;
}
.animating .panel {
transform: translateY(0);
opacity: 1.0;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.animating .panel-interior {
opacity: 1.0;
}
.animating .spinner {
opacity: 0.0;
}
.animating .logo,
.animating .header-title,
.animating .header-text,
.animating .footer {
transform: translateY(0);
opacity: 1.0;
}
.spinner {
display: inline-flex;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
align-items: center;
transition: opacity 200ms ease;
}
.spinner span {
display: inline-block;
background-color: currentColor;
border-radius: 9999px;
animation-name: loading-dots-blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
width: 0.35em;
height: 0.35em;
margin: 0 0.15em;
}
.spinner span:nth-child(2) {
animation-delay: 200ms;
}
.spinner span:nth-child(3) {
animation-delay: 400ms;
}
.spinner {
display: none;
}
.animate .spinner {
display: inline-flex;
}
@keyframes loading-dots-blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
@media (prefers-reduced-motion) {
* {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
transition-delay: 0ms !important;
}
}
+11 -85
View File
@@ -6,77 +6,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+ W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+
github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry
github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+
github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4
github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+
github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/config from tailscale.com/wif
github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds
github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds
github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/context from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints+
github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/internal/middleware from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds
github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4
github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws
github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso
github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso
github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+
github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+
github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+
github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer
github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+
github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/endpoints/private/rulesfn from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer
github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+
github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config
github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware
github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/tracing from github.com/aws/aws-sdk-go-v2/aws/middleware+
github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
github.com/blang/semver/v4 from k8s.io/component-base/metrics github.com/blang/semver/v4 from k8s.io/component-base/metrics
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus+ 💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus+
@@ -130,7 +59,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/google/gnostic-models/jsonschema from github.com/google/gnostic-models/compiler github.com/google/gnostic-models/jsonschema from github.com/google/gnostic-models/compiler
github.com/google/gnostic-models/openapiv2 from k8s.io/client-go/discovery+ github.com/google/gnostic-models/openapiv2 from k8s.io/client-go/discovery+
github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+ github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+
github.com/google/uuid from github.com/prometheus-community/pro-bing+ github.com/google/uuid from k8s.io/apimachinery/pkg/util/uuid+
github.com/hdevalence/ed25519consensus from tailscale.com/tka github.com/hdevalence/ed25519consensus from tailscale.com/tka
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+ github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
@@ -164,7 +93,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal+ github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal+
github.com/pkg/errors from github.com/evanphx/json-patch/v5+ github.com/pkg/errors from github.com/evanphx/json-patch/v5+
github.com/pmezard/go-difflib/difflib from k8s.io/apimachinery/pkg/util/diff github.com/pmezard/go-difflib/difflib from k8s.io/apimachinery/pkg/util/diff
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil from github.com/prometheus/client_golang/prometheus/promhttp github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil from github.com/prometheus/client_golang/prometheus/promhttp
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil/header from github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil/header from github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil
💣 github.com/prometheus/client_golang/prometheus from github.com/prometheus/client_golang/prometheus/collectors+ 💣 github.com/prometheus/client_golang/prometheus from github.com/prometheus/client_golang/prometheus/collectors+
@@ -180,7 +108,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd+ github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd+
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
@@ -805,11 +733,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+ tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/c2n from tailscale.com/tsnet tailscale.com/feature/c2n from tailscale.com/tsnet
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
tailscale.com/feature/condregister/oauthkey 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/portmapper from tailscale.com/tsnet
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/logpolicy tailscale.com/feature/syspolicy from tailscale.com/logpolicy
@@ -817,7 +743,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/health from tailscale.com/control/controlclient+ tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
tailscale.com/hostinfo from tailscale.com/client/web+ tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/internal/client/tailscale from tailscale.com/feature/identityfederation+ tailscale.com/internal/client/tailscale from tailscale.com/feature/oauthkey+
tailscale.com/ipn from tailscale.com/client/local+ tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+ 💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
@@ -910,7 +836,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+ tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/tsweb from tailscale.com/util/eventbus tailscale.com/tsweb from tailscale.com/util/eventbus+
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+ tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/bools from tailscale.com/tsnet+ tailscale.com/types/bools from tailscale.com/tsnet+
@@ -1000,7 +926,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/wgengine/wgcfg/nmcfg 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/wgint from tailscale.com/wgengine+
tailscale.com/wgengine/wglog from tailscale.com/wgengine tailscale.com/wgengine/wglog from tailscale.com/wgengine
tailscale.com/wif from tailscale.com/feature/identityfederation
golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
@@ -1023,14 +948,15 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
golang.org/x/net/http2 from k8s.io/apimachinery/pkg/util/net+ golang.org/x/net/http2 from k8s.io/apimachinery/pkg/util/net+
golang.org/x/net/http2/hpack from golang.org/x/net/http2+ golang.org/x/net/http2/hpack from golang.org/x/net/http2+
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+ golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts+ golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/internal/httpcommon from golang.org/x/net/http2 golang.org/x/net/internal/httpcommon from golang.org/x/net/http2
golang.org/x/net/internal/httpsfv from golang.org/x/net/http2
golang.org/x/net/internal/iana from golang.org/x/net/icmp+ golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+ golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
golang.org/x/net/internal/socks from golang.org/x/net/proxy golang.org/x/net/internal/socks from golang.org/x/net/proxy
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+ golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+ golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/proxy from tailscale.com/net/netns golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from tailscale.com/net/netmon+ 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/net/websocket from tailscale.com/k8s-operator/sessionrecording/ws
@@ -1137,7 +1063,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
crypto/sha3 from crypto/internal/fips140hash+ crypto/sha3 from crypto/internal/fips140hash+
crypto/sha512 from crypto/ecdsa+ crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/cipher+ crypto/subtle from crypto/cipher+
crypto/tls from github.com/prometheus-community/pro-bing+ crypto/tls from github.com/prometheus/client_golang/prometheus/promhttp+
crypto/tls/internal/fips140tls from crypto/tls crypto/tls/internal/fips140tls from crypto/tls
crypto/x509 from crypto/tls+ crypto/x509 from crypto/tls+
D crypto/x509/internal/macos from crypto/x509 D crypto/x509/internal/macos from crypto/x509
@@ -1246,7 +1172,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
mime/quotedprintable from mime/multipart mime/quotedprintable from mime/multipart
net from crypto/tls+ net from crypto/tls+
net/http from expvar+ net/http from expvar+
net/http/httptrace from github.com/prometheus-community/pro-bing+ net/http/httptrace from github.com/prometheus/client_golang/prometheus/promhttp+
net/http/httputil from tailscale.com/client/web+ net/http/httputil from tailscale.com/client/web+
net/http/internal from net/http+ net/http/internal from net/http+
net/http/internal/ascii from net/http+ net/http/internal/ascii from net/http+
@@ -146,3 +146,6 @@ spec:
tolerations: tolerations:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
{{- with .Values.operatorConfig.priorityClassName }}
priorityClassName: {{ . }}
{{- end }}
@@ -72,6 +72,8 @@ operatorConfig:
affinity: {} affinity: {}
priorityClassName: ""
podSecurityContext: {} podSecurityContext: {}
securityContext: {} securityContext: {}
@@ -104,6 +104,884 @@ spec:
description: Pod configuration. description: Pod configuration.
type: object type: object
properties: properties:
affinity:
description: If specified, applies affinity rules to the pods deployed by the DNSConfig resource.
type: object
properties:
nodeAffinity:
description: Describes node affinity scheduling rules for the pod.
type: object
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling affinity expressions, etc.),
compute a sum by iterating through the elements of this field and adding
"weight" to the sum if the node matches the corresponding matchExpressions; the
node(s) with the highest sum are the most preferred.
type: array
items:
description: |-
An empty preferred scheduling term matches all objects with implicit weight 0
(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
type: object
required:
- preference
- weight
properties:
preference:
description: A node selector term, associated with the corresponding weight.
type: object
properties:
matchExpressions:
description: A list of node selector requirements by node's labels.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchFields:
description: A list of node selector requirements by node's fields.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
x-kubernetes-map-type: atomic
weight:
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
type: integer
format: int32
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to an update), the system
may or may not try to eventually evict the pod from its node.
type: object
required:
- nodeSelectorTerms
properties:
nodeSelectorTerms:
description: Required. A list of node selector terms. The terms are ORed.
type: array
items:
description: |-
A null or empty node selector term matches no objects. The requirements of
them are ANDed.
The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
type: object
properties:
matchExpressions:
description: A list of node selector requirements by node's labels.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchFields:
description: A list of node selector requirements by node's fields.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
x-kubernetes-map-type: atomic
x-kubernetes-list-type: atomic
x-kubernetes-map-type: atomic
podAffinity:
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
type: object
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling affinity expressions, etc.),
compute a sum by iterating through the elements of this field and adding
"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
node(s) with the highest sum are the most preferred.
type: array
items:
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
type: object
required:
- podAffinityTerm
- weight
properties:
podAffinityTerm:
description: Required. A pod affinity term, associated with the corresponding weight.
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
weight:
description: |-
weight associated with matching the corresponding podAffinityTerm,
in the range 1-100.
type: integer
format: int32
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to a pod label update), the
system may or may not try to eventually evict the pod from its node.
When there are multiple elements, the lists of nodes corresponding to each
podAffinityTerm are intersected, i.e. all terms must be satisfied.
type: array
items:
description: |-
Defines a set of pods (namely those matching the labelSelector
relative to the given namespace(s)) that this pod should be
co-located (affinity) or not co-located (anti-affinity) with,
where co-located is defined as running on a node whose value of
the label with key <topologyKey> matches that of any node on which
a pod of the set of pods is running
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
x-kubernetes-list-type: atomic
podAntiAffinity:
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
type: object
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the anti-affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling anti-affinity expressions, etc.),
compute a sum by iterating through the elements of this field and subtracting
"weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the
node(s) with the highest sum are the most preferred.
type: array
items:
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
type: object
required:
- podAffinityTerm
- weight
properties:
podAffinityTerm:
description: Required. A pod affinity term, associated with the corresponding weight.
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
weight:
description: |-
weight associated with matching the corresponding podAffinityTerm,
in the range 1-100.
type: integer
format: int32
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the anti-affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the anti-affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to a pod label update), the
system may or may not try to eventually evict the pod from its node.
When there are multiple elements, the lists of nodes corresponding to each
podAffinityTerm are intersected, i.e. all terms must be satisfied.
type: array
items:
description: |-
Defines a set of pods (namely those matching the labelSelector
relative to the given namespace(s)) that this pod should be
co-located (affinity) or not co-located (anti-affinity) with,
where co-located is defined as running on a node whose value of
the label with key <topologyKey> matches that of any node on which
a pod of the set of pods is running
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
x-kubernetes-list-type: atomic
nodeSelector:
description: If specified, applies node selector rules to the pods deployed by the DNSConfig resource.
type: object
additionalProperties:
type: string
tolerations: tolerations:
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource. description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
type: array type: array
@@ -442,6 +442,884 @@ spec:
pod: pod:
description: Pod configuration. description: Pod configuration.
properties: properties:
affinity:
description: If specified, applies affinity rules to the pods deployed by the DNSConfig resource.
properties:
nodeAffinity:
description: Describes node affinity scheduling rules for the pod.
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling affinity expressions, etc.),
compute a sum by iterating through the elements of this field and adding
"weight" to the sum if the node matches the corresponding matchExpressions; the
node(s) with the highest sum are the most preferred.
items:
description: |-
An empty preferred scheduling term matches all objects with implicit weight 0
(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
properties:
preference:
description: A node selector term, associated with the corresponding weight.
properties:
matchExpressions:
description: A list of node selector requirements by node's labels.
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchFields:
description: A list of node selector requirements by node's fields.
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
type: object
x-kubernetes-map-type: atomic
weight:
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
format: int32
type: integer
required:
- preference
- weight
type: object
type: array
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to an update), the system
may or may not try to eventually evict the pod from its node.
properties:
nodeSelectorTerms:
description: Required. A list of node selector terms. The terms are ORed.
items:
description: |-
A null or empty node selector term matches no objects. The requirements of
them are ANDed.
The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
properties:
matchExpressions:
description: A list of node selector requirements by node's labels.
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchFields:
description: A list of node selector requirements by node's fields.
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
type: object
x-kubernetes-map-type: atomic
type: array
x-kubernetes-list-type: atomic
required:
- nodeSelectorTerms
type: object
x-kubernetes-map-type: atomic
type: object
podAffinity:
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling affinity expressions, etc.),
compute a sum by iterating through the elements of this field and adding
"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
node(s) with the highest sum are the most preferred.
items:
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
properties:
podAffinityTerm:
description: Required. A pod affinity term, associated with the corresponding weight.
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
items:
type: string
type: array
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
required:
- topologyKey
type: object
weight:
description: |-
weight associated with matching the corresponding podAffinityTerm,
in the range 1-100.
format: int32
type: integer
required:
- podAffinityTerm
- weight
type: object
type: array
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to a pod label update), the
system may or may not try to eventually evict the pod from its node.
When there are multiple elements, the lists of nodes corresponding to each
podAffinityTerm are intersected, i.e. all terms must be satisfied.
items:
description: |-
Defines a set of pods (namely those matching the labelSelector
relative to the given namespace(s)) that this pod should be
co-located (affinity) or not co-located (anti-affinity) with,
where co-located is defined as running on a node whose value of
the label with key <topologyKey> matches that of any node on which
a pod of the set of pods is running
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
items:
type: string
type: array
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
required:
- topologyKey
type: object
type: array
x-kubernetes-list-type: atomic
type: object
podAntiAffinity:
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the anti-affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling anti-affinity expressions, etc.),
compute a sum by iterating through the elements of this field and subtracting
"weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the
node(s) with the highest sum are the most preferred.
items:
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
properties:
podAffinityTerm:
description: Required. A pod affinity term, associated with the corresponding weight.
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
items:
type: string
type: array
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
required:
- topologyKey
type: object
weight:
description: |-
weight associated with matching the corresponding podAffinityTerm,
in the range 1-100.
format: int32
type: integer
required:
- podAffinityTerm
- weight
type: object
type: array
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the anti-affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the anti-affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to a pod label update), the
system may or may not try to eventually evict the pod from its node.
When there are multiple elements, the lists of nodes corresponding to each
podAffinityTerm are intersected, i.e. all terms must be satisfied.
items:
description: |-
Defines a set of pods (namely those matching the labelSelector
relative to the given namespace(s)) that this pod should be
co-located (affinity) or not co-located (anti-affinity) with,
where co-located is defined as running on a node whose value of
the label with key <topologyKey> matches that of any node on which
a pod of the set of pods is running
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
items:
type: string
type: array
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
items:
type: string
type: array
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
required:
- topologyKey
type: object
type: array
x-kubernetes-list-type: atomic
type: object
type: object
nodeSelector:
additionalProperties:
type: string
description: If specified, applies node selector rules to the pods deployed by the DNSConfig resource.
type: object
tolerations: tolerations:
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource. description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
items: items:
+173 -37
View File
@@ -17,9 +17,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"tailscale.com/client/tailscale/v2"
kube "tailscale.com/k8s-operator" kube "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1" tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes" "tailscale.com/kube/kubetypes"
"tailscale.com/tsnet"
"tailscale.com/tstest" "tailscale.com/tstest"
"tailscale.com/util/httpm" "tailscale.com/util/httpm"
) )
@@ -31,12 +33,12 @@ func TestL3Ingress(t *testing.T) {
} }
// Apply nginx // Apply nginx
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx")) nginx := nginxDeployment(ns)
createAndCleanup(t, kubeClient, nginx)
// Apply service to expose it as ingress // Apply service to expose it as ingress
name := generateName("test-ingress")
svc := &corev1.Service{ svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: name, Name: generateName("test-ingress"),
Namespace: ns, Namespace: ns,
Annotations: map[string]string{ Annotations: map[string]string{
"tailscale.com/expose": "true", "tailscale.com/expose": "true",
@@ -44,7 +46,7 @@ func TestL3Ingress(t *testing.T) {
}, },
Spec: corev1.ServiceSpec{ Spec: corev1.ServiceSpec{
Selector: map[string]string{ Selector: map[string]string{
"app.kubernetes.io/name": "nginx", "app.kubernetes.io/name": nginx.Name,
}, },
Ports: []corev1.ServicePort{ Ports: []corev1.ServicePort{
{ {
@@ -58,7 +60,7 @@ func TestL3Ingress(t *testing.T) {
createAndCleanup(t, kubeClient, svc) createAndCleanup(t, kubeClient, svc)
if err := tstest.WaitFor(time.Minute, func() error { if err := tstest.WaitFor(time.Minute, func() error {
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, name)} maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, svc.Name)}
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil { if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
return err return err
} }
@@ -79,7 +81,7 @@ func TestL3Ingress(t *testing.T) {
if err := kubeClient.List(t.Context(), &secrets, if err := kubeClient.List(t.Context(), &secrets,
client.InNamespace("tailscale"), client.InNamespace("tailscale"),
client.MatchingLabels{ client.MatchingLabels{
"tailscale.com/parent-resource": name, "tailscale.com/parent-resource": svc.Name,
"tailscale.com/parent-resource-ns": ns, "tailscale.com/parent-resource-ns": ns,
}, },
); err != nil { ); err != nil {
@@ -109,33 +111,34 @@ func TestL3HAIngress(t *testing.T) {
} }
// Apply nginx. // Apply nginx.
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx")) nginx := nginxDeployment(ns)
createAndCleanup(t, kubeClient, nginx)
// Create an ingress ProxyGroup. // Create an ingress ProxyGroup.
createAndCleanup(t, kubeClient, &tsapi.ProxyGroup{ pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "ingress", Name: generateName("ingress"),
}, },
Spec: tsapi.ProxyGroupSpec{ Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress, Type: tsapi.ProxyGroupTypeIngress,
}, },
}) }
createAndCleanup(t, kubeClient, pg)
// Apply a Service to expose nginx via the ProxyGroup. // Apply a Service to expose nginx via the ProxyGroup.
name := generateName("test-ingress")
svc := &corev1.Service{ svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: name, Name: generateName("test-ingress"),
Namespace: ns, Namespace: ns,
Annotations: map[string]string{ Annotations: map[string]string{
"tailscale.com/proxy-group": "ingress", "tailscale.com/proxy-group": pg.Name,
}, },
}, },
Spec: corev1.ServiceSpec{ Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeLoadBalancer, Type: corev1.ServiceTypeLoadBalancer,
LoadBalancerClass: new("tailscale"), LoadBalancerClass: new("tailscale"),
Selector: map[string]string{ Selector: map[string]string{
"app.kubernetes.io/name": "nginx", "app.kubernetes.io/name": nginx.Name,
}, },
Ports: []corev1.ServicePort{ Ports: []corev1.ServicePort{
{ {
@@ -150,12 +153,12 @@ func TestL3HAIngress(t *testing.T) {
var svcIPv4 string var svcIPv4 string
forceReconcile := triggerReconcile(t, forceReconcile := triggerReconcile(t,
client.ObjectKey{Namespace: ns, Name: name}, client.ObjectKey{Namespace: ns, Name: svc.Name},
&corev1.Service{}, 30*time.Second) &corev1.Service{}, 30*time.Second)
// Wait for Service to be ready // Wait for Service to be ready
if err := tstest.WaitFor(5*time.Minute, func() error { if err := tstest.WaitFor(5*time.Minute, func() error {
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, name)} maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, svc.Name)}
forceReconcile() forceReconcile()
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil { if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
return err return err
@@ -186,15 +189,16 @@ func TestL7Ingress(t *testing.T) {
} }
// Apply nginx Deployment and Service. // Apply nginx Deployment and Service.
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx")) nginx := nginxDeployment(ns)
createAndCleanup(t, kubeClient, nginx)
createAndCleanup(t, kubeClient, &corev1.Service{ createAndCleanup(t, kubeClient, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "nginx", Name: nginx.Name,
Namespace: ns, Namespace: ns,
}, },
Spec: corev1.ServiceSpec{ Spec: corev1.ServiceSpec{
Selector: map[string]string{ Selector: map[string]string{
"app.kubernetes.io/name": "nginx", "app.kubernetes.io/name": nginx.Name,
}, },
Ports: []corev1.ServicePort{ Ports: []corev1.ServicePort{
{ {
@@ -206,13 +210,12 @@ func TestL7Ingress(t *testing.T) {
}) })
// Apply Ingress to expose nginx. // Apply Ingress to expose nginx.
name := generateName("test-ingress") ingress := l7Ingress(ns, nginx.Name, map[string]string{})
ingress := l7Ingress(ns, name, map[string]string{})
createAndCleanup(t, kubeClient, ingress) createAndCleanup(t, kubeClient, ingress)
t.Log("Waiting for the Ingress to be ready...") t.Log("Waiting for the Ingress to be ready...")
hostname, err := waitForIngressHostname(t, ns, name) hostname, err := waitForIngressHostname(t, ns, ingress.Name)
if err != nil { if err != nil {
t.Fatalf("error waiting for Ingress hostname: %v", err) t.Fatalf("error waiting for Ingress hostname: %v", err)
} }
@@ -228,15 +231,16 @@ func TestL7HAIngress(t *testing.T) {
} }
// Apply nginx Deployment and Service. // Apply nginx Deployment and Service.
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx")) nginx := nginxDeployment(ns)
createAndCleanup(t, kubeClient, nginx)
createAndCleanup(t, kubeClient, &corev1.Service{ createAndCleanup(t, kubeClient, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "nginx", Name: nginx.Name,
Namespace: ns, Namespace: ns,
}, },
Spec: corev1.ServiceSpec{ Spec: corev1.ServiceSpec{
Selector: map[string]string{ Selector: map[string]string{
"app.kubernetes.io/name": "nginx", "app.kubernetes.io/name": nginx.Name,
}, },
Ports: []corev1.ServicePort{ Ports: []corev1.ServicePort{
{ {
@@ -248,23 +252,23 @@ func TestL7HAIngress(t *testing.T) {
}) })
// Create ProxyGroup that the Ingress will reference. // Create ProxyGroup that the Ingress will reference.
createAndCleanup(t, kubeClient, &tsapi.ProxyGroup{ pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "ingress", Name: generateName("ingress"),
}, },
Spec: tsapi.ProxyGroupSpec{ Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress, Type: tsapi.ProxyGroupTypeIngress,
}, },
}) }
createAndCleanup(t, kubeClient, pg)
// Apply Ingress to expose nginx. // Apply Ingress to expose nginx.
name := generateName("test-ingress") ingress := l7Ingress(ns, nginx.Name, map[string]string{"tailscale.com/proxy-group": pg.Name})
ingress := l7Ingress(ns, name, map[string]string{"tailscale.com/proxy-group": "ingress"})
createAndCleanup(t, kubeClient, ingress) createAndCleanup(t, kubeClient, ingress)
t.Log("Waiting for the Ingress to be ready...") t.Log("Waiting for the Ingress to be ready...")
hostname, err := waitForIngressHostname(t, ns, name) hostname, err := waitForIngressHostname(t, ns, ingress.Name)
if err != nil { if err != nil {
t.Fatalf("error waiting for Ingress hostname: %v", err) t.Fatalf("error waiting for Ingress hostname: %v", err)
} }
@@ -274,7 +278,88 @@ func TestL7HAIngress(t *testing.T) {
} }
} }
func l7Ingress(namespace, name string, annotations map[string]string) *networkingv1.Ingress { func TestL7HAIngressMultiTailnet(t *testing.T) {
if tnClient == nil || secondTNClient == nil {
t.Skip("TestL7HAIngressMultiTailnet requires a working tailnet client for a first and second tailnet")
}
// Apply nginx Deployment and Service.
nginx := nginxDeployment(ns)
createAndCleanup(t, kubeClient, nginx)
createAndCleanup(t, kubeClient, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: nginx.Name,
Namespace: ns,
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{
"app.kubernetes.io/name": nginx.Name,
},
Ports: []corev1.ServicePort{
{
Name: "http",
Port: 80,
},
},
},
})
// Create Ingress ProxyGroup for each Tailnet.
firstTailnetPG := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: generateName("first-tailnet"),
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
},
}
createAndCleanup(t, kubeClient, firstTailnetPG)
secondTailnetPG := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: generateName("second-tailnet"),
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
Tailnet: "second-tailnet",
},
}
createAndCleanup(t, kubeClient, secondTailnetPG)
if err := verifyProxyGroupTailnet(t, firstTailnetPG, tnClient); err != nil {
t.Fatalf("verifying ProxyGroup %s is registered to the correct tailnet: %v", firstTailnetPG.Name, err)
}
if err := verifyProxyGroupTailnet(t, secondTailnetPG, secondTNClient); err != nil {
t.Fatalf("verifying ProxyGroup %s is registered to the correct tailnet: %v", secondTailnetPG.Name, err)
}
// Apply Ingress to expose nginx.
ingress := l7Ingress(ns, nginx.Name, map[string]string{
"tailscale.com/proxy-group": secondTailnetPG.Name,
})
createAndCleanup(t, kubeClient, ingress)
// Check that the tailscale (VIP) Service has been created in the expected Tailnet.
svcName := "svc:" + ingress.Name
if err := tstest.WaitFor(3*time.Minute, func() error {
_, err := secondTSClient.VIPServices().Get(t.Context(), svcName)
if tailscale.IsNotFound(err) {
return fmt.Errorf("Tailscale service %q not yet in expected tailnet", svcName)
}
return err
}); err != nil {
t.Fatalf("Tailscale service %q never appeared in expected tailnet: %v", svcName, err)
}
hostname, err := waitForIngressHostname(t, ns, ingress.Name)
if err != nil {
t.Fatalf("error waiting for Ingress hostname: %v", err)
}
if err := testIngressIsReachable(t, newHTTPClient(secondTNClient), fmt.Sprintf("https://%s:443", hostname)); err != nil {
t.Fatal(err)
}
}
func l7Ingress(namespace, svc string, annotations map[string]string) *networkingv1.Ingress {
name := generateName("test-ingress")
ingress := &networkingv1.Ingress{ ingress := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: name, Name: name,
@@ -296,7 +381,7 @@ func l7Ingress(namespace, name string, annotations map[string]string) *networkin
PathType: new(networkingv1.PathTypePrefix), PathType: new(networkingv1.PathTypePrefix),
Backend: networkingv1.IngressBackend{ Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{ Service: &networkingv1.IngressServiceBackend{
Name: "nginx", Name: svc,
Port: networkingv1.ServiceBackendPort{ Port: networkingv1.ServiceBackendPort{
Number: 80, Number: 80,
}, },
@@ -313,26 +398,27 @@ func l7Ingress(namespace, name string, annotations map[string]string) *networkin
return ingress return ingress
} }
func nginxDeployment(namespace, name string) *appsv1.Deployment { func nginxDeployment(namespace string) *appsv1.Deployment {
name := generateName("nginx")
return &appsv1.Deployment{ return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: name, Name: name,
Namespace: namespace, Namespace: namespace,
Labels: map[string]string{ Labels: map[string]string{
"app.kubernetes.io/name": "nginx", "app.kubernetes.io/name": name,
}, },
}, },
Spec: appsv1.DeploymentSpec{ Spec: appsv1.DeploymentSpec{
Replicas: new(int32(1)), Replicas: new(int32(1)),
Selector: &metav1.LabelSelector{ Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{ MatchLabels: map[string]string{
"app.kubernetes.io/name": "nginx", "app.kubernetes.io/name": name,
}, },
}, },
Template: corev1.PodTemplateSpec{ Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{ Labels: map[string]string{
"app.kubernetes.io/name": "nginx", "app.kubernetes.io/name": name,
}, },
}, },
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
@@ -406,6 +492,56 @@ func testIngressIsReachable(t *testing.T, httpClient *http.Client, url string) e
return nil return nil
} }
// verifyProxyGroupTailnet verifies that a ProxyGroup is registered to the correct tailnet.
// This is done by getting the expected tailnet domain for the tailnet client,
// and comparing this with the actual device fqdn in the ProxyGroup state secret.
func verifyProxyGroupTailnet(t *testing.T, pg *tsapi.ProxyGroup, cl *tsnet.Server) error {
t.Helper()
// Determine the expected tailnet Magic DNS Name.
lc, err := cl.LocalClient()
if err != nil {
return err
}
status, err := lc.Status(t.Context())
if err != nil {
return err
}
_, expectedTailnet, ok := strings.Cut(strings.TrimSuffix(status.Self.DNSName, "."), ".")
if !ok {
return fmt.Errorf("unexpected DNSName format %q", status.Self.DNSName)
}
// Read the device FQDN from the first state secret for the ProxyGroup,
// and verify that this matches the expected tailnet.
if err := tstest.WaitFor(3*time.Minute, func() error {
var secrets corev1.SecretList
if err := kubeClient.List(t.Context(), &secrets,
client.InNamespace("tailscale"),
client.MatchingLabels{
kubetypes.LabelSecretType: kubetypes.LabelSecretTypeState,
"tailscale.com/parent-resource-type": "proxygroup",
"tailscale.com/parent-resource": pg.Name,
},
); err != nil {
return err
}
if len(secrets.Items) == 0 {
return fmt.Errorf("no state secrets found for ProxyGroup %q yet", pg.Name)
}
fqdn := strings.TrimSuffix(string(secrets.Items[0].Data[kubetypes.KeyDeviceFQDN]), ".")
_, tailnet, ok := strings.Cut(fqdn, ".")
if !ok {
return fmt.Errorf("ProxyGroup %q: device FQDN %q has no domain yet", pg.Name, fqdn)
}
if tailnet != expectedTailnet {
return fmt.Errorf("ProxyGroup %q on wrong tailnet: got domain %q, want %q", pg.Name, tailnet, expectedTailnet)
}
return nil
}); err != nil {
return fmt.Errorf("ProxyGroup %q not on expected tailnet: %v", pg.Name, err)
}
return nil
}
func waitForIngressHostname(t *testing.T, namespace, name string) (string, error) { func waitForIngressHostname(t *testing.T, namespace, name string) (string, error) {
t.Helper() t.Helper()
var hostname string var hostname string
+2 -2
View File
@@ -54,7 +54,7 @@ func createAndCleanup(t *testing.T, cl client.Client, obj client.Object) {
t.Cleanup(func() { t.Cleanup(func() {
// Use context.Background() for cleanup, as t.Context() is cancelled // Use context.Background() for cleanup, as t.Context() is cancelled
// just before cleanup functions are called. // just before cleanup functions are called.
if err = cl.Delete(context.Background(), obj); err != nil { if err := cl.Delete(context.Background(), obj); err != nil {
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err) t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
} }
}) })
@@ -69,7 +69,7 @@ func createAndCleanupErr(t *testing.T, cl client.Client, obj client.Object) erro
} }
t.Cleanup(func() { t.Cleanup(func() {
if err = cl.Delete(context.Background(), obj); err != nil { if err := cl.Delete(context.Background(), obj); err != nil {
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err) t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
} }
}) })
+194 -25
View File
@@ -4,6 +4,7 @@
package e2e package e2e
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
@@ -39,6 +40,7 @@ import (
"helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver" "helm.sh/helm/v3/pkg/storage/driver"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
@@ -70,9 +72,12 @@ const (
var ( var (
tsClient *tailscale.Client // For API calls to control. tsClient *tailscale.Client // For API calls to control.
tnClient *tsnet.Server // For testing real tailnet traffic. tnClient *tsnet.Server // For testing real tailnet traffic on first tailnet.
secondTSClient *tailscale.Client // For API calls to the secondary tailnet (_second_tailnet).
secondTNClient *tsnet.Server // For testing real tailnet traffic on second tailnet.
restCfg *rest.Config // For constructing a client-go client if necessary. restCfg *rest.Config // For constructing a client-go client if necessary.
kubeClient client.WithWatch // For k8s API calls. kubeClient client.WithWatch // For k8s API calls.
clusterLoginServer string
//go:embed certs/pebble.minica.crt //go:embed certs/pebble.minica.crt
pebbleMiniCACert []byte pebbleMiniCACert []byte
@@ -157,11 +162,11 @@ func runTests(m *testing.M) (int, error) {
} }
var ( var (
clusterLoginServer string // Login server from cluster Pod point of view. clientID, clientSecret string // OAuth client for the first tailnet (for the operator to use).
clientID, clientSecret string // OAuth client for the operator to use.
caPaths []string // Extra CA cert file paths to add to images. caPaths []string // Extra CA cert file paths to add to images.
certsDir = filepath.Join(tmp, "certs") // Directory containing extra CA certs to add to images. certsDir = filepath.Join(tmp, "certs") // Directory containing extra CA certs to add to images.
secondClientID, secondClientSecret string // OAuth client for the second tailnet (for the operator to use).
) )
if *fDevcontrol { if *fDevcontrol {
// Deploy pebble and get its certs. // Deploy pebble and get its certs.
@@ -279,7 +284,7 @@ func runTests(m *testing.M) (int, error) {
return 0, fmt.Errorf("failed to set policy file: %w", err) return 0, fmt.Errorf("failed to set policy file: %w", err)
} }
logger.Infof("ACLs configured") logger.Info("ACLs configured for first tailnet")
key, err := tsClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{ key, err := tsClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
Scopes: []string{"auth_keys", "devices:core", "services"}, Scopes: []string{"auth_keys", "devices:core", "services"},
@@ -287,36 +292,77 @@ func runTests(m *testing.M) (int, error) {
Description: "k8s-operator client for e2e tests", Description: "k8s-operator client for e2e tests",
}) })
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to marshal OAuth client creation request: %w", err) return 0, fmt.Errorf("failed to create OAuth client for first tailnet: %w", err)
} }
clientID = key.ID clientID = key.ID
clientSecret = key.Key clientSecret = key.Key
logger.Info("OAuth credentials set for first tailnet")
// Create second tailnet. The bootstrap credentials returned have 'all' permissions-
// they are used for administrative actions and to create a separately scoped
// Oauth client for the k8s operator.
bootstrapClient, err := createTailnet(ctx, tsClient)
if err != nil {
return 0, fmt.Errorf("failed to create second tailnet: %w", err)
}
// Set HTTPS on second tailnet.
err = bootstrapClient.TailnetSettings().Update(ctx, tailscale.UpdateTailnetSettingsRequest{HTTPSEnabled: new(true)})
if err != nil {
return 0, fmt.Errorf("failed to configure https for second tailnet: %w", err)
}
logger.Info("HTTPS settings configured for second tailnet")
// Set ACLs for second tailnet.
if err = bootstrapClient.PolicyFile().Set(ctx, string(requiredACLs), ""); err != nil {
return 0, fmt.Errorf("failed to set policy file: %w", err)
}
logger.Info("ACLs configured for second tailnet")
// Create an OAuth client for the second tailnet to be used
// by the k8s-operator.
secondKey, err := bootstrapClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
Scopes: []string{"auth_keys", "devices:core", "services"},
Tags: []string{"tag:k8s-operator"},
Description: "k8s-operator client for e2e tests",
})
if err != nil {
return 0, fmt.Errorf("failed to create OAuth client for second tailnet: %w", err)
}
secondClientID = secondKey.ID
secondClientSecret = secondKey.Key
secondTSClient, err = tailscaleClientFromSecret(ctx, "http://localhost:31544", secondClientID, secondClientSecret)
if err != nil {
return 0, fmt.Errorf("failed to set up second tailnet client: %w", err)
}
} else { } else {
clientSecret = os.Getenv("TS_API_CLIENT_SECRET") clientSecret = os.Getenv("TS_API_CLIENT_SECRET")
if clientSecret == "" { if clientSecret == "" {
return 0, fmt.Errorf("must use --devcontrol or set TS_API_CLIENT_SECRET to an OAuth client suitable for the operator") return 0, fmt.Errorf("must use --devcontrol or set TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
} }
// Format is "tskey-client-<id>-<random>". clientID, err = clientIDFromSecret(clientSecret)
parts := strings.Split(clientSecret, "-")
if len(parts) != 4 {
return 0, fmt.Errorf("TS_API_CLIENT_SECRET is not valid")
}
clientID = parts[2]
credentials := clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token", ipn.DefaultControlURL),
Scopes: []string{"auth_keys"},
}
tk, err := credentials.Token(ctx)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to get OAuth token: %w", err) return 0, fmt.Errorf("failed to get client id from secret: %w", err)
} }
// An access token will last for an hour which is plenty of time for tsClient, err = tailscaleClientFromSecret(ctx, ipn.DefaultControlURL, clientID, clientSecret)
// the tests to run. No need for token refresh logic. if err != nil {
tsClient = &tailscale.Client{ return 0, fmt.Errorf("failed to set up first tailnet client: %w", err)
APIKey: tk.AccessToken, }
secondClientSecret = os.Getenv("SECOND_TS_API_CLIENT_SECRET")
if secondClientSecret == "" {
return 0, fmt.Errorf("must use --devcontrol or set SECOND_TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
}
secondClientID, err = clientIDFromSecret(secondClientSecret)
if err != nil {
return 0, fmt.Errorf("failed to get client id from secret: %w", err)
}
secondTSClient, err = tailscaleClientFromSecret(ctx, ipn.DefaultControlURL, secondClientID, secondClientSecret)
if err != nil {
return 0, fmt.Errorf("failed to set up second tailnet client: %w", err)
} }
} }
@@ -446,10 +492,16 @@ func runTests(m *testing.M) (int, error) {
authKey, err := tsClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps}) authKey, err := tsClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("failed to create auth key for first tailnet: %w", err)
} }
defer tsClient.Keys().Delete(context.Background(), authKey.ID) defer tsClient.Keys().Delete(context.Background(), authKey.ID)
secondAuthKey, err := secondTSClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
if err != nil {
return 0, fmt.Errorf("failed to create auth key for second tailnet: %w", err)
}
defer secondTSClient.Keys().Delete(context.Background(), secondAuthKey.ID)
tnClient = &tsnet.Server{ tnClient = &tsnet.Server{
ControlURL: tsClient.BaseURL.String(), ControlURL: tsClient.BaseURL.String(),
Hostname: "test-proxy", Hostname: "test-proxy",
@@ -463,9 +515,64 @@ func runTests(m *testing.M) (int, error) {
} }
defer tnClient.Close() defer tnClient.Close()
secondTNClient = &tsnet.Server{
ControlURL: secondTSClient.BaseURL.String(),
Hostname: "test-proxy",
Ephemeral: true,
Store: &mem.Store{},
AuthKey: secondAuthKey.Key,
}
_, err = secondTNClient.Up(ctx)
if err != nil {
return 0, err
}
defer secondTNClient.Close()
// Create the tailnet Secret in the tailscale namespace.
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "second-tailnet-credentials",
Namespace: "tailscale",
},
Data: map[string][]byte{
"client_id": []byte(secondClientID),
"client_secret": []byte(secondClientSecret),
},
}
if err := createOrUpdate(ctx, kubeClient, secret); err != nil {
return 0, fmt.Errorf("failed to create second-tailnet-credentials Secret: %w", err)
}
defer kubeClient.Delete(context.Background(), secret)
// Create the Tailnet resource.
tn := &tsapi.Tailnet{
ObjectMeta: metav1.ObjectMeta{
Name: "second-tailnet",
},
Spec: tsapi.TailnetSpec{
LoginURL: clusterLoginServer,
Credentials: tsapi.TailnetCredentials{
SecretName: "second-tailnet-credentials",
},
},
}
if err := createOrUpdate(ctx, kubeClient, tn); err != nil {
return 0, fmt.Errorf("failed to create second-tailnet Tailnet: %w", err)
}
defer kubeClient.Delete(context.Background(), tn)
return m.Run(), nil return m.Run(), nil
} }
func clientIDFromSecret(clientSecret string) (string, error) {
// Format is "tskey-client-<id>-<random>".
parts := strings.Split(clientSecret, "-")
if len(parts) != 4 {
return "", fmt.Errorf("secret is not valid")
}
return parts[2], nil
}
func upgraderOrInstaller(cfg *action.Configuration, releaseName string) helmInstallerFunc { func upgraderOrInstaller(cfg *action.Configuration, releaseName string) helmInstallerFunc {
hist := action.NewHistory(cfg) hist := action.NewHistory(cfg)
hist.Max = 1 hist.Max = 1
@@ -724,3 +831,65 @@ func buildImage(ctx context.Context, dir, repo, target, tag string, extraCACerts
return nil return nil
} }
func createOrUpdate(ctx context.Context, cl client.Client, obj client.Object) error {
if err := cl.Create(ctx, obj); err != nil {
if !apierrors.IsAlreadyExists(err) {
return err
}
return cl.Update(ctx, obj)
}
return nil
}
// createTailnet creates a new tailnet and returns a tailscale.Client
// authenticated against it using the bootstrap credentials included in the
// creation response.
func createTailnet(ctx context.Context, tsClient *tailscale.Client) (*tailscale.Client, error) {
tailnetName := fmt.Sprintf("second-tailnet-%d", time.Now().Unix())
body, err := json.Marshal(map[string]any{"displayName": tailnetName})
if err != nil {
return nil, fmt.Errorf("failed to marshal tailnet creation request: %w", err)
}
// TODO(beckypauley): change to use a method on tailscale.Client once this is available.
req, _ := http.NewRequestWithContext(ctx, "POST", tsClient.BaseURL.String()+"/api/v2/organizations/-/tailnets", bytes.NewBuffer(body))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tsClient.APIKey))
resp, err := tsClient.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to create tailnet: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d creating tailnet: %s", resp.StatusCode, string(b))
}
var result struct {
OauthClient struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"oauthClient"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return tailscaleClientFromSecret(ctx, tsClient.BaseURL.String(), result.OauthClient.ID, result.OauthClient.Secret)
}
// tailscaleClientFromSecret exchanges OAuth client credentials for an access token and
// returns a tailscale.Client configured to use it. The token is valid for
// one hour, which is sufficient for the tests to run. No need for refresh logic.
func tailscaleClientFromSecret(ctx context.Context, baseURL, clientID, clientSecret string) (*tailscale.Client, error) {
cfg := clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token", baseURL),
}
tk, err := cfg.Token(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth token for client %q: %w", clientID, err)
}
return &tailscale.Client{
APIKey: tk.AccessToken,
BaseURL: must.Get(url.Parse(baseURL)),
}, nil
}
+2 -1
View File
@@ -20,6 +20,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/kube/egressservices" "tailscale.com/kube/egressservices"
) )
@@ -90,7 +91,7 @@ func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Requ
lg.Debugf("No egress config found, likely because ProxyGroup has not been created") lg.Debugf("No egress config found, likely because ProxyGroup has not been created")
return res, nil return res, nil
} }
cfg, ok := (*cfgs)[tailnetSvc] cfg, ok := cfgs[tailnetSvc]
if !ok { if !ok {
lg.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc) lg.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc)
return res, nil return res, nil
+12 -11
View File
@@ -30,6 +30,7 @@ import (
"k8s.io/client-go/tools/record" "k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/reconcile"
tsoperator "tailscale.com/k8s-operator" tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1" tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/egressservices" "tailscale.com/kube/egressservices"
@@ -347,11 +348,11 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
return nil, false, nil return nil, false, nil
} }
tailnetSvc := tailnetSvcName(svc) tailnetSvc := tailnetSvcName(svc)
gotCfg := (*cfgs)[tailnetSvc] gotCfg := cfgs[tailnetSvc]
wantsCfg := egressSvcCfg(svc, clusterIPSvc, esr.tsNamespace, lg) wantsCfg := egressSvcCfg(svc, clusterIPSvc, esr.tsNamespace, lg)
if !reflect.DeepEqual(gotCfg, wantsCfg) { if !reflect.DeepEqual(gotCfg, wantsCfg) {
lg.Debugf("updating egress services ConfigMap %s", cm.Name) lg.Debugf("updating egress services ConfigMap %s", cm.Name)
mak.Set(cfgs, tailnetSvc, wantsCfg) mak.Set(&cfgs, tailnetSvc, wantsCfg)
bs, err := json.Marshal(cfgs) bs, err := json.Marshal(cfgs)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("error marshalling egress services configs: %w", err) return nil, false, fmt.Errorf("error marshalling egress services configs: %w", err)
@@ -485,19 +486,19 @@ func (esr *egressSvcsReconciler) ensureEgressSvcCfgDeleted(ctx context.Context,
lggr.Debugf("ConfigMap does not contain egress service configs") lggr.Debugf("ConfigMap does not contain egress service configs")
return nil return nil
} }
cfgs := &egressservices.Configs{} cfgs := egressservices.Configs{}
if err := json.Unmarshal(bs, cfgs); err != nil { if err := json.Unmarshal(bs, &cfgs); err != nil {
return fmt.Errorf("error unmarshalling egress services configs") return fmt.Errorf("error unmarshalling egress services configs")
} }
tailnetSvc := tailnetSvcName(svc) tailnetSvc := tailnetSvcName(svc)
_, ok := (*cfgs)[tailnetSvc] _, ok := cfgs[tailnetSvc]
if !ok { if !ok {
lggr.Debugf("ConfigMap does not contain egress service config, likely because it was already deleted") lggr.Debugf("ConfigMap does not contain egress service config, likely because it was already deleted")
return nil return nil
} }
lggr.Infof("before deleting config %+#v", *cfgs) lggr.Infof("before deleting config %+#v", cfgs)
delete(*cfgs, tailnetSvc) delete(cfgs, tailnetSvc)
lggr.Infof("after deleting config %+#v", *cfgs) lggr.Infof("after deleting config %+#v", cfgs)
bs, err := json.Marshal(cfgs) bs, err := json.Marshal(cfgs)
if err != nil { if err != nil {
return fmt.Errorf("error marshalling egress services configs: %w", err) return fmt.Errorf("error marshalling egress services configs: %w", err)
@@ -649,7 +650,7 @@ func isEgressSvcForProxyGroup(obj client.Object) bool {
// egressSvcConfig returns a ConfigMap that contains egress services configuration for the provided ProxyGroup as well // egressSvcConfig returns a ConfigMap that contains egress services configuration for the provided ProxyGroup as well
// as unmarshalled configuration from the ConfigMap. // as unmarshalled configuration from the ConfigMap.
func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, tsNamespace string) (cm *corev1.ConfigMap, cfgs *egressservices.Configs, err error) { func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, tsNamespace string) (cm *corev1.ConfigMap, cfgs egressservices.Configs, err error) {
name := pgEgressCMName(proxyGroupName) name := pgEgressCMName(proxyGroupName)
cm = &corev1.ConfigMap{ cm = &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
@@ -664,9 +665,9 @@ func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, ts
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("error retrieving egress services ConfigMap %s: %v", name, err) return nil, nil, fmt.Errorf("error retrieving egress services ConfigMap %s: %v", name, err)
} }
cfgs = &egressservices.Configs{} cfgs = egressservices.Configs{}
if len(cm.BinaryData[egressservices.KeyEgressServices]) != 0 { if len(cm.BinaryData[egressservices.KeyEgressServices]) != 0 {
if err := json.Unmarshal(cm.BinaryData[egressservices.KeyEgressServices], cfgs); err != nil { if err := json.Unmarshal(cm.BinaryData[egressservices.KeyEgressServices], &cfgs); err != nil {
return nil, nil, fmt.Errorf("error unmarshaling egress services config %v: %w", cm.BinaryData[egressservices.KeyEgressServices], err) return nil, nil, fmt.Errorf("error unmarshaling egress services config %v: %w", cm.BinaryData[egressservices.KeyEgressServices], err)
} }
} }
+4 -3
View File
@@ -21,6 +21,7 @@ import (
"k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/fake"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1" tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/egressservices" "tailscale.com/kube/egressservices"
"tailscale.com/tstest" "tailscale.com/tstest"
@@ -284,11 +285,11 @@ func configFromCM(t *testing.T, cm *corev1.ConfigMap, svcName string) *egressser
if !ok { if !ok {
return nil return nil
} }
cfgs := &egressservices.Configs{} cfgs := egressservices.Configs{}
if err := json.Unmarshal(cfgBs, cfgs); err != nil { if err := json.Unmarshal(cfgBs, &cfgs); err != nil {
t.Fatalf("error unmarshalling config: %v", err) t.Fatalf("error unmarshalling config: %v", err)
} }
cfg, ok := (*cfgs)[svcName] cfg, ok := cfgs[svcName]
if ok { if ok {
return &cfg return &cfg
} }
+1 -1
View File
@@ -1081,7 +1081,7 @@ func certResourceLabels(pgName, domain string) map[string]string {
return map[string]string{ return map[string]string{
kubetypes.LabelManaged: "true", kubetypes.LabelManaged: "true",
labelProxyGroup: pgName, labelProxyGroup: pgName,
labelDomain: domain, labelDomain: tsoperator.TruncateLabelValue(domain),
} }
} }
+6 -5
View File
@@ -19,6 +19,7 @@ import (
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
kube "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1" tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes" "tailscale.com/kube/kubetypes"
) )
@@ -227,13 +228,13 @@ func metricsResourceLabels(opts *metricsOpts) map[string]string {
kubetypes.LabelManaged: "true", kubetypes.LabelManaged: "true",
labelMetricsTarget: opts.proxyStsName, labelMetricsTarget: opts.proxyStsName,
labelPromProxyType: opts.proxyType, labelPromProxyType: opts.proxyType,
labelPromProxyParentName: opts.proxyLabels[LabelParentName], labelPromProxyParentName: kube.TruncateLabelValue(opts.proxyLabels[LabelParentName]),
} }
// Include namespace label for proxies created for a namespaced type. // Include namespace label for proxies created for a namespaced type.
if isNamespacedProxyType(opts.proxyType) { if isNamespacedProxyType(opts.proxyType) {
lbls[labelPromProxyParentNamespace] = opts.proxyLabels[LabelParentNamespace] lbls[labelPromProxyParentNamespace] = kube.TruncateLabelValue(opts.proxyLabels[LabelParentNamespace])
} }
lbls[labelPromJob] = promJobName(opts) lbls[labelPromJob] = kube.TruncateLabelValue(promJobName(opts))
return lbls return lbls
} }
@@ -250,11 +251,11 @@ func promJobName(opts *metricsOpts) string {
func metricsSvcSelector(proxyLabels map[string]string, proxyType string) map[string]string { func metricsSvcSelector(proxyLabels map[string]string, proxyType string) map[string]string {
sel := map[string]string{ sel := map[string]string{
labelPromProxyType: proxyType, labelPromProxyType: proxyType,
labelPromProxyParentName: proxyLabels[LabelParentName], labelPromProxyParentName: kube.TruncateLabelValue(proxyLabels[LabelParentName]),
} }
// Include namespace label for proxies created for a namespaced type. // Include namespace label for proxies created for a namespaced type.
if isNamespacedProxyType(proxyType) { if isNamespacedProxyType(proxyType) {
sel[labelPromProxyParentNamespace] = proxyLabels[LabelParentNamespace] sel[labelPromProxyParentNamespace] = kube.TruncateLabelValue(proxyLabels[LabelParentNamespace])
} }
return sel return sel
} }
+6
View File
@@ -190,6 +190,8 @@ func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsa
} }
if tsDNSCfg.Spec.Nameserver.Pod != nil { if tsDNSCfg.Spec.Nameserver.Pod != nil {
dCfg.tolerations = tsDNSCfg.Spec.Nameserver.Pod.Tolerations dCfg.tolerations = tsDNSCfg.Spec.Nameserver.Pod.Tolerations
dCfg.affinity = tsDNSCfg.Spec.Nameserver.Pod.Affinity
dCfg.nodeSelector = tsDNSCfg.Spec.Nameserver.Pod.NodeSelector
} }
for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} { for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} {
@@ -225,6 +227,8 @@ type deployConfig struct {
namespace string namespace string
clusterIP string clusterIP string
tolerations []corev1.Toleration tolerations []corev1.Toleration
affinity *corev1.Affinity
nodeSelector map[string]string
} }
var ( var (
@@ -250,6 +254,8 @@ var (
d.ObjectMeta.Labels = cfg.labels d.ObjectMeta.Labels = cfg.labels
d.ObjectMeta.OwnerReferences = cfg.ownerRefs d.ObjectMeta.OwnerReferences = cfg.ownerRefs
d.Spec.Template.Spec.Tolerations = cfg.tolerations d.Spec.Template.Spec.Tolerations = cfg.tolerations
d.Spec.Template.Spec.Affinity = cfg.affinity
d.Spec.Template.Spec.NodeSelector = cfg.nodeSelector
updateF := func(oldD *appsv1.Deployment) { updateF := func(oldD *appsv1.Deployment) {
oldD.Spec = d.Spec oldD.Spec = d.Spec
} }
+40
View File
@@ -43,6 +43,9 @@ func TestNameserverReconciler(t *testing.T) {
ClusterIP: "5.4.3.2", ClusterIP: "5.4.3.2",
}, },
Pod: &tsapi.NameserverPod{ Pod: &tsapi.NameserverPod{
NodeSelector: map[string]string{
"foo": "bar",
},
Tolerations: []corev1.Toleration{ Tolerations: []corev1.Toleration{
{ {
Key: "some-key", Key: "some-key",
@@ -51,6 +54,23 @@ func TestNameserverReconciler(t *testing.T) {
Effect: corev1.TaintEffectNoSchedule, Effect: corev1.TaintEffectNoSchedule,
}, },
}, },
Affinity: &corev1.Affinity{
NodeAffinity: &corev1.NodeAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{
NodeSelectorTerms: []corev1.NodeSelectorTerm{
{
MatchExpressions: []corev1.NodeSelectorRequirement{
{
Key: "some-key",
Operator: corev1.NodeSelectorOpIn,
Values: []string{"some-value"},
},
},
},
},
},
},
},
}, },
}, },
}, },
@@ -97,6 +117,26 @@ func TestNameserverReconciler(t *testing.T) {
Effect: corev1.TaintEffectNoSchedule, Effect: corev1.TaintEffectNoSchedule,
}, },
} }
wantsDeploy.Spec.Template.Spec.Affinity = &corev1.Affinity{
NodeAffinity: &corev1.NodeAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{
NodeSelectorTerms: []corev1.NodeSelectorTerm{
{
MatchExpressions: []corev1.NodeSelectorRequirement{
{
Key: "some-key",
Operator: corev1.NodeSelectorOpIn,
Values: []string{"some-value"},
},
},
},
},
},
},
}
wantsDeploy.Spec.Template.Spec.NodeSelector = map[string]string{
"foo": "bar",
}
expectEqual(t, fc, wantsDeploy) expectEqual(t, fc, wantsDeploy)
}) })
+2
View File
@@ -698,6 +698,8 @@ func runReconcilers(opts reconcilerOpts) {
log: opts.log.Named("recorder-reconciler"), log: opts.log.Named("recorder-reconciler"),
clock: tstime.DefaultClock{}, clock: tstime.DefaultClock{},
clients: clients, clients: clients,
authKeyRateLimits: make(map[string]*rate.Limiter),
authKeyReissuing: make(map[string]bool),
}) })
if err != nil { if err != nil {
startlog.Fatalf("could not create Recorder reconciler: %v", err) startlog.Fatalf("could not create Recorder reconciler: %v", err)
+3
View File
@@ -1160,6 +1160,9 @@ func (r *ProxyGroupReconciler) ensureStateRemovedForProxyGroup(pg *tsapi.ProxyGr
gaugeIngressProxyGroupResources.Set(int64(r.ingressProxyGroups.Len())) gaugeIngressProxyGroupResources.Set(int64(r.ingressProxyGroups.Len()))
gaugeAPIServerProxyGroupResources.Set(int64(r.apiServerProxyGroups.Len())) gaugeAPIServerProxyGroupResources.Set(int64(r.apiServerProxyGroups.Len()))
delete(r.authKeyRateLimits, pg.Name) delete(r.authKeyRateLimits, pg.Name)
for i := range pgReplicas(pg) {
delete(r.authKeyReissuing, pgStateSecretName(pg.Name, i))
}
} }
func pgTailscaledConfig(pg *tsapi.ProxyGroup, loginServer string, pc *tsapi.ProxyClass, idx int32, authKey *string, staticEndpoints []netip.AddrPort, oldAdvertiseServices []string) (tailscaledConfigs, error) { func pgTailscaledConfig(pg *tsapi.ProxyGroup, loginServer string, pc *tsapi.ProxyClass, idx int32, authKey *string, staticEndpoints []netip.AddrPort, oldAdvertiseServices []string) (tailscaledConfigs, error) {
+124 -14
View File
@@ -14,9 +14,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time"
"go.uber.org/zap" "go.uber.org/zap"
xslices "golang.org/x/exp/slices" xslices "golang.org/x/exp/slices"
"golang.org/x/time/rate"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
@@ -62,7 +64,8 @@ type RecorderReconciler struct {
clock tstime.Clock clock tstime.Clock
clients ClientProvider clients ClientProvider
tsNamespace string tsNamespace string
authKeyRateLimits map[string]*rate.Limiter // per-Recorder rate limiters for auth key re-issuance.
authKeyReissuing map[string]bool
mu sync.Mutex // protects following mu sync.Mutex // protects following
recorders set.Slice[types.UID] // for recorders gauge recorders set.Slice[types.UID] // for recorders gauge
} }
@@ -164,9 +167,23 @@ func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Reques
func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error { func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error {
logger := r.logger(tsr.Name) logger := r.logger(tsr.Name)
var replicas int32 = 1
if tsr.Spec.Replicas != nil {
replicas = *tsr.Spec.Replicas
}
r.mu.Lock() r.mu.Lock()
r.recorders.Add(tsr.UID) r.recorders.Add(tsr.UID)
gaugeRecorderResources.Set(int64(r.recorders.Len())) gaugeRecorderResources.Set(int64(r.recorders.Len()))
if _, ok := r.authKeyRateLimits[tsr.Name]; !ok {
r.authKeyRateLimits[tsr.Name] = rate.NewLimiter(rate.Every(30*time.Second), int(replicas))
}
for replica := range replicas {
name := fmt.Sprintf("%s-%d", tsr.Name, replica)
if _, ok := r.authKeyReissuing[name]; !ok {
r.authKeyReissuing[name] = false
}
}
r.mu.Unlock() r.mu.Unlock()
if err := r.ensureAuthSecretsCreated(ctx, tsClient, tsr); err != nil { if err := r.ensureAuthSecretsCreated(ctx, tsClient, tsr); err != nil {
@@ -174,11 +191,6 @@ func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclie
} }
// State Secrets are pre-created so we can use the Recorder CR as its owner ref. // State Secrets are pre-created so we can use the Recorder CR as its owner ref.
var replicas int32 = 1
if tsr.Spec.Replicas != nil {
replicas = *tsr.Spec.Replicas
}
for replica := range replicas { for replica := range replicas {
sec := tsrStateSecret(tsr, r.tsNamespace, replica) sec := tsrStateSecret(tsr, r.tsNamespace, replica)
_, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) { _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) {
@@ -423,6 +435,10 @@ func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Record
r.mu.Lock() r.mu.Lock()
r.recorders.Remove(tsr.UID) r.recorders.Remove(tsr.UID)
gaugeRecorderResources.Set(int64(r.recorders.Len())) gaugeRecorderResources.Set(int64(r.recorders.Len()))
delete(r.authKeyRateLimits, tsr.Name)
for replica := range replicas {
delete(r.authKeyReissuing, fmt.Sprintf("%s-%d", tsr.Name, replica))
}
r.mu.Unlock() r.mu.Unlock()
return true, nil return true, nil
@@ -447,28 +463,122 @@ func (r *RecorderReconciler) ensureAuthSecretsCreated(ctx context.Context, tsCli
Name: fmt.Sprintf("%s-auth-%d", tsr.Name, replica), Name: fmt.Sprintf("%s-auth-%d", tsr.Name, replica),
} }
err := r.Get(ctx, key, &corev1.Secret{}) existingSecret := &corev1.Secret{}
err := r.Get(ctx, key, existingSecret)
switch { switch {
case err == nil: case err == nil:
logger.Debugf("auth Secret %q already exists", key.Name) reissue, err := r.shouldReissueAuthKey(ctx, tsClient, tsr, replica, existingSecret)
continue if err != nil {
case !apierrors.IsNotFound(err): return fmt.Errorf("error checking auth key reissue for replica %d: %w", replica, err)
return fmt.Errorf("failed to get Secret %q: %w", key.Name, err) }
if !reissue {
logger.Debugf("auth Secret %q already exists, no reissue needed", key.Name)
continue
} }
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify()) authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
if err != nil { if err != nil {
return err return err
} }
existingSecret.Data["authkey"] = []byte(authKey)
if err = r.Create(ctx, tsrAuthSecret(tsr, r.tsNamespace, authKey, replica)); err != nil { if err = r.Update(ctx, existingSecret); err != nil {
return err return err
} }
continue
case apierrors.IsNotFound(err):
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
if err != nil {
return err
}
if err := r.Create(ctx, tsrAuthSecret(tsr, r.tsNamespace, authKey, replica)); err != nil {
return err
}
default:
return fmt.Errorf("failed to get Secret %q: %w", key.Name, err)
}
} }
return nil return nil
} }
// shouldReissueAuthKey returns true if the proxy needs a new auth key. It
// tracks in-flight reissues via authKeyReissuing to avoid duplicate API calls
// across reconciles.
func (r *RecorderReconciler) shouldReissueAuthKey(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder, replica int32, authSecret *corev1.Secret) (shouldReissue bool, err error) {
stateSecret, err := r.getStateSecret(ctx, tsr.Name, replica)
if err != nil || stateSecret == nil {
return false, err
}
stateSecretName := fmt.Sprintf("%s-%d", tsr.Name, replica)
r.mu.Lock()
reissuing := r.authKeyReissuing[stateSecretName]
r.mu.Unlock()
if reissuing {
_, requestStillPresent := stateSecret.Data[kubetypes.KeyReissueAuthkey]
if !requestStillPresent {
r.mu.Lock()
r.authKeyReissuing[stateSecretName] = false
r.mu.Unlock()
r.log.Debugf("auth key reissue completed for %q", stateSecretName)
return false, nil
}
r.log.Debugf("auth key already in process of re-issuance for %q, waiting", stateSecretName)
return false, nil
}
defer func() {
r.mu.Lock()
r.authKeyReissuing[stateSecretName] = shouldReissue
r.mu.Unlock()
}()
brokenAuthkey, ok := stateSecret.Data[kubetypes.KeyReissueAuthkey]
if !ok {
return false, nil
}
cfgAuthKey := string(authSecret.Data["authkey"])
empty := cfgAuthKey == ""
broken := cfgAuthKey == string(brokenAuthkey)
if !empty && !broken {
return false, nil
}
lim := r.authKeyRateLimits[tsr.Name]
if !lim.Allow() {
r.log.Debugf("auth key re-issuance rate limit exceeded, limit: %.2f, burst: %d, tokens: %.2f",
lim.Limit(), lim.Burst(), lim.Tokens())
return false, fmt.Errorf("auth key re-issuance rate limit exceeded for Recorder %q, will retry with backoff", tsr.Name)
}
r.log.Infof("Recorder replica %s failing to auth; attempting cleanup and new key", stateSecretName)
if tsID := stateSecret.Data[kubetypes.KeyDeviceID]; len(tsID) > 0 {
id := tailcfg.StableNodeID(tsID)
if err := r.ensureDeviceDeleted(ctx, tsClient, id, r.log); err != nil {
return false, err
}
}
return true, nil
}
func (r *RecorderReconciler) ensureDeviceDeleted(ctx context.Context, tsClient tsclient.Client, id tailcfg.StableNodeID, logger *zap.SugaredLogger) error {
logger.Debugf("deleting device %s from control", string(id))
err := tsClient.Devices().Delete(ctx, string(id))
switch {
case tailscale.IsNotFound(err):
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
case err != nil:
return fmt.Errorf("error deleting device: %w", err)
default:
logger.Debugf("device %s deleted from control", string(id))
}
return nil
}
func (r *RecorderReconciler) validate(ctx context.Context, tsr *tsapi.Recorder) error { func (r *RecorderReconciler) validate(ctx context.Context, tsr *tsapi.Recorder) error {
if !tsr.Spec.EnableUI && tsr.Spec.Storage.S3 == nil { if !tsr.Spec.EnableUI && tsr.Spec.Storage.S3 == nil {
return errors.New("must either enable UI or use S3 storage to ensure recordings are accessible") return errors.New("must either enable UI or use S3 storage to ensure recordings are accessible")
+3
View File
@@ -14,6 +14,7 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/time/rate"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
@@ -61,6 +62,8 @@ func TestRecorder(t *testing.T) {
recorder: fr, recorder: fr,
log: zl.Sugar(), log: zl.Sugar(),
clock: cl, clock: cl,
authKeyRateLimits: make(map[string]*rate.Limiter),
authKeyReissuing: make(map[string]bool),
} }
t.Run("invalid_spec_gives_an_error_condition", func(t *testing.T) { t.Run("invalid_spec_gives_an_error_condition", func(t *testing.T) {
+79 -8
View File
@@ -31,6 +31,7 @@ import (
"k8s.io/utils/strings/slices" "k8s.io/utils/strings/slices"
"tailscale.com/client/local" "tailscale.com/client/local"
"tailscale.com/cmd/k8s-proxy/internal/config" "tailscale.com/cmd/k8s-proxy/internal/config"
"tailscale.com/health"
"tailscale.com/hostinfo" "tailscale.com/hostinfo"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/store" "tailscale.com/ipn/store"
@@ -41,6 +42,7 @@ import (
"tailscale.com/kube/certs" "tailscale.com/kube/certs"
healthz "tailscale.com/kube/health" healthz "tailscale.com/kube/health"
"tailscale.com/kube/k8s-proxy/conf" "tailscale.com/kube/k8s-proxy/conf"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes" "tailscale.com/kube/kubetypes"
klc "tailscale.com/kube/localclient" klc "tailscale.com/kube/localclient"
"tailscale.com/kube/metrics" "tailscale.com/kube/metrics"
@@ -171,10 +173,31 @@ func run(logger *zap.SugaredLogger) error {
// If Pod UID unset, assume we're running outside of a cluster/not managed // If Pod UID unset, assume we're running outside of a cluster/not managed
// by the operator, so no need to set additional state keys. // by the operator, so no need to set additional state keys.
var kc kubeclient.Client
var stateSecretName string
if podUID != "" { if podUID != "" {
if err := state.SetInitialKeys(st, podUID); err != nil { if err := state.SetInitialKeys(st, podUID); err != nil {
return fmt.Errorf("error setting initial state: %w", err) return fmt.Errorf("error setting initial state: %w", err)
} }
if cfg.Parsed.State != nil {
if name, ok := strings.CutPrefix(*cfg.Parsed.State, "kube:"); ok {
stateSecretName = name
kc, err = kubeclient.New(k8sProxyFieldManager)
if err != nil {
return err
}
var configAuthKey string
if cfg.Parsed.AuthKey != nil {
configAuthKey = *cfg.Parsed.AuthKey
}
if err := resetState(ctx, kc, stateSecretName, podUID, configAuthKey); err != nil {
return fmt.Errorf("error resetting state: %w", err)
}
}
}
} }
var authKey string var authKey string
@@ -197,23 +220,69 @@ func run(logger *zap.SugaredLogger) error {
ts.Hostname = *cfg.Parsed.Hostname ts.Hostname = *cfg.Parsed.Hostname
} }
// Make sure we crash loop if Up doesn't complete in reasonable time.
upCtx, upCancel := context.WithTimeout(ctx, time.Minute)
defer upCancel()
if _, err := ts.Up(upCtx); err != nil {
return fmt.Errorf("error starting tailscale server: %w", err)
}
defer ts.Close()
lc, err := ts.LocalClient() lc, err := ts.LocalClient()
if err != nil { if err != nil {
return fmt.Errorf("error getting local client: %w", err) return fmt.Errorf("error getting local client: %w", err)
} }
// Setup for updating state keys. // Make sure we crash loop if Up doesn't complete in reasonable time.
upCtx, upCancel := context.WithTimeout(ctx, 30*time.Second)
defer upCancel()
// ts.Up() deliberately ignores NeedsLogin because it fires transiently
// during normal auth-key login. We can watch for the login-state health
// warning here though, which only fires on terminal auth failure, and
// cancel early.
go func() {
w, err := lc.WatchIPNBus(upCtx, ipn.NotifyInitialHealthState)
if err != nil {
return
}
defer w.Close()
for {
n, err := w.Next()
if err != nil {
logger.Debugf("failed to process message from ipn bus: %s", err.Error())
return
}
if n.Health != nil {
if _, ok := n.Health.Warnings[health.LoginStateWarnable.Code]; ok {
upCancel()
return
}
}
}
}()
if _, err := ts.Up(upCtx); err != nil {
if kc != nil && stateSecretName != "" {
return handleAuthKeyReissue(ctx, lc, kc, stateSecretName, authKey, cfgChan, logger)
}
return err
}
defer ts.Close()
reissueCh := make(chan struct{}, 1)
if podUID != "" { if podUID != "" {
group.Go(func() error { group.Go(func() error {
return state.KeepKeysUpdated(ctx, st, klc.New(lc)) return state.KeepKeysUpdated(ctx, st, klc.New(lc))
}) })
if kc != nil && stateSecretName != "" {
needsReissue, err := checkInitialAuthState(ctx, lc)
if err != nil {
return fmt.Errorf("error checking initial auth state: %w", err)
}
if needsReissue {
logger.Info("Auth key missing or invalid after startup, requesting new key from operator")
return handleAuthKeyReissue(ctx, lc, kc, stateSecretName, authKey, cfgChan, logger)
}
group.Go(func() error {
return monitorAuthHealth(ctx, lc, reissueCh, logger)
})
}
} }
if cfg.Parsed.HealthCheckEnabled.EqualBool(true) || cfg.Parsed.MetricsEnabled.EqualBool(true) { if cfg.Parsed.HealthCheckEnabled.EqualBool(true) || cfg.Parsed.MetricsEnabled.EqualBool(true) {
@@ -362,6 +431,8 @@ func run(logger *zap.SugaredLogger) error {
} }
cfgLogger.Infof("Config reloaded") cfgLogger.Infof("Config reloaded")
case <-reissueCh:
return handleAuthKeyReissue(ctx, lc, kc, stateSecretName, authKey, cfgChan, logger)
} }
} }
} }
+161
View File
@@ -0,0 +1,161 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"fmt"
"strings"
"sync"
"time"
"go.uber.org/zap"
"tailscale.com/client/local"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/kube/authkey"
"tailscale.com/kube/k8s-proxy/conf"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
)
const k8sProxyFieldManager = "tailscale-k8s-proxy"
// resetState clears k8s-proxy state from previous runs and sets
// initial values. This ensures the operator doesn't use stale state when a Pod
// is first recreated.
//
// It also clears the reissue_authkey marker if the operator has actioned it
// (i.e., the config now has a different auth key than what was marked for
// reissue).
func resetState(ctx context.Context, kc kubeclient.Client, stateSecretName string, podUID string, configAuthKey string) error {
existingSecret, err := kc.GetSecret(ctx, stateSecretName)
switch {
case kubeclient.IsNotFoundErr(err):
return nil
case err != nil:
return fmt.Errorf("failed to read state Secret %q to reset state: %w", stateSecretName, err)
}
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
},
}
if podUID != "" {
s.Data[kubetypes.KeyPodUID] = []byte(podUID)
}
// Only clear reissue_authkey if the operator has actioned it.
brokenAuthkey, ok := existingSecret.Data[kubetypes.KeyReissueAuthkey]
if ok && configAuthKey != "" && string(brokenAuthkey) != configAuthKey {
s.Data[kubetypes.KeyReissueAuthkey] = nil
}
return kc.StrategicMergePatchSecret(ctx, stateSecretName, s, k8sProxyFieldManager)
}
// needsAuthKeyReissue reports whether the given backend state and health
// warnings indicate a terminal auth failure requiring a new key from the
// operator.
func needsAuthKeyReissue(backendState string, healthWarnings []string) bool {
if backendState == ipn.NeedsLogin.String() {
return true
}
loginWarnableCode := string(health.LoginStateWarnable.Code)
for _, h := range healthWarnings {
if strings.Contains(h, loginWarnableCode) {
return true
}
}
return false
}
// checkInitialAuthState checks if the tsnet server is in an auth failure state
// immediately after coming up. Returns true if auth key reissue is needed.
func checkInitialAuthState(ctx context.Context, lc *local.Client) (bool, error) {
status, err := lc.Status(ctx)
if err != nil {
return false, fmt.Errorf("error getting status: %w", err)
}
return needsAuthKeyReissue(status.BackendState, status.Health), nil
}
// monitorAuthHealth watches the IPN bus for auth failures and triggers reissue
// when needed. Runs until context is cancelled or auth failure is detected.
func monitorAuthHealth(ctx context.Context, lc *local.Client, reissueCh chan<- struct{}, logger *zap.SugaredLogger) error {
w, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialHealthState)
if err != nil {
return fmt.Errorf("failed to watch IPN bus for auth health: %w", err)
}
defer w.Close()
for {
if ctx.Err() != nil {
return ctx.Err()
}
n, err := w.Next()
if err != nil {
return err
}
if n.Health != nil {
if _, ok := n.Health.Warnings[health.LoginStateWarnable.Code]; ok {
logger.Info("Auth key failed to authenticate (may be expired or single-use), requesting new key from operator")
select {
case reissueCh <- struct{}{}:
case <-ctx.Done():
}
return nil
}
}
}
}
// handleAuthKeyReissue orchestrates the auth key reissue flow:
// 1. Disconnect from control
// 2. Set reissue marker in state Secret
// 3. Wait for operator to provide new key
// 4. Exit cleanly (Kubernetes will restart the pod with the new key)
func handleAuthKeyReissue(ctx context.Context, lc *local.Client, kc kubeclient.Client, stateSecretName string, currentAuthKey string, cfgChan <-chan *conf.Config, logger *zap.SugaredLogger) error {
if err := lc.DisconnectControl(ctx); err != nil {
return fmt.Errorf("error disconnecting from control: %w", err)
}
if err := authkey.SetReissueAuthKey(ctx, kc, stateSecretName, currentAuthKey, k8sProxyFieldManager); err != nil {
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
}
var mu sync.Mutex
var latestAuthKey string
notify := make(chan struct{}, 1)
// we use this go func to abstract away conf.Config from the shared function
go func() {
for cfg := range cfgChan {
if cfg.Parsed.AuthKey != nil {
mu.Lock()
latestAuthKey = *cfg.Parsed.AuthKey
mu.Unlock()
select {
case notify <- struct{}{}:
default:
}
}
}
}()
getAuthKey := func() string {
mu.Lock()
defer mu.Unlock()
return latestAuthKey
}
clearFn := func(ctx context.Context) error {
return authkey.ClearReissueAuthKey(ctx, kc, stateSecretName, k8sProxyFieldManager)
}
return authkey.WaitForAuthKeyReissue(ctx, currentAuthKey, 10*time.Minute, getAuthKey, clearFn, notify)
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/health"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
)
func TestResetState(t *testing.T) {
tests := []struct {
name string
existingData map[string][]byte
podUID string
configAuthKey string
wantPatched map[string][]byte
}{
{
name: "sets_capver_and_pod_uid",
existingData: map[string][]byte{
kubetypes.KeyDeviceID: []byte("device-123"),
kubetypes.KeyDeviceFQDN: []byte("node.tailnet"),
kubetypes.KeyDeviceIPs: []byte(`["100.64.0.1"]`),
},
podUID: "pod-123",
configAuthKey: "new-key",
wantPatched: map[string][]byte{
kubetypes.KeyPodUID: []byte("pod-123"),
},
},
{
name: "clears_reissue_marker_when_actioned",
existingData: map[string][]byte{
kubetypes.KeyReissueAuthkey: []byte("old-key"),
},
podUID: "pod-123",
configAuthKey: "new-key",
wantPatched: map[string][]byte{
kubetypes.KeyPodUID: []byte("pod-123"),
kubetypes.KeyReissueAuthkey: nil,
},
},
{
name: "keeps_reissue_marker_when_not_actioned",
existingData: map[string][]byte{
kubetypes.KeyReissueAuthkey: []byte("old-key"),
},
podUID: "pod-123",
configAuthKey: "old-key",
wantPatched: map[string][]byte{
kubetypes.KeyPodUID: []byte("pod-123"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.wantPatched[kubetypes.KeyCapVer] = fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion)
var patched map[string][]byte
kc := &kubeclient.FakeClient{
GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{Data: tt.existingData}, nil
},
StrategicMergePatchSecretImpl: func(ctx context.Context, name string, s *kubeapi.Secret, fm string) error {
patched = s.Data
return nil
},
}
err := resetState(context.Background(), kc, "test-secret", tt.podUID, tt.configAuthKey)
if err != nil {
t.Fatalf("resetState() error = %v", err)
}
if diff := cmp.Diff(tt.wantPatched, patched); diff != "" {
t.Errorf("resetState() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestNeedsAuthKeyReissue(t *testing.T) {
loginWarnableCode := string(health.LoginStateWarnable.Code)
tests := []struct {
name string
backendState string
health []string
want bool
}{
{
name: "running_healthy",
backendState: "Running",
want: false,
},
{
name: "needs_login",
backendState: "NeedsLogin",
want: true,
},
{
name: "running_with_login_warning",
backendState: "Running",
health: []string{"warning: " + loginWarnableCode + ": you are logged out"},
want: true,
},
{
name: "running_with_unrelated_warning",
backendState: "Running",
health: []string{"dns-not-working"},
want: false,
},
{
name: "running_no_warnings",
backendState: "Running",
health: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := needsAuthKeyReissue(tt.backendState, tt.health)
if got != tt.want {
t.Errorf("needsAuthKeyReissue() = %v, want %v", got, tt.want)
}
})
}
}
+7 -167
View File
@@ -9,22 +9,13 @@
// git-pull-oss.sh having Nix available. // git-pull-oss.sh having Nix available.
package main package main
// For the format, see:
// See https://gist.github.com/jbeda/5c79d2b1434f0018d693
import ( import (
"bufio"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"flag" "flag"
"fmt" "fmt"
"io"
"io/fs"
"log" "log"
"os" "os"
"path"
"sort" "tailscale.com/cmd/nardump/nardump"
) )
var sri = flag.Bool("sri", false, "print SRI") var sri = flag.Bool("sri", false, "print SRI")
@@ -34,167 +25,16 @@ func main() {
if flag.NArg() != 1 { if flag.NArg() != 1 {
log.Fatal("usage: nardump <dir>") log.Fatal("usage: nardump <dir>")
} }
arg := flag.Arg(0) fsys := os.DirFS(flag.Arg(0))
if err := os.Chdir(arg); err != nil {
log.Fatal(err)
}
if *sri { if *sri {
hash := sha256.New() s, err := nardump.SRI(fsys)
if err := writeNAR(hash, os.DirFS(".")); err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("sha256-%s\n", base64.StdEncoding.EncodeToString(hash.Sum(nil))) fmt.Println(s)
return return
} }
bw := bufio.NewWriter(os.Stdout) if err := nardump.WriteNAR(os.Stdout, fsys); err != nil {
if err := writeNAR(bw, os.DirFS(".")); err != nil {
log.Fatal(err) log.Fatal(err)
} }
bw.Flush()
}
// writeNARError is a sentinel panic type that's recovered by writeNAR
// and converted into the wrapped error.
type writeNARError struct{ err error }
// narWriter writes NAR files.
type narWriter struct {
w io.Writer
fs fs.FS
}
// writeNAR writes a NAR file to w from the root of fs.
func writeNAR(w io.Writer, fs fs.FS) (err error) {
defer func() {
if e := recover(); e != nil {
if we, ok := e.(writeNARError); ok {
err = we.err
return
}
panic(e)
}
}()
nw := &narWriter{w: w, fs: fs}
nw.str("nix-archive-1")
return nw.writeDir(".")
}
func (nw *narWriter) writeDir(dirPath string) error {
ents, err := fs.ReadDir(nw.fs, dirPath)
if err != nil {
return err
}
sort.Slice(ents, func(i, j int) bool {
return ents[i].Name() < ents[j].Name()
})
nw.str("(")
nw.str("type")
nw.str("directory")
for _, ent := range ents {
nw.str("entry")
nw.str("(")
nw.str("name")
nw.str(ent.Name())
nw.str("node")
mode := ent.Type()
sub := path.Join(dirPath, ent.Name())
var err error
switch {
case mode.IsDir():
err = nw.writeDir(sub)
case mode.IsRegular():
err = nw.writeRegular(sub)
case mode&os.ModeSymlink != 0:
err = nw.writeSymlink(sub)
default:
return fmt.Errorf("unsupported file type %v at %q", sub, mode)
}
if err != nil {
return err
}
nw.str(")")
}
nw.str(")")
return nil
}
func (nw *narWriter) writeRegular(path string) error {
nw.str("(")
nw.str("type")
nw.str("regular")
fi, err := fs.Stat(nw.fs, path)
if err != nil {
return err
}
if fi.Mode()&0111 != 0 {
nw.str("executable")
nw.str("")
}
contents, err := fs.ReadFile(nw.fs, path)
if err != nil {
return err
}
nw.str("contents")
if err := writeBytes(nw.w, contents); err != nil {
return err
}
nw.str(")")
return nil
}
func (nw *narWriter) writeSymlink(path string) error {
nw.str("(")
nw.str("type")
nw.str("symlink")
nw.str("target")
// broken symlinks are valid in a nar
// given we do os.chdir(dir) and os.dirfs(".") above
// readlink now resolves relative links even if they are broken
link, err := os.Readlink(path)
if err != nil {
return err
}
nw.str(link)
nw.str(")")
return nil
}
func (nw *narWriter) str(s string) {
if err := writeString(nw.w, s); err != nil {
panic(writeNARError{err})
}
}
func writeString(w io.Writer, s string) error {
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], uint64(len(s)))
if _, err := w.Write(buf[:]); err != nil {
return err
}
if _, err := io.WriteString(w, s); err != nil {
return err
}
return writePad(w, len(s))
}
func writeBytes(w io.Writer, b []byte) error {
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], uint64(len(b)))
if _, err := w.Write(buf[:]); err != nil {
return err
}
if _, err := w.Write(b); err != nil {
return err
}
return writePad(w, len(b))
}
func writePad(w io.Writer, n int) error {
pad := n % 8
if pad == 0 {
return nil
}
var zeroes [8]byte
_, err := w.Write(zeroes[:8-pad])
return err
} }
+193
View File
@@ -0,0 +1,193 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package nardump writes a NAR (Nix Archive) representation of an
// fs.FS to an io.Writer, or summarizes it as a Subresource Integrity
// hash, as used by Nix flake.nix vendor and toolchain hashes.
//
// For the format, see:
// https://gist.github.com/jbeda/5c79d2b1434f0018d693
package nardump
import (
"bufio"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"io/fs"
"path"
"sort"
)
// WriteNAR writes a NAR-encoded representation of fsys, rooted at
// the FS root, to w.
//
// The encoder issues many small writes; if w is not already a
// *bufio.Writer, WriteNAR wraps it in one and flushes on return so
// the caller doesn't have to.
//
// fsys must implement fs.ReadLinkFS to encode any symlinks it
// contains; os.DirFS satisfies this on Go 1.25+.
func WriteNAR(w io.Writer, fsys fs.FS) (err error) {
defer func() {
if e := recover(); e != nil {
if we, ok := e.(writeNARError); ok {
err = we.err
return
}
panic(e)
}
}()
bw, ok := w.(*bufio.Writer)
if !ok {
bw = bufio.NewWriter(w)
defer func() {
if flushErr := bw.Flush(); err == nil {
err = flushErr
}
}()
}
nw := &narWriter{w: bw, fs: fsys}
nw.str("nix-archive-1")
return nw.writeDir(".")
}
// SRI returns the Subresource Integrity hash of the NAR encoding of
// fsys, in the form "sha256-<base64>". This is the format Nix
// expects for vendorHash and similar fields.
func SRI(fsys fs.FS) (string, error) {
h := sha256.New()
if err := WriteNAR(h, fsys); err != nil {
return "", err
}
return "sha256-" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
// writeNARError is a sentinel panic type that's recovered by
// WriteNAR and converted into the wrapped error.
type writeNARError struct{ err error }
// narWriter writes NAR files.
type narWriter struct {
w io.Writer
fs fs.FS
}
func (nw *narWriter) writeDir(dirPath string) error {
ents, err := fs.ReadDir(nw.fs, dirPath)
if err != nil {
return err
}
sort.Slice(ents, func(i, j int) bool {
return ents[i].Name() < ents[j].Name()
})
nw.str("(")
nw.str("type")
nw.str("directory")
for _, ent := range ents {
nw.str("entry")
nw.str("(")
nw.str("name")
nw.str(ent.Name())
nw.str("node")
mode := ent.Type()
sub := path.Join(dirPath, ent.Name())
var err error
switch {
case mode.IsDir():
err = nw.writeDir(sub)
case mode.IsRegular():
err = nw.writeRegular(sub)
case mode&fs.ModeSymlink != 0:
err = nw.writeSymlink(sub)
default:
return fmt.Errorf("unsupported file type %v at %q", sub, mode)
}
if err != nil {
return err
}
nw.str(")")
}
nw.str(")")
return nil
}
func (nw *narWriter) writeRegular(p string) error {
nw.str("(")
nw.str("type")
nw.str("regular")
fi, err := fs.Stat(nw.fs, p)
if err != nil {
return err
}
if fi.Mode()&0111 != 0 {
nw.str("executable")
nw.str("")
}
contents, err := fs.ReadFile(nw.fs, p)
if err != nil {
return err
}
nw.str("contents")
if err := writeBytes(nw.w, contents); err != nil {
return err
}
nw.str(")")
return nil
}
func (nw *narWriter) writeSymlink(p string) error {
nw.str("(")
nw.str("type")
nw.str("symlink")
nw.str("target")
link, err := fs.ReadLink(nw.fs, p)
if err != nil {
return err
}
nw.str(link)
nw.str(")")
return nil
}
func (nw *narWriter) str(s string) {
if err := writeString(nw.w, s); err != nil {
panic(writeNARError{err})
}
}
func writeString(w io.Writer, s string) error {
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], uint64(len(s)))
if _, err := w.Write(buf[:]); err != nil {
return err
}
if _, err := io.WriteString(w, s); err != nil {
return err
}
return writePad(w, len(s))
}
func writeBytes(w io.Writer, b []byte) error {
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], uint64(len(b)))
if _, err := w.Write(buf[:]); err != nil {
return err
}
if _, err := w.Write(b); err != nil {
return err
}
return writePad(w, len(b))
}
func writePad(w io.Writer, n int) error {
pad := n % 8
if pad == 0 {
return nil
}
var zeroes [8]byte
_, err := w.Write(zeroes[:8-pad])
return err
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package nardump
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
)
// setupTmpdir sets up a known golden layout, covering all allowed file/folder types in a nar.
func setupTmpdir(t *testing.T) string {
t.Helper()
tmpdir := t.TempDir()
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
must(os.MkdirAll(filepath.Join(tmpdir, "sub/dir"), 0755))
must(os.Symlink("brokenfile", filepath.Join(tmpdir, "brokenlink")))
must(os.Symlink("sub/dir", filepath.Join(tmpdir, "dirl")))
must(os.Symlink("/abs/nonexistentdir", filepath.Join(tmpdir, "dirb")))
f, err := os.Create(filepath.Join(tmpdir, "sub/dir/file1"))
must(err)
f.Close()
f, err = os.Create(filepath.Join(tmpdir, "file2m"))
must(err)
must(f.Truncate(2 * 1024 * 1024))
f.Close()
must(os.Symlink("../file2m", filepath.Join(tmpdir, "sub/goodlink")))
return tmpdir
}
func TestWriteNAR(t *testing.T) {
if runtime.GOOS == "windows" {
// Skip test on Windows as the Nix package manager is not supported on this platform
t.Skip("nix package manager is not available on Windows")
}
dir := setupTmpdir(t)
// obtained via `nix-store --dump /tmp/... | sha256sum` of the above test dir
const expected = "727613a36f41030e93a4abf2649c3ec64a2757ccff364e3f6f7d544eb976e442"
h := sha256.New()
if err := WriteNAR(h, os.DirFS(dir)); err != nil {
t.Fatal(err)
}
if got := fmt.Sprintf("%x", h.Sum(nil)); got != expected {
t.Fatalf("sha256sum of nar: got %s, want %s", got, expected)
}
}
-52
View File
@@ -1,52 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"crypto/sha256"
"fmt"
"os"
"runtime"
"testing"
)
// setupTmpdir sets up a known golden layout, covering all allowed file/folder types in a nar
func setupTmpdir(t *testing.T) string {
tmpdir := t.TempDir()
pwd, _ := os.Getwd()
os.Chdir(tmpdir)
defer os.Chdir(pwd)
os.MkdirAll("sub/dir", 0755)
os.Symlink("brokenfile", "brokenlink")
os.Symlink("sub/dir", "dirl")
os.Symlink("/abs/nonexistentdir", "dirb")
os.Create("sub/dir/file1")
f, _ := os.Create("file2m")
_ = f.Truncate(2 * 1024 * 1024)
f.Close()
os.Symlink("../file2m", "sub/goodlink")
return tmpdir
}
func TestWriteNar(t *testing.T) {
if runtime.GOOS == "windows" {
// Skip test on Windows as the Nix package manager is not supported on this platform
t.Skip("nix package manager is not available on Windows")
}
dir := setupTmpdir(t)
t.Run("nar", func(t *testing.T) {
// obtained via `nix-store --dump /tmp/... | sha256sum` of the above test dir
expected := "727613a36f41030e93a4abf2649c3ec64a2757ccff364e3f6f7d544eb976e442"
h := sha256.New()
os.Chdir(dir)
err := writeNAR(h, os.DirFS("."))
if err != nil {
t.Fatal(err)
}
hash := fmt.Sprintf("%x", h.Sum(nil))
if expected != hash {
t.Fatal("sha256sum of nar not matched", hash, expected)
}
})
}
+1 -1
View File
@@ -291,7 +291,7 @@ func (p *proxy) serve(sessionID int64, c net.Conn) error {
Certificates: p.downstreamCert, Certificates: p.downstreamCert,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
}) })
if err = uptc.HandshakeContext(ctx); err != nil { if err = s.HandshakeContext(ctx); err != nil {
p.errors.Add("client-tls", 1) p.errors.Add("client-tls", 1)
return fmt.Errorf("client TLS handshake: %v", err) return fmt.Errorf("client TLS handshake: %v", err)
} }
+9 -7
View File
@@ -138,9 +138,9 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
} }
// Finally, start mainloop to configure app connector based on information // Finally, start mainloop to configure app connector based on information
// in the netmap. // in the self node's CapMap. We set NotifyInitialNetMap so the first
// We set the NotifyInitialNetMap flag so we will always get woken with the // Notify carries the current self node (now via Notify.SelfChange);
// current netmap, before only being woken on changes. // subsequent self changes wake us up too.
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap) bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap)
if err != nil { if err != nil {
log.Fatalf("watching IPN bus: %v", err) log.Fatalf("watching IPN bus: %v", err)
@@ -155,10 +155,13 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
log.Fatalf("reading IPN bus: %v", err) log.Fatalf("reading IPN bus: %v", err)
} }
// NetMap contains app-connector configuration self := msg.SelfChange
if nm := msg.NetMap; nm != nil && nm.SelfNode.Valid() { if self == nil {
continue
}
var c appctype.AppConnectorConfig var c appctype.AppConnectorConfig
nmConf, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorConfig](nm.SelfNode.CapMap(), configCapKey) // View() lets us reuse the existing CapView decoder.
nmConf, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorConfig](self.View().CapMap(), configCapKey)
if err != nil { if err != nil {
log.Printf("failed to read app connector configuration from coordination server: %v", err) log.Printf("failed to read app connector configuration from coordination server: %v", err)
} else if len(nmConf) > 0 { } else if len(nmConf) > 0 {
@@ -177,7 +180,6 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
s.mergeConfigFromFlags(&c, ports, forwards) s.mergeConfigFromFlags(&c, ports, forwards)
s.srv.Configure(&c) s.srv.Configure(&c)
} }
}
} }
type sniproxy struct { type sniproxy struct {
+2
View File
@@ -15,9 +15,11 @@ import (
) )
var socket = flag.String("socket", paths.DefaultTailscaledSocket(), "path to tailscaled socket") var socket = flag.String("socket", paths.DefaultTailscaledSocket(), "path to tailscaled socket")
var theme = flag.String("theme", "dark", "color theme for Tailscale icon: dark, dark:nobg, light, light:nobg")
func main() { func main() {
flag.Parse() flag.Parse()
lc := &local.Client{Socket: *socket} lc := &local.Client{Socket: *socket}
systray.SetTheme(*theme)
new(systray.Menu).Run(lc) new(systray.Menu).Run(lc)
} }
+42 -10
View File
@@ -28,6 +28,7 @@ import (
"tailscale.com/feature" "tailscale.com/feature"
"tailscale.com/paths" "tailscale.com/paths"
"tailscale.com/util/slicesx" "tailscale.com/util/slicesx"
"tailscale.com/util/testenv"
"tailscale.com/version/distro" "tailscale.com/version/distro"
) )
@@ -92,8 +93,8 @@ var localClient = local.Client{
Socket: paths.DefaultTailscaledSocket(), Socket: paths.DefaultTailscaledSocket(),
} }
// Run runs the CLI. The args do not include the binary name. // RunWithContext runs the CLI. The args do not include the binary name.
func Run(args []string) (err error) { func RunWithContext(ctx context.Context, args []string) (err error) {
if runtime.GOOS == "linux" && os.Getenv("GOKRAZY_FIRST_START") == "1" && distro.Get() == distro.Gokrazy && os.Getppid() == 1 && len(args) == 0 { if runtime.GOOS == "linux" && os.Getenv("GOKRAZY_FIRST_START") == "1" && distro.Get() == distro.Gokrazy && os.Getppid() == 1 && len(args) == 0 {
// We're running on gokrazy and the user did not specify 'up'. // We're running on gokrazy and the user did not specify 'up'.
// Don't run the tailscale CLI and spam logs with usage; just exit. // Don't run the tailscale CLI and spam logs with usage; just exit.
@@ -163,7 +164,7 @@ func Run(args []string) (err error) {
return return
} }
err = rootCmd.Run(context.Background()) err = rootCmd.Run(ctx)
if local.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" { if local.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" {
return fmt.Errorf("%v\n\nUse 'sudo tailscale %s'.\nTo not require root, use 'sudo tailscale set --operator=$USER' once.", err, strings.Join(args, " ")) return fmt.Errorf("%v\n\nUse 'sudo tailscale %s'.\nTo not require root, use 'sudo tailscale set --operator=$USER' once.", err, strings.Join(args, " "))
} }
@@ -173,6 +174,11 @@ func Run(args []string) (err error) {
return err return err
} }
// Run is equivalent to calling [RunWithContext] with the background context.
func Run(args []string) (err error) {
return RunWithContext(context.Background(), args)
}
type onceFlagValue struct { type onceFlagValue struct {
flag.Value flag.Value
set bool set bool
@@ -194,17 +200,39 @@ func (v *onceFlagValue) IsBoolFlag() bool {
return ok && bf.IsBoolFlag() return ok && bf.IsBoolFlag()
} }
// noDupFlagify modifies c recursively to make all the // noDupFlagify modifies c recursively to make all the flag values be
// flag values be wrappers that permit setting the value // wrappers that permit setting the value at most once. If tb is
// at most once. // non-nil, the original values are restored when the test completes.
func noDupFlagify(c *ffcli.Command) { func noDupFlagify(c *ffcli.Command, tb testenv.TB) {
if tb == nil && testenv.InTest() {
return
}
type restore struct {
f *flag.Flag
v flag.Value
}
var restores []restore
var walk func(*ffcli.Command)
walk = func(c *ffcli.Command) {
if c.FlagSet != nil { if c.FlagSet != nil {
c.FlagSet.VisitAll(func(f *flag.Flag) { c.FlagSet.VisitAll(func(f *flag.Flag) {
if tb != nil {
restores = append(restores, restore{f, f.Value})
}
f.Value = &onceFlagValue{Value: f.Value} f.Value = &onceFlagValue{Value: f.Value}
}) })
} }
for _, sub := range c.Subcommands { for _, sub := range c.Subcommands {
noDupFlagify(sub) walk(sub)
}
}
walk(c)
if tb != nil {
tb.Cleanup(func() {
for _, r := range restores {
r.f.Value = r.v
}
})
} }
} }
@@ -221,7 +249,7 @@ var (
_ func() *ffcli.Command _ func() *ffcli.Command
) )
func newRootCmd() *ffcli.Command { func newRootCmd(tb ...testenv.TB) *ffcli.Command {
rootfs := newFlagSet("tailscale") rootfs := newFlagSet("tailscale")
rootfs.Func("socket", "path to tailscaled socket", func(s string) error { rootfs.Func("socket", "path to tailscaled socket", func(s string) error {
localClient.Socket = s localClient.Socket = s
@@ -303,7 +331,11 @@ change in the future.
}) })
ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc) ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc)
noDupFlagify(rootCmd) var t testenv.TB
if len(tb) > 0 {
t = tb[0]
}
noDupFlagify(rootCmd, t)
return rootCmd return rootCmd
} }
+36 -4
View File
@@ -779,11 +779,43 @@ func TestPrefsFromUpArgs(t *testing.T) {
wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`, wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`,
}, },
{ {
name: "error_tag_prefix", name: "error_tag_bad_prefix",
args: upArgsT{ args: upArgsT{
advertiseTags: "foo", advertiseTags: "notatag:foo",
},
wantErr: `tag: "notatag:foo": tags must start with 'tag:'`,
},
{
name: "tag_auto_prefix",
args: upArgsFromOSArgs("linux", "--advertise-tags=foo,bar"),
want: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
CorpDNS: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
NoSNAT: false,
NoStatefulFiltering: "true",
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
},
},
},
{
name: "tag_mixed_prefix",
args: upArgsFromOSArgs("linux", "--advertise-tags=tag:foo,bar"),
want: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
CorpDNS: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
NoSNAT: false,
NoStatefulFiltering: "true",
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
},
}, },
wantErr: `tag: "foo": tags must start with 'tag:'`,
}, },
{ {
name: "error_long_hostname", name: "error_long_hostname",
@@ -1618,7 +1650,7 @@ func TestNoDups(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cmd := newRootCmd() cmd := newRootCmd(t)
makeQuietContinueOnError(cmd) makeQuietContinueOnError(cmd)
err := cmd.Parse(tt.args) err := cmd.Parse(tt.args)
if got := fmt.Sprint(err); got != tt.want { if got := fmt.Sprint(err); got != tt.want {
+8 -22
View File
@@ -20,10 +20,8 @@ import (
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"k8s.io/client-go/util/homedir" "k8s.io/client-go/util/homedir"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/util/dnsname" "tailscale.com/util/dnsname"
"tailscale.com/version" "tailscale.com/version"
) )
@@ -98,12 +96,12 @@ func runConfigureKubeconfig(ctx context.Context, args []string) error {
if st.BackendState != "Running" { if st.BackendState != "Running" {
return errors.New("Tailscale is not running") return errors.New("Tailscale is not running")
} }
nm, err := getNetMap(ctx) dnsCfg, err := getDNSConfig(ctx)
if err != nil { if err != nil {
return err return err
} }
targetFQDN, err := nodeOrServiceDNSNameFromArg(st, nm, hostOrFQDNOrIP) targetFQDN, err := nodeOrServiceDNSNameFromArg(st, dnsCfg, hostOrFQDNOrIP)
if err != nil { if err != nil {
return err return err
} }
@@ -240,14 +238,14 @@ func setKubeconfigForPeer(scheme, fqdn, filePath string) error {
// nodeOrServiceDNSNameFromArg returns the PeerStatus.DNSName value from a peer // nodeOrServiceDNSNameFromArg returns the PeerStatus.DNSName value from a peer
// in st that matches the input arg which can be a base name, full DNS name, or // in st that matches the input arg which can be a base name, full DNS name, or
// an IP. If none is found, it looks for a Tailscale Service // an IP. If none is found, it looks for a Tailscale Service
func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg string) (string, error) { func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, dns *tailcfg.DNSConfig, arg string) (string, error) {
// First check for a node DNS name. // First check for a node DNS name.
if dnsName, ok := nodeDNSNameFromArg(st, arg); ok { if dnsName, ok := nodeDNSNameFromArg(st, arg); ok {
return dnsName, nil return dnsName, nil
} }
// If not found, check for a Tailscale Service DNS name. // If not found, check for a Tailscale Service DNS name.
rec, ok := serviceDNSRecordFromNetMap(nm, arg) rec, ok := serviceDNSRecordFromDNSConfig(dns, arg)
if !ok { if !ok {
return "", fmt.Errorf("no peer found for %q", arg) return "", fmt.Errorf("no peer found for %q", arg)
} }
@@ -269,25 +267,13 @@ func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg
return "", fmt.Errorf("%q is in MagicDNS, but is not currently reachable on any known peer", arg) return "", fmt.Errorf("%q is in MagicDNS, but is not currently reachable on any known peer", arg)
} }
func getNetMap(ctx context.Context) (*netmap.NetworkMap, error) { func getDNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
return localClient.DNSConfig(ctx)
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialNetMap)
if err != nil {
return nil, err
}
defer watcher.Close()
n, err := watcher.Next()
if err != nil {
return nil, err
}
return n.NetMap, nil
} }
func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, arg string) (rec tailcfg.DNSRecord, ok bool) { func serviceDNSRecordFromDNSConfig(dns *tailcfg.DNSConfig, arg string) (rec tailcfg.DNSRecord, ok bool) {
argIP, _ := netip.ParseAddr(arg) argIP, _ := netip.ParseAddr(arg)
argFQDN, err := dnsname.ToFQDN(arg) argFQDN, err := dnsname.ToFQDN(arg)
argFQDNValid := err == nil argFQDNValid := err == nil
@@ -295,7 +281,7 @@ func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, arg string) (rec tailcfg.
return rec, false return rec, false
} }
for _, rec := range nm.DNS.ExtraRecords { for _, rec := range dns.ExtraRecords {
if argIP.IsValid() { if argIP.IsValid() {
recIP, _ := netip.ParseAddr(rec.Value) recIP, _ := netip.ParseAddr(rec.Value)
if recIP == argIP { if recIP == argIP {
+4 -4
View File
@@ -18,7 +18,7 @@ func init() {
maybeSystrayCmd = systrayConfigCmd maybeSystrayCmd = systrayConfigCmd
} }
var systrayArgs struct { var configSystrayArgs struct {
initSystem string initSystem string
installStartup bool installStartup bool
} }
@@ -32,7 +32,7 @@ func systrayConfigCmd() *ffcli.Command {
Exec: configureSystray, Exec: configureSystray,
FlagSet: (func() *flag.FlagSet { FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("systray") fs := newFlagSet("systray")
fs.StringVar(&systrayArgs.initSystem, "enable-startup", "", fs.StringVar(&configSystrayArgs.initSystem, "enable-startup", "",
"Install startup script for init system. Currently supported systems are [systemd, freedesktop].") "Install startup script for init system. Currently supported systems are [systemd, freedesktop].")
return fs return fs
})(), })(),
@@ -40,8 +40,8 @@ func systrayConfigCmd() *ffcli.Command {
} }
func configureSystray(_ context.Context, _ []string) error { func configureSystray(_ context.Context, _ []string) error {
if systrayArgs.initSystem != "" { if configSystrayArgs.initSystem != "" {
if err := systray.InstallStartupScript(systrayArgs.initSystem); err != nil { if err := systray.InstallStartupScript(configSystrayArgs.initSystem); err != nil {
fmt.Printf("%s\n\n", err.Error()) fmt.Printf("%s\n\n", err.Error())
return flag.ErrHelp return flag.ErrHelp
} }
+2 -9
View File
@@ -670,18 +670,11 @@ func runNetmap(ctx context.Context, args []string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
var mask ipn.NotifyWatchOpt = ipn.NotifyInitialNetMap raw, err := localClient.DebugResultJSON(ctx, "current-netmap")
watcher, err := localClient.WatchIPNBus(ctx, mask)
if err != nil { if err != nil {
return err return err
} }
defer watcher.Close() j, _ := json.MarshalIndent(raw, "", "\t")
n, err := watcher.Next()
if err != nil {
return err
}
j, _ := json.MarshalIndent(n.NetMap, "", "\t")
fmt.Printf("%s\n", j) fmt.Printf("%s\n", j)
return nil return nil
} }
+2 -21
View File
@@ -14,9 +14,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/jsonoutput" "tailscale.com/cmd/tailscale/cli/jsonoutput"
"tailscale.com/ipn"
"tailscale.com/types/dnstype" "tailscale.com/types/dnstype"
"tailscale.com/types/netmap"
) )
var dnsStatusCmd = &ffcli.Command{ var dnsStatusCmd = &ffcli.Command{
@@ -120,11 +118,10 @@ func runDNSStatus(ctx context.Context, args []string) error {
SelfDNSName: s.Self.DNSName, SelfDNSName: s.Self.DNSName,
} }
netMap, err := fetchNetMap() dnsConfig, err := localClient.DNSConfig(ctx)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch network map: %w", err) return fmt.Errorf("failed to fetch DNS config: %w", err)
} }
dnsConfig := netMap.DNS
for _, r := range dnsConfig.Resolvers { for _, r := range dnsConfig.Resolvers {
data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r)) data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r))
@@ -357,19 +354,3 @@ func formatDNSStatusText(data *jsonoutput.DNSStatusResult, all bool) string {
fmt.Fprintf(&sb, "[this is a preliminary version of this command; the output format may change in the future]\n") fmt.Fprintf(&sb, "[this is a preliminary version of this command; the output format may change in the future]\n")
return sb.String() return sb.String()
} }
func fetchNetMap() (netMap *netmap.NetworkMap, err error) {
w, err := localClient.WatchIPNBus(context.Background(), ipn.NotifyInitialNetMap)
if err != nil {
return nil, err
}
defer w.Close()
notify, err := w.Next()
if err != nil {
return nil, err
}
if notify.NetMap == nil {
return nil, fmt.Errorf("no network map yet available, please try again later")
}
return notify.NetMap, nil
}
+184 -15
View File
@@ -32,6 +32,7 @@ import (
"tailscale.com/client/tailscale/apitype" "tailscale.com/client/tailscale/apitype"
"tailscale.com/cmd/tailscale/cli/ffcomplete" "tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsaddr" "tailscale.com/net/tsaddr"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@@ -78,6 +79,7 @@ var fileCpCmd = &ffcli.Command{
fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when <file> is \"-\" (stdin)") fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when <file> is \"-\" (stdin)")
fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output") fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output")
fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets") fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets")
fs.DurationVar(&cpArgs.updateInterval, "update-interval", 250*time.Millisecond, "how often to repaint the progress line; zero or negative disables progress display entirely")
return fs return fs
})(), })(),
} }
@@ -86,6 +88,7 @@ var cpArgs struct {
name string name string
verbose bool verbose bool
targets bool targets bool
updateInterval time.Duration
} }
func runCp(ctx context.Context, args []string) error { func runCp(ctx context.Context, args []string) error {
@@ -119,9 +122,6 @@ func runCp(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("can't send to %s: %v", target, err) return fmt.Errorf("can't send to %s: %v", target, err)
} }
if isOffline {
fmt.Fprintf(Stderr, "# warning: %s is offline\n", target)
}
if len(files) > 1 { if len(files) > 1 {
if cpArgs.name != "" { if cpArgs.name != "" {
@@ -132,7 +132,51 @@ func runCp(ctx context.Context, args []string) error {
} }
} }
for _, fileArg := range files { // outFiles tracks per-name push state, populated by a goroutine subscribed
// to the IPN bus. tailscaled's OutgoingFile.Sent is the bytes-pulled-toward-
// peerAPI signal; it stays at 0 until the peerAPI request body is actually
// being read, which is what we want both for the progress display and for
// disarming the offline warning. The CLI's local-side bytes counter would
// say "100% sent" the moment net/http buffers a small body into the local
// unix-socket conn to tailscaled, well before the peer has heard a thing.
type pushState struct {
sent atomic.Int64
warnTimer *time.Timer // disarmed on first byte sent to peerAPI; nil after
}
var (
outMu sync.Mutex
outFiles = map[string]*pushState{} // keyed by file name
)
busCtx, cancelBus := context.WithCancel(ctx)
defer cancelBus()
go watchOutgoingFiles(busCtx, stableID, func(name string, sent int64) {
outMu.Lock()
ps := outFiles[name]
outMu.Unlock()
if ps == nil {
return
}
// Only ever advance ps.sent forward. Bus updates can arrive late
// (after the success path below has already written contentLength
// to ps.sent for an instant final-100% paint), so we'd otherwise
// regress the count and the progress printer would compute a
// negative delta on its next tick.
for {
old := ps.sent.Load()
if sent <= old {
return
}
if ps.sent.CompareAndSwap(old, sent) {
if old == 0 && ps.warnTimer != nil {
ps.warnTimer.Stop()
}
return
}
}
})
for i, fileArg := range files {
var fileContents *countingReader var fileContents *countingReader
var name = cpArgs.name var name = cpArgs.name
var contentLength int64 = -1 var contentLength int64 = -1
@@ -175,16 +219,57 @@ func runCp(ctx context.Context, args []string) error {
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID) log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID)
} }
// Register this file with the watcher and, for the first file only,
// arm a timer that warns the user if no bytes have flowed to peerAPI
// after a few seconds. The watcher disarms it on first byte; PushFile
// returning also disarms it (cleanup, below). We don't gate on the
// netmap's Online bit (which can lag reality), but we do use it to
// pick between two warning messages.
ps := &pushState{}
if i == 0 {
ps.warnTimer = time.AfterFunc(3*time.Second, func() {
// vtRestartLine clears whatever (possibly progress) was on
// the current line, then we print the warning + \n so the
// next progress redraw lands on a fresh line below.
const vtRestartLine = "\r\x1b[K"
if isOffline {
fmt.Fprintf(Stderr, "%s# warning: %s is reportedly offline; trying anyway\n", vtRestartLine, target)
} else {
fmt.Fprintf(Stderr, "%s# warning: %s is not replying; trying anyway\n", vtRestartLine, target)
}
})
}
outMu.Lock()
outFiles[name] = ps
outMu.Unlock()
var group sync.WaitGroup var group sync.WaitGroup
ctxProgress, cancelProgress := context.WithCancel(ctx) ctxProgress, cancelProgress := context.WithCancel(ctx)
defer cancelProgress() defer cancelProgress()
if isatty.IsTerminal(os.Stderr.Fd()) { if cpArgs.updateInterval > 0 && isatty.IsTerminal(os.Stderr.Fd()) {
group.Go(func() { progressPrinter(ctxProgress, name, fileContents.n.Load, contentLength) }) group.Go(func() {
progressPrinter(ctxProgress, name, ps.sent.Load, contentLength, cpArgs.updateInterval)
})
} }
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents) err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
if err == nil {
// PushFile can finish faster than the IPN bus delivers a final
// OutgoingFile update, leaving the progress display stuck at 0%.
// Synthesize a "fully done" count before stopping the printer so
// its final paint shows 100%. For stdin (contentLength == -1) we
// don't know the size, so fall back to the local read count.
if contentLength >= 0 {
ps.sent.Store(contentLength)
} else {
ps.sent.Store(fileContents.n.Load())
}
}
cancelProgress() cancelProgress()
group.Wait() // wait for progress printer to stop before reporting the error group.Wait() // wait for progress printer to stop before reporting the error
if ps.warnTimer != nil {
ps.warnTimer.Stop()
}
if err != nil { if err != nil {
return err return err
} }
@@ -195,15 +280,71 @@ func runCp(ctx context.Context, args []string) error {
return nil return nil
} }
func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64) { // watchOutgoingFiles subscribes to the IPN bus and invokes onUpdate once
// per OutgoingFile event for files going to peer. It runs until ctx is
// done (which runCp does on return) and is best-effort: if the bus
// subscription fails for any reason, onUpdate simply isn't called and the
// caller's progress display stays at 0 — exactly the right degradation,
// since the warning timer will then fire on its normal 3-second deadline.
func watchOutgoingFiles(ctx context.Context, peer tailcfg.StableNodeID, onUpdate func(name string, sent int64)) {
// NotifyPeerChanges opts in to per-peer add/remove notifications so the
// bus stays responsive without us also subscribing to the full NetMap,
// which we don't read here.
w, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialOutgoingFiles|ipn.NotifyPeerChanges)
if err != nil {
return
}
defer w.Close()
for {
n, err := w.Next()
if err != nil {
return
}
for _, of := range n.OutgoingFiles {
if of.PeerID != peer {
continue
}
// tailscaled keeps Finished entries in its OutgoingFiles map
// across PushFile calls (see feature/taildrop/ext.go), so a
// re-send of the same filename will see both the old completed
// (Sent == DeclaredSize) entry and the new in-progress one.
// Without this filter the watcher's monotonic CAS would latch
// onto the old entry's max value and the new transfer would
// appear stuck at 100% from the first bus tick.
if of.Finished {
continue
}
onUpdate(of.Name, of.Sent)
}
}
}
// progressPrinter repaints a single-line transfer progress display every
// interval. interval must be > 0; runCp's caller gates on the
// --update-interval flag and skips invoking us when it's <= 0.
//
// It returns when ctx is done OR when it detects the transfer is stuck —
// "stuck" being: contentCount has equalled contentLength with a near-zero
// rate for >2 seconds. The stuck case prints a final newline so subsequent
// output (e.g. an error from PushFile) lands on a fresh line below the
// frozen progress line, instead of being painted over by it.
func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64, interval time.Duration) {
var rateValueFast, rateValueSlow tsrate.Value var rateValueFast, rateValueSlow tsrate.Value
rateValueFast.HalfLife = 1 * time.Second // fast response for rate measurement // tailscaled emits OutgoingFile.Sent updates at ~1 Hz, so most printer
rateValueSlow.HalfLife = 10 * time.Second // slow response for ETA measurement // ticks see no delta. With too short a half-life the displayed rate
// roughly halves between updates and doubles back when one arrives,
// looking jumpy. 5s keeps the swing under ~15% while still settling
// within a few seconds of a real change.
rateValueFast.HalfLife = 5 * time.Second // smoothed rate for display
rateValueSlow.HalfLife = 10 * time.Second // even slower, for ETA measurement
var prevContentCount int64 var prevContentCount int64
print := func() { print := func() {
currContentCount := contentCount() currContentCount := contentCount()
rateValueFast.Add(float64(currContentCount - prevContentCount)) // Clamp so a regression (which shouldn't happen, but tsrate.Value.Add
rateValueSlow.Add(float64(currContentCount - prevContentCount)) // panics on a negative count) can't take down the CLI.
delta := max(currContentCount-prevContentCount, 0)
rateValueFast.Add(float64(delta))
rateValueSlow.Add(float64(delta))
prevContentCount = currContentCount prevContentCount = currContentCount
const vtRestartLine = "\r\x1b[K" const vtRestartLine = "\r\x1b[K"
@@ -215,16 +356,23 @@ func progressPrinter(ctx context.Context, name string, contentCount func() int64
if contentLength >= 0 { if contentLength >= 0 {
currContentCount = min(currContentCount, contentLength) // cap at 100% currContentCount = min(currContentCount, contentLength) // cap at 100%
ratioRemain := float64(currContentCount) / float64(contentLength) ratioRemain := float64(currContentCount) / float64(contentLength)
etaStr := "ETA -"
if rate := rateValueSlow.Rate(); rate > 0 {
bytesRemain := float64(contentLength - currContentCount) bytesRemain := float64(contentLength - currContentCount)
secsRemain := bytesRemain / rateValueSlow.Rate() secsRemain := bytesRemain / rate
secs := int(min(max(0, secsRemain), 99*60*60+59+60+59)) secs := int(min(max(0, secsRemain), 99*60*60+59+60+59))
etaStr = fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60)
}
fmt.Fprintf(os.Stderr, " %s %s", fmt.Fprintf(os.Stderr, " %s %s",
leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")), leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")),
fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60)) etaStr)
} }
} }
tc := time.NewTicker(250 * time.Millisecond) const stuckAfter = 2 * time.Second
var fullStartedAt time.Time // when we first observed currCount==contentLength with ~zero rate
tc := time.NewTicker(interval)
defer tc.Stop() defer tc.Stop()
print() print()
for { for {
@@ -235,6 +383,24 @@ func progressPrinter(ctx context.Context, name string, contentCount func() int64
return return
case <-tc.C: case <-tc.C:
print() print()
if contentLength < 0 {
continue
}
currCount := contentCount()
rate := rateValueFast.Rate()
if currCount >= contentLength && rate < 1 {
if fullStartedAt.IsZero() {
fullStartedAt = time.Now()
} else if time.Since(fullStartedAt) >= stuckAfter {
// Transfer is stuck at 100% with no movement. Stop
// repainting so we don't keep clobbering anything the
// rest of runCp prints (warnings, errors).
fmt.Fprintln(os.Stderr)
return
}
} else {
fullStartedAt = time.Time{}
}
} }
} }
} }
@@ -328,7 +494,10 @@ peerLoop:
return "", isOffline, errors.New("cannot send files: missing required Taildrop capability") return "", isOffline, errors.New("cannot send files: missing required Taildrop capability")
case ipnstate.TaildropTargetOffline: case ipnstate.TaildropTargetOffline:
return "", isOffline, errors.New("cannot send files: peer is offline") // Don't gate on the server-reported Online bit (which lags reality
// and isn't always accurate). runCp probes reachability itself with
// TSMP pings.
return foundPeer.ID, isOffline, nil
case ipnstate.TaildropTargetNoPeerInfo: case ipnstate.TaildropTargetNoPeerInfo:
return "", isOffline, errors.New("cannot send files: invalid or unrecognized peer") return "", isOffline, errors.New("cannot send files: invalid or unrecognized peer")
@@ -159,7 +159,7 @@ type expandedAUMV1 struct {
} }
// tkaKeyV1 is the expanded version of a [tka.Key], which describes // tkaKeyV1 is the expanded version of a [tka.Key], which describes
// the public components of a key known to network-lock. // the public components of a key known to tailnet-lock.
type tkaKeyV1 struct { type tkaKeyV1 struct {
Kind string `json:"Kind,omitzero"` Kind string `json:"Kind,omitzero"`
@@ -116,7 +116,7 @@ type tailnetLockStatusV1Base struct {
// Enabled is true if Tailnet Lock is enabled. // Enabled is true if Tailnet Lock is enabled.
Enabled bool Enabled bool
// PublicKey describes the node's network-lock public key. // PublicKey describes the node's tailnet-lock public key.
PublicKey string `json:"PublicKey,omitzero"` PublicKey string `json:"PublicKey,omitzero"`
// NodeKey describes the node's current node-key. This field is not // NodeKey describes the node's current node-key. This field is not
@@ -144,7 +144,7 @@ type tailnetLockEnabledStatusV1 struct {
NodeKeySignature *tkaNodeKeySignatureV1 NodeKeySignature *tkaNodeKeySignatureV1
// TrustedKeys describes the keys currently trusted to make changes // TrustedKeys describes the keys currently trusted to make changes
// to network-lock. // to tailnet-lock.
TrustedKeys []tkaKeyV1 TrustedKeys []tkaKeyV1
// VisiblePeers describes peers which are visible in the netmap that // VisiblePeers describes peers which are visible in the netmap that
+2 -2
View File
@@ -848,10 +848,10 @@ func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string,
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1) e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
return err return err
} }
if nm := n.NetMap; nm != nil && nm.SelfNode.Valid() { if self := n.SelfChange; self != nil {
gotAll := true gotAll := true
for _, c := range caps { for _, c := range caps {
if !nm.SelfNode.HasCap(c) { if _, has := self.CapMap[c]; !has {
// The feature is not yet enabled. // The feature is not yet enabled.
// Continue blocking until it is. // Continue blocking until it is.
gotAll = false gotAll = false
+11
View File
@@ -7,6 +7,7 @@ package cli
import ( import (
"context" "context"
"flag"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/systray" "tailscale.com/client/systray"
@@ -17,10 +18,20 @@ var systrayCmd = &ffcli.Command{
ShortUsage: "tailscale systray", ShortUsage: "tailscale systray",
ShortHelp: "Run a systray application to manage Tailscale", ShortHelp: "Run a systray application to manage Tailscale",
LongHelp: "Run a systray application to manage Tailscale.", LongHelp: "Run a systray application to manage Tailscale.",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("systray")
fs.StringVar(&systrayArgs.theme, "theme", "dark", "color theme for Tailscale icon: dark, dark:nobg, light, light:nobg")
return fs
})(),
Exec: runSystray, Exec: runSystray,
} }
var systrayArgs struct {
theme string
}
func runSystray(ctx context.Context, _ []string) error { func runSystray(ctx context.Context, _ []string) error {
systray.SetTheme(systrayArgs.theme)
new(systray.Menu).Run(&localClient) new(systray.Menu).Run(&localClient)
return nil return nil
} }
+12 -6
View File
@@ -113,12 +113,12 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node") upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy") upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")") upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request (e.g. \"tag:eng,tag:montreal,tag:ssh\"); the \"tag:\" prefix is optional and added automatically when omitted (e.g. \"eng,montreal,ssh\")")
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes") upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector") upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector")
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
upf.BoolVar(&upArgs.postureChecking, "report-posture", false, hidden+"allow management plane to gather device posture information") upf.BoolVar(&upArgs.postureChecking, "report-posture", false, "allow management plane to gather device posture information")
if safesocket.GOOSUsesPeerCreds(goos) { if safesocket.GOOSUsesPeerCreds(goos) {
upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
@@ -309,9 +309,15 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
var tags []string var tags []string
if upArgs.advertiseTags != "" { if upArgs.advertiseTags != "" {
tags = strings.Split(upArgs.advertiseTags, ",") tags = strings.Split(upArgs.advertiseTags, ",")
for _, tag := range tags { for i, tag := range tags {
err := tailcfg.CheckTag(tag) // Allow users to omit the "tag:" prefix; if the tag has no
if err != nil { // colon at all, add it for them. Tags with a colon must be
// fully qualified ("tag:foo") and are validated as-is.
if !strings.Contains(tag, ":") {
tag = "tag:" + tag
tags[i] = tag
}
if err := tailcfg.CheckTag(tag); err != nil {
return nil, fmt.Errorf("tag: %q: %s", tag, err) return nil, fmt.Errorf("tag: %q: %s", tag, err)
} }
} }
@@ -726,7 +732,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
if s := n.State; s != nil { if s := n.State; s != nil {
ipnIsRunning = *s == ipn.Running ipnIsRunning = *s == ipn.Running
} }
if n.NetMap != nil && n.NetMap.NodeKey != origNodeKey { if n.SelfChange != nil && n.SelfChange.Key != origNodeKey {
waitingForKeyChange = false waitingForKeyChange = false
} }
if ipnIsRunning && !waitingForKeyChange { if ipnIsRunning && !waitingForKeyChange {
+2 -2
View File
@@ -239,7 +239,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/tstime from tailscale.com/control/controlhttp+ tailscale.com/tstime from tailscale.com/control/controlhttp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli
tailscale.com/tsweb from tailscale.com/util/eventbus tailscale.com/tsweb from tailscale.com/util/eventbus+
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+ tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
tailscale.com/types/appctype from tailscale.com/client/local+ tailscale.com/types/appctype from tailscale.com/client/local+
tailscale.com/types/dnstype from tailscale.com/tailcfg+ tailscale.com/types/dnstype from tailscale.com/tailcfg+
@@ -331,7 +331,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/net/icmp from tailscale.com/net/ping golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpproxy+ golang.org/x/net/idna from golang.org/x/net/http/httpproxy+
golang.org/x/net/internal/iana from golang.org/x/net/icmp+ golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+ golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
golang.org/x/net/internal/socks from golang.org/x/net/proxy golang.org/x/net/internal/socks from golang.org/x/net/proxy
golang.org/x/net/ipv4 from golang.org/x/net/icmp+ golang.org/x/net/ipv4 from golang.org/x/net/icmp+
golang.org/x/net/ipv6 from golang.org/x/net/icmp+ golang.org/x/net/ipv6 from golang.org/x/net/icmp+
+1 -1
View File
@@ -219,7 +219,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/net/icmp from tailscale.com/net/ping golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts golang.org/x/net/idna from golang.org/x/net/http/httpguts
golang.org/x/net/internal/iana from golang.org/x/net/icmp+ golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+ golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+ golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+ golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
golang.org/x/sync/errgroup from github.com/mdlayher/socket golang.org/x/sync/errgroup from github.com/mdlayher/socket
+1 -1
View File
@@ -240,7 +240,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/net/icmp from tailscale.com/net/ping golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts+ golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/internal/iana from golang.org/x/net/icmp+ golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+ golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+ golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+ golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
golang.org/x/sync/errgroup from github.com/mdlayher/socket golang.org/x/sync/errgroup from github.com/mdlayher/socket
+12 -10
View File
@@ -130,7 +130,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/google/nftables/expr from github.com/google/nftables+ L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
DW github.com/google/uuid from tailscale.com/clientupdate+ W github.com/google/uuid from tailscale.com/clientupdate
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+ github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
@@ -173,9 +173,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+ L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
LD github.com/tailscale/gliderssh from tailscale.com/ssh/tailssh LD github.com/tailscale/gliderssh from tailscale.com/ssh/tailssh
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
@@ -259,6 +258,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/client/web from tailscale.com/ipn/ipnlocal tailscale.com/client/web from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate from tailscale.com/feature/clientupdate tailscale.com/clientupdate from tailscale.com/feature/clientupdate
LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscale/cli/jsonoutput from tailscale.com/feature/tailnetlock
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+ tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
tailscale.com/cmd/tailscaled/tailscaledhooks from tailscale.com/cmd/tailscaled+ tailscale.com/cmd/tailscaled/tailscaledhooks from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+ tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
@@ -303,10 +303,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/posture from tailscale.com/feature/condregister tailscale.com/feature/posture from tailscale.com/feature/condregister
tailscale.com/feature/relayserver from tailscale.com/feature/condregister tailscale.com/feature/relayserver from tailscale.com/feature/condregister
tailscale.com/feature/routecheck from tailscale.com/feature/condregister
L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+ tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
tailscale.com/feature/taildrop from tailscale.com/feature/condregister tailscale.com/feature/taildrop from tailscale.com/feature/condregister
tailscale.com/feature/tailnetlock from tailscale.com/feature/condregister
L tailscale.com/feature/tap from tailscale.com/feature/condregister L tailscale.com/feature/tap from tailscale.com/feature/condregister
tailscale.com/feature/tpm from tailscale.com/feature/condregister tailscale.com/feature/tpm from tailscale.com/feature/condregister
L 💣 tailscale.com/feature/tundevstats from tailscale.com/feature/condregister L 💣 tailscale.com/feature/tundevstats from tailscale.com/feature/condregister
@@ -402,7 +404,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/tstime from tailscale.com/control/controlclient+ tailscale.com/tstime from tailscale.com/control/controlclient+
tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/tsweb from tailscale.com/util/eventbus tailscale.com/tsweb from tailscale.com/util/eventbus+
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+ tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/bools from tailscale.com/wgengine/netlog tailscale.com/types/bools from tailscale.com/wgengine/netlog
@@ -525,13 +527,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+ golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
golang.org/x/net/icmp from tailscale.com/net/ping+ golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts+ golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/internal/iana from golang.org/x/net/icmp+ golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+ golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
golang.org/x/net/internal/socks from golang.org/x/net/proxy golang.org/x/net/internal/socks from golang.org/x/net/proxy
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+ golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+ golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/proxy from tailscale.com/net/netns golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from tailscale.com/net/netmon+ D golang.org/x/net/route from tailscale.com/net/netmon+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+ golang.org/x/sync/errgroup from github.com/mdlayher/socket+
@@ -642,7 +644,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
crypto/x509 from crypto/tls+ crypto/x509 from crypto/tls+
D crypto/x509/internal/macos from crypto/x509 D crypto/x509/internal/macos from crypto/x509
crypto/x509/pkix from crypto/x509+ crypto/x509/pkix from crypto/x509+
DW database/sql/driver from github.com/google/uuid W database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe W debug/pe from github.com/dblohm7/wingoes/pe
embed from github.com/tailscale/web-client-prebuilt+ embed from github.com/tailscale/web-client-prebuilt+
@@ -732,7 +734,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
mime/quotedprintable from mime/multipart mime/quotedprintable from mime/multipart
net from crypto/tls+ net from crypto/tls+
net/http from expvar+ net/http from expvar+
net/http/httptrace from github.com/prometheus-community/pro-bing+ net/http/httptrace from github.com/aws/smithy-go/transport/http+
net/http/httputil from github.com/aws/smithy-go/transport/http+ net/http/httputil from github.com/aws/smithy-go/transport/http+
net/http/internal from net/http+ net/http/internal from net/http+
net/http/internal/ascii from net/http+ net/http/internal/ascii from net/http+
+13
View File
@@ -202,6 +202,19 @@ func TestOmitPortlist(t *testing.T) {
}.Check(t) }.Check(t)
} }
func TestOmitRouteCheck(t *testing.T) {
deptest.DepChecker{
GOOS: "linux",
GOARCH: "amd64",
Tags: "ts_omit_routecheck,ts_include_cli",
OnDep: func(dep string) {
if strings.Contains(dep, "routecheck") {
t.Errorf("unexpected dep: %q", dep)
}
},
}.Check(t)
}
func TestOmitGRO(t *testing.T) { func TestOmitGRO(t *testing.T) {
deptest.DepChecker{ deptest.DepChecker{
GOOS: "linux", GOOS: "linux",
-1
View File
@@ -828,7 +828,6 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
if err != nil { if err != nil {
return onlyNetstack, err return onlyNetstack, err
} }
e = wgengine.NewWatchdog(e)
sys.Set(e) sys.Set(e)
sys.NetstackRouter.Set(netstackSubnetRouter) sys.NetstackRouter.Set(netstackSubnetRouter)
+1 -1
View File
@@ -267,7 +267,7 @@ func main() {
if cached { if cached {
lastCol = "(cached)" lastCol = "(cached)"
} else { } else {
lastCol = fmt.Sprintf("%.3f", testDur.Seconds()) lastCol = fmt.Sprintf("%.3fs", testDur.Seconds())
} }
fmt.Printf("%s\t%s\t%v\n", outcome, pkg, lastCol) fmt.Printf("%s\t%s\t%v\n", outcome, pkg, lastCol)
} }
+7 -1
View File
@@ -258,7 +258,12 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
if n.State != nil { if n.State != nil {
notifyState(*n.State) notifyState(*n.State)
} }
if nm := n.NetMap; nm != nil { if n.SelfChange != nil {
// Self changed: rebuild the JS-side NetMap snapshot. Peers
// don't ride on the bus anymore, so fetch them on demand
// from LocalBackend.
nm := i.lb.NetMapWithPeers()
if nm != nil {
jsNetMap := jsNetMap{ jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{ Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{ jsNetMapNode: jsNetMapNode{
@@ -298,6 +303,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
log.Printf("Could not generate JSON netmap: %v", err) log.Printf("Could not generate JSON netmap: %v", err)
} }
} }
}
if n.BrowseToURL != nil { if n.BrowseToURL != nil {
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
} }
+10 -87
View File
@@ -6,77 +6,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+ W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+
github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry
github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+
github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4
github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+
github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/config from tailscale.com/wif
github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds
github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds
github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/context from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints+
github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/internal/middleware from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds
github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4
github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws
github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso
github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso
github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+
github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+
github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+
github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer
github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+
github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/endpoints/private/rulesfn from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer
github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+
github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config
github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware
github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/tracing from github.com/aws/aws-sdk-go-v2/aws/middleware+
github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
github.com/coder/websocket from tailscale.com/util/eventbus 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/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket github.com/coder/websocket/internal/util from github.com/coder/websocket
@@ -105,7 +34,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
D github.com/google/uuid from github.com/prometheus-community/pro-bing
github.com/hdevalence/ed25519consensus from tailscale.com/tka github.com/hdevalence/ed25519consensus from tailscale.com/tka
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+ github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
@@ -128,9 +56,8 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+ L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket 💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
@@ -223,11 +150,9 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+ tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/c2n from tailscale.com/tsnet tailscale.com/feature/c2n from tailscale.com/tsnet
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
tailscale.com/feature/condregister/oauthkey 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/portmapper from tailscale.com/tsnet
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/logpolicy tailscale.com/feature/syspolicy from tailscale.com/logpolicy
@@ -309,7 +234,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/tstime from tailscale.com/control/controlclient+ tailscale.com/tstime from tailscale.com/control/controlclient+
tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/tsweb from tailscale.com/util/eventbus tailscale.com/tsweb from tailscale.com/util/eventbus+
tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/bools from tailscale.com/tsnet+ tailscale.com/types/bools from tailscale.com/tsnet+
@@ -399,7 +324,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/wgengine/wgcfg/nmcfg 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/wgint from tailscale.com/wgengine+
tailscale.com/wgengine/wglog from tailscale.com/wgengine tailscale.com/wgengine/wglog from tailscale.com/wgengine
tailscale.com/wif from tailscale.com/feature/identityfederation
golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
@@ -421,16 +345,16 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+ golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+ golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts+ golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/internal/iana from golang.org/x/net/icmp+ golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+ golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
golang.org/x/net/internal/socks from golang.org/x/net/proxy golang.org/x/net/internal/socks from golang.org/x/net/proxy
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+ golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+ golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/proxy from tailscale.com/net/netns golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from tailscale.com/net/netmon+ D golang.org/x/net/route from tailscale.com/net/netmon+
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials+ golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials
golang.org/x/oauth2/clientcredentials from tailscale.com/feature/oauthkey golang.org/x/oauth2/clientcredentials from tailscale.com/feature/oauthkey
golang.org/x/oauth2/internal from golang.org/x/oauth2+ golang.org/x/oauth2/internal from golang.org/x/oauth2+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+ golang.org/x/sync/errgroup from github.com/mdlayher/socket+
@@ -533,12 +457,11 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
crypto/sha3 from crypto/internal/fips140hash+ crypto/sha3 from crypto/internal/fips140hash+
crypto/sha512 from crypto/ecdsa+ crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/cipher+ crypto/subtle from crypto/cipher+
crypto/tls from github.com/prometheus-community/pro-bing+ crypto/tls from net/http+
crypto/tls/internal/fips140tls from crypto/tls crypto/tls/internal/fips140tls from crypto/tls
crypto/x509 from crypto/tls+ crypto/x509 from crypto/tls+
D crypto/x509/internal/macos from crypto/x509 D crypto/x509/internal/macos from crypto/x509
crypto/x509/pkix from crypto/x509+ crypto/x509/pkix from crypto/x509+
D database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe W debug/pe from github.com/dblohm7/wingoes/pe
embed from github.com/tailscale/web-client-prebuilt+ embed from github.com/tailscale/web-client-prebuilt+
@@ -627,7 +550,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
mime/quotedprintable from mime/multipart mime/quotedprintable from mime/multipart
net from crypto/tls+ net from crypto/tls+
net/http from expvar+ net/http from expvar+
net/http/httptrace from github.com/prometheus-community/pro-bing+ net/http/httptrace from net/http+
net/http/httputil from tailscale.com/client/web+ net/http/httputil from tailscale.com/client/web+
net/http/internal from net/http+ net/http/internal from net/http+
net/http/internal/ascii from net/http+ net/http/internal/ascii from net/http+
@@ -642,7 +565,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
os/user from github.com/godbus/dbus/v5+ os/user from github.com/godbus/dbus/v5+
path from debug/dwarf+ path from debug/dwarf+
path/filepath from crypto/x509+ path/filepath from crypto/x509+
reflect from database/sql/driver+ reflect from encoding/asn1+
regexp from github.com/huin/goupnp/httpu+ regexp from github.com/huin/goupnp/httpu+
regexp/syntax from regexp regexp/syntax from regexp
runtime from crypto/internal/fips140+ runtime from crypto/internal/fips140+
+173
View File
@@ -0,0 +1,173 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// The tsnet-proxy command exposes a local port on the tailnet under a
// chosen hostname. By default it proxies raw TCP; pass --http to reverse
// proxy as HTTP, or --https to reverse proxy as HTTPS with an auto-issued
// Tailscale cert. Both HTTP modes inject Tailscale-User-* identity headers
// from WhoIs.
//
// Arguments are <name> <local> [tailnet]: local is the port on localhost
// to proxy to and tailnet is the port to expose on the tailnet. If tailnet
// is omitted, it defaults to 443 for --https, 80 for --http, and the local
// port otherwise.
//
// go run ./cmd/tsnet-proxy myapp 8080 # raw TCP, tailnet :8080
// go run ./cmd/tsnet-proxy myapp 22 2222 # raw TCP, tailnet :2222
// go run ./cmd/tsnet-proxy --http myapp 8080 # tailnet :80
// go run ./cmd/tsnet-proxy --https myapp 8080 # tailnet :443
//
// Or run directly from the module, no checkout required:
//
// go run tailscale.com/cmd/tsnet-proxy@latest myapp 8080
package main
import (
"flag"
"fmt"
"io"
"log"
"mime"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strconv"
"unicode/utf8"
"tailscale.com/client/local"
"tailscale.com/tsnet"
)
func main() {
asHTTP := flag.Bool("http", false, "reverse proxy as HTTP and inject Tailscale-User-* headers")
asHTTPS := flag.Bool("https", false, "reverse proxy as HTTPS with an auto-issued Tailscale cert; implies --http")
dir := flag.String("dir", "", "directory to persist tsnet state (default: per-user config dir)")
verbose := flag.Bool("v", false, "verbose tsnet backend logs")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [flags] <name> <local> [tailnet]\n", flag.CommandLine.Name())
flag.PrintDefaults()
}
flag.Parse()
if n := flag.NArg(); n != 2 && n != 3 {
flag.Usage()
os.Exit(2)
}
name := flag.Arg(0)
localPort, err := parsePort(flag.Arg(1))
if err != nil {
log.Fatalf("invalid local port %q: %v", flag.Arg(1), err)
}
tailnetPort := defaultTailnetPort(localPort, *asHTTP, *asHTTPS)
if flag.NArg() == 3 {
tailnetPort, err = parsePort(flag.Arg(2))
if err != nil {
log.Fatalf("invalid tailnet port %q: %v", flag.Arg(2), err)
}
}
target := "localhost:" + strconv.Itoa(localPort)
addr := ":" + strconv.Itoa(tailnetPort)
s := &tsnet.Server{Hostname: name, Dir: *dir}
if *verbose {
s.Logf = log.Printf
}
defer s.Close()
var ln net.Listener
if *asHTTPS {
ln, err = s.ListenTLS("tcp", addr)
} else {
ln, err = s.Listen("tcp", addr)
}
if err != nil {
log.Fatal(err)
}
defer ln.Close()
log.Printf("proxying %s -> %s on tailnet", target, name+addr)
if *asHTTP || *asHTTPS {
lc, err := s.LocalClient()
if err != nil {
log.Fatal(err)
}
targetURL := &url.URL{Scheme: "http", Host: target}
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(targetURL)
r.SetXForwarded()
addTailscaleIdentityHeaders(lc, r)
},
}
log.Fatal(http.Serve(ln, rp))
}
for {
c, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
go proxyTCP(c, target)
}
}
func parsePort(s string) (int, error) {
p, err := strconv.Atoi(s)
if err != nil || p <= 0 || p > 65535 {
return 0, fmt.Errorf("bad port")
}
return p, nil
}
// defaultTailnetPort returns the tailnet port when the user didn't
// specify one: 443 for HTTPS, 80 for HTTP, else the local port.
func defaultTailnetPort(local int, asHTTP, asHTTPS bool) int {
switch {
case asHTTPS:
return 443
case asHTTP:
return 80
}
return local
}
func proxyTCP(c net.Conn, target string) {
defer c.Close()
d, err := net.Dial("tcp", target)
if err != nil {
log.Printf("dial %s: %v", target, err)
return
}
defer d.Close()
go io.Copy(d, c)
io.Copy(c, d)
}
func addTailscaleIdentityHeaders(lc *local.Client, r *httputil.ProxyRequest) {
r.Out.Header.Del("Tailscale-User-Login")
r.Out.Header.Del("Tailscale-User-Name")
r.Out.Header.Del("Tailscale-User-Profile-Pic")
r.Out.Header.Del("Tailscale-Funnel-Request")
r.Out.Header.Del("Tailscale-Headers-Info")
who, err := lc.WhoIs(r.In.Context(), r.In.RemoteAddr)
if err != nil || who == nil || who.Node.IsTagged() {
return
}
r.Out.Header.Set("Tailscale-User-Login", encHeader(who.UserProfile.LoginName))
r.Out.Header.Set("Tailscale-User-Name", encHeader(who.UserProfile.DisplayName))
r.Out.Header.Set("Tailscale-User-Profile-Pic", who.UserProfile.ProfilePicURL)
}
// encHeader mirrors the encoding tailscaled's serve path applies to
// user-provided strings destined for HTTP headers.
func encHeader(v string) string {
if !utf8.ValidString(v) {
return ""
}
return mime.QEncoding.Encode("utf-8", v)
}
+513
View File
@@ -0,0 +1,513 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Program tsp is a low-level Tailscale protocol tool for performing
// composable building block operations like generating keys and
// registering nodes.
package main
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"reflect"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/control/tsp"
"tailscale.com/hostinfo"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
var globalArgs struct {
// serverURL is the base URL of the coordination server (-s flag).
// If empty, tsp.DefaultServerURL is used.
serverURL string
// controlKeyFile is a path to a file containing the server's
// MachinePublic key in MarshalText form (--control-key flag).
// When set, server key discovery is skipped.
controlKeyFile string
}
func main() {
args := os.Args[1:]
if err := rootCmd.Parse(args); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
err := rootCmd.Run(context.Background())
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
var rootCmd = &ffcli.Command{
Name: "tsp",
ShortUsage: "tsp [-s url] <subcommand> [flags]",
ShortHelp: "Low-level Tailscale protocol tool.",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("tsp", flag.ExitOnError)
fs.StringVar(&globalArgs.serverURL, "s", "", "base URL of coordination server (default: "+tsp.DefaultServerURL+")")
fs.StringVar(&globalArgs.controlKeyFile, "control-key", "", "file containing the server's public key (skips discovery)")
return fs
})(),
Subcommands: []*ffcli.Command{
newMachineKeyCmd,
newNodeKeyCmd,
newNodeCmd,
registerCmd,
mapCmd,
discoverServerKeyCmd,
},
Exec: func(ctx context.Context, args []string) error {
return flag.ErrHelp
},
}
var newMachineKeyArgs struct {
output string
}
var newMachineKeyCmd = &ffcli.Command{
Name: "new-machine-key",
ShortUsage: "tsp new-machine-key [-o file]",
ShortHelp: "Generate a new machine key.",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("new-machine-key", flag.ExitOnError)
fs.StringVar(&newMachineKeyArgs.output, "o", "", "output file (default: stdout)")
return fs
})(),
Exec: runNewMachineKey,
}
func runNewMachineKey(ctx context.Context, args []string) error {
k := key.NewMachine()
text, err := k.MarshalText()
if err != nil {
return err
}
text = append(text, '\n')
return writeOutput(newMachineKeyArgs.output, text)
}
var newNodeKeyArgs struct {
output string
}
var newNodeKeyCmd = &ffcli.Command{
Name: "new-node-key",
ShortUsage: "tsp new-node-key [-o file]",
ShortHelp: "Generate a new node key.",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("new-node-key", flag.ExitOnError)
fs.StringVar(&newNodeKeyArgs.output, "o", "", "output file (default: stdout)")
return fs
})(),
Exec: runNewNodeKey,
}
func runNewNodeKey(ctx context.Context, args []string) error {
k := key.NewNode()
text, err := k.MarshalText()
if err != nil {
return err
}
text = append(text, '\n')
return writeOutput(newNodeKeyArgs.output, text)
}
var discoverServerKeyArgs struct {
output string
}
var discoverServerKeyCmd = &ffcli.Command{
Name: "discover-server-key",
ShortUsage: "tsp [-s url] discover-server-key [-o file]",
ShortHelp: "Discover and print the coordination server's public key.",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("discover-server-key", flag.ExitOnError)
fs.StringVar(&discoverServerKeyArgs.output, "o", "", "output file (default: stdout)")
return fs
})(),
Exec: runDiscoverServerKey,
}
func runDiscoverServerKey(ctx context.Context, args []string) error {
k, err := tsp.DiscoverServerKey(ctx, globalArgs.serverURL)
if err != nil {
return err
}
text, err := k.MarshalText()
if err != nil {
return fmt.Errorf("marshaling server key: %w", err)
}
text = append(text, '\n')
return writeOutput(discoverServerKeyArgs.output, text)
}
var newNodeArgs struct {
nodeKeyFile string
machineKeyFile string
output string
}
var newNodeCmd = &ffcli.Command{
Name: "new-node",
ShortUsage: "tsp [-s url] [--control-key file] new-node [-n node-key-file] [-m machine-key-file] [-o output]",
ShortHelp: "Generate a new node JSON file with keys and server info.",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("new-node", flag.ExitOnError)
fs.StringVar(&newNodeArgs.nodeKeyFile, "n", "", "existing node key file (default: generate new)")
fs.StringVar(&newNodeArgs.machineKeyFile, "m", "", "existing machine key file (default: generate new)")
fs.StringVar(&newNodeArgs.output, "o", "", "output file (default: stdout)")
return fs
})(),
Exec: runNewNode,
}
func runNewNode(ctx context.Context, args []string) error {
var nodeKey key.NodePrivate
if newNodeArgs.nodeKeyFile != "" {
var err error
nodeKey, err = readNodeKeyFile(newNodeArgs.nodeKeyFile)
if err != nil {
return fmt.Errorf("reading node key: %w", err)
}
} else {
nodeKey = key.NewNode()
}
var machineKey key.MachinePrivate
if newNodeArgs.machineKeyFile != "" {
var err error
machineKey, err = readMachineKeyFile(newNodeArgs.machineKeyFile)
if err != nil {
return fmt.Errorf("reading machine key: %w", err)
}
} else {
machineKey = key.NewMachine()
}
serverURL := cmp.Or(globalArgs.serverURL, tsp.DefaultServerURL)
var serverKey key.MachinePublic
if globalArgs.controlKeyFile != "" {
var err error
serverKey, err = readControlKeyFile(globalArgs.controlKeyFile)
if err != nil {
return fmt.Errorf("reading control key: %w", err)
}
} else {
var err error
serverKey, err = tsp.DiscoverServerKey(ctx, serverURL)
if err != nil {
return fmt.Errorf("discovering server key: %w", err)
}
}
nf := tsp.NodeFile{
NodeKey: nodeKey,
MachineKey: machineKey,
ServerInfo: tsp.ServerInfo{URL: serverURL, Key: serverKey},
}
out, err := json.MarshalIndent(nf, "", " ")
if err != nil {
return fmt.Errorf("encoding node file: %w", err)
}
out = append(out, '\n')
return writeOutput(newNodeArgs.output, out)
}
var registerArgs struct {
nodeFile string
output string
hostname string
ephemeral bool
authKey string
tags string
}
var registerCmd = &ffcli.Command{
Name: "register",
ShortUsage: "tsp [-s url] register -n <node-file> [flags]",
ShortHelp: "Register a node key with a coordination server.",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("register", flag.ExitOnError)
fs.StringVar(&registerArgs.nodeFile, "n", "", "node JSON file (required)")
fs.StringVar(&registerArgs.output, "o", "", "output file (default: stdout)")
fs.StringVar(&registerArgs.hostname, "hostname", "", "hostname to register")
fs.BoolVar(&registerArgs.ephemeral, "ephemeral", false, "register as ephemeral node")
fs.StringVar(&registerArgs.authKey, "auth-key", "", "pre-authorized auth key or file containing one")
fs.StringVar(&registerArgs.tags, "tags", "", "comma-separated ACL tags")
return fs
})(),
Exec: runRegister,
}
func runRegister(ctx context.Context, args []string) error {
if registerArgs.nodeFile == "" {
return fmt.Errorf("flag -n (node file) is required")
}
nf, err := tsp.ReadNodeFile(registerArgs.nodeFile)
if err != nil {
return fmt.Errorf("reading node file: %w", err)
}
hi := hostinfo.New()
if registerArgs.hostname != "" {
hi.Hostname = registerArgs.hostname
}
var tags []string
if registerArgs.tags != "" {
tags = strings.Split(registerArgs.tags, ",")
}
authKey, err := resolveAuthKey(registerArgs.authKey)
if err != nil {
return err
}
client, err := tsp.NewClient(tsp.ClientOpts{
ServerURL: cmp.Or(globalArgs.serverURL, nf.URL),
MachineKey: nf.MachineKey,
})
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
defer client.Close()
if globalArgs.controlKeyFile != "" {
controlKey, err := readControlKeyFile(globalArgs.controlKeyFile)
if err != nil {
return fmt.Errorf("reading control key: %w", err)
}
client.SetControlPublicKey(controlKey)
} else {
client.SetControlPublicKey(nf.ServerInfo.Key)
}
resp, err := client.Register(ctx, tsp.RegisterOpts{
NodeKey: nf.NodeKey,
Hostinfo: hi,
Ephemeral: registerArgs.ephemeral,
AuthKey: authKey,
Tags: tags,
})
if err != nil {
return err
}
out, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return fmt.Errorf("encoding response: %w", err)
}
out = append(out, '\n')
if err := writeOutput(registerArgs.output, out); err != nil {
return err
}
if resp.AuthURL != "" {
fmt.Fprintf(os.Stderr, "AuthURL: %s\n", resp.AuthURL)
}
return nil
}
var mapArgs struct {
nodeFile string
stream bool
peers bool
quiet bool
output string
}
var mapCmd = &ffcli.Command{
Name: "map",
ShortUsage: "tsp [-s url] map -n <node-file> [-stream]",
ShortHelp: "Send a map request to the coordination server.",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("map", flag.ExitOnError)
fs.StringVar(&mapArgs.nodeFile, "n", "", "node JSON file (required)")
fs.BoolVar(&mapArgs.stream, "stream", false, "stream map responses")
fs.BoolVar(&mapArgs.peers, "peers", true, "include peers in map response")
fs.BoolVar(&mapArgs.quiet, "quiet", true, "suppress keepalives and handled c2n ping requests from output")
fs.StringVar(&mapArgs.output, "o", "", "output file (default: stdout)")
return fs
})(),
Exec: runMap,
}
func runMap(ctx context.Context, args []string) error {
if mapArgs.nodeFile == "" {
return fmt.Errorf("flag -n (node file) is required")
}
nf, err := tsp.ReadNodeFile(mapArgs.nodeFile)
if err != nil {
return fmt.Errorf("reading node file: %w", err)
}
if globalArgs.serverURL != "" && globalArgs.serverURL != nf.URL {
return fmt.Errorf("server URL mismatch: -s flag is %q but node file is for %q", globalArgs.serverURL, nf.URL)
}
hi := hostinfo.New()
client, err := tsp.NewClient(tsp.ClientOpts{
ServerURL: cmp.Or(globalArgs.serverURL, nf.URL),
MachineKey: nf.MachineKey,
})
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
defer client.Close()
if globalArgs.controlKeyFile != "" {
controlKey, err := readControlKeyFile(globalArgs.controlKeyFile)
if err != nil {
return fmt.Errorf("reading control key: %w", err)
}
client.SetControlPublicKey(controlKey)
} else {
client.SetControlPublicKey(nf.ServerInfo.Key)
}
session, err := client.Map(ctx, tsp.MapOpts{
NodeKey: nf.NodeKey,
Hostinfo: hi,
Stream: mapArgs.stream,
OmitPeers: !mapArgs.peers,
})
if err != nil {
return err
}
defer session.Close()
gotResponse := false
for {
resp, err := session.Next()
if err == io.EOF {
if !gotResponse {
return fmt.Errorf("server returned no map response")
}
return nil
}
if err != nil {
return fmt.Errorf("reading map response: %w", err)
}
gotResponse = true
if pr := resp.PingRequest; pr != nil && pr.Types == "c2n" {
if client.AnswerC2NPing(ctx, pr, session.NoiseRoundTrip) && mapArgs.quiet {
resp.PingRequest = nil
}
}
if mapArgs.quiet {
resp.KeepAlive = false
}
if isZeroMapResponse(resp) {
continue
}
out, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return fmt.Errorf("encoding response: %w", err)
}
out = append(out, '\n')
if err := writeOutput(mapArgs.output, out); err != nil {
return err
}
}
}
// readMachineKeyFile reads a machine private key from a file.
func readMachineKeyFile(path string) (key.MachinePrivate, error) {
data, err := os.ReadFile(path)
if err != nil {
return key.MachinePrivate{}, err
}
var k key.MachinePrivate
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
return key.MachinePrivate{}, fmt.Errorf("parsing machine key from %q: %w", path, err)
}
return k, nil
}
// readNodeKeyFile reads a node private key from a file.
func readNodeKeyFile(path string) (key.NodePrivate, error) {
data, err := os.ReadFile(path)
if err != nil {
return key.NodePrivate{}, err
}
var k key.NodePrivate
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
return key.NodePrivate{}, fmt.Errorf("parsing node key from %q: %w", path, err)
}
return k, nil
}
// readControlKeyFile reads a file containing a server's MachinePublic key
// in its MarshalText form (e.g. "mkey:...").
func readControlKeyFile(path string) (key.MachinePublic, error) {
data, err := os.ReadFile(path)
if err != nil {
return key.MachinePublic{}, err
}
var k key.MachinePublic
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
return key.MachinePublic{}, fmt.Errorf("parsing control key from %q: %w", path, err)
}
return k, nil
}
// resolveAuthKey returns the auth key from v. If v is empty, it returns "".
// If v starts with "tskey-", it's used directly. Otherwise v is treated as a
// filename and its contents are read and trimmed.
func resolveAuthKey(v string) (string, error) {
if v == "" {
return "", nil
}
if strings.HasPrefix(strings.TrimSpace(v), "tskey-") {
return strings.TrimSpace(v), nil
}
data, err := os.ReadFile(v)
if err != nil {
return "", fmt.Errorf("reading auth key file: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
func writeOutput(path string, data []byte) error {
if path == "" {
_, err := os.Stdout.Write(data)
return err
}
return os.WriteFile(path, data, 0600)
}
// isZeroMapResponse reports whether all fields of resp are zero values.
func isZeroMapResponse(resp *tailcfg.MapResponse) bool {
v := reflect.ValueOf(*resp)
for i := range v.NumField() {
if !v.Field(i).IsZero() {
return false
}
}
return true
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"syscall"
"golang.org/x/sys/unix"
"tailscale.com/net/netmon"
)
// bypassControlFunc is set as net.Dialer.Control so that sockets dialed by
// TTA bypass tailscaled's policy routing. Without it, sockets opened before
// tailscaled installs an exit-node route would have their packets rerouted
// via the exit node when the route is later installed, breaking the
// existing connection.
//
// We bind the socket to the default route's interface (typically the VM's
// LAN-facing NIC) rather than relying on the bypass fwmark. The fwmark
// approach is conditional on tailscaled having configured SO_MARK-based
// policy routing; binding to the underlying interface is unconditional.
func bypassControlFunc(network, address string, c syscall.RawConn) error {
ifc, err := netmon.DefaultRouteInterface()
if err != nil {
return fmt.Errorf("netmon.DefaultRouteInterface: %w", err)
}
var sockErr error
if err := c.Control(func(fd uintptr) {
sockErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, ifc)
}); err != nil {
return err
}
if sockErr != nil {
return fmt.Errorf("setting SO_BINDTODEVICE on %q: %w", ifc, sockErr)
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package main
import "syscall"
// bypassControlFunc is a no-op on non-Linux platforms; SO_MARK is a Linux
// concept and exit-node routing only matters here for Linux VMs in vmtest.
func bypassControlFunc(network, address string, c syscall.RawConn) error {
return nil
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin
package main
import (
"encoding/json"
"fmt"
"log"
"net"
"os/exec"
"strconv"
"time"
"unsafe"
"golang.org/x/sys/unix"
"tailscale.com/tstest/natlab/vnet"
)
const (
afVSOCK = 40 // AF_VSOCK on macOS
vmaddrCIDHost = 2 // VMADDR_CID_HOST
vsockPort = 51011 // port for IP assignment protocol
)
// sockaddrVM is the Go equivalent of struct sockaddr_vm from <sys/vsock.h>.
type sockaddrVM struct {
Len uint8
Family uint8
Reserved1 uint16
Port uint32
CID uint32
}
type netConfig struct {
IP string `json:"ip"`
Mask string `json:"mask"`
GW string `json:"gw"`
}
// startIPAssignLoop starts a background goroutine that polls the host
// via the virtio socket for an IP assignment. When the host responds
// with a JSON config (rather than "wait"), TTA sets the IP statically
// using ifconfig and stops polling.
func startIPAssignLoop() {
go ipAssignLoop()
}
func ipAssignLoop() {
log.Printf("ipassign: starting vsock poll loop")
var lastErr string
for attempt := 0; ; attempt++ {
resp, err := askHostForIP()
if err != nil {
if e := err.Error(); e != lastErr {
log.Printf("ipassign: attempt %d: %v", attempt, err)
lastErr = e
}
time.Sleep(500 * time.Millisecond)
continue
}
if resp == "wait" {
time.Sleep(500 * time.Millisecond)
continue
}
var nc netConfig
if err := json.Unmarshal([]byte(resp), &nc); err != nil {
log.Printf("ipassign: bad config: %v", err)
time.Sleep(500 * time.Millisecond)
continue
}
if err := setStaticIP(nc); err != nil {
log.Printf("ipassign: %v", err)
time.Sleep(500 * time.Millisecond)
continue
}
log.Printf("ipassign: configured en0 with %s/%s gw %s", nc.IP, nc.Mask, nc.GW)
// Switch the driver address from the DNS name to the IP directly
// (avoids DNS resolution delay) and kick the dial-out loop so it
// retries immediately with the new address.
ipAddr := net.JoinHostPort(vnet.TestDriverIPv4().String(), strconv.Itoa(vnet.TestDriverPort))
*driverAddr = ipAddr
log.Printf("ipassign: switched driver addr to %s", ipAddr)
resetDialCancels()
return
}
}
// askHostForIP connects to the host via AF_VSOCK and reads the response.
func askHostForIP() (string, error) {
fd, err := unix.Socket(afVSOCK, unix.SOCK_STREAM, 0)
if err != nil {
return "", fmt.Errorf("socket: %w", err)
}
defer unix.Close(fd)
// Set a short connect+read timeout via SO_RCVTIMEO.
tv := unix.Timeval{Sec: 1}
unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv)
addr := sockaddrVM{
Len: uint8(unsafe.Sizeof(sockaddrVM{})),
Family: afVSOCK,
Port: vsockPort,
CID: vmaddrCIDHost,
}
_, _, errno := unix.RawSyscall(unix.SYS_CONNECT, uintptr(fd),
uintptr(unsafe.Pointer(&addr)), unsafe.Sizeof(addr))
if errno != 0 {
return "", fmt.Errorf("connect: %w", errno)
}
var buf [1024]byte
n, err := unix.Read(fd, buf[:])
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
return string(buf[:n]), nil
}
// setStaticIP configures en0 with a static IP address and default route.
func setStaticIP(nc netConfig) error {
out, err := exec.Command("ifconfig", "en0", nc.IP, "netmask", nc.Mask, "up").CombinedOutput()
if err != nil {
return fmt.Errorf("ifconfig: %v: %s", err, out)
}
out, err = exec.Command("route", "add", "default", nc.GW).CombinedOutput()
if err != nil {
return fmt.Errorf("route add: %v: %s", err, out)
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !darwin
package main
// startIPAssignLoop is a no-op on non-macOS platforms.
// macOS VMs use vsock-based IP assignment to bypass slow DHCP.
func startIPAssignLoop() {}
// Reference resetDialCancels to prevent unused-function lint errors.
// It's called from ipassign_darwin.go on macOS builds.
var _ = resetDialCancels
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"os"
"strconv"
"strings"
)
func init() {
restartTailscaled = restartTailscaledLinux
}
// restartTailscaledLinux finds the tailscaled process by walking /proc and
// sends it SIGKILL. On gokrazy, the supervisor will restart tailscaled within
// a few seconds. The PID of the process that was killed is returned.
func restartTailscaledLinux() (int, error) {
ents, err := os.ReadDir("/proc")
if err != nil {
return 0, err
}
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
comm, err := os.ReadFile("/proc/" + e.Name() + "/comm")
if err != nil {
continue
}
if strings.TrimSpace(string(comm)) != "tailscaled" {
continue
}
proc, err := os.FindProcess(pid)
if err != nil {
return 0, err
}
if err := proc.Kill(); err != nil {
return 0, fmt.Errorf("killing tailscaled pid %d: %w", pid, err)
}
return pid, nil
}
return 0, fmt.Errorf("tailscaled process not found in /proc")
}

Some files were not shown because too many files have changed in this diff Show More