Compare commits

...

53 Commits

Author SHA1 Message Date
Flakes Updater
93aa395223 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2025-03-13 17:38:34 +00:00
Patrick O'Doherty
f0b395d851 go.mod update golang.org/x/net to 0.36.0 for govulncheck (#15296)
Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2025-03-13 10:37:42 -07:00
M. J. Fromberger
0663412559 util/eventbus: add basic throughput benchmarks (#15284)
Shovel small events through the pipeine as fast as possible in a few basic
configurations, to establish some baseline performance numbers.

Updates #15160

Change-Id: I1dcbbd1109abb7b93aa4dcb70da57f183eb0e60e
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2025-03-13 08:06:20 -07:00
Paul Scott
eb680edbce cmd/testwrapper: print failed tests preventing retry (#15270)
Updates tailscale/corp#26637

Signed-off-by: Paul Scott <paul@tailscale.com>
2025-03-13 14:21:29 +00:00
Irbe Krumina
cd391b37a6 ipn/ipnlocal, envknob: make it possible to configure the cert client to act in read-only mode (#15250)
* ipn/ipnlocal,envknob: add some primitives for HA replica cert share.

Add an envknob for configuring
an instance's cert store as read-only, so that it
does not attempt to issue or renew TLS credentials,
only reads them from its cert store.
This will be used by the Kubernetes Operator's HA Ingress
to enable multiple replicas serving the same HTTPS endpoint
to be able to share the same cert.

Also some minor refactor to allow adding more tests
for cert retrieval logic.


Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-03-13 14:14:03 +00:00
Will Norris
45ecc0f85a tsweb: add title to DebugHandler and helper registration methods
Allow customizing the title on the debug index page.  Also add methods
for registering http.HandlerFunc to make it a little easier on callers.

Updates tailscale/corp#27058

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
2025-03-12 19:21:25 -07:00
David Anderson
6d217d81d1 util/eventbus: add a helper program for bus development
The demo program generates a stream of made up bus events between
a number of bus actors, as a way to generate some interesting activity
to show on the bus debug page.

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-12 17:47:47 -07:00
David Anderson
d83024a63f util/eventbus: add a debug HTTP handler for the bus
Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-12 17:47:47 -07:00
Andrew Dunham
640b2fa3ae net/netmon, wgengine/magicsock: be quieter with portmapper logs
This adds a new helper to the netmon package that allows us to
rate-limit log messages, so that they only print once per (major)
LinkChange event. We then use this when constructing the portmapper, so
that we don't keep spamming logs forever on the same network.

Updates #13145

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I6e7162509148abea674f96efd76be9dffb373ae4
2025-03-12 17:45:26 -04:00
Jonathan Nobels
52710945f5 control/controlclient, ipn: add client audit logging (#14950)
updates tailscale/corp#26435

Adds client support for sending audit logs to control via /machine/audit-log.
Specifically implements audit logging for user initiated disconnections.

This will require further work to optimize the peristant storage and exclusion
via build tags for mobile:
tailscale/corp#27011
tailscale/corp#27012

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2025-03-12 10:37:03 -04:00
Naman Sood
06ae52d309 words: append to the tail of the wordlists (#15278)
Updates tailscale/corp#14698

Signed-off-by: Naman Sood <mail@nsood.in>
2025-03-11 17:23:21 -04:00
Fran Bull
5ebc135397 tsnet,wgengine: fix src to primary Tailscale IP for TCP dials
Ensure that the src address for a connection is one of the primary
addresses assigned by Tailscale. Not, for example, a virtual IP address.

Updates #14667

Signed-off-by: Fran Bull <fran@tailscale.com>
2025-03-11 13:11:01 -07:00
Patrick O'Doherty
8f0080c7a4 cmd/tsidp: allow CORS requests to openid-configuration (#15229)
Add support for Cross-Origin XHR requests to the openid-configuration
endpoint to enable clients like Grafana's auto-population of OIDC setup
data from its contents.

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

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2025-03-11 13:10:22 -07:00
dependabot[bot]
03f7f1860e .github: Bump peter-evans/create-pull-request from 7.0.7 to 7.0.8 (#15257)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.7 to 7.0.8.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](dd2324fc52...271a8d0340)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 11:31:52 -06:00
dependabot[bot]
ce0d8b0fb9 .github: Bump github/codeql-action from 3.28.10 to 3.28.11 (#15258)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.10 to 3.28.11.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](b56ba49b26...6bb031afdd)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 11:25:35 -06:00
Jonathan Nobels
660b0515b9 safesocket, version: fix safesocket_darwin behavior for cmd/tailscale (#15275)
fixes tailscale/tailscale#15269

Fixes the various CLIs for all of the various flavors of tailscaled on
darwin.  The logic in version is updated so that we have methods that
return true only for the actual GUI app (which can beCLI) and the
order of the checks in localTCPPortAndTokenDarwin are corrected so
that the logic works with all 5 combinations of CLI and tailscaled.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2025-03-11 13:24:11 -04:00
Tom Proctor
a6e19f2881 ipn/ipnlocal: allow cache hits for testing ACME certs (#15023)
PR #14771 added support for getting certs from alternate ACME servers, but the
certStore caching mechanism breaks unless you install the CA in system roots,
because we check the validity of the cert before allowing a cache hit, which
includes checking for a valid chain back to a trusted CA. For ease of testing,
allow cert cache hits when the chain is unknown to avoid re-issuing the cert
on every TLS request served. We will still get a cache miss when the cert has
expired, as enforced by a test, and this makes it much easier to test against
non-prod ACME servers compared to having to manage the installation of non-prod
CAs on clients.

Updates #14771

Change-Id: I74fe6593fe399bd135cc822195155e99985ec08a
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-03-11 14:09:46 +00:00
Brad Fitzpatrick
e38e5c38cc ssh/tailssh: fix typo in forwardedEnviron method, add docs
And don't return a comma-separated string. That's kinda weird
signature-wise, and not needed by half the callers anyway. The callers
that care can do the join themselves.

Updates #cleanup

Change-Id: Ib5ad51a3c6b663d868eba14fe9dc54b2609cfb0d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-10 20:28:36 -07:00
James Tucker
69b27d2fcf cmd/natc: error and log when IP range is exhausted
natc itself can't immediately fix the problem, but it can more correctly
error that return bad addresses.

Updates tailscale/corp#26968

Signed-off-by: James Tucker <james@tailscale.com>
2025-03-10 10:20:22 -07:00
dependabot[bot]
b9f4c5d246 .github: Bump golangci/golangci-lint-action from 6.3.1 to 6.5.0 (#15046)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6.3.1 to 6.5.0.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](2e788936b0...2226d7cb06)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Mario Minardi <mario@tailscale.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 13:31:02 -06:00
dependabot[bot]
71b1ae6bef .github: Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#15111)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](65c4c4a1dd...4cec3d8aa0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 13:02:04 -06:00
dependabot[bot]
5827e20fdf .github: Bump github/codeql-action from 3.28.9 to 3.28.10 (#15110)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.9 to 3.28.10.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](9e8d0789d4...b56ba49b26)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 12:42:13 -06:00
dependabot[bot]
f67725c3ff .github: Bump peter-evans/create-pull-request from 7.0.6 to 7.0.7 (#15113)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.6 to 7.0.7.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](67ccf781d6...dd2324fc52)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 12:41:30 -06:00
Brad Fitzpatrick
eb3313e825 tailcfg: add DERPRegion.NoMeasureNoHome, deprecate+document Avoid [cap 115]
Fixes tailscale/corp#24697

Change-Id: Ib81994b5ded3dc87a1eef079eb268906a2acb3f8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-07 23:15:38 -07:00
David Anderson
346a35f612 util/eventbus: add debugger methods to list pub/sub types
This lets debug tools list the types that clients are wielding, so
that they can build a dataflow graph and other debugging views.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-07 14:28:04 -08:00
David Anderson
e71e95b841 util/eventbus: don't allow publishers to skip events while debugging
If any debugging hook might see an event, Publisher.ShouldPublish should
tell its caller to publish even if there are no ordinary subscribers.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-07 14:27:48 -08:00
David Anderson
853abf8661 util/eventbus: initial debugging facilities for the event bus
Enables monitoring events as they flow, listing bus clients, and
snapshotting internal queues to troubleshoot stalls.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-07 12:48:32 -08:00
Mario Minardi
5ce8cd5fec .github/workflows: tidy go caches before uploading
Delete files from `$(go env GOCACHE)` and `$(go env GOMODCACHE)/cache`
that have not been modified in >= 90 minutes as these files are not
resulting in cache hits on the current branch.

These deltions have resulted in the uploaded / downloaded compressed
cache size to go down to ~1/3 of the original size in some instances
with the extracted size being ~1/4 of the original extraced size.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2025-03-07 12:27:29 -08:00
Andrew Dunham
5177fd2ccb net/portmapper: retry UPnP when we get an "Invalid Args"
We previously retried getting a UPnP mapping when the device returned
error code 725, "OnlyPermanentLeasesSupported". However, we've seen
devices in the wild also return 402, "Invalid Args", when given a lease
duration. Fall back to the no-duration mapping method in these cases.

Updates #15223

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I6a25007c9eeac0dac83750dd3ae9bfcc287c8fcf
2025-03-07 14:06:13 -05:00
Naman Sood
a4b8c24834 ipn: sort VIP services before hashing (#15035)
We're computing the list of services to hash by iterating over the
values of a map, the ordering of which is not guaranteed. This can cause
the hash to fluctuate depending on the ordering if there's more than one
service hosted by the same host.

Updates tailscale/corp#25733.

Signed-off-by: Naman Sood <mail@nsood.in>
2025-03-07 12:50:15 -05:00
Brad Fitzpatrick
75a03fc719 wgengine/magicsock: use learned DERP route as send path of last resort
If we get a packet in over some DERP and don't otherwise know how to
reply (no known DERP home or UDP endpoint), this makes us use the
DERP connection on which we received the packet to reply. This will
almost always be our own home DERP region.

This is particularly useful for large one-way nodes (such as
hello.ts.net) that don't actively reach out to other nodes, so don't
need to be told the DERP home of peers. They can instead learn the
DERP home upon getting the first connection.

This can also help nodes from a slow or misbehaving control plane.

Updates tailscale/corp#26438

Change-Id: I6241ec92828bf45982e0eb83ad5c7404df5968bc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-07 05:37:24 -08:00
Brad Fitzpatrick
7fac0175c0 cmd/derper, derp/derphttp: support, generate self-signed IP address certs
For people who can't use LetsEncrypt because it's banned.

Per https://github.com/tailscale/tailscale/issues/11776#issuecomment-2520955317

This does two things:

1) if you run derper with --certmode=manual and --hostname=$IP_ADDRESS
   we previously permitted, but now we also:
   * auto-generate the self-signed cert for you if it doesn't yet exist on disk
   * print out the derpmap configuration you need to use that
     self-signed cert

2) teaches derp/derphttp's derp dialer to verify the signature of
   self-signed TLS certs, if so declared in the existing
   DERPNode.CertName field, which previously existed for domain fronting,
   separating out the dial hostname from how certs are validates,
   so it's not overloaded much; that's what it was meant for.

Fixes #11776

Change-Id: Ie72d12f209416bb7e8325fe0838cd2c66342c5cf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-07 05:36:55 -08:00
David Anderson
e80d2b4ad1 util/eventbus: add debug hooks to snoop on bus traffic
Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-06 18:43:19 -08:00
David Anderson
dd7166cb8e util/eventbus: add internal hook type for debugging
Publicly exposed debugging functions will use these hooks to
observe dataflow in the bus.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-06 18:43:19 -08:00
Irbe Krumina
74a2373e1d cmd/k8s-operator: ensure HA Ingress can operate in multicluster mode. (#15157)
cmd/k8s-operator: ensure HA Ingress can operate in multicluster mode.

Update the owner reference mechanism so that:
- if during HA Ingress resource creation, a VIPService
with some other operator's owner reference is already found,
just update the owner references to add one for this operator
- if during HA Ingress deletion, the VIPService is found to have owner
reference(s) from another operator, don't delete the VIPService, just
remove this operator's owner reference
- requeue after HA Ingress reconciles that resulted in VIPService updates,
to guard against overwrites due to concurrent operations from different
clusters.

Updates tailscale/corp#24795


Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-03-06 23:13:10 +00:00
Patrick O'Doherty
9d7f2719bb cmd/tsidp: use constant time comparison for client_id/secret (#15222)
Use secure constant time comparisons for the client ID and secret values
during the allowRelyingParty authorization check.

Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2025-03-06 08:52:35 -08:00
Tom Proctor
ffb0b66d5b cmd/k8s-operator: advertise VIPServices in ProxyGroup config (#14946)
Now that packets flow for VIPServices, the last piece needed to start
serving them from a ProxyGroup is config to tell the proxy Pods which
services they should advertise.

Updates tailscale/corp#24795

Change-Id: Ic7bbeac8e93c9503558107bc5f6123be02a84c77
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-03-06 14:05:41 +00:00
David Anderson
cf5c788cf1 util/eventbus: track additional event context in subscribe queue
Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-05 18:29:34 -08:00
David Anderson
a1192dd686 util/eventbus: track additional event context in publish queue
Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-05 18:29:34 -08:00
David Anderson
bf40bc4fa0 util/eventbus: make internal queue a generic type
In preparation for making the queues carry additional event metadata.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-05 18:29:34 -08:00
Brad Fitzpatrick
96202a7c0c .github/workflows: descope natlab CI for now until GitHub flakes are fixed
The natlab VM tests are flaking on GitHub Actions.

To not distract people, disable them for now (unless they're touched
directly) until they're made more reliable, which will be some painful
debugging probably.

Updates #13038

Change-Id: I6570f1cd43f8f4d628a54af8481b67455ebe83dc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-05 16:46:33 -08:00
Sam Linville
27e0575f76 cmd/tsidp: add README and Dockerfile (#15205) 2025-03-05 10:55:37 -06:00
License Updater
c6b8e6f6b7 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2025-03-05 08:54:00 -08:00
David Anderson
24d4846f00 util/eventbus: adjust worker goroutine management helpers
This makes the helpers closer in behavior to cancelable contexts
and taskgroup.Single, and makes the worker code use a more normal
and easier to reason about context.Context for shutdown.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-05 08:35:13 -08:00
Brad Fitzpatrick
5eafce7e25 gokrazy/natlab: update gokrazy, wire up natlab tests to GitHub CI
Updates #13038

Change-Id: I610f9076816f44d59c0ca405a1b4f5eb4c6c0594
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-04 18:57:29 -08:00
David Anderson
3e18434595 util/eventbus: rework to have a Client abstraction
The Client carries both publishers and subscribers for a single
actor. This makes the APIs for publish and subscribe look more
similar, and this structure is a better fit for upcoming debug
facilities.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-04 17:38:20 -08:00
Patrick O'Doherty
f840aad49e go.toolchain.rev: bump to go1.24.1 (#15209)
Bump to 1.24.1 to avail of security fixes.

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

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2025-03-04 16:17:57 -08:00
dependabot[bot]
1d2d449b57 .github: Bump actions/cache from 4.2.0 to 4.2.2
Bumps [actions/cache](https://github.com/actions/cache) from 4.2.0 to 4.2.2.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](1bd1e32a3b...d4323d4df1)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-04 14:13:16 -08:00
Brad Fitzpatrick
cae5b97626 cmd/derper: add --home flag to control home page behavior
Updates #12897

Change-Id: I7e9c8de0d2daf92cc32e9f6121bc0874c6672540
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-04 08:27:50 -08:00
James Sanderson
fa374fa852 cmd/testwrapper: Display package-level output
Updates tailscale/corp#26861

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2025-03-04 16:01:28 +00:00
Brian Palmer
e74a705c67 cmd/hello: display native ipv4 (#15191)
We are soon going to start assigning shared-in nodes a CGNAT IPv4 in the Hello tailnet when necessary, the same way that normal node shares assign a new IPv4 on conflict.

But Hello wants to display the node's native IPv4, the one it uses in its own tailnet. That IPv4 isn't available anywhere in the netmap today, because it's not normally needed for anything.

We are going to start sending that native IPv4 in the peer node CapMap, only for Hello's netmap responses. This change enables Hello to display that native IPv4 instead, when available.

Updates tailscale/corp#25393

Change-Id: I87480b6d318ab028b41ef149eb3ba618bd7f1e08
Signed-off-by: Brian Palmer <brianp@tailscale.com>
2025-03-04 08:47:35 -07:00
Jonathan Nobels
16a920b96e safesocket: add isMacSysExt Check (#15192)
fixes tailscale/corp#26806

IsMacSysApp is not returning the correct answer... It looks like the
rest of the code base uses isMacSysExt (when what they really want
to know is isMacSysApp).   To fix the immediate issue (localAPI is broken
entirely in corp), we'll add this check to safesocket which lines up with
the other usages, despite the confusing naming.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2025-03-03 18:28:26 -05:00
Jonathan Nobels
5449aba94c safesocket: correct logic for determining if we're a macOS GUI client (#15187)
fixes tailscale/corp#26806

This was still slightly incorrect. We care only if the caller is the macSys
or macOs app.  isSandBoxedMacOS doesn't give us the correct answer
for macSys because technically, macsys isn't sandboxed.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2025-03-03 14:54:57 -05:00
103 changed files with 5293 additions and 810 deletions

View File

@@ -55,7 +55,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
uses: github/codeql-action/autobuild@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11

View File

@@ -31,7 +31,7 @@ jobs:
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@2e788936b09dd82dc280e845628a40d2ba6b204c # v6.3.1
uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0
with:
version: v1.64

View File

@@ -0,0 +1,27 @@
# Run some natlab integration tests.
# See https://github.com/tailscale/tailscale/issues/13038
name: "natlab-integrationtest"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
pull_request:
paths:
- "tstest/integration/nat/nat_test.go"
jobs:
natlab-integrationtest:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install qemu
run: |
sudo rm /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
- name: Run natlab integration tests
run: |
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests

View File

@@ -79,7 +79,7 @@ jobs:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Restore Cache
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -139,7 +139,11 @@ jobs:
echo "Build/test created untracked files in the repo (file names above)."
exit 1
fi
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
windows:
runs-on: windows-2022
steps:
@@ -153,7 +157,7 @@ jobs:
cache: false
- name: Restore Cache
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -176,6 +180,11 @@ jobs:
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go test ./... -bench . -benchtime 1x -run "^$"
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
privileged:
runs-on: ubuntu-22.04
@@ -254,7 +263,7 @@ jobs:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Restore Cache
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -283,6 +292,11 @@ jobs:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
ios: # similar to cross above, but iOS can't build most of the repo. So, just
#make it build a few smoke packages.
@@ -319,7 +333,7 @@ jobs:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Restore Cache
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -342,6 +356,11 @@ jobs:
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
android:
# similar to cross above, but android fails to build a few pieces of the
@@ -367,7 +386,7 @@ jobs:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Restore Cache
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -394,6 +413,11 @@ jobs:
run: |
./tool/go run ./cmd/tsconnect --fast-compression build
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
tailscale_go: # Subset of tests that depend on our custom Go toolchain.
runs-on: ubuntu-22.04
@@ -461,7 +485,7 @@ jobs:
run: |
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
- name: upload crash
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
with:
name: artifacts

View File

@@ -36,7 +36,7 @@ jobs:
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f #v7.0.6
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
with:
token: ${{ steps.generate-token.outputs.token }}
author: Flakes Updater <noreply+flakes-updater@tailscale.com>

View File

@@ -35,7 +35,7 @@ jobs:
- name: Send pull request
id: pull-request
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f #v7.0.6
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
with:
token: ${{ steps.generate-token.outputs.token }}
author: OSS Updater <noreply+oss-updater@tailscale.com>

View File

@@ -26,16 +26,11 @@ issues:
# Per-linter settings are contained in this top-level key
linters-settings:
# Enable all rules by default; we don't use invisible unicode runes.
bidichk:
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
goimports:
govet:
# Matches what we use in corp as of 2023-12-07
enable:
@@ -78,8 +73,6 @@ linters-settings:
# analyzer doesn't support type declarations
#- github.com/tailscale/tailscale/types/logger.Logf
misspell:
revive:
enable-all-rules: false
ignore-generated-header: true

View File

@@ -4,16 +4,28 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"time"
"golang.org/x/crypto/acme/autocert"
"tailscale.com/tailcfg"
)
var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`)
@@ -65,8 +77,18 @@ func NewManualCertManager(certdir, hostname string) (certProvider, error) {
crtPath := filepath.Join(certdir, keyname+".crt")
keyPath := filepath.Join(certdir, keyname+".key")
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
hostnameIP := net.ParseIP(hostname) // or nil if hostname isn't an IP address
if err != nil {
return nil, fmt.Errorf("can not load x509 key pair for hostname %q: %w", keyname, err)
// If the hostname is an IP address, automatically create a
// self-signed certificate for it.
var certp *tls.Certificate
if os.IsNotExist(err) && hostnameIP != nil {
certp, err = createSelfSignedIPCert(crtPath, keyPath, hostname)
}
if err != nil {
return nil, fmt.Errorf("can not load x509 key pair for hostname %q: %w", keyname, err)
}
cert = *certp
}
// ensure hostname matches with the certificate
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
@@ -76,6 +98,18 @@ func NewManualCertManager(certdir, hostname string) (certProvider, error) {
if err := x509Cert.VerifyHostname(hostname); err != nil {
return nil, fmt.Errorf("cert invalid for hostname %q: %w", hostname, err)
}
if hostnameIP != nil {
// If the hostname is an IP address, print out information on how to
// confgure this in the derpmap.
dn := &tailcfg.DERPNode{
Name: "custom",
RegionID: 900,
HostName: hostname,
CertName: fmt.Sprintf("sha256-raw:%-02x", sha256.Sum256(x509Cert.Raw)),
}
dnJSON, _ := json.Marshal(dn)
log.Printf("Using self-signed certificate for IP address %q. Configure it in DERPMap using: (https://tailscale.com/s/custom-derp)\n %s", hostname, dnJSON)
}
return &manualCertManager{
cert: &cert,
hostname: hostname,
@@ -109,3 +143,69 @@ func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certif
func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler {
return fallback
}
func createSelfSignedIPCert(crtPath, keyPath, ipStr string) (*tls.Certificate, error) {
ip := net.ParseIP(ipStr)
if ip == nil {
return nil, fmt.Errorf("invalid IP address: %s", ipStr)
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate EC private key: %v", err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %v", err)
}
now := time.Now()
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: ipStr,
},
NotBefore: now,
NotAfter: now.AddDate(1, 0, 0), // expires in 1 year; a bit over that is rejected by macOS etc
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Set the IP as a SAN.
template.IPAddresses = []net.IP{ip}
// Create the self-signed certificate.
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyBytes, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, fmt.Errorf("unable to marshal EC private key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
if err := os.MkdirAll(filepath.Dir(crtPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create directory for certificate: %v", err)
}
if err := os.WriteFile(crtPath, certPEM, 0644); err != nil {
return nil, fmt.Errorf("failed to write certificate to %s: %v", crtPath, err)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return nil, fmt.Errorf("failed to write key to %s: %v", keyPath, err)
}
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("failed to create tls.Certificate: %v", err)
}
return &tlsCert, nil
}

View File

@@ -4,19 +4,29 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// Verify that in --certmode=manual mode, we can use a bare IP address
@@ -95,3 +105,66 @@ func TestCertIP(t *testing.T) {
t.Fatalf("GetCertificate returned nil")
}
}
// Test that we can dial a raw IP without using a hostname and without a WebPKI
// cert, validating the cert against the signature of the cert in the DERP map's
// DERPNode.
//
// See https://github.com/tailscale/tailscale/issues/11776.
func TestPinnedCertRawIP(t *testing.T) {
td := t.TempDir()
cp, err := NewManualCertManager(td, "127.0.0.1")
if err != nil {
t.Fatalf("NewManualCertManager: %v", err)
}
cert, err := cp.TLSConfig().GetCertificate(&tls.ClientHelloInfo{
ServerName: "127.0.0.1",
})
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer ln.Close()
ds := derp.NewServer(key.NewNode(), t.Logf)
derpHandler := derphttp.Handler(ds)
mux := http.NewServeMux()
mux.Handle("/derp", derpHandler)
var hs http.Server
hs.Handler = mux
hs.TLSConfig = cp.TLSConfig()
go hs.ServeTLS(ln, "", "")
lnPort := ln.Addr().(*net.TCPAddr).Port
reg := &tailcfg.DERPRegion{
RegionID: 900,
Nodes: []*tailcfg.DERPNode{
{
RegionID: 900,
HostName: "127.0.0.1",
CertName: fmt.Sprintf("sha256-raw:%-02x", sha256.Sum256(cert.Leaf.Raw)),
DERPPort: lnPort,
},
},
}
netMon := netmon.NewStatic()
dc := derphttp.NewRegionClient(key.NewNode(), t.Logf, netMon, func() *tailcfg.DERPRegion {
return reg
})
defer dc.Close()
_, connClose, _, err := dc.DialRegionTLS(context.Background(), reg)
if err != nil {
t.Fatalf("DialRegionTLS: %v", err)
}
defer connClose.Close()
}

View File

@@ -63,6 +63,7 @@ var (
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443. When --certmode=manual, this can be an IP address to avoid SNI checks")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
flagHome = flag.String("home", "", "what to serve at the root path. It may be left empty (the default, for a default homepage), \"blank\" for a blank page, or a URL to redirect to")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list. If an entry contains a slash, the second part names a hostname to be used when dialing the target.")
@@ -254,6 +255,11 @@ func main() {
}
expvar.Publish("derp", s.ExpVar())
handleHome, ok := getHomeHandler(*flagHome)
if !ok {
log.Fatalf("unknown --home value %q", *flagHome)
}
mux := http.NewServeMux()
if *runDERP {
derpHandler := derphttp.Handler(s)
@@ -274,19 +280,7 @@ func main() {
mux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
err := homePageTemplate.Execute(w, templateData{
ShowAbuseInfo: validProdHostname.MatchString(*hostname),
Disabled: !*runDERP,
AllowDebug: tsweb.AllowDebugAccess(r),
})
if err != nil {
if r.Context().Err() == nil {
log.Printf("homePageTemplate.Execute: %v", err)
}
return
}
handleHome.ServeHTTP(w, r)
}))
mux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
@@ -579,3 +573,35 @@ var homePageTemplate = template.Must(template.New("home").Parse(`<html><body>
</body>
</html>
`))
// getHomeHandler returns a handler for the home page based on a flag string
// as documented on the --home flag.
func getHomeHandler(val string) (_ http.Handler, ok bool) {
if val == "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
err := homePageTemplate.Execute(w, templateData{
ShowAbuseInfo: validProdHostname.MatchString(*hostname),
Disabled: !*runDERP,
AllowDebug: tsweb.AllowDebugAccess(r),
})
if err != nil {
if r.Context().Err() == nil {
log.Printf("homePageTemplate.Execute: %v", err)
}
return
}
}), true
}
if val == "blank" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
}), true
}
if strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://") {
return http.RedirectHandler(val, http.StatusFound), true
}
return nil, false
}

View File

@@ -20,6 +20,7 @@ import (
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
)
var (
@@ -134,6 +135,10 @@ 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()

View File

@@ -814,6 +814,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/internal/client/tailscale from tailscale.com/cmd/k8s-operator
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/auditlog from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/desktop from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
@@ -904,6 +905,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/tstime/rate from tailscale.com/derp+
tailscale.com/tsweb/varz from tailscale.com/util/usermetric
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/bools from tailscale.com/tsnet
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+

View File

@@ -630,7 +630,11 @@ func tailnetTargetFromSvc(svc *corev1.Service) egressservices.TailnetTarget {
func portMap(p corev1.ServicePort) egressservices.PortMap {
// TODO (irbekrm): out of bounds check?
return egressservices.PortMap{Protocol: string(p.Protocol), MatchPort: uint16(p.TargetPort.IntVal), TargetPort: uint16(p.Port)}
return egressservices.PortMap{
Protocol: string(p.Protocol),
MatchPort: uint16(p.TargetPort.IntVal),
TargetPort: uint16(p.Port),
}
}
func isEgressSvcForProxyGroup(obj client.Object) bool {

View File

@@ -15,6 +15,9 @@ import (
"slices"
"strings"
"sync"
"time"
"math/rand/v2"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
@@ -53,9 +56,9 @@ const (
var gaugePGIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressPGResourceCount)
// IngressPGReconciler is a controller that reconciles Tailscale Ingresses should be exposed on an ingress ProxyGroup
// (in HA mode).
type IngressPGReconciler struct {
// HAIngressReconciler is a controller that reconciles Tailscale Ingresses
// should be exposed on an ingress ProxyGroup (in HA mode).
type HAIngressReconciler struct {
client.Client
recorder record.EventRecorder
@@ -65,6 +68,7 @@ type IngressPGReconciler struct {
tsNamespace string
lc localClient
defaultTags []string
operatorID string // stableID of the operator's Tailscale device
mu sync.Mutex // protects following
// managedIngresses is a set of all ingress resources that we're currently
@@ -72,20 +76,29 @@ type IngressPGReconciler struct {
managedIngresses set.Slice[types.UID]
}
// Reconcile reconciles Ingresses that should be exposed over Tailscale in HA mode (on a ProxyGroup). It looks at all
// Ingresses with tailscale.com/proxy-group annotation. For each such Ingress, it ensures that a VIPService named after
// the hostname of the Ingress exists and is up to date. It also ensures that the serve config for the ingress
// ProxyGroup is updated to route traffic for the VIPService to the Ingress's backend Services.
// When an Ingress is deleted or unexposed, the VIPService and the associated serve config are cleaned up.
// Ingress hostname change also results in the VIPService for the previous hostname being cleaned up and a new VIPService
// being created for the new hostname.
func (a *IngressPGReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
logger := a.logger.With("Ingress", req.NamespacedName)
// Reconcile reconciles Ingresses that should be exposed over Tailscale in HA
// mode (on a ProxyGroup). It looks at all Ingresses with
// tailscale.com/proxy-group annotation. For each such Ingress, it ensures that
// a VIPService named after the hostname of the Ingress exists and is up to
// date. It also ensures that the serve config for the ingress ProxyGroup is
// updated to route traffic for the VIPService to the Ingress's backend
// Services. Ingress hostname change also results in the VIPService for the
// previous hostname being cleaned up and a new VIPService being created for the
// new hostname.
// HA Ingresses support multi-cluster Ingress setup.
// Each VIPService contains a list of owner references that uniquely identify
// the Ingress resource and the operator. When an Ingress that acts as a
// backend is being deleted, the corresponding VIPService is only deleted if the
// only owner reference that it contains is for this Ingress. If other owner
// references are found, then cleanup operation only removes this Ingress' owner
// reference.
func (r *HAIngressReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
logger := r.logger.With("Ingress", req.NamespacedName)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
ing := new(networkingv1.Ingress)
err = a.Get(ctx, req.NamespacedName, ing)
err = r.Get(ctx, req.NamespacedName, ing)
if apierrors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
logger.Debugf("Ingress not found, assuming it was deleted")
@@ -99,56 +112,70 @@ func (a *IngressPGReconciler) Reconcile(ctx context.Context, req reconcile.Reque
hostname := hostnameForIngress(ing)
logger = logger.With("hostname", hostname)
if !ing.DeletionTimestamp.IsZero() || !a.shouldExpose(ing) {
return res, a.maybeCleanup(ctx, hostname, ing, logger)
// needsRequeue is set to true if the underlying VIPService has changed as a result of this reconcile. If that
// is the case, we reconcile the Ingress one more time to ensure that concurrent updates to the VIPService in a
// multi-cluster Ingress setup have not resulted in another actor overwriting our VIPService update.
needsRequeue := false
if !ing.DeletionTimestamp.IsZero() || !r.shouldExpose(ing) {
needsRequeue, err = r.maybeCleanup(ctx, hostname, ing, logger)
} else {
needsRequeue, err = r.maybeProvision(ctx, hostname, ing, logger)
}
if err := a.maybeProvision(ctx, hostname, ing, logger); err != nil {
return res, fmt.Errorf("failed to provision: %w", err)
if err != nil {
return res, err
}
if needsRequeue {
res = reconcile.Result{RequeueAfter: requeueInterval()}
}
return res, nil
}
// maybeProvision ensures that the VIPService and serve config for the Ingress are created or updated.
func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
if err := validateIngressClass(ctx, a.Client); err != nil {
// maybeProvision ensures that a VIPService for this Ingress exists and is up to date and that the serve config for the
// corresponding ProxyGroup contains the Ingress backend's definition.
// If a VIPService does not exist, it will be created.
// If a VIPService exists, but only with owner references from other operator instances, an owner reference for this
// operator instance is added.
// If a VIPService exists, but does not have an owner reference from any operator, we error
// out assuming that this is an owner reference created by an unknown actor.
// Returns true if the operation resulted in a VIPService update.
func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) (svcsChanged bool, err error) {
if err := validateIngressClass(ctx, r.Client); err != nil {
logger.Infof("error validating tailscale IngressClass: %v.", err)
return nil
return false, nil
}
// Get and validate ProxyGroup readiness
pgName := ing.Annotations[AnnotationProxyGroup]
if pgName == "" {
logger.Infof("[unexpected] no ProxyGroup annotation, skipping VIPService provisioning")
return nil
return false, nil
}
logger = logger.With("ProxyGroup", pgName)
pg := &tsapi.ProxyGroup{}
if err := a.Get(ctx, client.ObjectKey{Name: pgName}, pg); err != nil {
if err := r.Get(ctx, client.ObjectKey{Name: pgName}, pg); err != nil {
if apierrors.IsNotFound(err) {
logger.Infof("ProxyGroup %q does not exist", pgName)
return nil
return false, nil
}
return fmt.Errorf("getting ProxyGroup %q: %w", pgName, err)
return false, fmt.Errorf("getting ProxyGroup %q: %w", pgName, err)
}
if !tsoperator.ProxyGroupIsReady(pg) {
// TODO(irbekrm): we need to reconcile ProxyGroup Ingresses on ProxyGroup changes to not miss the status update
// in this case.
logger.Infof("ProxyGroup %q is not ready", pgName)
return nil
logger.Infof("ProxyGroup %q is not (yet) ready", pgName)
return false, nil
}
// Validate Ingress configuration
if err := a.validateIngress(ing, pg); err != nil {
if err := r.validateIngress(ctx, ing, pg); err != nil {
logger.Infof("invalid Ingress configuration: %v", err)
a.recorder.Event(ing, corev1.EventTypeWarning, "InvalidIngressConfiguration", err.Error())
return nil
r.recorder.Event(ing, corev1.EventTypeWarning, "InvalidIngressConfiguration", err.Error())
return false, nil
}
if !IsHTTPSEnabledOnTailnet(a.tsnetServer) {
a.recorder.Event(ing, corev1.EventTypeWarning, "HTTPSNotEnabled", "HTTPS is not enabled on the tailnet; ingress may not work")
if !IsHTTPSEnabledOnTailnet(r.tsnetServer) {
r.recorder.Event(ing, corev1.EventTypeWarning, "HTTPSNotEnabled", "HTTPS is not enabled on the tailnet; ingress may not work")
}
logger = logger.With("proxy-group", pg)
logger = logger.With("proxy-group", pg.Name)
if !slices.Contains(ing.Finalizers, FinalizerNamePG) {
// This log line is printed exactly once during initial provisioning,
@@ -157,64 +184,78 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
// multi-reconcile operation is underway.
logger.Infof("exposing Ingress over tailscale")
ing.Finalizers = append(ing.Finalizers, FinalizerNamePG)
if err := a.Update(ctx, ing); err != nil {
return fmt.Errorf("failed to add finalizer: %w", err)
if err := r.Update(ctx, ing); err != nil {
return false, fmt.Errorf("failed to add finalizer: %w", err)
}
a.mu.Lock()
a.managedIngresses.Add(ing.UID)
gaugePGIngressResources.Set(int64(a.managedIngresses.Len()))
a.mu.Unlock()
r.mu.Lock()
r.managedIngresses.Add(ing.UID)
gaugePGIngressResources.Set(int64(r.managedIngresses.Len()))
r.mu.Unlock()
}
// 1. Ensure that if Ingress' hostname has changed, any VIPService resources corresponding to the old hostname
// are cleaned up.
// In practice, this function will ensure that any VIPServices that are associated with the provided ProxyGroup
// and no longer owned by an Ingress are cleaned up. This is fine- it is not expensive and ensures that in edge
// cases (a single update changed both hostname and removed ProxyGroup annotation) the VIPService is more likely
// to be (eventually) removed.
if err := a.maybeCleanupProxyGroup(ctx, pgName, logger); err != nil {
return fmt.Errorf("failed to cleanup VIPService resources for ProxyGroup: %w", err)
}
// 2. Ensure that there isn't a VIPService with the same hostname already created and not owned by this Ingress.
// TODO(irbekrm): perhaps in future we could have record names being stored on VIPServices. I am not certain if
// there might not be edge cases (custom domains, etc?) where attempting to determine the DNS name of the
// VIPService in this way won't be incorrect.
tcd, err := a.tailnetCertDomain(ctx)
// 1. Ensure that if Ingress' hostname has changed, any VIPService
// resources corresponding to the old hostname are cleaned up.
// In practice, this function will ensure that any VIPServices that are
// associated with the provided ProxyGroup and no longer owned by an
// Ingress are cleaned up. This is fine- it is not expensive and ensures
// that in edge cases (a single update changed both hostname and removed
// ProxyGroup annotation) the VIPService is more likely to be
// (eventually) removed.
svcsChanged, err = r.maybeCleanupProxyGroup(ctx, pgName, logger)
if err != nil {
return fmt.Errorf("error determining DNS name base: %w", err)
return false, fmt.Errorf("failed to cleanup VIPService resources for ProxyGroup: %w", err)
}
// 2. Ensure that there isn't a VIPService with the same hostname
// already created and not owned by this Ingress.
// TODO(irbekrm): perhaps in future we could have record names being
// stored on VIPServices. I am not certain if there might not be edge
// cases (custom domains, etc?) where attempting to determine the DNS
// name of the VIPService in this way won't be incorrect.
tcd, err := r.tailnetCertDomain(ctx)
if err != nil {
return false, fmt.Errorf("error determining DNS name base: %w", err)
}
dnsName := hostname + "." + tcd
serviceName := tailcfg.ServiceName("svc:" + hostname)
existingVIPSvc, err := a.tsClient.GetVIPService(ctx, serviceName)
// TODO(irbekrm): here and when creating the VIPService, verify if the error is not terminal (and therefore
// should not be reconciled). For example, if the hostname is already a hostname of a Tailscale node, the GET
// here will fail.
existingVIPSvc, err := r.tsClient.GetVIPService(ctx, serviceName)
// TODO(irbekrm): here and when creating the VIPService, verify if the
// error is not terminal (and therefore should not be reconciled). For
// example, if the hostname is already a hostname of a Tailscale node,
// the GET here will fail.
if err != nil {
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status != http.StatusNotFound {
return fmt.Errorf("error getting VIPService %q: %w", hostname, err)
return false, fmt.Errorf("error getting VIPService %q: %w", hostname, err)
}
}
if existingVIPSvc != nil && !isVIPServiceForIngress(existingVIPSvc, ing) {
logger.Infof("VIPService %q for MagicDNS name %q already exists, but is not owned by this Ingress. Please delete it manually and recreate this Ingress to proceed or create an Ingress for a different MagicDNS name", hostname, dnsName)
a.recorder.Event(ing, corev1.EventTypeWarning, "ConflictingVIPServiceExists", fmt.Sprintf("VIPService %q for MagicDNS name %q already exists, but is not owned by this Ingress. Please delete it manually to proceed or create an Ingress for a different MagicDNS name", hostname, dnsName))
return nil
// Generate the VIPService comment for new or existing VIPService. This
// checks and ensures that VIPService's owner references are updated for
// this Ingress and errors if that is not possible (i.e. because it
// appears that the VIPService has been created by a non-operator
// actor).
svcComment, err := r.ownerRefsComment(existingVIPSvc)
if err != nil {
const instr = "To proceed, you can either manually delete the existing VIPService or choose a different MagicDNS name at `.spec.tls.hosts[0] in the Ingress definition"
msg := fmt.Sprintf("error ensuring ownership of VIPService %s: %v. %s", hostname, err, instr)
logger.Warn(msg)
r.recorder.Event(ing, corev1.EventTypeWarning, "InvalidVIPService", msg)
return false, nil
}
// 3. Ensure that the serve config for the ProxyGroup contains the VIPService
cm, cfg, err := a.proxyGroupServeConfig(ctx, pgName)
// 3. Ensure that the serve config for the ProxyGroup contains the VIPService.
cm, cfg, err := r.proxyGroupServeConfig(ctx, pgName)
if err != nil {
return fmt.Errorf("error getting Ingress serve config: %w", err)
return false, fmt.Errorf("error getting Ingress serve config: %w", err)
}
if cm == nil {
logger.Infof("no Ingress serve config ConfigMap found, unable to update serve config. Ensure that ProxyGroup is healthy.")
return nil
return svcsChanged, nil
}
ep := ipn.HostPort(fmt.Sprintf("%s:443", dnsName))
handlers, err := handlersForIngress(ctx, ing, a.Client, a.recorder, dnsName, logger)
handlers, err := handlersForIngress(ctx, ing, r.Client, r.recorder, dnsName, logger)
if err != nil {
return fmt.Errorf("failed to get handlers for Ingress: %w", err)
return false, fmt.Errorf("failed to get handlers for Ingress: %w", err)
}
ingCfg := &ipn.ServiceConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
@@ -250,16 +291,16 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
mak.Set(&cfg.Services, serviceName, ingCfg)
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("error marshaling serve config: %w", err)
return false, fmt.Errorf("error marshaling serve config: %w", err)
}
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
if err := a.Update(ctx, cm); err != nil {
return fmt.Errorf("error updating serve config: %w", err)
if err := r.Update(ctx, cm); err != nil {
return false, fmt.Errorf("error updating serve config: %w", err)
}
}
// 4. Ensure that the VIPService exists and is up to date.
tags := a.defaultTags
tags := r.defaultTags
if tstr, ok := ing.Annotations[AnnotationTags]; ok {
tags = strings.Split(tstr, ",")
}
@@ -273,22 +314,33 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
Name: serviceName,
Tags: tags,
Ports: vipPorts,
Comment: fmt.Sprintf(VIPSvcOwnerRef, ing.UID),
Comment: svcComment,
}
if existingVIPSvc != nil {
vipSvc.Addrs = existingVIPSvc.Addrs
}
// TODO(irbekrm): right now if two Ingress resources attempt to apply different VIPService configs (different
// tags, or HTTP endpoint settings) we can end up reconciling those in a loop. We should detect when an Ingress
// with the same generation number has been reconciled ~more than N times and stop attempting to apply updates.
if existingVIPSvc == nil ||
!reflect.DeepEqual(vipSvc.Tags, existingVIPSvc.Tags) ||
!reflect.DeepEqual(vipSvc.Ports, existingVIPSvc.Ports) {
!reflect.DeepEqual(vipSvc.Ports, existingVIPSvc.Ports) ||
!strings.EqualFold(vipSvc.Comment, existingVIPSvc.Comment) {
logger.Infof("Ensuring VIPService %q exists and is up to date", hostname)
if err := a.tsClient.CreateOrUpdateVIPService(ctx, vipSvc); err != nil {
logger.Infof("error creating VIPService: %v", err)
return fmt.Errorf("error creating VIPService: %w", err)
if err := r.tsClient.CreateOrUpdateVIPService(ctx, vipSvc); err != nil {
return false, fmt.Errorf("error creating VIPService: %w", err)
}
}
// 5. Update Ingress status
// 5. Update tailscaled's AdvertiseServices config, which should add the VIPService
// IPs to the ProxyGroup Pods' AllowedIPs in the next netmap update if approved.
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, pg.Name, serviceName, true, logger); err != nil {
return false, fmt.Errorf("failed to update tailscaled config: %w", err)
}
// TODO(irbekrm): check that the replicas are ready to route traffic for the VIPService before updating Ingress
// status.
// 6. Update Ingress status
oldStatus := ing.Status.DeepCopy()
ports := []networkingv1.IngressPortStatus{
{
@@ -309,30 +361,29 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
},
}
if apiequality.Semantic.DeepEqual(oldStatus, ing.Status) {
return nil
return svcsChanged, nil
}
if err := a.Status().Update(ctx, ing); err != nil {
return fmt.Errorf("failed to update Ingress status: %w", err)
if err := r.Status().Update(ctx, ing); err != nil {
return false, fmt.Errorf("failed to update Ingress status: %w", err)
}
return nil
return svcsChanged, nil
}
// maybeCleanupProxyGroup ensures that if an Ingress hostname has changed, any VIPService resources created for the
// Ingress' ProxyGroup corresponding to the old hostname are cleaned up. A run of this function will ensure that any
// VIPServices that are associated with the provided ProxyGroup and no longer owned by an Ingress are cleaned up.
func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyGroupName string, logger *zap.SugaredLogger) error {
// VIPServices that are associated with the provided ProxyGroup and no longer managed this operator's instance are deleted, if not owned by other operator instances, else the owner reference is cleaned up.
// Returns true if the operation resulted in existing VIPService updates (owner reference removal).
func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyGroupName string, logger *zap.SugaredLogger) (svcsChanged bool, err error) {
// Get serve config for the ProxyGroup
cm, cfg, err := a.proxyGroupServeConfig(ctx, proxyGroupName)
cm, cfg, err := r.proxyGroupServeConfig(ctx, proxyGroupName)
if err != nil {
return fmt.Errorf("getting serve config: %w", err)
return false, fmt.Errorf("getting serve config: %w", err)
}
if cfg == nil {
return nil // ProxyGroup does not have any VIPServices
return false, nil // ProxyGroup does not have any VIPServices
}
ingList := &networkingv1.IngressList{}
if err := a.List(ctx, ingList); err != nil {
return fmt.Errorf("listing Ingresses: %w", err)
if err := r.List(ctx, ingList); err != nil {
return false, fmt.Errorf("listing Ingresses: %w", err)
}
serveConfigChanged := false
// For each VIPService in serve config...
@@ -349,25 +400,24 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
if !found {
logger.Infof("VIPService %q is not owned by any Ingress, cleaning up", vipServiceName)
svc, err := a.getVIPService(ctx, vipServiceName, logger)
if err != nil {
errResp := &tailscale.ErrResponse{}
if errors.As(err, &errResp) && errResp.Status == http.StatusNotFound {
delete(cfg.Services, vipServiceName)
serveConfigChanged = true
continue
}
return err
}
if isVIPServiceForAnyIngress(svc) {
// Delete the VIPService from control if necessary.
svc, _ := r.tsClient.GetVIPService(ctx, vipServiceName)
if svc != nil && isVIPServiceForAnyIngress(svc) {
logger.Infof("cleaning up orphaned VIPService %q", vipServiceName)
if err := a.tsClient.DeleteVIPService(ctx, vipServiceName); err != nil {
svcsChanged, err = r.cleanupVIPService(ctx, vipServiceName, logger)
if err != nil {
errResp := &tailscale.ErrResponse{}
if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound {
return fmt.Errorf("deleting VIPService %q: %w", vipServiceName, err)
return false, fmt.Errorf("deleting VIPService %q: %w", vipServiceName, err)
}
}
}
// Make sure the VIPService is not advertised in tailscaled or serve config.
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, proxyGroupName, vipServiceName, false, logger); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
delete(cfg.Services, vipServiceName)
serveConfigChanged = true
}
@@ -376,74 +426,81 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
if serveConfigChanged {
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling serve config: %w", err)
return false, fmt.Errorf("marshaling serve config: %w", err)
}
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
if err := a.Update(ctx, cm); err != nil {
return fmt.Errorf("updating serve config: %w", err)
if err := r.Update(ctx, cm); err != nil {
return false, fmt.Errorf("updating serve config: %w", err)
}
}
return nil
return svcsChanged, nil
}
// maybeCleanup ensures that any resources, such as a VIPService created for this Ingress, are cleaned up when the
// Ingress is being deleted or is unexposed.
func (a *IngressPGReconciler) maybeCleanup(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
// Ingress is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup- the VIPService is only
// deleted if it does not contain any other owner references. If it does the cleanup only removes the owner reference
// corresponding to this Ingress.
func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) (svcChanged bool, err error) {
logger.Debugf("Ensuring any resources for Ingress are cleaned up")
ix := slices.Index(ing.Finalizers, FinalizerNamePG)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
a.mu.Lock()
defer a.mu.Unlock()
a.managedIngresses.Remove(ing.UID)
gaugePGIngressResources.Set(int64(a.managedIngresses.Len()))
return nil
}
// 1. Check if there is a VIPService created for this Ingress.
pg := ing.Annotations[AnnotationProxyGroup]
cm, cfg, err := a.proxyGroupServeConfig(ctx, pg)
if err != nil {
return fmt.Errorf("error getting ProxyGroup serve config: %w", err)
}
serviceName := tailcfg.ServiceName("svc:" + hostname)
// VIPService is always first added to serve config and only then created in the Tailscale API, so if it is not
// found in the serve config, we can assume that there is no VIPService. TODO(irbekrm): once we have ingress
// ProxyGroup, we will probably add currently exposed VIPServices to its status. At that point, we can use the
// status rather than checking the serve config each time.
if cfg == nil || cfg.Services == nil || cfg.Services[serviceName] == nil {
return nil
return false, nil
}
logger.Infof("Ensuring that VIPService %q configuration is cleaned up", hostname)
// 2. Delete the VIPService.
if err := a.deleteVIPServiceIfExists(ctx, serviceName, ing, logger); err != nil {
return fmt.Errorf("error deleting VIPService: %w", err)
// Ensure that if cleanup succeeded Ingress finalizers are removed.
defer func() {
if err != nil {
return
}
if e := r.deleteFinalizer(ctx, ing, logger); err != nil {
err = errors.Join(err, e)
}
}()
// 1. Check if there is a VIPService associated with this Ingress.
pg := ing.Annotations[AnnotationProxyGroup]
cm, cfg, err := r.proxyGroupServeConfig(ctx, pg)
if err != nil {
return false, fmt.Errorf("error getting ProxyGroup serve config: %w", err)
}
serviceName := tailcfg.ServiceName("svc:" + hostname)
// VIPService is always first added to serve config and only then created in the Tailscale API, so if it is not
// found in the serve config, we can assume that there is no VIPService. (If the serve config does not exist at
// all, it is possible that the ProxyGroup has been deleted before cleaning up the Ingress, so carry on with
// cleanup).
if cfg != nil && cfg.Services != nil && cfg.Services[serviceName] == nil {
return false, nil
}
// 3. Remove the VIPService from the serve config for the ProxyGroup.
// 2. Clean up the VIPService resources.
svcChanged, err = r.cleanupVIPService(ctx, serviceName, logger)
if err != nil {
return false, fmt.Errorf("error deleting VIPService: %w", err)
}
if cfg == nil || cfg.Services == nil { // user probably deleted the ProxyGroup
return svcChanged, nil
}
// 3. Unadvertise the VIPService in tailscaled config.
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, pg, serviceName, false, logger); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
// 4. Remove the VIPService from the serve config for the ProxyGroup.
logger.Infof("Removing VIPService %q from serve config for ProxyGroup %q", hostname, pg)
delete(cfg.Services, serviceName)
cfgBytes, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("error marshaling serve config: %w", err)
return false, fmt.Errorf("error marshaling serve config: %w", err)
}
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
if err := a.Update(ctx, cm); err != nil {
return fmt.Errorf("error updating ConfigMap %q: %w", cm.Name, err)
}
if err := a.deleteFinalizer(ctx, ing, logger); err != nil {
return fmt.Errorf("failed to remove finalizer: %w", err)
}
a.mu.Lock()
defer a.mu.Unlock()
a.managedIngresses.Remove(ing.UID)
gaugePGIngressResources.Set(int64(a.managedIngresses.Len()))
return nil
return svcChanged, r.Update(ctx, cm)
}
func (a *IngressPGReconciler) deleteFinalizer(ctx context.Context, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
func (r *HAIngressReconciler) deleteFinalizer(ctx context.Context, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
found := false
ing.Finalizers = slices.DeleteFunc(ing.Finalizers, func(f string) bool {
found = true
@@ -454,9 +511,13 @@ func (a *IngressPGReconciler) deleteFinalizer(ctx context.Context, ing *networki
}
logger.Debug("ensure %q finalizer is removed", FinalizerNamePG)
if err := a.Update(ctx, ing); err != nil {
if err := r.Update(ctx, ing); err != nil {
return fmt.Errorf("failed to remove finalizer %q: %w", FinalizerNamePG, err)
}
r.mu.Lock()
defer r.mu.Unlock()
r.managedIngresses.Remove(ing.UID)
gaugePGIngressResources.Set(int64(r.managedIngresses.Len()))
return nil
}
@@ -464,15 +525,15 @@ func pgIngressCMName(pg string) string {
return fmt.Sprintf("%s-ingress-config", pg)
}
func (a *IngressPGReconciler) proxyGroupServeConfig(ctx context.Context, pg string) (cm *corev1.ConfigMap, cfg *ipn.ServeConfig, err error) {
func (r *HAIngressReconciler) proxyGroupServeConfig(ctx context.Context, pg string) (cm *corev1.ConfigMap, cfg *ipn.ServeConfig, err error) {
name := pgIngressCMName(pg)
cm = &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: a.tsNamespace,
Namespace: r.tsNamespace,
},
}
if err := a.Get(ctx, client.ObjectKeyFromObject(cm), cm); err != nil && !apierrors.IsNotFound(err) {
if err := r.Get(ctx, client.ObjectKeyFromObject(cm), cm); err != nil && !apierrors.IsNotFound(err) {
return nil, nil, fmt.Errorf("error retrieving ingress serve config ConfigMap %s: %v", name, err)
}
if apierrors.IsNotFound(err) {
@@ -492,16 +553,16 @@ type localClient interface {
}
// tailnetCertDomain returns the base domain (TCD) of the current tailnet.
func (a *IngressPGReconciler) tailnetCertDomain(ctx context.Context) (string, error) {
st, err := a.lc.StatusWithoutPeers(ctx)
func (r *HAIngressReconciler) tailnetCertDomain(ctx context.Context) (string, error) {
st, err := r.lc.StatusWithoutPeers(ctx)
if err != nil {
return "", fmt.Errorf("error getting tailscale status: %w", err)
}
return st.CurrentTailnet.MagicDNSSuffix, nil
}
// shouldExpose returns true if the Ingress should be exposed over Tailscale in HA mode (on a ProxyGroup)
func (a *IngressPGReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
// shouldExpose returns true if the Ingress should be exposed over Tailscale in HA mode (on a ProxyGroup).
func (r *HAIngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
isTSIngress := ing != nil &&
ing.Spec.IngressClassName != nil &&
*ing.Spec.IngressClassName == tailscaleIngressClassName
@@ -509,25 +570,6 @@ func (a *IngressPGReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
return isTSIngress && pgAnnot != ""
}
func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.ServiceName, logger *zap.SugaredLogger) (*tailscale.VIPService, error) {
svc, err := a.tsClient.GetVIPService(ctx, name)
if err != nil {
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status != http.StatusNotFound {
logger.Infof("error getting VIPService %q: %v", name, err)
return nil, fmt.Errorf("error getting VIPService %q: %w", name, err)
}
}
return svc, nil
}
func isVIPServiceForIngress(svc *tailscale.VIPService, ing *networkingv1.Ingress) bool {
if svc == nil || ing == nil {
return false
}
return strings.EqualFold(svc.Comment, fmt.Sprintf(VIPSvcOwnerRef, ing.UID))
}
func isVIPServiceForAnyIngress(svc *tailscale.VIPService) bool {
if svc == nil {
return false
@@ -541,7 +583,7 @@ func isVIPServiceForAnyIngress(svc *tailscale.VIPService) bool {
// - The derived hostname is a valid DNS label
// - The referenced ProxyGroup exists and is of type 'ingress'
// - Ingress' TLS block is invalid
func (a *IngressPGReconciler) validateIngress(ing *networkingv1.Ingress, pg *tsapi.ProxyGroup) error {
func (r *HAIngressReconciler) validateIngress(ctx context.Context, ing *networkingv1.Ingress, pg *tsapi.ProxyGroup) error {
var errs []error
// Validate tags if present
@@ -577,26 +619,66 @@ func (a *IngressPGReconciler) validateIngress(ing *networkingv1.Ingress, pg *tsa
errs = append(errs, fmt.Errorf("ProxyGroup %q is not ready", pg.Name))
}
// It is invalid to have multiple Ingress resources for the same VIPService in one cluster.
ingList := &networkingv1.IngressList{}
if err := r.List(ctx, ingList); err != nil {
errs = append(errs, fmt.Errorf("[unexpected] error listing Ingresses: %w", err))
return errors.Join(errs...)
}
for _, i := range ingList.Items {
if r.shouldExpose(&i) && hostnameForIngress(&i) == hostname && i.Name != ing.Name {
errs = append(errs, fmt.Errorf("found duplicate Ingress %q for hostname %q - multiple Ingresses for the same hostname in the same cluster are not allowed", i.Name, hostname))
}
}
return errors.Join(errs...)
}
// deleteVIPServiceIfExists attempts to delete the VIPService if it exists and is owned by the given Ingress.
func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name tailcfg.ServiceName, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
svc, err := a.getVIPService(ctx, name, logger)
// cleanupVIPService deletes any VIPService by the provided name if it is not owned by operator instances other than this one.
// If a VIPService is found, but contains other owner references, only removes this operator's owner reference.
// If a VIPService by the given name is not found or does not contain this operator's owner reference, do nothing.
// It returns true if an existing VIPService was updated to remove owner reference, as well as any error that occurred.
func (r *HAIngressReconciler) cleanupVIPService(ctx context.Context, name tailcfg.ServiceName, logger *zap.SugaredLogger) (updated bool, _ error) {
svc, err := r.tsClient.GetVIPService(ctx, name)
if err != nil {
return fmt.Errorf("error getting VIPService: %w", err)
}
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound {
return false, nil
}
// isVIPServiceForIngress handles nil svc, so we don't need to check it here
if !isVIPServiceForIngress(svc, ing) {
return nil
return false, fmt.Errorf("error getting VIPService: %w", err)
}
if svc == nil {
return false, nil
}
c, err := parseComment(svc)
if err != nil {
return false, fmt.Errorf("error parsing VIPService comment")
}
if c == nil || len(c.OwnerRefs) == 0 {
return false, nil
}
// Comparing with the operatorID only means that we will not be able to
// clean up VIPServices in cases where the operator was deleted from the
// cluster before deleting the Ingress. Perhaps the comparison could be
// 'if or.OperatorID === r.operatorID || or.ingressUID == r.ingressUID'.
ix := slices.IndexFunc(c.OwnerRefs, func(or OwnerRef) bool {
return or.OperatorID == r.operatorID
})
if ix == -1 {
return false, nil
}
if len(c.OwnerRefs) == 1 {
logger.Infof("Deleting VIPService %q", name)
return false, r.tsClient.DeleteVIPService(ctx, name)
}
c.OwnerRefs = slices.Delete(c.OwnerRefs, ix, ix+1)
logger.Infof("Deleting VIPService %q", name)
if err = a.tsClient.DeleteVIPService(ctx, name); err != nil {
return fmt.Errorf("error deleting VIPService: %w", err)
json, err := json.Marshal(c)
if err != nil {
return false, fmt.Errorf("error marshalling updated VIPService owner reference: %w", err)
}
return nil
svc.Comment = string(json)
return true, r.tsClient.CreateOrUpdateVIPService(ctx, svc)
}
// isHTTPEndpointEnabled returns true if the Ingress has been configured to expose an HTTP endpoint to tailnet.
@@ -606,3 +688,126 @@ func isHTTPEndpointEnabled(ing *networkingv1.Ingress) bool {
}
return ing.Annotations[annotationHTTPEndpoint] == "enabled"
}
func (a *HAIngressReconciler) maybeUpdateAdvertiseServicesConfig(ctx context.Context, pgName string, serviceName tailcfg.ServiceName, shouldBeAdvertised bool, logger *zap.SugaredLogger) (err error) {
logger.Debugf("Updating ProxyGroup tailscaled configs to advertise service %q: %v", serviceName, shouldBeAdvertised)
// Get all config Secrets for this ProxyGroup.
secrets := &corev1.SecretList{}
if err := a.List(ctx, secrets, client.InNamespace(a.tsNamespace), client.MatchingLabels(pgSecretLabels(pgName, "config"))); err != nil {
return fmt.Errorf("failed to list config Secrets: %w", err)
}
for _, secret := range secrets.Items {
var updated bool
for fileName, confB := range secret.Data {
var conf ipn.ConfigVAlpha
if err := json.Unmarshal(confB, &conf); err != nil {
return fmt.Errorf("error unmarshalling ProxyGroup config: %w", err)
}
// Update the services to advertise if required.
idx := slices.Index(conf.AdvertiseServices, serviceName.String())
isAdvertised := idx >= 0
switch {
case isAdvertised == shouldBeAdvertised:
// Already up to date.
continue
case isAdvertised:
// Needs to be removed.
conf.AdvertiseServices = slices.Delete(conf.AdvertiseServices, idx, idx+1)
case shouldBeAdvertised:
// Needs to be added.
conf.AdvertiseServices = append(conf.AdvertiseServices, serviceName.String())
}
// Update the Secret.
confB, err := json.Marshal(conf)
if err != nil {
return fmt.Errorf("error marshalling ProxyGroup config: %w", err)
}
mak.Set(&secret.Data, fileName, confB)
updated = true
}
if updated {
if err := a.Update(ctx, &secret); err != nil {
return fmt.Errorf("error updating ProxyGroup config Secret: %w", err)
}
}
}
return nil
}
// OwnerRef is an owner reference that uniquely identifies a Tailscale
// Kubernetes operator instance.
type OwnerRef struct {
// OperatorID is the stable ID of the operator's Tailscale device.
OperatorID string `json:"operatorID,omitempty"`
}
// comment is the content of the VIPService.Comment field.
type comment struct {
// OwnerRefs is a list of owner references that identify all operator
// instances that manage this VIPService.
OwnerRefs []OwnerRef `json:"ownerRefs,omitempty"`
}
// ownerRefsComment return VIPService Comment that includes owner reference for this
// operator instance for the provided VIPService. If the VIPService is nil, a
// new comment with owner ref is returned. If the VIPService is not nil, the
// existing comment is returned with the owner reference added, if not already
// present. If the VIPService is not nil, but does not contain a comment we
// return an error as this likely means that the VIPService was created by
// somthing other than a Tailscale Kubernetes operator.
func (r *HAIngressReconciler) ownerRefsComment(svc *tailscale.VIPService) (string, error) {
ref := OwnerRef{
OperatorID: r.operatorID,
}
if svc == nil {
c := &comment{OwnerRefs: []OwnerRef{ref}}
json, err := json.Marshal(c)
if err != nil {
return "", fmt.Errorf("[unexpected] unable to marshal VIPService comment contents: %w, please report this", err)
}
return string(json), nil
}
c, err := parseComment(svc)
if err != nil {
return "", fmt.Errorf("error parsing existing VIPService comment: %w", err)
}
if c == nil || len(c.OwnerRefs) == 0 {
return "", fmt.Errorf("VIPService %s exists, but does not contain Comment field with owner references- not proceeding as this is likely a resource created by something other than a Tailscale Kubernetes Operator", svc.Name)
}
if slices.Contains(c.OwnerRefs, ref) { // up to date
return svc.Comment, nil
}
c.OwnerRefs = append(c.OwnerRefs, ref)
json, err := json.Marshal(c)
if err != nil {
return "", fmt.Errorf("error marshalling updated owner references: %w", err)
}
return string(json), nil
}
// parseComment returns VIPService comment or nil if none found or not matching the expected format.
func parseComment(vipSvc *tailscale.VIPService) (*comment, error) {
if vipSvc.Comment == "" {
return nil, nil
}
c := &comment{}
if err := json.Unmarshal([]byte(vipSvc.Comment), c); err != nil {
return nil, fmt.Errorf("error parsing VIPService Comment field %q: %w", vipSvc.Comment, err)
}
return c, nil
}
// requeueInterval returns a time duration between 5 and 10 minutes, which is
// the period of time after which an HA Ingress, whose VIPService has been newly
// created or changed, needs to be requeued. This is to protect against
// VIPService owner references being overwritten as a result of concurrent
// updates during multi-clutster Ingress create/update operations.
func requeueInterval() time.Duration {
return time.Duration(rand.N(5)+5) * time.Minute
}

View File

@@ -8,6 +8,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"maps"
"reflect"
"testing"
@@ -22,8 +23,10 @@ import (
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"tailscale.com/internal/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tailcfg"
"tailscale.com/types/ptr"
@@ -63,6 +66,7 @@ func TestIngressPGReconciler(t *testing.T) {
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
ing.Annotations["tailscale.com/tags"] = "tag:custom,tag:test"
@@ -122,6 +126,8 @@ func TestIngressPGReconciler(t *testing.T) {
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
verifyTailscaledConfig(t, fc, []string{"svc:my-svc", "svc:my-other-svc"})
// Delete second Ingress
if err := fc.Delete(context.Background(), ing2); err != nil {
t.Fatalf("deleting second Ingress: %v", err)
@@ -151,6 +157,8 @@ func TestIngressPGReconciler(t *testing.T) {
t.Error("second Ingress service config was not cleaned up")
}
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
// Delete the first Ingress and verify cleanup
if err := fc.Delete(context.Background(), ing); err != nil {
t.Fatalf("deleting Ingress: %v", err)
@@ -175,6 +183,7 @@ func TestIngressPGReconciler(t *testing.T) {
if len(cfg.Services) > 0 {
t.Error("serve config not cleaned up")
}
verifyTailscaledConfig(t, fc, nil)
}
func TestValidateIngress(t *testing.T) {
@@ -182,6 +191,15 @@ func TestValidateIngress(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
Annotations: map[string]string{
AnnotationProxyGroup: "test-pg",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"test"}},
},
},
}
@@ -205,10 +223,11 @@ func TestValidateIngress(t *testing.T) {
}
tests := []struct {
name string
ing *networkingv1.Ingress
pg *tsapi.ProxyGroup
wantErr string
name string
ing *networkingv1.Ingress
pg *tsapi.ProxyGroup
existingIngs []networkingv1.Ingress
wantErr string
}{
{
name: "valid_ingress_with_hostname",
@@ -298,12 +317,38 @@ func TestValidateIngress(t *testing.T) {
},
wantErr: "ProxyGroup \"test-pg\" is not ready",
},
{
name: "duplicate_hostname",
ing: baseIngress,
pg: readyProxyGroup,
existingIngs: []networkingv1.Ingress{{
ObjectMeta: metav1.ObjectMeta{
Name: "existing-ingress",
Namespace: "default",
Annotations: map[string]string{
AnnotationProxyGroup: "test-pg",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"test"}},
},
},
}},
wantErr: `found duplicate Ingress "existing-ingress" for hostname "test" - multiple Ingresses for the same hostname in the same cluster are not allowed`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &IngressPGReconciler{}
err := r.validateIngress(tt.ing, tt.pg)
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(tt.ing).
WithLists(&networkingv1.IngressList{Items: tt.existingIngs}).
Build()
r := &HAIngressReconciler{Client: fc}
err := r.validateIngress(context.Background(), tt.ing, tt.pg)
if (err == nil && tt.wantErr != "") || (err != nil && err.Error() != tt.wantErr) {
t.Errorf("validateIngress() error = %v, wantErr %v", err, tt.wantErr)
}
@@ -464,8 +509,28 @@ func verifyServeConfig(t *testing.T, fc client.Client, serviceName string, wantH
}
}
func setupIngressTest(t *testing.T) (*IngressPGReconciler, client.Client, *fakeTSClient) {
t.Helper()
func verifyTailscaledConfig(t *testing.T, fc client.Client, expectedServices []string) {
var expected string
if expectedServices != nil {
expectedServicesJSON, err := json.Marshal(expectedServices)
if err != nil {
t.Fatalf("marshaling expected services: %v", err)
}
expected = fmt.Sprintf(`,"AdvertiseServices":%s`, expectedServicesJSON)
}
expectEqual(t, fc, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName("test-pg", 0),
Namespace: "operator-ns",
Labels: pgSecretLabels("test-pg", "config"),
},
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): []byte(fmt.Sprintf(`{"Version":""%s}`, expected)),
},
})
}
func setupIngressTest(t *testing.T) (*HAIngressReconciler, client.Client, *fakeTSClient) {
tsIngressClass := &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
@@ -494,9 +559,21 @@ func setupIngressTest(t *testing.T) (*IngressPGReconciler, client.Client, *fakeT
},
}
// Pre-create a config Secret for the ProxyGroup
pgCfgSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName("test-pg", 0),
Namespace: "operator-ns",
Labels: pgSecretLabels("test-pg", "config"),
},
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): []byte("{}"),
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pg, pgConfigMap, tsIngressClass).
WithObjects(pg, pgCfgSecret, pgConfigMap, tsIngressClass).
WithStatusSubresource(pg).
Build()
@@ -511,9 +588,9 @@ func setupIngressTest(t *testing.T) (*IngressPGReconciler, client.Client, *fakeT
if err := fc.Status().Update(context.Background(), pg); err != nil {
t.Fatal(err)
}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
@@ -527,12 +604,12 @@ func setupIngressTest(t *testing.T) (*IngressPGReconciler, client.Client, *fakeT
},
}
ingPGR := &IngressPGReconciler{
ingPGR := &HAIngressReconciler{
Client: fc,
tsClient: ft,
tsnetServer: fakeTsnetServer,
defaultTags: []string{"tag:k8s"},
tsNamespace: "operator-ns",
tsnetServer: fakeTsnetServer,
logger: zl.Sugar(),
recorder: record.NewFakeRecorder(10),
lc: lc,
@@ -540,3 +617,87 @@ func setupIngressTest(t *testing.T) (*IngressPGReconciler, client.Client, *fakeT
return ingPGR, fc, ft
}
func TestIngressPGReconciler_MultiCluster(t *testing.T) {
ingPGR, fc, ft := setupIngressTest(t)
ingPGR.operatorID = "operator-1"
// Create initial Ingress
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/proxy-group": "test-pg",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"my-svc"}},
},
},
}
mustCreate(t, fc, ing)
// Simulate existing VIPService from another cluster
existingVIPSvc := &tailscale.VIPService{
Name: "svc:my-svc",
Comment: `{"ownerrefs":[{"operatorID":"operator-2"}]}`,
}
ft.vipServices = map[tailcfg.ServiceName]*tailscale.VIPService{
"svc:my-svc": existingVIPSvc,
}
// Verify reconciliation adds our operator reference
expectReconciled(t, ingPGR, "default", "test-ingress")
vipSvc, err := ft.GetVIPService(context.Background(), "svc:my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}
if vipSvc == nil {
t.Fatal("VIPService not found")
}
c := &comment{}
if err := json.Unmarshal([]byte(vipSvc.Comment), c); err != nil {
t.Fatalf("parsing comment: %v", err)
}
wantOwnerRefs := []OwnerRef{
{OperatorID: "operator-2"},
{OperatorID: "operator-1"},
}
if !reflect.DeepEqual(c.OwnerRefs, wantOwnerRefs) {
t.Errorf("incorrect owner refs\ngot: %+v\nwant: %+v", c.OwnerRefs, wantOwnerRefs)
}
// Delete the Ingress and verify VIPService still exists with one owner ref
if err := fc.Delete(context.Background(), ing); err != nil {
t.Fatalf("deleting Ingress: %v", err)
}
expectRequeue(t, ingPGR, "default", "test-ingress")
vipSvc, err = ft.GetVIPService(context.Background(), "svc:my-svc")
if err != nil {
t.Fatalf("getting VIPService after deletion: %v", err)
}
if vipSvc == nil {
t.Fatal("VIPService was incorrectly deleted")
}
c = &comment{}
if err := json.Unmarshal([]byte(vipSvc.Comment), c); err != nil {
t.Fatalf("parsing comment after deletion: %v", err)
}
wantOwnerRefs = []OwnerRef{
{OperatorID: "operator-2"},
}
if !reflect.DeepEqual(c.OwnerRefs, wantOwnerRefs) {
t.Errorf("incorrect owner refs after deletion\ngot: %+v\nwant: %+v", c.OwnerRefs, wantOwnerRefs)
}
}

View File

@@ -73,6 +73,7 @@ func (a *IngressReconciler) Reconcile(ctx context.Context, req reconcile.Request
return reconcile.Result{}, fmt.Errorf("failed to get ing: %w", err)
}
if !ing.DeletionTimestamp.IsZero() || !a.shouldExpose(ing) {
// TODO(irbekrm): this message is confusing if the Ingress is an HA Ingress
logger.Debugf("ingress is being deleted or should not be exposed, cleaning up")
return reconcile.Result{}, a.maybeCleanup(ctx, logger, ing)
}

View File

@@ -9,6 +9,7 @@ package main
import (
"context"
"fmt"
"net/http"
"os"
"regexp"
@@ -39,6 +40,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/client/local"
"tailscale.com/client/tailscale"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
@@ -335,6 +337,10 @@ func runReconcilers(opts reconcilerOpts) {
if err != nil {
startlog.Fatalf("could not get local client: %v", err)
}
id, err := id(context.Background(), lc)
if err != nil {
startlog.Fatalf("error determining stable ID of the operator's Tailscale device: %v", err)
}
ingressProxyGroupFilter := handler.EnqueueRequestsFromMapFunc(ingressesFromIngressProxyGroup(mgr.GetClient(), opts.log))
err = builder.
ControllerManagedBy(mgr).
@@ -342,7 +348,7 @@ func runReconcilers(opts reconcilerOpts) {
Named("ingress-pg-reconciler").
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog))).
Watches(&tsapi.ProxyGroup{}, ingressProxyGroupFilter).
Complete(&IngressPGReconciler{
Complete(&HAIngressReconciler{
recorder: eventRecorder,
tsClient: opts.tsClient,
tsnetServer: opts.tsServer,
@@ -350,6 +356,7 @@ func runReconcilers(opts reconcilerOpts) {
Client: mgr.GetClient(),
logger: opts.log.Named("ingress-pg-reconciler"),
lc: lc,
operatorID: id,
tsNamespace: opts.tailscaleNamespace,
})
if err != nil {
@@ -1262,3 +1269,14 @@ func hasProxyGroupAnnotation(obj client.Object) bool {
ing := obj.(*networkingv1.Ingress)
return ing.Annotations[AnnotationProxyGroup] != ""
}
func id(ctx context.Context, lc *local.Client) (string, error) {
st, err := lc.StatusWithoutPeers(ctx)
if err != nil {
return "", fmt.Errorf("error getting tailscale status: %w", err)
}
if st.Self == nil {
return "", fmt.Errorf("unexpected: device's status does not contain node's metadata")
}
return string(st.Self.ID), nil
}

View File

@@ -452,7 +452,7 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(ctx context.Context, p
for i := range pgReplicas(pg) {
cfgSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-%d-config", pg.Name, i),
Name: pgConfigSecretName(pg.Name, i),
Namespace: r.tsNamespace,
Labels: pgSecretLabels(pg.Name, "config"),
OwnerReferences: pgOwnerReference(pg),
@@ -596,10 +596,35 @@ func pgTailscaledConfig(pg *tsapi.ProxyGroup, class *tsapi.ProxyClass, idx int32
conf.AuthKey = key
}
capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha)
// AdvertiseServices config is set by ingress-pg-reconciler, so make sure we
// don't overwrite it here.
if err := copyAdvertiseServicesConfig(conf, oldSecret, 106); err != nil {
return nil, err
}
capVerConfigs[106] = *conf
return capVerConfigs, nil
}
func copyAdvertiseServicesConfig(conf *ipn.ConfigVAlpha, oldSecret *corev1.Secret, capVer tailcfg.CapabilityVersion) error {
if oldSecret == nil {
return nil
}
oldConfB := oldSecret.Data[tsoperator.TailscaledConfigFileName(capVer)]
if len(oldConfB) == 0 {
return nil
}
var oldConf ipn.ConfigVAlpha
if err := json.Unmarshal(oldConfB, &oldConf); err != nil {
return fmt.Errorf("error unmarshalling existing config: %w", err)
}
conf.AdvertiseServices = oldConf.AdvertiseServices
return nil
}
func (r *ProxyGroupReconciler) validate(_ *tsapi.ProxyGroup) error {
return nil
}

View File

@@ -73,7 +73,7 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
Name: fmt.Sprintf("tailscaledconfig-%d", i),
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: fmt.Sprintf("%s-%d-config", pg.Name, i),
SecretName: pgConfigSecretName(pg.Name, i),
},
},
})
@@ -236,8 +236,8 @@ func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
ResourceNames: func() (secrets []string) {
for i := range pgReplicas(pg) {
secrets = append(secrets,
fmt.Sprintf("%s-%d-config", pg.Name, i), // Config with auth key.
fmt.Sprintf("%s-%d", pg.Name, i), // State.
pgConfigSecretName(pg.Name, i), // Config with auth key.
fmt.Sprintf("%s-%d", pg.Name, i), // State.
)
}
return secrets
@@ -349,6 +349,10 @@ func pgReplicas(pg *tsapi.ProxyGroup) int32 {
return 2
}
func pgConfigSecretName(pgName string, i int32) string {
return fmt.Sprintf("%s-%d-config", pgName, i)
}
func pgEgressCMName(pg string) string {
return fmt.Sprintf("%s-egress-config", pg)
}

View File

@@ -24,6 +24,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
@@ -446,6 +447,79 @@ func TestProxyGroupTypes(t *testing.T) {
})
}
func TestIngressAdvertiseServicesConfigPreserved(t *testing.T) {
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
Build()
reconciler := &ProxyGroupReconciler{
tsNamespace: tsNamespace,
proxyImage: testProxyImage,
Client: fc,
l: zap.Must(zap.NewDevelopment()).Sugar(),
tsClient: &fakeTSClient{},
clock: tstest.NewClock(tstest.ClockOpts{}),
}
existingServices := []string{"svc1", "svc2"}
existingConfigBytes, err := json.Marshal(ipn.ConfigVAlpha{
AdvertiseServices: existingServices,
Version: "should-get-overwritten",
})
if err != nil {
t.Fatal(err)
}
const pgName = "test-ingress"
mustCreate(t, fc, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName(pgName, 0),
Namespace: tsNamespace,
},
// Write directly to Data because the fake client doesn't copy the write-only
// StringData field across to Data for us.
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): existingConfigBytes,
},
})
mustCreate(t, fc, &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: pgName,
UID: "test-ingress-uid",
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
Replicas: ptr.To[int32](1),
},
})
expectReconciled(t, reconciler, "", pgName)
expectedConfigBytes, err := json.Marshal(ipn.ConfigVAlpha{
// Preserved.
AdvertiseServices: existingServices,
// Everything else got updated in the reconcile:
Version: "alpha0",
AcceptDNS: "false",
AcceptRoutes: "false",
Locked: "false",
Hostname: ptr.To(fmt.Sprintf("%s-%d", pgName, 0)),
})
if err != nil {
t.Fatal(err)
}
expectEqual(t, fc, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName(pgName, 0),
Namespace: tsNamespace,
ResourceVersion: "2",
},
StringData: map[string]string{
tsoperator.TailscaledConfigFileName(106): string(expectedConfigBytes),
},
}, omitSecretData)
}
func verifyProxyGroupCounts(t *testing.T, r *ProxyGroupReconciler, wantIngress, wantEgress int) {
t.Helper()
if r.ingressProxyGroups.Len() != wantIngress {
@@ -501,7 +575,7 @@ func expectProxyGroupResources(t *testing.T, fc client.WithWatch, pg *tsapi.Prox
for i := range pgReplicas(pg) {
expectedSecrets = append(expectedSecrets,
fmt.Sprintf("%s-%d", pg.Name, i),
fmt.Sprintf("%s-%d-config", pg.Name, i),
pgConfigSecretName(pg.Name, i),
)
}
}
@@ -546,3 +620,11 @@ func addNodeIDToStateSecrets(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyG
})
}
}
// The operator mostly writes to StringData and reads from Data, but the fake
// client doesn't copy StringData across to Data on write. When comparing actual
// vs expected Secrets, use this function to only check what the operator writes
// to StringData.
func omitSecretData(secret *corev1.Secret) {
secret.Data = nil
}

View File

@@ -41,6 +41,8 @@ import (
"tailscale.com/wgengine/netstack"
)
var ErrNoIPsAvailable = errors.New("no IPs available")
func main() {
hostinfo.SetApp("natc")
if !envknob.UseWIPCode() {
@@ -277,14 +279,14 @@ func (c *connector) handleDNS(pc net.PacketConn, buf []byte, remoteAddr *net.UDP
defer cancel()
who, err := c.lc.WhoIs(ctx, remoteAddr.String())
if err != nil {
log.Printf("HandleDNS: WhoIs failed: %v\n", err)
log.Printf("HandleDNS(remote=%s): WhoIs failed: %v\n", remoteAddr.String(), err)
return
}
var msg dnsmessage.Message
err = msg.Unpack(buf)
if err != nil {
log.Printf("HandleDNS: dnsmessage unpack failed: %v\n ", err)
log.Printf("HandleDNS(remote=%s): dnsmessage unpack failed: %v\n", remoteAddr.String(), err)
return
}
@@ -297,19 +299,19 @@ func (c *connector) handleDNS(pc net.PacketConn, buf []byte, remoteAddr *net.UDP
case dnsmessage.TypeAAAA, dnsmessage.TypeA:
dstAddrs, err := lookupDestinationIP(q.Name.String())
if err != nil {
log.Printf("HandleDNS: lookup destination failed: %v\n ", err)
log.Printf("HandleDNS(remote=%s): lookup destination failed: %v\n", remoteAddr.String(), err)
return
}
if c.ignoreDestination(dstAddrs) {
bs, err := dnsResponse(&msg, dstAddrs)
// TODO (fran): treat as SERVFAIL
if err != nil {
log.Printf("HandleDNS: generate ignore response failed: %v\n", err)
log.Printf("HandleDNS(remote=%s): generate ignore response failed: %v\n", remoteAddr.String(), err)
return
}
_, err = pc.WriteTo(bs, remoteAddr)
if err != nil {
log.Printf("HandleDNS: write failed: %v\n", err)
log.Printf("HandleDNS(remote=%s): write failed: %v\n", remoteAddr.String(), err)
}
return
}
@@ -322,7 +324,7 @@ func (c *connector) handleDNS(pc net.PacketConn, buf []byte, remoteAddr *net.UDP
resp, err := c.generateDNSResponse(&msg, who.Node.ID)
// TODO (fran): treat as SERVFAIL
if err != nil {
log.Printf("HandleDNS: connector handling failed: %v\n", err)
log.Printf("HandleDNS(remote=%s): connector handling failed: %v\n", remoteAddr.String(), err)
return
}
// TODO (fran): treat as NXDOMAIN
@@ -332,7 +334,7 @@ func (c *connector) handleDNS(pc net.PacketConn, buf []byte, remoteAddr *net.UDP
// This connector handled the DNS request
_, err = pc.WriteTo(resp, remoteAddr)
if err != nil {
log.Printf("HandleDNS: write failed: %v\n", err)
log.Printf("HandleDNS(remote=%s): write failed: %v\n", remoteAddr.String(), err)
}
}
@@ -529,6 +531,9 @@ func (ps *perPeerState) ipForDomain(domain string) ([]netip.Addr, error) {
return addrs, nil
}
addrs := ps.assignAddrsLocked(domain)
if addrs == nil {
return nil, ErrNoIPsAvailable
}
return addrs, nil
}
@@ -575,6 +580,9 @@ func (ps *perPeerState) assignAddrsLocked(domain string) []netip.Addr {
ps.addrToDomain = &bart.Table[string]{}
}
v4 := ps.unusedIPv4Locked()
if !v4.IsValid() {
return nil
}
as16 := ps.c.v6ULA.Addr().As16()
as4 := v4.As4()
copy(as16[12:], as4[:])

View File

@@ -271,6 +271,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/auditlog from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/desktop from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+

View File

@@ -141,7 +141,7 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
}
outcome := goOutput.Action
if outcome == "build-fail" {
outcome = "FAIL"
outcome = "fail"
}
pkgTests[""].logs.WriteString(goOutput.Output)
ch <- &testAttempt{
@@ -152,7 +152,15 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
logs: pkgTests[""].logs,
pkgFinished: true,
}
case "output":
// Capture all output from the package except for the final
// "FAIL tailscale.io/control 0.684s" line, as
// printPkgOutcome will output a similar line
if !strings.HasPrefix(goOutput.Output, fmt.Sprintf("FAIL\t%s\t", goOutput.Package)) {
pkgTests[""].logs.WriteString(goOutput.Output)
}
}
continue
}
testName := goOutput.Test
@@ -251,6 +259,7 @@ func main() {
fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\nflakytest failures JSON: %s\n\n", thisRun.attempt, j)
}
fatalFailures := make(map[string]struct{}) // pkg.Test key
toRetry := make(map[string][]*testAttempt) // pkg -> tests to retry
for _, pt := range thisRun.tests {
ch := make(chan *testAttempt)
@@ -276,7 +285,11 @@ func main() {
// when a package times out.
failed = true
}
os.Stdout.ReadFrom(&tr.logs)
if testingVerbose || tr.outcome == "fail" {
// Output package-level output which is where e.g.
// panics outside tests will be printed
io.Copy(os.Stdout, &tr.logs)
}
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt, tr.end.Sub(tr.start))
continue
}
@@ -289,11 +302,24 @@ func main() {
if tr.isMarkedFlaky {
toRetry[tr.pkg] = append(toRetry[tr.pkg], tr)
} else {
fatalFailures[tr.pkg+"."+tr.testName] = struct{}{}
failed = true
}
}
if failed {
fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
// Print the list of non-flakytest failures.
// We will later analyze the retried GitHub Action runs to see
// if non-flakytest failures succeeded upon retry. This will
// highlight tests which are flaky but not yet flagged as such.
if len(fatalFailures) > 0 {
tests := slicesx.MapKeys(fatalFailures)
sort.Strings(tests)
j, _ := json.Marshal(tests)
fmt.Printf("non-flakytest failures: %s\n", j)
}
fmt.Println()
os.Exit(1)
}

41
cmd/tsidp/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM golang:alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /src
# Copy only go.mod and go.sum first to leverage Docker caching
COPY go.mod go.sum ./
RUN go mod download
# Copy the entire repository
COPY . .
# Build the tsidp binary
RUN go build -o /bin/tsidp ./cmd/tsidp
# Final stage
FROM alpine:latest
# Create necessary directories
RUN mkdir -p /var/lib/tsidp
# Copy binary from builder stage
COPY --from=builder /bin/tsidp /app/tsidp
# Set working directory
WORKDIR /app
# Environment variables
ENV TAILSCALE_USE_WIP_CODE=1 \
TS_HOSTNAME=tsidp \
TS_STATE_DIR=/var/lib/tsidp
# Expose the default port
EXPOSE 443
# Run the application
ENTRYPOINT ["/app/tsidp"]

100
cmd/tsidp/README.md Normal file
View File

@@ -0,0 +1,100 @@
# `tsidp` - Tailscale OpenID Connect (OIDC) Identity Provider
[![status: experimental](https://img.shields.io/badge/status-experimental-blue)](https://tailscale.com/kb/1167/release-stages/#experimental)
`tsidp` is an OIDC Identity Provider (IdP) server that integrates with your Tailscale network. It allows you to use Tailscale identities for authentication in applications that support OpenID Connect, enabling single sign-on (SSO) capabilities within your tailnet.
## Prerequisites
- A Tailscale network (tailnet) with magicDNS and HTTPS enabled
- A Tailscale authentication key from your tailnet
- Docker installed on your system
## Installation using Docker
1. **Build the Docker Image**
The Dockerfile uses a multi-stage build process to:
- Build the `tsidp` binary from source
- Create a minimal Alpine-based image with just the necessary components
```bash
# Clone the Tailscale repository
git clone https://github.com/tailscale/tailscale.git
cd tailscale
```
```bash
# Build the Docker image
docker build -t tsidp:latest -f cmd/tsidp/Dockerfile .
```
2. **Run the Container**
Replace `YOUR_TAILSCALE_AUTHKEY` with your Tailscale authentication key.
```bash
docker run -d \
--name `tsidp` \
-p 443:443 \
-e TS_AUTHKEY=YOUR_TAILSCALE_AUTHKEY \
-e TS_HOSTNAME=tsidp \
-v tsidp-data:/var/lib/tsidp \
tsidp:latest
```
3. **Verify Installation**
```bash
docker logs tsidp
```
Visit `https://tsidp.tailnet.ts.net` to confirm the service is running.
## Usage Example: Proxmox Integration
Here's how to configure Proxmox to use `tsidp` for authentication:
1. In Proxmox, navigate to Datacenter > Realms > Add OpenID Connect Server
2. Configure the following settings:
- Issuer URL: `https://idp.velociraptor.ts.net`
- Realm: `tailscale` (or your preferred name)
- Client ID: `unused`
- Client Key: `unused`
- Default: `true`
- Autocreate users: `true`
- Username claim: `email`
3. Set up user permissions:
- Go to Datacenter > Permissions > Groups
- Create a new group (e.g., "tsadmins")
- Click Permissions in the sidebar
- Add Group Permission
- Set Path to `/` for full admin access or scope as needed
- Set the group and role
- Add Tailscale-authenticated users to the group
## Configuration Options
The `tsidp` server supports several command-line flags:
- `--verbose`: Enable verbose logging
- `--port`: Port to listen on (default: 443)
- `--local-port`: Allow requests from localhost
- `--use-local-tailscaled`: Use local tailscaled instead of tsnet
- `--dir`: tsnet state directory
## Environment Variables
- `TS_AUTHKEY`: Your Tailscale authentication key (required)
- `TS_HOSTNAME`: Hostname for the `tsidp` server (default: "idp")
- `TS_STATE_DIR`: State directory (default: "/var/lib/tsidp")
- `TAILSCALE_USE_WIP_CODE`: Enable work-in-progress code (default: "1")
## Support
This is an [experimental](https://tailscale.com/kb/1167/release-stages#experimental), work in progress feature. For issues or questions, file issues on the [GitHub repository](https://github.com/tailscale/tailscale)
## License
BSD-3-Clause License. See [LICENSE](../../LICENSE) for details.

View File

@@ -11,6 +11,7 @@ import (
"context"
crand "crypto/rand"
"crypto/rsa"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/base64"
@@ -345,7 +346,9 @@ func (ar *authRequest) allowRelyingParty(r *http.Request, lc *local.Client) erro
clientID = r.FormValue("client_id")
clientSecret = r.FormValue("client_secret")
}
if ar.funnelRP.ID != clientID || ar.funnelRP.Secret != clientSecret {
clientIDcmp := subtle.ConstantTimeCompare([]byte(clientID), []byte(ar.funnelRP.ID))
clientSecretcmp := subtle.ConstantTimeCompare([]byte(clientSecret), []byte(ar.funnelRP.Secret))
if clientIDcmp != 1 || clientSecretcmp != 1 {
return fmt.Errorf("tsidp: invalid client credentials")
}
return nil
@@ -762,6 +765,18 @@ var (
)
func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Method", "GET, OPTIONS")
// allow all to prevent errors from client sending their own bespoke headers
// and having the server reject the request.
h.Set("Access-Control-Allow-Headers", "*")
// early return for pre-flight OPTIONS requests.
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.URL.Path != oidcConfigPath {
http.Error(w, "tsidp: not found", http.StatusNotFound)
return

View File

@@ -119,6 +119,7 @@ type Auto struct {
updateCh chan struct{} // readable when we should inform the server of a change
observer Observer // called to update Client status; always non-nil
observerQueue execqueue.ExecQueue
shutdownFn func() // to be called prior to shutdown or nil
unregisterHealthWatch func()
@@ -189,6 +190,7 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
mapDone: make(chan struct{}),
updateDone: make(chan struct{}),
observer: opts.Observer,
shutdownFn: opts.Shutdown,
}
c.authCtx, c.authCancel = context.WithCancel(context.Background())
c.authCtx = sockstats.WithSockStats(c.authCtx, sockstats.LabelControlClientAuto, opts.Logf)
@@ -755,6 +757,7 @@ func (c *Auto) Shutdown() {
return
}
c.logf("client.Shutdown ...")
shutdownFn := c.shutdownFn
direct := c.direct
c.closed = true
@@ -767,6 +770,10 @@ func (c *Auto) Shutdown() {
c.unpauseWaiters = nil
c.mu.Unlock()
if shutdownFn != nil {
shutdownFn()
}
c.unregisterHealthWatch()
<-c.authDone
<-c.mapDone

View File

@@ -4,6 +4,8 @@
package controlclient
import (
"errors"
"fmt"
"io"
"reflect"
"slices"
@@ -147,3 +149,42 @@ func TestCanSkipStatus(t *testing.T) {
t.Errorf("Status fields = %q; this code was only written to handle fields %q", f, want)
}
}
func TestRetryableErrors(t *testing.T) {
errorTests := []struct {
err error
want bool
}{
{errNoNoiseClient, true},
{errNoNodeKey, true},
{fmt.Errorf("%w: %w", errNoNoiseClient, errors.New("no noise")), true},
{fmt.Errorf("%w: %w", errHTTPPostFailure, errors.New("bad post")), true},
{fmt.Errorf("%w: %w", errNoNodeKey, errors.New("not node key")), true},
{errBadHTTPResponse(429, "too may requests"), true},
{errBadHTTPResponse(500, "internal server eror"), true},
{errBadHTTPResponse(502, "bad gateway"), true},
{errBadHTTPResponse(503, "service unavailable"), true},
{errBadHTTPResponse(504, "gateway timeout"), true},
{errBadHTTPResponse(1234, "random error"), false},
}
for _, tt := range errorTests {
t.Run(tt.err.Error(), func(t *testing.T) {
if isRetryableErrorForTest(tt.err) != tt.want {
t.Fatalf("retriable: got %v, want %v", tt.err, tt.want)
}
})
}
}
type retryableForTest interface {
Retryable() bool
}
func isRetryableErrorForTest(err error) bool {
var ae retryableForTest
if errors.As(err, &ae) {
return ae.Retryable()
}
return false
}

View File

@@ -156,6 +156,11 @@ type Options struct {
// If we receive a new DialPlan from the server, this value will be
// updated.
DialPlan ControlDialPlanner
// Shutdown is an optional function that will be called before client shutdown is
// attempted. It is used to allow the client to clean up any resources or complete any
// tasks that are dependent on a live client.
Shutdown func()
}
// ControlDialPlanner is the interface optionally supplied when creating a
@@ -1255,6 +1260,7 @@ type devKnobs struct {
DumpNetMapsVerbose func() bool
ForceProxyDNS func() bool
StripEndpoints func() bool // strip endpoints from control (only use disco messages)
StripHomeDERP func() bool // strip Home DERP from control
StripCaps func() bool // strip all local node's control-provided capabilities
}
@@ -1266,6 +1272,7 @@ func initDevKnob() devKnobs {
DumpRegister: envknob.RegisterBool("TS_DEBUG_REGISTER"),
ForceProxyDNS: envknob.RegisterBool("TS_DEBUG_PROXY_DNS"),
StripEndpoints: envknob.RegisterBool("TS_DEBUG_STRIP_ENDPOINTS"),
StripHomeDERP: envknob.RegisterBool("TS_DEBUG_STRIP_HOME_DERP"),
StripCaps: envknob.RegisterBool("TS_DEBUG_STRIP_CAPS"),
}
}
@@ -1660,11 +1667,11 @@ func (c *Auto) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) err
func (c *Direct) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) error {
nc, err := c.getNoiseClient()
if err != nil {
return err
return fmt.Errorf("%w: %w", errNoNoiseClient, err)
}
nodeKey, ok := c.GetPersist().PublicNodeKeyOK()
if !ok {
return errors.New("no node key")
return errNoNodeKey
}
if c.panicOnUse {
panic("tainted client")
@@ -1695,6 +1702,47 @@ func (c *Direct) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) e
return nil
}
// SendAuditLog implements [auditlog.Transport] by sending an audit log synchronously to the control plane.
//
// See docs on [tailcfg.AuditLogRequest] and [auditlog.Logger] for background.
func (c *Auto) SendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequest) (err error) {
return c.direct.sendAuditLog(ctx, auditLog)
}
func (c *Direct) sendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequest) (err error) {
nc, err := c.getNoiseClient()
if err != nil {
return fmt.Errorf("%w: %w", errNoNoiseClient, err)
}
nodeKey, ok := c.GetPersist().PublicNodeKeyOK()
if !ok {
return errNoNodeKey
}
req := &tailcfg.AuditLogRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: nodeKey,
Action: auditLog.Action,
Details: auditLog.Details,
}
if c.panicOnUse {
panic("tainted client")
}
res, err := nc.post(ctx, "/machine/audit-log", nodeKey, req)
if err != nil {
return fmt.Errorf("%w: %w", errHTTPPostFailure, err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
all, _ := io.ReadAll(res.Body)
return errBadHTTPResponse(res.StatusCode, string(all))
}
return nil
}
func addLBHeader(req *http.Request, nodeKey key.NodePublic) {
if !nodeKey.IsZero() {
req.Header.Add(tailcfg.LBHeader, nodeKey.String())

View File

@@ -0,0 +1,51 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
import (
"errors"
"fmt"
"net/http"
)
// apiResponseError is an error type that can be returned by controlclient
// api requests.
//
// It wraps an underlying error and a flag for clients to query if the
// error is retryable via the Retryable() method.
type apiResponseError struct {
err error
retryable bool
}
// Error implements [error].
func (e *apiResponseError) Error() string {
return e.err.Error()
}
// Retryable reports whether the error is retryable.
func (e *apiResponseError) Retryable() bool {
return e.retryable
}
func (e *apiResponseError) Unwrap() error { return e.err }
var (
errNoNodeKey = &apiResponseError{errors.New("no node key"), true}
errNoNoiseClient = &apiResponseError{errors.New("no noise client"), true}
errHTTPPostFailure = &apiResponseError{errors.New("http failure"), true}
)
func errBadHTTPResponse(code int, msg string) error {
retryable := false
switch code {
case http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
retryable = true
}
return &apiResponseError{fmt.Errorf("http error %d: %s", code, msg), retryable}
}

View File

@@ -240,6 +240,9 @@ func upgradeNode(n *tailcfg.Node) {
}
n.LegacyDERPString = ""
}
if DevKnob.StripHomeDERP() {
n.HomeDERP = 0
}
if n.AllowedIPs == nil {
n.AllowedIPs = slices.Clone(n.Addresses)

View File

@@ -652,7 +652,11 @@ func (c *Client) tlsClient(nc net.Conn, node *tailcfg.DERPNode) *tls.Conn {
tlsConf.VerifyConnection = nil
}
if node.CertName != "" {
tlsdial.SetConfigExpectedCert(tlsConf, node.CertName)
if suf, ok := strings.CutPrefix(node.CertName, "sha256-raw:"); ok {
tlsdial.SetConfigExpectedCertHash(tlsConf, suf)
} else {
tlsdial.SetConfigExpectedCert(tlsConf, node.CertName)
}
}
}
return tls.Client(nc, tlsConf)
@@ -666,7 +670,7 @@ func (c *Client) tlsClient(nc net.Conn, node *tailcfg.DERPNode) *tls.Conn {
func (c *Client) DialRegionTLS(ctx context.Context, reg *tailcfg.DERPRegion) (tlsConn *tls.Conn, connClose io.Closer, node *tailcfg.DERPNode, err error) {
tcpConn, node, err := c.dialRegion(ctx, reg)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, fmt.Errorf("dialRegion(%d): %w", reg.RegionID, err)
}
done := make(chan bool) // unbuffered
defer close(done)
@@ -741,6 +745,17 @@ func (c *Client) dialNode(ctx context.Context, n *tailcfg.DERPNode) (net.Conn, e
nwait := 0
startDial := func(dstPrimary, proto string) {
dst := cmp.Or(dstPrimary, n.HostName)
// If dialing an IP address directly, check its address family
// and bail out before incrementing nwait.
if ip, err := netip.ParseAddr(dst); err == nil {
if proto == "tcp4" && ip.Is6() ||
proto == "tcp6" && ip.Is4() {
return
}
}
nwait++
go func() {
if proto == "tcp4" && c.preferIPv6() {
@@ -755,7 +770,6 @@ func (c *Client) dialNode(ctx context.Context, n *tailcfg.DERPNode) (net.Conn, e
// Start v4 dial
}
}
dst := cmp.Or(dstPrimary, n.HostName)
port := "443"
if !c.useHTTPS() {
port = "3340"

View File

@@ -417,6 +417,23 @@ func App() string {
return ""
}
// IsCertShareReadOnlyMode returns true if this replica should never attempt to
// issue or renew TLS credentials for any of the HTTPS endpoints that it is
// serving. It should only return certs found in its cert store. Currently,
// this is used by the Kubernetes Operator's HA Ingress via VIPServices, where
// multiple Ingress proxy instances serve the same HTTPS endpoint with a shared
// TLS credentials. The TLS credentials should only be issued by one of the
// replicas.
// For HTTPS Ingress the operator and containerboot ensure
// that read-only replicas will not be serving the HTTPS endpoints before there
// is a shared cert available.
func IsCertShareReadOnlyMode() bool {
m := String("TS_CERT_SHARE_MODE")
return m == modeRO
}
const modeRO = "ro"
// CrashOnUnexpected reports whether the Tailscale client should panic
// on unexpected conditions. If TS_DEBUG_CRASH_ON_UNEXPECTED is set, that's
// used. Otherwise the default value is true for unstable builds.

View File

@@ -130,4 +130,4 @@
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=
# nix-direnv cache busting line: sha256-SiUkN6BQK1IQmLfkfPetzvYqRu9ENK6+6txtGxegF5Y=

2
go.mod
View File

@@ -97,7 +97,7 @@ require (
golang.org/x/crypto v0.35.0
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac
golang.org/x/mod v0.23.0
golang.org/x/net v0.35.0
golang.org/x/net v0.36.0
golang.org/x/oauth2 v0.26.0
golang.org/x/sync v0.11.0
golang.org/x/sys v0.30.0

View File

@@ -1 +1 @@
sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=
sha256-SiUkN6BQK1IQmLfkfPetzvYqRu9ENK6+6txtGxegF5Y=

4
go.sum
View File

@@ -1135,8 +1135,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@@ -1 +1 @@
2b494987ff3c1a6a26e10570c490394ff0a77aa4
4fdaeeb8fe43bcdb4e8cc736433b9cd9c0ddd221

View File

@@ -11,7 +11,6 @@ package main
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"flag"
@@ -30,7 +29,6 @@ import (
var (
app = flag.String("app", "tsapp", "appliance name; one of the subdirectories of gokrazy/")
bucket = flag.String("bucket", "tskrazy-import", "S3 bucket to upload disk image to while making AMI")
goArch = flag.String("arch", cmp.Or(os.Getenv("GOARCH"), "amd64"), "GOARCH architecture to build for: arm64 or amd64")
build = flag.Bool("build", false, "if true, just build locally and stop, without uploading")
)
@@ -54,6 +52,26 @@ func findMkfsExt4() (string, error) {
return "", errors.New("No mkfs.ext4 found on system")
}
var conf gokrazyConfig
// gokrazyConfig is the subset of gokrazy/internal/config.Struct
// that we care about.
type gokrazyConfig struct {
// Environment is os.Environment pairs to use when
// building userspace.
// See https://gokrazy.org/userguide/instance-config/#environment
Environment []string
}
func (c *gokrazyConfig) GOARCH() string {
for _, e := range c.Environment {
if v, ok := strings.CutPrefix(e, "GOARCH="); ok {
return v
}
}
return ""
}
func main() {
flag.Parse()
@@ -61,6 +79,19 @@ func main() {
log.Fatalf("--app must be non-empty name such as 'tsapp' or 'natlabapp'")
}
confJSON, err := os.ReadFile(filepath.Join(*app, "config.json"))
if err != nil {
log.Fatalf("reading config.json: %v", err)
}
if err := json.Unmarshal(confJSON, &conf); err != nil {
log.Fatalf("unmarshaling config.json: %v", err)
}
switch conf.GOARCH() {
case "amd64", "arm64":
default:
log.Fatalf("config.json GOARCH %q must be amd64 or arm64", conf.GOARCH())
}
if err := buildImage(); err != nil {
log.Fatalf("build image: %v", err)
}
@@ -106,7 +137,6 @@ func buildImage() error {
// Build the tsapp.img
var buf bytes.Buffer
cmd := exec.Command("go", "run",
"-exec=env GOOS=linux GOARCH="+*goArch+" ",
"github.com/gokrazy/tools/cmd/gok",
"--parent_dir="+dir,
"--instance="+*app,
@@ -253,13 +283,13 @@ func waitForImportSnapshot(importTaskID string) (snapID string, err error) {
func makeAMI(name, ebsSnapID string) (ami string, err error) {
var arch string
switch *goArch {
switch conf.GOARCH() {
case "arm64":
arch = "arm64"
case "amd64":
arch = "x86_64"
default:
return "", fmt.Errorf("unknown arch %q", *goArch)
return "", fmt.Errorf("unknown arch %q", conf.GOARCH())
}
out, err := exec.Command("aws", "ec2", "register-image",
"--name", name,

View File

@@ -1,13 +1,13 @@
module tailscale.com/gokrazy
go 1.23.1
go 1.23
require github.com/gokrazy/tools v0.0.0-20240730192548-9f81add3a91e
require github.com/gokrazy/tools v0.0.0-20250128200151-63160424957c
require (
github.com/breml/rootcerts v0.2.10 // indirect
github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0 // indirect
github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5 // indirect
github.com/gokrazy/internal v0.0.0-20250126213949-423a5b587b57 // indirect
github.com/gokrazy/updater v0.0.0-20230215172637-813ccc7f21e2 // indirect
github.com/google/renameio/v2 v2.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -15,9 +15,5 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/mod v0.11.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/sys v0.28.0 // indirect
)
replace github.com/gokrazy/gokrazy => github.com/tailscale/gokrazy v0.0.0-20240812224643-6b21ddf64678
replace github.com/gokrazy/tools => github.com/tailscale/gokrazy-tools v0.0.0-20240730192548-9f81add3a91e

View File

@@ -3,8 +3,10 @@ github.com/breml/rootcerts v0.2.10/go.mod h1:24FDtzYMpqIeYC7QzaE8VPRQaFZU5TIUDly
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0 h1:C7t6eeMaEQVy6e8CarIhscYQlNmw5e3G36y7l7Y21Ao=
github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0/go.mod h1:56wL82FO0bfMU5RvfXoIwSOP2ggqqxT+tAfNEIyxuHw=
github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5 h1:XDklMxV0pE5jWiNaoo5TzvWfqdoiRRScmr4ZtDzE4Uw=
github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5/go.mod h1:t3ZirVhcs9bH+fPAJuGh51rzT7sVCZ9yfXvszf0ZjF0=
github.com/gokrazy/internal v0.0.0-20250126213949-423a5b587b57 h1:f5bEvO4we3fbfiBkECrrUgWQ8OH6J3SdB2Dwxid/Yx4=
github.com/gokrazy/internal v0.0.0-20250126213949-423a5b587b57/go.mod h1:SJG1KwuJQXFEoBgryaNCkMbdISyovDgZd0xmXJRZmiw=
github.com/gokrazy/tools v0.0.0-20250128200151-63160424957c h1:iEbS8GrNOn671ze8J/AfrYFEVzf8qMx8aR5K0VxPK2w=
github.com/gokrazy/tools v0.0.0-20250128200151-63160424957c/go.mod h1:f2vZhnaPzy92+Bjpx1iuZHK7VuaJx6SNCWQWmu23HZA=
github.com/gokrazy/updater v0.0.0-20230215172637-813ccc7f21e2 h1:kBY5R1tSf+EYZ+QaSrofLaVJtBqYsVNVBWkdMq3Smcg=
github.com/gokrazy/updater v0.0.0-20230215172637-813ccc7f21e2/go.mod h1:PYOvzGOL4nlBmuxu7IyKQTFLaxr61+WPRNRzVtuYOHw=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
@@ -19,14 +21,12 @@ github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/tailscale/gokrazy-tools v0.0.0-20240730192548-9f81add3a91e h1:3/xIc1QCvnKL7BCLng9od98HEvxCadjvqiI/bN+Twso=
github.com/tailscale/gokrazy-tools v0.0.0-20240730192548-9f81add3a91e/go.mod h1:eTZ0QsugEPFU5UAQ/87bKMkPxQuTNa7+iFAIahOFwRg=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -4,32 +4,58 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk=
github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw=
github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
@@ -46,10 +72,14 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@@ -62,6 +92,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
@@ -70,6 +102,8 @@ github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwso
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
github.com/illarion/gonotify/v2 v2.0.2 h1:oDH5yvxq9oiQGWUeut42uShcWzOy/hsT9E7pvO95+kQ=
github.com/illarion/gonotify/v2 v2.0.2/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
@@ -84,6 +118,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
@@ -96,6 +132,8 @@ github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy5
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
@@ -126,12 +164,18 @@ github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1 h1:ycpNCSYwzZ7x4G4ioPNtKQmIY0G/3o4pVf8wCZq6blY=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9 h1:81P7rjnikHKTJ75EkjppvbwUfKHDHYk6LJpO5PZy8pA=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
@@ -144,6 +188,8 @@ github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs=
github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
@@ -152,42 +198,66 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.32.1-0.20250118192723-a8ea4be81f07 h1:Z+Zg+aXJYq6f4TK2E4H+vZkQ4dJAWnInXDR6hM9znxo=
golang.org/x/crypto v0.32.1-0.20250118192723-a8ea4be81f07/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab h1:BMkEEWYOjkvOX7+YKOGbp6jCyQ5pR2j0Ah47p1Vdsx4=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 h1:/8/t5pz/mgdRXhYOIeqqYhFAQLE4DDGegc0Y4ZjyFJM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q=
k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc=
k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k=
k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U=
k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8=
k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=

View File

@@ -20,6 +20,10 @@
}
}
},
"Environment": [
"GOOS=linux",
"GOARCH=arm64"
],
"KernelPackage": "github.com/gokrazy/kernel.arm64",
"FirmwarePackage": "github.com/gokrazy/kernel.arm64",
"EEPROMPackage": "",

View File

@@ -4,32 +4,58 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk=
github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw=
github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
@@ -46,10 +72,14 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@@ -62,6 +92,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
@@ -86,6 +118,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
@@ -98,6 +132,8 @@ github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy5
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
@@ -128,14 +164,20 @@ github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1 h1:ycpNCSYwzZ7x4G4ioPNtKQmIY0G/3o4pVf8wCZq6blY=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc h1:cezaQN9pvKVaw56Ma5qr/G646uKIYP0yQf+OyWN/okc=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9 h1:81P7rjnikHKTJ75EkjppvbwUfKHDHYk6LJpO5PZy8pA=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
@@ -148,6 +190,8 @@ github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs=
github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
@@ -156,42 +200,66 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.32.1-0.20250118192723-a8ea4be81f07 h1:Z+Zg+aXJYq6f4TK2E4H+vZkQ4dJAWnInXDR6hM9znxo=
golang.org/x/crypto v0.32.1-0.20250118192723-a8ea4be81f07/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab h1:BMkEEWYOjkvOX7+YKOGbp6jCyQ5pR2j0Ah47p1Vdsx4=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 h1:/8/t5pz/mgdRXhYOIeqqYhFAQLE4DDGegc0Y4ZjyFJM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q=
k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc=
k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k=
k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U=
k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8=
k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=

View File

@@ -20,6 +20,10 @@
}
}
},
"Environment": [
"GOOS=linux",
"GOARCH=amd64"
],
"KernelPackage": "github.com/tailscale/gokrazy-kernel",
"FirmwarePackage": "",
"EEPROMPackage": "",

View File

@@ -4,48 +4,80 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk=
github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw=
github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@@ -58,12 +90,16 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
@@ -78,6 +114,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
@@ -90,6 +128,8 @@ github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy5
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
@@ -116,14 +156,22 @@ github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29X
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1 h1:ycpNCSYwzZ7x4G4ioPNtKQmIY0G/3o4pVf8wCZq6blY=
github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9 h1:81P7rjnikHKTJ75EkjppvbwUfKHDHYk6LJpO5PZy8pA=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
@@ -136,6 +184,8 @@ github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs=
github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
@@ -144,42 +194,66 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.32.1-0.20250118192723-a8ea4be81f07 h1:Z+Zg+aXJYq6f4TK2E4H+vZkQ4dJAWnInXDR6hM9znxo=
golang.org/x/crypto v0.32.1-0.20250118192723-a8ea4be81f07/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab h1:BMkEEWYOjkvOX7+YKOGbp6jCyQ5pR2j0Ah47p1Vdsx4=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 h1:/8/t5pz/mgdRXhYOIeqqYhFAQLE4DDGegc0Y4ZjyFJM=
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q=
k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc=
k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k=
k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U=
k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8=
k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=

View File

@@ -27,6 +27,10 @@
}
}
},
"Environment": [
"GOOS=linux",
"GOARCH=amd64"
],
"KernelPackage": "github.com/tailscale/gokrazy-kernel",
"FirmwarePackage": "github.com/tailscale/gokrazy-kernel",
"InternalCompatibilityFlags": {}

466
ipn/auditlog/auditlog.go Normal file
View File

@@ -0,0 +1,466 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package auditlog provides a mechanism for logging audit events.
package auditlog
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"sync"
"time"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/rands"
"tailscale.com/util/set"
)
// transaction represents an audit log that has not yet been sent to the control plane.
type transaction struct {
// EventID is the unique identifier for the event being logged.
// This is used on the client side only and is not sent to control.
EventID string `json:",omitempty"`
// Retries is the number of times the logger has attempted to send this log.
// This is used on the client side only and is not sent to control.
Retries int `json:",omitempty"`
// Action is the action to be logged. It must correspond to a known action in the control plane.
Action tailcfg.ClientAuditAction `json:",omitempty"`
// Details is an opaque string specific to the action being logged. Empty strings may not
// be valid depending on the action being logged.
Details string `json:",omitempty"`
// TimeStamp is the time at which the audit log was generated on the node.
TimeStamp time.Time `json:",omitzero"`
}
// Transport provides a means for a client to send audit logs to a consumer (typically the control plane).
type Transport interface {
// SendAuditLog sends an audit log to a consumer of audit logs.
// Errors should be checked with [IsRetryableError] for retryability.
SendAuditLog(context.Context, tailcfg.AuditLogRequest) error
}
// LogStore provides a means for a [Logger] to persist logs to disk or memory.
type LogStore interface {
// Save saves the given data to a persistent store. Save will overwrite existing data
// for the given key.
save(key ipn.ProfileID, txns []*transaction) error
// Load retrieves the data from a persistent store. Returns a nil slice and
// no error if no data exists for the given key.
load(key ipn.ProfileID) ([]*transaction, error)
}
// Opts contains the configuration options for a [Logger].
type Opts struct {
// RetryLimit is the maximum number of attempts the logger will make to send a log before giving up.
RetryLimit int
// Store is the persistent store used to save logs to disk. Must be non-nil.
Store LogStore
// Logf is the logger used to log messages from the audit logger. Must be non-nil.
Logf logger.Logf
}
// IsRetryableError returns true if the given error is retryable
// See [controlclient.apiResponseError]. Potentially retryable errors implement the Retryable() method.
func IsRetryableError(err error) bool {
var retryable interface{ Retryable() bool }
return errors.As(err, &retryable) && retryable.Retryable()
}
type backoffOpts struct {
min, max time.Duration
multiplier float64
}
// .5, 1, 2, 4, 8, 10, 10, 10, 10, 10...
var defaultBackoffOpts = backoffOpts{
min: time.Millisecond * 500,
max: 10 * time.Second,
multiplier: 2,
}
// Logger provides a queue-based mechanism for submitting audit logs to the control plane - or
// another suitable consumer. Logs are stored to disk and retried until they are successfully sent,
// or until they permanently fail.
//
// Each individual profile/controlclient tuple should construct and manage a unique [Logger] instance.
type Logger struct {
logf logger.Logf
retryLimit int // the maximum number of attempts to send a log before giving up.
flusher chan struct{} // channel used to signal a flush operation.
done chan struct{} // closed when the flush worker exits.
ctx context.Context // canceled when the logger is stopped.
ctxCancel context.CancelFunc // cancels ctx.
backoffOpts // backoff settings for retry operations.
// mu protects the fields below.
mu sync.Mutex
store LogStore // persistent storage for unsent logs.
profileID ipn.ProfileID // empty if [Logger.SetProfileID] has not been called.
transport Transport // nil until [Logger.Start] is called.
}
// NewLogger creates a new [Logger] with the given options.
func NewLogger(opts Opts) *Logger {
ctx, cancel := context.WithCancel(context.Background())
al := &Logger{
retryLimit: opts.RetryLimit,
logf: logger.WithPrefix(opts.Logf, "auditlog: "),
store: opts.Store,
flusher: make(chan struct{}, 1),
done: make(chan struct{}),
ctx: ctx,
ctxCancel: cancel,
backoffOpts: defaultBackoffOpts,
}
al.logf("created")
return al
}
// FlushAndStop synchronously flushes all pending logs and stops the audit logger.
// This will block until a final flush operation completes or context is done.
// If the logger is already stopped, this will return immediately. All unsent
// logs will be persisted to the store.
func (al *Logger) FlushAndStop(ctx context.Context) {
al.stop()
al.flush(ctx)
}
// SetProfileID sets the profileID for the logger. This must be called before any logs can be enqueued.
// The profileID of a logger cannot be changed once set.
func (al *Logger) SetProfileID(profileID ipn.ProfileID) error {
al.mu.Lock()
defer al.mu.Unlock()
if al.profileID != "" {
return errors.New("profileID already set")
}
al.profileID = profileID
return nil
}
// Start starts the audit logger with the given transport.
// It returns an error if the logger is already started.
func (al *Logger) Start(t Transport) error {
al.mu.Lock()
defer al.mu.Unlock()
if al.transport != nil {
return errors.New("already started")
}
al.transport = t
pending, err := al.storedCountLocked()
if err != nil {
al.logf("[unexpected] failed to restore logs: %v", err)
}
go al.flushWorker()
if pending > 0 {
al.flushAsync()
}
return nil
}
// ErrAuditLogStorageFailure is returned when the logger fails to persist logs to the store.
var ErrAuditLogStorageFailure = errors.New("audit log storage failure")
// Enqueue queues an audit log to be sent to the control plane (or another suitable consumer/transport).
// This will return an error if the underlying store fails to save the log or we fail to generate a unique
// eventID for the log.
func (al *Logger) Enqueue(action tailcfg.ClientAuditAction, details string) error {
txn := &transaction{
Action: action,
Details: details,
TimeStamp: time.Now(),
}
// Generate a suitably random eventID for the transaction.
txn.EventID = fmt.Sprint(txn.TimeStamp, rands.HexString(16))
return al.enqueue(txn)
}
// flushAsync requests an asynchronous flush.
// It is a no-op if a flush is already pending.
func (al *Logger) flushAsync() {
select {
case al.flusher <- struct{}{}:
default:
}
}
func (al *Logger) flushWorker() {
defer close(al.done)
var retryDelay time.Duration
retry := time.NewTimer(0)
retry.Stop()
for {
select {
case <-al.ctx.Done():
return
case <-al.flusher:
err := al.flush(al.ctx)
switch {
case errors.Is(err, context.Canceled):
// The logger was stopped, no need to retry.
return
case err != nil:
retryDelay = max(al.backoffOpts.min, min(retryDelay*time.Duration(al.backoffOpts.multiplier), al.backoffOpts.max))
al.logf("retrying after %v, %v", retryDelay, err)
retry.Reset(retryDelay)
default:
retryDelay = 0
retry.Stop()
}
case <-retry.C:
al.flushAsync()
}
}
}
// flush attempts to send all pending logs to the control plane.
// l.mu must not be held.
func (al *Logger) flush(ctx context.Context) error {
al.mu.Lock()
pending, err := al.store.load(al.profileID)
t := al.transport
al.mu.Unlock()
if err != nil {
// This will catch nil profileIDs
return fmt.Errorf("failed to restore pending logs: %w", err)
}
if len(pending) == 0 {
return nil
}
if t == nil {
return errors.New("no transport")
}
complete, unsent := al.sendToTransport(ctx, pending, t)
al.markTransactionsDone(complete)
al.mu.Lock()
defer al.mu.Unlock()
if err = al.appendToStoreLocked(unsent); err != nil {
al.logf("[unexpected] failed to persist logs: %v", err)
}
if len(unsent) != 0 {
return fmt.Errorf("failed to send %d logs", len(unsent))
}
if len(complete) != 0 {
al.logf("complete %d audit log transactions", len(complete))
}
return nil
}
// sendToTransport sends all pending logs to the control plane. Returns a pair of slices
// containing the logs that were successfully sent (or failed permanently) and those that were not.
//
// This may require multiple round trips to the control plane and can be a long running transaction.
func (al *Logger) sendToTransport(ctx context.Context, pending []*transaction, t Transport) (complete []*transaction, unsent []*transaction) {
for i, txn := range pending {
req := tailcfg.AuditLogRequest{
Action: tailcfg.ClientAuditAction(txn.Action),
Details: txn.Details,
Timestamp: txn.TimeStamp,
}
if err := t.SendAuditLog(ctx, req); err != nil {
switch {
case errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded):
// The contex is done. All further attempts will fail.
unsent = append(unsent, pending[i:]...)
return complete, unsent
case IsRetryableError(err) && txn.Retries+1 < al.retryLimit:
// We permit a maximum number of retries for each log. All retriable
// errors should be transient and we should be able to send the log eventually, but
// we don't want logs to be persisted indefinitely.
txn.Retries++
unsent = append(unsent, txn)
default:
complete = append(complete, txn)
al.logf("failed permanently: %v", err)
}
} else {
// No error - we're done.
complete = append(complete, txn)
}
}
return complete, unsent
}
func (al *Logger) stop() {
al.mu.Lock()
t := al.transport
al.mu.Unlock()
if t == nil {
// No transport means no worker goroutine and done will not be
// closed if we cancel the context.
return
}
al.ctxCancel()
<-al.done
al.logf("stopped for profileID: %v", al.profileID)
}
// appendToStoreLocked persists logs to the store. This will deduplicate
// logs so it is safe to call this with the same logs multiple time, to
// requeue failed transactions for example.
//
// l.mu must be held.
func (al *Logger) appendToStoreLocked(txns []*transaction) error {
if len(txns) == 0 {
return nil
}
if al.profileID == "" {
return errors.New("no logId set")
}
persisted, err := al.store.load(al.profileID)
if err != nil {
al.logf("[unexpected] append failed to restore logs: %v", err)
}
// The order is important here. We want the latest transactions first, which will
// ensure when we dedup, the new transactions are seen and the older transactions
// are discarded.
txnsOut := append(txns, persisted...)
txnsOut = deduplicateAndSort(txnsOut)
return al.store.save(al.profileID, txnsOut)
}
// storedCountLocked returns the number of logs persisted to the store.
// al.mu must be held.
func (al *Logger) storedCountLocked() (int, error) {
persisted, err := al.store.load(al.profileID)
return len(persisted), err
}
// markTransactionsDone removes logs from the store that are complete (sent or failed permanently).
// al.mu must not be held.
func (al *Logger) markTransactionsDone(sent []*transaction) {
al.mu.Lock()
defer al.mu.Unlock()
ids := set.Set[string]{}
for _, txn := range sent {
ids.Add(txn.EventID)
}
persisted, err := al.store.load(al.profileID)
if err != nil {
al.logf("[unexpected] markTransactionsDone failed to restore logs: %v", err)
}
var unsent []*transaction
for _, txn := range persisted {
if !ids.Contains(txn.EventID) {
unsent = append(unsent, txn)
}
}
al.store.save(al.profileID, unsent)
}
// deduplicateAndSort removes duplicate logs from the given slice and sorts them by timestamp.
// The first log entry in the slice will be retained, subsequent logs with the same EventID will be discarded.
func deduplicateAndSort(txns []*transaction) []*transaction {
seen := set.Set[string]{}
deduped := make([]*transaction, 0, len(txns))
for _, txn := range txns {
if !seen.Contains(txn.EventID) {
deduped = append(deduped, txn)
seen.Add(txn.EventID)
}
}
// Sort logs by timestamp - oldest to newest. This will put the oldest logs at
// the front of the queue.
sort.Slice(deduped, func(i, j int) bool {
return deduped[i].TimeStamp.Before(deduped[j].TimeStamp)
})
return deduped
}
func (al *Logger) enqueue(txn *transaction) error {
al.mu.Lock()
defer al.mu.Unlock()
if err := al.appendToStoreLocked([]*transaction{txn}); err != nil {
return fmt.Errorf("%w: %w", ErrAuditLogStorageFailure, err)
}
// If a.transport is nil if the logger is stopped.
if al.transport != nil {
al.flushAsync()
}
return nil
}
var _ LogStore = (*logStateStore)(nil)
// logStateStore is a concrete implementation of [LogStore]
// using [ipn.StateStore] as the underlying storage.
type logStateStore struct {
store ipn.StateStore
}
// NewLogStore creates a new LogStateStore with the given [ipn.StateStore].
func NewLogStore(store ipn.StateStore) LogStore {
return &logStateStore{
store: store,
}
}
func (s *logStateStore) generateKey(key ipn.ProfileID) string {
return "auditlog-" + string(key)
}
// Save saves the given logs to an [ipn.StateStore]. This overwrites
// any existing entries for the given key.
func (s *logStateStore) save(key ipn.ProfileID, txns []*transaction) error {
if key == "" {
return errors.New("empty key")
}
data, err := json.Marshal(txns)
if err != nil {
return err
}
k := ipn.StateKey(s.generateKey(key))
return s.store.WriteState(k, data)
}
// Load retrieves the logs from an [ipn.StateStore].
func (s *logStateStore) load(key ipn.ProfileID) ([]*transaction, error) {
if key == "" {
return nil, errors.New("empty key")
}
k := ipn.StateKey(s.generateKey(key))
data, err := s.store.ReadState(k)
switch {
case errors.Is(err, ipn.ErrStateNotExist):
return nil, nil
case err != nil:
return nil, err
}
var txns []*transaction
err = json.Unmarshal(data, &txns)
return txns, err
}

View File

@@ -0,0 +1,481 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package auditlog
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
qt "github.com/frankban/quicktest"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
)
// loggerForTest creates an auditLogger for you and cleans it up
// (and ensures no goroutines are leaked) when the test is done.
func loggerForTest(t *testing.T, opts Opts) *Logger {
t.Helper()
tstest.ResourceCheck(t)
if opts.Logf == nil {
opts.Logf = t.Logf
}
if opts.Store == nil {
t.Fatalf("opts.Store must be set")
}
a := NewLogger(opts)
t.Cleanup(func() {
a.FlushAndStop(context.Background())
})
return a
}
func TestNonRetryableErrors(t *testing.T) {
errorTests := []struct {
desc string
err error
want bool
}{
{"DeadlineExceeded", context.DeadlineExceeded, false},
{"Canceled", context.Canceled, false},
{"Canceled wrapped", fmt.Errorf("%w: %w", context.Canceled, errors.New("ctx cancelled")), false},
{"Random error", errors.New("random error"), false},
}
for _, tt := range errorTests {
t.Run(tt.desc, func(t *testing.T) {
if IsRetryableError(tt.err) != tt.want {
t.Fatalf("retriable: got %v, want %v", !tt.want, tt.want)
}
})
}
}
// TestEnqueueAndFlush enqueues n logs and flushes them.
// We expect all logs to be flushed and for no
// logs to remain in the store once FlushAndStop returns.
func TestEnqueueAndFlush(t *testing.T) {
c := qt.New(t)
mockTransport := newMockTransport(nil)
al := loggerForTest(t, Opts{
RetryLimit: 200,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
c.Assert(al.SetProfileID("test"), qt.IsNil)
c.Assert(al.Start(mockTransport), qt.IsNil)
wantSent := 10
for i := range wantSent {
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
c.Assert(err, qt.IsNil)
}
al.FlushAndStop(context.Background())
al.mu.Lock()
defer al.mu.Unlock()
gotStored, err := al.storedCountLocked()
c.Assert(err, qt.IsNil)
if wantStored := 0; gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
if gotSent := mockTransport.sentCount(); gotSent != wantSent {
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
}
}
// TestEnqueueAndFlushWithFlushCancel calls FlushAndCancel with a cancelled
// context. We expect nothing to be sent and all logs to be stored.
func TestEnqueueAndFlushWithFlushCancel(t *testing.T) {
c := qt.New(t)
mockTransport := newMockTransport(&retriableError)
al := loggerForTest(t, Opts{
RetryLimit: 200,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
c.Assert(al.SetProfileID("test"), qt.IsNil)
c.Assert(al.Start(mockTransport), qt.IsNil)
for i := range 10 {
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
c.Assert(err, qt.IsNil)
}
// Cancel the context before calling FlushAndStop - nothing should get sent.
// This mimics a timeout before flush() has a chance to execute.
ctx, cancel := context.WithCancel(context.Background())
cancel()
al.FlushAndStop(ctx)
al.mu.Lock()
defer al.mu.Unlock()
gotStored, err := al.storedCountLocked()
c.Assert(err, qt.IsNil)
if wantStored := 10; gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
}
}
// TestDeduplicateAndSort tests that the most recent log is kept when deduplicating logs
func TestDeduplicateAndSort(t *testing.T) {
c := qt.New(t)
al := loggerForTest(t, Opts{
RetryLimit: 100,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
c.Assert(al.SetProfileID("test"), qt.IsNil)
logs := []*transaction{
{EventID: "1", Details: "log 1", TimeStamp: time.Now().Add(-time.Minute * 1), Retries: 1},
}
al.mu.Lock()
defer al.mu.Unlock()
al.appendToStoreLocked(logs)
// Update the transaction and re-append it
logs[0].Retries = 2
al.appendToStoreLocked(logs)
fromStore, err := al.store.load("test")
c.Assert(err, qt.IsNil)
// We should see only one transaction
if wantStored, gotStored := len(logs), len(fromStore); gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
// We should see the latest transaction
if wantRetryCount, gotRetryCount := 2, fromStore[0].Retries; gotRetryCount != wantRetryCount {
t.Fatalf("reties: got %d, want %d", gotRetryCount, wantRetryCount)
}
}
func TestChangeProfileId(t *testing.T) {
c := qt.New(t)
al := loggerForTest(t, Opts{
RetryLimit: 100,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
c.Assert(al.SetProfileID("test"), qt.IsNil)
// Changing a profile ID must fail
c.Assert(al.SetProfileID("test"), qt.IsNotNil)
}
// TestSendOnRestore pushes a n logs to the persistent store, and ensures they
// are sent as soon as Start is called then checks to ensure the sent logs no
// longer exist in the store.
func TestSendOnRestore(t *testing.T) {
c := qt.New(t)
mockTransport := newMockTransport(nil)
al := loggerForTest(t, Opts{
RetryLimit: 100,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
al.SetProfileID("test")
wantTotal := 10
for range 10 {
al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
}
c.Assert(al.Start(mockTransport), qt.IsNil)
al.FlushAndStop(context.Background())
al.mu.Lock()
defer al.mu.Unlock()
gotStored, err := al.storedCountLocked()
c.Assert(err, qt.IsNil)
if wantStored := 0; gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
if gotSent, wantSent := mockTransport.sentCount(), wantTotal; gotSent != wantSent {
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
}
}
// TestFailureExhaustion enqueues n logs, with the transport in a failable state.
// We then set it to a non-failing state, call FlushAndStop and expect all logs to be sent.
func TestFailureExhaustion(t *testing.T) {
c := qt.New(t)
mockTransport := newMockTransport(&retriableError)
al := loggerForTest(t, Opts{
RetryLimit: 1,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
c.Assert(al.SetProfileID("test"), qt.IsNil)
c.Assert(al.Start(mockTransport), qt.IsNil)
for range 10 {
err := al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
c.Assert(err, qt.IsNil)
}
al.FlushAndStop(context.Background())
al.mu.Lock()
defer al.mu.Unlock()
gotStored, err := al.storedCountLocked()
c.Assert(err, qt.IsNil)
if wantStored := 0; gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
}
}
// TestEnqueueAndFailNoRetry enqueues a set of logs, all of which will fail and are not
// retriable. We then call FlushAndStop and expect all to be unsent.
func TestEnqueueAndFailNoRetry(t *testing.T) {
c := qt.New(t)
mockTransport := newMockTransport(&nonRetriableError)
al := loggerForTest(t, Opts{
RetryLimit: 100,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
c.Assert(al.SetProfileID("test"), qt.IsNil)
c.Assert(al.Start(mockTransport), qt.IsNil)
for i := range 10 {
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
c.Assert(err, qt.IsNil)
}
al.FlushAndStop(context.Background())
al.mu.Lock()
defer al.mu.Unlock()
gotStored, err := al.storedCountLocked()
c.Assert(err, qt.IsNil)
if wantStored := 0; gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
}
}
// TestEnqueueAndRetry enqueues a set of logs, all of which will fail and are retriable.
// Mid-test, we set the transport to not-fail and expect the queue to flush properly
// We set the backoff parameters to 0 seconds so retries are immediate.
func TestEnqueueAndRetry(t *testing.T) {
c := qt.New(t)
mockTransport := newMockTransport(&retriableError)
al := loggerForTest(t, Opts{
RetryLimit: 100,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
al.backoffOpts = backoffOpts{
min: 1 * time.Millisecond,
max: 4 * time.Millisecond,
multiplier: 2.0,
}
c.Assert(al.SetProfileID("test"), qt.IsNil)
c.Assert(al.Start(mockTransport), qt.IsNil)
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log 1"))
c.Assert(err, qt.IsNil)
// This will wait for at least 2 retries
gotRetried, wantRetried := mockTransport.waitForSendAttemptsToReach(3), true
if gotRetried != wantRetried {
t.Fatalf("retried: got %v, want %v", gotRetried, wantRetried)
}
mockTransport.setErrorCondition(nil)
al.FlushAndStop(context.Background())
al.mu.Lock()
defer al.mu.Unlock()
gotStored, err := al.storedCountLocked()
c.Assert(err, qt.IsNil)
if wantStored := 0; gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
if gotSent, wantSent := mockTransport.sentCount(), 1; gotSent != wantSent {
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
}
}
// TestEnqueueBeforeSetProfileID tests that logs enqueued before SetProfileId are not sent
func TestEnqueueBeforeSetProfileID(t *testing.T) {
c := qt.New(t)
al := loggerForTest(t, Opts{
RetryLimit: 100,
Logf: t.Logf,
Store: NewLogStore(&mem.Store{}),
})
err := al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
c.Assert(err, qt.IsNotNil)
al.FlushAndStop(context.Background())
al.mu.Lock()
defer al.mu.Unlock()
gotStored, err := al.storedCountLocked()
c.Assert(err, qt.IsNotNil)
if wantStored := 0; gotStored != wantStored {
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
}
}
// TestLogStoring tests that audit logs are persisted sorted by timestamp, oldest to newest
func TestLogSorting(t *testing.T) {
c := qt.New(t)
mockStore := NewLogStore(&mem.Store{})
logs := []*transaction{
{EventID: "1", Details: "log 3", TimeStamp: time.Now().Add(-time.Minute * 1)},
{EventID: "1", Details: "log 3", TimeStamp: time.Now().Add(-time.Minute * 2)},
{EventID: "2", Details: "log 2", TimeStamp: time.Now().Add(-time.Minute * 3)},
{EventID: "3", Details: "log 1", TimeStamp: time.Now().Add(-time.Minute * 4)},
}
wantLogs := []transaction{
{Details: "log 1"},
{Details: "log 2"},
{Details: "log 3"},
}
mockStore.save("test", logs)
gotLogs, err := mockStore.load("test")
c.Assert(err, qt.IsNil)
gotLogs = deduplicateAndSort(gotLogs)
for i := range gotLogs {
if want, got := wantLogs[i].Details, gotLogs[i].Details; want != got {
t.Fatalf("Details: got %v, want %v", got, want)
}
}
}
// mock implementations for testing
// newMockTransport returns a mock transport for testing
// If err is no nil, SendAuditLog will return this error if the send is attempted
// before the context is cancelled.
func newMockTransport(err error) *mockAuditLogTransport {
return &mockAuditLogTransport{
err: err,
attempts: make(chan int, 1),
}
}
type mockAuditLogTransport struct {
attempts chan int // channel to notify of send attempts
mu sync.Mutex
sendAttmpts int // number of attempts to send logs
sendCount int // number of logs sent by the transport
err error // error to return when sending logs
}
// waitForSendAttemptsToReach blocks until the number of send attempts reaches n
// This should be use only in tests where the transport is expected to retry sending logs
func (t *mockAuditLogTransport) waitForSendAttemptsToReach(n int) bool {
for attempts := range t.attempts {
if attempts >= n {
return true
}
}
return false
}
func (t *mockAuditLogTransport) setErrorCondition(err error) {
t.mu.Lock()
defer t.mu.Unlock()
t.err = err
}
func (t *mockAuditLogTransport) sentCount() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.sendCount
}
func (t *mockAuditLogTransport) SendAuditLog(ctx context.Context, _ tailcfg.AuditLogRequest) (err error) {
t.mu.Lock()
t.sendAttmpts += 1
defer func() {
a := t.sendAttmpts
t.mu.Unlock()
select {
case t.attempts <- a:
default:
}
}()
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if t.err != nil {
return t.err
}
t.sendCount += 1
return nil
}
var (
retriableError = mockError{errors.New("retriable error")}
nonRetriableError = mockError{errors.New("permanent failure error")}
)
type mockError struct {
error
}
func (e mockError) Retryable() bool {
return e == retriableError
}

View File

@@ -10,12 +10,11 @@ import (
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
)
// AuditLogFunc is any function that can be used to log audit actions performed by an [Actor].
//
// TODO(nickkhyl,barnstar): define a named string type for the action (in tailcfg?) and use it here.
type AuditLogFunc func(action, details string)
type AuditLogFunc func(action tailcfg.ClientAuditAction, details string) error
// Actor is any actor using the [ipnlocal.LocalBackend].
//
@@ -45,7 +44,7 @@ type Actor interface {
//
// If the auditLogger is non-nil, it is used to write details about the action
// to the audit log when required by the policy.
CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess ProfileAccess, auditLogger AuditLogFunc) error
CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess ProfileAccess, auditLogFn AuditLogFunc) error
// IsLocalSystem reports whether the actor is the Windows' Local System account.
//

View File

@@ -9,6 +9,7 @@ import (
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/util/syspolicy"
)
@@ -48,7 +49,7 @@ func (a actorWithPolicyChecks) CheckProfileAccess(profile ipn.LoginProfileView,
//
// TODO(nickkhyl): unexport it when we move [ipn.Actor] implementations from [ipnserver]
// and corp to this package.
func CheckDisconnectPolicy(actor Actor, profile ipn.LoginProfileView, reason string, auditLogger AuditLogFunc) error {
func CheckDisconnectPolicy(actor Actor, profile ipn.LoginProfileView, reason string, auditFn AuditLogFunc) error {
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); !alwaysOn {
return nil
}
@@ -58,15 +59,16 @@ func CheckDisconnectPolicy(actor Actor, profile ipn.LoginProfileView, reason str
if reason == "" {
return errors.New("disconnect not allowed: reason required")
}
if auditLogger != nil {
if auditFn != nil {
var details string
if username, _ := actor.Username(); username != "" { // best-effort; we don't have it on all platforms
details = fmt.Sprintf("%q is being disconnected by %q: %v", profile.Name(), username, reason)
} else {
details = fmt.Sprintf("%q is being disconnected: %v", profile.Name(), reason)
}
// TODO(nickkhyl,barnstar): use a const for DISCONNECT_NODE.
auditLogger("DISCONNECT_NODE", details)
if err := auditFn(tailcfg.AuditNodeDisconnect, details); err != nil {
return err
}
}
return nil
}

View File

@@ -119,6 +119,9 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
}
if pair, err := getCertPEMCached(cs, domain, now); err == nil {
if envknob.IsCertShareReadOnlyMode() {
return pair, nil
}
// If we got here, we have a valid unexpired cert.
// Check whether we should start an async renewal.
shouldRenew, err := b.shouldStartDomainRenewal(cs, domain, now, pair, minValidity)
@@ -134,7 +137,7 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
if minValidity == 0 {
logf("starting async renewal")
// Start renewal in the background, return current valid cert.
go b.getCertPEM(context.Background(), cs, logf, traceACME, domain, now, minValidity)
b.goTracker.Go(func() { getCertPEM(context.Background(), b, cs, logf, traceACME, domain, now, minValidity) })
return pair, nil
}
// If the caller requested a specific validity duration, fall through
@@ -142,7 +145,11 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
logf("starting sync renewal")
}
pair, err := b.getCertPEM(ctx, cs, logf, traceACME, domain, now, minValidity)
if envknob.IsCertShareReadOnlyMode() {
return nil, fmt.Errorf("retrieving cached TLS certificate failed and cert store is configured in read-only mode, not attempting to issue a new certificate: %w", err)
}
pair, err := getCertPEM(ctx, b, cs, logf, traceACME, domain, now, minValidity)
if err != nil {
logf("getCertPEM: %v", err)
return nil, err
@@ -358,7 +365,29 @@ type certStateStore struct {
testRoots *x509.CertPool
}
// TLSCertKeyReader is an interface implemented by state stores where it makes
// sense to read the TLS cert and key in a single operation that can be
// distinguished from generic state value reads. Currently this is only implemented
// by the kubestore.Store, which, in some cases, need to read cert and key from a
// non-cached TLS Secret.
type TLSCertKeyReader interface {
ReadTLSCertAndKey(domain string) ([]byte, []byte, error)
}
func (s certStateStore) Read(domain string, now time.Time) (*TLSCertKeyPair, error) {
// If we're using a store that supports atomic reads, use that
if kr, ok := s.StateStore.(TLSCertKeyReader); ok {
cert, key, err := kr.ReadTLSCertAndKey(domain)
if err != nil {
return nil, err
}
if !validCertPEM(domain, key, cert, s.testRoots, now) {
return nil, errCertExpired
}
return &TLSCertKeyPair{CertPEM: cert, KeyPEM: key, Cached: true}, nil
}
// Otherwise fall back to separate reads
certPEM, err := s.ReadState(ipn.StateKey(domain + ".crt"))
if err != nil {
return nil, err
@@ -446,7 +475,9 @@ func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKey
return cs.Read(domain, now)
}
func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) {
// getCertPem checks if a cert needs to be renewed and if so, renews it.
// It can be overridden in tests.
var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) {
acmeMu.Lock()
defer acmeMu.Unlock()
@@ -471,6 +502,10 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
return nil, err
}
if !isDefaultDirectoryURL(ac.DirectoryURL) {
logf("acme: using Directory URL %q", ac.DirectoryURL)
}
a, err := ac.GetReg(ctx, "" /* pre-RFC param */)
switch {
case err == nil:
@@ -737,7 +772,28 @@ func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain s
// binary's baked-in roots (LetsEncrypt). See tailscale/tailscale#14690.
return validateLeaf(leaf, intermediates, domain, now, bakedroots.Get())
}
return err == nil
if err == nil {
return true
}
// When pointed at a non-prod ACME server, we don't expect to have the CA
// in our system or baked-in roots. Verify only throws UnknownAuthorityError
// after first checking the leaf cert's expiry, hostnames etc, so we know
// that the only reason for an error is to do with constructing a full chain.
// Allow this error so that cert caching still works in testing environments.
if errors.As(err, &x509.UnknownAuthorityError{}) {
acmeURL := envknob.String("TS_DEBUG_ACME_DIRECTORY_URL")
if !isDefaultDirectoryURL(acmeURL) {
return true
}
}
return false
}
func isDefaultDirectoryURL(u string) bool {
return u == "" || u == acme.LetsEncryptURL
}
// validLookingCertDomain reports whether name looks like a valid domain name that

View File

@@ -6,6 +6,7 @@
package ipnlocal
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@@ -14,11 +15,17 @@ import (
"embed"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/envknob"
"tailscale.com/ipn/store/mem"
"tailscale.com/tstest"
"tailscale.com/types/logger"
"tailscale.com/util/must"
)
func TestValidLookingCertDomain(t *testing.T) {
@@ -47,10 +54,10 @@ var certTestFS embed.FS
func TestCertStoreRoundTrip(t *testing.T) {
const testDomain = "example.com"
// Use a fixed verification timestamp so validity doesn't fall off when the
// cert expires. If you update the test data below, this may also need to be
// updated.
// Use fixed verification timestamps so validity doesn't change over time.
// If you update the test data below, these may also need to be updated.
testNow := time.Date(2023, time.February, 10, 0, 0, 0, 0, time.UTC)
testExpired := time.Date(2026, time.February, 10, 0, 0, 0, 0, time.UTC)
// To re-generate a root certificate and domain certificate for testing,
// use:
@@ -78,14 +85,20 @@ func TestCertStoreRoundTrip(t *testing.T) {
}
tests := []struct {
name string
store certStore
name string
store certStore
debugACMEURL bool
}{
{"FileStore", certFileStore{dir: t.TempDir(), testRoots: roots}},
{"StateStore", certStateStore{StateStore: new(mem.Store), testRoots: roots}},
{"FileStore", certFileStore{dir: t.TempDir(), testRoots: roots}, false},
{"FileStore_UnknownCA", certFileStore{dir: t.TempDir()}, true},
{"StateStore", certStateStore{StateStore: new(mem.Store), testRoots: roots}, false},
{"StateStore_UnknownCA", certStateStore{StateStore: new(mem.Store)}, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.debugACMEURL {
t.Setenv("TS_DEBUG_ACME_DIRECTORY_URL", "https://acme-staging-v02.api.letsencrypt.org/directory")
}
if err := test.store.WriteTLSCertAndKey(testDomain, testCert, testKey); err != nil {
t.Fatalf("WriteTLSCertAndKey: unexpected error: %v", err)
}
@@ -99,6 +112,10 @@ func TestCertStoreRoundTrip(t *testing.T) {
if diff := cmp.Diff(kp.KeyPEM, testKey); diff != "" {
t.Errorf("Key (-got, +want):\n%s", diff)
}
unexpected, err := test.store.Read(testDomain, testExpired)
if err != errCertExpired {
t.Fatalf("Read: expected expiry error: %v", string(unexpected.CertPEM))
}
})
}
}
@@ -211,3 +228,151 @@ func TestDebugACMEDirectoryURL(t *testing.T) {
})
}
}
func TestGetCertPEMWithValidity(t *testing.T) {
const testDomain = "example.com"
b := &LocalBackend{
store: &mem.Store{},
varRoot: t.TempDir(),
ctx: context.Background(),
logf: t.Logf,
}
certDir, err := b.certDir()
if err != nil {
t.Fatalf("certDir error: %v", err)
}
if _, err := b.getCertStore(); err != nil {
t.Fatalf("getCertStore error: %v", err)
}
testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem")
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(testRoot) {
t.Fatal("Unable to add test CA to the cert pool")
}
testX509Roots = roots
defer func() { testX509Roots = nil }()
tests := []struct {
name string
now time.Time
// storeCerts is true if the test cert and key should be written to store.
storeCerts bool
readOnlyMode bool // TS_READ_ONLY_CERTS env var
wantAsyncRenewal bool // async issuance should be started
wantIssuance bool // sync issuance should be started
wantErr bool
}{
{
name: "valid_no_renewal",
now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC),
storeCerts: true,
wantAsyncRenewal: false,
wantIssuance: false,
wantErr: false,
},
{
name: "issuance_needed",
now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC),
storeCerts: false,
wantAsyncRenewal: false,
wantIssuance: true,
wantErr: false,
},
{
name: "renewal_needed",
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
storeCerts: true,
wantAsyncRenewal: true,
wantIssuance: false,
wantErr: false,
},
{
name: "renewal_needed_read_only_mode",
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
storeCerts: true,
readOnlyMode: true,
wantAsyncRenewal: false,
wantIssuance: false,
wantErr: false,
},
{
name: "no_certs_read_only_mode",
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
storeCerts: false,
readOnlyMode: true,
wantAsyncRenewal: false,
wantIssuance: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.readOnlyMode {
envknob.Setenv("TS_CERT_SHARE_MODE", "ro")
}
os.RemoveAll(certDir)
if tt.storeCerts {
os.MkdirAll(certDir, 0755)
if err := os.WriteFile(filepath.Join(certDir, "example.com.crt"),
must.Get(os.ReadFile("testdata/example.com.pem")), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certDir, "example.com.key"),
must.Get(os.ReadFile("testdata/example.com-key.pem")), 0644); err != nil {
t.Fatal(err)
}
}
b.clock = tstest.NewClock(tstest.ClockOpts{Start: tt.now})
allDone := make(chan bool, 1)
defer b.goTracker.AddDoneCallback(func() {
b.mu.Lock()
defer b.mu.Unlock()
if b.goTracker.RunningGoroutines() > 0 {
return
}
select {
case allDone <- true:
default:
}
})()
// Set to true if get getCertPEM is called. GetCertPEM can be called in a goroutine for async
// renewal or in the main goroutine if issuance is required to obtain valid TLS credentials.
getCertPemWasCalled := false
getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) {
getCertPemWasCalled = true
return nil, nil
}
prevGoRoutines := b.goTracker.StartedGoroutines()
_, err = b.GetCertPEMWithValidity(context.Background(), testDomain, 0)
if (err != nil) != tt.wantErr {
t.Errorf("b.GetCertPemWithValidity got err %v, wants error: '%v'", err, tt.wantErr)
}
// GetCertPEMWithValidity calls getCertPEM in a goroutine if async renewal is needed. That's the
// only goroutine it starts, so this can be used to test if async renewal was started.
gotAsyncRenewal := b.goTracker.StartedGoroutines()-prevGoRoutines != 0
if gotAsyncRenewal {
select {
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for goroutines to finish")
case <-allDone:
}
}
// Verify that async renewal was triggered if expected.
if tt.wantAsyncRenewal != gotAsyncRenewal {
t.Fatalf("wants getCertPem to be called async: %v, got called %v", tt.wantAsyncRenewal, gotAsyncRenewal)
}
// Verify that (non-async) issuance was started if expected.
gotIssuance := getCertPemWasCalled && !gotAsyncRenewal
if tt.wantIssuance != gotIssuance {
t.Errorf("wants getCertPem to be called: %v, got called %v", tt.wantIssuance, gotIssuance)
}
})
}
}

View File

@@ -57,10 +57,12 @@ import (
"tailscale.com/health/healthmsg"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/auditlog"
"tailscale.com/ipn/conffile"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/policy"
memstore "tailscale.com/ipn/store/mem"
"tailscale.com/log/sockstatlog"
"tailscale.com/logpolicy"
"tailscale.com/net/captivedetection"
@@ -450,6 +452,12 @@ type LocalBackend struct {
// Each callback is called exactly once in unspecified order and without b.mu held.
// Returned errors are logged but otherwise ignored and do not affect the shutdown process.
shutdownCbs set.HandleSet[func() error]
// auditLogger, if non-nil, manages audit logging for the backend.
//
// It queues, persists, and sends audit logs
// to the control client. auditLogger has the same lifespan as b.cc.
auditLogger *auditlog.Logger
}
// HealthTracker returns the health tracker for the backend.
@@ -1679,6 +1687,15 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
b.logf("Failed to save new controlclient state: %v", err)
}
}
// Update the audit logger with the current profile ID.
if b.auditLogger != nil && prefsChanged {
pid := b.pm.CurrentProfile().ID()
if err := b.auditLogger.SetProfileID(pid); err != nil {
b.logf("Failed to set profile ID in audit logger: %v", err)
}
}
// initTKALocked is dependent on CurrentProfile.ID, which is initialized
// (for new profiles) on the first call to b.pm.SetPrefs.
if err := b.initTKALocked(); err != nil {
@@ -2386,6 +2403,27 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
debugFlags = append([]string{"netstack"}, debugFlags...)
}
var auditLogShutdown func()
// Audit logging is only available if the client has set up a proper persistent
// store for the logs in sys.
store, ok := b.sys.AuditLogStore.GetOK()
if !ok {
b.logf("auditlog: [unexpected] no persistent audit log storage configured. using memory store.")
store = auditlog.NewLogStore(&memstore.Store{})
}
al := auditlog.NewLogger(auditlog.Opts{
Logf: b.logf,
RetryLimit: 32,
Store: store,
})
b.auditLogger = al
auditLogShutdown = func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
al.FlushAndStop(ctx)
}
// TODO(apenwarr): The only way to change the ServerURL is to
// re-run b.Start, because this is the only place we create a
// new controlclient. EditPrefs allows you to overwrite ServerURL,
@@ -2411,6 +2449,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
C2NHandler: http.HandlerFunc(b.handleC2N),
DialPlan: &b.dialPlan, // pointer because it can't be copied
ControlKnobs: b.sys.ControlKnobs(),
Shutdown: auditLogShutdown,
// Don't warn about broken Linux IP forwarding when
// netstack is being used.
@@ -4263,6 +4302,21 @@ func (b *LocalBackend) MaybeClearAppConnector(mp *ipn.MaskedPrefs) error {
return err
}
var errNoAuditLogger = errors.New("no audit logger configured")
func (b *LocalBackend) getAuditLoggerLocked() ipnauth.AuditLogFunc {
logger := b.auditLogger
return func(action tailcfg.ClientAuditAction, details string) error {
if logger == nil {
return errNoAuditLogger
}
if err := logger.Enqueue(action, details); err != nil {
return fmt.Errorf("failed to enqueue audit log %v %q: %w", action, details, err)
}
return nil
}
}
// EditPrefs applies the changes in mp to the current prefs,
// acting as the tailscaled itself rather than a specific user.
func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) {
@@ -4288,9 +4342,8 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip
unlock := b.lockAndGetUnlock()
defer unlock()
if mp.WantRunningSet && !mp.WantRunning && b.pm.CurrentPrefs().WantRunning() {
// TODO(barnstar,nickkhyl): replace loggerFn with the actual audit logger.
loggerFn := func(action, details string) { b.logf("[audit]: %s: %s", action, details) }
if err := actor.CheckProfileAccess(b.pm.CurrentProfile(), ipnauth.Disconnect, loggerFn); err != nil {
if err := actor.CheckProfileAccess(b.pm.CurrentProfile(), ipnauth.Disconnect, b.getAuditLoggerLocked()); err != nil {
b.logf("check profile access failed: %v", err)
return ipn.PrefsView{}, err
}
@@ -5874,6 +5927,15 @@ func (b *LocalBackend) requestEngineStatusAndWait() {
func (b *LocalBackend) setControlClientLocked(cc controlclient.Client) {
b.cc = cc
b.ccAuto, _ = cc.(*controlclient.Auto)
if b.auditLogger != nil {
if err := b.auditLogger.SetProfileID(b.pm.CurrentProfile().ID()); err != nil {
b.logf("audit logger set profile ID failure: %v", err)
}
if err := b.auditLogger.Start(b.ccAuto); err != nil {
b.logf("audit logger start failure: %v", err)
}
}
}
// resetControlClientLocked sets b.cc to nil and returns the old value. If the
@@ -8238,7 +8300,14 @@ func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcf
services[sn].Active = true
}
return slicesx.MapValues(services)
servicesList := slicesx.MapValues(services)
// [slicesx.MapValues] provides the values in an indeterminate order, but since we'll
// be hashing a representation of this list later we want it to be in a consistent
// order.
slices.SortFunc(servicesList, func(a, b *tailcfg.VIPService) int {
return strings.Compare(a.Name.String(), b.Name.String())
})
return servicesList
}
var (

View File

@@ -9,34 +9,33 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.1.0/LICENSE))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.24.1/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/config](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/config) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.26.5/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.16.16/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.14.11/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.2.10/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.5.10/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.7.2/internal/ini/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.24.1/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.10.4/service/internal/accept-encoding/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/presigned-url/v1.10.10/service/internal/presigned-url/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.36.0/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/config](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/config) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.29.5/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.17.58/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.16.27/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.31/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.31/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.8.2/internal/ini/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.36.0/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.12.2/service/internal/accept-encoding/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/presigned-url/v1.12.12/service/internal/presigned-url/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssm](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssm) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssm/v1.44.7/service/ssm/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sso](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sso) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sso/v1.18.7/service/sso/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssooidc) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssooidc/v1.21.7/service/ssooidc/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.26.7/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.19.0/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.19.0/internal/sync/singleflight/LICENSE))
- [github.com/bits-and-blooms/bitset](https://pkg.go.dev/github.com/bits-and-blooms/bitset) ([BSD-3-Clause](https://github.com/bits-and-blooms/bitset/blob/v1.13.0/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/sso](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sso) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sso/v1.24.14/service/sso/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssooidc) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssooidc/v1.28.13/service/ssooidc/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.33.13/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.22.2/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.22.2/internal/sync/singleflight/LICENSE))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/65c67c9f46e6/LICENSE))
- [github.com/djherbis/times](https://pkg.go.dev/github.com/djherbis/times) ([MIT](https://github.com/djherbis/times/blob/v1.6.0/LICENSE))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.7.0/LICENSE))
- [github.com/gaissmai/bart](https://pkg.go.dev/github.com/gaissmai/bart) ([MIT](https://github.com/gaissmai/bart/blob/v0.11.1/LICENSE))
- [github.com/gaissmai/bart](https://pkg.go.dev/github.com/gaissmai/bart) ([MIT](https://github.com/gaissmai/bart/blob/v0.18.0/LICENSE))
- [github.com/go-json-experiment/json](https://pkg.go.dev/github.com/go-json-experiment/json) ([BSD-3-Clause](https://github.com/go-json-experiment/json/blob/6a9a0fde9288/LICENSE))
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/76236955d466/LICENSE))
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/5e242ec57806/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.2.0/LICENSE))
- [github.com/illarion/gonotify/v2](https://pkg.go.dev/github.com/illarion/gonotify/v2) ([MIT](https://github.com/illarion/gonotify/blob/v2.0.3/LICENSE))
- [github.com/illarion/gonotify/v3](https://pkg.go.dev/github.com/illarion/gonotify/v3) ([MIT](https://github.com/illarion/gonotify/blob/v3.0.2/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/8c70d406f6d2/LICENSE))
- [github.com/jellydator/ttlcache/v3](https://pkg.go.dev/github.com/jellydator/ttlcache/v3) ([MIT](https://github.com/jellydator/ttlcache/blob/v3.1.0/LICENSE))
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
@@ -65,17 +64,17 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [go4.org/unsafe/assume-no-moving-gc](https://pkg.go.dev/go4.org/unsafe/assume-no-moving-gc) ([BSD-3-Clause](https://github.com/go4org/unsafe-assume-no-moving-gc/blob/e7c30c78aeb2/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/a8ea4be8:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/7588d65b:LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.33.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/939b2ce7:LICENSE))
- [golang.org/x/mobile](https://pkg.go.dev/golang.org/x/mobile) ([BSD-3-Clause](https://cs.opensource.google/go/x/mobile/+/81131f64:LICENSE))
- [golang.org/x/mod/semver](https://pkg.go.dev/golang.org/x/mod/semver) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.22.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.34.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.10.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/1c14dcad:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.28.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.21.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.9.0:LICENSE))
- [golang.org/x/tools](https://pkg.go.dev/golang.org/x/tools) ([BSD-3-Clause](https://cs.opensource.google/go/x/tools/+/v0.29.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/64c016c92987/LICENSE))
- [golang.org/x/mod/semver](https://pkg.go.dev/golang.org/x/mod/semver) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.23.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.35.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.11.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.30.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.29.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.22.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.10.0:LICENSE))
- [golang.org/x/tools](https://pkg.go.dev/golang.org/x/tools) ([BSD-3-Clause](https://cs.opensource.google/go/x/tools/+/v0.30.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/9414b50a5633/LICENSE))
- [inet.af/netaddr](https://pkg.go.dev/inet.af/netaddr) ([BSD-3-Clause](Unknown))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))

View File

@@ -28,20 +28,19 @@ See also the dependencies in the [Tailscale CLI][].
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.33.13/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.22.2/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.22.2/internal/sync/singleflight/LICENSE))
- [github.com/bits-and-blooms/bitset](https://pkg.go.dev/github.com/bits-and-blooms/bitset) ([BSD-3-Clause](https://github.com/bits-and-blooms/bitset/blob/v1.13.0/LICENSE))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/65c67c9f46e6/LICENSE))
- [github.com/digitalocean/go-smbios/smbios](https://pkg.go.dev/github.com/digitalocean/go-smbios/smbios) ([Apache-2.0](https://github.com/digitalocean/go-smbios/blob/390a4f403a8e/LICENSE.md))
- [github.com/djherbis/times](https://pkg.go.dev/github.com/djherbis/times) ([MIT](https://github.com/djherbis/times/blob/v1.6.0/LICENSE))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.7.0/LICENSE))
- [github.com/gaissmai/bart](https://pkg.go.dev/github.com/gaissmai/bart) ([MIT](https://github.com/gaissmai/bart/blob/v0.11.1/LICENSE))
- [github.com/go-json-experiment/json](https://pkg.go.dev/github.com/go-json-experiment/json) ([BSD-3-Clause](https://github.com/go-json-experiment/json/blob/6a9a0fde9288/LICENSE))
- [github.com/gaissmai/bart](https://pkg.go.dev/github.com/gaissmai/bart) ([MIT](https://github.com/gaissmai/bart/blob/v0.18.0/LICENSE))
- [github.com/go-json-experiment/json](https://pkg.go.dev/github.com/go-json-experiment/json) ([BSD-3-Clause](https://github.com/go-json-experiment/json/blob/d3c622f1b874/LICENSE))
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/76236955d466/LICENSE))
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/5e242ec57806/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.2.0/LICENSE))
- [github.com/illarion/gonotify/v2](https://pkg.go.dev/github.com/illarion/gonotify/v2) ([MIT](https://github.com/illarion/gonotify/blob/v2.0.3/LICENSE))
- [github.com/illarion/gonotify/v3](https://pkg.go.dev/github.com/illarion/gonotify/v3) ([MIT](https://github.com/illarion/gonotify/blob/v3.0.2/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/15c9b8791914/LICENSE))
- [github.com/jellydator/ttlcache/v3](https://pkg.go.dev/github.com/jellydator/ttlcache/v3) ([MIT](https://github.com/jellydator/ttlcache/blob/v3.1.0/LICENSE))
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
@@ -69,14 +68,14 @@ See also the dependencies in the [Tailscale CLI][].
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/a8ea4be8:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/7588d65b:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.34.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.10.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/1c14dcad:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.28.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.21.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.9.0:LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.35.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/939b2ce7:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.35.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.11.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.30.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.29.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.22.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.10.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/9414b50a5633/LICENSE))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))

View File

@@ -33,7 +33,6 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.33.13/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.22.2/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.22.2/internal/sync/singleflight/LICENSE))
- [github.com/bits-and-blooms/bitset](https://pkg.go.dev/github.com/bits-and-blooms/bitset) ([BSD-3-Clause](https://github.com/bits-and-blooms/bitset/blob/v1.13.0/LICENSE))
- [github.com/coder/websocket](https://pkg.go.dev/github.com/coder/websocket) ([ISC](https://github.com/coder/websocket/blob/v1.8.12/LICENSE.txt))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/65c67c9f46e6/LICENSE))
- [github.com/creack/pty](https://pkg.go.dev/github.com/creack/pty) ([MIT](https://github.com/creack/pty/blob/v1.1.23/LICENSE))
@@ -41,8 +40,8 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/digitalocean/go-smbios/smbios](https://pkg.go.dev/github.com/digitalocean/go-smbios/smbios) ([Apache-2.0](https://github.com/digitalocean/go-smbios/blob/390a4f403a8e/LICENSE.md))
- [github.com/djherbis/times](https://pkg.go.dev/github.com/djherbis/times) ([MIT](https://github.com/djherbis/times/blob/v1.6.0/LICENSE))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.7.0/LICENSE))
- [github.com/gaissmai/bart](https://pkg.go.dev/github.com/gaissmai/bart) ([MIT](https://github.com/gaissmai/bart/blob/v0.11.1/LICENSE))
- [github.com/go-json-experiment/json](https://pkg.go.dev/github.com/go-json-experiment/json) ([BSD-3-Clause](https://github.com/go-json-experiment/json/blob/6a9a0fde9288/LICENSE))
- [github.com/gaissmai/bart](https://pkg.go.dev/github.com/gaissmai/bart) ([MIT](https://github.com/gaissmai/bart/blob/v0.18.0/LICENSE))
- [github.com/go-json-experiment/json](https://pkg.go.dev/github.com/go-json-experiment/json) ([BSD-3-Clause](https://github.com/go-json-experiment/json/blob/d3c622f1b874/LICENSE))
- [github.com/go-ole/go-ole](https://pkg.go.dev/github.com/go-ole/go-ole) ([MIT](https://github.com/go-ole/go-ole/blob/v1.3.0/LICENSE))
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/76236955d466/LICENSE))
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
@@ -52,7 +51,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/gorilla/csrf](https://pkg.go.dev/github.com/gorilla/csrf) ([BSD-3-Clause](https://github.com/gorilla/csrf/blob/9dd6af1f6d30/LICENSE))
- [github.com/gorilla/securecookie](https://pkg.go.dev/github.com/gorilla/securecookie) ([BSD-3-Clause](https://github.com/gorilla/securecookie/blob/v1.1.2/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.2.0/LICENSE))
- [github.com/illarion/gonotify/v2](https://pkg.go.dev/github.com/illarion/gonotify/v2) ([MIT](https://github.com/illarion/gonotify/blob/v2.0.3/LICENSE))
- [github.com/illarion/gonotify/v3](https://pkg.go.dev/github.com/illarion/gonotify/v3) ([MIT](https://github.com/illarion/gonotify/blob/v3.0.2/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/8c70d406f6d2/LICENSE))
- [github.com/jellydator/ttlcache/v3](https://pkg.go.dev/github.com/jellydator/ttlcache/v3) ([MIT](https://github.com/jellydator/ttlcache/blob/v3.1.0/LICENSE))
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
@@ -91,15 +90,15 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/a8ea4be8:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/7588d65b:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.34.0:LICENSE))
- [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.25.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.10.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/1c14dcad:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.28.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.21.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.9.0:LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.35.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/939b2ce7:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.35.0:LICENSE))
- [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.26.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.11.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.30.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.29.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.22.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.10.0:LICENSE))
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=0fa3db229ce2))
- [golang.zx2c4.com/wireguard/windows/tunnel/winipcfg](https://pkg.go.dev/golang.zx2c4.com/wireguard/windows/tunnel/winipcfg) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING?h=v0.5.3))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/9414b50a5633/LICENSE))

View File

@@ -35,7 +35,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/b75a8a7d7eb0/LICENSE))
- [github.com/djherbis/times](https://pkg.go.dev/github.com/djherbis/times) ([MIT](https://github.com/djherbis/times/blob/v1.6.0/LICENSE))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.7.0/LICENSE))
- [github.com/go-json-experiment/json](https://pkg.go.dev/github.com/go-json-experiment/json) ([BSD-3-Clause](https://github.com/go-json-experiment/json/blob/6a9a0fde9288/LICENSE))
- [github.com/go-json-experiment/json](https://pkg.go.dev/github.com/go-json-experiment/json) ([BSD-3-Clause](https://github.com/go-json-experiment/json/blob/d3c622f1b874/LICENSE))
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/5e242ec57806/LICENSE))
@@ -62,23 +62,23 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/tailscale/go-winio](https://pkg.go.dev/github.com/tailscale/go-winio) ([MIT](https://github.com/tailscale/go-winio/blob/c4f33415bf55/LICENSE))
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/4d49adab4de7/LICENSE))
- [github.com/tailscale/walk](https://pkg.go.dev/github.com/tailscale/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/72f92d5087d4/LICENSE))
- [github.com/tailscale/win](https://pkg.go.dev/github.com/tailscale/win) ([BSD-3-Clause](https://github.com/tailscale/win/blob/cfd3289ef17f/LICENSE))
- [github.com/tailscale/walk](https://pkg.go.dev/github.com/tailscale/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/04068c1cab63/LICENSE))
- [github.com/tailscale/win](https://pkg.go.dev/github.com/tailscale/win) ([BSD-3-Clause](https://github.com/tailscale/win/blob/5992cb43ca35/LICENSE))
- [github.com/tailscale/xnet/webdav](https://pkg.go.dev/github.com/tailscale/xnet/webdav) ([BSD-3-Clause](https://github.com/tailscale/xnet/blob/8497ac4dab2e/LICENSE))
- [github.com/tc-hib/winres](https://pkg.go.dev/github.com/tc-hib/winres) ([0BSD](https://github.com/tc-hib/winres/blob/v0.2.1/LICENSE))
- [github.com/vishvananda/netns](https://pkg.go.dev/github.com/vishvananda/netns) ([Apache-2.0](https://github.com/vishvananda/netns/blob/v0.0.4/LICENSE))
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/a8ea4be8:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/7588d65b:LICENSE))
- [golang.org/x/image/bmp](https://pkg.go.dev/golang.org/x/image/bmp) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.23.0:LICENSE))
- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.22.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.34.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.10.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/1c14dcad:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.28.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.21.0:LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.35.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/939b2ce7:LICENSE))
- [golang.org/x/image/bmp](https://pkg.go.dev/golang.org/x/image/bmp) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.24.0:LICENSE))
- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.23.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.35.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.11.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.30.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.29.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.22.0:LICENSE))
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=0fa3db229ce2))
- [golang.zx2c4.com/wireguard/windows/tunnel/winipcfg](https://pkg.go.dev/golang.zx2c4.com/wireguard/windows/tunnel/winipcfg) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING?h=v0.5.3))
- [google.golang.org/protobuf](https://pkg.go.dev/google.golang.org/protobuf) ([BSD-3-Clause](https://github.com/protocolbuffers/protobuf-go/blob/v1.35.1/LICENSE))

View File

@@ -89,7 +89,7 @@ func availableEndpoints(derpMap *tailcfg.DERPMap, preferredDERPRegionID int, log
// Use the DERP IPs as captive portal detection endpoints. Using IPs is better than hostnames
// because they do not depend on DNS resolution.
for _, region := range derpMap.Regions {
if region.Avoid {
if region.Avoid || region.NoMeasureNoHome {
continue
}
for _, node := range region.Nodes {

View File

@@ -387,6 +387,9 @@ type probePlan map[string][]probe
func sortRegions(dm *tailcfg.DERPMap, last *Report, preferredDERP int) (prev []*tailcfg.DERPRegion) {
prev = make([]*tailcfg.DERPRegion, 0, len(dm.Regions))
for _, reg := range dm.Regions {
if reg.NoMeasureNoHome {
continue
}
// include an otherwise avoid region if it is the current preferred region
if reg.Avoid && reg.RegionID != preferredDERP {
continue
@@ -533,7 +536,7 @@ func makeProbePlanInitial(dm *tailcfg.DERPMap, ifState *netmon.State) (plan prob
plan = make(probePlan)
for _, reg := range dm.Regions {
if len(reg.Nodes) == 0 {
if reg.NoMeasureNoHome || len(reg.Nodes) == 0 {
continue
}

View File

@@ -455,7 +455,7 @@ func TestMakeProbePlan(t *testing.T) {
basicMap := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{},
}
for rid := 1; rid <= 5; rid++ {
for rid := 1; rid <= 6; rid++ {
var nodes []*tailcfg.DERPNode
for nid := 0; nid < rid; nid++ {
nodes = append(nodes, &tailcfg.DERPNode{
@@ -467,8 +467,9 @@ func TestMakeProbePlan(t *testing.T) {
})
}
basicMap.Regions[rid] = &tailcfg.DERPRegion{
RegionID: rid,
Nodes: nodes,
RegionID: rid,
Nodes: nodes,
NoMeasureNoHome: rid == 6,
}
}

42
net/netmon/loghelper.go Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package netmon
import (
"sync"
"tailscale.com/types/logger"
)
// LinkChangeLogLimiter returns a new [logger.Logf] that logs each unique
// format string to the underlying logger only once per major LinkChange event.
//
// The returned function should be called when the logger is no longer needed,
// to release resources from the Monitor.
func LinkChangeLogLimiter(logf logger.Logf, nm *Monitor) (_ logger.Logf, unregister func()) {
var formatSeen sync.Map // map[string]bool
unregister = nm.RegisterChangeCallback(func(cd *ChangeDelta) {
// If we're in a major change or a time jump, clear the seen map.
if cd.Major || cd.TimeJumped {
formatSeen.Clear()
}
})
return func(format string, args ...any) {
// We only store 'true' in the map, so if it's present then it
// means we've already logged this format string.
_, loaded := formatSeen.LoadOrStore(format, true)
if loaded {
// TODO(andrew-d): we may still want to log this
// message every N minutes (1x/hour?) even if it's been
// seen, so that debugging doesn't require searching
// back in the logs for an unbounded amount of time.
//
// See: https://github.com/tailscale/tailscale/issues/13145
return
}
logf(format, args...)
}, unregister
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package netmon
import (
"bytes"
"fmt"
"testing"
)
func TestLinkChangeLogLimiter(t *testing.T) {
mon, err := New(t.Logf)
if err != nil {
t.Fatal(err)
}
defer mon.Close()
var logBuffer bytes.Buffer
logf := func(format string, args ...any) {
t.Logf("captured log: "+format, args...)
if format[len(format)-1] != '\n' {
format += "\n"
}
fmt.Fprintf(&logBuffer, format, args...)
}
logf, unregister := LinkChangeLogLimiter(logf, mon)
defer unregister()
// Log once, which should write to our log buffer.
logf("hello %s", "world")
if got := logBuffer.String(); got != "hello world\n" {
t.Errorf("unexpected log buffer contents: %q", got)
}
// Log again, which should not write to our log buffer.
logf("hello %s", "andrew")
if got := logBuffer.String(); got != "hello world\n" {
t.Errorf("unexpected log buffer contents: %q", got)
}
// Log a different message, which should write to our log buffer.
logf("other message")
if got := logBuffer.String(); got != "hello world\nother message\n" {
t.Errorf("unexpected log buffer contents: %q", got)
}
// Synthesize a fake major change event, which should clear the format
// string cache and allow the next log to write to our log buffer.
//
// InjectEvent doesn't work because it's not a major event, so we
// instead reach into the netmon and grab the callback, and then call
// it ourselves.
mon.mu.Lock()
var cb func(*ChangeDelta)
for _, c := range mon.cbs {
cb = c
break
}
mon.mu.Unlock()
cb(&ChangeDelta{Major: true})
logf("hello %s", "world")
if got := logBuffer.String(); got != "hello world\nother message\nhello world\n" {
t.Errorf("unexpected log buffer contents: %q", got)
}
// Unregistering the callback should clear our 'cbs' set.
unregister()
mon.mu.Lock()
if len(mon.cbs) != 0 {
t.Errorf("expected no callbacks, got %v", mon.cbs)
}
mon.mu.Unlock()
}

View File

@@ -610,8 +610,9 @@ func (c *Client) tryUPnPPortmapWithDevice(
}
// From the UPnP spec: http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v2-Service.pdf
// 402: Invalid Args (see: https://github.com/tailscale/tailscale/issues/15223)
// 725: OnlyPermanentLeasesSupported
if ok && code == 725 {
if ok && (code == 402 || code == 725) {
newPort, err = addAnyPortMapping(
ctx,
client,
@@ -620,7 +621,7 @@ func (c *Client) tryUPnPPortmapWithDevice(
internal.Addr().String(),
0, // permanent
)
c.vlogf("addAnyPortMapping: 725 retry %v, err=%q", newPort, err)
c.vlogf("addAnyPortMapping: errcode=%d retried: port=%v err=%v", code, newPort, err)
}
}
if err != nil {

View File

@@ -628,6 +628,96 @@ func TestGetUPnPPortMapping(t *testing.T) {
}
}
func TestGetUPnPPortMapping_LeaseDuration(t *testing.T) {
testCases := []struct {
name string
resp string
}{
{"only_permanent_leases", testAddPortMappingPermanentLease},
{"invalid_args", testAddPortMappingPermanentLease_InvalidArgs},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// This is a very basic fake UPnP server handler.
var sawRequestWithLease atomic.Bool
handlers := map[string]any{
"AddPortMapping": func(body []byte) (int, string) {
// Decode a minimal body to determine whether we skip the request or not.
var req struct {
Protocol string `xml:"NewProtocol"`
InternalPort string `xml:"NewInternalPort"`
ExternalPort string `xml:"NewExternalPort"`
InternalClient string `xml:"NewInternalClient"`
LeaseDuration string `xml:"NewLeaseDuration"`
}
if err := xml.Unmarshal(body, &req); err != nil {
t.Errorf("bad request: %v", err)
return http.StatusBadRequest, "bad request"
}
if req.Protocol != "UDP" {
t.Errorf(`got Protocol=%q, want "UDP"`, req.Protocol)
}
if req.LeaseDuration != "0" {
// Return a fake error to ensure that we fall back to a permanent lease.
sawRequestWithLease.Store(true)
return http.StatusOK, tc.resp
}
return http.StatusOK, testAddPortMappingResponse
},
"GetExternalIPAddress": testGetExternalIPAddressResponse,
"GetStatusInfo": testGetStatusInfoResponse,
"DeletePortMapping": "", // Do nothing for test
}
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
if err != nil {
t.Fatal(err)
}
defer igd.Close()
igd.SetUPnPHandler(&upnpServer{
t: t,
Desc: testRootDesc,
Control: map[string]map[string]any{
"/ctl/IPConn": handlers,
"/upnp/control/yomkmsnooi/wanipconn-1": handlers,
},
})
ctx := context.Background()
c := newTestClient(t, igd)
c.debug.VerboseLogs = true
t.Logf("Listening on upnp=%v", c.testUPnPPort)
defer c.Close()
// Actually test the UPnP port mapping.
mustProbeUPnP(t, ctx, c)
gw, myIP, ok := c.gatewayAndSelfIP()
if !ok {
t.Fatalf("could not get gateway and self IP")
}
t.Logf("gw=%v myIP=%v", gw, myIP)
ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
if !ok {
t.Fatal("could not get UPnP port mapping")
}
if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want {
t.Errorf("bad external address; got %v want %v", got, want)
}
if !sawRequestWithLease.Load() {
t.Errorf("wanted request with lease, but didn't see one")
}
t.Logf("external IP: %v", ext)
})
}
}
// TestGetUPnPPortMapping_NoValidServices tests that getUPnPPortMapping doesn't
// crash when a valid UPnP response with no supported services is discovered
// and parsed.
@@ -1045,6 +1135,23 @@ const testAddPortMappingPermanentLease = `<?xml version="1.0"?>
</s:Envelope>
`
const testAddPortMappingPermanentLease_InvalidArgs = `<?xml version="1.0"?>
<SOAP:Envelope xmlns:SOAP="http://schemas.xmlsoap.org/soap/envelope/" SOAP:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<SOAP:Body>
<SOAP:Fault>
<faultcode>SOAP:Client</faultcode>
<faultstring>UPnPError</faultstring>
<detail>
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
<errorCode>402</errorCode>
<errorDescription>Invalid Args</errorDescription>
</UPnPError>
</detail>
</SOAP:Fault>
</SOAP:Body>
</SOAP:Envelope>
`
const testAddPortMappingResponse = `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>

View File

@@ -12,6 +12,7 @@ package tlsdial
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"errors"
@@ -246,6 +247,46 @@ func SetConfigExpectedCert(c *tls.Config, certDNSName string) {
}
}
// SetConfigExpectedCertHash configures c's VerifyPeerCertificate function
// to require that exactly 1 cert is presented, and that the hex of its SHA256 hash
// is equal to wantFullCertSHA256Hex and that it's a valid cert for c.ServerName.
func SetConfigExpectedCertHash(c *tls.Config, wantFullCertSHA256Hex string) {
if c.VerifyPeerCertificate != nil {
panic("refusing to override tls.Config.VerifyPeerCertificate")
}
// Set InsecureSkipVerify to prevent crypto/tls from doing its
// own cert verification, but do the same work that it'd do
// (but using certDNSName) in the VerifyPeerCertificate hook.
c.InsecureSkipVerify = true
c.VerifyConnection = nil
c.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("no certs presented")
}
if len(rawCerts) > 1 {
return errors.New("unexpected multiple certs presented")
}
if fmt.Sprintf("%02x", sha256.Sum256(rawCerts[0])) != wantFullCertSHA256Hex {
return fmt.Errorf("cert hash does not match expected cert hash")
}
cert, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return fmt.Errorf("ParseCertificate: %w", err)
}
if err := cert.VerifyHostname(c.ServerName); err != nil {
return fmt.Errorf("cert does not match server name %q: %w", c.ServerName, err)
}
now := time.Now()
if now.After(cert.NotAfter) {
return fmt.Errorf("cert expired %v", cert.NotAfter)
}
if now.Before(cert.NotBefore) {
return fmt.Errorf("cert not yet valid until %v; is your clock correct?", cert.NotBefore)
}
return nil
}
}
// NewTransport returns a new HTTP transport that verifies TLS certs using this
// package, including its baked-in LetsEncrypt fallback roots.
func NewTransport() *http.Transport {

View File

@@ -34,19 +34,19 @@ type safesocketDarwin struct {
mu sync.Mutex
token string // safesocket auth token
port int // safesocket port
sameuserproofFD *os.File // file descriptor for macos app store sameuserproof file
sharedDir string // shared directory for location of sameuserproof file
sameuserproofFD *os.File // File descriptor for macos app store sameuserproof file
sharedDir string // Shared directory for location of sameuserproof file
checkConn bool // Check macsys safesocket port before returning it
isMacSysExt func() bool // For testing only to force macsys
isSandboxedMacos func() bool // For testing only to force macOS sandbox
checkConn bool // If true, check macsys safesocket port before returning it
isMacSysExt func() bool // Reports true if this binary is the macOS System Extension
isMacGUIApp func() bool // Reports true if running as a macOS GUI app (Tailscale.app)
}
var ssd = safesocketDarwin{
isMacSysExt: version.IsMacSysExt,
isSandboxedMacos: version.IsSandboxedMacOS,
checkConn: true,
sharedDir: "/Library/Tailscale",
isMacSysExt: version.IsMacSysExt,
isMacGUIApp: func() bool { return version.IsMacAppStoreGUI() || version.IsMacSysGUI() },
checkConn: true,
sharedDir: "/Library/Tailscale",
}
// There are three ways a Darwin binary can be run: as the Mac App Store (macOS)
@@ -63,22 +63,25 @@ var ssd = safesocketDarwin{
// calls InitListenerDarwin.
// localTCPPortAndTokenDarwin returns the localhost TCP port number and auth token
// either generated, or sourced from the NEPacketTunnelProvider managed tailscaled process.
// either from the sameuserproof mechanism, or source and set directly from the
// NEPacketTunnelProvider managed tailscaled process when the CLI is invoked
// from the Tailscale.app GUI.
func localTCPPortAndTokenDarwin() (port int, token string, err error) {
ssd.mu.Lock()
defer ssd.mu.Unlock()
if !ssd.isSandboxedMacos() {
return 0, "", ErrNoTokenOnOS
}
if ssd.port != 0 && ssd.token != "" {
switch {
case ssd.port != 0 && ssd.token != "":
// If something has explicitly set our credentials (typically non-standalone macos binary), use them.
return ssd.port, ssd.token, nil
case !ssd.isMacGUIApp():
// We're not a GUI app (probably cmd/tailscale), so try falling back to sameuserproof.
// If portAndTokenFromSameUserProof returns an error here, cmd/tailscale will
// attempt to use the default unix socket mechanism supported by tailscaled.
return portAndTokenFromSameUserProof()
default:
return 0, "", ErrTokenNotFound
}
// Credentials were not explicitly, this is likely a standalone CLI binary.
// Fallback to reading the sameuserproof file.
return portAndTokenFromSameUserProof()
}
// SetCredentials sets an token and port used to authenticate safesocket generated
@@ -341,6 +344,11 @@ func readMacosSameUserProof() (port int, token string, err error) {
}
func portAndTokenFromSameUserProof() (port int, token string, err error) {
// When we're cmd/tailscale, we have no idea what tailscaled is, so we'll try
// macos, then macsys and finally, fallback to tailscaled via a unix socket
// if both of those return an error. You can run macos or macsys and
// tailscaled at the same time, but we are forced to choose one and the GUI
// clients are first in line here. You cannot run macos and macsys simultaneously.
if port, token, err := readMacosSameUserProof(); err == nil {
return port, token, nil
}
@@ -349,5 +357,5 @@ func portAndTokenFromSameUserProof() (port int, token string, err error) {
return port, token, nil
}
return 0, "", err
return 0, "", ErrTokenNotFound
}

View File

@@ -15,9 +15,12 @@ import (
// sets the port and token correctly and that LocalTCPPortAndToken
// returns the given values.
func TestSetCredentials(t *testing.T) {
wantPort := 123
wantToken := "token"
tstest.Replace(t, &ssd.isSandboxedMacos, func() bool { return true })
const (
wantToken = "token"
wantPort = 123
)
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
SetCredentials(wantToken, wantPort)
gotPort, gotToken, err := LocalTCPPortAndToken()
@@ -26,11 +29,47 @@ func TestSetCredentials(t *testing.T) {
}
if gotPort != wantPort {
t.Errorf("got port %d, want %d", gotPort, wantPort)
t.Errorf("port: got %d, want %d", gotPort, wantPort)
}
if gotToken != wantToken {
t.Errorf("got token %s, want %s", gotToken, wantToken)
t.Errorf("token: got %s, want %s", gotToken, wantToken)
}
}
// TestFallbackToSameuserproof verifies that we fallback to the
// sameuserproof file via LocalTCPPortAndToken when we're running
//
// s cmd/tailscale
func TestFallbackToSameuserproof(t *testing.T) {
dir := t.TempDir()
const (
wantToken = "token"
wantPort = 123
)
// Mimics cmd/tailscale falling back to sameuserproof
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
tstest.Replace(t, &ssd.sharedDir, dir)
tstest.Replace(t, &ssd.checkConn, false)
// Behave as macSysExt when initializing sameuserproof
tstest.Replace(t, &ssd.isMacSysExt, func() bool { return true })
if err := initSameUserProofToken(dir, wantPort, wantToken); err != nil {
t.Fatalf("initSameUserProofToken: %v", err)
}
gotPort, gotToken, err := LocalTCPPortAndToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPort != wantPort {
t.Errorf("port: got %d, want %d", gotPort, wantPort)
}
if gotToken != wantToken {
t.Errorf("token: got %s, want %s", gotToken, wantToken)
}
}
@@ -38,7 +77,7 @@ func TestSetCredentials(t *testing.T) {
// returns a listener and a non-zero port and non-empty token.
func TestInitListenerDarwin(t *testing.T) {
temp := t.TempDir()
tstest.Replace(t, &ssd.isSandboxedMacos, func() bool { return true })
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
ln, err := InitListenerDarwin(temp)
if err != nil || ln == nil {
@@ -52,15 +91,14 @@ func TestInitListenerDarwin(t *testing.T) {
}
if port == 0 {
t.Errorf("expected non-zero port, got %d", port)
t.Errorf("port: got %d, want non-zero", port)
}
if token == "" {
t.Errorf("expected non-empty token, got empty string")
t.Errorf("token: got %s, want non-empty", token)
}
}
// TestTokenGeneration verifies token generation behavior
func TestTokenGeneration(t *testing.T) {
token, err := getToken()
if err != nil {
@@ -70,7 +108,7 @@ func TestTokenGeneration(t *testing.T) {
// Verify token length (hex string is 2x byte length)
wantLen := sameUserProofTokenLength * 2
if got := len(token); got != wantLen {
t.Errorf("token length = %d, want %d", got, wantLen)
t.Errorf("token length: got %d, want %d", got, wantLen)
}
// Verify token persistence
@@ -79,7 +117,7 @@ func TestTokenGeneration(t *testing.T) {
t.Fatalf("subsequent getToken: %v", err)
}
if subsequentToken != token {
t.Errorf("subsequent token = %q, want %q", subsequentToken, token)
t.Errorf("subsequent token: got %q, want %q", subsequentToken, token)
}
}
@@ -107,10 +145,10 @@ func TestMacsysSameuserproof(t *testing.T) {
}
if gotPort != wantPort {
t.Errorf("got port = %d, want %d", gotPort, wantPort)
t.Errorf("port: got %d, want %d", gotPort, wantPort)
}
if wantToken != gotToken {
t.Errorf("got token = %s, want %s", wantToken, gotToken)
t.Errorf("token: got %s, want %s", wantToken, gotToken)
}
assertFileCount(t, dir, 1, "sameuserproof-")
}
@@ -138,7 +176,7 @@ func assertFileCount(t *testing.T, dir string, want int, prefix string) {
files, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("[unexpected] error: %v", err)
}
count := 0
for _, file := range files {
@@ -147,6 +185,6 @@ func assertFileCount(t *testing.T, dir string, want int, prefix string) {
}
}
if count != want {
t.Errorf("expected 1 file, got %d", count)
t.Errorf("files: got %d, want 1", count)
}
}

View File

@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=
# nix-direnv cache busting line: sha256-SiUkN6BQK1IQmLfkfPetzvYqRu9ENK6+6txtGxegF5Y=

View File

@@ -254,32 +254,44 @@ func parseIncubatorArgs(args []string) (incubatorArgs, error) {
return ia, nil
}
func (ia incubatorArgs) forwadedEnviron() ([]string, string, error) {
// forwardedEnviron returns the concatenation of the current environment with
// any environment variables specified in ia.encodedEnv.
//
// It also returns allowedExtraKeys, containing the env keys that were passed in
// to ia.encodedEnv.
func (ia incubatorArgs) forwardedEnviron() (env, allowedExtraKeys []string, err error) {
environ := os.Environ()
// pass through SSH_AUTH_SOCK environment variable to support ssh agent forwarding
allowListKeys := "SSH_AUTH_SOCK"
// TODO(bradfitz,percy): why is this listed specially? If the parent wanted to included
// it, couldn't it have just passed it to the incubator in encodedEnv?
// If it didn't, no reason for us to pass it to "su -w ..." if it's not in our env
// anyway? (Surely we don't want to inherit the tailscaled parent SSH_AUTH_SOCK, if any)
allowedExtraKeys = []string{"SSH_AUTH_SOCK"}
if ia.encodedEnv != "" {
unquoted, err := strconv.Unquote(ia.encodedEnv)
if err != nil {
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
return nil, nil, fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
}
var extraEnviron []string
err = json.Unmarshal([]byte(unquoted), &extraEnviron)
if err != nil {
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
return nil, nil, fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
}
environ = append(environ, extraEnviron...)
for _, v := range extraEnviron {
allowListKeys = fmt.Sprintf("%s,%s", allowListKeys, strings.Split(v, "=")[0])
for _, kv := range extraEnviron {
if k, _, ok := strings.Cut(kv, "="); ok {
allowedExtraKeys = append(allowedExtraKeys, k)
}
}
}
return environ, allowListKeys, nil
return environ, allowedExtraKeys, nil
}
// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
@@ -459,7 +471,7 @@ func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) error {
loginArgs := ia.loginArgs(loginCmdPath)
dlogf("logging in with %+v", loginArgs)
environ, _, err := ia.forwadedEnviron()
environ, _, err := ia.forwardedEnviron()
if err != nil {
return err
}
@@ -498,14 +510,14 @@ func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
defer sessionCloser()
}
environ, allowListEnvKeys, err := ia.forwadedEnviron()
environ, allowListEnvKeys, err := ia.forwardedEnviron()
if err != nil {
return false, err
}
loginArgs := []string{
su,
"-w", allowListEnvKeys,
"-w", strings.Join(allowListEnvKeys, ","),
"-l",
ia.localUser,
}
@@ -546,7 +558,7 @@ func findSU(dlogf logger.Logf, ia incubatorArgs) string {
return ""
}
_, allowListEnvKeys, err := ia.forwadedEnviron()
_, allowListEnvKeys, err := ia.forwardedEnviron()
if err != nil {
return ""
}
@@ -555,7 +567,7 @@ func findSU(dlogf logger.Logf, ia incubatorArgs) string {
// to make sure su supports the necessary arguments.
err = exec.Command(
su,
"-w", allowListEnvKeys,
"-w", strings.Join(allowListEnvKeys, ","),
"-l",
ia.localUser,
"-c", "true",
@@ -582,7 +594,7 @@ func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
return err
}
environ, _, err := ia.forwadedEnviron()
environ, _, err := ia.forwardedEnviron()
if err != nil {
return err
}

View File

@@ -96,12 +96,32 @@ type DERPRegion struct {
Latitude float64 `json:",omitempty"`
Longitude float64 `json:",omitempty"`
// Avoid is whether the client should avoid picking this as its home
// region. The region should only be used if a peer is there.
// Clients already using this region as their home should migrate
// away to a new region without Avoid set.
// Avoid is whether the client should avoid picking this as its home region.
// The region should only be used if a peer is there. Clients already using
// this region as their home should migrate away to a new region without
// Avoid set.
//
// Deprecated: because of bugs in past implementations combined with unclear
// docs that caused people to think the bugs were intentional, this field is
// deprecated. It was never supposed to cause STUN/DERP measurement probes,
// but due to bugs, it sometimes did. And then some parts of the code began
// to rely on that property. But then we were unable to use this field for
// its original purpose, nor its later imagined purpose, because various
// parts of the codebase thought it meant one thing and others thought it
// meant another. But it did something in the middle instead. So we're retiring
// it. Use NoMeasureNoHome instead.
Avoid bool `json:",omitempty"`
// NoMeasureNoHome says that this regions should not be measured for its
// latency distance (STUN, HTTPS, etc) or availability (e.g. captive portal
// checks) and should never be selected as the node's home region. However,
// if a peer declares this region as its home, then this client is allowed
// to connect to it for the purpose of communicating with that peer.
//
// This is what the now deprecated Avoid bool was supposed to mean
// originally but had implementation bugs and documentation omissions.
NoMeasureNoHome bool `json:",omitempty"`
// Nodes are the DERP nodes running in this region, in
// priority order for the current client. Client TLS
// connections should ideally only go to the first entry
@@ -139,6 +159,12 @@ type DERPNode struct {
// name. If empty, HostName is used. If CertName is non-empty,
// HostName is only used for the TCP dial (if IPv4/IPv6 are
// not present) + TLS ClientHello.
//
// As a special case, if CertName starts with "sha256-raw:",
// then the rest of the string is a hex-encoded SHA256 of the
// cert to expect. This is used for self-signed certs.
// In this case, the HostName field will typically be an IP
// address literal.
CertName string `json:",omitempty"`
// IPv4 optionally forces an IPv4 address to use, instead of using DNS.

View File

@@ -159,7 +159,8 @@ type CapabilityVersion int
// - 112: 2025-01-14: Client interprets AllowedIPs of nil as meaning same as Addresses
// - 113: 2025-01-20: Client communicates to control whether funnel is enabled by sending Hostinfo.IngressEnabled (#14688)
// - 114: 2025-01-30: NodeAttrMaxKeyDuration CapMap defined, clients might use it (no tailscaled code change) (#14829)
const CurrentCapabilityVersion CapabilityVersion = 114
// - 115: 2025-03-07: Client understands DERPRegion.NoMeasureNoHome.
const CurrentCapabilityVersion CapabilityVersion = 115
// ID is an integer ID for a user, node, or login allocated by the
// control plane.
@@ -2440,6 +2441,11 @@ const (
// type float64 representing the duration in seconds. This cap will be
// omitted if the tailnet's MaxKeyDuration is the default.
NodeAttrMaxKeyDuration NodeCapability = "tailnet.maxKeyDuration"
// NodeAttrNativeIPV4 contains the IPV4 address of the node in its
// native tailnet. This is currently only sent to Hello, in its
// peer node list.
NodeAttrNativeIPV4 NodeCapability = "native-ipv4"
)
// SetDNSRequest is a request to add a DNS record.
@@ -2976,3 +2982,33 @@ const LBHeader = "Ts-Lb"
// correspond to those IPs. Any services that don't correspond to a service
// this client is hosting can be ignored.
type ServiceIPMappings map[ServiceName][]netip.Addr
// ClientAuditAction represents an auditable action that a client can report to the
// control plane. These actions must correspond to the supported actions
// in the control plane.
type ClientAuditAction string
const (
// AuditNodeDisconnect action is sent when a node has disconnected
// from the control plane. The details must include a reason in the Details
// field, either generated, or entered by the user.
AuditNodeDisconnect = ClientAuditAction("DISCONNECT_NODE")
)
// AuditLogRequest represents an audit log request to be sent to the control plane.
//
// This is JSON-encoded and sent over the control plane connection to:
// POST https://<control-plane>/machine/audit-log
type AuditLogRequest struct {
// Version is the client's current CapabilityVersion.
Version CapabilityVersion `json:",omitempty"`
// NodeKey is the client's current node key.
NodeKey key.NodePublic `json:",omitzero"`
// Action is the action to be logged. It must correspond to a known action in the control plane.
Action ClientAuditAction `json:",omitempty"`
// Details is an opaque string, specific to the action being logged. Empty strings may not
// be valid depending on the action being logged.
Details string `json:",omitempty"`
// Timestamp is the time at which the audit log was generated on the node.
Timestamp time.Time `json:",omitzero"`
}

View File

@@ -416,13 +416,14 @@ func (src *DERPRegion) Clone() *DERPRegion {
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _DERPRegionCloneNeedsRegeneration = DERPRegion(struct {
RegionID int
RegionCode string
RegionName string
Latitude float64
Longitude float64
Avoid bool
Nodes []*DERPNode
RegionID int
RegionCode string
RegionName string
Latitude float64
Longitude float64
Avoid bool
NoMeasureNoHome bool
Nodes []*DERPNode
}{})
// Clone makes a deep copy of DERPMap.

View File

@@ -880,25 +880,27 @@ func (v *DERPRegionView) UnmarshalJSON(b []byte) error {
return nil
}
func (v DERPRegionView) RegionID() int { return v.ж.RegionID }
func (v DERPRegionView) RegionCode() string { return v.ж.RegionCode }
func (v DERPRegionView) RegionName() string { return v.ж.RegionName }
func (v DERPRegionView) Latitude() float64 { return v.ж.Latitude }
func (v DERPRegionView) Longitude() float64 { return v.ж.Longitude }
func (v DERPRegionView) Avoid() bool { return v.ж.Avoid }
func (v DERPRegionView) RegionID() int { return v.ж.RegionID }
func (v DERPRegionView) RegionCode() string { return v.ж.RegionCode }
func (v DERPRegionView) RegionName() string { return v.ж.RegionName }
func (v DERPRegionView) Latitude() float64 { return v.ж.Latitude }
func (v DERPRegionView) Longitude() float64 { return v.ж.Longitude }
func (v DERPRegionView) Avoid() bool { return v.ж.Avoid }
func (v DERPRegionView) NoMeasureNoHome() bool { return v.ж.NoMeasureNoHome }
func (v DERPRegionView) Nodes() views.SliceView[*DERPNode, DERPNodeView] {
return views.SliceOfViews[*DERPNode, DERPNodeView](v.ж.Nodes)
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _DERPRegionViewNeedsRegeneration = DERPRegion(struct {
RegionID int
RegionCode string
RegionName string
Latitude float64
Longitude float64
Avoid bool
Nodes []*DERPNode
RegionID int
RegionCode string
RegionName string
Latitude float64
Longitude float64
Avoid bool
NoMeasureNoHome bool
Nodes []*DERPNode
}{})
// View returns a read-only view of DERPMap.

View File

@@ -25,6 +25,7 @@ import (
"tailscale.com/drive"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/auditlog"
"tailscale.com/ipn/conffile"
"tailscale.com/ipn/desktop"
"tailscale.com/net/dns"
@@ -50,6 +51,7 @@ type System struct {
Router SubSystem[router.Router]
Tun SubSystem[*tstun.Wrapper]
StateStore SubSystem[ipn.StateStore]
AuditLogStore SubSystem[auditlog.LogStore]
Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl
DriveForLocal SubSystem[drive.FileSystemForLocal]
DriveForRemote SubSystem[drive.FileSystemForRemote]
@@ -106,6 +108,8 @@ func (s *System) Set(v any) {
s.MagicSock.Set(v)
case ipn.StateStore:
s.StateStore.Set(v)
case auditlog.LogStore:
s.AuditLogStore.Set(v)
case NetstackImpl:
s.Netstack.Set(v)
case drive.FileSystemForLocal:

View File

@@ -49,6 +49,7 @@ import (
"tailscale.com/net/socks5"
"tailscale.com/net/tsdial"
"tailscale.com/tsd"
"tailscale.com/types/bools"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/nettype"
@@ -601,7 +602,9 @@ func (s *Server) start() (reterr error) {
// Note: don't just return ns.DialContextTCP or we'll return
// *gonet.TCPConn(nil) instead of a nil interface which trips up
// callers.
tcpConn, err := ns.DialContextTCP(ctx, dst)
v4, v6 := s.TailscaleIPs()
src := bools.IfElse(dst.Addr().Is6(), v6, v4)
tcpConn, err := ns.DialContextTCPWithBind(ctx, src, dst)
if err != nil {
return nil, err
}
@@ -611,7 +614,9 @@ func (s *Server) start() (reterr error) {
// Note: don't just return ns.DialContextUDP or we'll return
// *gonet.UDPConn(nil) instead of a nil interface which trips up
// callers.
udpConn, err := ns.DialContextUDP(ctx, dst)
v4, v6 := s.TailscaleIPs()
src := bools.IfElse(dst.Addr().Is6(), v6, v4)
udpConn, err := ns.DialContextUDPWithBind(ctx, src, dst)
if err != nil {
return nil, err
}

View File

@@ -32,6 +32,7 @@ import (
)
var (
runVMTests = flag.Bool("run-vm-tests", false, "run tests that require a VM")
logTailscaled = flag.Bool("log-tailscaled", false, "log tailscaled output")
pcapFile = flag.String("pcap", "", "write pcap to file")
)
@@ -59,8 +60,25 @@ func newNatTest(tb testing.TB) *natTest {
base: filepath.Join(modRoot, "gokrazy/natlabapp.qcow2"),
}
if !*runVMTests {
tb.Skip("skipping heavy test; set --run-vm-tests to run")
}
if _, err := os.Stat(nt.base); err != nil {
tb.Skipf("skipping test; base image %q not found", nt.base)
if !os.IsNotExist(err) {
tb.Fatal(err)
}
tb.Logf("building VM image...")
cmd := exec.Command("make", "natlab")
cmd.Dir = filepath.Join(modRoot, "gokrazy")
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
tb.Fatalf("Error running 'make natlab' in gokrazy directory")
}
if _, err := os.Stat(nt.base); err != nil {
tb.Skipf("still can't find VM image: %v", err)
}
}
nt.kernel, err = findKernelPath(filepath.Join(modRoot, "gokrazy/natlabapp/builddir/github.com/tailscale/gokrazy-kernel/go.mod"))
@@ -218,6 +236,22 @@ func hard(c *vnet.Config) *vnet.Node {
fmt.Sprintf("10.0.%d.1/24", n), vnet.HardNAT))
}
func hardNoDERPOrEndoints(c *vnet.Config) *vnet.Node {
n := c.NumNodes() + 1
return c.AddNode(c.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("10.0.%d.1/24", n), vnet.HardNAT),
vnet.TailscaledEnv{
Key: "TS_DEBUG_STRIP_ENDPOINTS",
Value: "1",
},
vnet.TailscaledEnv{
Key: "TS_DEBUG_STRIP_HOME_DERP",
Value: "1",
},
)
}
func hardPMP(c *vnet.Config) *vnet.Node {
n := c.NumNodes() + 1
return c.AddNode(c.AddNetwork(
@@ -492,6 +526,26 @@ func TestEasyEasy(t *testing.T) {
nt.want(routeDirect)
}
// Issue tailscale/corp#26438: use learned DERP route as send path of last
// resort
//
// See (*magicsock.Conn).fallbackDERPRegionForPeer and its comment for
// background.
//
// This sets up a test with two nodes that must use DERP to communicate but the
// target of the ping (the second node) additionally is not getting DERP or
// Endpoint updates from the control plane. (Or rather, it's getting them but is
// configured to scrub them right when they come off the network before being
// processed) This then tests whether node2, upon receiving a packet, will be
// able to reply to node1 since it knows neither node1's endpoints nor its home
// DERP. The only reply route it can use is that fact that it just received a
// packet over a particular DERP from that peer.
func TestFallbackDERPRegionForPeer(t *testing.T) {
nt := newNatTest(t)
nt.runTest(hard, hardNoDERPOrEndoints)
nt.want(routeDERP)
}
func TestSingleJustIPv6(t *testing.T) {
nt := newNatTest(t)
nt.runTest(just6)

View File

@@ -34,6 +34,7 @@ type DebugHandler struct {
kvs []func(io.Writer) // output one <li>...</li> each, see KV()
urls []string // one <li>...</li> block with link each
sections []func(io.Writer, *http.Request) // invoked in registration order prior to outputting </body>
title string // title displayed on index page
}
// Debugger returns the DebugHandler registered on mux at /debug/,
@@ -44,7 +45,8 @@ func Debugger(mux *http.ServeMux) *DebugHandler {
return d
}
ret := &DebugHandler{
mux: mux,
mux: mux,
title: fmt.Sprintf("%s debug", version.CmdName()),
}
mux.Handle("/debug/", ret)
@@ -85,7 +87,7 @@ func (d *DebugHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
AddBrowserHeaders(w)
f := func(format string, args ...any) { fmt.Fprintf(w, format, args...) }
f("<html><body><h1>%s debug</h1><ul>", version.CmdName())
f("<html><body><h1>%s</h1><ul>", html.EscapeString(d.title))
for _, kv := range d.kvs {
kv(w)
}
@@ -103,14 +105,20 @@ func (d *DebugHandler) handle(slug string, handler http.Handler) string {
return href
}
// Handle registers handler at /debug/<slug> and creates a descriptive
// entry in /debug/ for it.
// Handle registers handler at /debug/<slug> and adds a link to it
// on /debug/ with the provided description.
func (d *DebugHandler) Handle(slug, desc string, handler http.Handler) {
href := d.handle(slug, handler)
d.URL(href, desc)
}
// HandleSilent registers handler at /debug/<slug>. It does not create
// Handle registers handler at /debug/<slug> and adds a link to it
// on /debug/ with the provided description.
func (d *DebugHandler) HandleFunc(slug, desc string, handler http.HandlerFunc) {
d.Handle(slug, desc, handler)
}
// HandleSilent registers handler at /debug/<slug>. It does not add
// a descriptive entry in /debug/ for it. This should be used
// sparingly, for things that need to be registered but would pollute
// the list of debug links.
@@ -118,6 +126,14 @@ func (d *DebugHandler) HandleSilent(slug string, handler http.Handler) {
d.handle(slug, handler)
}
// HandleSilent registers handler at /debug/<slug>. It does not add
// a descriptive entry in /debug/ for it. This should be used
// sparingly, for things that need to be registered but would pollute
// the list of debug links.
func (d *DebugHandler) HandleSilentFunc(slug string, handler http.HandlerFunc) {
d.HandleSilent(slug, handler)
}
// KV adds a key/value list item to /debug/.
func (d *DebugHandler) KV(k string, v any) {
val := html.EscapeString(fmt.Sprintf("%v", v))
@@ -149,6 +165,11 @@ func (d *DebugHandler) Section(f func(w io.Writer, r *http.Request)) {
d.sections = append(d.sections, f)
}
// Title sets the title at the top of the debug page.
func (d *DebugHandler) Title(title string) {
d.title = title
}
func gcHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("running GC...\n"))
if f, ok := w.(http.Flusher); ok {

View File

@@ -0,0 +1,6 @@
<li id="monitor" hx-swap-oob="afterbegin">
<details>
<summary>{{.Count}}: {{.Type}} from {{.Event.From.Name}}, {{len .Event.To}} recipients</summary>
{{.Event.Event}}
</details>
</li>

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html>
<head>
<script src="bus/htmx.min.js"></script>
<script src="bus/htmx-websocket.min.js"></script>
<link rel="stylesheet" href="bus/style.css">
</head>
<body hx-ext="ws">
<h1>Event bus</h1>
<section>
<h2>General</h2>
{{with $.PublishQueue}}
{{len .}} pending
{{end}}
<button hx-post="bus/monitor" hx-swap="outerHTML">Monitor all events</button>
</section>
<section>
<h2>Clients</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Publishing</th>
<th>Subscribing</th>
<th>Pending</th>
</tr>
</thead>
{{range .Clients}}
<tr id="{{.Name}}">
<td>{{.Name}}</td>
<td class="list">
<ul>
{{range .Publish}}
<li><a href="#{{.}}">{{.}}</a></li>
{{end}}
</ul>
</td>
<td class="list">
<ul>
{{range .Subscribe}}
<li><a href="#{{.}}">{{.}}</a></li>
{{end}}
</ul>
</td>
<td>
{{len ($.SubscribeQueue .Client)}}
</td>
</tr>
{{end}}
</table>
</section>
<section>
<h2>Types</h2>
{{range .Types}}
<section id="{{.}}">
<h3>{{.Name}}</h3>
<h4>Definition</h4>
<code>{{prettyPrintStruct .}}</code>
<h4>Published by:</h4>
{{if len (.Publish)}}
<ul>
{{range .Publish}}
<li><a href="#{{.Name}}">{{.Name}}</a></li>
{{end}}
</ul>
{{else}}
<ul>
<li>No publishers.</li>
</ul>
{{end}}
<h4>Received by:</h4>
{{if len (.Subscribe)}}
<ul>
{{range .Subscribe}}
<li><a href="#{{.Name}}">{{.Name}}</a></li>
{{end}}
</ul>
{{else}}
<ul>
<li>No subscribers.</li>
</ul>
{{end}}
</section>
{{end}}
</section>
</body>
</html>

View File

@@ -0,0 +1,5 @@
<div>
<ul id="monitor" ws-connect="bus/monitor">
</ul>
<button hx-get="bus" hx-target="body">Stop monitoring</button>
</div>

View File

@@ -0,0 +1,90 @@
/* CSS reset, thanks Josh Comeau: https://www.joshwcomeau.com/css/custom-css-reset/ */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
input, button, textarea, select { font: inherit; }
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
p { text-wrap: pretty; }
h1, h2, h3, h4, h5, h6 { text-wrap: balance; }
#root, #__next { isolation: isolate; }
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
/* Local styling begins */
body {
padding: 12px;
}
div {
width: 100%;
}
section {
display: flex;
flex-direction: column;
flex-gap: 6px;
align-items: flex-start;
padding: 12px 0;
}
section > * {
margin-left: 24px;
}
section > h2, section > h3 {
margin-left: 0;
padding-bottom: 6px;
padding-top: 12px;
}
details {
padding-bottom: 12px;
}
table {
table-layout: fixed;
width: calc(100% - 48px);
border-collapse: collapse;
border: 1px solid black;
}
th, td {
padding: 12px;
border: 1px solid black;
}
td.list {
vertical-align: top;
}
ul {
list-style: none;
}
td ul {
margin: 0;
padding: 0;
}
code {
padding: 12px;
white-space: pre;
}
#monitor {
width: calc(100% - 48px);
resize: vertical;
padding: 12px;
overflow: scroll;
height: 15lh;
border: 1px inset;
min-height: 1em;
display: flex;
flex-direction: column-reverse;
}

125
util/eventbus/bench_test.go Normal file
View File

@@ -0,0 +1,125 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package eventbus_test
import (
"math/rand/v2"
"testing"
"tailscale.com/util/eventbus"
)
func BenchmarkBasicThroughput(b *testing.B) {
bus := eventbus.New()
pcli := bus.Client(b.Name() + "-pub")
scli := bus.Client(b.Name() + "-sub")
type emptyEvent [0]byte
// One publisher and a corresponding subscriber shoveling events as fast as
// they can through the plumbing.
pub := eventbus.Publish[emptyEvent](pcli)
sub := eventbus.Subscribe[emptyEvent](scli)
go func() {
for {
select {
case <-sub.Events():
continue
case <-sub.Done():
return
}
}
}()
for b.Loop() {
pub.Publish(emptyEvent{})
}
bus.Close()
}
func BenchmarkSubsThroughput(b *testing.B) {
bus := eventbus.New()
pcli := bus.Client(b.Name() + "-pub")
scli1 := bus.Client(b.Name() + "-sub1")
scli2 := bus.Client(b.Name() + "-sub2")
type emptyEvent [0]byte
// One publisher and two subscribers shoveling events as fast as they can
// through the plumbing.
pub := eventbus.Publish[emptyEvent](pcli)
sub1 := eventbus.Subscribe[emptyEvent](scli1)
sub2 := eventbus.Subscribe[emptyEvent](scli2)
for _, sub := range []*eventbus.Subscriber[emptyEvent]{sub1, sub2} {
go func() {
for {
select {
case <-sub.Events():
continue
case <-sub.Done():
return
}
}
}()
}
for b.Loop() {
pub.Publish(emptyEvent{})
}
bus.Close()
}
func BenchmarkMultiThroughput(b *testing.B) {
bus := eventbus.New()
cli := bus.Client(b.Name())
type eventA struct{}
type eventB struct{}
// Two disjoint event streams routed through the global order.
apub := eventbus.Publish[eventA](cli)
asub := eventbus.Subscribe[eventA](cli)
bpub := eventbus.Publish[eventB](cli)
bsub := eventbus.Subscribe[eventB](cli)
go func() {
for {
select {
case <-asub.Events():
continue
case <-asub.Done():
return
}
}
}()
go func() {
for {
select {
case <-bsub.Events():
continue
case <-bsub.Done():
return
}
}
}()
var rng uint64
var bits int
for b.Loop() {
if bits == 0 {
rng = rand.Uint64()
bits = 64
}
if rng&1 == 0 {
apub.Publish(eventA{})
} else {
bpub.Publish(eventB{})
}
rng >>= 1
bits--
}
bus.Close()
}

View File

@@ -12,42 +12,90 @@ import (
"tailscale.com/util/set"
)
type PublishedEvent struct {
Event any
From *Client
}
type RoutedEvent struct {
Event any
From *Client
To []*Client
}
// Bus is an event bus that distributes published events to interested
// subscribers.
type Bus struct {
write chan any
stop goroutineShutdownControl
snapshot chan chan []any
router *worker
write chan PublishedEvent
snapshot chan chan []PublishedEvent
routeDebug hook[RoutedEvent]
topicsMu sync.Mutex // guards everything below.
topics map[reflect.Type][]*Queue
topicsMu sync.Mutex
topics map[reflect.Type][]*subscribeState
// Used for introspection/debugging only, not in the normal event
// publishing path.
publishers set.Set[publisher]
queues set.Set[*Queue]
clientsMu sync.Mutex
clients set.Set[*Client]
}
// New returns a new bus. Use [PublisherOf] to make event publishers,
// and [Bus.Queue] and [Subscribe] to make event subscribers.
func New() *Bus {
stopCtl, stopWorker := newGoroutineShutdown()
ret := &Bus{
write: make(chan any),
stop: stopCtl,
snapshot: make(chan chan []any),
topics: map[reflect.Type][]*Queue{},
publishers: set.Set[publisher]{},
queues: set.Set[*Queue]{},
write: make(chan PublishedEvent),
snapshot: make(chan chan []PublishedEvent),
topics: map[reflect.Type][]*subscribeState{},
clients: set.Set[*Client]{},
}
go ret.pump(stopWorker)
ret.router = runWorker(ret.pump)
return ret
}
func (b *Bus) pump(stop goroutineShutdownWorker) {
defer stop.Done()
var vals queue
acceptCh := func() chan any {
// Client returns a new client with no subscriptions. Use [Subscribe]
// to receive events, and [Publish] to emit events.
//
// The client's name is used only for debugging, to tell humans what
// piece of code a publisher/subscriber belongs to. Aim for something
// short but unique, for example "kernel-route-monitor" or "taildrop",
// not "watcher".
func (b *Bus) Client(name string) *Client {
ret := &Client{
name: name,
bus: b,
pub: set.Set[publisher]{},
}
b.clientsMu.Lock()
defer b.clientsMu.Unlock()
b.clients.Add(ret)
return ret
}
// Debugger returns the debugging facility for the bus.
func (b *Bus) Debugger() *Debugger {
return &Debugger{b}
}
// Close closes the bus. Implicitly closes all clients, publishers and
// subscribers attached to the bus.
//
// Close blocks until the bus is fully shut down. The bus is
// permanently unusable after closing.
func (b *Bus) Close() {
b.router.StopAndWait()
b.clientsMu.Lock()
defer b.clientsMu.Unlock()
for c := range b.clients {
c.Close()
}
b.clients = nil
}
func (b *Bus) pump(ctx context.Context) {
var vals queue[PublishedEvent]
acceptCh := func() chan PublishedEvent {
if vals.Full() {
return nil
}
@@ -60,20 +108,39 @@ func (b *Bus) pump(stop goroutineShutdownWorker) {
// queue space for it.
for !vals.Empty() {
val := vals.Peek()
dests := b.dest(reflect.ValueOf(val).Type())
dests := b.dest(reflect.ValueOf(val.Event).Type())
if b.routeDebug.active() {
clients := make([]*Client, len(dests))
for i := range len(dests) {
clients[i] = dests[i].client
}
b.routeDebug.run(RoutedEvent{
Event: val.Event,
From: val.From,
To: clients,
})
}
for _, d := range dests {
evt := DeliveredEvent{
Event: val.Event,
From: val.From,
To: d.client,
}
deliverOne:
for {
select {
case d.write <- val:
case d.write <- evt:
break deliverOne
case <-d.stop.WaitChan():
case <-d.closed():
// Queue closed, don't block but continue
// delivering to others.
break deliverOne
case in := <-acceptCh():
vals.Add(in)
case <-stop.Stop():
in.From.publishDebug.run(in)
case <-ctx.Done():
return
case ch := <-b.snapshot:
ch <- vals.Snapshot()
@@ -87,10 +154,11 @@ func (b *Bus) pump(stop goroutineShutdownWorker) {
// resuming.
for vals.Empty() {
select {
case <-stop.Stop():
case <-ctx.Done():
return
case val := <-b.write:
vals.Add(val)
case in := <-b.write:
vals.Add(in)
in.From.publishDebug.run(in)
case ch := <-b.snapshot:
ch <- nil
}
@@ -98,13 +166,39 @@ func (b *Bus) pump(stop goroutineShutdownWorker) {
}
}
func (b *Bus) dest(t reflect.Type) []*Queue {
func (b *Bus) dest(t reflect.Type) []*subscribeState {
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
return b.topics[t]
}
func (b *Bus) subscribe(t reflect.Type, q *Queue) (cancel func()) {
func (b *Bus) shouldPublish(t reflect.Type) bool {
if b.routeDebug.active() {
return true
}
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
return len(b.topics[t]) > 0
}
func (b *Bus) listClients() []*Client {
b.clientsMu.Lock()
defer b.clientsMu.Unlock()
return b.clients.Slice()
}
func (b *Bus) snapshotPublishQueue() []PublishedEvent {
resp := make(chan []PublishedEvent)
select {
case b.snapshot <- resp:
return <-resp
case <-b.router.Done():
return nil
}
}
func (b *Bus) subscribe(t reflect.Type, q *subscribeState) (cancel func()) {
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
b.topics[t] = append(b.topics[t], q)
@@ -113,7 +207,7 @@ func (b *Bus) subscribe(t reflect.Type, q *Queue) (cancel func()) {
}
}
func (b *Bus) unsubscribe(t reflect.Type, q *Queue) {
func (b *Bus) unsubscribe(t reflect.Type, q *subscribeState) {
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
// Topic slices are accessed by pump without holding a lock, so we
@@ -127,97 +221,89 @@ func (b *Bus) unsubscribe(t reflect.Type, q *Queue) {
b.topics[t] = slices.Delete(slices.Clone(b.topics[t]), i, i+1)
}
func (b *Bus) Close() {
b.stop.StopAndWait()
// A worker runs a worker goroutine and helps coordinate its shutdown.
type worker struct {
ctx context.Context
stop context.CancelFunc
stopped chan struct{}
}
// Queue returns a new queue with no subscriptions. Use [Subscribe] to
// atach subscriptions to it.
// runWorker creates a worker goroutine running fn. The context passed
// to fn is canceled by [worker.Stop].
func runWorker(fn func(context.Context)) *worker {
ctx, stop := context.WithCancel(context.Background())
ret := &worker{
ctx: ctx,
stop: stop,
stopped: make(chan struct{}),
}
go ret.run(fn)
return ret
}
func (w *worker) run(fn func(context.Context)) {
defer close(w.stopped)
fn(w.ctx)
}
// Stop signals the worker goroutine to shut down.
func (w *worker) Stop() { w.stop() }
// Done returns a channel that is closed when the worker goroutine
// exits.
func (w *worker) Done() <-chan struct{} { return w.stopped }
// Wait waits until the worker goroutine has exited.
func (w *worker) Wait() { <-w.stopped }
// StopAndWait signals the worker goroutine to shut down, then waits
// for it to exit.
func (w *worker) StopAndWait() {
w.stop()
<-w.stopped
}
// stopFlag is a value that can be watched for a notification. The
// zero value is ready for use.
//
// The queue's name should be a short, human-readable string that
// identifies this queue. The name is only visible through debugging
// APIs.
func (b *Bus) Queue(name string) *Queue {
return newQueue(b, name)
// The flag is notified by running [stopFlag.Stop]. Stop can be called
// multiple times. Upon the first call to Stop, [stopFlag.Done] is
// closed, all pending [stopFlag.Wait] calls return, and future Wait
// calls return immediately.
//
// A stopFlag can only notify once, and is intended for use as a
// one-way shutdown signal that's lighter than a cancellable
// context.Context.
type stopFlag struct {
// guards the lazy construction of stopped, and the value of
// alreadyStopped.
mu sync.Mutex
stopped chan struct{}
alreadyStopped bool
}
func (b *Bus) addQueue(q *Queue) {
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
b.queues.Add(q)
}
func (b *Bus) deleteQueue(q *Queue) {
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
b.queues.Delete(q)
}
func (b *Bus) addPublisher(p publisher) {
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
b.publishers.Add(p)
}
func (b *Bus) deletePublisher(p publisher) {
b.topicsMu.Lock()
defer b.topicsMu.Unlock()
b.publishers.Delete(p)
}
func newGoroutineShutdown() (goroutineShutdownControl, goroutineShutdownWorker) {
ctx, cancel := context.WithCancel(context.Background())
ctl := goroutineShutdownControl{
startShutdown: cancel,
shutdownFinished: make(chan struct{}),
func (s *stopFlag) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.alreadyStopped {
return
}
work := goroutineShutdownWorker{
startShutdown: ctx.Done(),
shutdownFinished: ctl.shutdownFinished,
s.alreadyStopped = true
if s.stopped == nil {
s.stopped = make(chan struct{})
}
return ctl, work
close(s.stopped)
}
// goroutineShutdownControl is a helper type to manage the shutdown of
// a worker goroutine. The worker goroutine should use the
// goroutineShutdownWorker related to this controller.
type goroutineShutdownControl struct {
startShutdown context.CancelFunc
shutdownFinished chan struct{}
func (s *stopFlag) Done() <-chan struct{} {
s.mu.Lock()
defer s.mu.Unlock()
if s.stopped == nil {
s.stopped = make(chan struct{})
}
return s.stopped
}
func (ctl *goroutineShutdownControl) Stop() {
ctl.startShutdown()
}
func (ctl *goroutineShutdownControl) Wait() {
<-ctl.shutdownFinished
}
func (ctl *goroutineShutdownControl) WaitChan() <-chan struct{} {
return ctl.shutdownFinished
}
func (ctl *goroutineShutdownControl) StopAndWait() {
ctl.Stop()
ctl.Wait()
}
// goroutineShutdownWorker is a helper type for a worker goroutine to
// be notified that it should shut down, and to report that shutdown
// has completed. The notification is triggered by the related
// goroutineShutdownControl.
type goroutineShutdownWorker struct {
startShutdown <-chan struct{}
shutdownFinished chan struct{}
}
func (work *goroutineShutdownWorker) Stop() <-chan struct{} {
return work.startShutdown
}
func (work *goroutineShutdownWorker) Done() {
close(work.shutdownFinished)
func (s *stopFlag) Wait() {
<-s.Done()
}

View File

@@ -26,14 +26,16 @@ func TestBus(t *testing.T) {
b := eventbus.New()
defer b.Close()
q := b.Queue("TestBus")
defer q.Close()
s := eventbus.Subscribe[EventA](q)
c := b.Client("TestSub")
defer c.Close()
s := eventbus.Subscribe[EventA](c)
go func() {
pa := eventbus.PublisherOf[EventA](b, "TestBusA")
p := b.Client("TestPub")
defer p.Close()
pa := eventbus.Publish[EventA](p)
defer pa.Close()
pb := eventbus.PublisherOf[EventB](b, "TestBusB")
pb := eventbus.Publish[EventB](p)
defer pb.Close()
pa.Publish(EventA{1})
pb.Publish(EventB{2})
@@ -45,7 +47,7 @@ func TestBus(t *testing.T) {
select {
case got := <-s.Events():
want.Got(got)
case <-q.Done():
case <-s.Done():
t.Fatalf("queue closed unexpectedly")
case <-time.After(time.Second):
t.Fatalf("timed out waiting for event")
@@ -57,19 +59,21 @@ func TestBusMultipleConsumers(t *testing.T) {
b := eventbus.New()
defer b.Close()
q1 := b.Queue("TestBusA")
defer q1.Close()
s1 := eventbus.Subscribe[EventA](q1)
c1 := b.Client("TestSubA")
defer c1.Close()
s1 := eventbus.Subscribe[EventA](c1)
q2 := b.Queue("TestBusAB")
defer q2.Close()
s2A := eventbus.Subscribe[EventA](q2)
s2B := eventbus.Subscribe[EventB](q2)
c2 := b.Client("TestSubB")
defer c2.Close()
s2A := eventbus.Subscribe[EventA](c2)
s2B := eventbus.Subscribe[EventB](c2)
go func() {
pa := eventbus.PublisherOf[EventA](b, "TestBusA")
p := b.Client("TestPub")
defer p.Close()
pa := eventbus.Publish[EventA](p)
defer pa.Close()
pb := eventbus.PublisherOf[EventB](b, "TestBusB")
pb := eventbus.Publish[EventB](p)
defer pb.Close()
pa.Publish(EventA{1})
pb.Publish(EventB{2})
@@ -86,9 +90,11 @@ func TestBusMultipleConsumers(t *testing.T) {
wantB.Got(got)
case got := <-s2B.Events():
wantB.Got(got)
case <-q1.Done():
case <-s1.Done():
t.Fatalf("queue closed unexpectedly")
case <-q2.Done():
case <-s2A.Done():
t.Fatalf("queue closed unexpectedly")
case <-s2B.Done():
t.Fatalf("queue closed unexpectedly")
case <-time.After(time.Second):
t.Fatalf("timed out waiting for event")
@@ -111,15 +117,15 @@ func TestSpam(t *testing.T) {
received := make([][]EventA, subscribers)
for i := range subscribers {
q := b.Queue(fmt.Sprintf("Subscriber%d", i))
defer q.Close()
s := eventbus.Subscribe[EventA](q)
c := b.Client(fmt.Sprintf("Subscriber%d", i))
defer c.Close()
s := eventbus.Subscribe[EventA](c)
g.Go(func() error {
for range wantEvents {
select {
case evt := <-s.Events():
received[i] = append(received[i], evt)
case <-q.Done():
case <-s.Done():
t.Errorf("queue done before expected number of events received")
return errors.New("queue prematurely closed")
case <-time.After(5 * time.Second):
@@ -134,7 +140,8 @@ func TestSpam(t *testing.T) {
published := make([][]EventA, publishers)
for i := range publishers {
g.Run(func() {
p := eventbus.PublisherOf[EventA](b, fmt.Sprintf("Publisher%d", i))
c := b.Client(fmt.Sprintf("Publisher%d", i))
p := eventbus.Publish[EventA](c)
for j := range eventsPerPublisher {
evt := EventA{i*eventsPerPublisher + j}
p.Publish(evt)

127
util/eventbus/client.go Normal file
View File

@@ -0,0 +1,127 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package eventbus
import (
"reflect"
"sync"
"tailscale.com/util/set"
)
// A Client can publish and subscribe to events on its attached
// bus. See [Publish] to publish events, and [Subscribe] to receive
// events.
//
// Subscribers that share the same client receive events one at a
// time, in the order they were published.
type Client struct {
name string
bus *Bus
publishDebug hook[PublishedEvent]
mu sync.Mutex
pub set.Set[publisher]
sub *subscribeState // Lazily created on first subscribe
}
func (c *Client) Name() string { return c.name }
// Close closes the client. Implicitly closes all publishers and
// subscribers obtained from this client.
func (c *Client) Close() {
var (
pub set.Set[publisher]
sub *subscribeState
)
c.mu.Lock()
pub, c.pub = c.pub, nil
sub, c.sub = c.sub, nil
c.mu.Unlock()
if sub != nil {
sub.close()
}
for p := range pub {
p.Close()
}
}
func (c *Client) snapshotSubscribeQueue() []DeliveredEvent {
return c.peekSubscribeState().snapshotQueue()
}
func (c *Client) peekSubscribeState() *subscribeState {
c.mu.Lock()
defer c.mu.Unlock()
return c.sub
}
func (c *Client) publishTypes() []reflect.Type {
c.mu.Lock()
defer c.mu.Unlock()
ret := make([]reflect.Type, 0, len(c.pub))
for pub := range c.pub {
ret = append(ret, pub.publishType())
}
return ret
}
func (c *Client) subscribeTypes() []reflect.Type {
return c.peekSubscribeState().subscribeTypes()
}
func (c *Client) subscribeState() *subscribeState {
c.mu.Lock()
defer c.mu.Unlock()
if c.sub == nil {
c.sub = newSubscribeState(c)
}
return c.sub
}
func (c *Client) addPublisher(pub publisher) {
c.mu.Lock()
defer c.mu.Unlock()
c.pub.Add(pub)
}
func (c *Client) deletePublisher(pub publisher) {
c.mu.Lock()
defer c.mu.Unlock()
c.pub.Delete(pub)
}
func (c *Client) addSubscriber(t reflect.Type, s *subscribeState) {
c.bus.subscribe(t, s)
}
func (c *Client) deleteSubscriber(t reflect.Type, s *subscribeState) {
c.bus.unsubscribe(t, s)
}
func (c *Client) publish() chan<- PublishedEvent {
return c.bus.write
}
func (c *Client) shouldPublish(t reflect.Type) bool {
return c.publishDebug.active() || c.bus.shouldPublish(t)
}
// Subscribe requests delivery of events of type T through the given
// Queue. Panics if the queue already has a subscriber for T.
func Subscribe[T any](c *Client) *Subscriber[T] {
return newSubscriber[T](c.subscribeState())
}
// Publisher returns a publisher for event type T using the given
// client.
func Publish[T any](c *Client) *Publisher[T] {
ret := newPublisher[T](c)
c.mu.Lock()
defer c.mu.Unlock()
c.pub.Add(ret)
return ret
}

View File

@@ -0,0 +1,103 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// debug-demo is a program that serves a bus's debug interface over
// HTTP, then generates some fake traffic from a handful of
// clients. It is an aid to development, to have something to present
// on the debug interfaces while writing them.
package main
import (
"log"
"math/rand/v2"
"net/http"
"net/netip"
"time"
"tailscale.com/tsweb"
"tailscale.com/types/key"
"tailscale.com/util/eventbus"
)
func main() {
b := eventbus.New()
c := b.Client("RouteMonitor")
go testPub[RouteAdded](c, 5*time.Second)
go testPub[RouteRemoved](c, 5*time.Second)
c = b.Client("ControlClient")
go testPub[PeerAdded](c, 3*time.Second)
go testPub[PeerRemoved](c, 6*time.Second)
c = b.Client("Portmapper")
go testPub[PortmapAcquired](c, 10*time.Second)
go testPub[PortmapLost](c, 15*time.Second)
go testSub[RouteAdded](c)
c = b.Client("WireguardConfig")
go testSub[PeerAdded](c)
go testSub[PeerRemoved](c)
c = b.Client("Magicsock")
go testPub[PeerPathChanged](c, 5*time.Second)
go testSub[RouteAdded](c)
go testSub[RouteRemoved](c)
go testSub[PortmapAcquired](c)
go testSub[PortmapLost](c)
m := http.NewServeMux()
d := tsweb.Debugger(m)
b.Debugger().RegisterHTTP(d)
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/debug/bus", http.StatusFound)
})
log.Printf("Serving debug interface at http://localhost:8185/debug/bus")
http.ListenAndServe(":8185", m)
}
func testPub[T any](c *eventbus.Client, every time.Duration) {
p := eventbus.Publish[T](c)
for {
jitter := time.Duration(rand.N(2000)) * time.Millisecond
time.Sleep(jitter)
var zero T
log.Printf("%s publish: %T", c.Name(), zero)
p.Publish(zero)
time.Sleep(every)
}
}
func testSub[T any](c *eventbus.Client) {
s := eventbus.Subscribe[T](c)
for v := range s.Events() {
log.Printf("%s received: %T", c.Name(), v)
}
}
type RouteAdded struct {
Prefix netip.Prefix
Via netip.Addr
Priority int
}
type RouteRemoved struct {
Prefix netip.Addr
}
type PeerAdded struct {
ID int
Key key.NodePublic
}
type PeerRemoved struct {
ID int
Key key.NodePublic
}
type PortmapAcquired struct {
Endpoint netip.Addr
}
type PortmapLost struct {
Endpoint netip.Addr
}
type PeerPathChanged struct {
ID int
EndpointID int
Quality int
}

188
util/eventbus/debug.go Normal file
View File

@@ -0,0 +1,188 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package eventbus
import (
"cmp"
"fmt"
"reflect"
"slices"
"sync"
"sync/atomic"
"tailscale.com/tsweb"
)
// A Debugger offers access to a bus's privileged introspection and
// debugging facilities.
//
// The debugger's functionality is intended for humans and their tools
// to examine and troubleshoot bus clients, and should not be used in
// normal codepaths.
//
// In particular, the debugger provides access to information that is
// deliberately withheld from bus clients to encourage more robust and
// maintainable code - for example, the sender of an event, or the
// event streams of other clients. Please don't use the debugger to
// circumvent these restrictions for purposes other than debugging.
type Debugger struct {
bus *Bus
}
// Clients returns a list of all clients attached to the bus.
func (d *Debugger) Clients() []*Client {
ret := d.bus.listClients()
slices.SortFunc(ret, func(a, b *Client) int {
return cmp.Compare(a.Name(), b.Name())
})
return ret
}
// PublishQueue returns the contents of the publish queue.
//
// The publish queue contains events that have been accepted by the
// bus from Publish() calls, but have not yet been routed to relevant
// subscribers.
//
// This queue is expected to be almost empty in normal operation. A
// full publish queue indicates that a slow subscriber downstream is
// causing backpressure and stalling the bus.
func (d *Debugger) PublishQueue() []PublishedEvent {
return d.bus.snapshotPublishQueue()
}
// checkClient verifies that client is attached to the same bus as the
// Debugger, and panics if not.
func (d *Debugger) checkClient(client *Client) {
if client.bus != d.bus {
panic(fmt.Errorf("SubscribeQueue given client belonging to wrong bus"))
}
}
// SubscribeQueue returns the contents of the given client's subscribe
// queue.
//
// The subscribe queue contains events that are to be delivered to the
// client, but haven't yet been handed off to the relevant
// [Subscriber].
//
// This queue is expected to be almost empty in normal operation. A
// full subscribe queue indicates that the client is accepting events
// too slowly, and may be causing the rest of the bus to stall.
func (d *Debugger) SubscribeQueue(client *Client) []DeliveredEvent {
d.checkClient(client)
return client.snapshotSubscribeQueue()
}
// WatchBus streams information about all events passing through the
// bus.
//
// Monitored events are delivered in the bus's global publication
// order (see "Concurrency properties" in the package docs).
//
// The caller must consume monitoring events promptly to avoid
// stalling the bus (see "Expected subscriber behavior" in the package
// docs).
func (d *Debugger) WatchBus() *Subscriber[RoutedEvent] {
return newMonitor(d.bus.routeDebug.add)
}
// WatchPublish streams information about all events published by the
// given client.
//
// Monitored events are delivered in the bus's global publication
// order (see "Concurrency properties" in the package docs).
//
// The caller must consume monitoring events promptly to avoid
// stalling the bus (see "Expected subscriber behavior" in the package
// docs).
func (d *Debugger) WatchPublish(client *Client) *Subscriber[PublishedEvent] {
d.checkClient(client)
return newMonitor(client.publishDebug.add)
}
// WatchSubscribe streams information about all events received by the
// given client.
//
// Monitored events are delivered in the bus's global publication
// order (see "Concurrency properties" in the package docs).
//
// The caller must consume monitoring events promptly to avoid
// stalling the bus (see "Expected subscriber behavior" in the package
// docs).
func (d *Debugger) WatchSubscribe(client *Client) *Subscriber[DeliveredEvent] {
d.checkClient(client)
return newMonitor(client.subscribeState().debug.add)
}
// PublishTypes returns the list of types being published by client.
//
// The returned types are those for which the client has obtained a
// [Publisher]. The client may not have ever sent the type in
// question.
func (d *Debugger) PublishTypes(client *Client) []reflect.Type {
d.checkClient(client)
return client.publishTypes()
}
// SubscribeTypes returns the list of types being subscribed to by
// client.
//
// The returned types are those for which the client has obtained a
// [Subscriber]. The client may not have ever received the type in
// question, and here may not be any publishers of the type.
func (d *Debugger) SubscribeTypes(client *Client) []reflect.Type {
d.checkClient(client)
return client.subscribeTypes()
}
func (d *Debugger) RegisterHTTP(td *tsweb.DebugHandler) { registerHTTPDebugger(d, td) }
// A hook collects hook functions that can be run as a group.
type hook[T any] struct {
sync.Mutex
fns []hookFn[T]
}
var hookID atomic.Uint64
// add registers fn to be called when the hook is run. Returns an
// unregistration function that removes fn from the hook when called.
func (h *hook[T]) add(fn func(T)) (remove func()) {
id := hookID.Add(1)
h.Lock()
defer h.Unlock()
h.fns = append(h.fns, hookFn[T]{id, fn})
return func() { h.remove(id) }
}
// remove removes the function with the given ID from the hook.
func (h *hook[T]) remove(id uint64) {
h.Lock()
defer h.Unlock()
h.fns = slices.DeleteFunc(h.fns, func(f hookFn[T]) bool { return f.ID == id })
}
// active reports whether any functions are registered with the
// hook. This can be used to skip expensive work when the hook is
// inactive.
func (h *hook[T]) active() bool {
h.Lock()
defer h.Unlock()
return len(h.fns) > 0
}
// run calls all registered functions with the value v.
func (h *hook[T]) run(v T) {
h.Lock()
defer h.Unlock()
for _, fn := range h.fns {
fn.Fn(v)
}
}
type hookFn[T any] struct {
ID uint64
Fn func(T)
}

238
util/eventbus/debughttp.go Normal file
View File

@@ -0,0 +1,238 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package eventbus
import (
"bytes"
"cmp"
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"path/filepath"
"reflect"
"slices"
"strings"
"sync"
"github.com/coder/websocket"
"tailscale.com/tsweb"
)
type httpDebugger struct {
*Debugger
}
func registerHTTPDebugger(d *Debugger, td *tsweb.DebugHandler) {
dh := httpDebugger{d}
td.Handle("bus", "Event bus", dh)
td.HandleSilent("bus/monitor", http.HandlerFunc(dh.serveMonitor))
td.HandleSilent("bus/style.css", serveStatic("style.css"))
td.HandleSilent("bus/htmx.min.js", serveStatic("htmx.min.js.gz"))
td.HandleSilent("bus/htmx-websocket.min.js", serveStatic("htmx-websocket.min.js.gz"))
}
//go:embed assets/*.html
var templatesSrc embed.FS
var templates = sync.OnceValue(func() *template.Template {
d, err := fs.Sub(templatesSrc, "assets")
if err != nil {
panic(fmt.Errorf("getting eventbus debughttp templates subdir: %w", err))
}
ret := template.New("").Funcs(map[string]any{
"prettyPrintStruct": prettyPrintStruct,
})
return template.Must(ret.ParseFS(d, "*"))
})
//go:generate go run fetch-htmx.go
//go:embed assets/*.css assets/*.min.js.gz
var static embed.FS
func serveStatic(name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(name, ".css"):
w.Header().Set("Content-Type", "text/css")
case strings.HasSuffix(name, ".min.js.gz"):
w.Header().Set("Content-Type", "text/javascript")
w.Header().Set("Content-Encoding", "gzip")
case strings.HasSuffix(name, ".js"):
w.Header().Set("Content-Type", "text/javascript")
default:
http.Error(w, "not found", http.StatusNotFound)
return
}
f, err := static.Open(filepath.Join("assets", name))
if err != nil {
http.Error(w, fmt.Sprintf("opening asset: %v", err), http.StatusInternalServerError)
return
}
defer f.Close()
if _, err := io.Copy(w, f); err != nil {
http.Error(w, fmt.Sprintf("serving asset: %v", err), http.StatusInternalServerError)
return
}
})
}
func render(w http.ResponseWriter, name string, data any) {
err := templates().ExecuteTemplate(w, name+".html", data)
if err != nil {
err := fmt.Errorf("rendering template: %v", err)
log.Print(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h httpDebugger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
type clientInfo struct {
*Client
Publish []reflect.Type
Subscribe []reflect.Type
}
type typeInfo struct {
reflect.Type
Publish []*Client
Subscribe []*Client
}
type info struct {
*Debugger
Clients map[string]*clientInfo
Types map[string]*typeInfo
}
data := info{
Debugger: h.Debugger,
Clients: map[string]*clientInfo{},
Types: map[string]*typeInfo{},
}
getTypeInfo := func(t reflect.Type) *typeInfo {
if data.Types[t.Name()] == nil {
data.Types[t.Name()] = &typeInfo{
Type: t,
}
}
return data.Types[t.Name()]
}
for _, c := range h.Clients() {
ci := &clientInfo{
Client: c,
Publish: h.PublishTypes(c),
Subscribe: h.SubscribeTypes(c),
}
slices.SortFunc(ci.Publish, func(a, b reflect.Type) int { return cmp.Compare(a.Name(), b.Name()) })
slices.SortFunc(ci.Subscribe, func(a, b reflect.Type) int { return cmp.Compare(a.Name(), b.Name()) })
data.Clients[c.Name()] = ci
for _, t := range ci.Publish {
ti := getTypeInfo(t)
ti.Publish = append(ti.Publish, c)
}
for _, t := range ci.Subscribe {
ti := getTypeInfo(t)
ti.Subscribe = append(ti.Subscribe, c)
}
}
render(w, "main", data)
}
func (h httpDebugger) serveMonitor(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") == "websocket" {
h.serveMonitorStream(w, r)
return
}
render(w, "monitor", nil)
}
func (h httpDebugger) serveMonitorStream(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer conn.CloseNow()
wsCtx := conn.CloseRead(r.Context())
mon := h.WatchBus()
defer mon.Close()
i := 0
for {
select {
case <-r.Context().Done():
return
case <-wsCtx.Done():
return
case <-mon.Done():
return
case event := <-mon.Events():
msg, err := conn.Writer(r.Context(), websocket.MessageText)
if err != nil {
return
}
data := map[string]any{
"Count": i,
"Type": reflect.TypeOf(event.Event),
"Event": event,
}
i++
if err := templates().ExecuteTemplate(msg, "event.html", data); err != nil {
log.Println(err)
return
}
if err := msg.Close(); err != nil {
return
}
}
}
}
func prettyPrintStruct(t reflect.Type) string {
if t.Kind() != reflect.Struct {
return t.String()
}
var rec func(io.Writer, int, reflect.Type)
rec = func(out io.Writer, indent int, t reflect.Type) {
ind := strings.Repeat(" ", indent)
fmt.Fprintf(out, "%s", t.String())
fs := collectFields(t)
if len(fs) > 0 {
io.WriteString(out, " {\n")
for _, f := range fs {
fmt.Fprintf(out, "%s %s ", ind, f.Name)
if f.Type.Kind() == reflect.Struct {
rec(out, indent+1, f.Type)
} else {
fmt.Fprint(out, f.Type)
}
io.WriteString(out, "\n")
}
fmt.Fprintf(out, "%s}", ind)
}
}
var ret bytes.Buffer
rec(&ret, 0, t)
return ret.String()
}
func collectFields(t reflect.Type) (ret []reflect.StructField) {
for _, f := range reflect.VisibleFields(t) {
if !f.IsExported() {
continue
}
ret = append(ret, f)
}
return ret
}

View File

@@ -3,56 +3,59 @@
// Package eventbus provides an in-process event bus.
//
// The event bus connects publishers of typed events with subscribers
// interested in those events.
// An event bus connects publishers of typed events with subscribers
// interested in those events. Typically, there is one global event
// bus per process.
//
// # Usage
//
// To publish events, use [PublisherOf] to get a typed publisher for
// your event type, then call [Publisher.Publish] as needed. If your
// event is expensive to construct, you can optionally use
// [Publisher.ShouldPublish] to skip the work if nobody is listening
// for the event.
// To send or receive events, first use [Bus.Client] to register with
// the bus. Clients should register with a human-readable name that
// identifies the code using the client, to aid in debugging.
//
// To receive events, first use [Bus.Queue] to create an event
// delivery queue, then use [Subscribe] to get a [Subscriber] for each
// event type you're interested in. Receive the events themselves by
// selecting over all your [Subscriber.Chan] channels, as well as
// [Queue.Done] for shutdown notifications.
// To publish events, use [Publish] on a Client to get a typed
// publisher for your event type, then call [Publisher.Publish] as
// needed. If your event is expensive to construct, you can optionally
// use [Publisher.ShouldPublish] to skip the work if nobody is
// listening for the event.
//
// To receive events, use [Subscribe] to get a typed subscriber for
// each event type you're interested in. Receive the events themselves
// by selecting over all your [Subscriber.Events] channels, as well as
// [Subscriber.Done] for shutdown notifications.
//
// # Concurrency properties
//
// The bus serializes all published events, and preserves that
// ordering when delivering to subscribers that are attached to the
// same Queue. In more detail:
// The bus serializes all published events across all publishers, and
// preserves that ordering when delivering to subscribers that are
// attached to the same Client. In more detail:
//
// - An event is published to the bus at some instant between the
// start and end of the call to [Publisher.Publish].
// - Events cannot be published at the same instant, and so are
// - Two events cannot be published at the same instant, and so are
// totally ordered by their publication time. Given two events E1
// and E2, either E1 happens before E2, or E2 happens before E1.
// - Queues dispatch events to their Subscribers in publication
// order: if E1 happens before E2, the queue always delivers E1
// - Clients dispatch events to their Subscribers in publication
// order: if E1 happens before E2, the client always delivers E1
// before E2.
// - Queues do not synchronize with each other: given queues Q1 and
// Q2, both subscribed to events E1 and E2, Q1 may deliver both E1
// and E2 before Q2 delivers E1.
// - Clients do not synchronize subscriptions with each other: given
// clients C1 and C2, both subscribed to events E1 and E2, C1 may
// deliver both E1 and E2 before C2 delivers E1.
//
// Less formally: there is one true timeline of all published events.
// If you make a Queue and subscribe to events on it, you will receive
// those events one at a time, in the same order as the one true
// If you make a Client and subscribe to events, you will receive
// events one at a time, in the same order as the one true
// timeline. You will "skip over" events you didn't subscribe to, but
// your view of the world always moves forward in time, never
// backwards, and you will observe events in the same order as
// everyone else.
//
// However, you cannot assume that what your subscribers on your queue
// see as "now" is the same as what other subscribers on other
// queues. Their queue may be further behind you in the timeline, or
// running ahead of you. This means you should be careful about
// reaching out to another component directly after receiving an
// event, as its view of the world may not yet (or ever) be exactly
// consistent with yours.
// However, you cannot assume that what your client see as "now" is
// the same as what other clients. They may be further behind you in
// working through the timeline, or running ahead of you. This means
// you should be careful about reaching out to another component
// directly after receiving an event, as its view of the world may not
// yet (or ever) be exactly consistent with yours.
//
// To make your code more testable and understandable, you should try
// to structure it following the actor model: you have some local
@@ -63,7 +66,7 @@
// # Expected subscriber behavior
//
// Subscribers are expected to promptly receive their events on
// [Subscriber.Chan]. The bus has a small, fixed amount of internal
// [Subscriber.Events]. The bus has a small, fixed amount of internal
// buffering, meaning that a slow subscriber will eventually cause
// backpressure and block publication of all further events.
//
@@ -83,18 +86,7 @@
//
// # Debugging facilities
//
// (TODO, not implemented yet, sorry, I promise we're working on it next!)
//
// The bus comes with introspection facilities to help reason about
// the state of the client, and diagnose issues such as slow
// subscribers.
//
// The bus provide a tsweb debugging page that shows the current state
// of the bus, including all publishers, subscribers, and queued
// events.
//
// The bus also has a snooping and tracing facility, which lets you
// observe all events flowing through the bus, along with their
// source, destination(s) and timing information such as the time of
// delivery to each subscriber and end-to-end bus delays.
// The [Debugger], obtained through [Bus.Debugger], provides
// introspection facilities to monitor events flowing through the bus,
// and inspect publisher and subscriber state.
package eventbus

View File

@@ -0,0 +1,93 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ignore
// Program fetch-htmx fetches and installs local copies of the HTMX
// library and its dependencies, used by the debug UI. It is meant to
// be run via go generate.
package main
import (
"compress/gzip"
"crypto/sha512"
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"os"
)
func main() {
// Hash from https://htmx.org/docs/#installing
htmx, err := fetchHashed("https://unpkg.com/htmx.org@2.0.4", "HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+")
if err != nil {
log.Fatalf("fetching htmx: %v", err)
}
// Hash SHOULD be from https://htmx.org/extensions/ws/ , but the
// hash is currently incorrect, see
// https://github.com/bigskysoftware/htmx-extensions/issues/153
//
// Until that bug is resolved, hash was obtained by rebuilding the
// extension from git source, and verifying that the hash matches
// what unpkg is serving.
ws, err := fetchHashed("https://unpkg.com/htmx-ext-ws@2.0.2", "932iIqjARv+Gy0+r6RTGrfCkCKS5MsF539Iqf6Vt8L4YmbnnWI2DSFoMD90bvXd0")
if err != nil {
log.Fatalf("fetching htmx-websockets: %v", err)
}
if err := writeGz("assets/htmx.min.js.gz", htmx); err != nil {
log.Fatalf("writing htmx.min.js.gz: %v", err)
}
if err := writeGz("assets/htmx-websocket.min.js.gz", ws); err != nil {
log.Fatalf("writing htmx-websocket.min.js.gz: %v", err)
}
}
func writeGz(path string, bs []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
g, err := gzip.NewWriterLevel(f, gzip.BestCompression)
if err != nil {
return err
}
if _, err := g.Write(bs); err != nil {
return err
}
if err := g.Flush(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return nil
}
func fetchHashed(url, wantHash string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching %q returned error status: %s", url, resp.Status)
}
ret, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading file from %q: %v", url, err)
}
h := sha512.Sum384(ret)
got := base64.StdEncoding.EncodeToString(h[:])
if got != wantHash {
return nil, fmt.Errorf("wrong hash for %q: got %q, want %q", url, got, wantHash)
}
return ret, nil
}

View File

@@ -4,76 +4,71 @@
package eventbus
import (
"context"
"reflect"
)
// publisher is a uniformly typed wrapper around Publisher[T], so that
// debugging facilities can look at active publishers.
type publisher interface {
publisherName() string
publishType() reflect.Type
Close()
}
// A Publisher publishes events on the bus.
// A Publisher publishes typed events on a bus.
type Publisher[T any] struct {
bus *Bus
name string
stopCtx context.Context
stop context.CancelFunc
client *Client
stop stopFlag
}
// PublisherOf returns a publisher for event type T on the given bus.
//
// The publisher's name should be a short, human-readable string that
// identifies this event publisher. The name is only visible through
// debugging APIs.
func PublisherOf[T any](b *Bus, name string) *Publisher[T] {
ctx, cancel := context.WithCancel(context.Background())
func newPublisher[T any](c *Client) *Publisher[T] {
ret := &Publisher[T]{
bus: b,
name: name,
stopCtx: ctx,
stop: cancel,
client: c,
}
b.addPublisher(ret)
c.addPublisher(ret)
return ret
}
func (p *Publisher[T]) publisherName() string { return p.name }
// Close closes the publisher.
//
// Calls to Publish after Close silently do nothing.
func (p *Publisher[T]) Close() {
// Just unblocks any active calls to Publish, no other
// synchronization needed.
p.stop.Stop()
p.client.deletePublisher(p)
}
func (p *Publisher[T]) publishType() reflect.Type {
return reflect.TypeFor[T]()
}
// Publish publishes event v on the bus.
func (p *Publisher[T]) Publish(v T) {
// Check for just a stopped publisher or bus before trying to
// write, so that once closed Publish consistently does nothing.
select {
case <-p.stopCtx.Done():
return
case <-p.bus.stop.WaitChan():
case <-p.stop.Done():
return
default:
}
evt := PublishedEvent{
Event: v,
From: p.client,
}
select {
case p.bus.write <- v:
case <-p.stopCtx.Done():
case <-p.bus.stop.WaitChan():
case p.client.publish() <- evt:
case <-p.stop.Done():
}
}
// ShouldPublish reports whether anyone is subscribed to events of
// type T.
// ShouldPublish reports whether anyone is subscribed to the events
// that this publisher emits.
//
// ShouldPublish can be used to skip expensive event construction if
// nobody seems to care. Publishers must not assume that someone will
// definitely receive an event if ShouldPublish returns true.
func (p *Publisher[T]) ShouldPublish() bool {
dests := p.bus.dest(reflect.TypeFor[T]())
return len(dests) > 0
}
// Close closes the publisher, indicating that no further events will
// be published with it.
func (p *Publisher[T]) Close() {
p.stop()
p.bus.deletePublisher(p)
return p.client.shouldPublish(reflect.TypeFor[T]())
}

View File

@@ -10,32 +10,32 @@ import (
const maxQueuedItems = 16
// queue is an ordered queue of length up to maxQueuedItems.
type queue struct {
vals []any
type queue[T any] struct {
vals []T
start int
}
// canAppend reports whether a value can be appended to q.vals without
// shifting values around.
func (q *queue) canAppend() bool {
func (q *queue[T]) canAppend() bool {
return cap(q.vals) < maxQueuedItems || len(q.vals) < cap(q.vals)
}
func (q *queue) Full() bool {
func (q *queue[T]) Full() bool {
return q.start == 0 && !q.canAppend()
}
func (q *queue) Empty() bool {
func (q *queue[T]) Empty() bool {
return q.start == len(q.vals)
}
func (q *queue) Len() int {
func (q *queue[T]) Len() int {
return len(q.vals) - q.start
}
// Add adds v to the end of the queue. Blocks until append can be
// done.
func (q *queue) Add(v any) {
func (q *queue[T]) Add(v T) {
if !q.canAppend() {
if q.start == 0 {
panic("Add on a full queue")
@@ -54,21 +54,23 @@ func (q *queue) Add(v any) {
// Peek returns the first value in the queue, without removing it from
// the queue, or nil if the queue is empty.
func (q *queue) Peek() any {
func (q *queue[T]) Peek() T {
if q.Empty() {
return nil
var zero T
return zero
}
return q.vals[q.start]
}
// Drop discards the first value in the queue, if any.
func (q *queue) Drop() {
func (q *queue[T]) Drop() {
if q.Empty() {
return
}
q.vals[q.start] = nil
var zero T
q.vals[q.start] = zero
q.start++
if q.Empty() {
// Reset cursor to start of array, it's free to do.
@@ -78,6 +80,6 @@ func (q *queue) Drop() {
}
// Snapshot returns a copy of the queue's contents.
func (q *queue) Snapshot() []any {
func (q *queue[T]) Snapshot() []T {
return slices.Clone(q.vals[q.start:])
}

View File

@@ -4,49 +4,66 @@
package eventbus
import (
"context"
"fmt"
"reflect"
"sync"
)
type dispatchFn func(vals *queue, stop goroutineShutdownWorker, acceptCh func() chan any) bool
// A Queue receives events from a Bus.
//
// To receive events through the queue, see [Subscribe]. Subscribers
// that share the same Queue receive events one at time, in the order
// they were published.
type Queue struct {
bus *Bus
name string
write chan any
stop goroutineShutdownControl
snapshot chan chan []any
outputsMu sync.Mutex
outputs map[reflect.Type]dispatchFn
type DeliveredEvent struct {
Event any
From *Client
To *Client
}
func newQueue(b *Bus, name string) *Queue {
stopCtl, stopWorker := newGoroutineShutdown()
ret := &Queue{
bus: b,
name: name,
write: make(chan any),
stop: stopCtl,
snapshot: make(chan chan []any),
outputs: map[reflect.Type]dispatchFn{},
// subscriber is a uniformly typed wrapper around Subscriber[T], so
// that debugging facilities can look at active subscribers.
type subscriber interface {
subscribeType() reflect.Type
// dispatch is a function that dispatches the head value in vals to
// a subscriber, while also handling stop and incoming queue write
// events.
//
// dispatch exists because of the strongly typed Subscriber[T]
// wrapper around subscriptions: within the bus events are boxed in an
// 'any', and need to be unpacked to their full type before delivery
// to the subscriber. This involves writing to a strongly-typed
// channel, so subscribeState cannot handle that dispatch by itself -
// but if that strongly typed send blocks, we also need to keep
// processing other potential sources of wakeups, which is how we end
// up at this awkward type signature and sharing of internal state
// through dispatch.
dispatch(ctx context.Context, vals *queue[DeliveredEvent], acceptCh func() chan DeliveredEvent, snapshot chan chan []DeliveredEvent) bool
Close()
}
// subscribeState handles dispatching of events received from a Bus.
type subscribeState struct {
client *Client
dispatcher *worker
write chan DeliveredEvent
snapshot chan chan []DeliveredEvent
debug hook[DeliveredEvent]
outputsMu sync.Mutex
outputs map[reflect.Type]subscriber
}
func newSubscribeState(c *Client) *subscribeState {
ret := &subscribeState{
client: c,
write: make(chan DeliveredEvent),
snapshot: make(chan chan []DeliveredEvent),
outputs: map[reflect.Type]subscriber{},
}
b.addQueue(ret)
go ret.pump(stopWorker)
ret.dispatcher = runWorker(ret.pump)
return ret
}
func (q *Queue) pump(stop goroutineShutdownWorker) {
defer stop.Done()
var vals queue
acceptCh := func() chan any {
func (q *subscribeState) pump(ctx context.Context) {
var vals queue[DeliveredEvent]
acceptCh := func() chan DeliveredEvent {
if vals.Full() {
return nil
}
@@ -55,15 +72,23 @@ func (q *Queue) pump(stop goroutineShutdownWorker) {
for {
if !vals.Empty() {
val := vals.Peek()
fn := q.dispatchFn(val)
if fn == nil {
sub := q.subscriberFor(val.Event)
if sub == nil {
// Raced with unsubscribe.
vals.Drop()
continue
}
if !fn(&vals, stop, acceptCh) {
if !sub.dispatch(ctx, &vals, acceptCh, q.snapshot) {
return
}
if q.debug.active() {
q.debug.run(DeliveredEvent{
Event: val.Event,
From: val.From,
To: q.client,
})
}
} else {
// Keep the cases in this select in sync with
// Subscriber.dispatch below. The only different should be
@@ -72,7 +97,7 @@ func (q *Queue) pump(stop goroutineShutdownWorker) {
select {
case val := <-q.write:
vals.Add(val)
case <-stop.Stop():
case <-ctx.Done():
return
case ch := <-q.snapshot:
ch <- vals.Snapshot()
@@ -81,16 +106,117 @@ func (q *Queue) pump(stop goroutineShutdownWorker) {
}
}
// A Subscriber delivers one type of event from a [Queue].
type Subscriber[T any] struct {
recv *Queue
read chan T
func (s *subscribeState) snapshotQueue() []DeliveredEvent {
if s == nil {
return nil
}
resp := make(chan []DeliveredEvent)
select {
case s.snapshot <- resp:
return <-resp
case <-s.dispatcher.Done():
return nil
}
}
func (s *Subscriber[T]) dispatch(vals *queue, stop goroutineShutdownWorker, acceptCh func() chan any) bool {
t := vals.Peek().(T)
func (s *subscribeState) subscribeTypes() []reflect.Type {
if s == nil {
return nil
}
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
ret := make([]reflect.Type, 0, len(s.outputs))
for t := range s.outputs {
ret = append(ret, t)
}
return ret
}
func (s *subscribeState) addSubscriber(t reflect.Type, sub subscriber) {
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
if s.outputs[t] != nil {
panic(fmt.Errorf("double subscription for event %s", t))
}
s.outputs[t] = sub
s.client.addSubscriber(t, s)
}
func (s *subscribeState) deleteSubscriber(t reflect.Type) {
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
delete(s.outputs, t)
s.client.deleteSubscriber(t, s)
}
func (q *subscribeState) subscriberFor(val any) subscriber {
q.outputsMu.Lock()
defer q.outputsMu.Unlock()
return q.outputs[reflect.TypeOf(val)]
}
// Close closes the subscribeState. Implicitly closes all Subscribers
// linked to this state, and any pending events are discarded.
func (s *subscribeState) close() {
s.dispatcher.StopAndWait()
var subs map[reflect.Type]subscriber
s.outputsMu.Lock()
subs, s.outputs = s.outputs, nil
s.outputsMu.Unlock()
for _, sub := range subs {
sub.Close()
}
}
func (s *subscribeState) closed() <-chan struct{} {
return s.dispatcher.Done()
}
// A Subscriber delivers one type of event from a [Client].
type Subscriber[T any] struct {
stop stopFlag
read chan T
unregister func()
}
func newSubscriber[T any](r *subscribeState) *Subscriber[T] {
t := reflect.TypeFor[T]()
ret := &Subscriber[T]{
read: make(chan T),
unregister: func() { r.deleteSubscriber(t) },
}
r.addSubscriber(t, ret)
return ret
}
func newMonitor[T any](attach func(fn func(T)) (cancel func())) *Subscriber[T] {
ret := &Subscriber[T]{
read: make(chan T, 100), // arbitrary, large
}
ret.unregister = attach(ret.monitor)
return ret
}
func (s *Subscriber[T]) subscribeType() reflect.Type {
return reflect.TypeFor[T]()
}
func (s *Subscriber[T]) monitor(debugEvent T) {
select {
case s.read <- debugEvent:
case <-s.stop.Done():
}
}
func (s *Subscriber[T]) dispatch(ctx context.Context, vals *queue[DeliveredEvent], acceptCh func() chan DeliveredEvent, snapshot chan chan []DeliveredEvent) bool {
t := vals.Peek().Event.(T)
for {
// Keep the cases in this select in sync with Queue.pump
// Keep the cases in this select in sync with subscribeState.pump
// above. The only different should be that this select
// delivers a value on s.read.
select {
@@ -99,9 +225,9 @@ func (s *Subscriber[T]) dispatch(vals *queue, stop goroutineShutdownWorker, acce
return true
case val := <-acceptCh():
vals.Add(val)
case <-stop.Stop():
case <-ctx.Done():
return false
case ch := <-s.recv.snapshot:
case ch := <-snapshot:
ch <- vals.Snapshot()
}
}
@@ -113,58 +239,16 @@ func (s *Subscriber[T]) Events() <-chan T {
return s.read
}
// Close shuts down the Subscriber, indicating the caller no longer
// wishes to receive these events. After Close, receives on
// [Subscriber.Chan] block for ever.
// Done returns a channel that is closed when the subscriber is
// closed.
func (s *Subscriber[T]) Done() <-chan struct{} {
return s.stop.Done()
}
// Close closes the Subscriber, indicating the caller no longer wishes
// to receive this event type. After Close, receives on
// [Subscriber.Events] block for ever.
func (s *Subscriber[T]) Close() {
t := reflect.TypeFor[T]()
s.recv.bus.unsubscribe(t, s.recv)
s.recv.deleteDispatchFn(t)
}
func (q *Queue) dispatchFn(val any) dispatchFn {
q.outputsMu.Lock()
defer q.outputsMu.Unlock()
return q.outputs[reflect.ValueOf(val).Type()]
}
func (q *Queue) addDispatchFn(t reflect.Type, fn dispatchFn) {
q.outputsMu.Lock()
defer q.outputsMu.Unlock()
if q.outputs[t] != nil {
panic(fmt.Errorf("double subscription for event %s", t))
}
q.outputs[t] = fn
}
func (q *Queue) deleteDispatchFn(t reflect.Type) {
q.outputsMu.Lock()
defer q.outputsMu.Unlock()
delete(q.outputs, t)
}
// Done returns a channel that is closed when the Queue is closed.
func (q *Queue) Done() <-chan struct{} {
return q.stop.WaitChan()
}
// Close closes the queue. All Subscribers attached to the queue are
// implicitly closed, and any pending events are discarded.
func (q *Queue) Close() {
q.stop.StopAndWait()
q.bus.deleteQueue(q)
}
// Subscribe requests delivery of events of type T through the given
// Queue. Panics if the queue already has a subscriber for T.
func Subscribe[T any](r *Queue) Subscriber[T] {
t := reflect.TypeFor[T]()
ret := Subscriber[T]{
recv: r,
read: make(chan T),
}
r.addDispatchFn(t, ret.dispatch)
r.bus.subscribe(t, r)
return ret
s.stop.Stop() // unblock receivers
s.unregister()
}

View File

@@ -62,26 +62,21 @@ func IsSandboxedMacOS() bool {
// Tailscale for macOS, either the main GUI process (non-sandboxed) or the
// system extension (sandboxed).
func IsMacSys() bool {
return IsMacSysExt() || IsMacSysApp()
return IsMacSysExt() || IsMacSysGUI()
}
var isMacSysApp lazy.SyncValue[bool]
// IsMacSysApp reports whether this process is the main, non-sandboxed GUI process
// IsMacSysGUI reports whether this process is the main, non-sandboxed GUI process
// that ships with the Standalone variant of Tailscale for macOS.
func IsMacSysApp() bool {
func IsMacSysGUI() bool {
if runtime.GOOS != "darwin" {
return false
}
return isMacSysApp.Get(func() bool {
exe, err := os.Executable()
if err != nil {
return false
}
// Check that this is the GUI binary, and it is not sandboxed. The GUI binary
// shipped in the App Store will always have the App Sandbox enabled.
return strings.HasSuffix(exe, "/Contents/MacOS/Tailscale") && !IsMacAppStore()
return strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") ||
strings.Contains(os.Getenv("XPC_SERVICE_NAME"), "io.tailscale.ipn.macsys")
})
}
@@ -95,10 +90,6 @@ func IsMacSysExt() bool {
return false
}
return isMacSysExt.Get(func() bool {
if strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") ||
strings.Contains(os.Getenv("XPC_SERVICE_NAME"), "io.tailscale.ipn.macsys") {
return true
}
exe, err := os.Executable()
if err != nil {
return false
@@ -109,8 +100,8 @@ func IsMacSysExt() bool {
var isMacAppStore lazy.SyncValue[bool]
// IsMacAppStore whether this binary is from the App Store version of Tailscale
// for macOS.
// IsMacAppStore returns whether this binary is from the App Store version of Tailscale
// for macOS. Returns true for both the network extension and the GUI app.
func IsMacAppStore() bool {
if runtime.GOOS != "darwin" {
return false
@@ -124,6 +115,25 @@ func IsMacAppStore() bool {
})
}
var isMacAppStoreGUI lazy.SyncValue[bool]
// IsMacAppStoreGUI reports whether this binary is the GUI app from the App Store
// version of Tailscale for macOS.
func IsMacAppStoreGUI() bool {
if runtime.GOOS != "darwin" {
return false
}
return isMacAppStoreGUI.Get(func() bool {
exe, err := os.Executable()
if err != nil {
return false
}
// Check that this is the GUI binary, and it is not sandboxed. The GUI binary
// shipped in the App Store will always have the App Sandbox enabled.
return strings.Contains(exe, "/Tailscale") && !IsMacSysGUI()
})
}
var isAppleTV lazy.SyncValue[bool]
// IsAppleTV reports whether this binary is part of the Tailscale network extension for tvOS.

View File

@@ -64,10 +64,30 @@ func (c *Conn) removeDerpPeerRoute(peer key.NodePublic, regionID int, dc *derpht
// addDerpPeerRoute adds a DERP route entry, noting that peer was seen
// on DERP node derpID, at least on the connection identified by dc.
// See issue 150 for details.
func (c *Conn) addDerpPeerRoute(peer key.NodePublic, derpID int, dc *derphttp.Client) {
func (c *Conn) addDerpPeerRoute(peer key.NodePublic, regionID int, dc *derphttp.Client) {
c.mu.Lock()
defer c.mu.Unlock()
mak.Set(&c.derpRoute, peer, derpRoute{derpID, dc})
mak.Set(&c.derpRoute, peer, derpRoute{regionID, dc})
}
// fallbackDERPRegionForPeer returns the DERP region ID we might be able to use
// to contact peer, learned from observing recent DERP traffic from them.
//
// This is used as a fallback when a peer receives a packet from a peer
// over DERP but doesn't known that peer's home DERP or any UDP endpoints.
// This is particularly useful for large one-way nodes (such as hello.ts.net)
// that don't actively reach out to other nodes, so don't need to be told
// the DERP home of peers. They can instead learn the DERP home upon getting the
// first connection.
//
// This can also help nodes from a slow or misbehaving control plane.
func (c *Conn) fallbackDERPRegionForPeer(peer key.NodePublic) (regionID int) {
c.mu.Lock()
defer c.mu.Unlock()
if dr, ok := c.derpRoute[peer]; ok {
return dr.regionID
}
return 0
}
// activeDerp contains fields for an active DERP connection.

View File

@@ -948,7 +948,15 @@ func (de *endpoint) send(buffs [][]byte) error {
de.mu.Unlock()
if !udpAddr.IsValid() && !derpAddr.IsValid() {
return errNoUDPOrDERP
// Make a last ditch effort to see if we have a DERP route for them. If
// they contacted us over DERP and we don't know their UDP endpoints or
// their DERP home, we can at least assume they're reachable over the
// DERP they used to contact us.
if rid := de.c.fallbackDERPRegionForPeer(de.publicKey); rid != 0 {
derpAddr = netip.AddrPortFrom(tailcfg.DerpMagicIPAddr, uint16(rid))
} else {
return errNoUDPOrDERP
}
}
var err error
if udpAddr.IsValid() {

View File

@@ -177,6 +177,10 @@ type Conn struct {
// port mappings from NAT devices.
portMapper *portmapper.Client
// portMapperLogfUnregister is the function to call to unregister
// the portmapper log limiter.
portMapperLogfUnregister func()
// derpRecvCh is used by receiveDERP to read DERP messages.
// It must have buffer size > 0; see issue 3736.
derpRecvCh chan derpReadResult
@@ -532,10 +536,15 @@ func NewConn(opts Options) (*Conn, error) {
c.idleFunc = opts.IdleFunc
c.testOnlyPacketListener = opts.TestOnlyPacketListener
c.noteRecvActivity = opts.NoteRecvActivity
// Don't log the same log messages possibly every few seconds in our
// portmapper.
portmapperLogf := logger.WithPrefix(c.logf, "portmapper: ")
portmapperLogf, c.portMapperLogfUnregister = netmon.LinkChangeLogLimiter(portmapperLogf, opts.NetMon)
portMapOpts := &portmapper.DebugKnobs{
DisableAll: func() bool { return opts.DisablePortMapper || c.onlyTCP443.Load() },
}
c.portMapper = portmapper.NewClient(logger.WithPrefix(c.logf, "portmapper: "), opts.NetMon, portMapOpts, opts.ControlKnobs, c.onPortMapChanged)
c.portMapper = portmapper.NewClient(portmapperLogf, opts.NetMon, portMapOpts, opts.ControlKnobs, c.onPortMapChanged)
c.portMapper.SetGatewayLookupFunc(opts.NetMon.GatewayAndSelfIP)
c.netMon = opts.NetMon
c.health = opts.HealthTracker
@@ -2481,6 +2490,7 @@ func (c *Conn) Close() error {
}
c.stopPeriodicReSTUNTimerLocked()
c.portMapper.Close()
c.portMapperLogfUnregister()
c.peerMap.forEachEndpoint(func(ep *endpoint) {
ep.stopAndReset()

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