Compare commits

...

77 Commits

Author SHA1 Message Date
Anton Tolchanov
cc950c0791 VERSION.txt: this is v1.62.0
Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-03-13 14:35:30 +00:00
Anton Tolchanov
f12d2557f9 prober: add a DERP bandwidth probe
Updates tailscale/corp#17912

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-03-13 13:36:45 +00:00
Anton Tolchanov
5018683d58 prober: remove unused derp prober latency measurements
Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-03-13 13:36:45 +00:00
Anton Tolchanov
205a10b51a prober: export probe counters and cumulative latency
Updates #cleanup

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-03-13 13:36:45 +00:00
Andrew Dunham
7429e8912a wgengine/netstack: fix bug with duplicate SYN packets in client limit
This fixes a bug that was introduced in #11258 where the handling of the
per-client limit didn't properly account for the fact that the gVisor
TCP forwarder will return 'true' to indicate that it's handled a
duplicate SYN packet, but not launch the handler goroutine.

In such a case, we neither decremented our per-client limit in the
wrapper function, nor did we do so in the handler function, leading to
our per-client limit table slowly filling up without bound.

Fix this by doing the same duplicate-tracking logic that the TCP
forwarder does so we can detect such cases and appropriately decrement
our in-flight counter.

Updates tailscale/corp#12184

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ib6011a71d382a10d68c0802593f34b8153d06892
2024-03-11 08:05:00 -04:00
Brad Fitzpatrick
ad33e47270 ipn/{ipnlocal,localapi}: add debug verb to force spam IPN bus NetMap
To force the problem in its worst case scenario before fixing it.

Updates tailscale/corp#17859

Change-Id: I2c8b8e5f15c7801e1ab093feeafac52ec175a763
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-03-09 17:47:15 -08:00
Flakes Updater
04fceae898 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-03-09 11:51:43 -08:00
James Tucker
055117ad45 util/linuxfw: fix support for containers without IPv6 iptables filters (#11381)
There are container environments such as GitHub codespaces that have
partial IPv6 support - routing support is enabled at the kernel level,
but lacking IPv6 filter support in the iptables module.

In the specific example of the codespaces environment, this also has
pre-existing legacy iptables rules in the IPv4 tables, as such the
nascent firewall mode detection will always pick iptables.

We would previously fault trying to install rules to the filter table,
this catches that condition earlier, and disables IPv6 support under
these conditions.

Updates #5621
Updates #11344
Updates #11354

Signed-off-by: James Tucker <james@tailscale.com>
2024-03-08 15:46:21 -08:00
James Tucker
43fba6e04d util/linuxfw: correct logical error in NAT table check (#11380)
Updates #11344
Updates #11354

Signed-off-by: James Tucker <james@tailscale.com>
2024-03-08 15:35:13 -08:00
panchajanya
50a570a83f Code Improvements (#11311)
build_docker, update-flake: cleanup and apply shellcheck fixes

Was editing this file to match my needs while shellcheck warnings
bugged me out.
REV isn't getting used anywhere. Better remove it.

Updates #cleanup

Signed-off-by: Panchajanya1999 <kernel@panchajanya.dev>
Signed-off-by: James Tucker <james@tailscale.com>
2024-03-08 15:24:36 -08:00
Percy Wegmann
e496451928 ipn,cmd/tailscale,client/tailscale: add support for renaming TailFS shares
- Updates API to support renaming TailFS shares.
- Adds a CLI rename subcommand for renaming a share.
- Renames the CLI subcommand 'add' to 'set' to make it clear that
  this is an add or update.
- Adds a unit test for TailFS in ipnlocal

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-03-08 14:48:26 -06:00
Percy Wegmann
6c160e6321 ipn,tailfs: tie TailFS share configuration to user profile
Previously, the configuration of which folders to share persisted across
profile changes. Now, it is tied to the user's profile.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-03-08 14:48:26 -06:00
Percy Wegmann
16ae0f65c0 cmd/viewer: import views when generating byteSliceField
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-03-08 14:48:26 -06:00
Andrew Dunham
f072d017bd wgengine/magicsock: don't change DERP home when not connected to control
This pretty much always results in an outage because peers won't
discover our new home region and thus won't be able to establish
connectivity.

Updates tailscale/corp#18095

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ic0d09133f198b528dd40c6383b16d7663d9d37a7
2024-03-08 14:15:13 -05:00
Sonia Appasamy
54e52532eb version/mkversion: enforce synology versions within int32 range
Synology requires version numbers are within int32 range. This
change updates the version logic to keep things closer within the
range, and errors on building when the range is exceeded.

Updates #cleanup

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-03-08 12:47:59 -05:00
Claire Wang
74e33b9c50 tailcfg: bump CapabilityVersion (#11368)
bump version for adding NodeAttrSuggestExitNode
remove extra s from NodeAttrSuggestExitNode
Updates tailscale/corp#17516

Signed-off-by: Claire Wang <claire@tailscale.com>
2024-03-07 14:17:40 -05:00
Mario Minardi
c662bd9fe7 client/web: dedupe packages in yarn.lock (#11327)
Run yarn-deduplicate on yarn.lock to dedupe packages. This is being done
to reduce the number of redundant packages fetched by yarn when existing
versions in the lockfile satisfy the version dependency we need.

See https://github.com/scinos/yarn-deduplicate for details on the tool
used to perform this deduplication.

Updates #cleanup

Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-03-07 09:29:20 -07:00
Andrew Dunham
34176432d6 cmd/derper, types/logger: move log filter to shared package
So we can use it in trunkd to quiet down the logs there.

Updates #5563

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ie3177dc33f5ad103db832aab5a3e0e4f128f973f
2024-03-07 11:05:03 -05:00
Irbe Krumina
3047b6274c docs/k8s: don't run subnet router in userspace mode (#11363)
There should not be a need to do that unless we run on host network

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-03-07 13:56:11 +00:00
Andrew Dunham
9884d06b80 net/interfaces: fix test hang on Darwin
This test could hang because the subprocess was blocked on writing to
the stdout pipe if we find the address we're looking for early in the
output.

Updates #cleanup

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I68d82c22a5d782098187ae6d8577e43063b72573
2024-03-06 22:37:40 -05:00
Andrew Dunham
62cf83eb92 go.mod: bump gvisor
The `stack.PacketBufferPtr` type no longer exists; replace it with
`*stack.PacketBuffer` instead.

Updates #8043

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ib56ceff09166a042aa3d9b80f50b2aa2d34b3683
2024-03-06 20:22:20 -05:00
Andrew Dunham
8f27d519bb tsweb: add String method to tsweb.RequestID
In case we want to change the format to something opaque later.

Updates tailscale/corp#2549

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ie2eac8b885b694be607e9d5101d24b650026d89c
2024-03-06 19:48:04 -05:00
Irbe Krumina
90c4067010 util/linuxfw: add container-friendly IPv6 NAT check (#11353)
Remove IPv6 NAT check when routing is being set up
using nftables.
This is unnecessary as support for nftables was
added after support for IPv6.
https://tldp.org/HOWTO/Linux+IPv6-HOWTO/ch18s04.html
https://wiki.nftables.org/wiki-nftables/index.php/Building_and_installing_nftables_from_sources

Additionally, run an extra check for IPv6 NAT support
when the routing is set up with iptables.
This is because the earlier checks rely on
being able to use modprobe and on /proc/net/ip6_tables_names
being populated on start - these conditions are usually not
true in container environments.

Updates tailscale/tailscale#11344

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-03-06 21:53:51 +00:00
Percy Wegmann
fd942b5384 ipn/ipnlocal: reduce allocations in TailFS share notifications
This eliminates unnecessary map.Clone() calls and also eliminates
repetitive notifications about the same set of shares.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-03-06 14:21:53 -06:00
Percy Wegmann
6f66f5a75a ipn: add comment about thread-safety to StateStore
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-03-06 12:42:18 -06:00
Andrea Gottardo
0cb86468ca ipn/localapi: add set-gui-visible endpoint
Updates tailscale/corp#17859

Provides a local API endpoint to be called from the GUI to inform the backend when the client menu is opened or closed.

cc @bradfitz

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-03-06 10:39:52 -08:00
Percy Wegmann
00373f07ac ipn/ipnlocal: exclude mullvad exit nodes from TailFS peers list
This is a temporary solution to at least omit Mullvad exit nodes
from the list of TailFS peers. Once we can identify peers that are
actually sharing via TailFS, we can remove this, but for alpha it'll
be sufficient to just omit Mullvad.

Updates tailscale/corp#17766

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-03-06 12:27:32 -06:00
Sonia Appasamy
c58c59ee54 {ipn,cmd/tailscale/cli}: move ServeConfig mutation logic to ipn/serve
Moving logic that manipulates a ServeConfig into recievers on the
ServeConfig in the ipn package. This is setup work to allow the
web client and cli to both utilize these shared functions to edit
the serve config.

Any logic specific to flag parsing or validation is left untouched
in the cli command. The web client will similarly manage its
validation of user's requested changes. If validation logic becomes
similar-enough, we can make a serve util for shared functionality,
which likely does not make sense in ipn.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-03-06 13:26:03 -05:00
Kristoffer Dalby
65255b060b client/tailscale: add postures to UserRuleMatch
Updates tailscale/corp#17770

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-03-06 15:36:17 +01:00
License Updater
d59878e457 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-03-05 17:55:26 -08:00
License Updater
797d75c50a licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-03-05 17:53:52 -08:00
License Updater
6a4e5329c3 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-03-05 17:53:05 -08:00
Andrew Dunham
4338db28f7 wgengine/magicsock: prefer link-local addresses to private ones
Since link-local addresses are definitionally more likely to be a direct
(lower-latency, more reliable) connection than a non-link-local private
address, give those a bit of a boost when selecting endpoints.

Updates #8097

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I93fdeb07de55ba39ba5fcee0834b579ca05c2a4e
2024-03-05 20:32:45 -05:00
Sonia Appasamy
65c3c690cf {ipn/serve,cmd/tailscale/cli}: move some shared funcs to ipn
In preparation for changes to allow configuration of serve/funnel
from the web client, this commit moves some functionality that will
be shared between the CLI and web client to the ipn package's
serve.go file, where some other util funcs are already defined.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-03-05 14:30:38 -05:00
Brad Fitzpatrick
8780e33500 go.toolchain.rev: bump Go toolchain to 1.22.1
Updates tailscale/corp#18000

Change-Id: I45de95e974ea55b0dac2218b3c82d124c4793390
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-03-05 10:51:13 -08:00
Paul Scott
2fa20e3787 util/cmpver: add Less/LessEq helper funcs
Updates tailscale/corp#17199

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-03-05 16:57:04 +00:00
Claire Wang
d610f8eec0 tailcfg: add suggest exit node related node attribute (#11329)
Updates tailscale/corp#17516

Signed-off-by: Claire Wang <claire@tailscale.com>
2024-03-05 10:54:41 -05:00
Chris Palmer
13853e7f29 tsweb: add more test cases for TestCleanRedirectURL (#11331)
Updates #cleanup

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2024-03-04 17:13:36 -08:00
Irbe Krumina
dff6f3377f docs/k8s: update docs (#11307)
Update docs for static Tailscale deployments on kube
to always use firewall mode autodection when in non-userspace.
Also add a note about running multiple replicas and a few suggestions how folks could do that.

Updates#cleanup

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Anton Tolchanov <1687799+knyar@users.noreply.github.com>
2024-03-04 14:59:51 +00:00
Percy Wegmann
232a2d627c tailfs: only impersonate unprivileged user if able to sudo -u as that user
When serving TailFS shares, tailscaled executes another tailscaled to act as a
file server. It attempts to execute this child process as an unprivileged user
using sudo -u. This is important to avoid accessing files as root, which would
result in potential privilege escalation.

Previously, tailscaled assumed that it was running as someone who can sudo -u,
and would fail if it was unable to sudo -u.

With this commit, if tailscaled is unable to sudo -u as the requested user, and
tailscaled is not running as root, then tailscaled executes the the file server
process under the same identity that ran tailscaled, since this is already an
unprivileged identity.

In the unlikely event that tailscaled is running as root but is unable to
sudo -u, it will refuse to run the child file server process in order to avoid
privilege escalation.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-03-01 17:42:19 -06:00
Flakes Updater
00554ad277 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-02-29 19:53:19 -08:00
Andrew Lytvynov
23fbf0003f clientupdate: handle multiple versions in "apk info tailscale" output (#11310)
The package info output can list multiple package versions, and not in
descending order. Find the newest version in the output, instead of the
first one.

Fixes #11309

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-02-29 11:54:46 -07:00
Irbe Krumina
097c5ed927 util/linuxfw: insert rather than append nftables DNAT rule (#11303)
Ensure that the latest DNATNonTailscaleTraffic rule
gets inserted on top of any pre-existing rules.

Updates tailscale/tailscale#11281

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-02-29 16:53:43 +00:00
Percy Wegmann
e324a5660f ipn: include full tailfs shares in ipn notifications
This allows the Mac application to regain access to restricted
folders after restarts.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-02-29 10:16:44 -06:00
Percy Wegmann
80f1cb6227 tailfs: support storing bookmark data on shares
This allows the sandboxed Mac application to store security-
scoped URL bookmarks in order to maintain access to restricted
folders across restarts.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-02-29 10:16:44 -06:00
Brad Fitzpatrick
f18f591bc6 wgengine: plumb the PeerByKey from wgengine to magicsock
This was just added in 69f4b459 which doesn't yet use it. This still
doesn't yet use it. It just pushes it down deeper into magicsock where
it'll used later.

Updates #7617

Change-Id: If2f8fd380af150ffc763489e1ff4f8ca2899fac6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-02-28 19:36:34 -08:00
Andrew Lytvynov
c7474431f1 tsweb: allow empty redirect URL in CleanRedirectURL (#11295)
Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-02-28 15:57:42 -08:00
Brad Fitzpatrick
b68a09cb34 ipn/ipnlocal: make active IPN sessions keyed by sessionID
We used a HandleSet before when we didn't have a unique handle. But a
sessionID is a unique handle, so use that instead. Then that replaces
the other map we had.

And now we'll have a way to look up an IPN session by sessionID for
later.

Updates tailscale/corp#17859

Change-Id: I5f647f367563ec8783c643e49f93817b341d9064
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-02-28 15:16:10 -08:00
Percy Wegmann
2d5d6f5403 ipn,wgengine: only intercept TailFS traffic on quad 100
This fixes a regression introduced with 993acf4 and released in
v1.60.0.

The regression caused us to intercept all userspace traffic to port
8080 which prevented users from exposing their own services to their
tailnet at port 8080.

Now, we only intercept traffic to port 8080 if it's bound for
100.100.100.100 or fd7a:115c:a1e0::53.

Fixes #11283

Signed-off-by: Percy Wegmann <percy@tailscale.com>
(cherry picked from commit 17cd0626f3)
2024-02-28 17:09:14 -06:00
Ross Zurowski
e83e2e881b client/web: fix Vite CJS deprecation warning (#11288)
Starting in Vite 5, Vite now issues a deprecation warning when using
a CJS-based Vite config file. This commit fixes it by adding the
`"type": "module"` to our package.json to opt our files into ESM module
behaviours.

Fixes #cleanup

Signed-off-by: Ross Zurowski <ross@rosszurowski.com>
2024-02-28 16:28:22 -05:00
Brad Fitzpatrick
69f4b4595a wgengine{,/wgint}: add wgint.Peer wrapper type, add to wgengine.Engine
This adds a method to wgengine.Engine and plumbed down into magicsock
to add a way to get a type-safe Tailscale-safe wrapper around a
wireguard-go device.Peer that only exposes methods that are safe for
Tailscale to use internally.

It also removes HandshakeAttempts from PeerStatusLite that was just
added as it wasn't needed yet and is now accessible ala cart as needed
from the Peer type accessor.

None of this is used yet.

Updates #7617

Change-Id: I07be0c4e6679883e6eeddf8dbed7394c9e79c5f4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-02-28 09:50:18 -08:00
James Tucker
7e17aeb36b .github/workflows: fix regular breakage of go toolchains
This server recently had a common ansible applied, which added a
periodic /tmp cleaner, as is needed on other CI machines to deal with
test tempfile leakage. The setting of $HOME to /tmp means that the go
toolchain in there was regularly getting pruned by the tmp cleaner, but
often incompletely, because it was also in use.

Move HOME to a runner owned directory.

Updates #11248

Signed-off-by: James Tucker <james@tailscale.com>
2024-02-28 08:17:35 -08:00
Brad Fitzpatrick
b4ff9a578f wgengine: rename local variable from 'found' to conventional 'ok'
Updates #cleanup

Change-Id: I799dc86ea9e4a3a949592abdd8e74282e7e5d086
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-02-28 07:33:57 -08:00
Brad Fitzpatrick
a8a525282c wgengine: use slices.Clone in two places
Updates #cleanup

Change-Id: I1cb30efb6d09180e82b807d6146f37897ef99307
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-02-28 07:33:57 -08:00
Brad Fitzpatrick
74b8985e19 ipn/ipnstate, wgengine: make PeerStatusLite.LastHandshake zero Time means none
... rather than 1970. Code was using IsZero against the 1970 team
(which isn't a zero value), but fortunately not anywhere that seems to
have mattered.

Updates #cleanup

Change-Id: I708a3f2a9398aaaedc9503678b4a8a311e0e019e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-02-28 07:33:57 -08:00
Andrew Dunham
3dd8ae2f26 net/tstun: fix spelling of "WireGuard"
Updates #cleanup

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ida7e30f4689bc18f5f7502f53a0adb5ac3c7981a
2024-02-28 00:00:18 -05:00
Andrew Dunham
a20e46a80f util/cache: fix missing interface methods (#11275)
Updates #cleanup


Change-Id: Ib3a33a7609530ef8c9f3f58fc607a61e8655c4b5

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2024-02-27 23:03:49 -05:00
Andrew Dunham
23e9447871 tsweb: expose function to generate request IDs
For use in corp.

Updates tailscale/corp#2549

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I71debae1ce9ae48cf69cc44c2ab5c443fc3b2005
2024-02-27 18:57:53 -05:00
Mario Minardi
7912d76da0 client/web: update to typescript 5.3.3 (#11267)
Update typescript to 5.3.3. This is a major bump from the previous
version of 4.8.3. This also requires adding newer versions of
@typescript-eslint/eslint-plugin and @typescript-eslint/parser to our
resolutions as eslint-config-react-app pulls in versions that otherwise
do not support typescript 5.x.

eslint-config-react-app has not been updated in 2 years and is seemingly
abandoned, so we may wish to fork it or move to a different eslint config
in the future.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-02-27 14:17:30 -07:00
Andrew Dunham
c5abbcd4b4 wgengine/netstack: add a per-client limit for in-flight TCP forwards
This is a fun one. Right now, when a client is connecting through a
subnet router, here's roughly what happens:

1. The client initiates a connection to an IP address behind a subnet
   router, and sends a TCP SYN
2. The subnet router gets the SYN packet from netstack, and after
   running through acceptTCP, starts DialContext-ing the destination IP,
   without accepting the connection¹
3. The client retransmits the SYN packet a few times while the dial is
   in progress, until either...
4. The subnet router successfully establishes a connection to the
   destination IP and sends the SYN-ACK back to the client, or...
5. The subnet router times out and sends a RST to the client.
6. If the connection was successful, the client ACKs the SYN-ACK it
   received, and traffic starts flowing

As a result, the notification code in forwardTCP never notices when a
new connection attempt is aborted, and it will wait until either the
connection is established, or until the OS-level connection timeout is
reached and it aborts.

To mitigate this, add a per-client limit on how many in-flight TCP
forwarding connections can be in-progress; after this, clients will see
a similar behaviour to the global limit, where new connection attempts
are aborted instead of waiting. This prevents a single misbehaving
client from blocking all other clients of a subnet router by ensuring
that it doesn't starve the global limiter.

Also, bump the global limit again to a higher value.

¹ We can't accept the connection before establishing a connection to the
remote server since otherwise we'd be opening the connection and then
immediately closing it, which breaks a bunch of stuff; see #5503 for
more details.

Updates tailscale/corp#12184

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I76e7008ddd497303d75d473f534e32309c8a5144
2024-02-27 15:25:40 -05:00
Claire Wang
352c1ac96c tailcfg: add latitude, longitude for node location (#11162)
Updates tailscale/corp#17590

Signed-off-by: Claire Wang <claire@tailscale.com>
2024-02-27 15:02:06 -05:00
Irbe Krumina
95dcc1745b cmd/k8s-operator: reconcile tailscale Ingresses when their backend Services change. (#11255)
This is so that if a backend Service gets created after the Ingress, it gets picked up by the operator.

Updates tailscale/tailscale#11251

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Anton Tolchanov <1687799+knyar@users.noreply.github.com>
2024-02-27 15:19:53 +00:00
Irbe Krumina
303125d96d cmd/k8s-operator: configure all proxies with declarative config (#11238)
Containerboot container created for operator's ingress and egress proxies
are now always configured by passing a configfile to tailscaled
(tailscaled --config <configfile-path>.
It does not run 'tailscale set' or 'tailscale up'.
Upgrading existing setups to this version as well as
downgrading existing setups at this version works.

Updates tailscale/tailscale#10869

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-02-27 15:14:09 +00:00
Irbe Krumina
45d27fafd6 cmd/k8s-operator,k8s-operator,go.{mod,sum},tstest/tools: add Tailscale Kubernetes operator API docs (#11246)
Add logic to autogenerate CRD docs.
.github/workflows/kubemanifests.yaml CI workflow will fail if the doc is out of date with regard to the current CRDs.
Docs can be refreshed by running make kube-generate-all.

Updates tailscale/tailscale#11023

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-02-27 14:51:53 +00:00
Percy Wegmann
05acf76392 tailfs: fix race condition in tailfs_test
Ues a noop authenticator to avoid potential races in gowebdav's
built-in authenticator.

Fixes #11259

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-02-27 08:31:04 -06:00
Keli
086ef19439 scripts/installer.sh: auto-start tailscale on Alpine (#11214)
On Alpine, we add the tailscale service but fail to call start.
This means that tailscale does not start up until the user reboots the machine.

Fixes #11161

Signed-off-by: Keli Velazquez <keli@tailscale.com>
2024-02-27 09:17:12 -05:00
Brad Fitzpatrick
1cf85822d0 ipn/ipnstate, wgengine/wgint: add handshake attempts accessors
Not yet used. This is being made available so magicsock/wgengine can
use it to ignore certain sends (UDP + DERP) later on at least mobile,
letting wireguard-go think it's doing its full attempt schedule, but
we can cut it short conditionally based on what we know from the
control plane.

Updates #7617

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ia367cf6bd87b2aeedd3c6f4989528acdb6773ca7
2024-02-26 19:09:12 -08:00
Brad Fitzpatrick
eb28818403 wgengine: make pendOpen time later, after dup check
Otherwise on OS retransmits, we'd make redundant timers in Go's timer
heap that upon firing just do nothing (well, grab a mutex and check a
map and see that there's nothing to do).

Updates #cleanup

Change-Id: Id30b8b2d629cf9c7f8133a3f7eca5dc79e81facb
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-02-26 19:09:12 -08:00
Brad Fitzpatrick
219efebad4 wgengine: reduce critical section
No need to hold wgLock while using the device to LookupPeer;
that has its own mutex already.

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ib56049fcc7163cf5a2c2e7e12916f07b4f9d67cb
2024-02-26 19:09:12 -08:00
Brad Fitzpatrick
9a8c2f47f2 types/key: remove copy returning array by value
It's unnecessary. Returning an array value is already a copy.

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: If7f350b61003ea08f16a531b7b4e8ae483617939
2024-02-26 19:09:12 -08:00
Anton Tolchanov
8cc5c51888 health: warn about reverse path filtering and exit nodes
When reverse path filtering is in strict mode on Linux, using an exit
node blocks all network connectivity. This change adds a warning about
this to `tailscale status` and the logs.

Example in `tailscale status`:

```
- not connected to home DERP region 22
- The following issues on your machine will likely make usage of exit nodes impossible: [interface "eth0" has strict reverse-path filtering enabled], please set rp_filter=2 instead of rp_filter=1; see https://github.com/tailscale/tailscale/issues/3310
```

Example in the logs:
```
2024/02/21 21:17:07 health("overall"): error: multiple errors:
	not in map poll
	The following issues on your machine will likely make usage of exit nodes impossible: [interface "eth0" has strict reverse-path filtering enabled], please set rp_filter=2 instead of rp_filter=1; see https://github.com/tailscale/tailscale/issues/3310
```

Updates #3310

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-02-27 00:43:01 +00:00
Nick Khyl
7ef1fb113d cmd/tailscaled, ipn/ipnlocal, wgengine: shutdown tailscaled if wgdevice is closed
Tailscaled becomes inoperative if the Tailscale Tunnel wintun adapter is abruptly removed.
wireguard-go closes the device in case of a read error, but tailscaled keeps running.
This adds detection of a closed WireGuard device, triggering a graceful shutdown of tailscaled.
It is then restarted by the tailscaled watchdog service process.

Fixes #11222

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-02-26 14:45:35 -06:00
Nick Khyl
b42b9817b0 net/dns: do not wait for the interface registry key to appear if the windowsManager is being closed
The WinTun adapter may have been removed by the time we're closing
the dns.windowsManager, and its associated interface registry key might
also have been deleted. We shouldn't use winutil.OpenKeyWait and wait
for the interface key to appear when performing a cleanup as a part of
the windowsManager shutdown.

Updates #11222

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-02-26 14:45:35 -06:00
OSS Updater
82c569a83a go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2024-02-26 13:26:47 -05:00
Sonia Appasamy
95f26565db client/web: use grants on web UI frontend
Starts using peer capabilities to restrict the management client
on a per-view basis. This change also includes a bulky cleanup
of the login-toggle.tsx file, which was getting pretty unwieldy
in its previous form.

Updates tailscale/corp#16695

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-02-26 12:59:37 -05:00
Sonia Appasamy
9aa704a05d client/web: restrict serveAPI endpoints to peer capabilities
This change adds a new apiHandler struct for use from serveAPI
to aid with restricting endpoints to specific peer capabilities.

Updates tailscale/corp#16695

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-02-26 12:59:37 -05:00
Anton Tolchanov
cd9cf93de6 wgengine/netstack: expose TCP forwarder drops via clientmetrics
- add a clientmetric with a counter of TCP forwarder drops due to the
  max attempts;
- fix varz metric types, as they are all counters.

Updates #8210

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-02-26 17:32:34 +00:00
121 changed files with 6352 additions and 1963 deletions

View File

@@ -206,7 +206,7 @@ jobs:
- name: Run VM tests
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
env:
HOME: "/tmp"
HOME: "/var/lib/ghrunner/home"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"

View File

@@ -1 +1 @@
1.61.0
1.62.0

View File

@@ -20,9 +20,9 @@
set -eu
# Use the "go" binary from the "tool" directory (which is github.com/tailscale/go)
export PATH=$PWD/tool:$PATH
export PATH="$PWD"/tool:"$PATH"
eval $(./build_dist.sh shellvars)
eval "$(./build_dist.sh shellvars)"
DEFAULT_TARGET="client"
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
@@ -74,4 +74,4 @@ case "$TARGET" in
echo "unknown target: $TARGET"
exit 1
;;
esac
esac

View File

@@ -270,6 +270,14 @@ type UserRuleMatch struct {
Users []string `json:"users"`
Ports []string `json:"ports"`
LineNumber int `json:"lineNumber"`
// Postures is a list of posture policies that are
// associated with this match. The rules can be looked
// up in the ACLPreviewResponse parent struct.
// The source of the list is from srcPosture on
// an ACL or Grant rule:
// https://tailscale.com/kb/1288/device-posture#posture-conditions
Postures []string `json:"postures"`
}
// ACLPreviewResponse is the response type of previewACLPostRequest
@@ -277,6 +285,12 @@ type ACLPreviewResponse struct {
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
Type string `json:"type"` // The request type: currently only "user" or "ipport".
PreviewFor string `json:"previewFor"` // A specific user or ipport.
// Postures is a map of postures and associated rules that apply
// to this preview.
// For more details about the posture mapping, see:
// https://tailscale.com/kb/1288/device-posture#postures
Postures map[string][]string `json:"postures,omitempty"`
}
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
@@ -284,6 +298,12 @@ type ACLPreview struct {
Matches []UserRuleMatch `json:"matches"`
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
// Postures is a map of postures and associated rules that apply
// to this preview.
// For more details about the posture mapping, see:
// https://tailscale.com/kb/1288/device-posture#postures
Postures map[string][]string `json:"postures,omitempty"`
}
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
@@ -341,8 +361,9 @@ func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (r
}
return &ACLPreview{
Matches: b.Matches,
User: b.PreviewFor,
Matches: b.Matches,
User: b.PreviewFor,
Postures: b.Postures,
}, nil
}
@@ -369,8 +390,9 @@ func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netip.
}
return &ACLPreview{
Matches: b.Matches,
IPPort: b.PreviewFor,
Matches: b.Matches,
IPPort: b.PreviewFor,
Postures: b.Postures,
}, nil
}
@@ -394,8 +416,9 @@ func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, use
}
return &ACLPreview{
Matches: b.Matches,
User: b.PreviewFor,
Matches: b.Matches,
User: b.PreviewFor,
Postures: b.Postures,
}, nil
}
@@ -419,8 +442,9 @@ func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, i
}
return &ACLPreview{
Matches: b.Matches,
IPPort: b.PreviewFor,
Matches: b.Matches,
IPPort: b.PreviewFor,
Postures: b.Postures,
}, nil
}

View File

@@ -1426,10 +1426,10 @@ func (lc *LocalClient) TailFSSetFileServerAddr(ctx context.Context, addr string)
return err
}
// TailFSShareAdd adds the given share to the list of shares that TailFS will
// serve to remote nodes. If a share with the same name already exists, the
// existing share is replaced/updated.
func (lc *LocalClient) TailFSShareAdd(ctx context.Context, share *tailfs.Share) error {
// TailFSShareSet adds or updates the given share in the list of shares that
// TailFS will serve to remote nodes. If a share with the same name already
// exists, the existing share is replaced/updated.
func (lc *LocalClient) TailFSShareSet(ctx context.Context, share *tailfs.Share) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/tailfs/shares", http.StatusCreated, jsonBody(share))
return err
}
@@ -1442,20 +1442,29 @@ func (lc *LocalClient) TailFSShareRemove(ctx context.Context, name string) error
"DELETE",
"/localapi/v0/tailfs/shares",
http.StatusNoContent,
jsonBody(&tailfs.Share{
Name: name,
}))
strings.NewReader(name))
return err
}
// TailFSShareRename renames the share from old to new name.
func (lc *LocalClient) TailFSShareRename(ctx context.Context, oldName, newName string) error {
_, err := lc.send(
ctx,
"POST",
"/localapi/v0/tailfs/shares",
http.StatusNoContent,
jsonBody([2]string{oldName, newName}))
return err
}
// TailFSShareList returns the list of shares that TailFS is currently serving
// to remote nodes.
func (lc *LocalClient) TailFSShareList(ctx context.Context) (map[string]*tailfs.Share, error) {
func (lc *LocalClient) TailFSShareList(ctx context.Context) ([]*tailfs.Share, error) {
result, err := lc.get200(ctx, "/localapi/v0/tailfs/shares")
if err != nil {
return nil, err
}
var shares map[string]*tailfs.Share
var shares []*tailfs.Share
err = json.Unmarshal(result, &shares)
return shares, err
}

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -234,7 +235,12 @@ func (s *Server) newSessionID() (string, error) {
return "", errors.New("too many collisions generating new session; please refresh page")
}
type peerCapabilities map[capFeature]bool // value is true if the peer can edit the given feature
// peerCapabilities holds information about what a source
// peer is allowed to edit via the web UI.
//
// map value is true if the peer can edit the given feature.
// Only capFeatures included in validCaps will be included.
type peerCapabilities map[capFeature]bool
// canEdit is true if the peerCapabilities grant edit access
// to the given feature.
@@ -248,21 +254,47 @@ func (p peerCapabilities) canEdit(feature capFeature) bool {
return p[feature]
}
// isEmpty is true if p is either nil or has no capabilities
// with value true.
func (p peerCapabilities) isEmpty() bool {
if p == nil {
return true
}
for _, v := range p {
if v == true {
return false
}
}
return true
}
type capFeature string
const (
// The following values should not be edited.
// New caps can be added, but existing ones should not be changed,
// as these exact values are used by users in tailnet policy files.
//
// IMPORTANT: When adding a new cap, also update validCaps slice below.
capFeatureAll capFeature = "*" // grants peer management of all features
capFeatureFunnel capFeature = "funnel" // grants peer serve/funnel management
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSubnet capFeature = "subnet" // grants peer subnet routes management
capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
capFeatureAll capFeature = "*" // grants peer management of all features
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
)
// validCaps contains the list of valid capabilities used in the web client.
// Any capabilities included in a peer's grants that do not fall into this
// list will be ignored.
var validCaps []capFeature = []capFeature{
capFeatureAll,
capFeatureSSH,
capFeatureSubnets,
capFeatureExitNodes,
capFeatureAccount,
}
type capRule struct {
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
}
@@ -270,7 +302,13 @@ type capRule struct {
// toPeerCapabilities parses out the web ui capabilities from the
// given whois response.
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
if whois == nil {
if whois == nil || status == nil {
return peerCapabilities{}, nil
}
if whois.Node.IsTagged() {
// We don't allow management *from* tagged nodes, so ignore caps.
// The web client auth flow relies on having a true user identity
// that can be verified through login.
return peerCapabilities{}, nil
}
@@ -291,7 +329,10 @@ func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (
}
for _, c := range rules {
for _, f := range c.CanEdit {
caps[capFeature(strings.ToLower(f))] = true
cap := capFeature(strings.ToLower(f))
if slices.Contains(validCaps, cap) {
caps[cap] = true
}
}
}
return caps, nil

View File

@@ -6,6 +6,7 @@
"node": "18.16.1",
"yarn": "1.22.19"
},
"type": "module",
"private": true,
"dependencies": {
"@radix-ui/react-collapsible": "^1.0.3",
@@ -32,12 +33,16 @@
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
"typescript": "^4.7.4",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^1.3.1"
},
"resolutions": {
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1"
},
"scripts": {
"build": "vite build",
"start": "vite",

View File

@@ -11,8 +11,8 @@ import LoginView from "src/components/views/login-view"
import SSHView from "src/components/views/ssh-view"
import SubnetRouterView from "src/components/views/subnet-router-view"
import { UpdatingView } from "src/components/views/updating-view"
import useAuth, { AuthResponse } from "src/hooks/auth"
import { Feature, featureDescription, NodeData } from "src/types"
import useAuth, { AuthResponse, canEdit } from "src/hooks/auth"
import { Feature, NodeData, featureDescription } from "src/types"
import Card from "src/ui/card"
import EmptyState from "src/ui/empty-state"
import LoadingDots from "src/ui/loading-dots"
@@ -56,16 +56,19 @@ function WebClient({
<Header node={node} auth={auth} newSession={newSession} />
<Switch>
<Route path="/">
<HomeView readonly={!auth.canManageNode} node={node} />
<HomeView node={node} auth={auth} />
</Route>
<Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
<DeviceDetailsView node={node} auth={auth} />
</Route>
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
<SubnetRouterView
readonly={!canEdit("subnets", auth)}
node={node}
/>
</FeatureRoute>
<FeatureRoute path="/ssh" feature="ssh" node={node}>
<SSHView readonly={!auth.canManageNode} node={node} />
<SSHView readonly={!canEdit("ssh", auth)} node={node} />
</FeatureRoute>
{/* <Route path="/serve">Share local content</Route> */}
<FeatureRoute path="/update" feature="auto-update" node={node}>

View File

@@ -2,15 +2,17 @@
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useEffect, useState } from "react"
import React, { useCallback, useMemo, useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Eye from "src/assets/icons/eye.svg?react"
import User from "src/assets/icons/user.svg?react"
import { AuthResponse, AuthType } from "src/hooks/auth"
import { AuthResponse, hasAnyEditCapabilities } from "src/hooks/auth"
import { useTSWebConnected } from "src/hooks/ts-web-connected"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic"
import { assertNever, isHTTPS } from "src/utils/util"
export default function LoginToggle({
node,
@@ -22,12 +24,29 @@ export default function LoginToggle({
newSession: () => Promise<void>
}) {
const [open, setOpen] = useState<boolean>(false)
const { tsWebConnected, checkTSWebConnection } = useTSWebConnected(
auth.serverMode,
node.IPv4
)
return (
<Popover
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
className="p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
content={
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
auth.serverMode === "readonly" ? (
<ReadonlyModeContent auth={auth} />
) : auth.serverMode === "login" ? (
<LoginModeContent
auth={auth}
node={node}
tsWebConnected={tsWebConnected}
checkTSWebConnection={checkTSWebConnection}
/>
) : auth.serverMode === "manage" ? (
<ManageModeContent auth={auth} node={node} newSession={newSession} />
) : (
assertNever(auth.serverMode)
)
}
side="bottom"
align="end"
@@ -35,228 +54,303 @@ export default function LoginToggle({
onOpenChange={setOpen}
asChild
>
{!auth.canManageNode ? (
<button
className={cx(
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
) : (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
{
"bg-transparent": !open,
"bg-gray-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic
size="medium"
url={auth.viewerIdentity?.profilePicUrl}
/>
</button>
</div>
)}
<div>
{auth.authorized ? (
<TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
) : (
<TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
)}
</div>
</Popover>
)
}
function LoginPopoverContent({
/**
* TriggerWhenManaging is displayed as the trigger for the login popover
* when the user has an active authorized managment session.
*/
function TriggerWhenManaging({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
{
"bg-transparent": !open,
"bg-gray-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
</button>
</div>
)
}
/**
* TriggerWhenReading is displayed as the trigger for the login popover
* when the user is currently in read mode (doesn't have an authorized
* management session).
*/
function TriggerWhenReading({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<button
className={cx(
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
)
}
/**
* PopoverContentHeader is the header for the login popover.
*/
function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
return (
<div className="text-black text-sm font-medium leading-tight mb-1">
{auth.authorized ? "Managing" : "Viewing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
)
}
/**
* PopoverContentFooter is the footer for the login popover.
*/
function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
return auth.viewerIdentity ? (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-gray-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
) : null
}
/**
* ReadonlyModeContent is the body of the login popover when the web
* client is being run in "readonly" server mode.
*/
function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
return (
<>
<PopoverContentHeader auth={auth} />
<p className="text-gray-500 text-xs">
This web interface is running in read-only mode.{" "}
<a
href="https://tailscale.com/s/web-client-read-only"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<PopoverContentFooter auth={auth} />
</>
)
}
/**
* LoginModeContent is the body of the login popover when the web
* client is being run in "login" server mode.
*/
function LoginModeContent({
node,
auth,
tsWebConnected,
checkTSWebConnection,
}: {
node: NodeData
auth: AuthResponse
tsWebConnected: boolean
checkTSWebConnection: () => void
}) {
const https = isHTTPS()
// We can't run the ts web connection test when the webpage is loaded
// over HTTPS. So in this case, we default to presenting a login button
// with some helper text reminding the user to check their connection
// themselves.
const hasACLAccess = https || tsWebConnected
const hasEditCaps = useMemo(() => {
if (!auth.viewerIdentity) {
// If not connected to login client over tailscale, we won't know the viewer's
// identity. So we must assume they may be able to edit something and have the
// management client handle permissions once the user gets there.
return true
}
return hasAnyEditCapabilities(auth)
}, [auth])
const handleLogin = useCallback(() => {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// If we're inside an iframe, open management client in new window.
window.open(manageURL, "_blank")
} else {
window.location.href = manageURL
}
}, [node.IPv4])
return (
<div
onMouseEnter={
hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
}
>
<PopoverContentHeader auth={auth} />
{!hasACLAccess || !hasEditCaps ? (
<>
<p className="text-gray-500 text-xs">
{!hasEditCaps ? (
// ACLs allow access, but user isn't allowed to edit any features,
// restricted to readonly. No point in sending them over to the
// tailscaleIP:5252 address.
<>
You dont have permission to make changes to this device, but
you can view most of its details.
</>
) : !node.ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access to anyone.
<>
The current tailnet policy file does not allow connecting to
this device.
</>
) : (
// ACLs don't allow access to this user specifically.
<>
Cannot access this devices Tailscale IP. Make sure you are
connected to your tailnet, and that your policy file allows
access.
</>
)}{" "}
<a
href="https://tailscale.com/s/web-client-access"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
</>
) : (
// User can connect to Tailcale IP; sign in when ready.
<>
<p className="text-gray-500 text-xs">
You can see most of this devices details. To make changes, you need
to sign in.
</p>
{https && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your policy
file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleLogin} />
</>
)}
<PopoverContentFooter auth={auth} />
</div>
)
}
/**
* ManageModeContent is the body of the login popover when the web
* client is being run in "manage" server mode.
*/
function ManageModeContent({
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
newSession: () => void
}) {
/**
* canConnectOverTS indicates whether the current viewer
* is able to hit the node's web client that's being served
* at http://${node.IP}:5252. If false, this means that the
* viewer must connect to the correct tailnet before being
* able to sign in.
*/
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
// Whether the current page is loaded over HTTPS.
// If it is, then the connectivity check to the management client
// will fail with a mixed-content error.
const isHTTPS = window.location.protocol === "https:"
const checkTSConnection = useCallback(() => {
if (auth.viewerIdentity || isHTTPS) {
// Skip the connectivity check if we either already know we're connected over Tailscale,
// or know the connectivity check will fail because the current page is loaded over HTTPS.
setCanConnectOverTS(true)
return
}
// Otherwise, test connection to the ts IP.
if (isRunningCheck) {
return // already checking
}
setIsRunningCheck(true)
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
.then(() => {
setCanConnectOverTS(true)
setIsRunningCheck(false)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
/**
* Checking connection for first time on page load.
*
* While not connected, we check again whenever the mouse
* enters the popover component, to pick up on the user
* leaving to turn on Tailscale then returning to the view.
* See `onMouseEnter` on the div below.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => checkTSConnection(), [])
const handleSignInClick = useCallback(() => {
if (auth.viewerIdentity && auth.serverMode === "manage") {
if (window.self !== window.top) {
// if we're inside an iframe, start session in new window
let url = new URL(window.location.href)
url.searchParams.set("check", "now")
window.open(url, "_blank")
} else {
newSession()
}
const handleLogin = useCallback(() => {
if (window.self !== window.top) {
// If we're inside an iframe, start session in new window.
let url = new URL(window.location.href)
url.searchParams.set("check", "now")
window.open(url, "_blank")
} else {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// if we're inside an iframe, open management client in new window
window.open(manageURL, "_blank")
} else {
window.location.href = manageURL
}
newSession()
}
}, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
}, [newSession])
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
return (
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
<div className="text-black text-sm font-medium leading-tight mb-1">
{!auth.canManageNode ? "Viewing" : "Managing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
{!auth.canManageNode && (
<>
{auth.serverMode === "readonly" ? (
<>
<PopoverContentHeader auth={auth} />
{!auth.authorized &&
(hasAnyPermissions ? (
// User is connected over Tailscale, but needs to complete check mode.
<>
<p className="text-gray-500 text-xs">
This web interface is running in read-only mode.{" "}
<a
href="https://tailscale.com/s/web-client-read-only"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
To make changes, sign in to confirm your identity. This extra step
helps us keep your device secure.
</p>
) : !auth.viewerIdentity ? (
// User is not connected over Tailscale.
// These states are only possible on the login client.
<>
{!canConnectOverTS ? (
<>
<p className="text-gray-500 text-xs">
{!node.ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access.
<>
The current tailnet policy file does not allow
connecting to this device.
</>
) : (
// ACLs allow access, but user can't connect.
<>
Cannot access this devices Tailscale IP. Make sure you
are connected to your tailnet, and that your policy file
allows access.
</>
)}{" "}
<a
href="https://tailscale.com/s/web-client-connection"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
</>
) : (
// User can connect to Tailcale IP; sign in when ready.
<>
<p className="text-gray-500 text-xs">
You can see most of this devices details. To make changes,
you need to sign in.
</p>
{isHTTPS && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your
policy file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
)}
</>
) : auth.authNeeded === AuthType.tailscale ? (
// User is connected over Tailscale, but needs to complete check mode.
<>
<p className="text-gray-500 text-xs">
To make changes, sign in to confirm your identity. This extra
step helps us keep your device secure.
</p>
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
) : (
// User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.
</p>
)}
</>
)}
{auth.viewerIdentity && (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-gray-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
)}
</div>
<SignInButton auth={auth} onClick={handleLogin} />
</>
) : (
// User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.{" "}
<a
href="https://tailscale.com/s/web-client-access"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
))}
<PopoverContentFooter auth={auth} />
</>
)
}

View File

@@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components"
import NiceIP from "src/components/nice-ip"
import { UpdateAvailableNotification } from "src/components/update-available"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Card from "src/ui/card"
@@ -16,11 +17,11 @@ import QuickCopy from "src/ui/quick-copy"
import { useLocation } from "wouter"
export default function DeviceDetailsView({
readonly,
node,
auth,
}: {
readonly: boolean
node: NodeData
auth: AuthResponse
}) {
return (
<>
@@ -37,11 +38,11 @@ export default function DeviceDetailsView({
})}
/>
</div>
{!readonly && <DisconnectDialog />}
{canEdit("account", auth) && <DisconnectDialog />}
</div>
</Card>
{node.Features["auto-update"] &&
!readonly &&
canEdit("account", auth) &&
node.ClientVersion &&
!node.ClientVersion.RunningLatest && (
<UpdateAvailableNotification details={node.ClientVersion} />

View File

@@ -8,17 +8,18 @@ import ArrowRight from "src/assets/icons/arrow-right.svg?react"
import Machine from "src/assets/icons/machine.svg?react"
import AddressCard from "src/components/address-copy-card"
import ExitNodeSelector from "src/components/exit-node-selector"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types"
import Card from "src/ui/card"
import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter"
export default function HomeView({
readonly,
node,
auth,
}: {
readonly: boolean
node: NodeData
auth: AuthResponse
}) {
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
() => [
@@ -63,7 +64,11 @@ export default function HomeView({
</div>
{(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && (
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} />
<ExitNodeSelector
className="mb-5"
node={node}
disabled={!canEdit("exitnodes", auth)}
/>
)}
<Link
className="link font-medium"

View File

@@ -4,25 +4,50 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api"
export enum AuthType {
synology = "synology",
tailscale = "tailscale",
}
export type AuthResponse = {
authNeeded?: AuthType
canManageNode: boolean
serverMode: "login" | "readonly" | "manage"
serverMode: AuthServerMode
authorized: boolean
viewerIdentity?: {
loginName: string
nodeName: string
nodeIP: string
profilePicUrl?: string
capabilities: { [key in PeerCapability]: boolean }
}
needsSynoAuth?: boolean
}
// useAuth reports and refreshes Tailscale auth status
// for the web client.
export type AuthServerMode = "login" | "readonly" | "manage"
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
/**
* canEdit reports whether the given auth response specifies that the viewer
* has the ability to edit the given capability.
*/
export function canEdit(cap: PeerCapability, auth: AuthResponse): boolean {
if (!auth.authorized || !auth.viewerIdentity) {
return false
}
if (auth.viewerIdentity.capabilities["*"] === true) {
return true // can edit all features
}
return auth.viewerIdentity.capabilities[cap] === true
}
/**
* hasAnyEditCapabilities reports whether the given auth response specifies
* that the viewer has at least one edit capability. If this is true, the
* user is able to go through the auth flow to authenticate a management
* session.
*/
export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
return Object.values(auth.viewerIdentity?.capabilities || {}).includes(true)
}
/**
* useAuth reports and refreshes Tailscale auth status for the web client.
*/
export default function useAuth() {
const [data, setData] = useState<AuthResponse>()
const [loading, setLoading] = useState<boolean>(true)
@@ -33,18 +58,16 @@ export default function useAuth() {
return apiFetch<AuthResponse>("/auth", "GET")
.then((d) => {
setData(d)
switch (d.authNeeded) {
case AuthType.synology:
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setRanSynoAuth(true)
setLoading(false)
})
break
default:
setLoading(false)
if (d.needsSynoAuth) {
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setRanSynoAuth(true)
setLoading(false)
})
} else {
setLoading(false)
}
return d
})
@@ -72,8 +95,13 @@ export default function useAuth() {
useEffect(() => {
loadAuth().then((d) => {
if (!d) {
return
}
if (
!d?.canManageNode &&
!d.authorized &&
hasAnyEditCapabilities(d) &&
// Start auth flow immediately if browser has requested it.
new URLSearchParams(window.location.search).get("check") === "now"
) {
newSession()

View File

@@ -0,0 +1,46 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { isHTTPS } from "src/utils/util"
import { AuthServerMode } from "./auth"
/**
* useTSWebConnected hook is used to check whether the browser is able to
* connect to the web client served at http://${nodeIPv4}:5252
*/
export function useTSWebConnected(mode: AuthServerMode, nodeIPv4: string) {
const [tsWebConnected, setTSWebConnected] = useState<boolean>(
mode === "manage" // browser already on the web client
)
const [isLoading, setIsLoading] = useState<boolean>(false)
const checkTSWebConnection = useCallback(() => {
if (mode === "manage") {
// Already connected to the web client.
setTSWebConnected(true)
return
}
if (isHTTPS()) {
// When page is loaded over HTTPS, the connectivity check will always
// fail with a mixed-content error. In this case don't bother doing
// the check.
return
}
if (isLoading) {
return // already checking
}
setIsLoading(true)
fetch(`http://${nodeIPv4}:5252/ok`, { mode: "no-cors" })
.then(() => {
setTSWebConnected(true)
setIsLoading(false)
})
.catch(() => setIsLoading(false))
}, [isLoading, mode, nodeIPv4])
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => checkTSWebConnection(), []) // checking connection for first time on page load
return { tsWebConnected, checkTSWebConnection, isLoading }
}

View File

@@ -49,3 +49,10 @@ export function isPromise<T = unknown>(val: unknown): val is Promise<T> {
}
return typeof val === "object" && "then" in val
}
/**
* isHTTPS reports whether the current page is loaded over HTTPS.
*/
export function isHTTPS() {
return window.location.protocol === "https:"
}

View File

@@ -1,7 +1,7 @@
const plugin = require("tailwindcss/plugin")
const styles = require("./styles.json")
import plugin from "tailwindcss/plugin"
import styles from "./styles.json"
module.exports = {
const config = {
theme: {
screens: {
sm: "420px",
@@ -96,20 +96,22 @@ module.exports = {
plugins: [
plugin(function ({ addVariant }) {
addVariant("state-open", [
'&[data-state="open"]',
'[data-state="open"] &',
"&[data-state=open”]",
"[data-state=open] &",
])
addVariant("state-closed", [
'&[data-state="closed"]',
'[data-state="closed"] &',
"&[data-state=closed”]",
"[data-state=closed] &",
])
addVariant("state-delayed-open", [
'&[data-state="delayed-open"]',
'[data-state="delayed-open"] &',
"&[data-state=delayed-open”]",
"[data-state=delayed-open] &",
])
addVariant("state-active", ['&[data-state="active"]'])
addVariant("state-inactive", ['&[data-state="inactive"]'])
addVariant("state-active", ["&[data-state=active”]"])
addVariant("state-inactive", ["&[data-state=inactive”]"])
}),
],
content: ["./src/**/*.html", "./src/**/*.{ts,tsx}", "./index.html"],
}
export default config

View File

@@ -445,18 +445,188 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
}
}
type authType string
type apiHandler[data any] struct {
s *Server
w http.ResponseWriter
r *http.Request
var (
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
)
// permissionCheck allows for defining whether a requesting peer's
// capabilities grant them access to make the given data update.
// If permissionCheck reports false, the request fails as unauthorized.
permissionCheck func(data data, peer peerCapabilities) bool
}
// newHandler constructs a new api handler which restricts the given request
// to the specified permission check. If the permission check fails for
// the peer associated with the request, an unauthorized error is returned
// to the client.
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
return &apiHandler[data]{
s: s,
w: w,
r: r,
permissionCheck: permissionCheck,
}
}
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
// for requests that are always allowed to complete regardless of a peer's
// capabilities.
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
// WhoIs when originally checking for a session from authorizeRequest.
// Would be nice if we could pipe those through to here so we don't end
// up having to re-call them to grab the peer capabilities.
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
if err != nil {
return nil, err
}
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
if err != nil {
return nil, err
}
peer, err := toPeerCapabilities(status, whois)
if err != nil {
return nil, err
}
return peer, nil
}
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
// handle runs the given handler if the source peer satisfies the
// constraints for running this request.
//
// handle is expected for use when `data` type is empty, or set to
// `noBodyData` in practice. For requests that expect JSON body data
// to be attached, use handleJSON instead.
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
var body data // not used
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
h(a.w, a.r)
}
// handleJSON manages decoding the request's body JSON and passing
// it on to the provided function if the source peer satisfies the
// constraints for running this request.
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
defer a.r.Body.Close()
var body data
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
if err := h(a.r.Context(), body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
a.w.WriteHeader(http.StatusOK)
}
// serveAPI serves requests for the web client api.
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
// which protects the handler using gorilla csrf.
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == httpm.PATCH {
// Enforce that PATCH requests are always application/json.
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
}
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetNodeData)
return
case path == "/exit-nodes" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetExitNodes)
return
case path == "/routes" && r.Method == httpm.POST:
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
return false
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
return false
}
return true
}
newHandler[postRoutesRequest](s, w, r, peerAllowed).
handleJSON(s.servePostRoutes)
return
case path == "/device-details-click" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveDeviceDetailsClick)
return
case path == "/local/v0/logout" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
return false
}
return true
}
newHandler[maskedPrefs](s, w, r, peerAllowed).
handleJSON(s.serveUpdatePrefs)
return
case path == "/local/v0/update/check" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/update/check" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
type authResponse struct {
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
CanManageNode bool `json:"canManageNode"`
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
ServerMode ServerMode `json:"serverMode"`
Authorized bool `json:"authorized"` // has an authorized management session
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
NeedsSynoAuth bool `json:"needsSynoAuth,omitempty"`
}
// viewerIdentity is the Tailscale identity of the source node
@@ -475,9 +645,11 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
var resp authResponse
resp.ServerMode = s.mode
session, whois, status, sErr := s.getSession(r)
var caps peerCapabilities
if whois != nil {
caps, err := toPeerCapabilities(status, whois)
var err error
caps, err = toPeerCapabilities(status, whois)
if err != nil {
http.Error(w, sErr.Error(), http.StatusInternalServerError)
return
@@ -504,7 +676,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
return
}
if !authorized {
resp.AuthNeeded = synoAuth
resp.NeedsSynoAuth = true
writeJSON(w, resp)
return
}
@@ -520,21 +692,17 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
switch {
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
resp.AuthNeeded = ""
resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errNotOwner):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
resp.AuthNeeded = ""
resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
resp.AuthNeeded = ""
resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
resp.AuthNeeded = ""
resp.Authorized = false // restricted to the readonly view
case sErr != nil && !errors.Is(sErr, errNoSession):
// Any other error.
http.Error(w, sErr.Error(), http.StatusInternalServerError)
@@ -545,16 +713,26 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
} else {
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
}
resp.CanManageNode = true
resp.AuthNeeded = ""
// User has a valid session. They're now authorized to edit if they
// have any edit capabilities. In practice, they won't be sent through
// the auth flow if they don't have edit caps, but their ACL granted
// permissions may change at any time. The frontend views and backend
// endpoints are always restricted to their current capabilities in
// addition to a valid session.
//
// But, we also check the caps here for a better user experience on
// the frontend login toggle, which uses resp.Authorized to display
// "viewing" vs "managing" copy. If they don't have caps, we want to
// display "viewing" even if they have a valid session.
resp.Authorized = !caps.isEmpty()
default:
// whois being nil implies local as the request did not come over Tailscale
if whois == nil || (whois.Node.StableID == status.Self.ID) {
// whois being nil implies local as the request did not come over Tailscale.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
} else {
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
}
resp.AuthNeeded = tailscaleAuth
resp.Authorized = false // not yet authorized
}
writeJSON(w, resp)
@@ -618,32 +796,6 @@ func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request)
}
}
// serveAPI serves requests for the web client api.
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
// which protects the handler using gorilla csrf.
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data" && r.Method == httpm.GET:
s.serveGetNodeData(w, r)
return
case path == "/exit-nodes" && r.Method == httpm.GET:
s.serveGetExitNodes(w, r)
return
case path == "/routes" && r.Method == httpm.POST:
s.servePostRoutes(w, r)
return
case path == "/device-details-click" && r.Method == httpm.POST:
s.serveDeviceDetailsClick(w, r)
return
case strings.HasPrefix(path, "/local/"):
s.proxyRequestToLocalAPI(w, r)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
type nodeData struct {
ID tailcfg.StableNodeID
Status string
@@ -880,6 +1032,23 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, exitNodes)
}
// maskedPrefs is the subset of ipn.MaskedPrefs that are
// allowed to be editable via the web UI.
type maskedPrefs struct {
RunSSHSet bool
RunSSH bool
}
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
RunSSHSet: prefs.RunSSHSet,
Prefs: ipn.Prefs{
RunSSH: prefs.RunSSH,
},
})
return err
}
type postRoutesRequest struct {
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
SetRoutes bool // when set, AdvertiseRoutes value is applied
@@ -888,18 +1057,10 @@ type postRoutesRequest struct {
AdvertiseRoutes []string
}
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var data postRoutesRequest
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
prefs, err := s.lc.GetPrefs(r.Context())
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return err
}
var currNonExitRoutes []string
var currAdvertisingExitNode bool
@@ -922,8 +1083,7 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
routesStr := strings.Join(data.AdvertiseRoutes, ",")
routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return err
}
hasExitNodeRoute := func(all []netip.Prefix) bool {
@@ -932,8 +1092,7 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
}
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
http.Error(w, "cannot use and advertise exit node at same time", http.StatusBadRequest)
return
return errors.New("cannot use and advertise exit node at same time")
}
// Make prefs update.
@@ -945,12 +1104,8 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
AdvertiseRoutes: routes,
},
}
if _, err := s.lc.EditPrefs(r.Context(), p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = s.lc.EditPrefs(ctx, p)
return err
}
// tailscaleUp starts the daemon with the provided options.
@@ -1089,26 +1244,12 @@ func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request)
//
// The web API request path is expected to exactly match a localapi path,
// with prefix /api/local/ rather than /localapi/.
//
// If the localapi path is not included in localapiAllowlist,
// the request is rejected.
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/local")
if r.URL.Path == path { // missing prefix
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if r.Method == httpm.PATCH {
// enforce that PATCH requests are always application/json
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
}
if !slices.Contains(localapiAllowlist, path) {
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
return
}
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
@@ -1133,21 +1274,6 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
}
}
// localapiAllowlist is an allowlist of localapi endpoints the
// web client is allowed to proxy to the client's localapi.
//
// Rather than exposing all localapi endpoints over the proxy,
// this limits to just the ones actually used from the web
// client frontend.
var localapiAllowlist = []string{
"/v0/logout",
"/v0/prefs",
"/v0/update/check",
"/v0/update/install",
"/v0/update/progress",
"/v0/upload-client-metrics",
}
// csrfKey returns a key that can be used for CSRF protection.
// If an error occurs during key creation, the error is logged and the active process terminated.
// If the server is running in CGI mode, the key is cached to disk and reused between requests.

View File

@@ -4,6 +4,7 @@
package web
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -86,75 +87,172 @@ func TestQnapAuthnURL(t *testing.T) {
// TestServeAPI tests the web client api's handling of
// 1. invalid endpoint errors
// 2. localapi proxy allowlist
// 2. permissioning of api endpoints based on node capabilities
func TestServeAPI(t *testing.T) {
selfTags := views.SliceOf([]string{"tag:server"})
self := &ipnstate.PeerStatus{ID: "self", Tags: &selfTags}
prefs := &ipn.Prefs{}
remoteUser := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
remoteIPWithAllCapabilities := "100.100.100.101"
remoteIPWithNoCapabilities := "100.100.100.102"
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
// Serve dummy localapi. Just returns "success".
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "success")
})}
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{
remoteIPWithAllCapabilities: {
Node: &tailcfg.Node{StableID: "node1"},
UserProfile: remoteUser,
CapMap: tailcfg.PeerCapMap{tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{"{\"canEdit\":[\"*\"]}"}},
},
remoteIPWithNoCapabilities: {
Node: &tailcfg.Node{StableID: "node2"},
UserProfile: remoteUser,
},
},
func() *ipnstate.PeerStatus { return self },
func() *ipn.Prefs { return prefs },
nil,
)
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
}
type requestTest struct {
remoteIP string
wantResponse string
wantStatus int
}
tests := []struct {
name string
reqMethod string
reqPath string
reqMethod string
reqContentType string
wantResp string
wantStatus int
reqBody string
tests []requestTest
}{{
name: "invalid_endpoint",
reqMethod: httpm.POST,
reqPath: "/not-an-endpoint",
wantResp: "invalid endpoint",
wantStatus: http.StatusNotFound,
reqPath: "/not-an-endpoint",
reqMethod: httpm.POST,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}},
}, {
name: "not_in_localapi_allowlist",
reqMethod: httpm.POST,
reqPath: "/local/v0/not-allowlisted",
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
wantStatus: http.StatusForbidden,
reqPath: "/local/v0/not-an-endpoint",
reqMethod: httpm.POST,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}},
}, {
name: "in_localapi_allowlist",
reqMethod: httpm.POST,
reqPath: "/local/v0/logout",
wantResp: "success", // Successfully allowed to hit localapi.
wantStatus: http.StatusOK,
reqPath: "/local/v0/logout",
reqMethod: httpm.POST,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed", // requesting node has insufficient permissions
wantStatus: http.StatusUnauthorized,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "success", // requesting node has sufficient permissions
wantStatus: http.StatusOK,
}},
}, {
reqPath: "/exit-nodes",
reqMethod: httpm.GET,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "null",
wantStatus: http.StatusOK, // allowed, no additional capabilities required
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "null",
wantStatus: http.StatusOK,
}},
}, {
reqPath: "/routes",
reqMethod: httpm.POST,
reqBody: "{\"setExitNode\":true}",
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed",
wantStatus: http.StatusUnauthorized,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantStatus: http.StatusOK,
}},
}, {
name: "patch_bad_contenttype",
reqMethod: httpm.PATCH,
reqPath: "/local/v0/prefs",
reqMethod: httpm.PATCH,
reqBody: "{\"runSSHSet\":true}",
reqContentType: "application/json",
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed",
wantStatus: http.StatusUnauthorized,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantStatus: http.StatusOK,
}},
}, {
reqPath: "/local/v0/prefs",
reqMethod: httpm.PATCH,
reqContentType: "multipart/form-data",
wantResp: "invalid request",
wantStatus: http.StatusBadRequest,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "invalid request",
wantStatus: http.StatusBadRequest,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "invalid request",
wantStatus: http.StatusBadRequest,
}},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, nil)
if tt.reqContentType != "" {
r.Header.Add("Content-Type", tt.reqContentType)
}
w := httptest.NewRecorder()
for _, req := range tt.tests {
t.Run(req.remoteIP+"_requesting_"+tt.reqPath, func(t *testing.T) {
var reqBody io.Reader
if tt.reqBody != "" {
reqBody = bytes.NewBuffer([]byte(tt.reqBody))
}
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, reqBody)
r.RemoteAddr = req.remoteIP
if tt.reqContentType != "" {
r.Header.Add("Content-Type", tt.reqContentType)
}
w := httptest.NewRecorder()
s.serveAPI(w, r)
res := w.Result()
defer res.Body.Close()
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
if tt.wantResp != gotResp {
t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
}
})
s.serveAPI(w, r)
res := w.Result()
defer res.Body.Close()
if gotStatus := res.StatusCode; req.wantStatus != gotStatus {
t.Errorf("wrong status; want=%v, got=%v", req.wantStatus, gotStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
if req.wantResponse != gotResp {
t.Errorf("wrong response; want=%q, got=%q", req.wantResponse, gotResp)
}
})
}
}
}
@@ -524,7 +622,7 @@ func TestServeAuth(t *testing.T) {
name: "no-session",
path: "/api/auth",
wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
wantNewCookie: false,
wantSession: nil,
},
@@ -549,7 +647,7 @@ func TestServeAuth(t *testing.T) {
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
@@ -597,7 +695,7 @@ func TestServeAuth(t *testing.T) {
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
@@ -1121,9 +1219,10 @@ func TestPeerCapabilities(t *testing.T) {
status: userOwnedStatus,
whois: &apitype.WhoIsResponse{
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)},
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
@@ -1134,9 +1233,10 @@ func TestPeerCapabilities(t *testing.T) {
status: userOwnedStatus,
whois: &apitype.WhoIsResponse{
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)},
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
@@ -1146,6 +1246,7 @@ func TestPeerCapabilities(t *testing.T) {
name: "tag-owned-no-webui-caps",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
},
@@ -1156,68 +1257,71 @@ func TestPeerCapabilities(t *testing.T) {
name: "tag-owned-one-webui-cap",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureSSH: true,
capFeatureSubnets: true,
},
},
{
name: "tag-owned-multiple-webui-cap",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
"{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}",
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
"{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureExitNode: true,
capFeatureAll: true,
capFeatureSSH: true,
capFeatureSubnets: true,
capFeatureExitNodes: true,
capFeatureAll: true,
},
},
{
name: "tag-owned-case-insensitive-caps",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"SSH\",\"sUBnet\"]}",
"{\"canEdit\":[\"SSH\",\"sUBnets\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureSSH: true,
capFeatureSubnets: true,
},
},
{
name: "tag-owned-random-canEdit-contents-dont-error",
name: "tag-owned-random-canEdit-contents-get-dropped",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"unknown-feature\"]}",
},
},
},
wantCaps: peerCapabilities{
"unknown-feature": true,
},
wantCaps: peerCapabilities{},
},
{
name: "tag-owned-no-canEdit-section",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canDoSomething\":[\"*\"]}",
@@ -1226,6 +1330,19 @@ func TestPeerCapabilities(t *testing.T) {
},
wantCaps: peerCapabilities{},
},
{
name: "tagged-source-caps-ignored",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
}
for _, tt := range toPeerCapsTests {
t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
@@ -1249,36 +1366,33 @@ func TestPeerCapabilities(t *testing.T) {
name: "empty-caps",
caps: nil,
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureFunnel: false,
capFeatureSSH: false,
capFeatureSubnet: false,
capFeatureExitNode: false,
capFeatureAccount: false,
capFeatureAll: false,
capFeatureSSH: false,
capFeatureSubnets: false,
capFeatureExitNodes: false,
capFeatureAccount: false,
},
},
{
name: "some-caps",
caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureFunnel: false,
capFeatureSSH: true,
capFeatureSubnet: false,
capFeatureExitNode: false,
capFeatureAccount: true,
capFeatureAll: false,
capFeatureSSH: true,
capFeatureSubnets: false,
capFeatureExitNodes: false,
capFeatureAccount: true,
},
},
{
name: "wildcard-in-caps",
caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: true,
capFeatureFunnel: true,
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureExitNode: true,
capFeatureAccount: true,
capFeatureAll: true,
capFeatureSSH: true,
capFeatureSubnets: true,
capFeatureExitNodes: true,
capFeatureAccount: true,
},
},
}
@@ -1339,6 +1453,9 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
metricCapture(metricNames[0].Name)
writeJSON(w, struct{}{})
return
case "/localapi/v0/logout":
fmt.Fprintf(w, "success")
return
default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}

View File

@@ -20,23 +20,7 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.10", "@babel/code-frame@^7.22.5":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.10.tgz#1c20e612b768fefa75f6e90d6ecb86329247f0a3"
integrity sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==
dependencies:
"@babel/highlight" "^7.22.10"
chalk "^2.4.2"
"@babel/code-frame@^7.22.13":
version "7.22.13"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
dependencies:
"@babel/highlight" "^7.22.13"
chalk "^2.4.2"
"@babel/code-frame@^7.23.4":
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.10", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.22.5", "@babel/code-frame@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.4.tgz#03ae5af150be94392cb5c7ccd97db5a19a5da6aa"
integrity sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==
@@ -44,17 +28,12 @@
"@babel/highlight" "^7.23.4"
chalk "^2.4.2"
"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.23.3":
"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9", "@babel/compat-data@^7.23.3":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.3.tgz#3febd552541e62b5e883a25eb3effd7c7379db11"
integrity sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==
"@babel/compat-data@^7.22.9":
version "7.22.9"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730"
integrity sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==
"@babel/core@^7.16.0":
"@babel/core@^7.16.0", "@babel/core@^7.21.3":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.3.tgz#5ec09c8803b91f51cc887dedc2654a35852849c9"
integrity sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==
@@ -75,27 +54,6 @@
json5 "^2.2.3"
semver "^6.3.1"
"@babel/core@^7.21.3":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.10.tgz#aad442c7bcd1582252cb4576747ace35bc122f35"
integrity sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==
dependencies:
"@ampproject/remapping" "^2.2.0"
"@babel/code-frame" "^7.22.10"
"@babel/generator" "^7.22.10"
"@babel/helper-compilation-targets" "^7.22.10"
"@babel/helper-module-transforms" "^7.22.9"
"@babel/helpers" "^7.22.10"
"@babel/parser" "^7.22.10"
"@babel/template" "^7.22.5"
"@babel/traverse" "^7.22.10"
"@babel/types" "^7.22.10"
convert-source-map "^1.7.0"
debug "^4.1.0"
gensync "^1.0.0-beta.2"
json5 "^2.2.2"
semver "^6.3.1"
"@babel/eslint-parser@^7.16.3":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.23.3.tgz#7bf0db1c53b54da0c8a12627373554a0828479ca"
@@ -105,27 +63,7 @@
eslint-visitor-keys "^2.1.0"
semver "^6.3.1"
"@babel/generator@^7.22.10":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.10.tgz#c92254361f398e160645ac58831069707382b722"
integrity sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==
dependencies:
"@babel/types" "^7.22.10"
"@jridgewell/gen-mapping" "^0.3.2"
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/generator@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420"
integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==
dependencies:
"@babel/types" "^7.23.0"
"@jridgewell/gen-mapping" "^0.3.2"
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/generator@^7.23.3", "@babel/generator@^7.23.4":
"@babel/generator@^7.22.10", "@babel/generator@^7.23.0", "@babel/generator@^7.23.3", "@babel/generator@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.4.tgz#4a41377d8566ec18f807f42962a7f3551de83d1c"
integrity sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==
@@ -149,18 +87,7 @@
dependencies:
"@babel/types" "^7.22.15"
"@babel/helper-compilation-targets@^7.22.10":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz#01d648bbc25dd88f513d862ee0df27b7d4e67024"
integrity sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==
dependencies:
"@babel/compat-data" "^7.22.9"
"@babel/helper-validator-option" "^7.22.5"
browserslist "^4.21.9"
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6":
"@babel/helper-compilation-targets@^7.22.10", "@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz#0698fc44551a26cf29f18d4662d5bf545a6cfc52"
integrity sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==
@@ -206,16 +133,11 @@
lodash.debounce "^4.0.8"
resolve "^1.14.2"
"@babel/helper-environment-visitor@^7.22.20":
"@babel/helper-environment-visitor@^7.22.20", "@babel/helper-environment-visitor@^7.22.5":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
"@babel/helper-environment-visitor@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98"
integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==
"@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
@@ -238,32 +160,14 @@
dependencies:
"@babel/types" "^7.23.0"
"@babel/helper-module-imports@^7.22.15":
"@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.22.5":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
dependencies:
"@babel/types" "^7.22.15"
"@babel/helper-module-imports@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz#1a8f4c9f4027d23f520bd76b364d44434a72660c"
integrity sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==
dependencies:
"@babel/types" "^7.22.5"
"@babel/helper-module-transforms@^7.22.9":
version "7.22.9"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz#92dfcb1fbbb2bc62529024f72d942a8c97142129"
integrity sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==
dependencies:
"@babel/helper-environment-visitor" "^7.22.5"
"@babel/helper-module-imports" "^7.22.5"
"@babel/helper-simple-access" "^7.22.5"
"@babel/helper-split-export-declaration" "^7.22.6"
"@babel/helper-validator-identifier" "^7.22.5"
"@babel/helper-module-transforms@^7.23.3":
"@babel/helper-module-transforms@^7.22.9", "@babel/helper-module-transforms@^7.23.3":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1"
integrity sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==
@@ -325,36 +229,21 @@
dependencies:
"@babel/types" "^7.22.5"
"@babel/helper-string-parser@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
"@babel/helper-string-parser@^7.23.4":
"@babel/helper-string-parser@^7.22.5", "@babel/helper-string-parser@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83"
integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==
"@babel/helper-validator-identifier@^7.22.20":
"@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.22.5":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
"@babel/helper-validator-identifier@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193"
integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==
"@babel/helper-validator-option@^7.22.15":
"@babel/helper-validator-option@^7.22.15", "@babel/helper-validator-option@^7.22.5":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040"
integrity sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==
"@babel/helper-validator-option@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz#de52000a15a177413c8234fa3a8af4ee8102d0ac"
integrity sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==
"@babel/helper-wrap-function@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz#15352b0b9bfb10fc9c76f79f6342c00e3411a569"
@@ -364,16 +253,7 @@
"@babel/template" "^7.22.15"
"@babel/types" "^7.22.19"
"@babel/helpers@^7.22.10":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.10.tgz#ae6005c539dfbcb5cd71fb51bfc8a52ba63bc37a"
integrity sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==
dependencies:
"@babel/template" "^7.22.5"
"@babel/traverse" "^7.22.10"
"@babel/types" "^7.22.10"
"@babel/helpers@^7.23.2":
"@babel/helpers@^7.22.10", "@babel/helpers@^7.23.2":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.23.4.tgz#7d2cfb969aa43222032193accd7329851facf3c1"
integrity sha512-HfcMizYz10cr3h29VqyfGL6ZWIjTwWfvYBMsBVGwpcbhNGe3wQ1ZXZRPzZoAHhd9OqHadHqjQ89iVKINXnbzuw==
@@ -382,25 +262,7 @@
"@babel/traverse" "^7.23.4"
"@babel/types" "^7.23.4"
"@babel/highlight@^7.22.10":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.10.tgz#02a3f6d8c1cb4521b2fd0ab0da8f4739936137d7"
integrity sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==
dependencies:
"@babel/helper-validator-identifier" "^7.22.5"
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/highlight@^7.22.13":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
dependencies:
"@babel/helper-validator-identifier" "^7.22.20"
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/highlight@^7.23.4":
"@babel/highlight@^7.22.10", "@babel/highlight@^7.22.13", "@babel/highlight@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==
@@ -409,17 +271,7 @@
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/parser@^7.22.10", "@babel/parser@^7.22.5":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.10.tgz#e37634f9a12a1716136c44624ef54283cabd3f55"
integrity sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==
"@babel/parser@^7.22.15", "@babel/parser@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
"@babel/parser@^7.23.3", "@babel/parser@^7.23.4":
"@babel/parser@^7.22.10", "@babel/parser@^7.22.15", "@babel/parser@^7.22.5", "@babel/parser@^7.23.0", "@babel/parser@^7.23.3", "@babel/parser@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.4.tgz#409fbe690c333bb70187e2de4021e1e47a026661"
integrity sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==
@@ -1234,21 +1086,14 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
"@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4":
"@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.16.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.4.tgz#36fa1d2b36db873d25ec631dcc4923fdc1cf2e2e"
integrity sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.13.10":
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.22.15":
"@babel/template@^7.22.15", "@babel/template@^7.22.5":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
@@ -1257,32 +1102,7 @@
"@babel/parser" "^7.22.15"
"@babel/types" "^7.22.15"
"@babel/template@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
integrity sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==
dependencies:
"@babel/code-frame" "^7.22.5"
"@babel/parser" "^7.22.5"
"@babel/types" "^7.22.5"
"@babel/traverse@^7.22.10":
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
dependencies:
"@babel/code-frame" "^7.22.13"
"@babel/generator" "^7.23.0"
"@babel/helper-environment-visitor" "^7.22.20"
"@babel/helper-function-name" "^7.23.0"
"@babel/helper-hoist-variables" "^7.22.5"
"@babel/helper-split-export-declaration" "^7.22.6"
"@babel/parser" "^7.23.0"
"@babel/types" "^7.23.0"
debug "^4.1.0"
globals "^11.1.0"
"@babel/traverse@^7.23.3", "@babel/traverse@^7.23.4":
"@babel/traverse@^7.22.10", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.4.tgz#c2790f7edf106d059a0098770fe70801417f3f85"
integrity sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==
@@ -1298,25 +1118,7 @@
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.21.3", "@babel/types@^7.22.10", "@babel/types@^7.22.5":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
integrity sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==
dependencies:
"@babel/helper-string-parser" "^7.22.5"
"@babel/helper-validator-identifier" "^7.22.5"
to-fast-properties "^2.0.0"
"@babel/types@^7.22.15", "@babel/types@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
dependencies:
"@babel/helper-string-parser" "^7.22.5"
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
"@babel/types@^7.22.19", "@babel/types@^7.23.3", "@babel/types@^7.23.4", "@babel/types@^7.4.4":
"@babel/types@^7.21.3", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.3", "@babel/types@^7.23.4", "@babel/types@^7.4.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.4.tgz#7206a1810fc512a7f7f7d4dace4cb4c1c9dbfb8e"
integrity sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==
@@ -1445,14 +1247,14 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae"
integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
"@eslint-community/eslint-utils@^4.2.0":
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
dependencies:
eslint-visitor-keys "^3.3.0"
"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1":
"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63"
integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==
@@ -2063,17 +1865,12 @@
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a"
integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==
"@types/estree@1.0.5":
"@types/estree@1.0.5", "@types/estree@^1.0.0":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
"@types/estree@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194"
integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==
"@types/json-schema@^7.0.9":
"@types/json-schema@^7.0.12", "@types/json-schema@^7.0.9":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
@@ -2121,26 +1918,27 @@
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
"@types/semver@^7.3.12":
version "7.5.6"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339"
integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
"@types/semver@^7.3.12", "@types/semver@^7.5.0":
version "7.5.8"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==
"@typescript-eslint/eslint-plugin@^5.5.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db"
integrity sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==
"@typescript-eslint/eslint-plugin@^5.5.0", "@typescript-eslint/eslint-plugin@^6.2.1":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3"
integrity sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==
dependencies:
"@eslint-community/regexpp" "^4.4.0"
"@typescript-eslint/scope-manager" "5.62.0"
"@typescript-eslint/type-utils" "5.62.0"
"@typescript-eslint/utils" "5.62.0"
"@eslint-community/regexpp" "^4.5.1"
"@typescript-eslint/scope-manager" "6.21.0"
"@typescript-eslint/type-utils" "6.21.0"
"@typescript-eslint/utils" "6.21.0"
"@typescript-eslint/visitor-keys" "6.21.0"
debug "^4.3.4"
graphemer "^1.4.0"
ignore "^5.2.0"
natural-compare-lite "^1.4.0"
semver "^7.3.7"
tsutils "^3.21.0"
ignore "^5.2.4"
natural-compare "^1.4.0"
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/experimental-utils@^5.0.0":
version "5.62.0"
@@ -2149,14 +1947,15 @@
dependencies:
"@typescript-eslint/utils" "5.62.0"
"@typescript-eslint/parser@^5.5.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.62.0.tgz#1b63d082d849a2fcae8a569248fbe2ee1b8a56c7"
integrity sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==
"@typescript-eslint/parser@^5.5.0", "@typescript-eslint/parser@^6.2.1":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b"
integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==
dependencies:
"@typescript-eslint/scope-manager" "5.62.0"
"@typescript-eslint/types" "5.62.0"
"@typescript-eslint/typescript-estree" "5.62.0"
"@typescript-eslint/scope-manager" "6.21.0"
"@typescript-eslint/types" "6.21.0"
"@typescript-eslint/typescript-estree" "6.21.0"
"@typescript-eslint/visitor-keys" "6.21.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@5.62.0":
@@ -2167,21 +1966,34 @@
"@typescript-eslint/types" "5.62.0"
"@typescript-eslint/visitor-keys" "5.62.0"
"@typescript-eslint/type-utils@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a"
integrity sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==
"@typescript-eslint/scope-manager@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1"
integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==
dependencies:
"@typescript-eslint/typescript-estree" "5.62.0"
"@typescript-eslint/utils" "5.62.0"
"@typescript-eslint/types" "6.21.0"
"@typescript-eslint/visitor-keys" "6.21.0"
"@typescript-eslint/type-utils@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz#6473281cfed4dacabe8004e8521cee0bd9d4c01e"
integrity sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==
dependencies:
"@typescript-eslint/typescript-estree" "6.21.0"
"@typescript-eslint/utils" "6.21.0"
debug "^4.3.4"
tsutils "^3.21.0"
ts-api-utils "^1.0.1"
"@typescript-eslint/types@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
"@typescript-eslint/types@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d"
integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==
"@typescript-eslint/typescript-estree@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
@@ -2195,6 +2007,20 @@
semver "^7.3.7"
tsutils "^3.21.0"
"@typescript-eslint/typescript-estree@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46"
integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==
dependencies:
"@typescript-eslint/types" "6.21.0"
"@typescript-eslint/visitor-keys" "6.21.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
minimatch "9.0.3"
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.58.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
@@ -2209,6 +2035,19 @@
eslint-scope "^5.1.1"
semver "^7.3.7"
"@typescript-eslint/utils@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.21.0.tgz#4714e7a6b39e773c1c8e97ec587f520840cd8134"
integrity sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@types/json-schema" "^7.0.12"
"@types/semver" "^7.5.0"
"@typescript-eslint/scope-manager" "6.21.0"
"@typescript-eslint/types" "6.21.0"
"@typescript-eslint/typescript-estree" "6.21.0"
semver "^7.5.4"
"@typescript-eslint/visitor-keys@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
@@ -2217,6 +2056,14 @@
"@typescript-eslint/types" "5.62.0"
eslint-visitor-keys "^3.3.0"
"@typescript-eslint/visitor-keys@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47"
integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==
dependencies:
"@typescript-eslint/types" "6.21.0"
eslint-visitor-keys "^3.4.1"
"@ungap/structured-clone@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
@@ -2283,16 +2130,11 @@ acorn-walk@^8.3.2:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa"
integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==
acorn@^8.11.3:
acorn@^8.11.3, acorn@^8.9.0:
version "8.11.3"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
acorn@^8.9.0:
version "8.10.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
agent-base@^7.0.2, agent-base@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434"
@@ -2579,6 +2421,13 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
brace-expansion@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
dependencies:
balanced-match "^1.0.0"
braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
@@ -2586,17 +2435,7 @@ braces@^3.0.2, braces@~3.0.2:
dependencies:
fill-range "^7.0.1"
browserslist@^4.21.10, browserslist@^4.21.9:
version "4.21.10"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0"
integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==
dependencies:
caniuse-lite "^1.0.30001517"
electron-to-chromium "^1.4.477"
node-releases "^2.0.13"
update-browserslist-db "^1.0.11"
browserslist@^4.22.1:
browserslist@^4.21.10, browserslist@^4.21.9, browserslist@^4.22.1:
version "4.22.1"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619"
integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==
@@ -2635,17 +2474,7 @@ camelcase@^6.2.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
caniuse-lite@^1.0.30001517:
version "1.0.30001519"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz#3e7b8b8a7077e78b0eb054d69e6edf5c7df35601"
integrity sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==
caniuse-lite@^1.0.30001520:
version "1.0.30001520"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001520.tgz#62e2b7a1c7b35269594cf296a80bdf8cb9565006"
integrity sha512-tahF5O9EiiTzwTUqAeFjIZbn4Dnqxzz7ktrgGlMYNLH43Ul26IgTMH/zvL3DG0lZxBYnlT04axvInszUsZULdA==
caniuse-lite@^1.0.30001541:
caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001541:
version "1.0.30001565"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz#a528b253c8a2d95d2b415e11d8b9942acc100c4f"
integrity sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==
@@ -2943,12 +2772,7 @@ dot-case@^3.0.4:
no-case "^3.0.4"
tslib "^2.0.3"
electron-to-chromium@^1.4.477:
version "1.4.490"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.490.tgz#d99286f6e915667fa18ea4554def1aa60eb4d5f1"
integrity sha512-6s7NVJz+sATdYnIwhdshx/N/9O6rvMxmhVoDSDFdj6iA45gHR8EQje70+RYsF4GeB+k0IeNSBnP7yG9ZXJFr7A==
electron-to-chromium@^1.4.535:
electron-to-chromium@^1.4.477, electron-to-chromium@^1.4.535:
version "1.4.596"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.596.tgz#6752d1aa795d942d49dfc5d3764d6ea283fab1d7"
integrity sha512-zW3zbZ40Icb2BCWjm47nxwcFGYlIgdXkAx85XDO7cyky9J4QQfq8t0W19/TLZqq3JPQXtlv8BPIGmfa9Jb4scg==
@@ -3379,18 +3203,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@^3.2.12:
version "3.3.1"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.4"
fast-glob@^3.2.9:
fast-glob@^3.2.12, fast-glob@^3.2.9:
version "3.3.2"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
@@ -3480,22 +3293,12 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
fsevents@~2.3.3:
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
function-bind@^1.1.2:
function-bind@^1.1.1, function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
@@ -3732,10 +3535,10 @@ iconv-lite@0.6.3:
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
ignore@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
ignore@^5.2.0, ignore@^5.2.4:
version "5.3.1"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
import-fresh@^3.2.1:
version "3.3.0"
@@ -3827,14 +3630,7 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
is-core-module@^2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
dependencies:
has "^1.0.3"
is-core-module@^2.13.1:
is-core-module@^2.13.0, is-core-module@^2.13.1:
version "2.13.1"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
@@ -4173,14 +3969,7 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
loupe@^2.3.6:
version "2.3.6"
resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53"
integrity sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==
dependencies:
get-func-name "^2.0.0"
loupe@^2.3.7:
loupe@^2.3.6, loupe@^2.3.7:
version "2.3.7"
resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697"
integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==
@@ -4250,6 +4039,13 @@ mimic-fn@^4.0.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
minimatch@9.0.3:
version "9.0.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
dependencies:
brace-expansion "^2.0.1"
minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -4262,17 +4058,7 @@ minimist@^1.2.0, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
mlly@^1.2.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.0.tgz#830c10d63f1f97bd8785377b24dc2a15d972832b"
integrity sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==
dependencies:
acorn "^8.9.0"
pathe "^1.1.1"
pkg-types "^1.0.3"
ufo "^1.1.2"
mlly@^1.4.2:
mlly@^1.2.0, mlly@^1.4.2:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.6.0.tgz#0ecfbddc706857f5e170ccd28c6b0b9c81d3f548"
integrity sha512-YOvg9hfYQmnaB56Yb+KrJE2u0Yzz5zR+sLejEvF4fzwzV1Al6hkf2vyHTwqCRyv0hCi9rVCqVoXpyYevQIRwLQ==
@@ -4301,21 +4087,11 @@ mz@^2.7.0:
object-assign "^4.0.1"
thenify-all "^1.0.0"
nanoid@^3.3.6:
version "3.3.6"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
nanoid@^3.3.7:
nanoid@^3.3.6, nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
natural-compare-lite@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@@ -4532,12 +4308,7 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
pathe@^1.1.0, pathe@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.1.tgz#1dd31d382b974ba69809adc9a7a347e65d84829a"
integrity sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==
pathe@^1.1.2:
pathe@^1.1.0, pathe@^1.1.1, pathe@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
@@ -4620,16 +4391,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.23, postcss@^8.4.31:
version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
postcss@^8.4.35:
postcss@^8.4.23, postcss@^8.4.31, postcss@^8.4.35:
version "8.4.35"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7"
integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==
@@ -4843,16 +4605,7 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
resolve@^1.1.7, resolve@^1.22.2:
version "1.22.4"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.4.tgz#1dc40df46554cdaf8948a486a10f6ba1e2026c34"
integrity sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==
dependencies:
is-core-module "^2.13.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.4:
resolve@^1.1.7, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.2, resolve@^1.22.4:
version "1.22.8"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
@@ -4959,10 +4712,10 @@ semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.7:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
semver@^7.3.7, semver@^7.5.4:
version "7.6.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
dependencies:
lru-cache "^6.0.0"
@@ -5261,6 +5014,11 @@ tr46@^5.0.0:
dependencies:
punycode "^2.3.1"
ts-api-utils@^1.0.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.2.1.tgz#f716c7e027494629485b21c0df6180f4d08f5e8b"
integrity sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==
ts-interface-checker@^0.1.9:
version "0.1.13"
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
@@ -5358,17 +5116,12 @@ typed-array-length@^1.0.4:
for-each "^0.3.3"
is-typed-array "^1.1.9"
typescript@^4.7.4:
version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.3.3:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
ufo@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.2.0.tgz#28d127a087a46729133fdc89cb1358508b3f80ba"
integrity sha512-RsPyTbqORDNDxqAdQPQBpgqhWle1VcTSou/FraClYlHf6TZnQcGslpLcAphNR+sQW4q5lLWLbOsRlh9j24baQg==
ufo@^1.3.2:
ufo@^1.1.2, ufo@^1.3.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.4.0.tgz#39845b31be81b4f319ab1d99fd20c56cac528d32"
integrity sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==
@@ -5416,15 +5169,7 @@ universalify@^0.2.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==
update-browserslist-db@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940"
integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==
dependencies:
escalade "^3.1.1"
picocolors "^1.0.0"
update-browserslist-db@^1.0.13:
update-browserslist-db@^1.0.11, update-browserslist-db@^1.0.13:
version "1.0.13"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==

View File

@@ -665,6 +665,7 @@ func (up *Updater) updateAlpineLike() (err error) {
func parseAlpinePackageVersion(out []byte) (string, error) {
s := bufio.NewScanner(bytes.NewReader(out))
var maxVer string
for s.Scan() {
// The line should look like this:
// tailscale-1.44.2-r0 description:
@@ -676,7 +677,13 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
if len(parts) < 3 {
return "", fmt.Errorf("malformed info line: %q", line)
}
return parts[1], nil
ver := parts[1]
if cmpver.Compare(ver, maxVer) == 1 {
maxVer = ver
}
}
if maxVer != "" {
return maxVer, nil
}
return "", errors.New("tailscale version not found in output")
}

View File

@@ -251,6 +251,29 @@ tailscale installed size:
out: "",
wantErr: true,
},
{
desc: "multiple versions",
out: `
tailscale-1.54.1-r0 description:
The easiest, most secure way to use WireGuard and 2FA
tailscale-1.54.1-r0 webpage:
https://tailscale.com/
tailscale-1.54.1-r0 installed size:
34 MiB
tailscale-1.58.2-r0 description:
The easiest, most secure way to use WireGuard and 2FA
tailscale-1.58.2-r0 webpage:
https://tailscale.com/
tailscale-1.58.2-r0 installed size:
35 MiB
`,
want: "1.58.2",
},
}
for _, tt := range tests {

View File

@@ -114,7 +114,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tailfs from tailscale.com/client/tailscale
tailscale.com/tailfs from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/derp+

View File

@@ -26,7 +26,6 @@ import (
"syscall"
"time"
"go4.org/mem"
"golang.org/x/time/rate"
"tailscale.com/atomicfile"
"tailscale.com/derp"
@@ -36,6 +35,7 @@ import (
"tailscale.com/net/stunserver"
"tailscale.com/tsweb"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
var (
@@ -235,7 +235,7 @@ func main() {
KeepAlive: *tcpKeepAlive,
}
quietLogger := log.New(logFilter{}, "", 0)
quietLogger := log.New(logger.HTTPServerLogFilter{Inner: log.Printf}, "", 0)
httpsrv := &http.Server{
Addr: *addr,
Handler: mux,
@@ -452,22 +452,3 @@ func (l *rateLimitedListener) Accept() (net.Conn, error) {
l.numAccepts.Add(1)
return cn, nil
}
// logFilter is used to filter out useless error logs that are logged to
// the net/http.Server.ErrorLog logger.
type logFilter struct{}
func (logFilter) Write(p []byte) (int, error) {
b := mem.B(p)
if mem.HasSuffix(b, mem.S(": EOF\n")) ||
mem.HasSuffix(b, mem.S(": i/o timeout\n")) ||
mem.HasSuffix(b, mem.S(": read: connection reset by peer\n")) ||
mem.HasSuffix(b, mem.S(": remote error: tls: bad certificate\n")) ||
mem.HasSuffix(b, mem.S(": tls: first record does not look like a TLS handshake\n")) {
// Skip this log message, but say that we processed it
return len(p), nil
}
log.Printf("%s", p)
return len(p), nil
}

View File

@@ -19,18 +19,31 @@ import (
)
var (
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)")
listen = flag.String("listen", ":8030", "HTTP listen address")
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
spread = flag.Bool("spread", true, "whether to spread probing over time")
interval = flag.Duration("interval", 15*time.Second, "probe interval")
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)")
listen = flag.String("listen", ":8030", "HTTP listen address")
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
spread = flag.Bool("spread", true, "whether to spread probing over time")
interval = flag.Duration("interval", 15*time.Second, "probe interval")
meshInterval = flag.Duration("mesh-interval", 15*time.Second, "mesh probe interval")
stunInterval = flag.Duration("stun-interval", 15*time.Second, "STUN probe interval")
tlsInterval = flag.Duration("tls-interval", 15*time.Second, "TLS probe interval")
bwInterval = flag.Duration("bw-interval", 0, "bandwidth probe interval (0 = no bandwidth probing)")
bwSize = flag.Int64("bw-probe-size-bytes", 1_000_000, "bandwidth probe size")
)
func main() {
flag.Parse()
p := prober.New().WithSpread(*spread).WithOnce(*probeOnce).WithMetricNamespace("derpprobe")
dp, err := prober.DERP(p, *derpMapURL, *interval, *interval, *interval)
opts := []prober.DERPOpt{
prober.WithMeshProbing(*meshInterval),
prober.WithSTUNProbing(*stunInterval),
prober.WithTLSProbing(*tlsInterval),
}
if *bwInterval > 0 {
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
}
dp, err := prober.DERP(p, *derpMapURL, opts...)
if err != nil {
log.Fatal(err)
}
@@ -53,6 +66,7 @@ func main() {
mux := http.NewServeMux()
tsweb.Debugger(mux)
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
log.Printf("Listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, mux))
}

View File

@@ -67,14 +67,13 @@ func TestConnector(t *testing.T) {
fullName, shortName := findGenName(t, fc, "", "test", "connector")
opts := configOpts{
stsName: shortName,
secretName: fullName,
parentType: "connector",
hostname: "test-connector",
shouldUseDeclarativeConfig: true,
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
stsName: shortName,
secretName: fullName,
parentType: "connector",
hostname: "test-connector",
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
}
expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts))
@@ -152,13 +151,12 @@ func TestConnector(t *testing.T) {
fullName, shortName = findGenName(t, fc, "", "test", "connector")
opts = configOpts{
stsName: shortName,
secretName: fullName,
parentType: "connector",
shouldUseDeclarativeConfig: true,
subnetRoutes: "10.40.0.0/14",
hostname: "test-connector",
confFileHash: "57d922331890c9b1c8c6ae664394cb254334c551d9cd9db14537b5d9da9fb17e",
stsName: shortName,
secretName: fullName,
parentType: "connector",
subnetRoutes: "10.40.0.0/14",
hostname: "test-connector",
confFileHash: "57d922331890c9b1c8c6ae664394cb254334c551d9cd9db14537b5d9da9fb17e",
}
expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts))
@@ -239,14 +237,13 @@ func TestConnectorWithProxyClass(t *testing.T) {
fullName, shortName := findGenName(t, fc, "", "test", "connector")
opts := configOpts{
stsName: shortName,
secretName: fullName,
parentType: "connector",
hostname: "test-connector",
shouldUseDeclarativeConfig: true,
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
stsName: shortName,
secretName: fullName,
parentType: "connector",
hostname: "test-connector",
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
}
expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts))

View File

@@ -28,8 +28,6 @@ spec:
env:
- name: TS_USERSPACE
value: "false"
- name: TS_AUTH_ONCE
value: "true"
- name: POD_IP
valueFrom:
fieldRef:

View File

@@ -20,5 +20,3 @@ spec:
env:
- name: TS_USERSPACE
value: "true"
- name: TS_AUTH_ONCE
value: "true"

View File

@@ -88,11 +88,12 @@ func TestTailscaleIngress(t *testing.T) {
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
opts := configOpts{
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "ingress",
hostname: "default-test",
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "ingress",
hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
@@ -125,6 +126,9 @@ func TestTailscaleIngress(t *testing.T) {
mak.Set(&ing.ObjectMeta.Annotations, AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy, "true")
})
opts.shouldEnableForwardingClusterTrafficViaIngress = true
// configfile hash changed at this point in test env only because we
// lost auth key due to how changes are applied in test client.
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
expectReconciled(t, ingR, "default", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts))
@@ -219,11 +223,12 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
opts := configOpts{
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "ingress",
hostname: "default-test",
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "ingress",
hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
@@ -256,6 +261,9 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
})
expectReconciled(t, ingR, "default", "test")
opts.proxyClass = pc.Name
// configfile hash changed at this point in test env only because we
// lost auth key due to how changes are applied in test client.
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
// 4. tailscale.com/proxy-class label is removed from the Ingress, the

View File

@@ -47,9 +47,12 @@ import (
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests
// Generate Connector CustomResourceDefinition yaml from its Go types.
// Generate Connector and ProxyClass CustomResourceDefinition yamls from their Go types.
//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd schemapatch:manifests=./deploy/crds output:dir=./deploy/crds paths=../../k8s-operator/apis/...
// Generate CRD docs from the yamls
//go:generate go run fybrik.io/crdoc --resources=./deploy/crds --output=../../k8s-operator/api.md
func main() {
// Required to use our client API. We're fine with the instability since the
// client lives in the same repo as this code.
@@ -269,12 +272,14 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
// If a ProxyClassChanges, enqueue all Ingresses labeled with that
// ProxyClass's name.
proxyClassFilterForIngress := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForIngress(mgr.GetClient(), startlog))
// Enque Ingress if a managed Service or backend Service associated with a tailscale Ingress changes.
svcHandlerForIngress := handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngress(mgr.GetClient(), startlog))
err = builder.
ControllerManagedBy(mgr).
For(&networkingv1.Ingress{}).
Watches(&appsv1.StatefulSet{}, ingressChildFilter).
Watches(&corev1.Secret{}, ingressChildFilter).
Watches(&corev1.Service{}, ingressChildFilter).
Watches(&corev1.Service{}, svcHandlerForIngress).
Watches(&tsapi.ProxyClass{}, proxyClassFilterForIngress).
Complete(&IngressReconciler{
ssr: ssr,
@@ -419,6 +424,46 @@ func proxyClassHandlerForConnector(cl client.Client, logger *zap.SugaredLogger)
}
}
// serviceHandlerForIngress returns a handler for Service events for ingress
// reconciler that ensures that if the Service associated with an event is of
// interest to the reconciler, the associated Ingress(es) gets be reconciled.
// The Services of interest are backend Services for tailscale Ingress and
// managed Services for an StatefulSet for a proxy configured for tailscale
// Ingress
func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
return func(ctx context.Context, o client.Object) []reconcile.Request {
if isManagedByType(o, "ingress") {
ingName := parentFromObjectLabels(o)
return []reconcile.Request{{NamespacedName: ingName}}
}
ingList := networkingv1.IngressList{}
if err := cl.List(ctx, &ingList, client.InNamespace(o.GetNamespace())); err != nil {
logger.Debugf("error listing Ingresses: %v", err)
return nil
}
reqs := make([]reconcile.Request, 0)
for _, ing := range ingList.Items {
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
return nil
}
if ing.Spec.DefaultBackend != nil && ing.Spec.DefaultBackend.Service != nil && ing.Spec.DefaultBackend.Service.Name == o.GetName() {
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
}
for _, rule := range ing.Spec.Rules {
if rule.HTTP == nil {
continue
}
for _, path := range rule.HTTP.Paths {
if path.Backend.Service != nil && path.Backend.Service.Name == o.GetName() {
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
}
}
}
}
return reqs
}
}
func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
if isManagedByType(o, "svc") {
// If this is a Service managed by a Service we want to enqueue its parent
@@ -437,7 +482,6 @@ func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
},
},
}
}
// isMagicDNSName reports whether name is a full tailnet node FQDN (with or

View File

@@ -6,15 +6,19 @@
package main
import (
"context"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/types/ptr"
"tailscale.com/util/mak"
@@ -67,6 +71,7 @@ func TestLoadBalancerClass(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSecret(t, opts))
@@ -208,6 +213,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
parentType: "svc",
tailnetTargetFQDN: tailnetTargetFQDN,
hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSecret(t, o))
@@ -318,6 +324,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
parentType: "svc",
tailnetTargetIP: tailnetTargetIP,
hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSecret(t, o))
@@ -425,6 +432,7 @@ func TestAnnotations(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSecret(t, o))
@@ -533,6 +541,7 @@ func TestAnnotationIntoLB(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSecret(t, o))
@@ -581,6 +590,8 @@ func TestAnnotationIntoLB(t *testing.T) {
})
expectReconciled(t, sr, "default", "test")
// None of the proxy machinery should have changed...
// (although configfile hash will change in test env only because we lose auth key due to out test not syncing secret.StringData -> secret.Data)
o.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
expectEqual(t, fc, expectedSTS(t, fc, o))
// ... but the service should have a LoadBalancer status.
@@ -664,6 +675,7 @@ func TestLBIntoAnnotation(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSecret(t, o))
@@ -730,6 +742,10 @@ func TestLBIntoAnnotation(t *testing.T) {
})
expectReconciled(t, sr, "default", "test")
// configfile hash changes on a re-apply in this case in tests only as
// we lose the auth key due to the test apply not syncing
// secret.StringData -> Data.
o.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
expectEqual(t, fc, expectedSTS(t, fc, o))
@@ -805,6 +821,7 @@ func TestCustomHostname(t *testing.T) {
parentType: "svc",
hostname: "reindeer-flotilla",
clusterTargetIP: "10.20.30.40",
confFileHash: "42376226c7d76ed6d6318315dc6c402f7d993bc0b01a5b0e6c8a833106b7509e",
}
expectEqual(t, fc, expectedSecret(t, o))
@@ -920,6 +937,7 @@ func TestCustomPriorityClassName(t *testing.T) {
hostname: "tailscale-critical",
priorityClassName: "custom-priority-class-name",
clusterTargetIP: "10.20.30.40",
confFileHash: "13cdef0d5f6f0f2406af028710ea1e0f99f65aba4021e4e70ac75a73cf141fd1",
}
expectEqual(t, fc, expectedSTS(t, fc, o))
@@ -982,6 +1000,7 @@ func TestProxyClassForService(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
@@ -1008,6 +1027,10 @@ func TestProxyClassForService(t *testing.T) {
}}}
})
opts.proxyClass = pc.Name
// configfile hash changes on a second apply in test env only because we
// lose auth key due to out test not syncing secret.StringData ->
// secret.Data
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts))
@@ -1071,6 +1094,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSTS(t, fc, o))
}
@@ -1124,6 +1148,7 @@ func TestProxyFirewallMode(t *testing.T) {
hostname: "default-test",
firewallMode: "nftables",
clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
}
expectEqual(t, fc, expectedSTS(t, fc, o))
@@ -1155,3 +1180,134 @@ func Test_isMagicDNSName(t *testing.T) {
})
}
}
func Test_serviceHandlerForIngress(t *testing.T) {
fc := fake.NewFakeClient()
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
// 1. An event on a headless Service for a tailscale Ingress results in
// the Ingress being reconciled.
mustCreate(t, fc, &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "ing-1",
Namespace: "ns-1",
},
Spec: networkingv1.IngressSpec{IngressClassName: ptr.To(tailscaleIngressClassName)},
})
svc1 := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "headless-1",
Namespace: "tailscale",
Labels: map[string]string{
LabelManaged: "true",
LabelParentName: "ing-1",
LabelParentNamespace: "ns-1",
LabelParentType: "ingress",
},
},
}
mustCreate(t, fc, svc1)
wantReqs := []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-1", Name: "ing-1"}}}
gotReqs := serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), svc1)
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
}
// 2. An event on a Service that is the default backend for a tailscale
// Ingress results in the Ingress being reconciled.
mustCreate(t, fc, &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "ing-2",
Namespace: "ns-2",
},
Spec: networkingv1.IngressSpec{
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{Name: "def-backend"},
},
IngressClassName: ptr.To(tailscaleIngressClassName),
},
})
backendSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "def-backend",
Namespace: "ns-2",
},
}
mustCreate(t, fc, backendSvc)
wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-2", Name: "ing-2"}}}
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc)
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
}
// 3. An event on a Service that is one of the non-default backends for
// a tailscale Ingress results in the Ingress being reconciled.
mustCreate(t, fc, &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "ing-3",
Namespace: "ns-3",
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To(tailscaleIngressClassName),
Rules: []networkingv1.IngressRule{{IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{Backend: networkingv1.IngressBackend{Service: &networkingv1.IngressServiceBackend{Name: "backend"}}}},
}}}},
},
})
backendSvc2 := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend",
Namespace: "ns-3",
},
}
mustCreate(t, fc, backendSvc2)
wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-3", Name: "ing-3"}}}
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc2)
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
}
// 4. An event on a Service that is a backend for an Ingress that is not
// tailscale Ingress does not result in an Ingress reconcile.
mustCreate(t, fc, &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "ing-4",
Namespace: "ns-4",
},
Spec: networkingv1.IngressSpec{
Rules: []networkingv1.IngressRule{{IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{Backend: networkingv1.IngressBackend{Service: &networkingv1.IngressServiceBackend{Name: "non-ts-backend"}}}},
}}}},
},
})
nonTSBackend := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "non-ts-backend",
Namespace: "ns-4",
},
}
mustCreate(t, fc, nonTSBackend)
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), nonTSBackend)
if len(gotReqs) > 0 {
t.Errorf("unexpected reconcile request for a Service that does not belong to a Tailscale Ingress: %#+v\n", gotReqs)
}
// 5. An event on a Service not related to any Ingress does not result
// in an Ingress reconcile.
someSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "some-svc",
Namespace: "ns-4",
},
}
mustCreate(t, fc, someSvc)
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), someSvc)
if len(gotReqs) > 0 {
t.Errorf("unexpected reconcile request for a Service that does not belong to any Ingress: %#+v\n", gotReqs)
}
}

View File

@@ -86,7 +86,6 @@ const (
// ensure that it does not get removed when a ProxyClass configuration
// is applied.
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
podAnnotationLastSetTailnetTargetFQDN = "tailscale.com/operator-last-set-ts-tailnet-target-fqdn"
// podAnnotationLastSetConfigFileHash is sha256 hash of the current tailscaled configuration contents.
@@ -101,7 +100,7 @@ var (
// tailscaleManagedLabels are label keys that tailscale operator sets on StatefulSets and Pods.
tailscaleManagedLabels = []string{LabelManaged, LabelParentType, LabelParentName, LabelParentNamespace, "app"}
// tailscaleManagedAnnotations are annotation keys that tailscale operator sets on StatefulSets and Pods.
tailscaleManagedAnnotations = []string{podAnnotationLastSetClusterIP, podAnnotationLastSetHostname, podAnnotationLastSetTailnetTargetIP, podAnnotationLastSetTailnetTargetFQDN, podAnnotationLastSetConfigFileHash}
tailscaleManagedAnnotations = []string{podAnnotationLastSetClusterIP, podAnnotationLastSetTailnetTargetIP, podAnnotationLastSetTailnetTargetFQDN, podAnnotationLastSetConfigFileHash}
)
type tailscaleSTSConfig struct {
@@ -312,9 +311,9 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
authKey, hash string
)
if orig == nil {
// Secret doesn't exist yet, create one. Initially it contains
// only the Tailscale authkey, but once Tailscale starts it'll
// also store the daemon state.
// Initially it contains only tailscaled config, but when the
// proxy starts, it will also store there the state, certs and
// ACME account key.
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, stsC.ChildResourceLabels)
if err != nil {
return "", "", err
@@ -337,17 +336,13 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
return "", "", err
}
}
if !shouldDoTailscaledDeclarativeConfig(stsC) && authKey != "" {
mak.Set(&secret.StringData, "authkey", authKey)
}
if shouldDoTailscaledDeclarativeConfig(stsC) {
confFileBytes, h, err := tailscaledConfig(stsC, authKey, orig)
if err != nil {
return "", "", fmt.Errorf("error creating tailscaled config: %w", err)
}
hash = h
mak.Set(&secret.StringData, tailscaledConfigKey, string(confFileBytes))
confFileBytes, h, err := tailscaledConfig(stsC, authKey, orig)
if err != nil {
return "", "", fmt.Errorf("error creating tailscaled config: %w", err)
}
hash = h
mak.Set(&secret.StringData, tailscaledConfigKey, string(confFileBytes))
if stsC.ServeConfig != nil {
j, err := json.Marshal(stsC.ServeConfig)
if err != nil {
@@ -477,6 +472,10 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Name: "TS_KUBE_SECRET",
Value: proxySecret,
},
corev1.EnvVar{
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
Value: "/etc/tsconfig/tailscaled",
},
)
if sts.ForwardClusterTrafficViaL7IngressProxy {
container.Env = append(container.Env, corev1.EnvVar{
@@ -484,42 +483,25 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Value: "true",
})
}
if !shouldDoTailscaledDeclarativeConfig(sts) {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_HOSTNAME",
Value: sts.Hostname,
})
// containerboot currently doesn't have a way to re-read the hostname/ip as
// it is passed via an environment variable. So we need to restart the
// container when the value changes. We do this by adding an annotation to
// the pod template that contains the last value we set.
mak.Set(&pod.Annotations, podAnnotationLastSetHostname, sts.Hostname)
}
// Configure containeboot to run tailscaled with a configfile read from the state Secret.
if shouldDoTailscaledDeclarativeConfig(sts) {
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash, tsConfigHash)
pod.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
Name: "tailscaledconfig",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: proxySecret,
Items: []corev1.KeyToPath{{
Key: tailscaledConfigKey,
Path: tailscaledConfigKey,
}},
},
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash, tsConfigHash)
pod.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
Name: "tailscaledconfig",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: proxySecret,
Items: []corev1.KeyToPath{{
Key: tailscaledConfigKey,
Path: tailscaledConfigKey,
}},
},
})
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
Name: "tailscaledconfig",
ReadOnly: true,
MountPath: "/etc/tsconfig",
})
container.Env = append(container.Env, corev1.EnvVar{
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
Value: "/etc/tsconfig/tailscaled",
})
}
},
})
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
Name: "tailscaledconfig",
ReadOnly: true,
MountPath: "/etc/tsconfig",
})
if a.tsFirewallMode != "" {
container.Env = append(container.Env, corev1.EnvVar{
@@ -828,10 +810,3 @@ func nameForService(svc *corev1.Service) (string, error) {
func isValidFirewallMode(m string) bool {
return m == "auto" || m == "nftables" || m == "iptables"
}
// shouldDoTailscaledDeclarativeConfig determines whether the proxy instance
// should be configured to run tailscaled only with a all config opts passed to
// tailscaled.
func shouldDoTailscaledDeclarativeConfig(stsC *tailscaleSTSConfig) bool {
return stsC.Connector != nil
}

View File

@@ -247,28 +247,28 @@ func Test_mergeStatefulSetLabelsOrAnnots(t *testing.T) {
},
{
name: "no custom annots specified and none present in current annots, return current annots",
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
managed: tailscaleManagedAnnotations,
},
{
name: "no custom annots specified, but some present in current annots, return tailscale managed annots only from the current annots",
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
managed: tailscaleManagedAnnotations,
},
{
name: "custom annots specified, current annots only contain tailscale managed annots, return a union of both",
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
custom: map[string]string{"foo": "bar", "something.io/foo": "bar"},
want: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
want: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
managed: tailscaleManagedAnnotations,
},
{
name: "custom annots specified, current annots contain tailscale managed annots and custom annots, some of which are not present in the new custom annots, return a union of managed annots and the desired custom annots",
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
custom: map[string]string{"something.io/foo": "bar"},
want: map[string]string{"something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
want: map[string]string{"something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
managed: tailscaleManagedAnnotations,
},
{

View File

@@ -44,7 +44,6 @@ type configOpts struct {
clusterTargetIP string
subnetRoutes string
isExitNode bool
shouldUseDeclarativeConfig bool // tailscaled in proxy should be configured using config file
confFileHash string
serveConfig *ipn.ServeConfig
shouldEnableForwardingClusterTrafficViaIngress bool
@@ -58,9 +57,9 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
},
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
@@ -77,37 +76,28 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
}
annots := make(map[string]string)
var volumes []corev1.Volume
if opts.shouldUseDeclarativeConfig {
volumes = []corev1.Volume{
{
Name: "tailscaledconfig",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: opts.secretName,
Items: []corev1.KeyToPath{
{
Key: "tailscaled",
Path: "tailscaled",
},
volumes = []corev1.Volume{
{
Name: "tailscaledconfig",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: opts.secretName,
Items: []corev1.KeyToPath{
{
Key: "tailscaled",
Path: "tailscaled",
},
},
},
},
}
tsContainer.VolumeMounts = []corev1.VolumeMount{{
Name: "tailscaledconfig",
ReadOnly: true,
MountPath: "/etc/tsconfig",
}}
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
Value: "/etc/tsconfig/tailscaled",
})
annots["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash
} else {
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{Name: "TS_HOSTNAME", Value: opts.hostname})
annots["tailscale.com/operator-last-set-hostname"] = opts.hostname
},
}
tsContainer.VolumeMounts = []corev1.VolumeMount{{
Name: "tailscaledconfig",
ReadOnly: true,
MountPath: "/etc/tsconfig",
}}
annots["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash
if opts.firewallMode != "" {
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
Name: "TS_DEBUG_FIREWALL_MODE",
@@ -211,22 +201,43 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
}
func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet {
t.Helper()
tsContainer := corev1.Container{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "true"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "TS_HOSTNAME", Value: opts.hostname},
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},
},
ImagePullPolicy: "Always",
VolumeMounts: []corev1.VolumeMount{{Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"}},
VolumeMounts: []corev1.VolumeMount{
{Name: "tailscaledconfig", ReadOnly: true, MountPath: "/etc/tsconfig"},
{Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"},
},
}
volumes := []corev1.Volume{
{
Name: "tailscaledconfig",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: opts.secretName,
Items: []corev1.KeyToPath{
{
Key: "tailscaled",
Path: "tailscaled",
},
},
},
},
},
{Name: "serve-config",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName,
Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}},
},
}
annots := make(map[string]string)
volumes := []corev1.Volume{{Name: "serve-config", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}}}}
annots["tailscale.com/operator-last-set-hostname"] = opts.hostname
ss := &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
@@ -250,7 +261,6 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
ServiceName: opts.stsName,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: annots,
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{
"tailscale.com/managed": "true",
@@ -259,6 +269,7 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
"tailscale.com/parent-resource-type": opts.parentType,
"app": "1234-UID",
},
Annotations: map[string]string{"tailscale.com/operator-last-set-config-file-hash": opts.confFileHash},
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
@@ -310,11 +321,6 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
t.Helper()
labels := map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-type": opts.parentType,
}
s := &corev1.Secret{
TypeMeta: metav1.TypeMeta{
Kind: "Secret",
@@ -332,37 +338,40 @@ func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
}
mak.Set(&s.StringData, "serve-config", string(serveConfigBs))
}
if !opts.shouldUseDeclarativeConfig {
mak.Set(&s.StringData, "authkey", "secret-authkey")
labels["tailscale.com/parent-resource-ns"] = opts.namespace
} else {
conf := &ipn.ConfigVAlpha{
Version: "alpha0",
AcceptDNS: "false",
Hostname: &opts.hostname,
Locked: "false",
AuthKey: ptr.To("secret-authkey"),
conf := &ipn.ConfigVAlpha{
Version: "alpha0",
AcceptDNS: "false",
Hostname: &opts.hostname,
Locked: "false",
AuthKey: ptr.To("secret-authkey"),
}
var routes []netip.Prefix
if opts.subnetRoutes != "" || opts.isExitNode {
r := opts.subnetRoutes
if opts.isExitNode {
r = "0.0.0.0/0,::/0," + r
}
var routes []netip.Prefix
if opts.subnetRoutes != "" || opts.isExitNode {
r := opts.subnetRoutes
if opts.isExitNode {
r = "0.0.0.0/0,::/0," + r
}
for _, rr := range strings.Split(r, ",") {
prefix, err := netip.ParsePrefix(rr)
if err != nil {
t.Fatal(err)
}
routes = append(routes, prefix)
for _, rr := range strings.Split(r, ",") {
prefix, err := netip.ParsePrefix(rr)
if err != nil {
t.Fatal(err)
}
routes = append(routes, prefix)
}
conf.AdvertiseRoutes = routes
b, err := json.Marshal(conf)
if err != nil {
t.Fatalf("error marshalling tailscaled config")
}
mak.Set(&s.StringData, "tailscaled", string(b))
}
conf.AdvertiseRoutes = routes
b, err := json.Marshal(conf)
if err != nil {
t.Fatalf("error marshalling tailscaled config")
}
mak.Set(&s.StringData, "tailscaled", string(b))
labels := map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-ns": "default",
"tailscale.com/parent-resource-type": opts.parentType,
}
if opts.parentType == "connector" {
labels["tailscale.com/parent-resource-ns"] = "" // Connector is cluster scoped
}
s.Labels = labels

View File

@@ -829,6 +829,10 @@ func TestPrefFlagMapping(t *testing.T) {
// Handled by TS_DEBUG_FIREWALL_MODE env var, we don't want to have
// a CLI flag for this. The Pref is used by c2n.
continue
case "TailFSShares":
// Handled by the tailscale share subcommand, we don't want a CLI
// flag for this.
continue
}
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
)
var funnelCmd = func() *ffcli.Command {
@@ -114,15 +113,8 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
// Nothing to do.
return nil
}
if on {
mak.Set(&sc.AllowFunnel, hp, true)
} else {
delete(sc.AllowFunnel, hp)
// clear map mostly for testing
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
}
sc.SetFunnel(dnsName, port, on)
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
return err
}

View File

@@ -27,7 +27,6 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
"tailscale.com/version"
)
@@ -357,35 +356,12 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bo
if err != nil {
return err
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if sc.IsTCPForwardingOnPort(srvPort) {
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
return errHelp
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
}
mak.Set(&sc.Web[hp].Handlers, mount, h)
for k, v := range sc.Web[hp].Handlers {
if v == h {
continue
}
// If the new mount point ends in / and another mount point
// shares the same prefix, remove the other handler.
// (e.g. /foo/ overwrites /foo)
// The opposite example is also handled.
m1 := strings.TrimSuffix(mount, "/")
m2 := strings.TrimSuffix(k, "/")
if m1 == m2 {
delete(sc.Web[hp].Handlers, k)
continue
}
}
sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS)
if !reflect.DeepEqual(cursc, sc) {
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
@@ -444,19 +420,7 @@ func (e *serveEnv) handleWebServeRemove(ctx context.Context, srvPort uint16, mou
if !sc.WebHandlerExists(hp, mount) {
return errors.New("error: handler does not exist")
}
// delete existing handler, then cascade delete if empty
delete(sc.Web[hp].Handlers, mount)
if len(sc.Web[hp].Handlers) == 0 {
delete(sc.Web, hp)
delete(sc.TCP, srvPort)
}
// clear empty maps mostly for testing
if len(sc.Web) == 0 {
sc.Web = nil
}
if len(sc.TCP) == 0 {
sc.TCP = nil
}
sc.RemoveWebHandler(dnsName, srvPort, []string{mount}, false)
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
return err
}
@@ -592,15 +556,12 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
}
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
dnsName, err := e.getSelfDNSName(ctx)
if err != nil {
return err
}
if terminateTLS {
sc.TCP[srcPort].TerminateTLS = dnsName
}
sc.SetTCPForwarding(srcPort, fwdAddr, terminateTLS, dnsName)
if !reflect.DeepEqual(cursc, sc) {
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
@@ -626,11 +587,7 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
}
if ph := sc.GetTCPPortHandler(src); ph != nil {
delete(sc.TCP, src)
// clear map mostly for testing
if len(sc.TCP) == 0 {
sc.TCP = nil
}
sc.RemoveTCPForwarding(src)
return e.lc.SetServeConfig(ctx, sc)
}
return errors.New("error: serve config does not exist")
@@ -642,6 +599,9 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
// Examples:
// - tailscale status
// - tailscale status --json
//
// TODO(tyler,marwan,sonia): `status` should also report foreground configs,
// currently only reports background config.
func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
sc, err := e.lc.GetServeConfig(ctx)
if err != nil {

View File

@@ -18,7 +18,6 @@ import (
"os/signal"
"path"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
@@ -334,7 +333,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration"
func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error {
sc, isFg := findConfig(sc, port)
sc, isFg := sc.FindConfig(port)
if sc == nil {
return nil
}
@@ -366,24 +365,6 @@ func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType {
}
}
// findConfig finds a config that contains the given port, which can be
// the top level background config or an inner foreground one. The second
// result is true if it's foreground
func findConfig(sc *ipn.ServeConfig, port uint16) (*ipn.ServeConfig, bool) {
if sc == nil {
return nil, false
}
if _, ok := sc.TCP[port]; ok {
return sc, false
}
for _, sc := range sc.Foreground {
if _, ok := sc.TCP[port]; ok {
return sc, true
}
}
return nil, false
}
func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error {
// update serve config based on the type
switch srvType {
@@ -535,7 +516,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
}
h.Path = target
default:
t, err := expandProxyTargetDev(target, []string{"http", "https", "https+insecure"}, "http")
t, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure"}, "http")
if err != nil {
return err
}
@@ -547,29 +528,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
return errors.New("cannot serve web; already serving TCP")
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
}
mak.Set(&sc.Web[hp].Handlers, mount, h)
// TODO: handle multiple web handlers from foreground mode
for k, v := range sc.Web[hp].Handlers {
if v == h {
continue
}
// If the new mount point ends in / and another mount point
// shares the same prefix, remove the other handler.
// (e.g. /foo/ overwrites /foo)
// The opposite example is also handled.
m1 := strings.TrimSuffix(mount, "/")
m2 := strings.TrimSuffix(k, "/")
if m1 == m2 {
delete(sc.Web[hp].Handlers, k)
}
}
sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS)
return nil
}
@@ -585,7 +544,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
return fmt.Errorf("invalid TCP target %q", target)
}
targetURL, err := expandProxyTargetDev(target, []string{"tcp"}, "tcp")
targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp")
if err != nil {
return fmt.Errorf("unable to expand target: %v", err)
}
@@ -600,11 +559,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
}
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: dstURL.Host})
if terminateTLS {
sc.TCP[srcPort].TerminateTLS = dnsName
}
sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, dnsName)
return nil
}
@@ -618,14 +573,10 @@ func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint
sc = new(ipn.ServeConfig)
}
// TODO: should ensure there is no other conflicting funnel
// TODO: add error handling for if toggling for existing sc
if allowFunnel {
mak.Set(&sc.AllowFunnel, hp, true)
} else if _, exists := sc.AllowFunnel[hp]; exists {
fmt.Fprintf(e.stderr(), "Removing Funnel for %s\n", hp)
delete(sc.AllowFunnel, hp)
if _, exists := sc.AllowFunnel[hp]; exists && !allowFunnel {
fmt.Fprintf(e.stderr(), "Removing Funnel for %s:%s\n", dnsName, hp)
}
sc.SetFunnel(dnsName, srvPort, allowFunnel)
}
// unsetServe removes the serve config for the given serve port.
@@ -814,34 +765,7 @@ func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort u
}
}
// delete existing handler, then cascade delete if empty
for _, m := range mounts {
delete(sc.Web[hp].Handlers, m)
}
if len(sc.Web[hp].Handlers) == 0 {
delete(sc.Web, hp)
delete(sc.AllowFunnel, hp)
delete(sc.TCP, srvPort)
}
// clear empty maps mostly for testing
if len(sc.Web) == 0 {
sc.Web = nil
}
if len(sc.TCP) == 0 {
sc.TCP = nil
}
// disable funnel if no remaining mounts exist for the serve port
if sc.Web == nil && sc.TCP == nil {
delete(sc.AllowFunnel, hp)
}
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
sc.RemoveWebHandler(dnsName, srvPort, mounts, true)
return nil
}
@@ -857,68 +781,10 @@ func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
if sc.IsServingWeb(src) {
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
}
delete(sc.TCP, src)
// clear map mostly for testing
if len(sc.TCP) == 0 {
sc.TCP = nil
}
sc.RemoveTCPForwarding(src)
return nil
}
// expandProxyTargetDev expands the supported target values to be proxied
// allowing for input values to be a port number, a partial URL, or a full URL
// including a path.
//
// examples:
// - 3000
// - localhost:3000
// - tcp://localhost:3000
// - http://localhost:3000
// - https://localhost:3000
// - https-insecure://localhost:3000
// - https-insecure://localhost:3000/foo
func expandProxyTargetDev(target string, supportedSchemes []string, defaultScheme string) (string, error) {
const host = "127.0.0.1"
// support target being a port number
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil
}
// prepend scheme if not present
if !strings.Contains(target, "://") {
target = defaultScheme + "://" + target
}
// make sure we can parse the target
u, err := url.ParseRequestURI(target)
if err != nil {
return "", fmt.Errorf("invalid URL %w", err)
}
// ensure a supported scheme
if !slices.Contains(supportedSchemes, u.Scheme) {
return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
}
// validate the host.
switch u.Hostname() {
case "localhost", "127.0.0.1":
default:
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
}
// validate the port
port, err := strconv.ParseUint(u.Port(), 10, 16)
if err != nil || port == 0 {
return "", fmt.Errorf("invalid port %q", u.Port())
}
u.Host = fmt.Sprintf("%s:%d", host, port)
return u.String(), nil
}
// cleanURLPath ensures the path is clean and has a leading "/".
func cleanURLPath(urlPath string) (string, error) {
if urlPath == "" {

View File

@@ -1041,63 +1041,6 @@ func TestSrcTypeFromFlags(t *testing.T) {
}
}
func TestExpandProxyTargetDev(t *testing.T) {
tests := []struct {
name string
input string
defaultScheme string
supportedSchemes []string
expected string
wantErr bool
}{
{name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"},
{name: "hostname+port", input: "localhost:8080", expected: "http://127.0.0.1:8080"},
{name: "convert-localhost", input: "http://localhost:8080", expected: "http://127.0.0.1:8080"},
{name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
{name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
{name: "https-scheme", input: "https://localhost:8080", expected: "https://127.0.0.1:8080"},
{name: "https+insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"},
{name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://127.0.0.1:8080"},
{name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://127.0.0.1:8080"},
// errors
{name: "invalid-port", input: "localhost:9999999", wantErr: true},
{name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true},
{name: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true},
{name: "empty-input", input: "", expected: "", wantErr: true},
}
for _, tt := range tests {
defaultScheme := "http"
supportedSchemes := []string{"http", "https", "https+insecure"}
if tt.supportedSchemes != nil {
supportedSchemes = tt.supportedSchemes
}
if tt.defaultScheme != "" {
defaultScheme = tt.defaultScheme
}
t.Run(tt.name, func(t *testing.T) {
actual, err := expandProxyTargetDev(tt.input, supportedSchemes, defaultScheme)
if tt.wantErr == true && err == nil {
t.Errorf("Expected an error but got none")
return
}
if tt.wantErr == false && err != nil {
t.Errorf("Got an error, but didn't expect one: %v", err)
return
}
if actual != tt.expected {
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
}
})
}
}
func TestCleanURLPath(t *testing.T) {
tests := []struct {
input string

View File

@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
@@ -15,7 +14,8 @@ import (
)
const (
shareAddUsage = "share add <name> <path>"
shareSetUsage = "share set <name> <path>"
shareRenameUsage = "share rename <oldname> <newname>"
shareRemoveUsage = "share remove <name>"
shareListUsage = "share list"
)
@@ -24,7 +24,7 @@ var shareCmd = &ffcli.Command{
Name: "share",
ShortHelp: "Share a directory with your tailnet",
ShortUsage: strings.Join([]string{
shareAddUsage,
shareSetUsage,
shareRemoveUsage,
shareListUsage,
}, "\n "),
@@ -32,9 +32,15 @@ var shareCmd = &ffcli.Command{
UsageFunc: usageFuncNoDefaultValues,
Subcommands: []*ffcli.Command{
{
Name: "add",
Exec: runShareAdd,
ShortHelp: "[ALPHA] add a share",
Name: "set",
Exec: runShareSet,
ShortHelp: "[ALPHA] set a share",
UsageFunc: usageFunc,
},
{
Name: "rename",
ShortHelp: "[ALPHA] rename a share",
Exec: runShareRename,
UsageFunc: usageFunc,
},
{
@@ -55,20 +61,20 @@ var shareCmd = &ffcli.Command{
},
}
// runShareAdd is the entry point for the "tailscale share add" command.
func runShareAdd(ctx context.Context, args []string) error {
// runShareSet is the entry point for the "tailscale share set" command.
func runShareSet(ctx context.Context, args []string) error {
if len(args) != 2 {
return fmt.Errorf("usage: tailscale %v", shareAddUsage)
return fmt.Errorf("usage: tailscale %v", shareSetUsage)
}
name, path := args[0], args[1]
err := localClient.TailFSShareAdd(ctx, &tailfs.Share{
err := localClient.TailFSShareSet(ctx, &tailfs.Share{
Name: name,
Path: path,
})
if err == nil {
fmt.Printf("Added share %q at %q\n", name, path)
fmt.Printf("Set share %q at %q\n", name, path)
}
return err
}
@@ -87,24 +93,31 @@ func runShareRemove(ctx context.Context, args []string) error {
return err
}
// runShareRename is the entry point for the "tailscale share rename" command.
func runShareRename(ctx context.Context, args []string) error {
if len(args) != 2 {
return fmt.Errorf("usage: tailscale %v", shareRenameUsage)
}
oldName := args[0]
newName := args[1]
err := localClient.TailFSShareRename(ctx, oldName, newName)
if err == nil {
fmt.Printf("Renamed share %q to %q\n", oldName, newName)
}
return err
}
// runShareList is the entry point for the "tailscale share list" command.
func runShareList(ctx context.Context, args []string) error {
if len(args) != 0 {
return fmt.Errorf("usage: tailscale %v", shareListUsage)
}
sharesMap, err := localClient.TailFSShareList(ctx)
shares, err := localClient.TailFSShareList(ctx)
if err != nil {
return err
}
shares := make([]*tailfs.Share, 0, len(sharesMap))
for _, share := range sharesMap {
shares = append(shares, share)
}
sort.Slice(shares, func(i, j int) bool {
return shares[i].Name < shares[j].Name
})
longestName := 4 // "name"
longestPath := 4 // "path"
@@ -157,7 +170,7 @@ For example, to enable sharing and accessing shares for all member nodes:
Each share is identified by a name and points to a directory at a specific path. For example, to share the path /Users/me/Documents under the name "docs", you would run:
$ tailscale share add docs /Users/me/Documents
$ tailscale share set docs /Users/me/Documents
Note that the system forces share names to lowercase to avoid problems with clients that don't support case-sensitive filenames.
@@ -211,9 +224,13 @@ To categorically give yourself access to all your shares, you can use the below
Whenever either you or anyone in the group "home" connects to the share, they connect as if they are using your local machine user. They'll be able to read the same files as your user and if they create files, those files will be owned by your user.%s
You can rename shares, for example you could rename the above share by running:
$ tailscale share rename docs newdocs
You can remove shares by name, for example you could remove the above share by running:
$ tailscale share remove docs
$ tailscale share remove newdocs
You can get a list of currently published shares by running:
@@ -223,4 +240,4 @@ var shareLongHelpAs = `
If you want a share to be accessed as a different user, you can use sudo to accomplish this. For example, to create the aforementioned share as "theuser", you could run:
$ sudo -u theuser tailscale share add docs /Users/theuser/Documents`
$ sudo -u theuser tailscale share set docs /Users/theuser/Documents`

View File

@@ -652,6 +652,7 @@ func upWorthyWarning(s string) bool {
return strings.Contains(s, healthmsg.TailscaleSSHOnBut) ||
strings.Contains(s, healthmsg.WarnAcceptRoutesOff) ||
strings.Contains(s, healthmsg.LockedOut) ||
strings.Contains(s, healthmsg.WarnExitNodeUsage) ||
strings.Contains(strings.ToLower(s), "update available: ")
}

View File

@@ -405,7 +405,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/wgengine/router from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
tailscale.com/wgengine/wglog from tailscale.com/wgengine
W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router
golang.org/x/crypto/argon2 from tailscale.com/tka

View File

@@ -432,13 +432,26 @@ func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID,
if sigPipe != nil {
signal.Ignore(sigPipe)
}
wgEngineCreated := make(chan struct{})
go func() {
select {
case s := <-interrupt:
logf("tailscaled got signal %v; shutting down", s)
cancel()
case <-ctx.Done():
// continue
var wgEngineClosed <-chan struct{}
wgEngineCreated := wgEngineCreated // local shadow
for {
select {
case s := <-interrupt:
logf("tailscaled got signal %v; shutting down", s)
cancel()
return
case <-wgEngineClosed:
logf("wgengine has been closed; shutting down")
cancel()
return
case <-wgEngineCreated:
wgEngineClosed = sys.Engine.Get().Done()
wgEngineCreated = nil
case <-ctx.Done():
return
}
}
}()
@@ -464,6 +477,7 @@ func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID,
if err == nil {
logf("got LocalBackend in %v", time.Since(t0).Round(time.Millisecond))
srv.SetLocalBackend(lb)
close(wgEngineCreated)
return
}
lbErr.Store(err) // before the following cancel

View File

@@ -172,6 +172,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
switch elem.String() {
case "byte":
args.FieldType = it.QualifiedName(fieldType)
it.Import("tailscale.com/types/views")
writeTemplate("byteSliceField")
default:
args.FieldType = it.QualifiedName(elem)

View File

@@ -1,6 +1,10 @@
# Overview
There are quite a few ways of running Tailscale inside a Kubernetes Cluster, some of the common ones are covered in this doc.
There are quite a few ways of running Tailscale inside a Kubernetes Cluster.
This doc covers creating and managing your own Tailscale node deployments in cluster.
If you want a higher level of automation, easier configuration, automated cleanup of stopped Tailscale devices, or a mechanism for exposing the [Kubernetes API](https://kubernetes.io/docs/concepts/overview/kubernetes-api/) server to the tailnet, take a look at [Tailscale Kubernetes operator](https://tailscale.com/kb/1236/kubernetes-operator).
:warning: Note that the manifests generated by the following commands are not intended for production use, and you will need to tweak them based on your environment and use case. For example, the commands to generate a standalone proxy manifest, will create a standalone `Pod`- this will not persist across cluster upgrades etc. :warning:
## Instructions
@@ -153,3 +157,74 @@ the entire Kubernetes cluster network (assuming NetworkPolicies allow) over Tail
INTERNAL_PORT=8080
curl http://$INTERNAL_IP:$INTERNAL_PORT
```
## Multiple replicas
Note that if you want to use the `Pod` manifests generated by the commands above in a multi-replica setup (i.e a multi-replica `StatefulSet`) you will need to change the mechanism for storing tailscale state to ensure that multiple replicas are not attemting to use a single Kubernetes `Secret` to store their individual states.
To avoid proxy state clashes you could either store the state in memory or an `emptyDir` volume, or you could change the provided state `Secret` name to ensure that a unique name gets generated for each replica.
### Option 1: storing in an `emptyDir`
You can mount an [`emptyDir` volume](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir) and configure the mount as the tailscale state store via `TS_STATE_DIR` env var.
You must also set `TS_KUBE_SECRET` to an empty string.
An example:
```yaml
kind: StatefulSet
metadata:
name: subnetrouter
spec:
replicas: 2
...
template:
...
spec:
...
volumes:
- name: tsstate
emptyDir: {}
containers:
- name: tailscale
env:
- name: TS_STATE_DIR
value: /tsstate
- name: TS_KUBE_SECRET
value: ""
volumeMounts:
- name: tsstate
mountPath: /tsstate
```
The downside of this approach is that the state will be lost when a `Pod` is
deleted. In practice this means that when you, for example, upgrade proxy
versions you will get a new set of Tailscale devices with different hostnames.
### Option 2: dynamically generating unique `Secret` names
If you run the proxy as a `StatefulSet`, the `Pod`s get [stable identifiers](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-network-id).
You can use that to pass an individual, static state `Secret` name to each proxy:
```yaml
kind: StatefulSet
metadata:
name: subnetrouter
spec:
replicas: 2
...
template:
...
spec:
...
containers:
- name: tailscale
env:
- name: TS_KUBE_SECRET
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.name
```
In this case, each replica will store its state in a `Secret` named the same as the `Pod` and as `Pod` names for a `StatefulSet` do not change if `Pod`s get recreated, proxy state will persist across cluster and proxy version updates etc.

View File

@@ -32,6 +32,8 @@ spec:
value: "{{TS_KUBE_SECRET}}"
- name: TS_USERSPACE
value: "false"
- name: TS_DEBUG_FIREWALL_MODE
value: auto
- name: TS_AUTHKEY
valueFrom:
secretKeyRef:

View File

@@ -18,6 +18,8 @@ spec:
value: "{{TS_KUBE_SECRET}}"
- name: TS_USERSPACE
value: "false"
- name: TS_DEBUG_FIREWALL_MODE
value: auto
- name: TS_AUTHKEY
valueFrom:
secretKeyRef:

View File

@@ -17,7 +17,9 @@ spec:
- name: TS_KUBE_SECRET
value: "{{TS_KUBE_SECRET}}"
- name: TS_USERSPACE
value: "true"
value: "false"
- name: TS_DEBUG_FIREWALL_MODE
value: auto
- name: TS_AUTHKEY
valueFrom:
secretKeyRef:

View File

@@ -120,4 +120,4 @@
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-1g50+BwoUCwc/tBmnP2KO6e3GwL8QQ/wJ+XoxCzzk3k=
# nix-direnv cache busting line: sha256-jyRjT/CQBlmjHzilxJvMuzZQlGyJB4X/yISgWjBVDxc=

7
go.mod
View File

@@ -4,6 +4,7 @@ go 1.22.0
require (
filippo.io/mkcert v1.4.4
fybrik.io/crdoc v0.6.3
github.com/akutz/memconn v0.1.0
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa
github.com/andybalholm/brotli v1.1.0
@@ -73,7 +74,7 @@ require (
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4
github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9
@@ -92,14 +93,14 @@ require (
golang.org/x/net v0.20.0
golang.org/x/oauth2 v0.16.0
golang.org/x/sync v0.6.0
golang.org/x/sys v0.16.0
golang.org/x/sys v0.17.0
golang.org/x/term v0.16.0
golang.org/x/time v0.5.0
golang.org/x/tools v0.17.0
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
golang.zx2c4.com/wireguard/windows v0.5.3
gopkg.in/square/go-jose.v2 v2.6.0
gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3
honnef.co/go/tools v0.4.6
k8s.io/api v0.29.1
k8s.io/apimachinery v0.29.1

View File

@@ -1 +1 @@
sha256-1g50+BwoUCwc/tBmnP2KO6e3GwL8QQ/wJ+XoxCzzk3k=
sha256-jyRjT/CQBlmjHzilxJvMuzZQlGyJB4X/yISgWjBVDxc=

14
go.sum
View File

@@ -46,6 +46,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
fybrik.io/crdoc v0.6.3 h1:jNNAVINu8up5vrLa0jrV7z7HSlyHF/6lNOrAtrXwYlI=
fybrik.io/crdoc v0.6.3/go.mod h1:kvZRt7VAzOyrmDpIqREtcKAVFSJYEBoAyniYebsJGtQ=
github.com/Abirdcfly/dupword v0.0.11 h1:z6v8rMETchZXUIuHxYNmlUAuKuB21PeaSymTed16wgU=
github.com/Abirdcfly/dupword v0.0.11/go.mod h1:wH8mVGuf3CP5fsBTkfWwwwKTjDnVVCxtU8d8rgeVYXA=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
@@ -879,8 +881,8 @@ github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQ
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/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/web-client-prebuilt v0.0.0-20240208184856-443a64766f61 h1:G6/VUGQkHbBffO0s3f51DThcHCWrShlWklcS4Zxh5BU=
github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
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/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 h1:zwsem4CaamMdC3tFoTpzrsUSMDPV0K6rhnQdF7kXekQ=
@@ -1172,8 +1174,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1415,8 +1417,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186 h1:VWRSJX9ghfqsRSZGMAILL6QpYRKWnHcYPi24SCubQRs=
gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -1 +1 @@
66fe5734c4555397ef1b9de3e1ec958bf0a2086e
f86d7c8ef64a0f8a2516fc23652eee28abc8d8e0

View File

@@ -103,6 +103,16 @@ func WithMapDebugFlag(name string) WarnableOpt {
})
}
// WithConnectivityImpact returns an option which makes a Warnable annotated as
// something that could be breaking external network connectivity on the
// machine. This will make the warnable returned by OverallError alongside
// network connectivity errors.
func WithConnectivityImpact() WarnableOpt {
return warnOptFunc(func(w *Warnable) {
w.hasConnectivityImpact = true
})
}
type warnOptFunc func(*Warnable)
func (f warnOptFunc) mod(w *Warnable) { f(w) }
@@ -112,6 +122,10 @@ func (f warnOptFunc) mod(w *Warnable) { f(w) }
type Warnable struct {
debugFlag string // optional MapRequest.DebugFlag to send when unhealthy
// If true, this warning is related to configuration of networking stack
// on the machine that impacts connectivity.
hasConnectivityImpact bool
isSet atomic.Bool
mu sync.Mutex
err error
@@ -442,9 +456,35 @@ func OverallError() error {
var fakeErrForTesting = envknob.RegisterString("TS_DEBUG_FAKE_HEALTH_ERROR")
// networkErrorf creates an error that indicates issues with outgoing network
// connectivity. Any active warnings related to network connectivity will
// automatically be appended to it.
func networkErrorf(format string, a ...any) error {
errs := []error{
fmt.Errorf(format, a...),
}
for w := range warnables {
if !w.hasConnectivityImpact {
continue
}
if err := w.get(); err != nil {
errs = append(errs, err)
}
}
if len(errs) == 1 {
return errs[0]
}
return multierr.New(errs...)
}
var errNetworkDown = networkErrorf("network down")
var errNotInMapPoll = networkErrorf("not in map poll")
var errNoDERPHome = errors.New("no DERP home")
var errNoUDP4Bind = networkErrorf("no udp4 bind")
func overallErrorLocked() error {
if !anyInterfaceUp {
return errors.New("network down")
return errNetworkDown
}
if localLogConfigErr != nil {
return localLogConfigErr
@@ -457,26 +497,26 @@ func overallErrorLocked() error {
}
now := time.Now()
if !inMapPoll && (lastMapPollEndedAt.IsZero() || now.Sub(lastMapPollEndedAt) > 10*time.Second) {
return errors.New("not in map poll")
return errNotInMapPoll
}
const tooIdle = 2*time.Minute + 5*time.Second
if d := now.Sub(lastStreamedMapResponse).Round(time.Second); d > tooIdle {
return fmt.Errorf("no map response in %v", d)
return networkErrorf("no map response in %v", d)
}
if !derpHomeless {
rid := derpHomeRegion
if rid == 0 {
return errors.New("no DERP home")
return errNoDERPHome
}
if !derpRegionConnected[rid] {
return fmt.Errorf("not connected to home DERP region %v", rid)
return networkErrorf("not connected to home DERP region %v", rid)
}
if d := now.Sub(derpRegionLastFrame[rid]).Round(time.Second); d > tooIdle {
return fmt.Errorf("haven't heard from home DERP region %v in %v", rid, d)
return networkErrorf("haven't heard from home DERP region %v in %v", rid, d)
}
}
if udp4Unbound {
return errors.New("no udp4 bind")
return errNoUDP4Bind
}
// TODO: use

View File

@@ -11,4 +11,5 @@ const (
WarnAcceptRoutesOff = "Some peers are advertising routes but --accept-routes is false"
TailscaleSSHOnBut = "Tailscale SSH enabled, but " // + ... something from caller
LockedOut = "this node is locked out; it will not have connectivity until it is signed. For more info, see https://tailscale.com/s/locked-out"
WarnExitNodeUsage = "The following issues on your machine will likely make usage of exit nodes impossible"
)

View File

@@ -10,10 +10,12 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/types/empty"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/structs"
"tailscale.com/types/views"
)
type State int
@@ -123,11 +125,12 @@ type Notify struct {
ClientVersion *tailcfg.ClientVersion `json:",omitempty"`
// TailFSShares tracks the full set of current TailFSShares that we're
// publishing as name->path. Some client applications, like the MacOS and
// Windows clients, will listen for updates to this and handle serving
// these shares under the identity of the unprivileged user that is running
// the application.
TailFSShares map[string]string `json:",omitempty"`
// publishing. Some client applications, like the MacOS and Windows clients,
// will listen for updates to this and handle serving these shares under
// the identity of the unprivileged user that is running the application. A
// nil value here means that we're not broadcasting shares information, an
// empty value means that there are no shares.
TailFSShares views.SliceView[*tailfs.Share, tailfs.ShareView]
// type is mirrored in xcode/Shared/IPN.swift
}

View File

@@ -10,6 +10,7 @@ import (
"net/netip"
"tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
)
@@ -24,6 +25,12 @@ func (src *Prefs) Clone() *Prefs {
*dst = *src
dst.AdvertiseTags = append(src.AdvertiseTags[:0:0], src.AdvertiseTags...)
dst.AdvertiseRoutes = append(src.AdvertiseRoutes[:0:0], src.AdvertiseRoutes...)
if src.TailFSShares != nil {
dst.TailFSShares = make([]*tailfs.Share, len(src.TailFSShares))
for i := range dst.TailFSShares {
dst.TailFSShares[i] = src.TailFSShares[i].Clone()
}
}
dst.Persist = src.Persist.Clone()
return dst
}
@@ -56,6 +63,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
TailFSShares []*tailfs.Share
Persist *persist.Persist
}{})

View File

@@ -11,6 +11,7 @@ import (
"net/netip"
"tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
"tailscale.com/types/views"
@@ -91,7 +92,10 @@ func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpda
func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector }
func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
func (v PrefsView) NetfilterKind() string { return v.ж.NetfilterKind }
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
func (v PrefsView) TailFSShares() views.SliceView[*tailfs.Share, tailfs.ShareView] {
return views.SliceOfViews[*tailfs.Share, tailfs.ShareView](v.ж.TailFSShares)
}
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _PrefsViewNeedsRegeneration = Prefs(struct {
@@ -121,6 +125,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
TailFSShares []*tailfs.Share
Persist *persist.Persist
}{})

View File

@@ -68,6 +68,7 @@ import (
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/taildrop"
"tailscale.com/tailfs"
"tailscale.com/tka"
"tailscale.com/tsd"
"tailscale.com/tstime"
@@ -252,8 +253,8 @@ type LocalBackend struct {
peerAPIListeners []*peerAPIListener
loginFlags controlclient.LoginFlags
fileWaiters set.HandleSet[context.CancelFunc] // of wake-up funcs
notifyWatchers set.HandleSet[*watchSession]
lastStatusTime time.Time // status.AsOf value of the last processed status update
notifyWatchers map[string]*watchSession // by session ID
lastStatusTime time.Time // status.AsOf value of the last processed status update
// directFileRoot, if non-empty, means to write received files
// directly to this directory, without staging them in an
// intermediate buffered directory for "pick-up" later. If
@@ -278,9 +279,8 @@ type LocalBackend struct {
capForcedNetfilter string
// ServeConfig fields. (also guarded by mu)
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
serveConfig ipn.ServeConfigView // or !Valid if none
activeWatchSessions set.Set[string] // of WatchIPN SessionID
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
serveConfig ipn.ServeConfigView // or !Valid if none
webClient webClient
webClientListeners map[netip.AddrPort]*localListener // listeners for local web client traffic
@@ -308,6 +308,10 @@ type LocalBackend struct {
// Last ClientVersion received in MapResponse, guarded by mu.
lastClientVersion *tailcfg.ClientVersion
// lastNotifiedTailFSShares keeps track of the last set of shares that we
// notified about.
lastNotifiedTailFSShares atomic.Pointer[views.SliceView[*tailfs.Share, tailfs.ShareView]]
}
type updateStatus struct {
@@ -382,7 +386,6 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
gotPortPollRes: make(chan struct{}),
loginFlags: loginFlags,
clock: clock,
activeWatchSessions: make(set.Set[string]),
selfUpdateProgress: make([]ipnstate.UpdateProgress, 0),
lastSelfUpdateState: ipnstate.UpdateFinished,
}
@@ -432,10 +435,12 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
// initialize TailFS shares from saved state
fs, ok := b.sys.TailFSForRemote.GetOK()
if ok {
b.mu.Lock()
shares, err := b.tailFSGetSharesLocked()
b.mu.Unlock()
if err == nil && len(shares) > 0 {
currentShares := b.pm.prefs.TailFSShares()
if currentShares.Len() > 0 {
var shares []*tailfs.Share
for i := 0; i < currentShares.Len(); i++ {
shares = append(shares, currentShares.At(i).AsStruct())
}
fs.SetShares(shares)
}
}
@@ -608,6 +613,7 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
// If the local network configuration has changed, our filter may
// need updating to tweak default routes.
b.updateFilterLocked(b.netMap, b.pm.CurrentPrefs())
updateExitNodeUsageWarning(b.pm.CurrentPrefs(), delta.New)
if peerAPIListenAsync && b.netMap != nil && b.state == ipn.Running {
want := b.netMap.GetAddresses().Len()
@@ -678,7 +684,7 @@ func (b *LocalBackend) Shutdown() {
}
b.ctxCancel()
b.e.Close()
b.e.Wait()
<-b.e.Done()
}
func stripKeysFromPrefs(p ipn.PrefsView) ipn.PrefsView {
@@ -2265,7 +2271,6 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
var ini *ipn.Notify
b.mu.Lock()
b.activeWatchSessions.Add(sessionID)
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialTailFSShares
if mask&initialBits != 0 {
@@ -2284,25 +2289,16 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
ini.NetMap = b.netMap
}
if mask&ipn.NotifyInitialTailFSShares != 0 && b.tailFSSharingEnabledLocked() {
shares, err := b.tailFSGetSharesLocked()
if err != nil {
b.logf("unable to notify initial tailfs shares: %v", err)
} else {
ini.TailFSShares = make(map[string]string, len(shares))
for _, share := range shares {
ini.TailFSShares[share.Name] = share.Path
}
}
ini.TailFSShares = b.pm.prefs.TailFSShares()
}
}
handle := b.notifyWatchers.Add(&watchSession{ch, sessionID})
mak.Set(&b.notifyWatchers, sessionID, &watchSession{ch, sessionID})
b.mu.Unlock()
defer func() {
b.mu.Lock()
delete(b.notifyWatchers, handle)
delete(b.activeWatchSessions, sessionID)
delete(b.notifyWatchers, sessionID)
b.mu.Unlock()
}()
@@ -2371,6 +2367,20 @@ func (b *LocalBackend) DebugNotify(n ipn.Notify) {
b.send(n)
}
// DebugNotifyLastNetMap injects a fake notify message to clients,
// repeating whatever the last netmap was.
//
// It should only be used via the LocalAPI's debug handler.
func (b *LocalBackend) DebugNotifyLastNetMap() {
b.mu.Lock()
nm := b.netMap
b.mu.Unlock()
if nm != nil {
b.send(ipn.Notify{NetMap: nm})
}
}
// DebugForceNetmapUpdate forces a full no-op netmap update of the current
// netmap in all the various subsystems (wireguard, magicsock, LocalBackend).
//
@@ -3086,6 +3096,22 @@ func (b *LocalBackend) isDefaultServerLocked() bool {
return prefs.ControlURLOrDefault() == ipn.DefaultControlURL
}
var warnExitNodeUsage = health.NewWarnable(health.WithConnectivityImpact())
// updateExitNodeUsageWarning updates a warnable meant to notify users of
// configuration issues that could break exit node usage.
func updateExitNodeUsageWarning(p ipn.PrefsView, state *interfaces.State) {
var result error
if p.ExitNodeIP().IsValid() || p.ExitNodeID() != "" {
warn, _ := netutil.CheckReversePathFiltering(state)
const comment = "please set rp_filter=2 instead of rp_filter=1; see https://github.com/tailscale/tailscale/issues/3310"
if len(warn) > 0 {
result = fmt.Errorf("%s: %v, %s", healthmsg.WarnExitNodeUsage, warn, comment)
}
}
warnExitNodeUsage.Set(result)
}
func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
if (p.ExitNodeIP.IsValid() || p.ExitNodeID != "") && p.AdvertisesExitNode() {
return errors.New("Cannot advertise an exit node and use an exit node at the same time.")
@@ -3312,13 +3338,24 @@ var (
// TCPHandlerForDst returns a TCP handler for connections to dst, or nil if
// no handler is needed. It also returns a list of TCP socket options to
// apply to the socket before calling the handler.
// TCPHandlerForDst is called both for connections to our node's local IP
// as well as to the service IP (quad 100).
func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c net.Conn) error, opts []tcpip.SettableSocketOption) {
if dst.Port() == 80 && (dst.Addr() == magicDNSIP || dst.Addr() == magicDNSIPv6) {
if b.ShouldRunWebClient() {
return b.handleWebClientConn, opts
// First handle internal connections to the service IP
hittingServiceIP := dst.Addr() == magicDNSIP || dst.Addr() == magicDNSIPv6
if hittingServiceIP {
switch dst.Port() {
case 80:
if b.ShouldRunWebClient() {
return b.handleWebClientConn, opts
}
return b.HandleQuad100Port80Conn, opts
case TailFSLocalPort:
return b.handleTailFSConn, opts
}
return b.HandleQuad100Port80Conn, opts
}
// Then handle external connections to the local IP.
if !b.isLocalIP(dst.Addr()) {
return nil, nil
}
@@ -3336,18 +3373,6 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
if dst.Port() == webClientPort && b.ShouldRunWebClient() {
return b.handleWebClientConn, opts
}
if dst.Port() == TailFSLocalPort {
fs, ok := b.sys.TailFSForLocal.GetOK()
if ok {
return func(conn net.Conn) error {
if !b.TailFSAccessEnabled() {
conn.Close()
return nil
}
return fs.HandleConn(conn, conn.RemoteAddr())
}, opts
}
}
if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port {
return func(c net.Conn) error {
b.handlePeerAPIConn(src, dst, c)
@@ -3360,6 +3385,15 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
return nil, nil
}
func (b *LocalBackend) handleTailFSConn(conn net.Conn) error {
fs, ok := b.sys.TailFSForLocal.GetOK()
if !ok || !b.TailFSAccessEnabled() {
conn.Close()
return nil
}
return fs.HandleConn(conn, conn.RemoteAddr())
}
func (b *LocalBackend) peerAPIServicesLocked() (ret []tailcfg.Service) {
for _, pln := range b.peerAPIListeners {
proto := tailcfg.PeerAPI4
@@ -4645,10 +4679,8 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
}
}
if b.tailFSSharingEnabledLocked() {
b.updateTailFSPeersLocked(nm)
b.tailFSNotifyCurrentSharesLocked()
}
b.updateTailFSPeersLocked(nm)
b.tailFSNotifyCurrentSharesLocked()
}
func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) {
@@ -4743,8 +4775,9 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
}
// remove inactive sessions
maps.DeleteFunc(conf.Foreground, func(s string, sc *ipn.ServeConfig) bool {
return !b.activeWatchSessions.Contains(s)
maps.DeleteFunc(conf.Foreground, func(sessionID string, sc *ipn.ServeConfig) bool {
_, ok := b.notifyWatchers[sessionID]
return !ok
})
b.serveConfig = conf.View()

View File

@@ -5,16 +5,20 @@ package ipnlocal
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"os"
"reflect"
"slices"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/appc"
@@ -25,6 +29,8 @@ import (
"tailscale.com/net/interfaces"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/tailfs/tailfsimpl"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/types/dnstype"
@@ -34,10 +40,10 @@ import (
"tailscale.com/types/netmap"
"tailscale.com/types/opt"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
@@ -765,9 +771,6 @@ var _ legacyBackend = (*LocalBackend)(nil)
func TestWatchNotificationsCallbacks(t *testing.T) {
b := new(LocalBackend)
// activeWatchSessions is typically set in NewLocalBackend
// so WatchNotifications expects it to be non-empty.
b.activeWatchSessions = make(set.Set[string])
n := new(ipn.Notify)
b.WatchNotifications(context.Background(), 0, func() {
b.mu.Lock()
@@ -2173,3 +2176,296 @@ func TestOnTailnetDefaultAutoUpdate(t *testing.T) {
})
}
}
func TestTCPHandlerForDst(t *testing.T) {
b := newTestBackend(t)
tests := []struct {
desc string
dst string
intercept bool
}{
{
desc: "intercept port 80 (Web UI) on quad100 IPv4",
dst: "100.100.100.100:80",
intercept: true,
},
{
desc: "intercept port 80 (Web UI) on quad100 IPv6",
dst: "[fd7a:115c:a1e0::53]:80",
intercept: true,
},
{
desc: "don't intercept port 80 on local ip",
dst: "100.100.103.100:80",
intercept: false,
},
{
desc: "intercept port 8080 (TailFS) on quad100 IPv4",
dst: "100.100.100.100:8080",
intercept: true,
},
{
desc: "intercept port 8080 (TailFS) on quad100 IPv6",
dst: "[fd7a:115c:a1e0::53]:8080",
intercept: true,
},
{
desc: "don't intercept port 8080 on local ip",
dst: "100.100.103.100:8080",
intercept: false,
},
{
desc: "don't intercept port 9080 on quad100 IPv4",
dst: "100.100.100.100:9080",
intercept: false,
},
{
desc: "don't intercept port 9080 on quad100 IPv6",
dst: "[fd7a:115c:a1e0::53]:9080",
intercept: false,
},
{
desc: "don't intercept port 9080 on local ip",
dst: "100.100.103.100:9080",
intercept: false,
},
}
for _, tt := range tests {
t.Run(tt.dst, func(t *testing.T) {
t.Log(tt.desc)
src := netip.MustParseAddrPort("100.100.102.100:51234")
h, _ := b.TCPHandlerForDst(src, netip.MustParseAddrPort(tt.dst))
if !tt.intercept && h != nil {
t.Error("intercepted traffic we shouldn't have")
} else if tt.intercept && h == nil {
t.Error("failed to intercept traffic we should have")
}
})
}
}
func TestTailFSManageShares(t *testing.T) {
tests := []struct {
name string
disabled bool
existing []*tailfs.Share
add *tailfs.Share
remove string
rename [2]string
expect any
}{
{
name: "append",
existing: []*tailfs.Share{
{Name: "b"},
{Name: "d"},
},
add: &tailfs.Share{Name: " E "},
expect: []*tailfs.Share{
{Name: "b"},
{Name: "d"},
{Name: "e"},
},
},
{
name: "prepend",
existing: []*tailfs.Share{
{Name: "b"},
{Name: "d"},
},
add: &tailfs.Share{Name: " A "},
expect: []*tailfs.Share{
{Name: "a"},
{Name: "b"},
{Name: "d"},
},
},
{
name: "insert",
existing: []*tailfs.Share{
{Name: "b"},
{Name: "d"},
},
add: &tailfs.Share{Name: " C "},
expect: []*tailfs.Share{
{Name: "b"},
{Name: "c"},
{Name: "d"},
},
},
{
name: "replace",
existing: []*tailfs.Share{
{Name: "b", Path: "i"},
{Name: "d"},
},
add: &tailfs.Share{Name: " B ", Path: "ii"},
expect: []*tailfs.Share{
{Name: "b", Path: "ii"},
{Name: "d"},
},
},
{
name: "add_bad_name",
add: &tailfs.Share{Name: "$"},
expect: ErrInvalidShareName,
},
{
name: "add_disabled",
disabled: true,
add: &tailfs.Share{Name: "a"},
expect: ErrTailFSNotEnabled,
},
{
name: "remove",
existing: []*tailfs.Share{
{Name: "a"},
{Name: "b"},
{Name: "c"},
},
remove: "b",
expect: []*tailfs.Share{
{Name: "a"},
{Name: "c"},
},
},
{
name: "remove_non_existing",
existing: []*tailfs.Share{
{Name: "a"},
{Name: "b"},
{Name: "c"},
},
remove: "D",
expect: os.ErrNotExist,
},
{
name: "remove_disabled",
disabled: true,
remove: "b",
expect: ErrTailFSNotEnabled,
},
{
name: "rename",
existing: []*tailfs.Share{
{Name: "a"},
{Name: "b"},
},
rename: [2]string{"a", " C "},
expect: []*tailfs.Share{
{Name: "b"},
{Name: "c"},
},
},
{
name: "rename_not_exist",
existing: []*tailfs.Share{
{Name: "a"},
{Name: "b"},
},
rename: [2]string{"d", "c"},
expect: os.ErrNotExist,
},
{
name: "rename_exists",
existing: []*tailfs.Share{
{Name: "a"},
{Name: "b"},
},
rename: [2]string{"a", "b"},
expect: os.ErrExist,
},
{
name: "rename_bad_name",
rename: [2]string{"a", "$"},
expect: ErrInvalidShareName,
},
{
name: "rename_disabled",
disabled: true,
rename: [2]string{"a", "c"},
expect: ErrTailFSNotEnabled,
},
}
tailfs.DisallowShareAs = true
t.Cleanup(func() {
tailfs.DisallowShareAs = false
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := newTestBackend(t)
b.mu.Lock()
if tt.existing != nil {
b.tailFSSetSharesLocked(tt.existing)
}
if !tt.disabled {
self := b.netMap.SelfNode.AsStruct()
self.CapMap = tailcfg.NodeCapMap{tailcfg.NodeAttrsTailFSShare: nil}
b.netMap.SelfNode = self.View()
b.sys.Set(tailfsimpl.NewFileSystemForRemote(b.logf))
}
b.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
t.Cleanup(cancel)
result := make(chan views.SliceView[*tailfs.Share, tailfs.ShareView], 1)
var wg sync.WaitGroup
wg.Add(1)
go b.WatchNotifications(
ctx,
0,
func() { wg.Done() },
func(n *ipn.Notify) bool {
select {
case result <- n.TailFSShares:
default:
//
}
return false
},
)
wg.Wait()
var err error
switch {
case tt.add != nil:
err = b.TailFSSetShare(tt.add)
case tt.remove != "":
err = b.TailFSRemoveShare(tt.remove)
default:
err = b.TailFSRenameShare(tt.rename[0], tt.rename[1])
}
switch e := tt.expect.(type) {
case error:
if !errors.Is(err, e) {
t.Errorf("expected error, want: %v got: %v", e, err)
}
case []*tailfs.Share:
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
r := <-result
got, err := json.MarshalIndent(r, "", " ")
if err != nil {
t.Fatalf("can't marshal got: %v", err)
}
want, err := json.MarshalIndent(e, "", " ")
if err != nil {
t.Fatalf("can't marshal want: %v", err)
}
if diff := cmp.Diff(string(got), string(want)); diff != "" {
t.Errorf("wrong shares; (-got+want):%v", diff)
}
}
}
})
}
}

View File

@@ -684,7 +684,8 @@ func newTestBackend(t *testing.T) *LocalBackend {
b.netMap = &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "example.ts.net",
Name: "example.ts.net",
Capabilities: []tailcfg.NodeCapability{tailcfg.NodeAttrsTailFSAccess},
}).View(),
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {

View File

@@ -4,30 +4,30 @@
package ipnlocal
import (
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"slices"
"strings"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
)
const (
// TailFSLocalPort is the port on which the TailFS listens for location
// connections on quad 100.
TailFSLocalPort = 8080
tailfsSharesStateKey = ipn.StateKey("_tailfs-shares")
)
var (
shareNameRegex = regexp.MustCompile(`^[a-z0-9_\(\) ]+$`)
errInvalidShareName = errors.New("Share names may only contain the letters a-z, underscore _, parentheses (), or spaces")
ErrTailFSNotEnabled = errors.New("TailFS not enabled")
ErrInvalidShareName = errors.New("Share names may only contain the letters a-z, underscore _, parentheses (), or spaces")
)
// TailFSSharingEnabled reports whether sharing to remote nodes via tailfs is
@@ -62,18 +62,18 @@ func (b *LocalBackend) tailFSAccessEnabledLocked() bool {
func (b *LocalBackend) TailFSSetFileServerAddr(addr string) error {
fs, ok := b.sys.TailFSForRemote.GetOK()
if !ok {
return errors.New("tailfs not enabled")
return ErrTailFSNotEnabled
}
fs.SetFileServerAddr(addr)
return nil
}
// TailFSAddShare adds the given share if no share with that name exists, or
// replaces the existing share if one with the same name already exists.
// To avoid potential incompatibilities across file systems, share names are
// TailFSSetShare adds the given share if no share with that name exists, or
// replaces the existing share if one with the same name already exists. To
// avoid potential incompatibilities across file systems, share names are
// limited to alphanumeric characters and the underscore _.
func (b *LocalBackend) TailFSAddShare(share *tailfs.Share) error {
func (b *LocalBackend) TailFSSetShare(share *tailfs.Share) error {
var err error
share.Name, err = normalizeShareName(share.Name)
if err != nil {
@@ -81,13 +81,13 @@ func (b *LocalBackend) TailFSAddShare(share *tailfs.Share) error {
}
b.mu.Lock()
shares, err := b.tailfsAddShareLocked(share)
shares, err := b.tailFSSetShareLocked(share)
b.mu.Unlock()
if err != nil {
return err
}
b.tailfsNotifyShares(shares)
b.tailFSNotifyShares(shares)
return nil
}
@@ -102,34 +102,108 @@ func normalizeShareName(name string) (string, error) {
name = strings.TrimSpace(name)
if !shareNameRegex.MatchString(name) {
return "", errInvalidShareName
return "", ErrInvalidShareName
}
return name, nil
}
func (b *LocalBackend) tailfsAddShareLocked(share *tailfs.Share) (map[string]string, error) {
func (b *LocalBackend) tailFSSetShareLocked(share *tailfs.Share) (views.SliceView[*tailfs.Share, tailfs.ShareView], error) {
existingShares := b.pm.prefs.TailFSShares()
fs, ok := b.sys.TailFSForRemote.GetOK()
if !ok {
return nil, errors.New("tailfs not enabled")
return existingShares, ErrTailFSNotEnabled
}
shares, err := b.tailFSGetSharesLocked()
if err != nil {
return nil, err
addedShare := false
var shares []*tailfs.Share
for i := 0; i < existingShares.Len(); i++ {
existing := existingShares.At(i)
if existing.Name() != share.Name {
if !addedShare && existing.Name() > share.Name {
// Add share in order
shares = append(shares, share)
addedShare = true
}
shares = append(shares, existing.AsStruct())
}
}
shares[share.Name] = share
data, err := json.Marshal(shares)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
if !addedShare {
shares = append(shares, share)
}
err = b.store.WriteState(tailfsSharesStateKey, data)
err := b.tailFSSetSharesLocked(shares)
if err != nil {
return nil, fmt.Errorf("write state: %w", err)
return existingShares, err
}
fs.SetShares(shares)
return shareNameMap(shares), nil
return b.pm.prefs.TailFSShares(), nil
}
// TailFSRenameShare renames the share at old name to new name. To avoid
// potential incompatibilities across file systems, the new share name is
// limited to alphanumeric characters and the underscore _.
// Any of the following will result in an error.
// - no share found under old name
// - new share name contains disallowed characters
// - share already exists under new name
func (b *LocalBackend) TailFSRenameShare(oldName, newName string) error {
var err error
newName, err = normalizeShareName(newName)
if err != nil {
return err
}
b.mu.Lock()
shares, err := b.tailFSRenameShareLocked(oldName, newName)
b.mu.Unlock()
if err != nil {
return err
}
b.tailFSNotifyShares(shares)
return nil
}
func (b *LocalBackend) tailFSRenameShareLocked(oldName, newName string) (views.SliceView[*tailfs.Share, tailfs.ShareView], error) {
existingShares := b.pm.prefs.TailFSShares()
fs, ok := b.sys.TailFSForRemote.GetOK()
if !ok {
return existingShares, ErrTailFSNotEnabled
}
found := false
var shares []*tailfs.Share
for i := 0; i < existingShares.Len(); i++ {
existing := existingShares.At(i)
if existing.Name() == newName {
return existingShares, os.ErrExist
}
if existing.Name() == oldName {
share := existing.AsStruct()
share.Name = newName
shares = append(shares, share)
found = true
} else {
shares = append(shares, existing.AsStruct())
}
}
if !found {
return existingShares, os.ErrNotExist
}
slices.SortFunc(shares, tailfs.CompareShares)
err := b.tailFSSetSharesLocked(shares)
if err != nil {
return existingShares, err
}
fs.SetShares(shares)
return b.pm.prefs.TailFSShares(), nil
}
// TailFSRemoveShare removes the named share. Share names are forced to
@@ -144,95 +218,109 @@ func (b *LocalBackend) TailFSRemoveShare(name string) error {
}
b.mu.Lock()
shares, err := b.tailfsRemoveShareLocked(name)
shares, err := b.tailFSRemoveShareLocked(name)
b.mu.Unlock()
if err != nil {
return err
}
b.tailfsNotifyShares(shares)
b.tailFSNotifyShares(shares)
return nil
}
func (b *LocalBackend) tailfsRemoveShareLocked(name string) (map[string]string, error) {
func (b *LocalBackend) tailFSRemoveShareLocked(name string) (views.SliceView[*tailfs.Share, tailfs.ShareView], error) {
existingShares := b.pm.prefs.TailFSShares()
fs, ok := b.sys.TailFSForRemote.GetOK()
if !ok {
return nil, errors.New("tailfs not enabled")
return existingShares, ErrTailFSNotEnabled
}
shares, err := b.tailFSGetSharesLocked()
if err != nil {
return nil, err
found := false
var shares []*tailfs.Share
for i := 0; i < existingShares.Len(); i++ {
existing := existingShares.At(i)
if existing.Name() != name {
shares = append(shares, existing.AsStruct())
} else {
found = true
}
}
_, shareExists := shares[name]
if !shareExists {
return nil, os.ErrNotExist
if !found {
return existingShares, os.ErrNotExist
}
delete(shares, name)
data, err := json.Marshal(shares)
err := b.tailFSSetSharesLocked(shares)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
err = b.store.WriteState(tailfsSharesStateKey, data)
if err != nil {
return nil, fmt.Errorf("write state: %w", err)
return existingShares, err
}
fs.SetShares(shares)
return shareNameMap(shares), nil
return b.pm.prefs.TailFSShares(), nil
}
func shareNameMap(sharesByName map[string]*tailfs.Share) map[string]string {
sharesMap := make(map[string]string, len(sharesByName))
for _, share := range sharesByName {
sharesMap[share.Name] = share.Path
}
return sharesMap
func (b *LocalBackend) tailFSSetSharesLocked(shares []*tailfs.Share) error {
prefs := b.pm.prefs.AsStruct()
prefs.ApplyEdits(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{
TailFSShares: shares,
},
TailFSSharesSet: true,
})
return b.pm.setPrefsLocked(prefs.View())
}
// tailfsNotifyShares notifies IPN bus listeners (e.g. Mac Application process)
// about the latest set of shares, supplied as a map of name -> directory.
func (b *LocalBackend) tailfsNotifyShares(shares map[string]string) {
// tailFSNotifyShares notifies IPN bus listeners (e.g. Mac Application process)
// about the latest list of shares.
func (b *LocalBackend) tailFSNotifyShares(shares views.SliceView[*tailfs.Share, tailfs.ShareView]) {
b.send(ipn.Notify{TailFSShares: shares})
}
// tailFSNotifyCurrentSharesLocked sends an ipn.Notify with the current set of
// TailFS shares.
// tailFSNotifyCurrentSharesLocked sends an ipn.Notify if the current set of
// shares has changed since the last notification.
func (b *LocalBackend) tailFSNotifyCurrentSharesLocked() {
shares, err := b.tailFSGetSharesLocked()
if err != nil {
b.logf("error notifying current tailfs shares: %v", err)
return
var shares views.SliceView[*tailfs.Share, tailfs.ShareView]
if b.tailFSSharingEnabledLocked() {
// Only populate shares if sharing is enabled.
shares = b.pm.prefs.TailFSShares()
}
lastNotified := b.lastNotifiedTailFSShares.Load()
if lastNotified == nil || !tailFSShareViewsEqual(lastNotified, shares) {
// Do the below on a goroutine to avoid deadlocking on b.mu in b.send().
if shares.IsNil() {
// set to a non-nil value to indicate we have 0 shares
shares = views.SliceOfViews(make([]*tailfs.Share, 0))
}
go b.tailFSNotifyShares(shares)
}
// Do the below on a goroutine to avoid deadlocking on b.mu in b.send().
go b.tailfsNotifyShares(shareNameMap(shares))
}
// TailFSGetShares returns the current set of shares from the state store,
// stored under ipn.StateKey("_tailfs-shares").
func (b *LocalBackend) TailFSGetShares() (map[string]*tailfs.Share, error) {
func tailFSShareViewsEqual(a *views.SliceView[*tailfs.Share, tailfs.ShareView], b views.SliceView[*tailfs.Share, tailfs.ShareView]) bool {
if a == nil {
return false
}
if a.Len() != b.Len() {
return false
}
for i := 0; i < a.Len(); i++ {
if !tailfs.ShareViewsEqual(a.At(i), b.At(i)) {
return false
}
}
return true
}
// TailFSGetShares() gets the current list of TailFS shares, sorted by name.
func (b *LocalBackend) TailFSGetShares() views.SliceView[*tailfs.Share, tailfs.ShareView] {
b.mu.Lock()
defer b.mu.Unlock()
return b.tailFSGetSharesLocked()
}
func (b *LocalBackend) tailFSGetSharesLocked() (map[string]*tailfs.Share, error) {
data, err := b.store.ReadState(tailfsSharesStateKey)
if err != nil {
if errors.Is(err, ipn.ErrStateNotExist) {
return make(map[string]*tailfs.Share), nil
}
return nil, fmt.Errorf("read state: %w", err)
}
var shares map[string]*tailfs.Share
err = json.Unmarshal(data, &shares)
if err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
return shares, nil
return b.pm.prefs.TailFSShares()
}
// updateTailFSPeersLocked sets all applicable peers from the netmap as tailfs
@@ -243,11 +331,28 @@ func (b *LocalBackend) updateTailFSPeersLocked(nm *netmap.NetworkMap) {
return
}
tailfsRemotes := make([]*tailfs.Remote, 0, len(nm.Peers))
var tailFSRemotes []*tailfs.Remote
if b.tailFSAccessEnabledLocked() {
// Only populate peers if access is enabled, otherwise leave blank.
tailFSRemotes = b.tailFSRemotesFromPeers(nm)
}
fs.SetRemotes(b.netMap.Domain, tailFSRemotes, &tailFSTransport{b: b})
}
func (b *LocalBackend) tailFSRemotesFromPeers(nm *netmap.NetworkMap) []*tailfs.Remote {
tailFSRemotes := make([]*tailfs.Remote, 0, len(nm.Peers))
for _, p := range nm.Peers {
// Exclude mullvad exit nodes from list of TailFS peers
// TODO(oxtoacart) - once we have a better mechanism for finding only accessible sharers
// (see below) we can remove this logic.
if strings.HasSuffix(p.Name(), ".mullvad.ts.net.") {
continue
}
peerID := p.ID()
url := fmt.Sprintf("%s/%s", peerAPIBase(nm, p), tailFSPrefix[1:])
tailfsRemotes = append(tailfsRemotes, &tailfs.Remote{
tailFSRemotes = append(tailFSRemotes, &tailfs.Remote{
Name: p.DisplayName(false),
URL: url,
Available: func() bool {
@@ -276,5 +381,5 @@ func (b *LocalBackend) updateTailFSPeersLocked(nm *netmap.NetworkMap) {
},
})
}
fs.SetRemotes(b.netMap.Domain, tailfsRemotes, &tailFSTransport{b: b})
return tailFSRemotes
}

View File

@@ -20,11 +20,11 @@ func TestNormalizeShareName(t *testing.T) {
},
{
name: "",
err: errInvalidShareName,
err: ErrInvalidShareName,
},
{
name: "generally good except for .",
err: errInvalidShareName,
err: ErrInvalidShareName,
},
}
for _, tt := range tests {

View File

@@ -188,14 +188,21 @@ func (s *Status) Peers() []key.NodePublic {
}
type PeerStatusLite struct {
// TxBytes/RxBytes is the total number of bytes transmitted to/received from this peer.
TxBytes, RxBytes int64
// LastHandshake is the last time a handshake succeeded with this peer.
// (Or we got key confirmation via the first data message,
// which is approximately the same thing.)
LastHandshake time.Time
// NodeKey is this peer's public node key.
NodeKey key.NodePublic
// TxBytes/RxBytes are the total number of bytes transmitted to/received
// from this peer.
TxBytes, RxBytes int64
// LastHandshake is the last time a handshake succeeded with this peer. (Or
// we got key confirmation via the first data message, which is
// approximately the same thing.)
//
// The time.Time zero value means that no handshake has succeeded, at least
// since this peer was last known to WireGuard. (Tailscale removes peers
// from the wireguard peer that are idle.)
LastHandshake time.Time
}
// PeerStatus describes a peer node and its current state.

View File

@@ -110,6 +110,7 @@ var handler = map[string]localAPIHandler{
"serve-config": (*Handler).serveServeConfig,
"set-dns": (*Handler).serveSetDNS,
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"set-gui-visible": (*Handler).serveSetGUIVisible,
"tailfs/fileserver-address": (*Handler).serveTailFSFileServerAddr,
"tailfs/shares": (*Handler).serveShares,
"start": (*Handler).serveStart,
@@ -598,6 +599,8 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
break
}
h.b.DebugNotify(n)
case "notify-last-netmap":
h.b.DebugNotifyLastNetMap()
case "break-tcp-conns":
err = h.b.DebugBreakTCPConns()
case "break-derp-conns":
@@ -1904,6 +1907,27 @@ func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) {
w.Write(j)
}
func (h *Handler) serveSetGUIVisible(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
type setGUIVisibleRequest struct {
IsVisible bool // whether the Tailscale client UI is now presented to the user
SessionID string // the last SessionID sent to the client in ipn.Notify.SessionID
}
var req setGUIVisibleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
// TODO(bradfitz): use `req.IsVisible == true` to flush netmap
w.WriteHeader(http.StatusOK)
}
func (h *Handler) serveTKASign(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "lock sign access denied", http.StatusForbidden)
@@ -2549,9 +2573,14 @@ func (h *Handler) serveTailFSFileServerAddr(w http.ResponseWriter, r *http.Reque
}
// serveShares handles the management of tailfs shares.
//
// PUT - adds or updates an existing share
// DELETE - removes a share
// GET - gets a list of all shares, sorted by name
// POST - renames an existing share
func (h *Handler) serveShares(w http.ResponseWriter, r *http.Request) {
if !h.b.TailFSSharingEnabled() {
http.Error(w, `tailfs sharing not enabled, please add the attribute "tailfs:share" to this node in your ACLs' "nodeAttrs" section`, http.StatusInternalServerError)
http.Error(w, `tailfs sharing not enabled, please add the attribute "tailfs:share" to this node in your ACLs' "nodeAttrs" section`, http.StatusForbidden)
return
}
switch r.Method {
@@ -2581,20 +2610,23 @@ func (h *Handler) serveShares(w http.ResponseWriter, r *http.Request) {
}
share.As = username
}
err = h.b.TailFSAddShare(&share)
err = h.b.TailFSSetShare(&share)
if err != nil {
if errors.Is(err, ipnlocal.ErrInvalidShareName) {
http.Error(w, "invalid share name", http.StatusBadRequest)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
case "DELETE":
var share tailfs.Share
err := json.NewDecoder(r.Body).Decode(&share)
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = h.b.TailFSRemoveShare(share.Name)
err = h.b.TailFSRemoveShare(string(b))
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "share not found", http.StatusNotFound)
@@ -2604,13 +2636,34 @@ func (h *Handler) serveShares(w http.ResponseWriter, r *http.Request) {
return
}
w.WriteHeader(http.StatusNoContent)
case "GET":
shares, err := h.b.TailFSGetShares()
case "POST":
var names [2]string
err := json.NewDecoder(r.Body).Decode(&names)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = h.b.TailFSRenameShare(names[0], names[1])
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "share not found", http.StatusNotFound)
return
}
if os.IsExist(err) {
http.Error(w, "share name already used", http.StatusBadRequest)
return
}
if errors.Is(err, ipnlocal.ErrInvalidShareName) {
http.Error(w, "invalid share name", http.StatusBadRequest)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(shares)
w.WriteHeader(http.StatusNoContent)
case "GET":
shares := h.b.TailFSGetShares()
err := json.NewEncoder(w).Encode(shares)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View File

@@ -14,6 +14,7 @@ import (
"path/filepath"
"reflect"
"runtime"
"slices"
"strings"
"tailscale.com/atomicfile"
@@ -21,6 +22,7 @@ import (
"tailscale.com/net/netaddr"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
@@ -222,6 +224,10 @@ type Prefs struct {
// Linux-only.
NetfilterKind string
// TailFSShares are the configured TailFSShares, stored in increasing order
// by name.
TailFSShares []*tailfs.Share
// The Persist field is named 'Config' in the file for backward
// compatibility with earlier versions.
// TODO(apenwarr): We should move this out of here, it's not a pref.
@@ -293,6 +299,7 @@ type MaskedPrefs struct {
AppConnectorSet bool `json:",omitempty"`
PostureCheckingSet bool `json:",omitempty"`
NetfilterKindSet bool `json:",omitempty"`
TailFSSharesSet bool `json:",omitempty"`
}
type AutoUpdatePrefsMask struct {
@@ -556,6 +563,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.AutoUpdate.Equals(p2.AutoUpdate) &&
p.AppConnector == p2.AppConnector &&
p.PostureChecking == p2.PostureChecking &&
slices.EqualFunc(p.TailFSShares, p2.TailFSShares, tailfs.SharesEqual) &&
p.NetfilterKind == p2.NetfilterKind
}

View File

@@ -62,6 +62,7 @@ func TestPrefsEqual(t *testing.T) {
"AppConnector",
"PostureChecking",
"NetfilterKind",
"TailFSShares",
"Persist",
}
if have := fieldsOf(reflect.TypeFor[Prefs]()); !reflect.DeepEqual(have, prefsHandles) {

View File

@@ -9,11 +9,13 @@ import (
"net"
"net/netip"
"net/url"
"slices"
"strconv"
"strings"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
)
// ServeConfigKey returns a StateKey that stores the
@@ -234,6 +236,129 @@ func (sc *ServeConfig) IsServingHTTP(port uint16) bool {
return sc.TCP[port].HTTP
}
// FindConfig finds a config that contains the given port, which can be
// the top level background config or an inner foreground one.
// The second result is true if it's foreground.
func (sc *ServeConfig) FindConfig(port uint16) (*ServeConfig, bool) {
if sc == nil {
return nil, false
}
if _, ok := sc.TCP[port]; ok {
return sc, false
}
for _, sc := range sc.Foreground {
if _, ok := sc.TCP[port]; ok {
return sc, true
}
}
return nil, false
}
// SetWebHandler sets the given HTTPHandler at the specified host, port,
// and mount in the serve config. sc.TCP is also updated to reflect web
// serving usage of the given port.
func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uint16, mount string, useTLS bool) {
if sc == nil {
sc = new(ServeConfig)
}
mak.Set(&sc.TCP, port, &TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
hp := HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(WebServerConfig))
}
mak.Set(&sc.Web[hp].Handlers, mount, handler)
// TODO(tylersmalley): handle multiple web handlers from foreground mode
for k, v := range sc.Web[hp].Handlers {
if v == handler {
continue
}
// If the new mount point ends in / and another mount point
// shares the same prefix, remove the other handler.
// (e.g. /foo/ overwrites /foo)
// The opposite example is also handled.
m1 := strings.TrimSuffix(mount, "/")
m2 := strings.TrimSuffix(k, "/")
if m1 == m2 {
delete(sc.Web[hp].Handlers, k)
}
}
}
// SetTCPForwarding sets the fwdAddr (IP:port form) to which to forward
// connections from the given port. If terminateTLS is true, TLS connections
// are terminated with only the given host name permitted before passing them
// to the fwdAddr.
func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTLS bool, host string) {
if sc == nil {
sc = new(ServeConfig)
}
mak.Set(&sc.TCP, port, &TCPPortHandler{TCPForward: fwdAddr})
if terminateTLS {
sc.TCP[port].TerminateTLS = host
}
}
// SetFunnel sets the sc.AllowFunnel value for the given host and port.
func (sc *ServeConfig) SetFunnel(host string, port uint16, setOn bool) {
if sc == nil {
sc = new(ServeConfig)
}
hp := HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
// TODO(tylersmalley): should ensure there is no other conflicting funnel
// TODO(tylersmalley): add error handling for if toggling for existing sc
if setOn {
mak.Set(&sc.AllowFunnel, hp, true)
} else if _, exists := sc.AllowFunnel[hp]; exists {
delete(sc.AllowFunnel, hp)
// Clear map mostly for testing.
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
}
}
// RemoveWebHandler deletes the web handlers at all of the given mount points
// for the provided host and port in the serve config. If cleanupFunnel is
// true, this also removes the funnel value for this port if no handlers remain.
func (sc *ServeConfig) RemoveWebHandler(host string, port uint16, mounts []string, cleanupFunnel bool) {
hp := HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
// Delete existing handler, then cascade delete if empty.
for _, m := range mounts {
delete(sc.Web[hp].Handlers, m)
}
if len(sc.Web[hp].Handlers) == 0 {
delete(sc.Web, hp)
delete(sc.TCP, port)
if cleanupFunnel {
delete(sc.AllowFunnel, hp) // disable funnel if no mounts remain for the port
}
}
// Clear empty maps, mostly for testing.
if len(sc.Web) == 0 {
sc.Web = nil
}
if len(sc.TCP) == 0 {
sc.TCP = nil
}
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
}
// RemoveTCPForwarding deletes the TCP forwarding configuration for the given
// port from the serve config.
func (sc *ServeConfig) RemoveTCPForwarding(port uint16) {
delete(sc.TCP, port)
if len(sc.TCP) == 0 {
sc.TCP = nil
}
}
// IsFunnelOn reports whether if ServeConfig is currently allowing funnel
// traffic for any host:port.
//
@@ -257,19 +382,28 @@ func (sc *ServeConfig) IsFunnelOn() bool {
// CheckFunnelAccess checks whether Funnel access is allowed for the given node
// and port.
// It checks:
// 1. HTTPS is enabled on the Tailnet
// 1. HTTPS is enabled on the tailnet
// 2. the node has the "funnel" nodeAttr
// 3. the port is allowed for Funnel
//
// The node arg should be the ipnstate.Status.Self node.
func CheckFunnelAccess(port uint16, node *ipnstate.PeerStatus) error {
if err := NodeCanFunnel(node); err != nil {
return err
}
return CheckFunnelPort(port, node)
}
// NodeCanFunnel returns an error if the given node is not configured to allow
// for Tailscale Funnel usage.
func NodeCanFunnel(node *ipnstate.PeerStatus) error {
if !node.HasCap(tailcfg.CapabilityHTTPS) {
return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.")
}
if !node.HasCap(tailcfg.NodeAttrFunnel) {
return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/s/no-funnel.")
}
return CheckFunnelPort(port, node)
return nil
}
// CheckFunnelPort checks whether the given port is allowed for Funnel.
@@ -355,6 +489,60 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error {
return deny(portsStr)
}
// ExpandProxyTargetValue expands the supported target values to be proxied
// allowing for input values to be a port number, a partial URL, or a full URL
// including a path.
//
// examples:
// - 3000
// - localhost:3000
// - tcp://localhost:3000
// - http://localhost:3000
// - https://localhost:3000
// - https-insecure://localhost:3000
// - https-insecure://localhost:3000/foo
func ExpandProxyTargetValue(target string, supportedSchemes []string, defaultScheme string) (string, error) {
const host = "127.0.0.1"
// support target being a port number
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil
}
// prepend scheme if not present
if !strings.Contains(target, "://") {
target = defaultScheme + "://" + target
}
// make sure we can parse the target
u, err := url.ParseRequestURI(target)
if err != nil {
return "", fmt.Errorf("invalid URL %w", err)
}
// ensure a supported scheme
if !slices.Contains(supportedSchemes, u.Scheme) {
return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
}
// validate the host.
switch u.Hostname() {
case "localhost", "127.0.0.1":
default:
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
}
// validate the port
port, err := strconv.ParseUint(u.Port(), 10, 16)
if err != nil || port == 0 {
return "", fmt.Errorf("invalid port %q", u.Port())
}
u.Host = fmt.Sprintf("%s:%d", host, port)
return u.String(), nil
}
// RangeOverTCPs ranges over both background and foreground TCPs.
// If the returned bool from the given f is false, then this function stops
// iterating immediately and does not check other foreground configs.

View File

@@ -126,3 +126,60 @@ func TestHasPathHandler(t *testing.T) {
})
}
}
func TestExpandProxyTargetDev(t *testing.T) {
tests := []struct {
name string
input string
defaultScheme string
supportedSchemes []string
expected string
wantErr bool
}{
{name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"},
{name: "hostname+port", input: "localhost:8080", expected: "http://127.0.0.1:8080"},
{name: "convert-localhost", input: "http://localhost:8080", expected: "http://127.0.0.1:8080"},
{name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
{name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
{name: "https-scheme", input: "https://localhost:8080", expected: "https://127.0.0.1:8080"},
{name: "https+insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"},
{name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://127.0.0.1:8080"},
{name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://127.0.0.1:8080"},
// errors
{name: "invalid-port", input: "localhost:9999999", wantErr: true},
{name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true},
{name: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true},
{name: "empty-input", input: "", expected: "", wantErr: true},
}
for _, tt := range tests {
defaultScheme := "http"
supportedSchemes := []string{"http", "https", "https+insecure"}
if tt.supportedSchemes != nil {
supportedSchemes = tt.supportedSchemes
}
if tt.defaultScheme != "" {
defaultScheme = tt.defaultScheme
}
t.Run(tt.name, func(t *testing.T) {
actual, err := ExpandProxyTargetValue(tt.input, supportedSchemes, defaultScheme)
if tt.wantErr == true && err == nil {
t.Errorf("Expected an error but got none")
return
}
if tt.wantErr == false && err != nil {
t.Errorf("Got an error, but didn't expect one: %v", err)
return
}
if actual != tt.expected {
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
}
})
}
}

View File

@@ -72,6 +72,7 @@ func CurrentProfileKey(userID string) StateKey {
}
// StateStore persists state, and produces it back on request.
// Implementations of StateStore are expected to be safe for concurrent use.
type StateStore interface {
// ReadState returns the bytes associated with ID. Returns (nil,
// ErrStateNotExist) if the ID doesn't have associated state.

1644
k8s-operator/api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,25 +9,26 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [eliasnaur.com/font/roboto](https://pkg.go.dev/eliasnaur.com/font/roboto) ([BSD-3-Clause](https://git.sr.ht/~eliasnaur/font/tree/832bb8fc08c3/LICENSE))
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.0.0/LICENSE))
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.1.0/LICENSE))
- [gioui.org](https://pkg.go.dev/gioui.org) ([MIT](https://git.sr.ht/~eliasnaur/gio/tree/32c6a9b10d0b/LICENSE))
- [gioui.org/cpu](https://pkg.go.dev/gioui.org/cpu) ([MIT](https://git.sr.ht/~eliasnaur/gio-cpu/tree/8d6a761490d2/LICENSE))
- [gioui.org/shader](https://pkg.go.dev/gioui.org/shader) ([MIT](https://git.sr.ht/~eliasnaur/gio-shader/tree/v1.0.6/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.21.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.18.42/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.13.40/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.13.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.1.41/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.4.35/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.3.43/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.21.0/internal/sync/singleflight/LICENSE))
- [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.9.35/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.38.0/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.14.1/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.17.1/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.22.0/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.14.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.14.2/internal/sync/singleflight/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/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/benoitkugler/textlayout](https://pkg.go.dev/github.com/benoitkugler/textlayout) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/LICENSE))
- [github.com/benoitkugler/textlayout/fonts](https://pkg.go.dev/github.com/benoitkugler/textlayout/fonts) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/fonts/LICENSE))
- [github.com/benoitkugler/textlayout/graphite](https://pkg.go.dev/github.com/benoitkugler/textlayout/graphite) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/graphite/LICENSE))
@@ -41,15 +42,15 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [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/9aa6fdf5a28c/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.3.1/LICENSE))
- [github.com/gorilla/csrf](https://pkg.go.dev/github.com/gorilla/csrf) ([BSD-3-Clause](https://github.com/gorilla/csrf/blob/v1.7.1/LICENSE))
- [github.com/gorilla/securecookie](https://pkg.go.dev/github.com/gorilla/securecookie) ([BSD-3-Clause](https://github.com/gorilla/securecookie/blob/v1.1.1/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.1.0/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.5.0/LICENSE))
- [github.com/gorilla/csrf](https://pkg.go.dev/github.com/gorilla/csrf) ([BSD-3-Clause](https://github.com/gorilla/csrf/blob/v1.7.2/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](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/65c27093e38a/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/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))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.3.5/LICENSE.md))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.4.0/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.4/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.4/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.4/zstd/internal/xxhash/LICENSE.txt))
@@ -58,40 +59,39 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
- [github.com/mdlayher/sdnotify](https://pkg.go.dev/github.com/mdlayher/sdnotify) ([MIT](https://github.com/mdlayher/sdnotify/blob/v1.0.0/LICENSE.md))
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.5.0/LICENSE.md))
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.56/LICENSE))
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.58/LICENSE))
- [github.com/mitchellh/go-ps](https://pkg.go.dev/github.com/mitchellh/go-ps) ([MIT](https://github.com/mitchellh/go-ps/blob/v1.0.0/LICENSE.md))
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.18/LICENSE))
- [github.com/pkg/errors](https://pkg.go.dev/github.com/pkg/errors) ([BSD-2-Clause](https://github.com/pkg/errors/blob/v0.9.1/LICENSE))
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.21/LICENSE))
- [github.com/safchain/ethtool](https://pkg.go.dev/github.com/safchain/ethtool) ([Apache-2.0](https://github.com/safchain/ethtool/blob/v0.3.0/LICENSE))
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/7ce1f622c780/LICENSE))
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/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/cabfb018fe85/LICENSE))
- [github.com/tailscale/peercred](https://pkg.go.dev/github.com/tailscale/peercred) ([BSD-3-Clause](https://github.com/tailscale/peercred/blob/b535050b2aa4/LICENSE))
- [github.com/tailscale/tailscale-android](https://pkg.go.dev/github.com/tailscale/tailscale-android) ([BSD-3-Clause](https://github.com/tailscale/tailscale-android/blob/HEAD/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/5ca22df9e6e7/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/5db17b287bf1/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/cc193a0b3272/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/3e8cd9d6bf63/LICENSE))
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/a3c409a6018e/LICENSE))
- [github.com/vishvananda/netlink/nl](https://pkg.go.dev/github.com/vishvananda/netlink/nl) ([Apache-2.0](https://github.com/vishvananda/netlink/blob/v1.2.1-beta.2/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/intern](https://pkg.go.dev/go4.org/intern) ([BSD-3-Clause](https://github.com/go4org/intern/blob/ae77deb06f29/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/6213f710f925/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/+/08396bb9:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/92128663:LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.18.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/1b970713:LICENSE))
- [golang.org/x/exp/shiny](https://pkg.go.dev/golang.org/x/exp/shiny) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/334a2380:shiny/LICENSE))
- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.12.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.18.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.5.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.15.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.15.0:LICENSE))
- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.15.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.20.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.6.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.16.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.16.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.14.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.3.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/4fe30062272c/LICENSE))
- [inet.af/netaddr](https://pkg.go.dev/inet.af/netaddr) ([BSD-3-Clause](https://github.com/inetaf/netaddr/blob/097006376321/LICENSE))
- [inet.af/peercred](https://pkg.go.dev/inet.af/peercred) ([BSD-3-Clause](https://github.com/inetaf/peercred/blob/0893ea02156a/LICENSE))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([MIT](https://github.com/nhooyr/websocket/blob/v1.8.7/LICENSE.txt))
- [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.5.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/c9c1d4f9b186/LICENSE))
- [inet.af/netaddr](https://pkg.go.dev/inet.af/netaddr) ([BSD-3-Clause](Unknown))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([ISC](https://github.com/nhooyr/websocket/blob/v1.8.10/LICENSE.txt))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))

View File

@@ -31,6 +31,7 @@ See also the dependencies in the [Tailscale CLI][].
- [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/v0.7.0/LICENSE))
- [github.com/coreos/go-systemd/v22/dbus](https://pkg.go.dev/github.com/coreos/go-systemd/v22/dbus) ([Apache-2.0](https://github.com/coreos/go-systemd/blob/v22.5.0/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.5.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))
@@ -40,12 +41,13 @@ See also the dependencies in the [Tailscale CLI][].
- [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](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/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))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.4.0/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.4/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.4/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.4/zstd/internal/xxhash/LICENSE.txt))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.6/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.6/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.6/zstd/internal/xxhash/LICENSE.txt))
- [github.com/kortschak/wol](https://pkg.go.dev/github.com/kortschak/wol) ([BSD-3-Clause](https://github.com/kortschak/wol/blob/da482cc4850a/LICENSE))
- [github.com/mdlayher/genetlink](https://pkg.go.dev/github.com/mdlayher/genetlink) ([MIT](https://github.com/mdlayher/genetlink/blob/v1.3.2/LICENSE.md))
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
@@ -59,7 +61,9 @@ See also the dependencies in the [Tailscale CLI][].
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/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/cabfb018fe85/LICENSE))
- [github.com/tailscale/peercred](https://pkg.go.dev/github.com/tailscale/peercred) ([BSD-3-Clause](https://github.com/tailscale/peercred/blob/b535050b2aa4/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/cc193a0b3272/LICENSE))
- [github.com/tailscale/xnet/webdav](https://pkg.go.dev/github.com/tailscale/xnet/webdav) ([BSD-3-Clause](https://github.com/tailscale/xnet/blob/62b9a7c569f9/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/a3c409a6018e/LICENSE))
- [github.com/vishvananda/netlink/nl](https://pkg.go.dev/github.com/vishvananda/netlink/nl) ([Apache-2.0](https://github.com/vishvananda/netlink/blob/v1.2.1-beta.2/LICENSE))
@@ -70,13 +74,12 @@ See also the dependencies in the [Tailscale CLI][].
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.18.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/1b970713:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.20.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.6.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.6.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.16.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.16.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.14.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.5.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/c9c1d4f9b186/LICENSE))
- [inet.af/peercred](https://pkg.go.dev/inet.af/peercred) ([BSD-3-Clause](https://github.com/inetaf/peercred/blob/0893ea02156a/LICENSE))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([ISC](https://github.com/nhooyr/websocket/blob/v1.8.10/LICENSE.txt))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))
@@ -86,3 +89,5 @@ See also the dependencies in the [Tailscale CLI][].
- [Sparkle](https://sparkle-project.org/) ([MIT](https://github.com/sparkle-project/Sparkle/blob/2.x/LICENSE))
- [wireguard-apple](https://git.zx2c4.com/wireguard-apple) ([MIT](https://git.zx2c4.com/wireguard-apple/tree/COPYING))
- [apple-oss-distributions/configd](https://github.com/apple-oss-distributions/configd) ([APSL](https://github.com/apple-oss-distributions/configd/blob/main/APPLE_LICENSE))
- [WebDAV-Swift](https://github.com/skjiisa/WebDAV-Swift) ([BSD](https://github.com/skjiisa/WebDAV-Swift#BSD-2-Clause-1-ov-file)

View File

@@ -38,6 +38,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/creack/pty](https://pkg.go.dev/github.com/creack/pty) ([MIT](https://github.com/creack/pty/blob/v1.1.21/LICENSE))
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/a09d6be7affa/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.5.0/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))
@@ -50,6 +51,7 @@ Some packages may only be included on certain architectures or operating systems
- [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](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/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))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.4.0/LICENSE.md))
@@ -77,8 +79,11 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/7ce1f622c780/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/cabfb018fe85/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/5ca22df9e6e7/LICENSE))
- [github.com/tailscale/peercred](https://pkg.go.dev/github.com/tailscale/peercred) ([BSD-3-Clause](https://github.com/tailscale/peercred/blob/b535050b2aa4/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/5db17b287bf1/LICENSE))
- [github.com/tailscale/wf](https://pkg.go.dev/github.com/tailscale/wf) ([BSD-3-Clause](https://github.com/tailscale/wf/blob/6fbb0a674ee6/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/cc193a0b3272/LICENSE))
- [github.com/tailscale/xnet/webdav](https://pkg.go.dev/github.com/tailscale/xnet/webdav) ([BSD-3-Clause](https://github.com/tailscale/xnet/blob/62b9a7c569f9/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/toqueteos/webbrowser](https://pkg.go.dev/github.com/toqueteos/webbrowser) ([MIT](https://github.com/toqueteos/webbrowser/blob/v1.2.0/LICENSE.md))
- [github.com/u-root/u-root/pkg/termios](https://pkg.go.dev/github.com/u-root/u-root/pkg/termios) ([BSD-3-Clause](https://github.com/u-root/u-root/blob/v0.12.0/LICENSE))
@@ -92,7 +97,7 @@ Some packages may only be included on certain architectures or operating systems
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/1b970713:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.20.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.16.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.6.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.6.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.16.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.16.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.14.0:LICENSE))
@@ -100,8 +105,6 @@ Some packages may only be included on certain architectures or operating systems
- [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/c9c1d4f9b186/LICENSE))
- [inet.af/peercred](https://pkg.go.dev/inet.af/peercred) ([BSD-3-Clause](https://github.com/inetaf/peercred/blob/0893ea02156a/LICENSE))
- [inet.af/wf](https://pkg.go.dev/inet.af/wf) ([BSD-3-Clause](https://github.com/inetaf/wf/blob/36129f591884/LICENSE))
- [k8s.io/client-go/util/homedir](https://pkg.go.dev/k8s.io/client-go/util/homedir) ([Apache-2.0](https://github.com/kubernetes/client-go/blob/v0.29.1/LICENSE))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([ISC](https://github.com/nhooyr/websocket/blob/v1.8.10/LICENSE.txt))
- [sigs.k8s.io/yaml](https://pkg.go.dev/sigs.k8s.io/yaml) ([Apache-2.0](https://github.com/kubernetes-sigs/yaml/blob/v1.4.0/LICENSE))

View File

@@ -31,6 +31,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [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/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/v0.7.0/LICENSE))
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/a09d6be7affa/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.5.0/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))
@@ -38,12 +39,13 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.5.0/LICENSE))
- [github.com/gregjones/httpcache](https://pkg.go.dev/github.com/gregjones/httpcache) ([MIT](https://github.com/gregjones/httpcache/blob/901d90724c79/LICENSE.txt))
- [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/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))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.4.0/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.4/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.4/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.4/zstd/internal/xxhash/LICENSE.txt))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.6/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.6/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.6/zstd/internal/xxhash/LICENSE.txt))
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.5.0/LICENSE.md))
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.58/LICENSE))
@@ -55,6 +57,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/walk](https://pkg.go.dev/github.com/tailscale/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/6a278000867c/LICENSE))
- [github.com/tailscale/win](https://pkg.go.dev/github.com/tailscale/win) ([BSD-3-Clause](https://github.com/tailscale/win/blob/d2e5cdeed6dc/LICENSE))
- [github.com/tailscale/xnet/webdav](https://pkg.go.dev/github.com/tailscale/xnet/webdav) ([BSD-3-Clause](https://github.com/tailscale/xnet/blob/62b9a7c569f9/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/netlink/nl](https://pkg.go.dev/github.com/vishvananda/netlink/nl) ([Apache-2.0](https://github.com/vishvananda/netlink/blob/v1.2.1-beta.2/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))
@@ -66,7 +69,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [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.15.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.14.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.20.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.6.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.6.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.16.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.16.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.14.0:LICENSE))

View File

@@ -14,6 +14,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
@@ -38,6 +39,9 @@ type windowsManager struct {
guid string
nrptDB *nrptRuleDatabase
wslManager *wslManager
mu sync.Mutex
closing bool
}
func NewOSConfigurator(logf logger.Logf, interfaceName string) (OSConfigurator, error) {
@@ -64,14 +68,37 @@ func NewOSConfigurator(logf logger.Logf, interfaceName string) (OSConfigurator,
}
func (m *windowsManager) openInterfaceKey(pfx winutil.RegistryPathPrefix) (registry.Key, error) {
var key registry.Key
var err error
path := pfx.WithSuffix(m.guid)
key, err := winutil.OpenKeyWait(registry.LOCAL_MACHINE, path, registry.SET_VALUE)
m.mu.Lock()
closing := m.closing
m.mu.Unlock()
if closing {
// Do not wait for the interface key to appear if the manager is being closed.
// If it's being closed due to the removal of the wintun adapter,
// the key would already be gone by now and will not reappear until tailscaled is restarted.
key, err = registry.OpenKey(registry.LOCAL_MACHINE, string(path), registry.SET_VALUE)
} else {
key, err = winutil.OpenKeyWait(registry.LOCAL_MACHINE, path, registry.SET_VALUE)
}
if err != nil {
return 0, fmt.Errorf("opening %s: %w", path, err)
}
return key, nil
}
func (m *windowsManager) muteKeyNotFoundIfClosing(err error) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.closing || (!errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND)) {
return err
}
return nil
}
func delValue(key registry.Key, name string) error {
if err := key.DeleteValue(name); err != nil && err != registry.ErrNotExist {
return err
@@ -205,7 +232,7 @@ func (m *windowsManager) setPrimaryDNS(resolvers []netip.Addr, domains []dnsname
key4, err := m.openInterfaceKey(winutil.IPv4TCPIPInterfacePrefix)
if err != nil {
return err
return m.muteKeyNotFoundIfClosing(err)
}
defer key4.Close()
@@ -227,7 +254,7 @@ func (m *windowsManager) setPrimaryDNS(resolvers []netip.Addr, domains []dnsname
key6, err := m.openInterfaceKey(winutil.IPv6TCPIPInterfacePrefix)
if err != nil {
return err
return m.muteKeyNotFoundIfClosing(err)
}
defer key6.Close()
@@ -387,6 +414,14 @@ func (m *windowsManager) SupportsSplitDNS() bool {
}
func (m *windowsManager) Close() error {
m.mu.Lock()
if m.closing {
m.mu.Unlock()
return nil
}
m.closing = true
m.mu.Unlock()
err := m.SetDNS(OSConfig{})
if m.nrptDB != nil {
m.nrptDB.Close()
@@ -407,7 +442,7 @@ func (m *windowsManager) disableDynamicUpdates() error {
for _, prefix := range prefixen {
k, err := m.openInterfaceKey(prefix)
if err != nil {
return err
return m.muteKeyNotFoundIfClosing(err)
}
defer k.Close()
@@ -426,7 +461,7 @@ func (m *windowsManager) disableDynamicUpdates() error {
func (m *windowsManager) setSingleDWORD(prefix winutil.RegistryPathPrefix, value string, data uint32) error {
k, err := m.openInterfaceKey(prefix)
if err != nil {
return err
return m.muteKeyNotFoundIfClosing(err)
}
defer k.Close()
return k.SetDWordValue(value, data)

View File

@@ -5,6 +5,7 @@ package interfaces
import (
"errors"
"io"
"net/netip"
"os/exec"
"testing"
@@ -69,6 +70,7 @@ func likelyHomeRouterIPDarwinExec() (ret netip.Addr, netif string, ok bool) {
return
}
defer cmd.Wait()
defer io.Copy(io.Discard, stdout) // clear the pipe to prevent hangs
var f []mem.RO
lineread.Reader(stdout, func(lineb []byte) error {

View File

@@ -153,7 +153,7 @@ func CheckIPForwarding(routes []netip.Prefix, state *interfaces.State) (warn, er
// This function returns an error if it is unable to determine whether reverse
// path filtering is enabled, or a warning describing configuration issues if
// reverse path fitering is non-functional or partly functional.
func CheckReversePathFiltering(routes []netip.Prefix, state *interfaces.State) (warn []string, err error) {
func CheckReversePathFiltering(state *interfaces.State) (warn []string, err error) {
if runtime.GOOS != "linux" {
return nil, nil
}
@@ -166,12 +166,6 @@ func CheckReversePathFiltering(routes []netip.Prefix, state *interfaces.State) (
}
}
// Reverse path filtering as a syscall is only implemented on Linux for IPv4.
wantV4, _ := protocolsRequiredForForwarding(routes, state)
if !wantV4 {
return nil, nil
}
// The kernel uses the maximum value for rp_filter between the 'all'
// setting and each per-interface config, so we need to fetch both.
allSetting, err := reversePathFilterValueLinux("all")
@@ -205,7 +199,7 @@ func CheckReversePathFiltering(routes []netip.Prefix, state *interfaces.State) (
iSetting = allSetting
}
if iSetting == filtStrict {
warn = append(warn, fmt.Sprintf("Interface %q has strict reverse-path filtering enabled", iface.Name))
warn = append(warn, fmt.Sprintf("interface %q has strict reverse-path filtering enabled", iface.Name))
}
}
return warn, nil

View File

@@ -6,7 +6,6 @@ package netutil
import (
"io"
"net"
"net/netip"
"runtime"
"testing"
)
@@ -71,9 +70,7 @@ func TestCheckReversePathFiltering(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skipf("skipping on %s", runtime.GOOS)
}
warn, err := CheckReversePathFiltering([]netip.Prefix{
netip.MustParsePrefix("192.168.1.1/24"),
}, nil)
warn, err := CheckReversePathFiltering(nil)
t.Logf("err: %v", err)
t.Logf("warnings: %v", warn)
}

View File

@@ -160,8 +160,8 @@ type Wrapper struct {
// PreFilterPacketInboundFromWireGuard is the inbound filter function that runs before the main filter
// and therefore sees the packets that may be later dropped by it.
PreFilterPacketInboundFromWireGuard FilterFunc
// PostFilterPacketInboundFromWireGaurd is the inbound filter function that runs after the main filter.
PostFilterPacketInboundFromWireGaurd FilterFunc
// PostFilterPacketInboundFromWireGuard is the inbound filter function that runs after the main filter.
PostFilterPacketInboundFromWireGuard FilterFunc
// PreFilterPacketOutboundToWireGuardNetstackIntercept is a filter function that runs before the main filter
// for packets from the local system. This filter is populated by netstack to hook
// packets that should be handled by netstack. If set, this filter runs before
@@ -203,7 +203,7 @@ type Wrapper struct {
type tunInjectedRead struct {
// Only one of packet or data should be set, and are read in that order of
// precedence.
packet stack.PacketBufferPtr
packet *stack.PacketBuffer
data []byte
}
@@ -1047,8 +1047,8 @@ func (t *Wrapper) filterPacketInboundFromWireGuard(p *packet.Parsed, captHook ca
return filter.Drop
}
if t.PostFilterPacketInboundFromWireGaurd != nil {
if res := t.PostFilterPacketInboundFromWireGaurd(p, t); res.IsDrop() {
if t.PostFilterPacketInboundFromWireGuard != nil {
if res := t.PostFilterPacketInboundFromWireGuard(p, t); res.IsDrop() {
return res
}
}
@@ -1113,7 +1113,7 @@ func (t *Wrapper) SetFilter(filt *filter.Filter) {
//
// This path is typically used to deliver synthesized packets to the
// host networking stack.
func (t *Wrapper) InjectInboundPacketBuffer(pkt stack.PacketBufferPtr) error {
func (t *Wrapper) InjectInboundPacketBuffer(pkt *stack.PacketBuffer) error {
buf := make([]byte, PacketStartOffset+pkt.Size())
n := copy(buf[PacketStartOffset:], pkt.NetworkHeader().Slice())
@@ -1221,7 +1221,7 @@ func (t *Wrapper) InjectOutbound(pkt []byte) error {
// InjectOutboundPacketBuffer logically behaves as InjectOutbound. It takes ownership of one
// reference count on the packet, and the packet may be mutated. The packet refcount will be
// decremented after the injected buffer has been read.
func (t *Wrapper) InjectOutboundPacketBuffer(pkt stack.PacketBufferPtr) error {
func (t *Wrapper) InjectOutboundPacketBuffer(pkt *stack.PacketBuffer) error {
size := pkt.Size()
if size > MaxPacketSize {
pkt.DecRef()

View File

@@ -5,12 +5,14 @@ package prober
import (
"bytes"
"cmp"
"context"
crand "crypto/rand"
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"net"
"net/http"
"strconv"
@@ -21,6 +23,7 @@ import (
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/stun"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
@@ -35,10 +38,15 @@ type derpProber struct {
meshInterval time.Duration
tlsInterval time.Duration
// Optional bandwidth probing.
bwInterval time.Duration
bwProbeSize int64
// Probe functions that can be overridden for testing.
tlsProbeFn func(string) ProbeFunc
udpProbeFn func(string, int) ProbeFunc
meshProbeFn func(string, string) ProbeFunc
bwProbeFn func(string, string, int64) ProbeFunc
sync.Mutex
lastDERPMap *tailcfg.DERPMap
@@ -47,20 +55,57 @@ type derpProber struct {
probes map[string]*Probe
}
type DERPOpt func(*derpProber)
// WithBandwidthProbing enables bandwidth probing. When enabled, a payload of
// `size` bytes will be regularly transferred through each DERP server, and each
// pair of DERP servers in every region.
func WithBandwidthProbing(interval time.Duration, size int64) DERPOpt {
return func(d *derpProber) {
d.bwInterval = interval
d.bwProbeSize = size
}
}
// WithMeshProbing enables mesh probing. When enabled, a small message will be
// transferred through each DERP server and each pair of DERP servers.
func WithMeshProbing(interval time.Duration) DERPOpt {
return func(d *derpProber) {
d.meshInterval = interval
}
}
// WithSTUNProbing enables STUN/UDP probing, with a STUN request being sent
// to each DERP server every `interval`.
func WithSTUNProbing(interval time.Duration) DERPOpt {
return func(d *derpProber) {
d.udpInterval = interval
}
}
// WithTLSProbing enables TLS probing that will check TLS certificate on port
// 443 of each DERP server every `interval`.
func WithTLSProbing(interval time.Duration) DERPOpt {
return func(d *derpProber) {
d.tlsInterval = interval
}
}
// DERP creates a new derpProber.
func DERP(p *Prober, derpMapURL string, udpInterval, meshInterval, tlsInterval time.Duration) (*derpProber, error) {
func DERP(p *Prober, derpMapURL string, opts ...DERPOpt) (*derpProber, error) {
d := &derpProber{
p: p,
derpMapURL: derpMapURL,
udpInterval: udpInterval,
meshInterval: meshInterval,
tlsInterval: tlsInterval,
tlsProbeFn: TLS,
nodes: make(map[string]*tailcfg.DERPNode),
probes: make(map[string]*Probe),
p: p,
derpMapURL: derpMapURL,
tlsProbeFn: TLS,
nodes: make(map[string]*tailcfg.DERPNode),
probes: make(map[string]*Probe),
}
for _, o := range opts {
o(d)
}
d.udpProbeFn = d.ProbeUDP
d.meshProbeFn = d.probeMesh
d.bwProbeFn = d.probeBandwidth
return d, nil
}
@@ -84,42 +129,59 @@ func (d *derpProber) ProbeMap(ctx context.Context) error {
"hostname": server.HostName,
}
n := fmt.Sprintf("derp/%s/%s/tls", region.RegionCode, server.Name)
wantProbes[n] = true
if d.probes[n] == nil {
log.Printf("adding DERP TLS probe for %s (%s)", server.Name, region.RegionName)
derpPort := 443
if server.DERPPort != 0 {
derpPort = server.DERPPort
}
d.probes[n] = d.p.Run(n, d.tlsInterval, labels, d.tlsProbeFn(fmt.Sprintf("%s:%d", server.HostName, derpPort)))
}
for idx, ipStr := range []string{server.IPv6, server.IPv4} {
n = fmt.Sprintf("derp/%s/%s/udp", region.RegionCode, server.Name)
if idx == 0 {
n = n + "6"
}
if ipStr == "" || server.STUNPort == -1 {
continue
}
if d.tlsInterval > 0 {
n := fmt.Sprintf("derp/%s/%s/tls", region.RegionCode, server.Name)
wantProbes[n] = true
if d.probes[n] == nil {
log.Printf("adding DERP UDP probe for %s (%s)", server.Name, n)
d.probes[n] = d.p.Run(n, d.udpInterval, labels, d.udpProbeFn(ipStr, server.STUNPort))
log.Printf("adding DERP TLS probe for %s (%s) every %v", server.Name, region.RegionName, d.tlsInterval)
derpPort := cmp.Or(server.DERPPort, 443)
d.probes[n] = d.p.Run(n, d.tlsInterval, labels, d.tlsProbeFn(fmt.Sprintf("%s:%d", server.HostName, derpPort)))
}
}
if d.udpInterval > 0 {
for idx, ipStr := range []string{server.IPv6, server.IPv4} {
n := fmt.Sprintf("derp/%s/%s/udp", region.RegionCode, server.Name)
if idx == 0 {
n += "6"
}
if ipStr == "" || server.STUNPort == -1 {
continue
}
wantProbes[n] = true
if d.probes[n] == nil {
log.Printf("adding DERP UDP probe for %s (%s) every %v", server.Name, n, d.udpInterval)
d.probes[n] = d.p.Run(n, d.udpInterval, labels, d.udpProbeFn(ipStr, server.STUNPort))
}
}
}
for _, to := range region.Nodes {
n = fmt.Sprintf("derp/%s/%s/%s/mesh", region.RegionCode, server.Name, to.Name)
wantProbes[n] = true
if d.probes[n] == nil {
log.Printf("adding DERP mesh probe for %s->%s (%s)", server.Name, to.Name, region.RegionName)
d.probes[n] = d.p.Run(n, d.meshInterval, labels, d.meshProbeFn(server.HostName, to.HostName))
if d.meshInterval > 0 {
n := fmt.Sprintf("derp/%s/%s/%s/mesh", region.RegionCode, server.Name, to.Name)
wantProbes[n] = true
if d.probes[n] == nil {
log.Printf("adding DERP mesh probe for %s->%s (%s) every %v", server.Name, to.Name, region.RegionName, d.meshInterval)
d.probes[n] = d.p.Run(n, d.meshInterval, labels, d.meshProbeFn(server.Name, to.Name))
}
}
if d.bwInterval > 0 && d.bwProbeSize > 0 {
bwLabels := maps.Clone(labels)
bwLabels["probe_size_bytes"] = fmt.Sprintf("%d", d.bwProbeSize)
if server.Name == to.Name {
bwLabels["derp_path"] = "single"
} else {
bwLabels["derp_path"] = "mesh"
}
n := fmt.Sprintf("derp/%s/%s/%s/bw", region.RegionCode, server.Name, to.Name)
wantProbes[n] = true
if d.probes[n] == nil {
log.Printf("adding DERP bandwidth probe for %s->%s (%s) %v bytes every %v", server.Name, to.Name, region.RegionName, d.bwProbeSize, d.bwInterval)
d.probes[n] = d.p.Run(n, d.bwInterval, bwLabels, d.bwProbeFn(server.Name, to.Name, d.bwProbeSize))
}
}
}
}
@@ -136,28 +198,52 @@ func (d *derpProber) ProbeMap(ctx context.Context) error {
return nil
}
// probeMesh returs a probe func that sends a test packet through a pair of DERP
// servers (or just one server, if 'from' and 'to' are the same). 'from' and 'to'
// are expected to be names (DERPNode.Name) of two DERP servers in the same region.
func (d *derpProber) probeMesh(from, to string) ProbeFunc {
return func(ctx context.Context) error {
d.Lock()
dm := d.lastDERPMap
fromN, ok := d.nodes[from]
if !ok {
d.Unlock()
return fmt.Errorf("could not find derp node %s", from)
fromN, toN, err := d.getNodePair(from, to)
if err != nil {
return err
}
toN, ok := d.nodes[to]
if !ok {
d.Unlock()
return fmt.Errorf("could not find derp node %s", to)
}
d.Unlock()
// TODO: instead of ignoring latency, export it as a separate metric.
_, err := derpProbeNodePair(ctx, dm, fromN, toN)
return err
dm := d.lastDERPMap
return derpProbeNodePair(ctx, dm, fromN, toN)
}
}
// probeBandwidth returs a probe func that sends a payload of a given size
// through a pair of DERP servers (or just one server, if 'from' and 'to' are
// the same). 'from' and 'to' are expected to be names (DERPNode.Name) of two
// DERP servers in the same region.
func (d *derpProber) probeBandwidth(from, to string, size int64) ProbeFunc {
return func(ctx context.Context) error {
fromN, toN, err := d.getNodePair(from, to)
if err != nil {
return err
}
return derpProbeBandwidth(ctx, d.lastDERPMap, fromN, toN, size)
}
}
// getNodePair returns DERPNode objects for two DERP servers based on their
// short names.
func (d *derpProber) getNodePair(n1, n2 string) (ret1, ret2 *tailcfg.DERPNode, _ error) {
d.Lock()
defer d.Unlock()
ret1, ok := d.nodes[n1]
if !ok {
return nil, nil, fmt.Errorf("could not find derp node %s", n1)
}
ret2, ok = d.nodes[n2]
if !ok {
return nil, nil, fmt.Errorf("could not find derp node %s", n2)
}
return ret1, ret2, nil
}
// updateMap refreshes the locally-cached DERP map.
func (d *derpProber) updateMap(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", d.derpMapURL, nil)
if err != nil {
@@ -191,13 +277,13 @@ func (d *derpProber) updateMap(ctx context.Context) error {
d.nodes = make(map[string]*tailcfg.DERPNode)
for _, reg := range d.lastDERPMap.Regions {
for _, n := range reg.Nodes {
if existing, ok := d.nodes[n.HostName]; ok {
if existing, ok := d.nodes[n.Name]; ok {
return fmt.Errorf("derpmap has duplicate nodes: %+v and %+v", existing, n)
}
// Allow the prober to monitor nodes marked as
// STUN only in the default map
n.STUNOnly = false
d.nodes[n.HostName] = n
d.nodes[n.Name] = n
}
}
return nil
@@ -205,15 +291,14 @@ func (d *derpProber) updateMap(ctx context.Context) error {
func (d *derpProber) ProbeUDP(ipaddr string, port int) ProbeFunc {
return func(ctx context.Context) error {
_, err := derpProbeUDP(ctx, ipaddr, port)
return err
return derpProbeUDP(ctx, ipaddr, port)
}
}
func derpProbeUDP(ctx context.Context, ipStr string, port int) (latency time.Duration, err error) {
func derpProbeUDP(ctx context.Context, ipStr string, port int) error {
pc, err := net.ListenPacket("udp", ":0")
if err != nil {
return 0, err
return err
}
defer pc.Close()
uc := pc.(*net.UDPConn)
@@ -228,7 +313,7 @@ func derpProbeUDP(ctx context.Context, ipStr string, port int) (latency time.Dur
ip := net.ParseIP(ipStr)
_, err := uc.WriteToUDP(req, &net.UDPAddr{IP: ip, Port: port})
if err != nil {
return 0, err
return err
}
// Binding requests and responses are fairly small (~40 bytes),
// but in practice a STUN response can be up to the size of the
@@ -240,38 +325,39 @@ func derpProbeUDP(ctx context.Context, ipStr string, port int) (latency time.Dur
d := time.Since(t0)
if err != nil {
if ctx.Err() != nil {
return 0, fmt.Errorf("timeout reading from %v: %v", ip, err)
return fmt.Errorf("timeout reading from %v: %v", ip, err)
}
if d < time.Second {
return 0, fmt.Errorf("error reading from %v: %v", ip, err)
return fmt.Errorf("error reading from %v: %v", ip, err)
}
time.Sleep(100 * time.Millisecond)
continue
}
txBack, _, err := stun.ParseResponse(buf[:n])
if err != nil {
return 0, fmt.Errorf("parsing STUN response from %v: %v", ip, err)
return fmt.Errorf("parsing STUN response from %v: %v", ip, err)
}
if txBack != tx {
return 0, fmt.Errorf("read wrong tx back from %v", ip)
}
if latency == 0 || d < latency {
latency = d
return fmt.Errorf("read wrong tx back from %v", ip)
}
break
}
return latency, nil
return nil
}
func derpProbeNodePair(ctx context.Context, dm *tailcfg.DERPMap, from, to *tailcfg.DERPNode) (latency time.Duration, err error) {
fromc, err := newConn(ctx, dm, from)
// derpProbeBandwidth sends a payload of a given size between two local
// DERP clients connected to two DERP servers.
func derpProbeBandwidth(ctx context.Context, dm *tailcfg.DERPMap, from, to *tailcfg.DERPNode, size int64) (err error) {
// This probe uses clients with isProber=false to avoid spamming the derper logs with every packet
// sent by the bandwidth probe.
fromc, err := newConn(ctx, dm, from, false)
if err != nil {
return 0, err
return err
}
defer fromc.Close()
toc, err := newConn(ctx, dm, to)
toc, err := newConn(ctx, dm, to, false)
if err != nil {
return 0, err
return err
}
defer toc.Close()
@@ -282,74 +368,147 @@ func derpProbeNodePair(ctx context.Context, dm *tailcfg.DERPMap, from, to *tailc
time.Sleep(100 * time.Millisecond) // pretty arbitrary
}
latency, err = runDerpProbeNodePair(ctx, from, to, fromc, toc)
if err != nil {
if err := runDerpProbeNodePair(ctx, from, to, fromc, toc, size); err != nil {
// Record pubkeys on failed probes to aid investigation.
err = fmt.Errorf("%s -> %s: %w",
return fmt.Errorf("%s -> %s: %w",
fromc.SelfPublicKey().ShortString(),
toc.SelfPublicKey().ShortString(), err)
}
return latency, err
return nil
}
func runDerpProbeNodePair(ctx context.Context, from, to *tailcfg.DERPNode, fromc, toc *derphttp.Client) (latency time.Duration, err error) {
// Make a random packet
pkt := make([]byte, 8)
crand.Read(pkt)
// derpProbeNodePair sends a small packet between two local DERP clients
// connected to two DERP servers.
func derpProbeNodePair(ctx context.Context, dm *tailcfg.DERPMap, from, to *tailcfg.DERPNode) (err error) {
fromc, err := newConn(ctx, dm, from, true)
if err != nil {
return err
}
defer fromc.Close()
toc, err := newConn(ctx, dm, to, true)
if err != nil {
return err
}
defer toc.Close()
t0 := time.Now()
// Send the random packet.
sendc := make(chan error, 1)
go func() {
sendc <- fromc.Send(toc.SelfPublicKey(), pkt)
}()
select {
case <-ctx.Done():
return 0, fmt.Errorf("timeout sending via %q: %w", from.Name, ctx.Err())
case err := <-sendc:
if err != nil {
return 0, fmt.Errorf("error sending via %q: %w", from.Name, err)
}
// Wait a bit for from's node to hear about to existing on the
// other node in the region, in the case where the two nodes
// are different.
if from.Name != to.Name {
time.Sleep(100 * time.Millisecond) // pretty arbitrary
}
// Receive the random packet.
recvc := make(chan any, 1) // either derp.ReceivedPacket or error
const meshProbePacketSize = 8
if err := runDerpProbeNodePair(ctx, from, to, fromc, toc, meshProbePacketSize); err != nil {
// Record pubkeys on failed probes to aid investigation.
return fmt.Errorf("%s -> %s: %w",
fromc.SelfPublicKey().ShortString(),
toc.SelfPublicKey().ShortString(), err)
}
return nil
}
// probePackets stores a pregenerated slice of probe packets keyed by their total size.
var probePackets syncs.Map[int64, [][]byte]
// packetsForSize returns a slice of packet payloads with a given total size.
func packetsForSize(size int64) [][]byte {
// For a small payload, create a unique random packet.
if size <= derp.MaxPacketSize {
pkt := make([]byte, size)
crand.Read(pkt)
return [][]byte{pkt}
}
// For a large payload, create a bunch of packets once and re-use them
// across probes.
pkts, _ := probePackets.LoadOrInit(size, func() [][]byte {
const packetSize = derp.MaxPacketSize
var pkts [][]byte
for remaining := size; remaining > 0; remaining -= packetSize {
pkt := make([]byte, min(remaining, packetSize))
crand.Read(pkt)
pkts = append(pkts, pkt)
}
return pkts
})
return pkts
}
// runDerpProbeNodePair takes two DERP clients (fromc and toc) connected to two
// DERP servers (from and to) and sends a test payload of a given size from one
// to another.
func runDerpProbeNodePair(ctx context.Context, from, to *tailcfg.DERPNode, fromc, toc *derphttp.Client, size int64) error {
// To avoid derper dropping enqueued packets, limit the number of packets in flight.
// The value here is slightly smaller than perClientSendQueueDepth in derp_server.go
inFlight := syncs.NewSemaphore(30)
pkts := packetsForSize(size)
// Send the packets.
sendc := make(chan error, 1)
go func() {
for idx, pkt := range pkts {
inFlight.AcquireContext(ctx)
if err := fromc.Send(toc.SelfPublicKey(), pkt); err != nil {
sendc <- fmt.Errorf("sending packet %d: %w", idx, err)
return
}
}
}()
// Receive the packets.
recvc := make(chan error, 1)
go func() {
defer close(recvc) // to break out of 'select' below.
idx := 0
for {
m, err := toc.Recv()
if err != nil {
recvc <- err
recvc <- fmt.Errorf("after %d data packets: %w", idx, err)
return
}
switch v := m.(type) {
case derp.ReceivedPacket:
recvc <- v
inFlight.Release()
if v.Source != fromc.SelfPublicKey() {
recvc <- fmt.Errorf("got data packet %d from unexpected source, %v", idx, v.Source)
return
}
if got, want := v.Data, pkts[idx]; !bytes.Equal(got, want) {
recvc <- fmt.Errorf("unexpected data packet %d (out of %d)", idx, len(pkts))
return
}
idx += 1
if idx == len(pkts) {
return
}
case derp.KeepAliveMessage:
// Silently ignore.
default:
log.Printf("%v: ignoring Recv frame type %T", to.Name, v)
// Loop.
}
}
}()
select {
case <-ctx.Done():
return 0, fmt.Errorf("timeout receiving from %q: %w", to.Name, ctx.Err())
case v := <-recvc:
if err, ok := v.(error); ok {
return 0, fmt.Errorf("error receiving from %q: %w", to.Name, err)
return fmt.Errorf("timeout: %w", ctx.Err())
case err := <-sendc:
if err != nil {
return fmt.Errorf("error sending via %q: %w", from.Name, err)
}
p := v.(derp.ReceivedPacket)
if p.Source != fromc.SelfPublicKey() {
return 0, fmt.Errorf("got data packet from unexpected source, %v", p.Source)
}
if !bytes.Equal(p.Data, pkt) {
return 0, fmt.Errorf("unexpected data packet %q", p.Data)
case err := <-recvc:
if err != nil {
return fmt.Errorf("error receiving from %q: %w", to.Name, err)
}
}
return time.Since(t0), nil
return nil
}
func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode) (*derphttp.Client, error) {
func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode, isProber bool) (*derphttp.Client, error) {
// To avoid spamming the log with regular connection messages.
l := logger.Filtered(log.Printf, func(s string) bool {
return !strings.Contains(s, "derphttp.Client.Connect: connecting to")
@@ -364,7 +523,7 @@ func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode) (*de
Nodes: []*tailcfg.DERPNode{n},
}
})
dc.IsProber = true
dc.IsProber = isProber
err := dc.Connect(ctx)
if err != nil {
return nil, err

View File

@@ -5,12 +5,19 @@ package prober
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func TestDerpProber(t *testing.T) {
@@ -50,18 +57,21 @@ func TestDerpProber(t *testing.T) {
clk := newFakeTime()
p := newForTest(clk.Now, clk.NewTicker)
dp := &derpProber{
p: p,
derpMapURL: srv.URL,
tlsProbeFn: func(_ string) ProbeFunc { return func(context.Context) error { return nil } },
udpProbeFn: func(_ string, _ int) ProbeFunc { return func(context.Context) error { return nil } },
meshProbeFn: func(_, _ string) ProbeFunc { return func(context.Context) error { return nil } },
nodes: make(map[string]*tailcfg.DERPNode),
probes: make(map[string]*Probe),
p: p,
derpMapURL: srv.URL,
tlsInterval: time.Second,
tlsProbeFn: func(_ string) ProbeFunc { return func(context.Context) error { return nil } },
udpInterval: time.Second,
udpProbeFn: func(_ string, _ int) ProbeFunc { return func(context.Context) error { return nil } },
meshInterval: time.Second,
meshProbeFn: func(_, _ string) ProbeFunc { return func(context.Context) error { return nil } },
nodes: make(map[string]*tailcfg.DERPNode),
probes: make(map[string]*Probe),
}
if err := dp.ProbeMap(context.Background()); err != nil {
t.Errorf("unexpected ProbeMap() error: %s", err)
}
if len(dp.nodes) != 2 || dp.nodes["derpn1.tailscale.test"] == nil || dp.nodes["derpn2.tailscale.test"] == nil {
if len(dp.nodes) != 2 || dp.nodes["n1"] == nil || dp.nodes["n2"] == nil {
t.Errorf("unexpected nodes: %+v", dp.nodes)
}
// Probes expected for two nodes:
@@ -103,3 +113,99 @@ func TestDerpProber(t *testing.T) {
t.Errorf("unexpected probes: %+v", dp.probes)
}
}
func TestRunDerpProbeNodePair(t *testing.T) {
// os.Setenv("DERP_DEBUG_LOGS", "true")
serverPrivateKey := key.NewNode()
s := derp.NewServer(serverPrivateKey, t.Logf)
defer s.Close()
httpsrv := &http.Server{
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
Handler: derphttp.Handler(s),
}
ln, err := net.Listen("tcp4", "localhost:0")
if err != nil {
t.Fatal(err)
}
serverURL := "http://" + ln.Addr().String()
t.Logf("server URL: %s", serverURL)
go func() {
if err := httpsrv.Serve(ln); err != nil {
if err == http.ErrServerClosed {
return
}
panic(err)
}
}()
newClient := func() *derphttp.Client {
c, err := derphttp.NewClient(key.NewNode(), serverURL, t.Logf)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
m, err := c.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
switch m.(type) {
case derp.ServerInfoMessage:
default:
t.Fatalf("unexpected first message type %T", m)
}
return c
}
c1 := newClient()
defer c1.Close()
c2 := newClient()
defer c2.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err = runDerpProbeNodePair(ctx, &tailcfg.DERPNode{Name: "c1"}, &tailcfg.DERPNode{Name: "c2"}, c1, c2, 100_000_000)
if err != nil {
t.Error(err)
}
}
func Test_packetsForSize(t *testing.T) {
tests := []struct {
name string
size int
wantPackets int
wantUnique bool
}{
{"small_unqiue", 8, 1, true},
{"8k_unique", 8192, 1, true},
{"full_size_packet", derp.MaxPacketSize, 1, true},
{"larger_than_one", derp.MaxPacketSize + 1, 2, false},
{"large", 500000, 8, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hashes := make(map[string]int)
for i := 0; i < 5; i++ {
pkts := packetsForSize(int64(tt.size))
if len(pkts) != tt.wantPackets {
t.Errorf("packetsForSize(%d) got %d packets, want %d", tt.size, len(pkts), tt.wantPackets)
}
var total int
hash := sha256.New()
for _, p := range pkts {
hash.Write(p)
total += len(p)
}
hashes[string(hash.Sum(nil))]++
if total != tt.size {
t.Errorf("packetsForSize(%d) returned %d bytes total", tt.size, total)
}
}
unique := len(hashes) > 1
if unique != tt.wantUnique {
t.Errorf("packetsForSize(%d) is unique=%v (returned %d different answers); want unique=%v", tt.size, unique, len(hashes), unique)
}
})
}
}

View File

@@ -93,6 +93,12 @@ func (p *Prober) Run(name string, interval time.Duration, labels map[string]stri
mEndTime: prometheus.NewDesc("end_secs", "Latest probe end time (seconds since epoch)", nil, l),
mLatency: prometheus.NewDesc("latency_millis", "Latest probe latency (ms)", nil, l),
mResult: prometheus.NewDesc("result", "Latest probe result (1 = success, 0 = failure)", nil, l),
mAttempts: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "attempts_total", Help: "Total number of probing attempts", ConstLabels: l,
}, []string{"status"}),
mSeconds: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "seconds_total", Help: "Total amount of time spent executing the probe", ConstLabels: l,
}, []string{"status"}),
}
prometheus.WrapRegistererWithPrefix(p.namespace+"_", p.metrics).MustRegister(probe.metrics)
@@ -185,6 +191,8 @@ type Probe struct {
mEndTime *prometheus.Desc
mLatency *prometheus.Desc
mResult *prometheus.Desc
mAttempts *prometheus.CounterVec
mSeconds *prometheus.CounterVec
mu sync.Mutex
start time.Time // last time doProbe started
@@ -282,10 +290,15 @@ func (p *Probe) recordEnd(start time.Time, err error) {
p.end = end
p.succeeded = err == nil
p.lastErr = err
latency := end.Sub(p.start)
if p.succeeded {
p.latency = end.Sub(p.start)
p.latency = latency
p.mAttempts.WithLabelValues("ok").Inc()
p.mSeconds.WithLabelValues("ok").Add(latency.Seconds())
} else {
p.latency = 0
p.mAttempts.WithLabelValues("fail").Inc()
p.mSeconds.WithLabelValues("fail").Add(latency.Seconds())
}
}
@@ -334,6 +347,8 @@ func (p *Probe) Describe(ch chan<- *prometheus.Desc) {
ch <- p.mEndTime
ch <- p.mResult
ch <- p.mLatency
p.mAttempts.Describe(ch)
p.mSeconds.Describe(ch)
}
// Collect implements prometheus.Collector.
@@ -356,6 +371,8 @@ func (p *Probe) Collect(ch chan<- prometheus.Metric) {
if p.latency > 0 {
ch <- prometheus.MustNewConstMetric(p.mLatency, prometheus.GaugeValue, float64(p.latency.Milliseconds()))
}
p.mAttempts.Collect(ch)
p.mSeconds.Collect(ch)
}
// ticker wraps a time.Ticker in a way that can be faked for tests.

View File

@@ -9,6 +9,7 @@ import (
"bytes"
"compress/gzip"
"embed"
"errors"
"fmt"
"io"
"io/fs"
@@ -42,7 +43,8 @@ func (t *target) Build(b *dist.Build) ([]string, error) {
}
func (t *target) buildSPK(b *dist.Build, inner *innerPkg) ([]string, error) {
filename := fmt.Sprintf("tailscale-%s-%s-%d-dsm%d.spk", t.filenameArch, b.Version.Short, b.Version.Synology[t.dsmMajorVersion], t.dsmMajorVersion)
synoVersion := b.Version.Synology[t.dsmMajorVersion]
filename := fmt.Sprintf("tailscale-%s-%s-%d-dsm%d.spk", t.filenameArch, b.Version.Short, synoVersion, t.dsmMajorVersion)
out := filepath.Join(b.Out, filename)
if t.packageCenter {
log.Printf("Building %s (for package center)", filename)
@@ -50,6 +52,14 @@ func (t *target) buildSPK(b *dist.Build, inner *innerPkg) ([]string, error) {
log.Printf("Building %s (for sideloading)", filename)
}
if synoVersion > 2147483647 {
// Synology requires that version number is within int32 range.
// Erroring here if we create a build with a higher version.
// In this case, we'll want to adjust the VersionInfo.Synology logic in
// the mkversion package.
return nil, errors.New("syno version exceeds int32 range")
}
privFile := fmt.Sprintf("privilege-dsm%d", t.dsmMajorVersion)
if t.packageCenter && t.dsmMajorVersion == 7 {
privFile += ".for-package-center"

View File

@@ -528,6 +528,7 @@ main() {
set -x
$SUDO apk add tailscale
$SUDO rc-update add tailscale
$SUDO rc-service tailscale start
set +x
;;
xbps)

View File

@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-1g50+BwoUCwc/tBmnP2KO6e3GwL8QQ/wJ+XoxCzzk3k=
# nix-direnv cache busting line: sha256-jyRjT/CQBlmjHzilxJvMuzZQlGyJB4X/yISgWjBVDxc=

View File

@@ -92,6 +92,10 @@ type DERPRegion struct {
// "San Francisco", "Singapore", "Frankfurt", etc.
RegionName string
// Latitude, Longitude are optional geographical coordinates of the DERP region's city, in degrees.
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

View File

@@ -128,7 +128,8 @@ type CapabilityVersion int
// - 85: 2024-01-05: Client understands MaxKeyDuration
// - 86: 2024-01-23: Client understands NodeAttrProbeUDPLifetime
// - 87: 2024-02-11: UserProfile.Groups removed (added in 66)
const CurrentCapabilityVersion CapabilityVersion = 87
// - 88: 2024-03-05: Client understands NodeAttrSuggestExitNode
const CurrentCapabilityVersion CapabilityVersion = 88
type StableID string
@@ -680,6 +681,11 @@ type Location struct {
// IATA, ICAO or ISO 3166-2 codes are recommended ("YSE")
CityCode string `json:",omitempty"`
// Latitude, Longitude are optional geographical coordinates of the node, in degrees.
// No particular accuracy level is promised; the coordinates may simply be the center of the city or country.
Latitude float64 `json:",omitempty"`
Longitude float64 `json:",omitempty"`
// Priority determines the order of use of an exit node when a
// location based preference matches more than one exit node,
// the node with the highest priority wins. Nodes of equal
@@ -2209,6 +2215,10 @@ const (
// NodeAttrsTailFSAccess enables accessing shares via TailFS.
NodeAttrsTailFSAccess NodeCapability = "tailfs:access"
// NodeAttrSuggestExitNode is applied to each exit node which the control plane has determined
// is a recommended exit node.
NodeAttrSuggestExitNode NodeCapability = "suggest-exit-node"
)
// SetDNSRequest is a request to add a DNS record.

View File

@@ -405,6 +405,8 @@ var _DERPRegionCloneNeedsRegeneration = DERPRegion(struct {
RegionID int
RegionCode string
RegionName string
Latitude float64
Longitude float64
Avoid bool
Nodes []*DERPNode
}{})
@@ -575,6 +577,8 @@ var _LocationCloneNeedsRegeneration = Location(struct {
CountryCode string
City string
CityCode string
Latitude float64
Longitude float64
Priority int
}{})

View File

@@ -918,6 +918,8 @@ func (v *DERPRegionView) UnmarshalJSON(b []byte) error {
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) Nodes() views.SliceView[*DERPNode, DERPNodeView] {
return views.SliceOfViews[*DERPNode, DERPNodeView](v.ж.Nodes)
@@ -928,6 +930,8 @@ var _DERPRegionViewNeedsRegeneration = DERPRegion(struct {
RegionID int
RegionCode string
RegionName string
Latitude float64
Longitude float64
Avoid bool
Nodes []*DERPNode
}{})
@@ -1374,6 +1378,8 @@ func (v LocationView) Country() string { return v.ж.Country }
func (v LocationView) CountryCode() string { return v.ж.CountryCode }
func (v LocationView) City() string { return v.ж.City }
func (v LocationView) CityCode() string { return v.ж.CityCode }
func (v LocationView) Latitude() float64 { return v.ж.Latitude }
func (v LocationView) Longitude() float64 { return v.ж.Longitude }
func (v LocationView) Priority() int { return v.ж.Priority }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
@@ -1382,6 +1388,8 @@ var _LocationViewNeedsRegeneration = Location(struct {
CountryCode string
City string
CityCode string
Latitude float64
Longitude float64
Priority int
}{})

View File

@@ -3,8 +3,12 @@
package tailfs
//go:generate go run tailscale.com/cmd/viewer --type=Share --clonefunc
import (
"bytes"
"net/http"
"strings"
)
var (
@@ -21,16 +25,57 @@ func AllowShareAs() bool {
// Share configures a folder to be shared through TailFS.
type Share struct {
// Name is how this share appears on remote nodes.
Name string `json:"name"`
Name string `json:"name,omitempty"`
// Path is the path to the directory on this machine that's being shared.
Path string `json:"path"`
Path string `json:"path,omitempty"`
// As is the UNIX or Windows username of the local account used for this
// share. File read/write permissions are enforced based on this username.
// Can be left blank to use the default value of "whoever is running the
// Tailscale GUI".
As string `json:"who"`
As string `json:"who,omitempty"`
// BookmarkData contains security-scoped bookmark data for the Sandboxed
// Mac application. The Sandboxed Mac application gains permission to
// access the Share's folder as a result of a user selecting it in a file
// picker. In order to retain access to it across restarts, it needs to
// hold on to a security-scoped bookmark. That bookmark is stored here. See
// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox#4144043
BookmarkData []byte `json:"bookmarkData,omitempty"`
}
func ShareViewsEqual(a, b ShareView) bool {
if !a.Valid() && !b.Valid() {
return true
}
if !a.Valid() || !b.Valid() {
return false
}
return a.Name() == b.Name() && a.Path() == b.Path() && a.As() == b.As() && a.BookmarkData().Equal(b.ж.BookmarkData)
}
func SharesEqual(a, b *Share) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.Name == b.Name && a.Path == b.Path && a.As == b.As && bytes.Equal(a.BookmarkData, b.BookmarkData)
}
func CompareShares(a, b *Share) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
return strings.Compare(a.Name, b.Name)
}
// FileSystemForRemote is the TailFS filesystem exposed to remote nodes. It
@@ -48,7 +93,7 @@ type FileSystemForRemote interface {
// AllowShareAs() reports true, we will use one subprocess per user to
// access the filesystem (see userServer). Otherwise, we will use the file
// server configured via SetFileServerAddr.
SetShares(shares map[string]*Share)
SetShares(shares []*Share)
// ServeHTTPWithPerms behaves like the similar method from http.Handler but
// also accepts a Permissions map that captures the permissions of the

44
tailfs/tailfs_clone.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
package tailfs
// Clone makes a deep copy of Share.
// The result aliases no memory with the original.
func (src *Share) Clone() *Share {
if src == nil {
return nil
}
dst := new(Share)
*dst = *src
dst.BookmarkData = append(src.BookmarkData[:0:0], src.BookmarkData...)
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ShareCloneNeedsRegeneration = Share(struct {
Name string
Path string
As string
BookmarkData []byte
}{})
// Clone duplicates src into dst and reports whether it succeeded.
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
// where T is one of Share.
func Clone(dst, src any) bool {
switch src := src.(type) {
case *Share:
switch dst := dst.(type) {
case *Share:
*dst = *src.Clone()
return true
case **Share:
*dst = src.Clone()
return true
}
}
return false
}

75
tailfs/tailfs_view.go Normal file
View File

@@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
package tailfs
import (
"encoding/json"
"errors"
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=Share
// View returns a readonly view of Share.
func (p *Share) View() ShareView {
return ShareView{ж: p}
}
// ShareView provides a read-only view over Share.
//
// Its methods should only be called if `Valid()` returns true.
type ShareView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *Share
}
// Valid reports whether underlying value is non-nil.
func (v ShareView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v ShareView) AsStruct() *Share {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v ShareView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *ShareView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x Share
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v ShareView) Name() string { return v.ж.Name }
func (v ShareView) Path() string { return v.ж.Path }
func (v ShareView) As() string { return v.ж.As }
func (v ShareView) BookmarkData() views.ByteSlice[[]byte] {
return views.ByteSliceOf(v.ж.BookmarkData)
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ShareViewNeedsRegeneration = Share(struct {
Name string
Path string
As string
BookmarkData []byte
}{})

View File

@@ -5,6 +5,7 @@ package tailfsimpl
import (
"bufio"
"context"
"encoding/hex"
"fmt"
"log"
@@ -15,6 +16,8 @@ import (
"net/url"
"os"
"os/exec"
"os/user"
"slices"
"strings"
"sync"
"time"
@@ -50,7 +53,7 @@ type FileSystemForRemote struct {
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
fileServerAddr string
shares map[string]*tailfs.Share
shares []*tailfs.Share
children map[string]*compositedav.Child
userServers map[string]*userServer
}
@@ -62,16 +65,26 @@ func (s *FileSystemForRemote) SetFileServerAddr(addr string) {
s.mu.Unlock()
}
// SetShares implements tailfs.FileSystemForRemote.
func (s *FileSystemForRemote) SetShares(shares map[string]*tailfs.Share) {
// SetShares implements tailfs.FileSystemForRemote. Shares must be sorted
// according to tailfs.CompareShares.
func (s *FileSystemForRemote) SetShares(shares []*tailfs.Share) {
userServers := make(map[string]*userServer)
if tailfs.AllowShareAs() {
// set up per-user server
// Set up per-user server by running the current executable as an
// unprivileged user in order to avoid privilege escalation.
executable, err := os.Executable()
if err != nil {
s.logf("can't find executable: %v", err)
return
}
for _, share := range shares {
p, found := userServers[share.As]
if !found {
p = &userServer{
logf: s.logf,
logf: s.logf,
username: share.As,
executable: executable,
}
userServers[share.As] = p
}
@@ -120,7 +133,13 @@ func (s *FileSystemForRemote) buildChild(share *tailfs.Share) *compositedav.Chil
shareName := string(shareNameBytes)
s.mu.RLock()
share, shareFound := s.shares[shareName]
var share *tailfs.Share
i, shareFound := slices.BinarySearchFunc(s.shares, shareName, func(s *tailfs.Share, name string) int {
return strings.Compare(s.Name, name)
})
if shareFound {
share = s.shares[i]
}
userServers := s.userServers
fileServerAddr := s.fileServerAddr
s.mu.RUnlock()
@@ -227,8 +246,10 @@ func (s *FileSystemForRemote) Close() error {
// given Shares. All Shares are assumed to have the same Share.As, and the
// content is served as that Share.As user.
type userServer struct {
logf logger.Logf
shares []*tailfs.Share
logf logger.Logf
shares []*tailfs.Share
username string
executable string
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
@@ -251,11 +272,6 @@ func (s *userServer) Close() error {
}
func (s *userServer) runLoop() {
executable, err := os.Executable()
if err != nil {
s.logf("can't find executable: %v", err)
return
}
maxSleepTime := 30 * time.Second
consecutiveFailures := float64(0)
var timeOfLastFailure time.Time
@@ -267,7 +283,7 @@ func (s *userServer) runLoop() {
return
}
err := s.run(executable)
err := s.run()
now := time.Now()
timeSinceLastFailure := now.Sub(timeOfLastFailure)
timeOfLastFailure = now
@@ -280,22 +296,37 @@ func (s *userServer) runLoop() {
if sleepTime > maxSleepTime {
sleepTime = maxSleepTime
}
s.logf("user server % v stopped with error %v, will try again in %v", executable, err, sleepTime)
s.logf("user server % v stopped with error %v, will try again in %v", s.executable, err, sleepTime)
time.Sleep(sleepTime)
}
}
// Run runs the executable (tailscaled). This function only works on UNIX systems,
// but those are the only ones on which we use userServers anyway.
func (s *userServer) run(executable string) error {
// Run runs the user server using the configured executable. This function only
// works on UNIX systems, but those are the only ones on which we use
// userServers anyway.
func (s *userServer) run() error {
// set up the command
args := []string{"serve-tailfs"}
for _, s := range s.shares {
args = append(args, s.Name, s.Path)
}
allArgs := []string{"-u", s.shares[0].As, executable}
allArgs = append(allArgs, args...)
cmd := exec.Command("sudo", allArgs...)
var cmd *exec.Cmd
if s.canSudo() {
s.logf("starting TailFS file server as user %q", s.username)
allArgs := []string{"-n", "-u", s.username, s.executable}
allArgs = append(allArgs, args...)
cmd = exec.Command("sudo", allArgs...)
} else {
// If we were root, we should have been able to sudo as a specific
// user, but let's check just to make sure, since we never want to
// access shared folders as root.
err := s.assertNotRoot()
if err != nil {
return err
}
s.logf("starting TailFS file server as ourselves")
cmd = exec.Command(s.executable, args...)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
@@ -350,3 +381,32 @@ var writeMethods = map[string]bool{
"MOVE": true,
"PROPPATCH": true,
}
// canSudo checks wether we can sudo -u the configured executable as the
// configured user by attempting to call the executable with the '-h' flag to
// print help.
func (s *userServer) canSudo() bool {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "sudo", "-n", "-u", s.username, s.executable, "-h").Run(); err != nil {
return false
}
return true
}
// assertNotRoot returns an error if the current user has UID 0 or if we cannot
// determine the current user.
//
// On Linux, root users will always have UID 0.
//
// On BSD, root users should always have UID 0.
func (s *userServer) assertNotRoot() error {
u, err := user.Current()
if err != nil {
return fmt.Errorf("assertNotRoot failed to find current user: %s", err)
}
if u.Uid == "0" {
return fmt.Errorf("%q is root", u.Name)
}
return nil
}

View File

@@ -5,6 +5,7 @@ package tailfsimpl
import (
"fmt"
"io"
"io/fs"
"log"
"net"
@@ -12,6 +13,7 @@ import (
"os"
"path"
"path/filepath"
"slices"
"sync"
"testing"
"time"
@@ -142,7 +144,7 @@ func newSystem(t *testing.T) *system {
}
}()
client := gowebdav.NewClient(fmt.Sprintf("http://%s", l.Addr()), "", "")
client := gowebdav.NewAuthClient(fmt.Sprintf("http://%s", l.Addr()), &noopAuthorizer{})
client.SetTransport(&http.Transport{DisableKeepAlives: true})
s := &system{
t: t,
@@ -205,13 +207,14 @@ func (s *system) addShare(remoteName, shareName string, permission tailfs.Permis
r.shares[shareName] = f
r.permissions[shareName] = permission
shares := make(map[string]*tailfs.Share, len(r.shares))
shares := make([]*tailfs.Share, 0, len(r.shares))
for shareName, folder := range r.shares {
shares[shareName] = &tailfs.Share{
shares = append(shares, &tailfs.Share{
Name: shareName,
Path: folder,
}
})
}
slices.SortFunc(shares, tailfs.CompareShares)
r.fs.SetShares(shares)
r.fileServer.SetShares(r.shares)
}
@@ -375,3 +378,33 @@ func fileInfoToStatic(fi fs.FileInfo, fixupMode bool) fs.FileInfo {
func pathTo(remote, share, name string) string {
return path.Join(domain, remote, share, name)
}
// noopAuthorizer implements gowebdav.Authorizer. It does no actual
// authorizing. We use it in place of gowebdav's built-in authorizer in order
// to avoid a race condition in that authorizer.
type noopAuthorizer struct{}
func (a *noopAuthorizer) NewAuthenticator(body io.Reader) (gowebdav.Authenticator, io.Reader) {
return &noopAuthenticator{}, nil
}
func (a *noopAuthorizer) AddAuthenticator(key string, fn gowebdav.AuthFactory) {
}
type noopAuthenticator struct{}
func (a *noopAuthenticator) Authorize(c *http.Client, rq *http.Request, path string) error {
return nil
}
func (a *noopAuthenticator) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
return false, nil
}
func (a *noopAuthenticator) Clone() gowebdav.Authenticator {
return &noopAuthenticator{}
}
func (a *noopAuthenticator) Close() error {
return nil
}

View File

@@ -9,6 +9,7 @@
package tools
import (
_ "fybrik.io/crdoc"
_ "github.com/tailscale/mkctr"
_ "honnef.co/go/tools/cmd/staticcheck"
_ "sigs.k8s.io/controller-tools/cmd/controller-gen"

View File

@@ -25,6 +25,12 @@ import (
// opaque string. The current implementation uses a UUID.
type RequestID string
// String returns the string format of the request ID, for use in e.g. setting
// a [http.Header].
func (r RequestID) String() string {
return string(r)
}
// RequestIDKey stores and loads [RequestID] values within a [context.Context].
var RequestIDKey ctxkey.Key[RequestID]
@@ -33,20 +39,27 @@ var RequestIDKey ctxkey.Key[RequestID]
// or generate a new one.
const RequestIDHeader = "X-Tailscale-Request-Id"
// GenerateRequestID generates a new request ID with the current format.
func GenerateRequestID() RequestID {
// REQ-1 indicates the version of the RequestID pattern. It is
// currently arbitrary but allows for forward compatible
// transitions if needed.
return RequestID("REQ-1" + uuid.NewString())
}
// SetRequestID is an HTTP middleware that injects a RequestID in the
// *http.Request Context. The value of that request id is either retrieved from
// the RequestIDHeader or a randomly generated one if not exists. Inner
// handlers can retrieve this ID from the RequestIDFromContext function.
func SetRequestID(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get(RequestIDHeader)
if id == "" {
// REQ-1 indicates the version of the RequestID pattern. It is
// currently arbitrary but allows for forward compatible
// transitions if needed.
id = "REQ-1" + uuid.NewString()
var rid RequestID
if id := r.Header.Get(RequestIDHeader); id != "" {
rid = RequestID(id)
} else {
rid = GenerateRequestID()
}
ctx := RequestIDKey.WithValue(r.Context(), RequestID(id))
ctx := RequestIDKey.WithValue(r.Context(), rid)
r = r.WithContext(ctx)
h.ServeHTTP(w, r)
})

View File

@@ -524,6 +524,9 @@ func VarzHandler(w http.ResponseWriter, r *http.Request) {
// current server, or one of allowedHosts. Returns the cleaned URL or
// a validation error.
func CleanRedirectURL(urlStr string, allowedHosts []string) (*url.URL, error) {
if urlStr == "" {
return &url.URL{}, nil
}
// In some places, we unfortunately query-escape the redirect URL
// too many times, and end up needing to redirect to a URL that's
// still escaped by one level. Try to unescape the input.

View File

@@ -626,44 +626,57 @@ func TestCleanRedirectURL(t *testing.T) {
localHost := []string{"127.0.0.1", "localhost"}
myServer := []string{"myserver"}
cases := []struct {
url string
hosts []string
want string
url string
hosts []string
want string
wantErr bool
}{
{"http://tailscale.com/foo", tailscaleHost, "http://tailscale.com/foo"},
{"http://tailscale.com/foo", tailscaleAndOtherHost, "http://tailscale.com/foo"},
{"http://microsoft.com/foo", tailscaleAndOtherHost, "http://microsoft.com/foo"},
{"https://tailscale.com/foo", tailscaleHost, "https://tailscale.com/foo"},
{"/foo", tailscaleHost, "/foo"},
{"//tailscale.com/foo", tailscaleHost, "//tailscale.com/foo"},
{"/a/foobar", tailscaleHost, "/a/foobar"},
{"http://127.0.0.1/a/foobar", localHost, "http://127.0.0.1/a/foobar"},
{"http://127.0.0.1:123/a/foobar", localHost, "http://127.0.0.1:123/a/foobar"},
{"http://127.0.0.1:31544/a/foobar", localHost, "http://127.0.0.1:31544/a/foobar"},
{"http://localhost/a/foobar", localHost, "http://localhost/a/foobar"},
{"http://localhost:123/a/foobar", localHost, "http://localhost:123/a/foobar"},
{"http://localhost:31544/a/foobar", localHost, "http://localhost:31544/a/foobar"},
{"http://myserver/a/foobar", myServer, "http://myserver/a/foobar"},
{"http://myserver:123/a/foobar", myServer, "http://myserver:123/a/foobar"},
{"http://myserver:31544/a/foobar", myServer, "http://myserver:31544/a/foobar"},
{"http://evil.com/foo", tailscaleHost, ""},
{"//evil.com", tailscaleHost, ""},
{"HttP://tailscale.com", tailscaleHost, "http://tailscale.com"},
{"http://TaIlScAlE.CoM/spongebob", tailscaleHost, "http://TaIlScAlE.CoM/spongebob"},
{"ftp://tailscale.com", tailscaleHost, ""},
{"https:/evil.com", tailscaleHost, ""}, // regression test for tailscale/corp#892
{"%2Fa%2F44869c061701", tailscaleHost, "/a/44869c061701"}, // regression test for tailscale/corp#13288
{"https%3A%2Ftailscale.com", tailscaleHost, ""}, // escaped colon-single-slash malformed URL
{"http://tailscale.com/foo", tailscaleHost, "http://tailscale.com/foo", false},
{"http://tailscale.com/foo", tailscaleAndOtherHost, "http://tailscale.com/foo", false},
{"http://microsoft.com/foo", tailscaleAndOtherHost, "http://microsoft.com/foo", false},
{"https://tailscale.com/foo", tailscaleHost, "https://tailscale.com/foo", false},
{"/foo", tailscaleHost, "/foo", false},
{"//tailscale.com/foo", tailscaleHost, "//tailscale.com/foo", false},
{"/a/foobar", tailscaleHost, "/a/foobar", false},
{"http://127.0.0.1/a/foobar", localHost, "http://127.0.0.1/a/foobar", false},
{"http://127.0.0.1:123/a/foobar", localHost, "http://127.0.0.1:123/a/foobar", false},
{"http://127.0.0.1:31544/a/foobar", localHost, "http://127.0.0.1:31544/a/foobar", false},
{"http://localhost/a/foobar", localHost, "http://localhost/a/foobar", false},
{"http://localhost:123/a/foobar", localHost, "http://localhost:123/a/foobar", false},
{"http://localhost:31544/a/foobar", localHost, "http://localhost:31544/a/foobar", false},
{"http://myserver/a/foobar", myServer, "http://myserver/a/foobar", false},
{"http://myserver:123/a/foobar", myServer, "http://myserver:123/a/foobar", false},
{"http://myserver:31544/a/foobar", myServer, "http://myserver:31544/a/foobar", false},
{"http://evil.com/foo", tailscaleHost, "", true},
{"//evil.com", tailscaleHost, "", true},
{"\\\\evil.com", tailscaleHost, "", true},
{"javascript:alert(123)", tailscaleHost, "", true},
{"file:///", tailscaleHost, "", true},
{"file:////SERVER/directory/goats.txt", tailscaleHost, "", true},
{"https://google.com", tailscaleHost, "", true},
{"", tailscaleHost, "", false},
{"\"\"", tailscaleHost, "", true},
{"https://tailscale.com@goats.com:8443", tailscaleHost, "", true},
{"https://tailscale.com:8443@goats.com:8443", tailscaleHost, "", true},
{"HttP://tailscale.com", tailscaleHost, "http://tailscale.com", false},
{"http://TaIlScAlE.CoM/spongebob", tailscaleHost, "http://TaIlScAlE.CoM/spongebob", false},
{"ftp://tailscale.com", tailscaleHost, "", true},
{"https:/evil.com", tailscaleHost, "", true}, // regression test for tailscale/corp#892
{"%2Fa%2F44869c061701", tailscaleHost, "/a/44869c061701", false}, // regression test for tailscale/corp#13288
{"https%3A%2Ftailscale.com", tailscaleHost, "", true}, // escaped colon-single-slash malformed URL
{"", nil, "", false},
}
for _, tc := range cases {
gotURL, err := CleanRedirectURL(tc.url, tc.hosts)
if err != nil {
if tc.want != "" {
if !tc.wantErr {
t.Errorf("CleanRedirectURL(%q, %v) got error: %v", tc.url, tc.hosts, err)
}
} else {
if tc.wantErr {
t.Errorf("CleanRedirectURL(%q, %v) got %q, want an error", tc.url, tc.hosts, gotURL)
}
if got := gotURL.String(); got != tc.want {
t.Errorf("CleanRedirectURL(%q, %v) = %q, want %q", tc.url, tc.hosts, got, tc.want)
}

View File

@@ -284,11 +284,10 @@ func (k NodePublic) WriteRawWithoutAllocating(bw *bufio.Writer) error {
// Raw32 returns k encoded as 32 raw bytes.
//
// Deprecated: only needed for a single legacy use in the control
// server, don't add more uses.
// server and a few places in the wireguard-go API; don't add
// more uses.
func (k NodePublic) Raw32() [32]byte {
var ret [32]byte
copy(ret[:], k.k[:])
return ret
return k.k
}
// Less reports whether k orders before other, using an undocumented

View File

@@ -20,6 +20,7 @@ import (
"context"
"go4.org/mem"
"tailscale.com/envknob"
"tailscale.com/util/ctxkey"
)
@@ -393,3 +394,25 @@ func TestLogger(tb TBLogger) Logf {
tb.Logf(" ... "+format, args...)
}
}
// HTTPServerLogFilter is an io.Writer that can be used as the
// net/http.Server.ErrorLog logger, and will filter out noisy, low-signal
// messages that clutter up logs.
type HTTPServerLogFilter struct {
Inner Logf
}
func (lf HTTPServerLogFilter) Write(p []byte) (int, error) {
b := mem.B(p)
if mem.HasSuffix(b, mem.S(": EOF\n")) ||
mem.HasSuffix(b, mem.S(": i/o timeout\n")) ||
mem.HasSuffix(b, mem.S(": read: connection reset by peer\n")) ||
mem.HasSuffix(b, mem.S(": remote error: tls: bad certificate\n")) ||
mem.HasSuffix(b, mem.S(": tls: first record does not look like a TLS handshake\n")) {
// Skip this log message, but say that we processed it
return len(p), nil
}
lf.Inner("%s", p)
return len(p), nil
}

View File

@@ -258,3 +258,23 @@ func TestAsJSON(t *testing.T) {
t.Errorf("allocs = %v; want max 2", n)
}
}
func TestHTTPServerLogFilter(t *testing.T) {
var buf bytes.Buffer
logf := func(format string, args ...any) {
t.Logf("[logf] "+format, args...)
fmt.Fprintf(&buf, format, args...)
}
lf := HTTPServerLogFilter{logf}
quietLogger := log.New(lf, "", 0)
quietLogger.Printf("foo bar")
quietLogger.Printf("http: TLS handshake error from %s:%d: EOF", "1.2.3.4", 9999)
quietLogger.Printf("baz")
const want = "foo bar\nbaz\n"
if s := buf.String(); s != want {
t.Errorf("got buf=%q, want %q", s, want)
}
}

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