Compare commits

...

52 Commits

Author SHA1 Message Date
Andrew Dunham
63b6a19ffa control/controlhttp, util/multierr: deduplicate context errors
Add a new function to the util/multierr package that allows
deduplicating the `context.Canceled` and `context.DeadlineExceeded`
errors from a slice, and then use that to reduce log spam in
controlhttp.

Updates #14233

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I5a121124fe27c4449705ba35de6faf83665db5fe
2024-11-26 18:05:27 -05:00
Andrew Dunham
e87b71ec3c control/controlhttp: set *health.Tracker in tests
Observed during another PR:
    https://github.com/tailscale/tailscale/actions/runs/12040045880/job/33569141807

Updates #cleanup

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I9e0f49a35485fa2e097892737e5e3c95bf775a90
2024-11-26 18:05:05 -05:00
Nick Khyl
a62f7183e4 cmd/tailscale/cli: fix format string
Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-26 16:11:46 -06:00
Mario Minardi
26de518413 ipn/ipnlocal: only check CanUseExitNode if we are attempting to use one (#14230)
In https://github.com/tailscale/tailscale/pull/13726 we added logic to
`checkExitNodePrefsLocked` to error out on platforms where using an
exit node is unsupported in order to give users more obvious feedback
than having this silently fail downstream.

The above change neglected to properly check whether the device in
question was actually trying to use an exit node when doing the check
and was incorrectly returning an error on any calls to
`checkExitNodePrefsLocked` on platforms where using an exit node is not
supported as a result.

This change remedies this by adding a check to see whether the device is
attempting to use an exit node before doing the `CanUseExitNode` check.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-11-26 10:45:03 -07:00
James Tucker
4d33f30f91 net/netmon: improve panic reporting from #14202
I was hoping we'd catch an example input quickly, but the reporter had
rebooted their machine and it is no longer exhibiting the behavior. As
such this code may be sticking around quite a bit longer and we might
encounter other errors, so include the panic in the log entry.

Updates #14201
Updates #14202
Updates golang/go#70528

Signed-off-by: James Tucker <james@tailscale.com>
2024-11-25 12:31:24 -08:00
Nick Khyl
788121f475 docs/windows/policy: update ADMX policy definitions to reflect the syspolicy settings
We add a policy definition for the AllowedSuggestedExitNodes syspolicy setting, allowing admins
to configure a list of exit node IDs to be used as a pool for automatic suggested exit node selection.

We update definitions for policy settings configurable on both a per-user and per-machine basis,
such as UI customizations, to specify class="Both".

Lastly, we update the help text for existing policy definitions to include a link to the KB article
as the last line instead of in the first paragraph.

Updates #12687
Updates tailscale/corp#19681

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-25 10:49:22 -06:00
Irbe Krumina
ba3523fc3f cmd/containerboot: preserve headers of metrics endpoints responses (#14204)
Updates tailscale/tailscale#11292

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-11-23 08:51:40 +00:00
James Tucker
f6431185b0 net/netmon: catch ParseRIB panic to gather buffer data
Updates #14201
Updates golang/go#70528

Signed-off-by: James Tucker <james@tailscale.com>
2024-11-22 14:56:06 -08:00
Nick Khyl
36b7449fea ipn/ipnlocal: rebuild allowed suggested exit nodes when syspolicy changes
In this PR, we update LocalBackend to rebuild the set of allowed suggested exit nodes whenever
the AllowedSuggestedExitNodes syspolicy setting changes. Additionally, we request a new suggested
exit node when this occurs, enabling its use if the ExitNodeID syspolicy setting is set to auto:any.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 15:01:45 -06:00
Nick Khyl
3353f154bb control/controlclient: use the most recent syspolicy.MachineCertificateSubject value
This PR removes the sync.Once wrapper around retrieving the MachineCertificateSubject policy
setting value, ensuring the most recent version is always used if it changes after the service starts.

Although this policy setting is used by a very limited number of customers, recent support escalations have highlighted issues caused by outdated or incorrect policy values being applied.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 14:50:32 -06:00
Nick Khyl
eb3cd32911 ipn/ipnlocal: update ipn.Prefs when there's a change in syspolicy settings
In this PR, we update ipnlocal.NewLocalBackend to subscribe to policy change notifications
and reapply syspolicy settings to the current profile's ipn.Prefs whenever a change occurs.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 14:41:39 -06:00
Nick Khyl
2ab66d9698 ipn/ipnlocal: move syspolicy handling from setExitNodeID to applySysPolicy
This moves code that handles ExitNodeID/ExitNodeIP syspolicy settings
from (*LocalBackend).setExitNodeID to applySysPolicy.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 14:41:39 -06:00
Nick Khyl
7c8f663d70 cmd/tailscaled: log SCM interactions if the policy setting is enabled at the time of interaction
This updates the syspolicy.LogSCMInteractions check to run at the time of an interaction,
just before logging a message, instead of during service startup. This ensures the most
recent policy setting is used if it has changed since the service started.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 14:37:38 -06:00
Nick Khyl
50bf32a0ba cmd/tailscaled: flush DNS if FlushDNSOnSessionUnlock is true upon receiving a session change notification
In this PR, we move the syspolicy.FlushDNSOnSessionUnlock check from service startup
to when a session change notification is received. This ensures that the most recent policy
setting value is used if it has changed since the service started.

We also plan to handle session change notifications for unrelated reasons
and need to decouple notification subscriptions from DNS anyway.

Updates #12687
Updates tailscale/corp#18342

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 14:37:22 -06:00
Nick Khyl
8e5cfbe4ab util/syspolicy/rsop: reduce policyReloadMinDelay and policyReloadMaxDelay when in tests
These delays determine how soon syspolicy change callbacks are invoked after a policy setting is updated
in a policy source. For tests, we shorten these delays to minimize unnecessary wait times. This adjustment
only affects tests that subscribe to policy change notifications and modify policy settings after they have
already been set. Initial policy settings are always available immediately without delay.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 09:51:21 -06:00
Nick Khyl
462e1fc503 ipn/{ipnlocal,localapi}, wgengine/netstack: call (*LocalBackend).Shutdown when tests that create them complete
We have several places where LocalBackend instances are created for testing, but they are rarely shut down
when the tests that created them exit.

In this PR, we update newTestLocalBackend and similar functions to use testing.TB.Cleanup(lb.Shutdown)
to ensure LocalBackend instances are properly shut down during test cleanup.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-11-22 09:46:11 -06:00
Tom Proctor
74d4652144 cmd/{containerboot,k8s-operator},k8s-operator: new options to expose user metrics (#14035)
containerboot:

Adds 3 new environment variables for containerboot, `TS_LOCAL_ADDR_PORT` (default
`"${POD_IP}:9002"`), `TS_METRICS_ENABLED` (default `false`), and `TS_DEBUG_ADDR_PORT`
(default `""`), to configure metrics and debug endpoints. In a follow-up PR, the
health check endpoint will be updated to use the `TS_LOCAL_ADDR_PORT` if
`TS_HEALTHCHECK_ADDR_PORT` hasn't been set.

Users previously only had access to internal debug metrics (which are unstable
and not recommended) via passing the `--debug` flag to tailscaled, but can now
set `TS_METRICS_ENABLED=true` to expose the stable metrics documented at
https://tailscale.com/kb/1482/client-metrics at `/metrics` on the addr/port
specified by `TS_LOCAL_ADDR_PORT`.

Users can also now configure a debug endpoint more directly via the
`TS_DEBUG_ADDR_PORT` environment variable. This is not recommended for production
use, but exposes an internal set of debug metrics and pprof endpoints.

operator:

The `ProxyClass` CRD's `.spec.metrics.enable` field now enables serving the
stable user metrics documented at https://tailscale.com/kb/1482/client-metrics
at `/metrics` on the same "metrics" container port that debug metrics were
previously served on. To smooth the transition for anyone relying on the way the
operator previously consumed this field, we also _temporarily_ serve tailscaled's
internal debug metrics on the same `/debug/metrics` path as before, until 1.82.0
when debug metrics will be turned off by default even if `.spec.metrics.enable`
is set. At that point, anyone who wishes to continue using the internal debug
metrics (not recommended) will need to set the new `ProxyClass` field
`.spec.statefulSet.pod.tailscaleContainer.debug.enable`.

Users who wish to opt out of the transitional behaviour, where enabling
`.spec.metrics.enable` also enables debug metrics, can set
`.spec.statefulSet.pod.tailscaleContainer.debug.enable` to false (recommended).

Separately but related, the operator will no longer specify a host port for the
"metrics" container port definition. This caused scheduling conflicts when k8s
needs to schedule more than one proxy per node, and was not necessary for allowing
the pod's port to be exposed to prometheus scrapers.

Updates #11292

---------

Co-authored-by: Kristoffer Dalby <kristoffer@tailscale.com>
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-11-22 15:41:07 +00:00
Irbe Krumina
c59ab6baac cmd/k8s-operator/deploy: ensure that operator can write kube state Events (#14177)
A small follow-up to #14112- ensures that the operator itself can emit
Events for its kube state store changes.

Updates tailscale/tailscale#14080

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-11-22 06:53:46 +00:00
Andrea Gottardo
e3c6ca43d3 cli: present risk warning when setting up app connector on macOS (#14181) 2024-11-21 12:56:41 -08:00
Brad Fitzpatrick
0c8c7c0f90 net/tsaddr: include test input in test failure output
https://go.dev/wiki/CodeReviewComments#useful-test-failures

(Previously it was using subtests with names including the input, but
 once those went away, there was no context left)

Updates #14169

Change-Id: Ib217028183a3d001fe4aee58f2edb746b7b3aa88
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-21 08:32:38 -08:00
Andrew Dunham
af4c3a4a1b cmd/tailscale/cli: create netmon in debug ts2021
Otherwise we'll see a panic if we hit the dnsfallback code and try to
call NewDialer with a nil NetMon.

Updates #14161

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I81c6e72376599b341cb58c37134c2a948b97cf5f
2024-11-20 22:37:26 -05:00
Brad Fitzpatrick
70d1241ca6 util/fastuuid: delete unused package
Its sole user was deleted in 02cafbe1ca.

And it has no public users: https://pkg.go.dev/tailscale.com/util/fastuuid?tab=importedby

And nothing in other Tailsale repos that I can find.

Updates tailscale/corp#24721

Change-Id: I8755770a255a91c6c99f596e6d10c303b3ddf213
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-20 16:55:00 -08:00
Brad Fitzpatrick
02cafbe1ca tsweb: change RequestID format to have a date in it
So we can locate them in logs more easily.

Updates tailscale/corp#24721

Change-Id: Ia766c75608050dde7edc99835979a6e9bb328df2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-20 15:55:09 -08:00
James Scott
ebaf33a80c net/tsaddr: extract IsTailscaleIPv4 from IsTailscaleIP (#14169)
Extracts tsaddr.IsTailscaleIPv4 out of tsaddr.IsTailscaleIP.

This will allow for checking valid Tailscale assigned IPv4 addresses
without checking IPv6 addresses.

Updates #14168
Updates tailscale/corp#24620

Signed-off-by: James Scott <jim@tailscale.com>
2024-11-20 12:28:25 -08:00
Irbe Krumina
ebeb5da202 cmd/k8s-operator,kube/kubeclient,docs/k8s: update rbac to emit events + small fixes (#14164)
This is a follow-up to #14112 where our internal kube client was updated
to allow it to emit Events - this updates our sample kube manifests
and tsrecorder manifest templates so they can benefit from this functionality.

Updates tailscale/tailscale#14080

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-11-20 14:22:34 +00:00
James Stocker
303a4a1dfb Make the deployment of an IngressClass optional, default to true (#14153)
Fixes tailscale/tailscale#14152
Signed-off-by: James Stocker jamesrstocker@gmail.com

Co-authored-by: James Stocker <james.stocker@intenthq.co.uk>
2024-11-20 06:43:59 +00:00
Anton Tolchanov
9f33aeb649 wgengine/filter: actually use the passed CapTestFunc [capver 109]
Initial support for SrcCaps was added in 5ec01bf but it was not actually
working without this.

Updates #12542

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-11-19 19:18:35 +00:00
Aaron Klotz
48343ee673 util/winutil/s4u: fix token handle leak
Fixes #14156

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-11-19 14:11:50 -05:00
Brad Fitzpatrick
810da91a9e version: fix earlier test/wording mistakes
Updates #14069

Change-Id: I1d2fd8a8ab6591af11bfb83748b94342a8ac718f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-19 10:59:21 -08:00
Brad Fitzpatrick
d62baa45e6 version: validate Long format on Android builds
Updates #14069

Change-Id: I134a90db561dacc4b1c1c66ccadac135b5d64cf3
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-19 10:04:37 -08:00
License Updater
bb3d0cae5f licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-11-19 09:25:57 -08:00
Irbe Krumina
00517c8189 kube/{kubeapi,kubeclient},ipn/store/kubestore,cmd/{containerboot,k8s-operator}: emit kube store Events (#14112)
Adds functionality to kube client to emit Events.
Updates kube store to emit Events when tailscaled state has been loaded, updated or if any errors where
encountered during those operations.
This should help in cases where an error related to state loading/updating caused the Pod to crash in a loop-
unlike logs of the originally failed container instance, Events associated with the Pod will still be
accessible even after N restarts.

Updates tailscale/tailscale#14080

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-11-19 13:07:19 +00:00
Brad Fitzpatrick
da70a84a4b ipn/ipnlocal: fix build, remove another Notify.BackendLogID reference that crept in
I merged 5cae7c51bf (removing Notify.BackendLogID) and 93db503565
(adding another reference to Notify.BackendLogID) that didn't have merge
conflicts, but didn't compile together.

This removes the new reference, fixing the build.

Updates #14129

Change-Id: I9bb68efd977342ea8822e525d656817235039a66
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-18 12:17:19 -08:00
Brad Fitzpatrick
93db503565 ipn/ipnlocal: add IPN Bus NotifyRateLimit watch bit NotifyRateLimit
Limit spamming GUIs with boring updates to once in 3 seconds, unless
the notification is relatively interesting and the GUI should update
immediately.

This is basically @barnstar's #14119 but with the logic moved to be
per-watch-session (since the bit is per session), rather than
globally. And this distinguishes notable Notify messages (such as
state changes) and makes them send immediately.

Updates tailscale/corp#24553

Change-Id: I79cac52cce85280ce351e65e76ea11e107b00b49
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-18 10:50:30 -08:00
Andrew Lytvynov
c2a7f17f2b sessionrecording: implement v2 recording endpoint support (#14105)
The v2 endpoint supports HTTP/2 bidirectional streaming and acks for
received bytes. This is used to detect when a recorder disappears to
more quickly terminate the session.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-11-18 09:55:54 -08:00
Brad Fitzpatrick
5cae7c51bf ipn: remove unused Notify.BackendLogID
Updates #14129

Change-Id: I13b5df8765e786a4a919d6b2e72afe987000b2d1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-18 08:36:41 -08:00
Brad Fitzpatrick
f1e1048977 go.mod: bump tailscale/wireguard-go
Updates #11899

Change-Id: Ibd75134a20798c84c7174ba3af639cf22836c7d7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-16 15:31:07 -08:00
Brad Fitzpatrick
3b93fd9c44 net/captivedetection: replace 10k log lines with ... less
We see tons of logs of the form:

    2024/11/15 19:57:29 netcheck: [v2] 76 available captive portal detection endpoints: [Endpoint{URL="http://192.73.240.161/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.240.121/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.240.132/generate_204", StatusCode=204, ExpectedContent="",
11:58SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.158.246/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.158.15/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.182.118/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.243.135/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.243.229/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.243.141/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.97.144/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.97.61/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.97.233/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.98.196/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.98.253/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.98.145/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://68.183.90.120/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.156.94/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.248.83/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.156.197/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.181.104/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.145.120/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.181.93/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.181.103/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.165.90/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.165.185/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.165.36/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.90.147/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.90.207/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.90.104/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://162.248.221.199/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://162.248.221.215/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://162.248.221.248/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.34.3.232/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.34.3.207/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.34.3.75/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.83.234.151/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.83.233.233/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.72.155.133/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.40.234.219/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.40.234.113/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.40.234.77/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.48.220/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.48.50/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.48.250/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.252.65/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.252.134/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.111.34.178/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.49.105/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.49.83/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.49.144/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.92.144/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.88.183/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.92.254/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://148.163.220.129/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://148.163.220.134/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://148.163.220.210/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.242.187/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.242.28/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.242.204/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.93.248/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.93.147/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.93.154/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.244.245/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.111.40.12/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.111.40.216/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.6.84.152/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://205.147.105.30/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://205.147.105.78/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.167.245/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.167.37/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.167.188/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.84.155.178/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.84.155.188/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.84.155.46/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://controlplane.tailscale.com/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=false, Provider=Tailscale} Endpoint{URL="http://login.tailscale.com/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=false, Provider=Tailscale}]

That can be much shorter.

Also add a fast exit path to the concurrency on match. Doing 5 all at
once is still pretty gratuitous, though.

Updates #1634
Fixes #13019

Change-Id: Icdbb16572fca4477b0ee9882683a3ac6eb08e2f2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-15 15:25:31 -08:00
Naman Sood
aefbed323f ipn,tailcfg: add VIPService struct and c2n to fetch them from client (#14046)
* ipn,tailcfg: add VIPService struct and c2n to fetch them from client

Updates tailscale/corp#22743, tailscale/corp#22955

Signed-off-by: Naman Sood <mail@nsood.in>

* more review fixes

Signed-off-by: Naman Sood <mail@nsood.in>

* don't mention PeerCapabilityServicesDestination since it's currently unused

Signed-off-by: Naman Sood <mail@nsood.in>

---------

Signed-off-by: Naman Sood <mail@nsood.in>
2024-11-15 16:14:06 -05:00
Percy Wegmann
1355f622be cmd/derpprobe,prober: add ability to restrict derpprobe to a single region
Updates #24522

Co-authored-by: Mario Minardi <mario@tailscale.com>
Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-11-15 13:42:58 -06:00
Brad Fitzpatrick
c3c4c05331 tstest/integration/testcontrol: remove a vestigial unused parameter
Back in the day this testcontrol package only spoke the
nacl-boxed-based control protocol, which used this.

Then we added ts2021, which didn't, but still sometimes used it.

Then we removed the old mode and didn't remove this parameter
in 2409661a0d.

Updates #11585

Change-Id: Ifd290bd7dbbb52b681b3599786437a15bc98b6a5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-15 10:05:35 -08:00
Brad Fitzpatrick
8fd471ce57 control/controlclient: disable https on for http://localhost:$port URLs
Previously we required the program to be running in a test or have
TS_CONTROL_IS_PLAINTEXT_HTTP before we disabled its https fallback
on "http" schema control URLs to localhost with ports.

But nobody accidentally does all three of "http", explicit port
number, localhost and doesn't mean it. And when they mean it, they're
testing a localhost dev control server (like I was) and don't want 443
getting involved.

As of the changes for #13597, this became more annoying in that we
were trying to use a port which wasn't even available.

Updates #13597

Change-Id: Icd00bca56043d2da58ab31de7aa05a3b269c490f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-14 12:12:16 -08:00
Brad Fitzpatrick
e73cfd9700 go.toolchain.rev: bump from Go 1.23.1 to Go 1.23.3
Updates #14100

Change-Id: I57f9d4260be15ce1daebe4a9782910aba3fb9dc9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-14 10:57:49 -08:00
Brad Fitzpatrick
f593d3c5c0 cmd/tailscale/cli: add "help" alias for --help
Fixes #14053

Change-Id: I0a13e11af089f02b0656fea0d316543c67591fb5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-13 11:08:53 -08:00
dependabot[bot]
bfe5cd8760 .github: Bump actions/setup-go from 5.0.2 to 5.1.0 (#13934)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5.0.2 to 5.1.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](0a12ed9d6a...41dfa10bad)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 10:56:44 -07:00
Walter Poupore
0c9ade46a4 words: Add scoville to scales.txt (#14084)
https://en.wikipedia.org/wiki/Scoville_scale

Updates #words

Signed-off-by: Walter Poupore <walterp@tailscale.com>
2024-11-13 09:25:12 -08:00
dependabot[bot]
4474dcea68 .github: Bump actions/cache from 4.1.0 to 4.1.2 (#13933)
Bumps [actions/cache](https://github.com/actions/cache) from 4.1.0 to 4.1.2.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](2cdf405574...6849a64899)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 09:46:30 -07:00
dependabot[bot]
0cfa217f3e .github: Bump actions/upload-artifact from 4.4.0 to 4.4.3 (#13811)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.4.0 to 4.4.3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](50769540e7...b4b15b8c7c)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 09:34:10 -07:00
dependabot[bot]
1847f26042 .github: Bump github/codeql-action from 3.26.11 to 3.27.1 (#14062)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.11 to 3.27.1.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](6db8d6351f...4f3212b617)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 09:30:14 -07:00
Naman Sood
7c6562c861 words: scale up our word count (#14082)
Updates tailscale/corp#14698

Signed-off-by: Naman Sood <mail@nsood.in>
2024-11-13 09:56:02 -05:00
Brad Fitzpatrick
0c6bd9a33b words: add a scale
https://portsmouthbrewery.com/shilling-scale/

Any scale that includes "wee heavy" is a scale worth including.

Updates #words

Change-Id: I85fd7a64cf22e14f686f1093a220cb59c43e46ba
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-13 06:09:59 -08:00
Irbe Krumina
cf41cec5a8 cmd/{k8s-operator,containerboot},k8s-operator: remove support for proxies below capver 95. (#13986)
Updates tailscale/tailscale#13984

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-11-12 17:13:26 +00:00
107 changed files with 3144 additions and 718 deletions

View File

@@ -49,13 +49,13 @@ jobs:
# Install a more recent Go that understands modern go.mod content.
- name: Install Go
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
- uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version-file: go.mod
cache: false

View File

@@ -80,7 +80,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -153,13 +153,13 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Install Go
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version-file: go.mod
cache: false
- name: Restore Cache
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -260,7 +260,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -319,7 +319,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -367,7 +367,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -461,7 +461,7 @@ jobs:
run: |
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
- name: upload crash
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
with:
name: artifacts

View File

@@ -39,7 +39,7 @@ func runHealthz(addr string, h *healthz) {
log.Fatalf("error listening on the provided health endpoint address %q: %v", addr, err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", h)
mux.Handle("GET /healthz", h)
log.Printf("Running healthcheck endpoint at %s/healthz", addr)
hs := &http.Server{Handler: mux}

View File

@@ -61,7 +61,7 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
Path: "/data/authkey",
},
}
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
if err := kc.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, m); err != nil {
if s, ok := err.(*kubeapi.Status); ok && s.Code == http.StatusUnprocessableEntity {
// This is kubernetes-ese for "the field you asked to
// delete already doesn't exist", aka no-op.
@@ -81,7 +81,7 @@ func initKubeClient(root string) {
kubeclient.SetRootPathForTesting(root)
}
var err error
kc, err = kubeclient.New()
kc, err = kubeclient.New("tailscale-container")
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
}

View File

@@ -102,7 +102,6 @@ import (
"net/netip"
"os"
"os/signal"
"path"
"path/filepath"
"slices"
"strings"
@@ -179,6 +178,14 @@ func main() {
}
defer killTailscaled()
if cfg.LocalAddrPort != "" && cfg.MetricsEnabled {
m := &metrics{
lc: client,
debugEndpoint: cfg.DebugAddrPort,
}
runMetrics(cfg.LocalAddrPort, m)
}
if cfg.EnableForwardingOptimizations {
if err := client.SetUDPGROForwarding(bootCtx); err != nil {
log.Printf("[unexpected] error enabling UDP GRO forwarding: %v", err)
@@ -731,7 +738,6 @@ func tailscaledConfigFilePath() string {
}
cv, err := kubeutils.CapVerFromFileName(e.Name())
if err != nil {
log.Printf("skipping file %q in tailscaled config directory %q: %v", e.Name(), dir, err)
continue
}
if cv > maxCompatVer && cv <= tailcfg.CurrentCapabilityVersion {
@@ -739,8 +745,9 @@ func tailscaledConfigFilePath() string {
}
}
if maxCompatVer == -1 {
log.Fatalf("no tailscaled config file found in %q for current capability version %q", dir, tailcfg.CurrentCapabilityVersion)
log.Fatalf("no tailscaled config file found in %q for current capability version %d", dir, tailcfg.CurrentCapabilityVersion)
}
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
return path.Join(dir, kubeutils.TailscaledConfigFileName(maxCompatVer))
filePath := filepath.Join(dir, kubeutils.TailscaledConfigFileName(maxCompatVer))
log.Printf("Using tailscaled config file %q to match current capability version %d", filePath, tailcfg.CurrentCapabilityVersion)
return filePath
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"fmt"
"io"
"log"
"net"
"net/http"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
)
// metrics is a simple metrics HTTP server, if enabled it forwards requests to
// the tailscaled's LocalAPI usermetrics endpoint at /localapi/v0/usermetrics.
type metrics struct {
debugEndpoint string
lc *tailscale.LocalClient
}
func proxy(w http.ResponseWriter, r *http.Request, url string, do func(*http.Request) (*http.Response, error)) {
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to construct request: %s", err), http.StatusInternalServerError)
return
}
req.Header = r.Header.Clone()
resp, err := do(req)
if err != nil {
http.Error(w, fmt.Sprintf("failed to proxy request: %s", err), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
for key, val := range resp.Header {
for _, v := range val {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (m *metrics) handleMetrics(w http.ResponseWriter, r *http.Request) {
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi/v0/usermetrics"
proxy(w, r, localAPIURL, m.lc.DoLocalRequest)
}
func (m *metrics) handleDebug(w http.ResponseWriter, r *http.Request) {
if m.debugEndpoint == "" {
http.Error(w, "debug endpoint not configured", http.StatusNotFound)
return
}
debugURL := "http://" + m.debugEndpoint + r.URL.Path
proxy(w, r, debugURL, http.DefaultClient.Do)
}
// runMetrics runs a simple HTTP metrics endpoint at <addr>/metrics, forwarding
// requests to tailscaled's /localapi/v0/usermetrics API.
//
// In 1.78.x and 1.80.x, it also proxies debug paths to tailscaled's debug
// endpoint if configured to ease migration for a breaking change serving user
// metrics instead of debug metrics on the "metrics" port.
func runMetrics(addr string, m *metrics) {
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("error listening on the provided metrics endpoint address %q: %v", addr, err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /metrics", m.handleMetrics)
mux.HandleFunc("/debug/", m.handleDebug) // TODO(tomhjp): Remove for 1.82.0 release.
log.Printf("Running metrics endpoint at %s/metrics", addr)
ms := &http.Server{Handler: mux}
go func() {
if err := ms.Serve(ln); err != nil {
log.Fatalf("failed running metrics endpoint: %v", err)
}
}()
}

View File

@@ -389,7 +389,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
Path: fmt.Sprintf("/data/%s", egressservices.KeyEgressServices),
Value: bs,
}
if err := ep.kc.JSONPatchSecret(ctx, ep.stateSecret, []kubeclient.JSONPatch{patch}); err != nil {
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
return fmt.Errorf("error patching state Secret: %w", err)
}
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()

View File

@@ -67,11 +67,18 @@ type settings struct {
PodIP string
PodIPv4 string
PodIPv6 string
HealthCheckAddrPort string
HealthCheckAddrPort string // TODO(tomhjp): use the local addr/port instead.
LocalAddrPort string
MetricsEnabled bool
DebugAddrPort string
EgressSvcsCfgPath string
}
func configFromEnv() (*settings, error) {
defaultLocalAddrPort := ""
if v, ok := os.LookupEnv("POD_IP"); ok && v != "" {
defaultLocalAddrPort = fmt.Sprintf("%s:9002", v)
}
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
@@ -98,6 +105,9 @@ func configFromEnv() (*settings, error) {
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
LocalAddrPort: defaultEnv("TS_LOCAL_ADDR_PORT", defaultLocalAddrPort),
MetricsEnabled: defaultBool("TS_METRICS_ENABLED", false),
DebugAddrPort: defaultEnv("TS_DEBUG_ADDR_PORT", ""),
EgressSvcsCfgPath: defaultEnv("TS_EGRESS_SERVICES_CONFIG_PATH", ""),
}
podIPs, ok := os.LookupEnv("POD_IPS")
@@ -175,6 +185,16 @@ func (s *settings) validate() error {
return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
}
}
if s.LocalAddrPort != "" {
if _, err := netip.ParseAddrPort(s.LocalAddrPort); err != nil {
return fmt.Errorf("error parsing TS_LOCAL_ADDR_PORT value %q: %w", s.LocalAddrPort, err)
}
}
if s.DebugAddrPort != "" {
if _, err := netip.ParseAddrPort(s.DebugAddrPort); err != nil {
return fmt.Errorf("error parsing TS_DEBUG_ADDR_PORT value %q: %w", s.DebugAddrPort, err)
}
}
return nil
}

View File

@@ -90,6 +90,12 @@ func tailscaledArgs(cfg *settings) []string {
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
// Once enough proxy versions have been released for all the supported
// versions to understand this cfg setting, the operator can stop
// setting TS_TAILSCALED_EXTRA_ARGS for the debug flag.
if cfg.DebugAddrPort != "" && !strings.Contains(cfg.DaemonExtraArgs, cfg.DebugAddrPort) {
args = append(args, "--debug="+cfg.DebugAddrPort)
}
if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
}

View File

@@ -27,7 +27,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/util/fastuuid
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
@@ -152,7 +151,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/fastuuid from tailscale.com/tsweb
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineiter from tailscale.com/hostinfo+
@@ -160,6 +158,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/mak from tailscale.com/health+
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/rands from tailscale.com/tsweb
tailscale.com/util/set from tailscale.com/derp+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
@@ -244,7 +243,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/tls from golang.org/x/crypto/acme+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
@@ -276,7 +274,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+
math/rand/v2 from tailscale.com/util/fastuuid+
math/rand/v2 from internal/concurrent+
mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http
mime/quotedprintable from mime/multipart

View File

@@ -109,6 +109,7 @@ func TestDeps(t *testing.T) {
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
"tailscale.com/net/packet": "not needed in derper",
"github.com/gaissmai/bart": "not needed in derper",
"database/sql/driver": "not needed in derper", // previously came in via github.com/google/uuid
},
}.Check(t)
}

View File

@@ -29,6 +29,7 @@ var (
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")
regionCode = flag.String("region-code", "", "probe only this region (e.g. 'lax'); if left blank, all regions will be probed")
)
func main() {
@@ -47,6 +48,9 @@ func main() {
if *bwInterval > 0 {
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
}
if *regionCode != "" {
opts = append(opts, prober.WithRegion(*regionCode))
}
dp, err := prober.DERP(p, *derpMapURL, opts...)
if err != nil {
log.Fatal(err)

View File

@@ -81,6 +81,14 @@ spec:
- name: PROXY_DEFAULT_CLASS
value: {{ .Values.proxyConfig.defaultProxyClass }}
{{- end }}
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
{{- with .Values.operatorConfig.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}

View File

@@ -1,3 +1,4 @@
{{- if .Values.ingressClass.enabled }}
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
@@ -6,3 +7,4 @@ metadata:
spec:
controller: tailscale.com/ts-ingress # controller name currently can not be changed
# parameters: {} # currently no parameters are supported
{{- end }}

View File

@@ -16,6 +16,9 @@ rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch", "get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

View File

@@ -54,6 +54,9 @@ operatorConfig:
# - name: EXTRA_VAR2
# value: "value2"
# In the case that you already have a tailscale ingressclass in your cluster (or vcluster), you can disable the creation here
ingressClass:
enabled: true
# proxyConfig contains configuraton that will be applied to any ingress/egress
# proxies created by the operator.

View File

@@ -73,7 +73,12 @@ spec:
enable:
description: |-
Setting enable to true will make the proxy serve Tailscale metrics
at <pod-ip>:9001/debug/metrics.
at <pod-ip>:9002/metrics.
In 1.78.x and 1.80.x, this field also serves as the default value for
.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
fields will independently default to false.
Defaults to false.
type: boolean
statefulSet:
@@ -1249,6 +1254,25 @@ spec:
description: Configuration for the proxy container running tailscale.
type: object
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
type: object
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
env:
description: |-
List of environment variables to set in the container.
@@ -1553,6 +1577,25 @@ spec:
description: Configuration for the proxy init container that enables forwarding.
type: object
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
type: object
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
env:
description: |-
List of environment variables to set in the container.

View File

@@ -540,7 +540,12 @@ spec:
enable:
description: |-
Setting enable to true will make the proxy serve Tailscale metrics
at <pod-ip>:9001/debug/metrics.
at <pod-ip>:9002/metrics.
In 1.78.x and 1.80.x, this field also serves as the default value for
.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
fields will independently default to false.
Defaults to false.
type: boolean
required:
@@ -1716,6 +1721,25 @@ spec:
tailscaleContainer:
description: Configuration for the proxy container running tailscale.
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
type: object
env:
description: |-
List of environment variables to set in the container.
@@ -2020,6 +2044,25 @@ spec:
tailscaleInitContainer:
description: Configuration for the proxy init container that enables forwarding.
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
type: object
env:
description: |-
List of environment variables to set in the container.
@@ -4703,6 +4746,14 @@ rules:
- patch
- update
- watch
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
@@ -4775,6 +4826,14 @@ spec:
value: "false"
- name: PROXY_FIREWALL_MODE
value: auto
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
image: tailscale/k8s-operator:unstable
imagePullPolicy: Always
name: operator

View File

@@ -30,6 +30,14 @@ spec:
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -24,3 +24,11 @@ spec:
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid

View File

@@ -1388,7 +1388,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "362360188dac62bca8013c8134929fed8efd84b1f410c00873d14a05709b5647",
confFileHash: "a67b5ad3ff605531c822327e8f1a23dd0846e1075b722c13402f7d5d0ba32ba2",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
@@ -1399,7 +1399,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
mak.Set(&svc.Annotations, AnnotationHostname, "another-test")
})
o.hostname = "another-test"
o.confFileHash = "20db57cfabc3fc6490f6bb1dc85994e61d255cdfa2a56abb0141736e59f263ef"
o.confFileHash = "888a993ebee20ad6be99623b45015339de117946850cf1252bede0b570e04293"
expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
}

View File

@@ -160,6 +160,10 @@ func (pcr *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations fiel
violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleInitContainer", "image"), tc.Image, err.Error()))
}
}
if tc.Debug != nil {
violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleInitContainer", "debug"), tc.Debug, "debug settings cannot be configured on the init container"))
}
}
}
}

View File

@@ -135,3 +135,56 @@ func TestProxyClass(t *testing.T) {
expectReconciled(t, pcr, "", "test")
expectEvents(t, fr, expectedEvents)
}
func TestValidateProxyClass(t *testing.T) {
for name, tc := range map[string]struct {
pc *tsapi.ProxyClass
valid bool
}{
"empty": {
valid: true,
pc: &tsapi.ProxyClass{},
},
"debug_enabled_for_main_container": {
valid: true,
pc: &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
StatefulSet: &tsapi.StatefulSet{
Pod: &tsapi.Pod{
TailscaleContainer: &tsapi.Container{
Debug: &tsapi.Debug{
Enable: true,
},
},
},
},
},
},
},
"debug_enabled_for_init_container": {
valid: false,
pc: &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
StatefulSet: &tsapi.StatefulSet{
Pod: &tsapi.Pod{
TailscaleInitContainer: &tsapi.Container{
Debug: &tsapi.Debug{
Enable: true,
},
},
},
},
},
},
},
} {
t.Run(name, func(t *testing.T) {
pcr := &ProxyClassReconciler{}
err := pcr.validate(tc.pc)
valid := err == nil
if valid != tc.valid {
t.Errorf("expected valid=%v, got valid=%v, err=%v", tc.valid, valid, err)
}
})
}
}

View File

@@ -126,15 +126,6 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHa
},
},
},
{
Name: "POD_NAME",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
// Secret is named after the pod.
FieldPath: "metadata.name",
},
},
},
{
Name: "TS_KUBE_SECRET",
Value: "$(POD_NAME)",
@@ -147,10 +138,6 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHa
Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR",
Value: "/etc/tsconfig/$(POD_NAME)",
},
{
Name: "TS_USERSPACE",
Value: "false",
},
{
Name: "TS_INTERNAL_APP",
Value: kubetypes.AppProxyGroupEgress,
@@ -171,7 +158,7 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHa
})
}
return envs
return append(c.Env, envs...)
}()
return ss, nil
@@ -215,6 +202,15 @@ func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
return secrets
}(),
},
{
APIGroups: []string{""},
Resources: []string{"events"},
Verbs: []string{
"create",
"patch",
"get",
},
},
},
}
}

View File

@@ -476,7 +476,7 @@ var proxyYaml []byte
//go:embed deploy/manifests/userspace-proxy.yaml
var userspaceProxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string, configs map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) (*appsv1.StatefulSet, error) {
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string, _ map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) (*appsv1.StatefulSet, error) {
ss := new(appsv1.StatefulSet)
if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding
if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil {
@@ -521,11 +521,6 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Name: "TS_KUBE_SECRET",
Value: proxySecret,
},
corev1.EnvVar{
// Old tailscaled config key is still used for backwards compatibility.
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
Value: "/etc/tsconfig/tailscaled",
},
corev1.EnvVar{
// New style is in the form of cap-<capability-version>.hujson.
Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR",
@@ -671,24 +666,42 @@ func mergeStatefulSetLabelsOrAnnots(current, custom map[string]string, managed [
return custom
}
func debugSetting(pc *tsapi.ProxyClass) bool {
if pc == nil ||
pc.Spec.StatefulSet == nil ||
pc.Spec.StatefulSet.Pod == nil ||
pc.Spec.StatefulSet.Pod.TailscaleContainer == nil ||
pc.Spec.StatefulSet.Pod.TailscaleContainer.Debug == nil {
// This default will change to false in 1.82.0.
return pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable
}
return pc.Spec.StatefulSet.Pod.TailscaleContainer.Debug.Enable
}
func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet, stsCfg *tailscaleSTSConfig, logger *zap.SugaredLogger) *appsv1.StatefulSet {
if pc == nil || ss == nil {
return ss
}
if stsCfg != nil && pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable {
if stsCfg.TailnetTargetFQDN == "" && stsCfg.TailnetTargetIP == "" && !stsCfg.ForwardClusterTrafficViaL7IngressProxy {
enableMetrics(ss)
} else if stsCfg.ForwardClusterTrafficViaL7IngressProxy {
metricsEnabled := pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable
debugEnabled := debugSetting(pc)
if metricsEnabled || debugEnabled {
isEgress := stsCfg != nil && (stsCfg.TailnetTargetFQDN != "" || stsCfg.TailnetTargetIP != "")
isForwardingL7Ingress := stsCfg != nil && stsCfg.ForwardClusterTrafficViaL7IngressProxy
if isEgress {
// TODO (irbekrm): fix this
// For Ingress proxies that have been configured with
// tailscale.com/experimental-forward-cluster-traffic-via-ingress
// annotation, all cluster traffic is forwarded to the
// Ingress backend(s).
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.")
} else {
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for egress proxies.")
} else if isForwardingL7Ingress {
// TODO (irbekrm): fix this
// For egress proxies, currently all cluster traffic is forwarded to the tailnet target.
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.")
} else {
enableEndpoints(ss, metricsEnabled, debugEnabled)
}
}
@@ -766,16 +779,58 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet,
return ss
}
func enableMetrics(ss *appsv1.StatefulSet) {
func enableEndpoints(ss *appsv1.StatefulSet, metrics, debug bool) {
for i, c := range ss.Spec.Template.Spec.Containers {
if c.Name == "tailscale" {
// Serve metrics on on <pod-ip>:9001/debug/metrics. If
// we didn't specify Pod IP here, the proxy would, in
// some cases, also listen to its Tailscale IP- we don't
// want folks to start relying on this side-effect as a
// feature.
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"})
ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports, corev1.ContainerPort{Name: "metrics", Protocol: "TCP", HostPort: 9001, ContainerPort: 9001})
if debug {
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env,
// Serve tailscaled's debug metrics on on
// <pod-ip>:9001/debug/metrics. If we didn't specify Pod IP
// here, the proxy would, in some cases, also listen to its
// Tailscale IP- we don't want folks to start relying on this
// side-effect as a feature.
corev1.EnvVar{
Name: "TS_DEBUG_ADDR_PORT",
Value: "$(POD_IP):9001",
},
// TODO(tomhjp): Can remove this env var once 1.76.x is no
// longer supported.
corev1.EnvVar{
Name: "TS_TAILSCALED_EXTRA_ARGS",
Value: "--debug=$(TS_DEBUG_ADDR_PORT)",
},
)
ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports,
corev1.ContainerPort{
Name: "debug",
Protocol: "TCP",
ContainerPort: 9001,
},
)
}
if metrics {
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env,
// Serve client metrics on <pod-ip>:9002/metrics.
corev1.EnvVar{
Name: "TS_LOCAL_ADDR_PORT",
Value: "$(POD_IP):9002",
},
corev1.EnvVar{
Name: "TS_METRICS_ENABLED",
Value: "true",
},
)
ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports,
corev1.ContainerPort{
Name: "metrics",
Protocol: "TCP",
ContainerPort: 9002,
},
)
}
break
}
}
@@ -789,15 +844,9 @@ func readAuthKey(secret *corev1.Secret, key string) (*string, error) {
return origConf.AuthKey, nil
}
// tailscaledConfig takes a proxy config, a newly generated auth key if
// generated and a Secret with the previous proxy state and auth key and
// returns tailscaled configuration and a hash of that configuration.
//
// As of 2024-05-09 it also returns legacy tailscaled config without the
// later added NoStatefulFilter field to support proxies older than cap95.
// TODO (irbekrm): remove the legacy config once we no longer need to support
// versions older than cap94,
// https://tailscale.com/kb/1236/kubernetes-operator#operator-and-proxies
// tailscaledConfig takes a proxy config, a newly generated auth key if generated and a Secret with the previous proxy
// state and auth key and returns tailscaled config files for currently supported proxy versions and a hash of that
// configuration.
func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret) (tailscaledConfigs, error) {
conf := &ipn.ConfigVAlpha{
Version: "alpha0",
@@ -846,10 +895,6 @@ func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *co
// AppConnector config option is only understood by clients of capver 107 and newer.
conf.AppConnector = nil
capVerConfigs[95] = *conf
// StatefulFiltering is only understood by clients of capver 95 and newer.
conf.NoStatefulFiltering.Clear()
capVerConfigs[94] = *conf
return capVerConfigs, nil
}

View File

@@ -125,10 +125,26 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
},
},
}
proxyClassMetrics := &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
Metrics: &tsapi.Metrics{Enable: true},
},
proxyClassWithMetricsDebug := func(metrics bool, debug *bool) *tsapi.ProxyClass {
return &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
Metrics: &tsapi.Metrics{Enable: metrics},
StatefulSet: func() *tsapi.StatefulSet {
if debug == nil {
return nil
}
return &tsapi.StatefulSet{
Pod: &tsapi.Pod{
TailscaleContainer: &tsapi.Container{
Debug: &tsapi.Debug{Enable: *debug},
},
},
}
}(),
},
}
}
var userspaceProxySS, nonUserspaceProxySS appsv1.StatefulSet
@@ -184,7 +200,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
gotSS := applyProxyClassToStatefulSet(proxyClassAllOpts, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Fatalf("Unexpected result applying ProxyClass with all fields set to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
t.Errorf("Unexpected result applying ProxyClass with all fields set to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
}
// 2. Test that a ProxyClass with custom labels and annotations for
@@ -197,7 +213,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
t.Errorf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
}
// 3. Test that a ProxyClass with all fields set gets correctly applied
@@ -221,7 +237,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
wantSS.Spec.Template.Spec.Containers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething"
gotSS = applyProxyClassToStatefulSet(proxyClassAllOpts, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Fatalf("Unexpected result applying ProxyClass with all options to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
t.Errorf("Unexpected result applying ProxyClass with all options to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
}
// 4. Test that a ProxyClass with custom labels and annotations gets correctly applied
@@ -233,16 +249,48 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
t.Errorf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
}
// 5. Test that a ProxyClass with metrics enabled gets correctly applied to a StatefulSet.
// 5. Metrics enabled defaults to enabling both metrics and debug.
wantSS = nonUserspaceProxySS.DeepCopy()
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"})
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "metrics", Protocol: "TCP", ContainerPort: 9001, HostPort: 9001}}
gotSS = applyProxyClassToStatefulSet(proxyClassMetrics, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env,
corev1.EnvVar{Name: "TS_DEBUG_ADDR_PORT", Value: "$(POD_IP):9001"},
corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(TS_DEBUG_ADDR_PORT)"},
corev1.EnvVar{Name: "TS_LOCAL_ADDR_PORT", Value: "$(POD_IP):9002"},
corev1.EnvVar{Name: "TS_METRICS_ENABLED", Value: "true"},
)
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{
{Name: "debug", Protocol: "TCP", ContainerPort: 9001},
{Name: "metrics", Protocol: "TCP", ContainerPort: 9002},
}
gotSS = applyProxyClassToStatefulSet(proxyClassWithMetricsDebug(true, nil), nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Fatalf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
t.Errorf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
}
// 6. Enable _just_ metrics by explicitly disabling debug.
wantSS = nonUserspaceProxySS.DeepCopy()
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env,
corev1.EnvVar{Name: "TS_LOCAL_ADDR_PORT", Value: "$(POD_IP):9002"},
corev1.EnvVar{Name: "TS_METRICS_ENABLED", Value: "true"},
)
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "metrics", Protocol: "TCP", ContainerPort: 9002}}
gotSS = applyProxyClassToStatefulSet(proxyClassWithMetricsDebug(true, ptr.To(false)), nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
}
// 7. Enable _just_ debug without metrics.
wantSS = nonUserspaceProxySS.DeepCopy()
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env,
corev1.EnvVar{Name: "TS_DEBUG_ADDR_PORT", Value: "$(POD_IP):9001"},
corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(TS_DEBUG_ADDR_PORT)"},
)
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "debug", Protocol: "TCP", ContainerPort: 9001}}
gotSS = applyProxyClassToStatefulSet(proxyClassWithMetricsDebug(false, ptr.To(true)), nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
}
}

View File

@@ -70,8 +70,9 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.name"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_UID", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.uid"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
{Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
},
SecurityContext: &corev1.SecurityContext{
@@ -229,8 +230,9 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "true"},
{Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.name"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_UID", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.uid"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
{Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},
{Name: "TS_INTERNAL_APP", Value: opts.app},
@@ -404,12 +406,6 @@ func expectedSecret(t *testing.T, cl client.Client, opts configOpts) *corev1.Sec
if err != nil {
t.Fatalf("error marshalling tailscaled config")
}
conf.NoStatefulFiltering.Clear()
b, err := json.Marshal(conf)
if err != nil {
t.Fatalf("error marshalling tailscaled config")
}
mak.Set(&s.StringData, "tailscaled", string(b))
mak.Set(&s.StringData, "cap-95.hujson", string(bn))
mak.Set(&s.StringData, "cap-107.hujson", string(bnn))
labels := map[string]string{
@@ -662,18 +658,6 @@ func removeTargetPortsFromSvc(svc *corev1.Service) {
func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) {
return func(secret *corev1.Secret) {
t.Helper()
if len(secret.StringData["tailscaled"]) != 0 {
conf := &ipn.ConfigVAlpha{}
if err := json.Unmarshal([]byte(secret.StringData["tailscaled"]), conf); err != nil {
t.Fatalf("error unmarshalling 'tailscaled' contents: %v", err)
}
conf.AuthKey = nil
b, err := json.Marshal(conf)
if err != nil {
t.Fatalf("error marshalling updated 'tailscaled' config: %v", err)
}
mak.Set(&secret.StringData, "tailscaled", string(b))
}
if len(secret.StringData["cap-95.hujson"]) != 0 {
conf := &ipn.ConfigVAlpha{}
if err := json.Unmarshal([]byte(secret.StringData["cap-95.hujson"]), conf); err != nil {

View File

@@ -130,6 +130,15 @@ func tsrRole(tsr *tsapi.Recorder, namespace string) *rbacv1.Role {
fmt.Sprintf("%s-0", tsr.Name), // Contains the node state.
},
},
{
APIGroups: []string{""},
Resources: []string{"events"},
Verbs: []string{
"get",
"create",
"patch",
},
},
},
}
}
@@ -203,6 +212,14 @@ func env(tsr *tsapi.Recorder) []corev1.EnvVar {
},
},
},
{
Name: "POD_UID",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
FieldPath: "metadata.uid",
},
},
},
{
Name: "TS_STATE",
Value: "kube:$(POD_NAME)",

View File

@@ -8,7 +8,6 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/google/uuid from tailscale.com/util/fastuuid
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
github.com/prometheus/client_model/go from github.com/prometheus/client_golang/prometheus+
@@ -74,9 +73,9 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
tailscale.com/util/ctxkey from tailscale.com/tsweb+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/tailcfg
tailscale.com/util/fastuuid from tailscale.com/tsweb
tailscale.com/util/lineiter from tailscale.com/version/distro
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/rands from tailscale.com/tsweb
tailscale.com/util/slicesx from tailscale.com/tailcfg
tailscale.com/util/vizerror from tailscale.com/tailcfg+
tailscale.com/version from tailscale.com/envknob+
@@ -133,7 +132,6 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
crypto/tls from net/http+
crypto/x509 from crypto/tls
crypto/x509/pkix from crypto/x509
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
@@ -164,7 +162,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from math/big+
math/rand/v2 from tailscale.com/util/fastuuid+
math/rand/v2 from internal/concurrent+
mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http
mime/quotedprintable from mime/multipart

View File

@@ -93,8 +93,13 @@ func Run(args []string) (err error) {
args = CleanUpArgs(args)
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
args = []string{"version"}
if len(args) == 1 {
switch args[0] {
case "-V", "--version":
args = []string{"version"}
case "help":
args = []string{"--help"}
}
}
var warnOnce sync.Once

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"net/netip"
"reflect"
"strings"
@@ -1480,3 +1481,33 @@ func TestParseNLArgs(t *testing.T) {
})
}
}
func TestHelpAlias(t *testing.T) {
var stdout, stderr bytes.Buffer
tstest.Replace[io.Writer](t, &Stdout, &stdout)
tstest.Replace[io.Writer](t, &Stderr, &stderr)
gotExit0 := false
defer func() {
if !gotExit0 {
t.Error("expected os.Exit(0) to be called")
return
}
if !strings.Contains(stderr.String(), "SUBCOMMANDS") {
t.Errorf("expected help output to contain SUBCOMMANDS; got stderr=%q; stdout=%q", stderr.String(), stdout.String())
}
}()
defer func() {
if e := recover(); e != nil {
if strings.Contains(fmt.Sprint(e), "unexpected call to os.Exit(0)") {
gotExit0 = true
} else {
t.Errorf("unexpected panic: %v", e)
}
}
}()
err := Run([]string{"help"})
if err != nil {
t.Fatalf("Run: %v", err)
}
}

View File

@@ -36,6 +36,7 @@ import (
"tailscale.com/hostinfo"
"tailscale.com/internal/noiseconn"
"tailscale.com/ipn"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tshttpproxy"
"tailscale.com/paths"
@@ -213,6 +214,7 @@ var debugCmd = &ffcli.Command{
fs := newFlagSet("watch-ipn")
fs.BoolVar(&watchIPNArgs.netmap, "netmap", true, "include netmap in messages")
fs.BoolVar(&watchIPNArgs.initial, "initial", false, "include initial status")
fs.BoolVar(&watchIPNArgs.rateLimit, "rate-limit", true, "rate limit messags")
fs.BoolVar(&watchIPNArgs.showPrivateKey, "show-private-key", false, "include node private key in printed netmap")
fs.IntVar(&watchIPNArgs.count, "count", 0, "exit after printing this many statuses, or 0 to keep going forever")
return fs
@@ -500,6 +502,7 @@ var watchIPNArgs struct {
netmap bool
initial bool
showPrivateKey bool
rateLimit bool
count int
}
@@ -511,6 +514,9 @@ func runWatchIPN(ctx context.Context, args []string) error {
if !watchIPNArgs.showPrivateKey {
mask |= ipn.NotifyNoPrivateKeys
}
if watchIPNArgs.rateLimit {
mask |= ipn.NotifyRateLimit
}
watcher, err := localClient.WatchIPNBus(ctx, mask)
if err != nil {
return err
@@ -845,6 +851,11 @@ func runTS2021(ctx context.Context, args []string) error {
logf = log.Printf
}
netMon, err := netmon.New(logger.WithPrefix(logf, "netmon: "))
if err != nil {
return fmt.Errorf("creating netmon: %w", err)
}
noiseDialer := &controlhttp.Dialer{
Hostname: ts2021Args.host,
HTTPPort: "80",
@@ -854,6 +865,7 @@ func runTS2021(ctx context.Context, args []string) error {
ProtocolVersion: uint16(ts2021Args.version),
Dialer: dialFunc,
Logf: logf,
NetMon: netMon,
}
const tries = 2
for i := range tries {

View File

@@ -17,11 +17,18 @@ import (
)
var (
riskTypes []string
riskLoseSSH = registerRiskType("lose-ssh")
riskAll = registerRiskType("all")
riskTypes []string
riskLoseSSH = registerRiskType("lose-ssh")
riskMacAppConnector = registerRiskType("mac-app-connector")
riskAll = registerRiskType("all")
)
const riskMacAppConnectorMessage = `
You are trying to configure an app connector on macOS, which is not officially supported due to system limitations. This may result in performance and reliability issues.
Do not use a macOS app connector for any mission-critical purposes. For the best experience, Linux is the only recommended platform for app connectors.
`
func registerRiskType(riskType string) string {
riskTypes = append(riskTypes, riskType)
return riskType

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"net/netip"
"os/exec"
"runtime"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
@@ -203,6 +204,12 @@ func runSet(ctx context.Context, args []string) (retErr error) {
}
}
if runtime.GOOS == "darwin" && maskedPrefs.AppConnector.Advertise {
if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, setArgs.acceptedRisks); err != nil {
return err
}
}
if maskedPrefs.RunSSHSet {
wantSSH, haveSSH := maskedPrefs.RunSSH, curPrefs.RunSSH
if err := presentSSHToggleRisk(wantSSH, haveSSH, setArgs.acceptedRisks); err != nil {

View File

@@ -98,9 +98,9 @@ func printPolicySettings(policy *setting.Snapshot) {
origin = o.String()
}
if err := setting.Error(); err != nil {
fmt.Fprintf(w, "%s\t%s\t\t{%s}\n", k, origin, err)
fmt.Fprintf(w, "%s\t%s\t\t{%v}\n", k, origin, err)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t\n", k, origin, setting.Value())
fmt.Fprintf(w, "%s\t%s\t%v\t\n", k, origin, setting.Value())
}
}
w.Flush()

View File

@@ -379,6 +379,12 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
return false, nil, err
}
if runtime.GOOS == "darwin" && env.upArgs.advertiseConnector {
if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, env.upArgs.acceptedRisks); err != nil {
return false, nil, err
}
}
if env.upArgs.forceReauth && isSSHOverTailscale() {
if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will result in your SSH session disconnecting.`, env.upArgs.acceptedRisks); err != nil {
return false, nil, err

View File

@@ -134,14 +134,13 @@ func runWindowsService(pol *logpolicy.Policy) error {
logger.Logf(log.Printf).JSON(1, "SupportInfo", osdiag.SupportInfo(osdiag.LogSupportInfoReasonStartup))
}()
if logSCMInteractions, _ := syspolicy.GetBoolean(syspolicy.LogSCMInteractions, false); logSCMInteractions {
syslog, err := eventlog.Open(serviceName)
if err == nil {
syslogf = func(format string, args ...any) {
if syslog, err := eventlog.Open(serviceName); err == nil {
syslogf = func(format string, args ...any) {
if logSCMInteractions, _ := syspolicy.GetBoolean(syspolicy.LogSCMInteractions, false); logSCMInteractions {
syslog.Info(0, fmt.Sprintf(format, args...))
}
defer syslog.Close()
}
defer syslog.Close()
}
syslogf("Service entering svc.Run")
@@ -160,10 +159,7 @@ func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, ch
changes <- svc.Status{State: svc.StartPending}
syslogf("Service start pending")
svcAccepts := svc.AcceptStop
if flushDNSOnSessionUnlock, _ := syspolicy.GetBoolean(syspolicy.FlushDNSOnSessionUnlock, false); flushDNSOnSessionUnlock {
svcAccepts |= svc.AcceptSessionChange
}
svcAccepts := svc.AcceptStop | svc.AcceptSessionChange
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -371,13 +367,15 @@ func handleSessionChange(chgRequest svc.ChangeRequest) {
return
}
log.Printf("Received WTS_SESSION_UNLOCK event, initiating DNS flush.")
go func() {
err := dns.Flush()
if err != nil {
log.Printf("Error flushing DNS on session unlock: %v", err)
}
}()
if flushDNSOnSessionUnlock, _ := syspolicy.GetBoolean(syspolicy.FlushDNSOnSessionUnlock, false); flushDNSOnSessionUnlock {
log.Printf("Received WTS_SESSION_UNLOCK event, initiating DNS flush.")
go func() {
err := dns.Flush()
if err != nil {
log.Printf("Error flushing DNS on session unlock: %v", err)
}
}()
}
}
var (

View File

@@ -17,7 +17,6 @@ import (
"golang.org/x/net/http2"
"tailscale.com/control/controlhttp"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/internal/noiseconn"
"tailscale.com/net/dnscache"
@@ -30,7 +29,6 @@ import (
"tailscale.com/util/mak"
"tailscale.com/util/multierr"
"tailscale.com/util/singleflight"
"tailscale.com/util/testenv"
)
// NoiseClient provides a http.Client to connect to tailcontrol over
@@ -107,11 +105,6 @@ type NoiseOpts struct {
DialPlan func() *tailcfg.ControlDialPlan
}
// controlIsPlaintext is whether we should assume that the controlplane is only accessible
// over plaintext HTTP (as the first hop, before the ts2021 encryption begins).
// This is used by some tests which don't have a real TLS certificate.
var controlIsPlaintext = envknob.RegisterBool("TS_CONTROL_IS_PLAINTEXT_HTTP")
// NewNoiseClient returns a new noiseClient for the provided server and machine key.
// serverURL is of the form https://<host>:<port> (no trailing slash).
//
@@ -129,7 +122,7 @@ func NewNoiseClient(opts NoiseOpts) (*NoiseClient, error) {
if u.Scheme == "http" {
httpPort = port
httpsPort = "443"
if (testenv.InTest() || controlIsPlaintext()) && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost") {
if u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" {
httpsPort = ""
}
} else {

View File

@@ -13,7 +13,6 @@ import (
"crypto/x509"
"errors"
"fmt"
"sync"
"time"
"github.com/tailscale/certstore"
@@ -22,11 +21,6 @@ import (
"tailscale.com/util/syspolicy"
)
var getMachineCertificateSubjectOnce struct {
sync.Once
v string // Subject of machine certificate to search for
}
// getMachineCertificateSubject returns the exact name of a Subject that needs
// to be present in an identity's certificate chain to sign a RegisterRequest,
// formatted as per pkix.Name.String(). The Subject may be that of the identity
@@ -37,11 +31,8 @@ var getMachineCertificateSubjectOnce struct {
//
// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
func getMachineCertificateSubject() string {
getMachineCertificateSubjectOnce.Do(func() {
getMachineCertificateSubjectOnce.v, _ = syspolicy.GetString(syspolicy.MachineCertificateSubject, "")
})
return getMachineCertificateSubjectOnce.v
machineCertSubject, _ := syspolicy.GetString(syspolicy.MachineCertificateSubject, "")
return machineCertSubject
}
var (

View File

@@ -246,7 +246,7 @@ func (a *Dialer) dial(ctx context.Context) (*ClientConn, error) {
results[i].conn = nil // so we don't close it in the defer
return conn, nil
}
merr := multierr.New(errs...)
merr := multierr.New(multierr.DeduplicateContextErrors(errs)...)
// If we get here, then we didn't get anywhere with our dial plan; fall back to just using DNS.
a.logf("controlhttp: failed dialing using DialPlan, falling back to DNS; errs=%s", merr.Error())

View File

@@ -76,6 +76,8 @@ type Dialer struct {
// dropped.
Logf logger.Logf
// NetMon is the [netmon.Monitor] to use for this Dialer. It must be
// non-nil.
NetMon *netmon.Monitor
// HealthTracker, if non-nil, is the health tracker to use.

View File

@@ -25,6 +25,7 @@ import (
"tailscale.com/control/controlbase"
"tailscale.com/control/controlhttp/controlhttpcommon"
"tailscale.com/control/controlhttp/controlhttpserver"
"tailscale.com/health"
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/net/socks5"
@@ -228,6 +229,7 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
omitCertErrorLogging: true,
testFallbackDelay: fallbackDelay,
Clock: clock,
HealthTracker: new(health.Tracker),
}
if param.httpInDial {
@@ -729,6 +731,7 @@ func TestDialPlan(t *testing.T) {
omitCertErrorLogging: true,
testFallbackDelay: 50 * time.Millisecond,
Clock: clock,
HealthTracker: new(health.Tracker),
}
conn, err := a.dial(ctx)

View File

@@ -44,6 +44,14 @@ spec:
value: "{{TS_DEST_IP}}"
- name: TS_AUTH_ONCE
value: "true"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -13,3 +13,6 @@ rules:
resourceNames: ["{{TS_KUBE_SECRET}}"]
resources: ["secrets"]
verbs: ["get", "update", "patch"]
- apiGroups: [""] # "" indicates the core API group
resources: ["events"]
verbs: ["get", "create", "patch"]

View File

@@ -26,6 +26,14 @@ spec:
name: tailscale-auth
key: TS_AUTHKEY
optional: true
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -28,6 +28,14 @@ spec:
optional: true
- name: TS_ROUTES
value: "{{TS_ROUTES}}"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -27,3 +27,11 @@ spec:
name: tailscale-auth
key: TS_AUTHKEY
optional: true
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid

View File

@@ -15,16 +15,18 @@
<string id="SINCE_V1_58">Tailscale version 1.58.0 and later</string>
<string id="SINCE_V1_62">Tailscale version 1.62.0 and later</string>
<string id="SINCE_V1_74">Tailscale version 1.74.0 and later</string>
<string id="SINCE_V1_78">Tailscale version 1.78.0 and later</string>
<string id="Tailscale_Category">Tailscale</string>
<string id="UI_Category">UI customization</string>
<string id="Settings_Category">Settings</string>
<string id="LoginURL">Require using a specific Tailscale coordination server</string>
<string id="LoginURL_Help"><![CDATA[This policy can be used to require the use of a particular Tailscale coordination server.
See https://tailscale.com/kb/1315/mdm-keys#set-a-custom-control-server-url for more details.
If you configure this policy, set it to the URL of your coordination server, beginning with https:// and ending with no trailing slash. If blank or "https://controlplane.tailscale.com", the default coordination server will be required.
If you enable this policy, set it to the URL of your coordination server, beginning with https:// and ending with no trailing slash. If blank or "https://controlplane.tailscale.com", the default coordination server will be required.
If you disable this policy, the Tailscale SaaS coordination server will be used by default, but a non-standard Tailscale coordination server can be configured using the CLI.]]></string>
If you disable or do not configure this policy, the Tailscale SaaS coordination server will be used by default, but a non-standard Tailscale coordination server can be configured using the CLI.
See https://tailscale.com/kb/1315/mdm-keys#set-a-custom-control-server-url for more details.]]></string>
<string id="LogTarget">Require using a specific Tailscale log server</string>
<string id="LogTarget_Help"><![CDATA[This policy can be used to require the use of a non-standard log server.
Please note that using a non-standard log server will limit Tailscale Support's ability to diagnose problems.
@@ -34,15 +36,16 @@ If you configure this policy, set it to the URL of your log server, beginning wi
If you disable this policy, the Tailscale standard log server will be used by default, but a non-standard Tailscale log server can be configured using the TS_LOG_TARGET environment variable.]]></string>
<string id="Tailnet">Specify which Tailnet should be used for Login</string>
<string id="Tailnet_Help"><![CDATA[This policy can be used to suggest or require a specific tailnet when opening the login page.
See https://tailscale.com/kb/1315/mdm-keys#set-a-suggested-or-required-tailnet for more details.
To suggest a tailnet at login time, set this to the name of the tailnet, as shown in the top-left of the admin panel, such as "example.com". That tailnet's SSO button will be shown prominently, along with the option to select a different tailnet.
To require logging in to a particular tailnet, add the "required:" prefix, such as "required:example.com". The result is similar to the suggested tailnet but there will be no option to choose a different tailnet.
If you configure this policy, set it to the name of the tailnet, possibly with the "required:" prefix, as described above.
If you enable this policy, set it to the name of the tailnet, possibly with the "required:" prefix, as described above.
If you disable this policy, the standard login page will be used.]]></string>
If you disable or do not configure this policy, the standard login page will be used.
See https://tailscale.com/kb/1315/mdm-keys#set-a-suggested-or-required-tailnet for more details.]]></string>
<string id="AuthKey">Specify the auth key to authenticate devices without user interaction</string>
<string id="AuthKey_Help"><![CDATA[This policy allows specifying the default auth key to be used when registering new devices without requiring sign-in via a web browser, unless the user specifies a different auth key via the CLI.
@@ -52,85 +55,101 @@ While MDM solutions tend to offer better control over who can access the policy
Only consider this option after carefully reviewing the organization's security posture. For example, ensure you configure the auth keys specifically for the tag of the device and that access control policies only grant necessary access between the tailnet and the tagged device. Additionally, consider using short-lived auth keys, one-time auth keys (with one GPO/MDM configuration per device), Device Approval, and/or Tailnet lock to minimize risk. If you suspect an auth key has been compromised, revoke the auth key immediately.
If you configure this policy setting and specify an auth key, it will be used to authenticate the device unless the device is already logged in or an auth key is explicitly specified via the CLI.
If you enable this policy setting and specify an auth key, it will be used to authenticate the device unless the device is already logged in or an auth key is explicitly specified via the CLI.
If you disable or do not configure this policy setting, an interactive user login will be required..
See https://tailscale.com/kb/1315/mdm-keys#set-an-auth-key for more details.]]></string>
<string id="ExitNodeID">Require using a specific Exit Node</string>
<string id="ExitNodeID_Help"><![CDATA[This policy can be used to require always using the specified Exit Node whenever the Tailscale client is connected.
See https://tailscale.com/kb/1315/mdm-keys#force-an-exit-node-to-always-be-used and https://tailscale.com/kb/1103/exit-nodes for more details.
If you enable this policy, set it to the ID of an exit node. The ID is visible on the Machines page of the admin console, or can be queried using the Tailscale API. If the specified exit node is unavailable, this device will have no Internet access unless Tailscale is disconnected.
If you enable this policy, set it to the ID of an exit node. The ID is visible on the Machines page of the admin console, or can be queried using the Tailscale API. If the specified exit node is unavailable, this device will have no Internet access unless Tailscale is disconnected. Alternatively, you can set it to "auto:any" (without quotes), which allows the Tailscale client to automatically select the most suitable exit node.
If you disable this policy or supply an empty exit node ID, then usage of exit nodes will be disallowed.
If you do not configure this policy, no exit node will be used by default but an exit node (if one is available and permitted by ACLs) can be chosen by the user if desired.]]></string>
If you do not configure this policy, no exit node will be used by default but an exit node (if one is available and permitted by ACLs) can be chosen by the user if desired.
See https://tailscale.com/kb/1315/mdm-keys#force-an-exit-node-to-always-be-used and https://tailscale.com/kb/1103/exit-nodes for more details.]]></string>
<string id="AllowedSuggestedExitNodes">Limit automated Exit Node suggestions to specific nodes</string>
<string id="AllowedSuggestedExitNodes_Help"><![CDATA[This policy setting allows configuring a pool of exit nodes from which the Tailscale client will automatically select the most suitable suggested exit node when required. The suggested exit node is displayed in the GUI and CLI and is automatically selected and enforced when the "Require using a specific Exit Node" policy setting is enabled and set to "auto:any".
If you enable this policy setting, suggestions will be limited to exit nodes with the specified IDs. The IDs are visible on the Machines page of the admin console, or can be queried using the Tailscale API.
If you disable or do not configure this policy setting, no limitations will apply, and all available exit nodes will be considered when selecting the most suitable suggested node.
See https://tailscale.com/kb/1315/mdm-keys#suggest-allowed-forced-exit-nodes and https://tailscale.com/kb/1103/exit-nodes for more details.]]></string>
<string id="AllowIncomingConnections">Allow incoming connections</string>
<string id="AllowIncomingConnections_Help"><![CDATA[This policy can be used to require that the Allow Incoming Connections setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-to-allow-incoming-connections and https://tailscale.com/kb/1072/client-preferences#allow-incoming-connections for more details.
If you enable this policy, then Allow Incoming Connections is always enabled and the menu option is hidden.
If you disable this policy, then Allow Incoming Connections is always disabled and the menu option is hidden.
If you do not configure this policy, then Allow Incoming Connections depends on what is selected in the Preferences submenu.]]></string>
If you do not configure this policy, then Allow Incoming Connections depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-to-allow-incoming-connections and https://tailscale.com/kb/1072/client-preferences#allow-incoming-connections for more details.]]></string>
<string id="UnattendedMode">Run Tailscale in Unattended Mode</string>
<string id="UnattendedMode_Help"><![CDATA[This policy can be used to require that the Run Unattended setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-unattended-mode and https://tailscale.com/kb/1088/run-unattended for more details.
If you enable this policy, then Run Unattended is always enabled and the menu option is hidden.
If you disable this policy, then Run Unattended is always disabled and the menu option is hidden.
If you do not configure this policy, then Run Unattended depends on what is selected in the Preferences submenu.]]></string>
If you do not configure this policy, then Run Unattended depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-unattended-mode and https://tailscale.com/kb/1088/run-unattended for more details.]]></string>
<string id="ExitNodeAllowLANAccess">Allow Local Network Access when an Exit Node is in use</string>
<string id="ExitNodeAllowLANAccess_Help"><![CDATA[This policy can be used to require that the Allow Local Network Access setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#toggle-local-network-access-when-an-exit-node-is-in-use and https://tailscale.com/kb/1103/exit-nodes#step-4-use-the-exit-node for more details.
If you enable this policy, then Allow Local Network Access is always enabled and the menu option is hidden.
If you disable this policy, then Allow Local Network Access is always disabled and the menu option is hidden.
If you do not configure this policy, then Allow Local Network Access depends on what is selected in the Exit Node submenu.]]></string>
If you do not configure this policy, then Allow Local Network Access depends on what is selected in the Exit Node submenu.
See https://tailscale.com/kb/1315/mdm-keys#toggle-local-network-access-when-an-exit-node-is-in-use and https://tailscale.com/kb/1103/exit-nodes#step-4-use-the-exit-node for more details.]]></string>
<string id="UseTailscaleDNSSettings">Use Tailscale DNS Settings</string>
<string id="UseTailscaleDNSSettings_Help"><![CDATA[This policy can be used to require that Use Tailscale DNS is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-uses-tailscale-dns-settings for more details.
If you enable this policy, then Use Tailscale DNS is always enabled and the menu option is hidden.
If you disable this policy, then Use Tailscale DNS is always disabled and the menu option is hidden.
If you do not configure this policy, then Use Tailscale DNS depends on what is selected in the Preferences submenu.]]></string>
If you do not configure this policy, then Use Tailscale DNS depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-uses-tailscale-dns-settings for more details.]]></string>
<string id="UseTailscaleSubnets">Use Tailscale Subnets</string>
<string id="UseTailscaleSubnets_Help"><![CDATA[This policy can be used to require that Use Tailscale Subnets is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-accepts-tailscale-subnets or https://tailscale.com/kb/1019/subnets for more details.
If you enable this policy, then Use Tailscale Subnets is always enabled and the menu option is hidden.
If you disable this policy, then Use Tailscale Subnets is always disabled and the menu option is hidden.
If you do not configure this policy, then Use Tailscale Subnets depends on what is selected in the Preferences submenu.]]></string>
If you do not configure this policy, then Use Tailscale Subnets depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-accepts-tailscale-subnets or https://tailscale.com/kb/1019/subnets for more details.]]></string>
<string id="InstallUpdates">Automatically install updates</string>
<string id="InstallUpdates_Help"><![CDATA[This policy can be used to require that Automatically Install Updates is configured a certain way.
See https://tailscale.com/kb/1067/update#auto-updates for more details.
If you enable this policy, then Automatically Install Updates is always enabled and the menu option is hidden.
If you disable this policy, then Automatically Install Updates is always disabled and the menu option is hidden.
If you do not configure this policy, then Automatically Install Updates depends on what is selected in the Preferences submenu.]]></string>
If you do not configure this policy, then Automatically Install Updates depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1067/update#auto-updates for more details.]]></string>
<string id="AdvertiseExitNode">Run Tailscale as an Exit Node</string>
<string id="AdvertiseExitNode_Help"><![CDATA[This policy can be used to require that Run Exit Node is configured a certain way.
See https://tailscale.com/kb/1103/exit-nodes for more details.
If you enable this policy, then Run Exit Node is always enabled and the menu option is hidden.
If you disable this policy, then Run Exit Node is always disabled and the menu option is hidden.
If you do not configure this policy, then Run Exit Node depends on what is selected in the Exit Node submenu.]]></string>
<string id="AdminPanel">Show the "Admin Panel" menu item</string>
<string id="AdminPanel_Help"><![CDATA[This policy can be used to show or hide the Admin Console item in the Tailscale Menu.
If you do not configure this policy, then Run Exit Node depends on what is selected in the Exit Node submenu.
See https://tailscale.com/kb/1103/exit-nodes for more details.]]></string>
<string id="AdminConsole">Show the "Admin Console" menu item</string>
<string id="AdminConsole_Help"><![CDATA[This policy can be used to show or hide the Admin Console item in the Tailscale Menu.
If you enable or don't configure this policy, the Admin Console item will be shown in the Tailscale menu when available.
@@ -143,49 +162,55 @@ If you enable or don't configure this policy, the Network Devices submenu will b
If you disable this policy, the Network Devices submenu will be hidden from the Tailscale menu. This does not affect other devices' visibility in the CLI.]]></string>
<string id="TestMenu">Show the "Debug" submenu</string>
<string id="TestMenu_Help"><![CDATA[This policy can be used to show or hide the Debug submenu of the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-debug-menu for more details.
If you enable or don't configure this policy, the Debug submenu will be shown in the Tailscale menu when opened while holding Ctrl.
If you disable this policy, the Debug submenu will be hidden from the Tailscale menu.]]></string>
If you disable this policy, the Debug submenu will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-debug-menu for more details.]]></string>
<string id="UpdateMenu">Show the "Update Available" menu item</string>
<string id="UpdateMenu_Help"><![CDATA[This policy can be used to show or hide the Update Available item in the Tailscale Menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-update-menu for more details.
If you enable or don't configure this policy, the Update Available item will be shown in the Tailscale menu when there is an update.
If you disable this policy, the Update Available item will be hidden from the Tailscale menu.]]></string>
If you disable this policy, the Update Available item will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-update-menu for more details.]]></string>
<string id="RunExitNode">Show the "Run Exit Node" menu item</string>
<string id="RunExitNode_Help"><![CDATA[This policy can be used to show or hide the Run Exit Node item in the Exit Node submenu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-run-as-exit-node-menu-item for more details.
This does not affect using the CLI to enable or disable advertising an exit node. If you wish to enable or disable this feature, see the Run Exit Node policy in the Settings category.
If you enable or don't configure this policy, the Run Exit Node item will be shown in the Exit Node submenu.
If you disable this policy, the Run Exit Node item will be hidden from the Exit Node submenu.]]></string>
If you disable this policy, the Run Exit Node item will be hidden from the Exit Node submenu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-run-as-exit-node-menu-item for more details.]]></string>
<string id="PreferencesMenu">Show the "Preferences" submenu</string>
<string id="PreferencesMenu_Help"><![CDATA[This policy can be used to show or hide the Preferences submenu of the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-preferences-menu for more details.
This does not affect using the CLI to modify that menu's preferences. If you wish to control those, look at the policies in the Settings category.
If you enable or don't configure this policy, the Preferences submenu will be shown in the Tailscale menu.
If you disable this policy, the Preferences submenu will be hidden from the Tailscale menu.]]></string>
If you disable this policy, the Preferences submenu will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-preferences-menu for more details.]]></string>
<string id="ExitNodesPicker">Show the "Exit Node" submenu</string>
<string id="ExitNodesPicker_Help"><![CDATA[This policy can be used to show or hide the Exit Node submenu of the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-exit-node-picker for more details.
This does not affect using the CLI to select or stop using an exit node. If you wish to control exit node usage, look at the "Require using a specific Exit Node" policy in the Settings category.
If you enable or don't configure this policy, the Exit Node submenu will be shown in the Tailscale menu.
If you disable this policy, the Exit Node submenu will be hidden from the Tailscale menu.]]></string>
If you disable this policy, the Exit Node submenu will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-exit-node-picker for more details.]]></string>
<string id="KeyExpirationNotice">Specify a custom key expiration notification time</string>
<string id="KeyExpirationNotice_Help"><![CDATA[This policy can be used to configure how soon the notification appears before key expiry.
See https://tailscale.com/kb/1315/mdm-keys#set-the-key-expiration-notice-period for more details.
If you enable this policy and supply a valid time interval, the key expiry notification will begin to display when the current key has less than that amount of time remaining.
If you disable or don't configure this policy, the default time period will be used (as of Tailscale 1.56, this is 24 hours).]]></string>
If you disable or don't configure this policy, the default time period will be used (as of Tailscale 1.56, this is 24 hours).
See https://tailscale.com/kb/1315/mdm-keys#set-the-key-expiration-notice-period for more details.]]></string>
<string id="LogSCMInteractions">Log extra details about service events</string>
<string id="LogSCMInteractions_Help"><![CDATA[This policy can be used to enable additional logging related to Service Control Manager for debugging purposes.
This should only be enabled if recommended by Tailscale Support.
@@ -202,13 +227,14 @@ If you enable this policy, the DNS cache will be flushed on session unlock in ad
If you disable or don't configure this policy, the DNS cache is managed normally.]]></string>
<string id="PostureChecking">Collect data for posture checking</string>
<string id="PostureChecking_Help"><![CDATA[This policy can be used to require that the Posture Checking setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#enable-gathering-device-posture-data and https://tailscale.com/kb/1326/device-identity for more details.
If you enable this policy, then data collection is always enabled.
If you disable this policy, then data collection is always disabled.
If you do not configure this policy, then data collection depends on if it has been enabled from the CLI (as of Tailscale 1.56), it may be present in the GUI in later versions.]]></string>
If you do not configure this policy, then data collection depends on if it has been enabled from the CLI (as of Tailscale 1.56), it may be present in the GUI in later versions.
See https://tailscale.com/kb/1315/mdm-keys#enable-gathering-device-posture-data and https://tailscale.com/kb/1326/device-identity for more details.]]></string>
<string id="ManagedBy">Show the "Managed By {Organization}" menu item</string>
<string id="ManagedBy_Help"><![CDATA[Use this policy to configure the “Managed By {Organization}” item in the Tailscale Menu.
@@ -244,6 +270,9 @@ See https://tailscale.com/kb/1315/mdm-keys#set-your-organization-name for more d
<label>Exit Node:</label>
</textBox>
</presentation>
<presentation id="AllowedSuggestedExitNodes">
<listBox refId="AllowedSuggestedExitNodesList">Target IDs:</listBox>
</presentation>
<presentation id="ManagedBy">
<textBox refId="ManagedByOrganization">
<label>Organization Name:</label>

View File

@@ -50,6 +50,10 @@
displayName="$(string.SINCE_V1_74)">
<and><reference ref="TAILSCALE_PRODUCT"/></and>
</definition>
<definition name="SINCE_V1_78"
displayName="$(string.SINCE_V1_78)">
<and><reference ref="TAILSCALE_PRODUCT"/></and>
</definition>
</definitions>
</supportedOn>
<categories>
@@ -94,7 +98,14 @@
<parentCategory ref="Settings_Category" />
<supportedOn ref="SINCE_V1_56" />
<elements>
<text id="ExitNodeIDPrompt" valueName="ExitNodeID" required="true" />
<text id="ExitNodeIDPrompt" valueName="ExitNodeID" required="true" />>
</elements>
</policy>
<policy name="AllowedSuggestedExitNodes" class="Machine" displayName="$(string.AllowedSuggestedExitNodes)" explainText="$(string.AllowedSuggestedExitNodes_Help)" presentation="$(presentation.AllowedSuggestedExitNodes)" key="Software\Policies\Tailscale\AllowedSuggestedExitNodes">
<parentCategory ref="Settings_Category" />
<supportedOn ref="SINCE_V1_78" />
<elements>
<list id="AllowedSuggestedExitNodesList" />
</elements>
</policy>
<policy name="AllowIncomingConnections" class="Machine" displayName="$(string.AllowIncomingConnections)" explainText="$(string.AllowIncomingConnections_Help)" key="Software\Policies\Tailscale" valueName="AllowIncomingConnections">
@@ -197,7 +208,7 @@
<decimal value="0" />
</disabledValue>
</policy>
<policy name="AdminPanel" class="Machine" displayName="$(string.AdminPanel)" explainText="$(string.AdminPanel_Help)" key="Software\Policies\Tailscale" valueName="AdminPanel">
<policy name="AdminConsole" class="Both" displayName="$(string.AdminConsole)" explainText="$(string.AdminConsole_Help)" key="Software\Policies\Tailscale" valueName="AdminConsole">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -207,7 +218,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="NetworkDevices" class="Machine" displayName="$(string.NetworkDevices)" explainText="$(string.NetworkDevices_Help)" key="Software\Policies\Tailscale" valueName="NetworkDevices">
<policy name="NetworkDevices" class="Both" displayName="$(string.NetworkDevices)" explainText="$(string.NetworkDevices_Help)" key="Software\Policies\Tailscale" valueName="NetworkDevices">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -217,7 +228,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="TestMenu" class="Machine" displayName="$(string.TestMenu)" explainText="$(string.TestMenu_Help)" key="Software\Policies\Tailscale" valueName="TestMenu">
<policy name="TestMenu" class="Both" displayName="$(string.TestMenu)" explainText="$(string.TestMenu_Help)" key="Software\Policies\Tailscale" valueName="TestMenu">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -227,7 +238,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="UpdateMenu" class="Machine" displayName="$(string.UpdateMenu)" explainText="$(string.UpdateMenu_Help)" key="Software\Policies\Tailscale" valueName="UpdateMenu">
<policy name="UpdateMenu" class="Both" displayName="$(string.UpdateMenu)" explainText="$(string.UpdateMenu_Help)" key="Software\Policies\Tailscale" valueName="UpdateMenu">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -237,7 +248,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="RunExitNode" class="Machine" displayName="$(string.RunExitNode)" explainText="$(string.RunExitNode_Help)" key="Software\Policies\Tailscale" valueName="RunExitNode">
<policy name="RunExitNode" class="Both" displayName="$(string.RunExitNode)" explainText="$(string.RunExitNode_Help)" key="Software\Policies\Tailscale" valueName="RunExitNode">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -247,7 +258,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="PreferencesMenu" class="Machine" displayName="$(string.PreferencesMenu)" explainText="$(string.PreferencesMenu_Help)" key="Software\Policies\Tailscale" valueName="PreferencesMenu">
<policy name="PreferencesMenu" class="Both" displayName="$(string.PreferencesMenu)" explainText="$(string.PreferencesMenu_Help)" key="Software\Policies\Tailscale" valueName="PreferencesMenu">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -257,7 +268,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="ExitNodesPicker" class="Machine" displayName="$(string.ExitNodesPicker)" explainText="$(string.ExitNodesPicker_Help)" key="Software\Policies\Tailscale" valueName="ExitNodesPicker">
<policy name="ExitNodesPicker" class="Both" displayName="$(string.ExitNodesPicker)" explainText="$(string.ExitNodesPicker_Help)" key="Software\Policies\Tailscale" valueName="ExitNodesPicker">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -267,7 +278,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="ManagedBy" class="Machine" displayName="$(string.ManagedBy)" explainText="$(string.ManagedBy_Help)" presentation="$(presentation.ManagedBy)" key="Software\Policies\Tailscale">
<policy name="ManagedBy" class="Both" displayName="$(string.ManagedBy)" explainText="$(string.ManagedBy_Help)" presentation="$(presentation.ManagedBy)" key="Software\Policies\Tailscale">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_62" />
<elements>
@@ -276,7 +287,7 @@
<text id="ManagedBySupportURL" valueName="ManagedByURL" />
</elements>
</policy>
<policy name="KeyExpirationNotice" class="Machine" displayName="$(string.KeyExpirationNotice)" explainText="$(string.KeyExpirationNotice_Help)" presentation="$(presentation.KeyExpirationNotice)" key="Software\Policies\Tailscale">
<policy name="KeyExpirationNotice" class="Both" displayName="$(string.KeyExpirationNotice)" explainText="$(string.KeyExpirationNotice_Help)" presentation="$(presentation.KeyExpirationNotice)" key="Software\Policies\Tailscale">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_50" />
<elements>

2
go.mod
View File

@@ -85,7 +85,7 @@ require (
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4
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-20240905161824-799c1978fafc
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e
github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0

4
go.sum
View File

@@ -941,8 +941,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:t
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-20240905161824-799c1978fafc h1:cezaQN9pvKVaw56Ma5qr/G646uKIYP0yQf+OyWN/okc=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ=
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=

View File

@@ -1 +1 @@
bf15628b759344c6fc7763795a405ba65b8be5d7
96578f73d04e1a231fa2a495ad3fa97747785bc6

View File

@@ -73,6 +73,8 @@ const (
NotifyInitialOutgoingFiles // if set, the first Notify message (sent immediately) will contain the current Taildrop OutgoingFiles
NotifyInitialHealthState // if set, the first Notify message (sent immediately) will contain the current health.State of the client
NotifyRateLimit // if set, rate limit spammy netmap updates to every few seconds
)
// Notify is a communication from a backend (e.g. tailscaled) to a frontend
@@ -100,7 +102,6 @@ type Notify struct {
NetMap *netmap.NetworkMap // if non-nil, the new or current netmap
Engine *EngineStatus // if non-nil, the new or current wireguard stats
BrowseToURL *string // if non-nil, UI should open a browser right now
BackendLogID *string // if non-nil, the public logtail ID used by backend
// FilesWaiting if non-nil means that files are buffered in
// the Tailscale daemon and ready for local transfer to the
@@ -173,9 +174,6 @@ func (n Notify) String() string {
if n.BrowseToURL != nil {
sb.WriteString("URL=<...> ")
}
if n.BackendLogID != nil {
sb.WriteString("BackendLogID ")
}
if n.FilesWaiting != nil {
sb.WriteString("FilesWaiting ")
}

160
ipn/ipnlocal/bus.go Normal file
View File

@@ -0,0 +1,160 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"time"
"tailscale.com/ipn"
"tailscale.com/tstime"
)
type rateLimitingBusSender struct {
fn func(*ipn.Notify) (keepGoing bool)
lastFlush time.Time // last call to fn, or zero value if none
interval time.Duration // 0 to flush immediately; non-zero to rate limit sends
clock tstime.DefaultClock // non-nil for testing
didSendTestHook func() // non-nil for testing
// pending, if non-nil, is the pending notification that we
// haven't sent yet. We own this memory to mutate.
pending *ipn.Notify
// flushTimer is non-nil if the timer is armed.
flushTimer tstime.TimerController // effectively a *time.Timer
flushTimerC <-chan time.Time // ... said ~Timer's C chan
}
func (s *rateLimitingBusSender) close() {
if s.flushTimer != nil {
s.flushTimer.Stop()
}
}
func (s *rateLimitingBusSender) flushChan() <-chan time.Time {
return s.flushTimerC
}
func (s *rateLimitingBusSender) flush() (keepGoing bool) {
if n := s.pending; n != nil {
s.pending = nil
return s.flushNotify(n)
}
return true
}
func (s *rateLimitingBusSender) flushNotify(n *ipn.Notify) (keepGoing bool) {
s.lastFlush = s.clock.Now()
return s.fn(n)
}
// send conditionally sends n to the underlying fn, possibly rate
// limiting it, depending on whether s.interval is set, and whether
// n is a notable notification that the client (typically a GUI) would
// want to act on (render) immediately.
//
// It returns whether the caller should keep looping.
//
// The passed-in memory 'n' is owned by the caller and should
// not be mutated.
func (s *rateLimitingBusSender) send(n *ipn.Notify) (keepGoing bool) {
if s.interval <= 0 {
// No rate limiting case.
return s.fn(n)
}
if isNotableNotify(n) {
// Notable notifications are always sent immediately.
// But first send any boring one that was pending.
// TODO(bradfitz): there might be a boring one pending
// with a NetMap or Engine field that is redundant
// with the new one (n) with NetMap or Engine populated.
// We should clear the pending one's NetMap/Engine in
// that case. Or really, merge the two, but mergeBoringNotifies
// only handles the case of both sides being boring.
// So for now, flush both.
if !s.flush() {
return false
}
return s.flushNotify(n)
}
s.pending = mergeBoringNotifies(s.pending, n)
d := s.clock.Now().Sub(s.lastFlush)
if d > s.interval {
return s.flush()
}
nextFlushIn := s.interval - d
if s.flushTimer == nil {
s.flushTimer, s.flushTimerC = s.clock.NewTimer(nextFlushIn)
} else {
s.flushTimer.Reset(nextFlushIn)
}
return true
}
func (s *rateLimitingBusSender) Run(ctx context.Context, ch <-chan *ipn.Notify) {
for {
select {
case <-ctx.Done():
return
case n, ok := <-ch:
if !ok {
return
}
if !s.send(n) {
return
}
if f := s.didSendTestHook; f != nil {
f()
}
case <-s.flushChan():
if !s.flush() {
return
}
}
}
}
// mergeBoringNotify merges new notify 'src' into possibly-nil 'dst',
// either mutating 'dst' or allocating a new one if 'dst' is nil,
// returning the merged result.
//
// dst and src must both be "boring" (i.e. not notable per isNotifiableNotify).
func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
if dst == nil {
dst = &ipn.Notify{Version: src.Version}
}
if src.NetMap != nil {
dst.NetMap = src.NetMap
}
if src.Engine != nil {
dst.Engine = src.Engine
}
return dst
}
// isNotableNotify reports whether n is a "notable" notification that
// should be sent on the IPN bus immediately (e.g. to GUIs) without
// rate limiting it for a few seconds.
//
// It effectively reports whether n contains any field set that's
// not NetMap or Engine.
func isNotableNotify(n *ipn.Notify) bool {
if n == nil {
return false
}
return n.State != nil ||
n.SessionID != "" ||
n.BrowseToURL != nil ||
n.LocalTCPPort != nil ||
n.ClientVersion != nil ||
n.Prefs != nil ||
n.ErrMessage != nil ||
n.LoginFinished != nil ||
!n.DriveShares.IsNil() ||
n.Health != nil ||
len(n.IncomingFiles) > 0 ||
len(n.OutgoingFiles) > 0 ||
n.FilesWaiting != nil
}

220
ipn/ipnlocal/bus_test.go Normal file
View File

@@ -0,0 +1,220 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"reflect"
"slices"
"testing"
"time"
"tailscale.com/drive"
"tailscale.com/ipn"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
)
func TestIsNotableNotify(t *testing.T) {
tests := []struct {
name string
notify *ipn.Notify
want bool
}{
{"nil", nil, false},
{"empty", &ipn.Notify{}, false},
{"version", &ipn.Notify{Version: "foo"}, false},
{"netmap", &ipn.Notify{NetMap: new(netmap.NetworkMap)}, false},
{"engine", &ipn.Notify{Engine: new(ipn.EngineStatus)}, false},
}
// Then for all other fields, assume they're notable.
// We use reflect to catch fields that might be added in the future without
// remembering to update the [isNotableNotify] function.
rt := reflect.TypeFor[ipn.Notify]()
for i := range rt.NumField() {
n := &ipn.Notify{}
sf := rt.Field(i)
switch sf.Name {
case "_", "NetMap", "Engine", "Version":
// Already covered above or not applicable.
continue
case "DriveShares":
n.DriveShares = views.SliceOfViews[*drive.Share, drive.ShareView](make([]*drive.Share, 1))
default:
rf := reflect.ValueOf(n).Elem().Field(i)
switch rf.Kind() {
case reflect.Pointer:
rf.Set(reflect.New(rf.Type().Elem()))
case reflect.String:
rf.SetString("foo")
case reflect.Slice:
rf.Set(reflect.MakeSlice(rf.Type(), 1, 1))
default:
t.Errorf("unhandled field kind %v for %q", rf.Kind(), sf.Name)
}
}
tests = append(tests, struct {
name string
notify *ipn.Notify
want bool
}{
name: "field-" + rt.Field(i).Name,
notify: n,
want: true,
})
}
for _, tt := range tests {
if got := isNotableNotify(tt.notify); got != tt.want {
t.Errorf("%v: got %v; want %v", tt.name, got, tt.want)
}
}
}
type rateLimitingBusSenderTester struct {
tb testing.TB
got []*ipn.Notify
clock *tstest.Clock
s *rateLimitingBusSender
}
func (st *rateLimitingBusSenderTester) init() {
if st.s != nil {
return
}
st.clock = tstest.NewClock(tstest.ClockOpts{
Start: time.Unix(1731777537, 0), // time I wrote this test :)
})
st.s = &rateLimitingBusSender{
clock: tstime.DefaultClock{Clock: st.clock},
fn: func(n *ipn.Notify) bool {
st.got = append(st.got, n)
return true
},
}
}
func (st *rateLimitingBusSenderTester) send(n *ipn.Notify) {
st.tb.Helper()
st.init()
if !st.s.send(n) {
st.tb.Fatal("unexpected send failed")
}
}
func (st *rateLimitingBusSenderTester) advance(d time.Duration) {
st.tb.Helper()
st.clock.Advance(d)
select {
case <-st.s.flushChan():
if !st.s.flush() {
st.tb.Fatal("unexpected flush failed")
}
default:
}
}
func TestRateLimitingBusSender(t *testing.T) {
nm1 := &ipn.Notify{NetMap: new(netmap.NetworkMap)}
nm2 := &ipn.Notify{NetMap: new(netmap.NetworkMap)}
eng1 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
eng2 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
t.Run("unbuffered", func(t *testing.T) {
st := &rateLimitingBusSenderTester{tb: t}
st.send(nm1)
st.send(nm2)
st.send(eng1)
st.send(eng2)
if !slices.Equal(st.got, []*ipn.Notify{nm1, nm2, eng1, eng2}) {
t.Errorf("got %d items; want 4 specific ones, unmodified", len(st.got))
}
})
t.Run("buffered", func(t *testing.T) {
st := &rateLimitingBusSenderTester{tb: t}
st.init()
st.s.interval = 1 * time.Second
st.send(&ipn.Notify{Version: "initial"})
if len(st.got) != 1 {
t.Fatalf("got %d items; expected 1 (first to flush immediately)", len(st.got))
}
st.send(nm1)
st.send(nm2)
st.send(eng1)
st.send(eng2)
if len(st.got) != 1 {
if len(st.got) != 1 {
t.Fatalf("got %d items; expected still just that first 1", len(st.got))
}
}
// But moving the clock should flush the rest, collasced into one new one.
st.advance(5 * time.Second)
if len(st.got) != 2 {
t.Fatalf("got %d items; want 2", len(st.got))
}
gotn := st.got[1]
if gotn.NetMap != nm2.NetMap {
t.Errorf("got wrong NetMap; got %p", gotn.NetMap)
}
if gotn.Engine != eng2.Engine {
t.Errorf("got wrong Engine; got %p", gotn.Engine)
}
if t.Failed() {
t.Logf("failed Notify was: %v", logger.AsJSON(gotn))
}
})
// Test the Run method
t.Run("run", func(t *testing.T) {
st := &rateLimitingBusSenderTester{tb: t}
st.init()
st.s.interval = 1 * time.Second
st.s.lastFlush = st.clock.Now() // pretend we just flushed
flushc := make(chan *ipn.Notify, 1)
st.s.fn = func(n *ipn.Notify) bool {
flushc <- n
return true
}
didSend := make(chan bool, 2)
st.s.didSendTestHook = func() { didSend <- true }
waitSend := func() {
select {
case <-didSend:
case <-time.After(5 * time.Second):
t.Error("timeout waiting for call to send")
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
incoming := make(chan *ipn.Notify, 2)
go func() {
incoming <- nm1
waitSend()
incoming <- nm2
waitSend()
st.advance(5 * time.Second)
select {
case n := <-flushc:
if n.NetMap != nm2.NetMap {
t.Errorf("got wrong NetMap; got %p", n.NetMap)
}
case <-time.After(10 * time.Second):
t.Error("timeout")
}
cancel()
}()
st.s.Run(ctx, incoming)
})
}

View File

@@ -77,6 +77,9 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
// Linux netfilter.
req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
// VIP services.
req("GET /vip-services"): handleC2NVIPServicesGet,
}
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
@@ -269,6 +272,12 @@ func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNoContent)
}
func handleC2NVIPServicesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /vip-services received")
json.NewEncoder(w).Encode(b.VIPServices())
}
func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received")

View File

@@ -9,6 +9,7 @@ import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
@@ -86,7 +87,6 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/empty"
"tailscale.com/types/key"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/netmap"
@@ -105,6 +105,7 @@ import (
"tailscale.com/util/rands"
"tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/util/syspolicy/rsop"
"tailscale.com/util/systemd"
"tailscale.com/util/testenv"
"tailscale.com/util/uniq"
@@ -177,27 +178,28 @@ type watchSession struct {
// state machine generates events back out to zero or more components.
type LocalBackend struct {
// Elements that are thread-safe or constant after construction.
ctx context.Context // canceled by Close
ctxCancel context.CancelFunc // cancels ctx
logf logger.Logf // general logging
keyLogf logger.Logf // for printing list of peers on change
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
health *health.Tracker // always non-nil
metrics metrics
e wgengine.Engine // non-nil; TODO(bradfitz): remove; use sys
store ipn.StateStore // non-nil; TODO(bradfitz): remove; use sys
dialer *tsdial.Dialer // non-nil; TODO(bradfitz): remove; use sys
pushDeviceToken syncs.AtomicValue[string]
backendLogID logid.PublicID
unregisterNetMon func()
unregisterHealthWatch func()
portpoll *portlist.Poller // may be nil
portpollOnce sync.Once // guards starting readPoller
varRoot string // or empty if SetVarRoot never called
logFlushFunc func() // or nil if SetLogFlusher wasn't called
em *expiryManager // non-nil
sshAtomicBool atomic.Bool
ctx context.Context // canceled by Close
ctxCancel context.CancelFunc // cancels ctx
logf logger.Logf // general logging
keyLogf logger.Logf // for printing list of peers on change
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
health *health.Tracker // always non-nil
metrics metrics
e wgengine.Engine // non-nil; TODO(bradfitz): remove; use sys
store ipn.StateStore // non-nil; TODO(bradfitz): remove; use sys
dialer *tsdial.Dialer // non-nil; TODO(bradfitz): remove; use sys
pushDeviceToken syncs.AtomicValue[string]
backendLogID logid.PublicID
unregisterNetMon func()
unregisterHealthWatch func()
unregisterSysPolicyWatch func()
portpoll *portlist.Poller // may be nil
portpollOnce sync.Once // guards starting readPoller
varRoot string // or empty if SetVarRoot never called
logFlushFunc func() // or nil if SetLogFlusher wasn't called
em *expiryManager // non-nil
sshAtomicBool atomic.Bool
// webClientAtomicBool controls whether the web client is running. This should
// be true unless the disable-web-client node attribute has been set.
webClientAtomicBool atomic.Bool
@@ -353,6 +355,12 @@ type LocalBackend struct {
// avoid unnecessary churn between multiple equally-good options.
lastSuggestedExitNode tailcfg.StableNodeID
// allowedSuggestedExitNodes is a set of exit nodes permitted by the most recent
// [syspolicy.AllowedSuggestedExitNodes] value. The allowedSuggestedExitNodesMu
// mutex guards access to this set.
allowedSuggestedExitNodesMu sync.Mutex
allowedSuggestedExitNodes set.Set[tailcfg.StableNodeID]
// refreshAutoExitNode indicates if the exit node should be recomputed when the next netcheck report is available.
refreshAutoExitNode bool
@@ -409,7 +417,7 @@ type clientGen func(controlclient.Options) (controlclient.Client, error)
// but is not actually running.
//
// If dialer is nil, a new one is made.
func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, loginFlags controlclient.LoginFlags) (*LocalBackend, error) {
func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, loginFlags controlclient.LoginFlags) (_ *LocalBackend, err error) {
e := sys.Engine.Get()
store := sys.StateStore.Get()
dialer := sys.Dialer.Get()
@@ -484,6 +492,15 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
}
}
if b.unregisterSysPolicyWatch, err = b.registerSysPolicyWatch(); err != nil {
return nil, err
}
defer func() {
if err != nil {
b.unregisterSysPolicyWatch()
}
}()
netMon := sys.NetMon.Get()
b.sockstatLogger, err = sockstatlog.NewLogger(logpolicy.LogsDir(logf), logf, logID, netMon, sys.HealthTracker())
if err != nil {
@@ -980,6 +997,7 @@ func (b *LocalBackend) Shutdown() {
b.unregisterNetMon()
b.unregisterHealthWatch()
b.unregisterSysPolicyWatch()
if cc != nil {
cc.Shutdown()
}
@@ -1488,10 +1506,10 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
b.logf("SetControlClientStatus failed to select auto exit node: %v", err)
}
}
if setExitNodeID(prefs, curNetMap, b.lastSuggestedExitNode) {
if applySysPolicy(prefs, b.lastSuggestedExitNode) {
prefsChanged = true
}
if applySysPolicy(prefs) {
if setExitNodeID(prefs, curNetMap) {
prefsChanged = true
}
@@ -1657,12 +1675,37 @@ var preferencePolicies = []preferencePolicyInfo{
// applySysPolicy overwrites configured preferences with policies that may be
// configured by the system administrator in an OS-specific way.
func applySysPolicy(prefs *ipn.Prefs) (anyChange bool) {
func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID) (anyChange bool) {
if controlURL, err := syspolicy.GetString(syspolicy.ControlURL, prefs.ControlURL); err == nil && prefs.ControlURL != controlURL {
prefs.ControlURL = controlURL
anyChange = true
}
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
if shouldAutoExitNode() && lastSuggestedExitNode != "" {
exitNodeID = lastSuggestedExitNode
}
// Note: when exitNodeIDStr == "auto" && lastSuggestedExitNode == "",
// then exitNodeID is now "auto" which will never match a peer's node ID.
// When there is no a peer matching the node ID, traffic will blackhole,
// preventing accidental non-exit-node usage when a policy is in effect that requires an exit node.
if prefs.ExitNodeID != exitNodeID || prefs.ExitNodeIP.IsValid() {
anyChange = true
}
prefs.ExitNodeID = exitNodeID
prefs.ExitNodeIP = netip.Addr{}
} else if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" {
exitNodeIP, err := netip.ParseAddr(exitNodeIPStr)
if exitNodeIP.IsValid() && err == nil {
if prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP {
anyChange = true
}
prefs.ExitNodeID = ""
prefs.ExitNodeIP = exitNodeIP
}
}
for _, opt := range preferencePolicies {
if po, err := syspolicy.GetPreferenceOption(opt.key); err == nil {
curVal := opt.get(prefs.View())
@@ -1677,6 +1720,54 @@ func applySysPolicy(prefs *ipn.Prefs) (anyChange bool) {
return anyChange
}
// registerSysPolicyWatch subscribes to syspolicy change notifications
// and immediately applies the effective syspolicy settings to the current profile.
func (b *LocalBackend) registerSysPolicyWatch() (unregister func(), err error) {
if unregister, err = syspolicy.RegisterChangeCallback(b.sysPolicyChanged); err != nil {
return nil, fmt.Errorf("syspolicy: LocalBacked failed to register policy change callback: %v", err)
}
if prefs, anyChange := b.applySysPolicy(); anyChange {
b.logf("syspolicy: changed initial profile prefs: %v", prefs.Pretty())
}
b.refreshAllowedSuggestions()
return unregister, nil
}
// applySysPolicy overwrites the current profile's preferences with policies
// that may be configured by the system administrator in an OS-specific way.
//
// b.mu must not be held.
func (b *LocalBackend) applySysPolicy() (_ ipn.PrefsView, anyChange bool) {
unlock := b.lockAndGetUnlock()
prefs := b.pm.CurrentPrefs().AsStruct()
if !applySysPolicy(prefs, b.lastSuggestedExitNode) {
unlock.UnlockEarly()
return prefs.View(), false
}
return b.setPrefsLockedOnEntry(prefs, unlock), true
}
// sysPolicyChanged is a callback triggered by syspolicy when it detects
// a change in one or more syspolicy settings.
func (b *LocalBackend) sysPolicyChanged(policy *rsop.PolicyChange) {
if policy.HasChanged(syspolicy.AllowedSuggestedExitNodes) {
b.refreshAllowedSuggestions()
// Re-evaluate exit node suggestion now that the policy setting has changed.
b.mu.Lock()
_, err := b.suggestExitNodeLocked(nil)
b.mu.Unlock()
if err != nil && !errors.Is(err, ErrNoPreferredDERP) {
b.logf("failed to select auto exit node: %v", err)
}
// If [syspolicy.ExitNodeID] is set to `auto:any`, the suggested exit node ID
// will be used when [applySysPolicy] updates the current profile's prefs.
}
if prefs, anyChange := b.applySysPolicy(); anyChange {
b.logf("syspolicy: changed profile prefs: %v", prefs.Pretty())
}
}
var _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
// UpdateNetmapDelta implements controlclient.NetmapDeltaUpdater.
@@ -1769,30 +1860,7 @@ func (b *LocalBackend) updateNetmapDeltaLocked(muts []netmap.NodeMutation) (hand
// setExitNodeID updates prefs to reference an exit node by ID, rather
// than by IP. It returns whether prefs was mutated.
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap, lastSuggestedExitNode tailcfg.StableNodeID) (prefsChanged bool) {
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
if shouldAutoExitNode() && lastSuggestedExitNode != "" {
exitNodeID = lastSuggestedExitNode
}
// Note: when exitNodeIDStr == "auto" && lastSuggestedExitNode == "", then exitNodeID is now "auto" which will never match a peer's node ID.
// When there is no a peer matching the node ID, traffic will blackhole, preventing accidental non-exit-node usage when a policy is in effect that requires an exit node.
changed := prefs.ExitNodeID != exitNodeID || prefs.ExitNodeIP.IsValid()
prefs.ExitNodeID = exitNodeID
prefs.ExitNodeIP = netip.Addr{}
return changed
}
oldExitNodeID := prefs.ExitNodeID
if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" {
exitNodeIP, err := netip.ParseAddr(exitNodeIPStr)
if exitNodeIP.IsValid() && err == nil {
prefsChanged = prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP
prefs.ExitNodeID = ""
prefs.ExitNodeIP = exitNodeIP
}
}
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
if nm == nil {
// No netmap, can't resolve anything.
return false
@@ -1810,6 +1878,7 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap, lastSuggestedExitNod
prefsChanged = true
}
oldExitNodeID := prefs.ExitNodeID
for _, peer := range nm.Peers {
for _, addr := range peer.Addresses().All() {
if !addr.IsSingleIP() || addr.Addr() != prefs.ExitNodeIP {
@@ -1819,7 +1888,7 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap, lastSuggestedExitNod
// reference it directly for next time.
prefs.ExitNodeID = peer.StableID()
prefs.ExitNodeIP = netip.Addr{}
return oldExitNodeID != prefs.ExitNodeID
return prefsChanged || oldExitNodeID != prefs.ExitNodeID
}
}
@@ -2156,10 +2225,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
blid := b.backendLogID.String()
b.logf("Backend: logs: be:%v fe:%v", blid, opts.FrontendLogID)
b.sendToLocked(ipn.Notify{
BackendLogID: &blid,
Prefs: &prefs,
}, allClients)
b.sendToLocked(ipn.Notify{Prefs: &prefs}, allClients)
if !loggedOut && (b.hasNodeKeyLocked() || confWantRunning) {
// If we know that we're either logged in or meant to be
@@ -2782,20 +2848,17 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
go b.pollRequestEngineStatus(ctx)
}
// TODO(marwan-at-work): check err
// TODO(marwan-at-work): streaming background logs?
defer b.DeleteForegroundSession(sessionID)
for {
select {
case <-ctx.Done():
return
case n := <-ch:
if !fn(n) {
return
}
}
sender := &rateLimitingBusSender{fn: fn}
defer sender.close()
if mask&ipn.NotifyRateLimit != 0 {
sender.interval = 3 * time.Second
}
sender.Run(ctx, ch)
}
// pollRequestEngineStatus calls b.e.RequestStatus every 2 seconds until ctx
@@ -3677,11 +3740,16 @@ func updateExitNodeUsageWarning(p ipn.PrefsView, state *netmon.State, healthTrac
}
func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
tryingToUseExitNode := p.ExitNodeIP.IsValid() || p.ExitNodeID != ""
if !tryingToUseExitNode {
return nil
}
if err := featureknob.CanUseExitNode(); err != nil {
return err
}
if (p.ExitNodeIP.IsValid() || p.ExitNodeID != "") && p.AdvertisesExitNode() {
if p.AdvertisesExitNode() {
return errors.New("Cannot advertise an exit node and use an exit node at the same time.")
}
return nil
@@ -3849,12 +3917,12 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
if oldp.Valid() {
newp.Persist = oldp.Persist().AsStruct() // caller isn't allowed to override this
}
// setExitNodeID returns whether it updated b.prefs, but
// everything in this function treats b.prefs as completely new
// anyway. No-op if no exit node resolution is needed.
setExitNodeID(newp, netMap, b.lastSuggestedExitNode)
// applySysPolicy does likewise so we can also ignore its return value.
applySysPolicy(newp)
// applySysPolicyToPrefsLocked returns whether it updated newp,
// but everything in this function treats b.prefs as completely new
// anyway, so its return value can be ignored here.
applySysPolicy(newp, b.lastSuggestedExitNode)
// setExitNodeID does likewise. No-op if no exit node resolution is needed.
setExitNodeID(newp, netMap)
// We do this to avoid holding the lock while doing everything else.
oldHi := b.hostinfo
@@ -3891,10 +3959,14 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
}
prefs := newp.View()
if err := b.pm.SetPrefs(prefs, ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
np := b.pm.CurrentProfile().NetworkProfile
if netMap != nil {
np = ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}
}
if err := b.pm.SetPrefs(prefs, np); err != nil {
b.logf("failed to save new controlclient state: %v", err)
}
@@ -4888,6 +4960,14 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
}
hi.SSH_HostKeys = sshHostKeys
services := vipServicesFromPrefs(prefs)
if len(services) > 0 {
buf, _ := json.Marshal(services)
hi.ServicesHash = fmt.Sprintf("%02x", sha256.Sum256(buf))
} else {
hi.ServicesHash = ""
}
// The Hostinfo.WantIngress field tells control whether this node wants to
// be wired up for ingress connections. If harmless if it's accidentally
// true; the actual policy is controlled in tailscaled by ServeConfig. But
@@ -7141,7 +7221,7 @@ func (b *LocalBackend) suggestExitNodeLocked(netMap *netmap.NetworkMap) (respons
lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx)
prevSuggestion := b.lastSuggestedExitNode
res, err := suggestExitNode(lastReport, netMap, prevSuggestion, randomRegion, randomNode, getAllowedSuggestions())
res, err := suggestExitNode(lastReport, netMap, prevSuggestion, randomRegion, randomNode, b.getAllowedSuggestions())
if err != nil {
return res, err
}
@@ -7155,6 +7235,22 @@ func (b *LocalBackend) SuggestExitNode() (response apitype.ExitNodeSuggestionRes
return b.suggestExitNodeLocked(nil)
}
// getAllowedSuggestions returns a set of exit nodes permitted by the most recent
// [syspolicy.AllowedSuggestedExitNodes] value. Callers must not mutate the returned set.
func (b *LocalBackend) getAllowedSuggestions() set.Set[tailcfg.StableNodeID] {
b.allowedSuggestedExitNodesMu.Lock()
defer b.allowedSuggestedExitNodesMu.Unlock()
return b.allowedSuggestedExitNodes
}
// refreshAllowedSuggestions rebuilds the set of permitted exit nodes
// from the current [syspolicy.AllowedSuggestedExitNodes] value.
func (b *LocalBackend) refreshAllowedSuggestions() {
b.allowedSuggestedExitNodesMu.Lock()
defer b.allowedSuggestedExitNodesMu.Unlock()
b.allowedSuggestedExitNodes = fillAllowedSuggestions()
}
// selectRegionFunc returns a DERP region from the slice of candidate regions.
// The value is returned, not the slice index.
type selectRegionFunc func(views.Slice[int]) int
@@ -7164,8 +7260,6 @@ type selectRegionFunc func(views.Slice[int]) int
// choice.
type selectNodeFunc func(nodes views.Slice[tailcfg.NodeView], last tailcfg.StableNodeID) tailcfg.NodeView
var getAllowedSuggestions = lazy.SyncFunc(fillAllowedSuggestions)
func fillAllowedSuggestions() set.Set[tailcfg.StableNodeID] {
nodes, err := syspolicy.GetStringArray(syspolicy.AllowedSuggestedExitNodes, nil)
if err != nil {
@@ -7485,3 +7579,42 @@ func maybeUsernameOf(actor ipnauth.Actor) string {
}
return username
}
// VIPServices returns the list of tailnet services that this node
// is serving as a destination for.
// The returned memory is owned by the caller.
func (b *LocalBackend) VIPServices() []*tailcfg.VIPService {
b.mu.Lock()
defer b.mu.Unlock()
return vipServicesFromPrefs(b.pm.CurrentPrefs())
}
func vipServicesFromPrefs(prefs ipn.PrefsView) []*tailcfg.VIPService {
// keyed by service name
var services map[string]*tailcfg.VIPService
// TODO(naman): this envknob will be replaced with service-specific port
// information once we start storing that.
var allPortsServices []string
if env := envknob.String("TS_DEBUG_ALLPORTS_SERVICES"); env != "" {
allPortsServices = strings.Split(env, ",")
}
for _, s := range allPortsServices {
mak.Set(&services, s, &tailcfg.VIPService{
Name: s,
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
})
}
for _, s := range prefs.AdvertiseServices().AsSlice() {
if services == nil || services[s] == nil {
mak.Set(&services, s, &tailcfg.VIPService{
Name: s,
})
}
services[s].Active = true
}
return slices.Collect(maps.Values(services))
}

View File

@@ -30,6 +30,7 @@ import (
"tailscale.com/control/controlclient"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
@@ -457,6 +458,7 @@ func newTestLocalBackendWithSys(t testing.TB, sys *tsd.System) *LocalBackend {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(lb.Shutdown)
return lb
}
@@ -1787,10 +1789,13 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
b := newTestBackend(t)
policyStore := source.NewTestStoreOf(t,
source.TestSettingOf(syspolicy.ExitNodeID, test.exitNodeID),
source.TestSettingOf(syspolicy.ExitNodeIP, test.exitNodeIP),
)
policyStore := source.NewTestStore(t)
if test.exitNodeIDKey {
policyStore.SetStrings(source.TestSettingOf(syspolicy.ExitNodeID, test.exitNodeID))
}
if test.exitNodeIPKey {
policyStore.SetStrings(source.TestSettingOf(syspolicy.ExitNodeIP, test.exitNodeIP))
}
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
if test.nm == nil {
@@ -1804,7 +1809,16 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
b.netMap = test.nm
b.pm = pm
b.lastSuggestedExitNode = test.lastSuggestedExitNode
changed := setExitNodeID(b.pm.prefs.AsStruct(), test.nm, tailcfg.StableNodeID(test.lastSuggestedExitNode))
prefs := b.pm.prefs.AsStruct()
if changed := applySysPolicy(prefs, test.lastSuggestedExitNode) || setExitNodeID(prefs, test.nm); changed != test.prefsChanged {
t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed)
}
// Both [LocalBackend.SetPrefsForTest] and [LocalBackend.EditPrefs]
// apply syspolicy settings to the current profile's preferences. Therefore,
// we pass the current, unmodified preferences and expect the effective
// preferences to change.
b.SetPrefsForTest(pm.CurrentPrefs().AsStruct())
if got := b.pm.prefs.ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) {
@@ -1817,10 +1831,6 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
} else if got.String() != test.exitNodeIPWant {
t.Errorf("got %v want %v", got, test.exitNodeIPWant)
}
if changed != test.prefsChanged {
t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed)
}
})
}
}
@@ -2330,7 +2340,7 @@ func TestApplySysPolicy(t *testing.T) {
t.Run("unit", func(t *testing.T) {
prefs := tt.prefs.Clone()
gotAnyChange := applySysPolicy(prefs)
gotAnyChange := applySysPolicy(prefs, "")
if gotAnyChange && prefs.Equals(&tt.prefs) {
t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty())
@@ -2478,7 +2488,7 @@ func TestPreferencePolicyInfo(t *testing.T) {
prefs := defaultPrefs.AsStruct()
pp.set(prefs, tt.initialValue)
gotAnyChange := applySysPolicy(prefs)
gotAnyChange := applySysPolicy(prefs, "")
if gotAnyChange != tt.wantChange {
t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantChange)
@@ -4108,6 +4118,7 @@ func newLocalBackendWithTestControl(t *testing.T, enableLogging bool, newControl
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(b.Shutdown)
b.DisablePortMapperForTest()
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
@@ -4464,3 +4475,213 @@ func TestConfigFileReload(t *testing.T) {
t.Fatalf("got %q; want %q", hn, "bar")
}
}
func TestGetVIPServices(t *testing.T) {
tests := []struct {
name string
advertised []string
mapped []string
want []*tailcfg.VIPService
}{
{
"advertised-only",
[]string{"svc:abc", "svc:def"},
[]string{},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Active: true,
},
{
Name: "svc:def",
Active: true,
},
},
},
{
"mapped-only",
[]string{},
[]string{"svc:abc"},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
},
},
},
{
"mapped-and-advertised",
[]string{"svc:abc"},
[]string{"svc:abc"},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Active: true,
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
},
},
},
{
"mapped-and-advertised-separately",
[]string{"svc:def"},
[]string{"svc:abc"},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
},
{
Name: "svc:def",
Active: true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
envknob.Setenv("TS_DEBUG_ALLPORTS_SERVICES", strings.Join(tt.mapped, ","))
prefs := &ipn.Prefs{
AdvertiseServices: tt.advertised,
}
got := vipServicesFromPrefs(prefs.View())
slices.SortFunc(got, func(a, b *tailcfg.VIPService) int {
return strings.Compare(a.Name, b.Name)
})
if !reflect.DeepEqual(tt.want, got) {
t.Logf("want:")
for _, s := range tt.want {
t.Logf("%+v", s)
}
t.Logf("got:")
for _, s := range got {
t.Logf("%+v", s)
}
t.Fail()
return
}
})
}
}
func TestUpdatePrefsOnSysPolicyChange(t *testing.T) {
const enableLogging = false
type fieldChange struct {
name string
want any
}
wantPrefsChanges := func(want ...fieldChange) *wantedNotification {
return &wantedNotification{
name: "Prefs",
cond: func(t testing.TB, actor ipnauth.Actor, n *ipn.Notify) bool {
if n.Prefs != nil {
prefs := reflect.Indirect(reflect.ValueOf(n.Prefs.AsStruct()))
for _, f := range want {
got := prefs.FieldByName(f.name).Interface()
if !reflect.DeepEqual(got, f.want) {
t.Errorf("%v: got %v; want %v", f.name, got, f.want)
}
}
}
return n.Prefs != nil
},
}
}
unexpectedPrefsChange := func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
if n.Prefs != nil {
t.Errorf("Unexpected Prefs: %v", n.Prefs.Pretty())
return true
}
return false
}
tests := []struct {
name string
initialPrefs *ipn.Prefs
stringSettings []source.TestSetting[string]
want *wantedNotification
}{
{
name: "ShieldsUp/True",
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.EnableIncomingConnections, "never")},
want: wantPrefsChanges(fieldChange{"ShieldsUp", true}),
},
{
name: "ShieldsUp/False",
initialPrefs: &ipn.Prefs{ShieldsUp: true},
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.EnableIncomingConnections, "always")},
want: wantPrefsChanges(fieldChange{"ShieldsUp", false}),
},
{
name: "ExitNodeID",
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.ExitNodeID, "foo")},
want: wantPrefsChanges(fieldChange{"ExitNodeID", tailcfg.StableNodeID("foo")}),
},
{
name: "EnableRunExitNode",
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.EnableRunExitNode, "always")},
want: wantPrefsChanges(fieldChange{"AdvertiseRoutes", []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}}),
},
{
name: "Multiple",
initialPrefs: &ipn.Prefs{
ExitNodeAllowLANAccess: true,
},
stringSettings: []source.TestSetting[string]{
source.TestSettingOf(syspolicy.EnableServerMode, "always"),
source.TestSettingOf(syspolicy.ExitNodeAllowLANAccess, "never"),
source.TestSettingOf(syspolicy.ExitNodeIP, "127.0.0.1"),
},
want: wantPrefsChanges(
fieldChange{"ForceDaemon", true},
fieldChange{"ExitNodeAllowLANAccess", false},
fieldChange{"ExitNodeIP", netip.MustParseAddr("127.0.0.1")},
),
},
{
name: "NoChange",
initialPrefs: &ipn.Prefs{
CorpDNS: true,
ExitNodeID: "foo",
AdvertiseRoutes: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
},
stringSettings: []source.TestSetting[string]{
source.TestSettingOf(syspolicy.EnableTailscaleDNS, "always"),
source.TestSettingOf(syspolicy.ExitNodeID, "foo"),
source.TestSettingOf(syspolicy.EnableRunExitNode, "always"),
},
want: nil, // syspolicy settings match the preferences; no change notification is expected.
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
syspolicy.RegisterWellKnownSettingsForTest(t)
store := source.NewTestStoreOf[string](t)
syspolicy.MustRegisterStoreForTest(t, "TestSource", setting.DeviceScope, store)
lb := newLocalBackendWithTestControl(t, enableLogging, func(tb testing.TB, opts controlclient.Options) controlclient.Client {
return newClient(tb, opts)
})
if tt.initialPrefs != nil {
lb.SetPrefsForTest(tt.initialPrefs)
}
if err := lb.Start(ipn.Options{}); err != nil {
t.Fatalf("(*LocalBackend).Start(): %v", err)
}
nw := newNotificationWatcher(t, lb, &ipnauth.TestActor{})
if tt.want != nil {
nw.watch(0, []wantedNotification{*tt.want})
} else {
nw.watch(0, nil, unexpectedPrefsChange)
}
store.SetStrings(tt.stringSettings...)
nw.check()
})
}
}

View File

@@ -309,6 +309,7 @@ func TestStateMachine(t *testing.T) {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(b.Shutdown)
b.DisablePortMapperForTest()
var cc, previousCC *mockControl
@@ -942,6 +943,7 @@ func TestEditPrefsHasNoKeys(t *testing.T) {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(b.Shutdown)
b.hostinfo = &tailcfg.Hostinfo{OS: "testos"}
b.pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
@@ -1023,6 +1025,7 @@ func TestWGEngineStatusRace(t *testing.T) {
sys.Set(eng)
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
c.Assert(err, qt.IsNil)
t.Cleanup(b.Shutdown)
var cc *mockControl
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {

View File

@@ -349,6 +349,7 @@ func newTestLocalBackend(t testing.TB) *ipnlocal.LocalBackend {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(lb.Shutdown)
return lb
}

View File

@@ -7,6 +7,7 @@ package kubestore
import (
"context"
"fmt"
"log"
"net"
"os"
"strings"
@@ -19,8 +20,18 @@ import (
"tailscale.com/types/logger"
)
// TODO(irbekrm): should we bump this? should we have retries? See tailscale/tailscale#13024
const timeout = 5 * time.Second
const (
// timeout is the timeout for a single state update that includes calls to the API server to write or read a
// state Secret and emit an Event.
timeout = 30 * time.Second
reasonTailscaleStateUpdated = "TailscaledStateUpdated"
reasonTailscaleStateLoaded = "TailscaleStateLoaded"
reasonTailscaleStateUpdateFailed = "TailscaleStateUpdateFailed"
reasonTailscaleStateLoadFailed = "TailscaleStateLoadFailed"
eventTypeWarning = "Warning"
eventTypeNormal = "Normal"
)
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
type Store struct {
@@ -35,7 +46,7 @@ type Store struct {
// New returns a new Store that persists to the named Secret.
func New(_ logger.Logf, secretName string) (*Store, error) {
c, err := kubeclient.New()
c, err := kubeclient.New("tailscale-state-store")
if err != nil {
return nil, err
}
@@ -72,13 +83,22 @@ func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
// WriteState implements the StateStore interface.
func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
if err == nil {
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
}
if err != nil {
if err := s.client.Event(ctx, eventTypeWarning, reasonTailscaleStateUpdateFailed, err.Error()); err != nil {
log.Printf("kubestore: error creating tailscaled state update Event: %v", err)
}
} else {
if err := s.client.Event(ctx, eventTypeNormal, reasonTailscaleStateUpdated, "Successfully updated tailscaled state Secret"); err != nil {
log.Printf("kubestore: error creating tailscaled state Event: %v", err)
}
}
cancel()
}()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
secret, err := s.client.GetSecret(ctx, s.secretName)
if err != nil {
@@ -107,7 +127,7 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
Value: map[string][]byte{sanitizeKey(id): bs},
},
}
if err := s.client.JSONPatchSecret(ctx, s.secretName, m); err != nil {
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
return fmt.Errorf("error patching Secret %s with a /data field: %v", s.secretName, err)
}
return nil
@@ -119,8 +139,8 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
Value: bs,
},
}
if err := s.client.JSONPatchSecret(ctx, s.secretName, m); err != nil {
return fmt.Errorf("error patching Secret %s with /data/%s field", s.secretName, sanitizeKey(id))
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
return fmt.Errorf("error patching Secret %s with /data/%s field: %v", s.secretName, sanitizeKey(id), err)
}
return nil
}
@@ -131,7 +151,7 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
return err
}
func (s *Store) loadState() error {
func (s *Store) loadState() (err error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@@ -140,8 +160,14 @@ func (s *Store) loadState() error {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 404 {
return ipn.ErrStateNotExist
}
if err := s.client.Event(ctx, eventTypeWarning, reasonTailscaleStateLoadFailed, err.Error()); err != nil {
log.Printf("kubestore: error creating Event: %v", err)
}
return err
}
if err := s.client.Event(ctx, eventTypeNormal, reasonTailscaleStateLoaded, "Successfully loaded tailscaled state from Secret"); err != nil {
log.Printf("kubestore: error creating Event: %v", err)
}
s.memory.LoadFromMap(secret.Data)
return nil
}

View File

@@ -146,6 +146,7 @@ _Appears in:_
| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#pullpolicy-v1-core)_ | Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | Enum: [Always Never IfNotPresent] <br /> |
| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#resourcerequirements-v1-core)_ | Container resource requirements.<br />By default Tailscale Kubernetes operator does not apply any resource<br />requirements. The amount of resources required wil depend on the<br />amount of resources the operator needs to parse, usage patterns and<br />cluster size.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources | | |
| `securityContext` _[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#securitycontext-v1-core)_ | Container security context.<br />Security context specified here will override the security context by the operator.<br />By default the operator:<br />- sets 'privileged: true' for the init container<br />- set NET_ADMIN capability for tailscale container for proxies that<br />are created for Services or Connector.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context | | |
| `debug` _[Debug](#debug)_ | Configuration for enabling extra debug information in the container.<br />Not recommended for production use. | | |
#### DNSConfig
@@ -248,6 +249,22 @@ _Appears in:_
| `nameserver` _[NameserverStatus](#nameserverstatus)_ | Nameserver describes the status of nameserver cluster resources. | | |
#### Debug
_Appears in:_
- [Container](#container)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enable` _boolean_ | Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/<br />and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where<br />9001 is a container port named "debug". The endpoints and their responses<br />may change in backwards incompatible ways in the future, and should not<br />be considered stable.<br />In 1.78.x and 1.80.x, this setting will default to the value of<br />.spec.metrics.enable, and requests to the "metrics" port matching the<br />mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,<br />this setting will default to false, and no requests will be proxied. | | |
#### Env
@@ -309,7 +326,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enable` _boolean_ | Setting enable to true will make the proxy serve Tailscale metrics<br />at <pod-ip>:9001/debug/metrics.<br />Defaults to false. | | |
| `enable` _boolean_ | Setting enable to true will make the proxy serve Tailscale metrics<br />at <pod-ip>:9002/metrics.<br />In 1.78.x and 1.80.x, this field also serves as the default value for<br />.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both<br />fields will independently default to false.<br />Defaults to false. | | |
#### Name

View File

@@ -163,7 +163,12 @@ type Pod struct {
type Metrics struct {
// Setting enable to true will make the proxy serve Tailscale metrics
// at <pod-ip>:9001/debug/metrics.
// at <pod-ip>:9002/metrics.
//
// In 1.78.x and 1.80.x, this field also serves as the default value for
// .spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
// fields will independently default to false.
//
// Defaults to false.
Enable bool `json:"enable"`
}
@@ -209,6 +214,26 @@ type Container struct {
// https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context
// +optional
SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"`
// Configuration for enabling extra debug information in the container.
// Not recommended for production use.
// +optional
Debug *Debug `json:"debug,omitempty"`
}
type Debug struct {
// Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
// and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
// 9001 is a container port named "debug". The endpoints and their responses
// may change in backwards incompatible ways in the future, and should not
// be considered stable.
//
// In 1.78.x and 1.80.x, this setting will default to the value of
// .spec.metrics.enable, and requests to the "metrics" port matching the
// mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
// this setting will default to false, and no requests will be proxied.
//
// +optional
Enable bool `json:"enable"`
}
type Env struct {

View File

@@ -163,6 +163,11 @@ func (in *Container) DeepCopyInto(out *Container) {
*out = new(corev1.SecurityContext)
(*in).DeepCopyInto(*out)
}
if in.Debug != nil {
in, out := &in.Debug, &out.Debug
*out = new(Debug)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Container.
@@ -281,6 +286,21 @@ func (in *DNSConfigStatus) DeepCopy() *DNSConfigStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Debug) DeepCopyInto(out *Debug) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Debug.
func (in *Debug) DeepCopy() *Debug {
if in == nil {
return nil
}
out := new(Debug)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Env) DeepCopyInto(out *Env) {
*out = *in

View File

@@ -102,7 +102,7 @@ type Hijacker struct {
// connection succeeds. In case of success, returns a list with a single
// successful recording attempt and an error channel. If the connection errors
// after having been established, an error is sent down the channel.
type RecorderDialFn func(context.Context, []netip.AddrPort, func(context.Context, string, string) (net.Conn, error)) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error)
type RecorderDialFn func(context.Context, []netip.AddrPort, sessionrecording.DialFunc) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error)
// Hijack hijacks a 'kubectl exec' session and configures for the session
// contents to be sent to a recorder.

View File

@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
@@ -20,6 +19,7 @@ import (
"go.uber.org/zap"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/k8s-operator/sessionrecording/fakes"
"tailscale.com/sessionrecording"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/tstest"
@@ -80,7 +80,7 @@ func Test_Hijacker(t *testing.T) {
h := &Hijacker{
connectToRecorder: func(context.Context,
[]netip.AddrPort,
func(context.Context, string, string) (net.Conn, error),
sessionrecording.DialFunc,
) (wc io.WriteCloser, rec []*tailcfg.SSHRecordingAttempt, _ <-chan error, err error) {
if tt.failRecorderConnect {
err = errors.New("test")

View File

@@ -32,9 +32,6 @@ type Records struct {
// TailscaledConfigFileName returns a tailscaled config file name in
// format expected by containerboot for the given CapVer.
func TailscaledConfigFileName(cap tailcfg.CapabilityVersion) string {
if cap < 95 {
return "tailscaled"
}
return fmt.Sprintf("cap-%v.hujson", cap)
}

View File

@@ -7,7 +7,9 @@
// dependency size for those consumers when adding anything new here.
package kubeapi
import "time"
import (
"time"
)
// Note: The API types are copied from k8s.io/api{,machinery} to not introduce a
// module dependency on the Kubernetes API as it pulls in many more dependencies.
@@ -151,6 +153,57 @@ type Secret struct {
Data map[string][]byte `json:"data,omitempty"`
}
// Event contains a subset of fields from corev1.Event.
// https://github.com/kubernetes/api/blob/6cc44b8953ae704d6d9ec2adf32e7ae19199ea9f/core/v1/types.go#L7034
// It is copied here to avoid having to import kube libraries.
type Event struct {
TypeMeta `json:",inline"`
ObjectMeta `json:"metadata"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
Source EventSource `json:"source,omitempty"` // who is emitting this Event
Type string `json:"type,omitempty"` // Normal or Warning
// InvolvedObject is the subject of the Event. `kubectl describe` will, for most object types, display any
// currently present cluster Events matching the object (but you probably want to set UID for this to work).
InvolvedObject ObjectReference `json:"involvedObject"`
Count int32 `json:"count,omitempty"` // how many times Event was observed
FirstTimestamp time.Time `json:"firstTimestamp,omitempty"`
LastTimestamp time.Time `json:"lastTimestamp,omitempty"`
}
// EventSource includes a subset of fields from corev1.EventSource.
// https://github.com/kubernetes/api/blob/6cc44b8953ae704d6d9ec2adf32e7ae19199ea9f/core/v1/types.go#L7007
// It is copied here to avoid having to import kube libraries.
type EventSource struct {
// Component is the name of the component that is emitting the Event.
Component string `json:"component,omitempty"`
}
// ObjectReference contains a subset of fields from corev1.ObjectReference.
// https://github.com/kubernetes/api/blob/6cc44b8953ae704d6d9ec2adf32e7ae19199ea9f/core/v1/types.go#L6902
// It is copied here to avoid having to import kube libraries.
type ObjectReference struct {
// Kind of the referent.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
// +optional
Kind string `json:"kind,omitempty"`
// Namespace of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
// +optional
Namespace string `json:"namespace,omitempty"`
// Name of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
// +optional
Name string `json:"name,omitempty"`
// UID of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids
// +optional
UID string `json:"uid,omitempty"`
// API version of the referent.
// +optional
APIVersion string `json:"apiVersion,omitempty"`
}
// Status is a return value for calls that don't return other objects.
type Status struct {
TypeMeta `json:",inline"`
@@ -186,6 +239,6 @@ type Status struct {
Code int `json:"code,omitempty"`
}
func (s *Status) Error() string {
func (s Status) Error() string {
return s.Message
}

View File

@@ -23,16 +23,21 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"tailscale.com/kube/kubeapi"
"tailscale.com/tstime"
"tailscale.com/util/multierr"
)
const (
saPath = "/var/run/secrets/kubernetes.io/serviceaccount"
defaultURL = "https://kubernetes.default.svc"
TypeSecrets = "secrets"
typeEvents = "events"
)
// rootPathForTests is set by tests to override the root path to the
@@ -57,8 +62,13 @@ type Client interface {
GetSecret(context.Context, string) (*kubeapi.Secret, error)
UpdateSecret(context.Context, *kubeapi.Secret) error
CreateSecret(context.Context, *kubeapi.Secret) error
// Event attempts to ensure an event with the specified options associated with the Pod in which we are
// currently running. This is best effort - if the client is not able to create events, this operation will be a
// no-op. If there is already an Event with the given reason for the current Pod, it will get updated (only
// count and timestamp are expected to change), else a new event will be created.
Event(_ context.Context, typ, reason, msg string) error
StrategicMergePatchSecret(context.Context, string, *kubeapi.Secret, string) error
JSONPatchSecret(context.Context, string, []JSONPatch) error
JSONPatchResource(_ context.Context, resourceName string, resourceType string, patches []JSONPatch) error
CheckSecretPermissions(context.Context, string) (bool, bool, error)
SetDialer(dialer func(context.Context, string, string) (net.Conn, error))
SetURL(string)
@@ -66,15 +76,24 @@ type Client interface {
type client struct {
mu sync.Mutex
name string
url string
ns string
podName string
podUID string
ns string // Pod namespace
client *http.Client
token string
tokenExpiry time.Time
cl tstime.Clock
// hasEventsPerms is true if client can emit Events for the Pod in which it runs. If it is set to false any
// calls to Events() will be a no-op.
hasEventsPerms bool
// kubeAPIRequest sends a request to the kube API server. It can set to a fake in tests.
kubeAPIRequest kubeAPIRequestFunc
}
// New returns a new client
func New() (Client, error) {
func New(name string) (Client, error) {
ns, err := readFile("namespace")
if err != nil {
return nil, err
@@ -87,9 +106,11 @@ func New() (Client, error) {
if ok := cp.AppendCertsFromPEM(caCert); !ok {
return nil, fmt.Errorf("kube: error in creating root cert pool")
}
return &client{
url: defaultURL,
ns: string(ns),
c := &client{
url: defaultURL,
ns: string(ns),
name: name,
cl: tstime.DefaultClock{},
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
@@ -97,7 +118,10 @@ func New() (Client, error) {
},
},
},
}, nil
}
c.kubeAPIRequest = newKubeAPIRequest(c)
c.setEventPerms()
return c, nil
}
// SetURL sets the URL to use for the Kubernetes API.
@@ -115,14 +139,14 @@ func (c *client) SetDialer(dialer func(ctx context.Context, network, addr string
func (c *client) expireToken() {
c.mu.Lock()
defer c.mu.Unlock()
c.tokenExpiry = time.Now()
c.tokenExpiry = c.cl.Now()
}
func (c *client) getOrRenewToken() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
tk, te := c.token, c.tokenExpiry
if time.Now().Before(te) {
if c.cl.Now().Before(te) {
return tk, nil
}
@@ -131,17 +155,10 @@ func (c *client) getOrRenewToken() (string, error) {
return "", err
}
c.token = string(tkb)
c.tokenExpiry = time.Now().Add(30 * time.Minute)
c.tokenExpiry = c.cl.Now().Add(30 * time.Minute)
return c.token, nil
}
func (c *client) secretURL(name string) string {
if name == "" {
return fmt.Sprintf("%s/api/v1/namespaces/%s/secrets", c.url, c.ns)
}
return fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", c.url, c.ns, name)
}
func getError(resp *http.Response) error {
if resp.StatusCode == 200 || resp.StatusCode == 201 {
// These are the only success codes returned by the Kubernetes API.
@@ -161,36 +178,41 @@ func setHeader(key, value string) func(*http.Request) {
}
}
// doRequest performs an HTTP request to the Kubernetes API.
// If in is not nil, it is expected to be a JSON-encodable object and will be
// sent as the request body.
// If out is not nil, it is expected to be a pointer to an object that can be
// decoded from JSON.
// If the request fails with a 401, the token is expired and a new one is
// requested.
func (c *client) doRequest(ctx context.Context, method, url string, in, out any, opts ...func(*http.Request)) error {
req, err := c.newRequest(ctx, method, url, in)
if err != nil {
return err
}
for _, opt := range opts {
opt(req)
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := getError(resp); err != nil {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 401 {
c.expireToken()
type kubeAPIRequestFunc func(ctx context.Context, method, url string, in, out any, opts ...func(*http.Request)) error
// newKubeAPIRequest returns a function that can perform an HTTP request to the Kubernetes API.
func newKubeAPIRequest(c *client) kubeAPIRequestFunc {
// If in is not nil, it is expected to be a JSON-encodable object and will be
// sent as the request body.
// If out is not nil, it is expected to be a pointer to an object that can be
// decoded from JSON.
// If the request fails with a 401, the token is expired and a new one is
// requested.
f := func(ctx context.Context, method, url string, in, out any, opts ...func(*http.Request)) error {
req, err := c.newRequest(ctx, method, url, in)
if err != nil {
return err
}
return err
for _, opt := range opts {
opt(req)
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := getError(resp); err != nil {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 401 {
c.expireToken()
}
return err
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
return f
}
func (c *client) newRequest(ctx context.Context, method, url string, in any) (*http.Request, error) {
@@ -226,7 +248,7 @@ func (c *client) newRequest(ctx context.Context, method, url string, in any) (*h
// GetSecret fetches the secret from the Kubernetes API.
func (c *client) GetSecret(ctx context.Context, name string) (*kubeapi.Secret, error) {
s := &kubeapi.Secret{Data: make(map[string][]byte)}
if err := c.doRequest(ctx, "GET", c.secretURL(name), nil, s); err != nil {
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL(name, TypeSecrets), nil, s); err != nil {
return nil, err
}
return s, nil
@@ -235,16 +257,16 @@ func (c *client) GetSecret(ctx context.Context, name string) (*kubeapi.Secret, e
// CreateSecret creates a secret in the Kubernetes API.
func (c *client) CreateSecret(ctx context.Context, s *kubeapi.Secret) error {
s.Namespace = c.ns
return c.doRequest(ctx, "POST", c.secretURL(""), s, nil)
return c.kubeAPIRequest(ctx, "POST", c.resourceURL("", TypeSecrets), s, nil)
}
// UpdateSecret updates a secret in the Kubernetes API.
func (c *client) UpdateSecret(ctx context.Context, s *kubeapi.Secret) error {
return c.doRequest(ctx, "PUT", c.secretURL(s.Name), s, nil)
return c.kubeAPIRequest(ctx, "PUT", c.resourceURL(s.Name, TypeSecrets), s, nil)
}
// JSONPatch is a JSON patch operation.
// It currently (2023-03-02) only supports "add" and "remove" operations.
// It currently (2024-11-15) only supports "add", "remove" and "replace" operations.
//
// https://tools.ietf.org/html/rfc6902
type JSONPatch struct {
@@ -253,22 +275,22 @@ type JSONPatch struct {
Value any `json:"value,omitempty"`
}
// JSONPatchSecret updates a secret in the Kubernetes API using a JSON patch.
// It currently (2023-03-02) only supports "add" and "remove" operations.
func (c *client) JSONPatchSecret(ctx context.Context, name string, patch []JSONPatch) error {
for _, p := range patch {
// JSONPatchResource updates a resource in the Kubernetes API using a JSON patch.
// It currently (2024-11-15) only supports "add", "remove" and "replace" operations.
func (c *client) JSONPatchResource(ctx context.Context, name, typ string, patches []JSONPatch) error {
for _, p := range patches {
if p.Op != "remove" && p.Op != "add" && p.Op != "replace" {
return fmt.Errorf("unsupported JSON patch operation: %q", p.Op)
}
}
return c.doRequest(ctx, "PATCH", c.secretURL(name), patch, nil, setHeader("Content-Type", "application/json-patch+json"))
return c.kubeAPIRequest(ctx, "PATCH", c.resourceURL(name, typ), patches, nil, setHeader("Content-Type", "application/json-patch+json"))
}
// StrategicMergePatchSecret updates a secret in the Kubernetes API using a
// strategic merge patch.
// If a fieldManager is provided, it will be used to track the patch.
func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *kubeapi.Secret, fieldManager string) error {
surl := c.secretURL(name)
surl := c.resourceURL(name, TypeSecrets)
if fieldManager != "" {
uv := url.Values{
"fieldManager": {fieldManager},
@@ -277,7 +299,66 @@ func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *
}
s.Namespace = c.ns
s.Name = name
return c.doRequest(ctx, "PATCH", surl, s, nil, setHeader("Content-Type", "application/strategic-merge-patch+json"))
return c.kubeAPIRequest(ctx, "PATCH", surl, s, nil, setHeader("Content-Type", "application/strategic-merge-patch+json"))
}
// Event tries to ensure an Event associated with the Pod in which we are running. It is best effort - the event will be
// created if the kube client on startup was able to determine the name and UID of this Pod from POD_NAME,POD_UID env
// vars and if permissions check for event creation succeeded. Events are keyed on opts.Reason- if an Event for the
// current Pod with that reason already exists, its count and first timestamp will be updated, else a new Event will be
// created.
func (c *client) Event(ctx context.Context, typ, reason, msg string) error {
if !c.hasEventsPerms {
return nil
}
name := c.nameForEvent(reason)
ev, err := c.getEvent(ctx, name)
now := c.cl.Now()
if err != nil {
if !IsNotFoundErr(err) {
return err
}
// Event not found - create it
ev := kubeapi.Event{
ObjectMeta: kubeapi.ObjectMeta{
Name: name,
Namespace: c.ns,
},
Type: typ,
Reason: reason,
Message: msg,
Source: kubeapi.EventSource{
Component: c.name,
},
InvolvedObject: kubeapi.ObjectReference{
Name: c.podName,
Namespace: c.ns,
UID: c.podUID,
Kind: "Pod",
APIVersion: "v1",
},
FirstTimestamp: now,
LastTimestamp: now,
Count: 1,
}
return c.kubeAPIRequest(ctx, "POST", c.resourceURL("", typeEvents), &ev, nil)
}
// If the Event already exists, we patch its count and last timestamp. This ensures that when users run 'kubectl
// describe pod...', they see the event just once (but with a message of how many times it has appeared over
// last timestamp - first timestamp period of time).
count := ev.Count + 1
countPatch := JSONPatch{
Op: "replace",
Value: count,
Path: "/count",
}
tsPatch := JSONPatch{
Op: "replace",
Value: now,
Path: "/lastTimestamp",
}
return c.JSONPatchResource(ctx, name, typeEvents, []JSONPatch{countPatch, tsPatch})
}
// CheckSecretPermissions checks the secret access permissions of the current
@@ -293,7 +374,7 @@ func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *
func (c *client) CheckSecretPermissions(ctx context.Context, secretName string) (canPatch, canCreate bool, err error) {
var errs []error
for _, verb := range []string{"get", "update"} {
ok, err := c.checkPermission(ctx, verb, secretName)
ok, err := c.checkPermission(ctx, verb, TypeSecrets, secretName)
if err != nil {
log.Printf("error checking %s permission on secret %s: %v", verb, secretName, err)
} else if !ok {
@@ -303,12 +384,12 @@ func (c *client) CheckSecretPermissions(ctx context.Context, secretName string)
if len(errs) > 0 {
return false, false, multierr.New(errs...)
}
canPatch, err = c.checkPermission(ctx, "patch", secretName)
canPatch, err = c.checkPermission(ctx, "patch", TypeSecrets, secretName)
if err != nil {
log.Printf("error checking patch permission on secret %s: %v", secretName, err)
return false, false, nil
}
canCreate, err = c.checkPermission(ctx, "create", secretName)
canCreate, err = c.checkPermission(ctx, "create", TypeSecrets, secretName)
if err != nil {
log.Printf("error checking create permission on secret %s: %v", secretName, err)
return false, false, nil
@@ -316,36 +397,98 @@ func (c *client) CheckSecretPermissions(ctx context.Context, secretName string)
return canPatch, canCreate, nil
}
// checkPermission reports whether the current pod has permission to use the
// given verb (e.g. get, update, patch, create) on secretName.
func (c *client) checkPermission(ctx context.Context, verb, secretName string) (bool, error) {
sar := map[string]any{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": map[string]any{
"resourceAttributes": map[string]any{
"namespace": c.ns,
"verb": verb,
"resource": "secrets",
"name": secretName,
},
},
}
var res struct {
Status struct {
Allowed bool `json:"allowed"`
} `json:"status"`
}
url := c.url + "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews"
if err := c.doRequest(ctx, "POST", url, sar, &res); err != nil {
return false, err
}
return res.Status.Allowed, nil
}
func IsNotFoundErr(err error) bool {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 404 {
return true
}
return false
}
// setEventPerms checks whether this client will be able to write tailscaled Events to its Pod and updates the state
// accordingly. If it determines that the client can not write Events, any subsequent calls to client.Event will be a
// no-op.
func (c *client) setEventPerms() {
name := os.Getenv("POD_NAME")
uid := os.Getenv("POD_UID")
hasPerms := false
defer func() {
c.podName = name
c.podUID = uid
c.hasEventsPerms = hasPerms
if !hasPerms {
log.Printf(`kubeclient: this client is not able to write tailscaled Events to the Pod in which it is running.
To help with future debugging you can make it able write Events by giving it get,create,patch permissions for Events in the Pod namespace
and setting POD_NAME, POD_UID env vars for the Pod.`)
}
}()
if name == "" || uid == "" {
return
}
for _, verb := range []string{"get", "create", "patch"} {
can, err := c.checkPermission(context.Background(), verb, typeEvents, "")
if err != nil {
log.Printf("kubeclient: error checking Events permissions: %v", err)
return
}
if !can {
return
}
}
hasPerms = true
return
}
// checkPermission reports whether the current pod has permission to use the given verb (e.g. get, update, patch,
// create) on the given resource type. If name is not an empty string, will check the check will be for resource with
// the given name only.
func (c *client) checkPermission(ctx context.Context, verb, typ, name string) (bool, error) {
ra := map[string]any{
"namespace": c.ns,
"verb": verb,
"resource": typ,
}
if name != "" {
ra["name"] = name
}
sar := map[string]any{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": map[string]any{
"resourceAttributes": ra,
},
}
var res struct {
Status struct {
Allowed bool `json:"allowed"`
} `json:"status"`
}
url := c.url + "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews"
if err := c.kubeAPIRequest(ctx, "POST", url, sar, &res); err != nil {
return false, err
}
return res.Status.Allowed, nil
}
// resourceURL returns a URL that can be used to interact with the given resource type and, if name is not empty string,
// the named resource of that type.
// Note that this only works for core/v1 resource types.
func (c *client) resourceURL(name, typ string) string {
if name == "" {
return fmt.Sprintf("%s/api/v1/namespaces/%s/%s", c.url, c.ns, typ)
}
return fmt.Sprintf("%s/api/v1/namespaces/%s/%s/%s", c.url, c.ns, typ, name)
}
// nameForEvent returns a name for the Event that uniquely identifies Event with that reason for the current Pod.
func (c *client) nameForEvent(reason string) string {
return fmt.Sprintf("%s.%s.%s", c.podName, c.podUID, strings.ToLower(reason))
}
// getEvent fetches the event from the Kubernetes API.
func (c *client) getEvent(ctx context.Context, name string) (*kubeapi.Event, error) {
e := &kubeapi.Event{}
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL(name, typeEvents), nil, e); err != nil {
return nil, err
}
return e, nil
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package kubeclient
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/kube/kubeapi"
"tailscale.com/tstest"
)
func Test_client_Event(t *testing.T) {
cl := &tstest.Clock{}
tests := []struct {
name string
typ string
reason string
msg string
argSets []args
wantErr bool
}{
{
name: "new_event_gets_created",
typ: "Normal",
reason: "TestReason",
msg: "TestMessage",
argSets: []args{
{ // request to GET event returns not found
wantsMethod: "GET",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
setErr: &kubeapi.Status{Code: 404},
},
{ // sends POST request to create event
wantsMethod: "POST",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events",
wantsIn: &kubeapi.Event{
ObjectMeta: kubeapi.ObjectMeta{
Name: "test-pod.test-uid.testreason",
Namespace: "test-ns",
},
Type: "Normal",
Reason: "TestReason",
Message: "TestMessage",
Source: kubeapi.EventSource{
Component: "test-client",
},
InvolvedObject: kubeapi.ObjectReference{
Name: "test-pod",
UID: "test-uid",
Namespace: "test-ns",
APIVersion: "v1",
Kind: "Pod",
},
FirstTimestamp: cl.Now(),
LastTimestamp: cl.Now(),
Count: 1,
},
},
},
},
{
name: "existing_event_gets_patched",
typ: "Warning",
reason: "TestReason",
msg: "TestMsg",
argSets: []args{
{ // request to GET event does not error - this is enough to assume that event exists
wantsMethod: "GET",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
setOut: []byte(`{"count":2}`),
},
{ // sends PATCH request to update the event
wantsMethod: "PATCH",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
wantsIn: []JSONPatch{
{Op: "replace", Path: "/count", Value: int32(3)},
{Op: "replace", Path: "/lastTimestamp", Value: cl.Now()},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &client{
cl: cl,
name: "test-client",
podName: "test-pod",
podUID: "test-uid",
url: "test-apiserver",
ns: "test-ns",
kubeAPIRequest: fakeKubeAPIRequest(t, tt.argSets),
hasEventsPerms: true,
}
if err := c.Event(context.Background(), tt.typ, tt.reason, tt.msg); (err != nil) != tt.wantErr {
t.Errorf("client.Event() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// args is a set of values for testing a single call to client.kubeAPIRequest.
type args struct {
// wantsMethod is the expected value of 'method' arg.
wantsMethod string
// wantsURL is the expected value of 'url' arg.
wantsURL string
// wantsIn is the expected value of 'in' arg.
wantsIn any
// setOut can be set to a byte slice representing valid JSON. If set 'out' arg will get set to the unmarshalled
// JSON object.
setOut []byte
// setErr is the error that kubeAPIRequest will return.
setErr error
}
// fakeKubeAPIRequest can be used to test that a series of calls to client.kubeAPIRequest gets called with expected
// values and to set these calls to return preconfigured values. 'argSets' should be set to a slice of expected
// arguments and should-be return values of a series of kubeAPIRequest calls.
func fakeKubeAPIRequest(t *testing.T, argSets []args) kubeAPIRequestFunc {
count := 0
f := func(ctx context.Context, gotMethod, gotUrl string, gotIn, gotOut any, opts ...func(*http.Request)) error {
t.Helper()
if count >= len(argSets) {
t.Fatalf("unexpected call to client.kubeAPIRequest, expected %d calls, but got a %dth call", len(argSets), count+1)
}
a := argSets[count]
if gotMethod != a.wantsMethod {
t.Errorf("[%d] got method %q, wants method %q", count, gotMethod, a.wantsMethod)
}
if gotUrl != a.wantsURL {
t.Errorf("[%d] got URL %q, wants URL %q", count, gotUrl, a.wantsURL)
}
if d := cmp.Diff(gotIn, a.wantsIn); d != "" {
t.Errorf("[%d] unexpected payload (-want + got):\n%s", count, d)
}
if len(a.setOut) != 0 {
if err := json.Unmarshal(a.setOut, gotOut); err != nil {
t.Fatalf("[%d] error unmarshalling output: %v", count, err)
}
}
count++
return a.setErr
}
return f
}

View File

@@ -29,7 +29,11 @@ func (fc *FakeClient) SetDialer(dialer func(ctx context.Context, network, addr s
func (fc *FakeClient) StrategicMergePatchSecret(context.Context, string, *kubeapi.Secret, string) error {
return nil
}
func (fc *FakeClient) JSONPatchSecret(context.Context, string, []JSONPatch) error {
func (fc *FakeClient) Event(context.Context, string, string, string) error {
return nil
}
func (fc *FakeClient) JSONPatchResource(context.Context, string, string, []JSONPatch) error {
return nil
}
func (fc *FakeClient) UpdateSecret(context.Context, *kubeapi.Secret) error { return nil }

View File

@@ -12,24 +12,23 @@ See also the dependencies in the [Tailscale CLI][].
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.1.0/LICENSE))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.30.4/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.32.4/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.27.28/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.17.28/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.16.12/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.16/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.16/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.23/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.23/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.8.1/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.30.4/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.11.4/service/internal/accept-encoding/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.32.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.12.0/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.11.18/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.45.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.22.5/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.26.5/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.30.4/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.20.4/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.20.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.22.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.22.0/internal/sync/singleflight/LICENSE))
- [github.com/bits-and-blooms/bitset](https://pkg.go.dev/github.com/bits-and-blooms/bitset) ([BSD-3-Clause](https://github.com/bits-and-blooms/bitset/blob/v1.13.0/LICENSE))
- [github.com/coder/websocket](https://pkg.go.dev/github.com/coder/websocket) ([ISC](https://github.com/coder/websocket/blob/v1.8.12/LICENSE.txt))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/65c67c9f46e6/LICENSE))
- [github.com/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))
@@ -48,9 +47,9 @@ See also the dependencies in the [Tailscale CLI][].
- [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.1/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.8/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.8/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.8/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.11/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.11/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.11/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))
@@ -74,12 +73,12 @@ See also the dependencies in the [Tailscale CLI][].
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.28.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fe59bbe5:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.27.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.8.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.26.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fc45aab8:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.30.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.9.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.27.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.25.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.19.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.20.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/64c016c92987/LICENSE))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))

View File

@@ -58,9 +58,9 @@ Some packages may only be included on certain architectures or operating systems
- [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/kballard/go-shellquote](https://pkg.go.dev/github.com/kballard/go-shellquote) ([MIT](https://github.com/kballard/go-shellquote/blob/95032a82bc51/LICENSE))
- [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.11/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.11/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.11/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/kr/fs](https://pkg.go.dev/github.com/kr/fs) ([BSD-3-Clause](https://github.com/kr/fs/blob/v0.1.0/LICENSE))
- [github.com/mattn/go-colorable](https://pkg.go.dev/github.com/mattn/go-colorable) ([MIT](https://github.com/mattn/go-colorable/blob/v0.1.13/LICENSE))
@@ -84,7 +84,7 @@ Some packages may only be included on certain architectures or operating systems
- [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/799c1978fafc/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/4e883d38c8d3/LICENSE))
- [github.com/tailscale/xnet/webdav](https://pkg.go.dev/github.com/tailscale/xnet/webdav) ([BSD-3-Clause](https://github.com/tailscale/xnet/blob/8497ac4dab2e/LICENSE))
- [github.com/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))
@@ -98,8 +98,8 @@ 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.27.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](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.7.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.22.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.9.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.27.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.22.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.16.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))

View File

@@ -13,22 +13,22 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/1a75b4708caa/LICENSE))
- [github.com/apenwarr/fixconsole](https://pkg.go.dev/github.com/apenwarr/fixconsole) ([Apache-2.0](https://github.com/apenwarr/fixconsole/blob/5a9f6489cc29/LICENSE))
- [github.com/apenwarr/w32](https://pkg.go.dev/github.com/apenwarr/w32) ([BSD-3-Clause](https://github.com/apenwarr/w32/blob/aa00fece76ab/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.30.4/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.32.4/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.27.28/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.17.28/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.16.12/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.16/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.16/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.23/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.23/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.8.1/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.30.4/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.11.4/service/internal/accept-encoding/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.32.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.12.0/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.11.18/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.45.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.22.5/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.26.5/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.30.4/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.20.4/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.20.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.22.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.22.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/65c67c9f46e6/LICENSE))
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/b75a8a7d7eb0/LICENSE))
- [github.com/djherbis/times](https://pkg.go.dev/github.com/djherbis/times) ([MIT](https://github.com/djherbis/times/blob/v1.6.0/LICENSE))
@@ -44,9 +44,9 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [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.1/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.8/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.8/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.8/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.11/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.11/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.11/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))
@@ -66,14 +66,14 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.28.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fe59bbe5:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fc45aab8:LICENSE))
- [golang.org/x/image/bmp](https://pkg.go.dev/golang.org/x/image/bmp) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.18.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.19.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.27.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.8.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.26.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.30.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.9.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.27.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.25.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.19.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.20.0:LICENSE))
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=0fa3db229ce2))
- [golang.zx2c4.com/wireguard/windows/tunnel/winipcfg](https://pkg.go.dev/golang.zx2c4.com/wireguard/windows/tunnel/winipcfg) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING?h=v0.5.3))
- [gopkg.in/Knetic/govaluate.v3](https://pkg.go.dev/gopkg.in/Knetic/govaluate.v3) ([MIT](https://github.com/Knetic/govaluate/blob/v3.0.0/LICENSE))

View File

@@ -136,26 +136,31 @@ func interfaceNameDoesNotNeedCaptiveDetection(ifName string, goos string) bool {
func (d *Detector) detectOnInterface(ctx context.Context, ifIndex int, endpoints []Endpoint) bool {
defer d.httpClient.CloseIdleConnections()
d.logf("[v2] %d available captive portal detection endpoints: %v", len(endpoints), endpoints)
use := min(len(endpoints), 5)
endpoints = endpoints[:use]
d.logf("[v2] %d available captive portal detection endpoints; trying %v", len(endpoints), use)
// We try to detect the captive portal more quickly by making requests to multiple endpoints concurrently.
var wg sync.WaitGroup
resultCh := make(chan bool, len(endpoints))
for i, e := range endpoints {
if i >= 5 {
// Try a maximum of 5 endpoints, break out (returning false) if we run of attempts.
break
}
// Once any goroutine detects a captive portal, we shut down the others.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for _, e := range endpoints {
wg.Add(1)
go func(endpoint Endpoint) {
defer wg.Done()
found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, ifIndex)
if err != nil {
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
if ctx.Err() == nil {
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
}
return
}
if found {
cancel() // one match is good enough
resultCh <- true
}
}(e)

View File

@@ -7,10 +7,12 @@ import (
"context"
"runtime"
"sync"
"sync/atomic"
"testing"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/net/netmon"
"tailscale.com/syncs"
"tailscale.com/tstest/nettest"
)
func TestAvailableEndpointsAlwaysAtLeastTwo(t *testing.T) {
@@ -36,25 +38,46 @@ func TestDetectCaptivePortalReturnsFalse(t *testing.T) {
}
}
func TestAllEndpointsAreUpAndReturnExpectedResponse(t *testing.T) {
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/13019")
func TestEndpointsAreUpAndReturnExpectedResponse(t *testing.T) {
nettest.SkipIfNoNetwork(t)
d := NewDetector(t.Logf)
endpoints := availableEndpoints(nil, 0, t.Logf, runtime.GOOS)
t.Logf("testing %d endpoints", len(endpoints))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var good atomic.Bool
var wg sync.WaitGroup
sem := syncs.NewSemaphore(5)
for _, e := range endpoints {
wg.Add(1)
go func(endpoint Endpoint) {
defer wg.Done()
found, err := d.verifyCaptivePortalEndpoint(context.Background(), endpoint, 0)
if err != nil {
t.Errorf("verifyCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
if !sem.AcquireContext(ctx) {
return
}
defer sem.Release()
found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, 0)
if err != nil && ctx.Err() == nil {
t.Logf("verifyCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
}
if found {
t.Errorf("verifyCaptivePortalEndpoint with endpoint %v says we're behind a captive portal, but we aren't", endpoint)
t.Logf("verifyCaptivePortalEndpoint with endpoint %v says we're behind a captive portal, but we aren't", endpoint)
return
}
good.Store(true)
t.Logf("endpoint good: %v", endpoint)
cancel()
}(e)
}
wg.Wait()
if !good.Load() {
t.Errorf("no good endpoints found")
}
}

View File

@@ -56,7 +56,18 @@ func (m *darwinRouteMon) Receive() (message, error) {
if err != nil {
return nil, err
}
msgs, err := route.ParseRIB(route.RIBTypeRoute, m.buf[:n])
msgs, err := func() (msgs []route.Message, err error) {
defer func() {
// TODO(raggi,#14201): remove once we've got a fix from
// golang/go#70528.
msg := recover()
if msg != nil {
msgs = nil
err = fmt.Errorf("panic in route.ParseRIB: %s", msg)
}
}()
return route.ParseRIB(route.RIBTypeRoute, m.buf[:n])
}()
if err != nil {
if debugRouteMessages {
m.logf("read %d bytes (% 02x), failed to parse RIB: %v", n, m.buf[:n], err)

View File

@@ -66,15 +66,21 @@ const (
TailscaleServiceIPv6String = "fd7a:115c:a1e0::53"
)
// IsTailscaleIP reports whether ip is an IP address in a range that
// IsTailscaleIP reports whether IP is an IP address in a range that
// Tailscale assigns from.
func IsTailscaleIP(ip netip.Addr) bool {
if ip.Is4() {
return CGNATRange().Contains(ip) && !ChromeOSVMRange().Contains(ip)
return IsTailscaleIPv4(ip)
}
return TailscaleULARange().Contains(ip)
}
// IsTailscaleIPv4 reports whether an IPv4 IP is an IP address that
// Tailscale assigns from.
func IsTailscaleIPv4(ip netip.Addr) bool {
return CGNATRange().Contains(ip) && !ChromeOSVMRange().Contains(ip)
}
// TailscaleULARange returns the IPv6 Unique Local Address range that
// is the superset range that Tailscale assigns out of.
func TailscaleULARange() netip.Prefix {

View File

@@ -222,3 +222,71 @@ func TestContainsExitRoute(t *testing.T) {
}
}
}
func TestIsTailscaleIPv4(t *testing.T) {
tests := []struct {
in netip.Addr
want bool
}{
{
in: netip.MustParseAddr("100.67.19.57"),
want: true,
},
{
in: netip.MustParseAddr("10.10.10.10"),
want: false,
},
{
in: netip.MustParseAddr("fd7a:115c:a1e0:3f2b:7a1d:4e88:9c2b:7f01"),
want: false,
},
{
in: netip.MustParseAddr("bc9d:0aa0:1f0a:69ab:eb5c:28e0:5456:a518"),
want: false,
},
{
in: netip.MustParseAddr("100.115.92.157"),
want: false,
},
}
for _, tt := range tests {
if got := IsTailscaleIPv4(tt.in); got != tt.want {
t.Errorf("IsTailscaleIPv4(%v) = %v, want %v", tt.in, got, tt.want)
}
}
}
func TestIsTailscaleIP(t *testing.T) {
tests := []struct {
in netip.Addr
want bool
}{
{
in: netip.MustParseAddr("100.67.19.57"),
want: true,
},
{
in: netip.MustParseAddr("10.10.10.10"),
want: false,
},
{
in: netip.MustParseAddr("fd7a:115c:a1e0:3f2b:7a1d:4e88:9c2b:7f01"),
want: true,
},
{
in: netip.MustParseAddr("bc9d:0aa0:1f0a:69ab:eb5c:28e0:5456:a518"),
want: false,
},
{
in: netip.MustParseAddr("100.115.92.157"),
want: false,
},
}
for _, tt := range tests {
if got := IsTailscaleIP(tt.in); got != tt.want {
t.Errorf("IsTailscaleIP(%v) = %v, want %v", tt.in, got, tt.want)
}
}
}

View File

@@ -45,6 +45,9 @@ type derpProber struct {
bwInterval time.Duration
bwProbeSize int64
// Optionally restrict probes to a single regionCode.
regionCode string
// Probe class for fetching & updating the DERP map.
ProbeMap ProbeClass
@@ -97,6 +100,14 @@ func WithTLSProbing(interval time.Duration) DERPOpt {
}
}
// WithRegion restricts probing to the specified region identified by its code
// (e.g. "lax"). This is case sensitive.
func WithRegion(regionCode string) DERPOpt {
return func(d *derpProber) {
d.regionCode = regionCode
}
}
// DERP creates a new derpProber.
//
// If derpMapURL is "local", the DERPMap is fetched via
@@ -135,6 +146,10 @@ func (d *derpProber) probeMapFn(ctx context.Context) error {
defer d.Unlock()
for _, region := range d.lastDERPMap.Regions {
if d.skipRegion(region) {
continue
}
for _, server := range region.Nodes {
labels := Labels{
"region": region.RegionCode,
@@ -316,6 +331,10 @@ func (d *derpProber) updateMap(ctx context.Context) error {
d.lastDERPMapAt = time.Now()
d.nodes = make(map[string]*tailcfg.DERPNode)
for _, reg := range d.lastDERPMap.Regions {
if d.skipRegion(reg) {
continue
}
for _, n := range reg.Nodes {
if existing, ok := d.nodes[n.Name]; ok {
return fmt.Errorf("derpmap has duplicate nodes: %+v and %+v", existing, n)
@@ -338,6 +357,10 @@ func (d *derpProber) ProbeUDP(ipaddr string, port int) ProbeClass {
}
}
func (d *derpProber) skipRegion(region *tailcfg.DERPRegion) bool {
return d.regionCode != "" && region.RegionCode != d.regionCode
}
func derpProbeUDP(ctx context.Context, ipStr string, port int) error {
pc, err := net.ListenPacket("udp", ":0")
if err != nil {

View File

@@ -44,6 +44,19 @@ func TestDerpProber(t *testing.T) {
},
},
},
1: {
RegionID: 1,
RegionCode: "one",
Nodes: []*tailcfg.DERPNode{
{
Name: "n3",
RegionID: 0,
HostName: "derpn3.tailscale.test",
IPv4: "1.1.1.1",
IPv6: "::1",
},
},
},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -68,6 +81,7 @@ func TestDerpProber(t *testing.T) {
meshProbeFn: func(_, _ string) ProbeClass { return FuncProbe(func(context.Context) error { return nil }) },
nodes: make(map[string]*tailcfg.DERPNode),
probes: make(map[string]*Probe),
regionCode: "zero",
}
if err := dp.probeMapFn(context.Background()); err != nil {
t.Errorf("unexpected probeMapFn() error: %s", err)
@@ -84,9 +98,9 @@ func TestDerpProber(t *testing.T) {
// Add one more node and check that probes got created.
dm.Regions[0].Nodes = append(dm.Regions[0].Nodes, &tailcfg.DERPNode{
Name: "n3",
Name: "n4",
RegionID: 0,
HostName: "derpn3.tailscale.test",
HostName: "derpn4.tailscale.test",
IPv4: "1.1.1.1",
IPv6: "::1",
})
@@ -113,6 +127,19 @@ func TestDerpProber(t *testing.T) {
if len(dp.probes) != 4 {
t.Errorf("unexpected probes: %+v", dp.probes)
}
// Stop filtering regions.
dp.regionCode = ""
if err := dp.probeMapFn(context.Background()); err != nil {
t.Errorf("unexpected probeMapFn() error: %s", err)
}
if len(dp.nodes) != 2 {
t.Errorf("unexpected nodes: %+v", dp.nodes)
}
// 6 regular probes + 2 mesh probe
if len(dp.probes) != 8 {
t.Errorf("unexpected probes: %+v", dp.probes)
}
}
func TestRunDerpProbeNodePair(t *testing.T) {

View File

@@ -7,6 +7,8 @@ package sessionrecording
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
@@ -14,12 +16,33 @@ import (
"net/http"
"net/http/httptrace"
"net/netip"
"sync/atomic"
"time"
"golang.org/x/net/http2"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
"tailscale.com/util/multierr"
)
const (
// Timeout for an individual DialFunc call for a single recorder address.
perDialAttemptTimeout = 5 * time.Second
// Timeout for the V2 API HEAD probe request (supportsV2).
http2ProbeTimeout = 10 * time.Second
// Maximum timeout for trying all available recorders, including V2 API
// probes and dial attempts.
allDialAttemptsTimeout = 30 * time.Second
)
// uploadAckWindow is the period of time to wait for an ackFrame from recorder
// before terminating the connection. This is a variable to allow overriding it
// in tests.
var uploadAckWindow = 30 * time.Second
// DialFunc is a function for dialing the recorder.
type DialFunc func(ctx context.Context, network, host string) (net.Conn, error)
// ConnectToRecorder connects to the recorder at any of the provided addresses.
// It returns the first successful response, or a multierr if all attempts fail.
//
@@ -32,19 +55,15 @@ import (
// attempts are in order the recorder(s) was attempted. If successful a
// successful connection is made, the last attempt in the slice is the
// attempt for connected recorder.
func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial func(context.Context, string, string) (net.Conn, error)) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error) {
func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial DialFunc) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error) {
if len(recs) == 0 {
return nil, nil, nil, errors.New("no recorders configured")
}
// We use a special context for dialing the recorder, so that we can
// limit the time we spend dialing to 30 seconds and still have an
// unbounded context for the upload.
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
dialCtx, dialCancel := context.WithTimeout(ctx, allDialAttemptsTimeout)
defer dialCancel()
hc, err := SessionRecordingClientForDialer(dialCtx, dial)
if err != nil {
return nil, nil, nil, err
}
var errs []error
var attempts []*tailcfg.SSHRecordingAttempt
@@ -54,74 +73,230 @@ func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial func(con
}
attempts = append(attempts, attempt)
// We dial the recorder and wait for it to send a 100-continue
// response before returning from this function. This ensures that
// the recorder is ready to accept the recording.
// got100 is closed when we receive the 100-continue response.
got100 := make(chan struct{})
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
Got100Continue: func() {
close(got100)
},
})
pr, pw := io.Pipe()
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s:%d/record", ap.Addr(), ap.Port()), pr)
var pw io.WriteCloser
var errChan <-chan error
var err error
hc := clientHTTP2(dialCtx, dial)
// We need to probe V2 support using a separate HEAD request. Sending
// an HTTP/2 POST request to a HTTP/1 server will just "hang" until the
// request body is closed (instead of returning a 404 as one would
// expect). Sending a HEAD request without a body does not have that
// problem.
if supportsV2(ctx, hc, ap) {
pw, errChan, err = connectV2(ctx, hc, ap)
} else {
pw, errChan, err = connectV1(ctx, clientHTTP1(dialCtx, dial), ap)
}
if err != nil {
err = fmt.Errorf("recording: error starting recording: %w", err)
err = fmt.Errorf("recording: error starting recording on %q: %w", ap, err)
attempt.FailureMessage = err.Error()
errs = append(errs, err)
continue
}
// We set the Expect header to 100-continue, so that the recorder
// will send a 100-continue response before it starts reading the
// request body.
req.Header.Set("Expect", "100-continue")
// errChan is used to indicate the result of the request.
errChan := make(chan error, 1)
go func() {
resp, err := hc.Do(req)
if err != nil {
errChan <- fmt.Errorf("recording: error starting recording: %w", err)
return
}
if resp.StatusCode != 200 {
errChan <- fmt.Errorf("recording: unexpected status: %v", resp.Status)
return
}
errChan <- nil
}()
select {
case <-got100:
case err := <-errChan:
// If we get an error before we get the 100-continue response,
// we need to try another recorder.
if err == nil {
// If the error is nil, we got a 200 response, which
// is unexpected as we haven't sent any data yet.
err = errors.New("recording: unexpected EOF")
}
attempt.FailureMessage = err.Error()
errs = append(errs, err)
continue // try the next recorder
}
return pw, attempts, errChan, nil
}
return nil, attempts, nil, multierr.New(errs...)
}
// SessionRecordingClientForDialer returns an http.Client that uses a clone of
// the provided Dialer's PeerTransport to dial connections. This is used to make
// requests to the session recording server to upload session recordings. It
// uses the provided dialCtx to dial connections, and limits a single dial to 5
// seconds.
func SessionRecordingClientForDialer(dialCtx context.Context, dial func(context.Context, string, string) (net.Conn, error)) (*http.Client, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
// supportsV2 checks whether a recorder instance supports the /v2/record
// endpoint.
func supportsV2(ctx context.Context, hc *http.Client, ap netip.AddrPort) bool {
ctx, cancel := context.WithTimeout(ctx, http2ProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, httpm.HEAD, fmt.Sprintf("http://%s/v2/record", ap), nil)
if err != nil {
return false
}
resp, err := hc.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK && resp.ProtoMajor > 1
}
// connectV1 connects to the legacy /record endpoint on the recorder. It is
// used for backwards-compatibility with older tsrecorder instances.
//
// On success, it returns a WriteCloser that can be used to upload the
// recording, and a channel that will be sent an error (or nil) when the upload
// fails or completes.
func connectV1(ctx context.Context, hc *http.Client, ap netip.AddrPort) (io.WriteCloser, <-chan error, error) {
// We dial the recorder and wait for it to send a 100-continue
// response before returning from this function. This ensures that
// the recorder is ready to accept the recording.
// got100 is closed when we receive the 100-continue response.
got100 := make(chan struct{})
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
Got100Continue: func() {
close(got100)
},
})
pr, pw := io.Pipe()
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s/record", ap), pr)
if err != nil {
return nil, nil, err
}
// We set the Expect header to 100-continue, so that the recorder
// will send a 100-continue response before it starts reading the
// request body.
req.Header.Set("Expect", "100-continue")
// errChan is used to indicate the result of the request.
errChan := make(chan error, 1)
go func() {
defer close(errChan)
resp, err := hc.Do(req)
if err != nil {
errChan <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
errChan <- fmt.Errorf("recording: unexpected status: %v", resp.Status)
return
}
}()
select {
case <-got100:
return pw, errChan, nil
case err := <-errChan:
// If we get an error before we get the 100-continue response,
// we need to try another recorder.
if err == nil {
// If the error is nil, we got a 200 response, which
// is unexpected as we haven't sent any data yet.
err = errors.New("recording: unexpected EOF")
}
return nil, nil, err
}
}
// connectV2 connects to the /v2/record endpoint on the recorder over HTTP/2.
// It explicitly tracks ack frames sent in the response and terminates the
// connection if sent recording data is un-acked for uploadAckWindow.
//
// On success, it returns a WriteCloser that can be used to upload the
// recording, and a channel that will be sent an error (or nil) when the upload
// fails or completes.
func connectV2(ctx context.Context, hc *http.Client, ap netip.AddrPort) (io.WriteCloser, <-chan error, error) {
pr, pw := io.Pipe()
upload := &readCounter{r: pr}
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s/v2/record", ap), upload)
if err != nil {
return nil, nil, err
}
// With HTTP/2, hc.Do will not block while the request body is being sent.
// It will return immediately and allow us to consume the response body at
// the same time.
resp, err := hc.Do(req)
if err != nil {
return nil, nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, nil, fmt.Errorf("recording: unexpected status: %v", resp.Status)
}
errChan := make(chan error, 1)
acks := make(chan int64)
// Read acks from the response and send them to the acks channel.
go func() {
defer close(errChan)
defer close(acks)
defer resp.Body.Close()
defer pw.Close()
dec := json.NewDecoder(resp.Body)
for {
var frame v2ResponseFrame
if err := dec.Decode(&frame); err != nil {
if !errors.Is(err, io.EOF) {
errChan <- fmt.Errorf("recording: unexpected error receiving acks: %w", err)
}
return
}
if frame.Error != "" {
errChan <- fmt.Errorf("recording: received error from the recorder: %q", frame.Error)
return
}
select {
case acks <- frame.Ack:
case <-ctx.Done():
return
}
}
}()
// Track acks from the acks channel.
go func() {
// Hack for tests: some tests modify uploadAckWindow and reset it when
// the test ends. This can race with t.Reset call below. Making a copy
// here is a lazy workaround to not wait for this goroutine to exit in
// the test cases.
uploadAckWindow := uploadAckWindow
// This timer fires if we didn't receive an ack for too long.
t := time.NewTimer(uploadAckWindow)
defer t.Stop()
for {
select {
case <-t.C:
// Close the pipe which terminates the connection and cleans up
// other goroutines. Note that tsrecorder will send us ack
// frames even if there is no new data to ack. This helps
// detect broken recorder connection if the session is idle.
pr.CloseWithError(errNoAcks)
resp.Body.Close()
return
case _, ok := <-acks:
if !ok {
// acks channel closed means that the goroutine reading them
// finished, which means that the request has ended.
return
}
// TODO(awly): limit how far behind the received acks can be. This
// should handle scenarios where a session suddenly dumps a lot of
// output.
t.Reset(uploadAckWindow)
case <-ctx.Done():
return
}
}
}()
return pw, errChan, nil
}
var errNoAcks = errors.New("did not receive ack frames from the recorder in 30s")
type v2ResponseFrame struct {
// Ack is the number of bytes received from the client so far. The bytes
// are not guaranteed to be durably stored yet.
Ack int64 `json:"ack,omitempty"`
// Error is an error encountered while storing the recording. Error is only
// ever set as the last frame in the response.
Error string `json:"error,omitempty"`
}
// readCounter is an io.Reader that counts how many bytes were read.
type readCounter struct {
r io.Reader
sent atomic.Int64
}
func (u *readCounter) Read(buf []byte) (int, error) {
n, err := u.r.Read(buf)
u.sent.Add(int64(n))
return n, err
}
// clientHTTP1 returns a claassic http.Client with a per-dial context. It uses
// dialCtx and adds a 5s timeout to it.
func clientHTTP1(dialCtx context.Context, dial DialFunc) *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
perAttemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
perAttemptCtx, cancel := context.WithTimeout(ctx, perDialAttemptTimeout)
defer cancel()
go func() {
select {
@@ -132,7 +307,32 @@ func SessionRecordingClientForDialer(dialCtx context.Context, dial func(context.
}()
return dial(perAttemptCtx, network, addr)
}
return &http.Client{
Transport: tr,
}, nil
return &http.Client{Transport: tr}
}
// clientHTTP2 is like clientHTTP1 but returns an http.Client suitable for h2c
// requests (HTTP/2 over plaintext). Unfortunately the same client does not
// work for HTTP/1 so we need to split these up.
func clientHTTP2(dialCtx context.Context, dial DialFunc) *http.Client {
return &http.Client{
Transport: &http2.Transport{
// Allow "http://" scheme in URLs.
AllowHTTP: true,
// Pretend like we're using TLS, but actually use the provided
// DialFunc underneath. This is necessary to convince the transport
// to actually dial.
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
perAttemptCtx, cancel := context.WithTimeout(ctx, perDialAttemptTimeout)
defer cancel()
go func() {
select {
case <-perAttemptCtx.Done():
case <-dialCtx.Done():
cancel()
}
}()
return dial(perAttemptCtx, network, addr)
},
},
}
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package sessionrecording
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func TestConnectToRecorder(t *testing.T) {
tests := []struct {
desc string
http2 bool
// setup returns a recorder server mux, and a channel which sends the
// hash of the recording uploaded to it. The channel is expected to
// fire only once.
setup func(t *testing.T) (*http.ServeMux, <-chan []byte)
wantErr bool
}{
{
desc: "v1 recorder",
setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) {
uploadHash := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) {
hash := sha256.New()
if _, err := io.Copy(hash, r.Body); err != nil {
t.Error(err)
}
uploadHash <- hash.Sum(nil)
})
return mux, uploadHash
},
},
{
desc: "v2 recorder",
http2: true,
setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) {
uploadHash := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) {
t.Error("received request to v1 endpoint")
http.Error(w, "not found", http.StatusNotFound)
})
mux.HandleFunc("POST /v2/record", func(w http.ResponseWriter, r *http.Request) {
// Force the status to send to unblock the client waiting
// for it.
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
body := &readCounter{r: r.Body}
hash := sha256.New()
ctx, cancel := context.WithCancel(r.Context())
go func() {
defer cancel()
if _, err := io.Copy(hash, body); err != nil {
t.Error(err)
}
}()
// Send acks for received bytes.
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
enc := json.NewEncoder(w)
outer:
for {
select {
case <-ctx.Done():
break outer
case <-tick.C:
if err := enc.Encode(v2ResponseFrame{Ack: body.sent.Load()}); err != nil {
t.Errorf("writing ack frame: %v", err)
break outer
}
}
}
uploadHash <- hash.Sum(nil)
})
// Probing HEAD endpoint which always returns 200 OK.
mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {})
return mux, uploadHash
},
},
{
desc: "v2 recorder no acks",
http2: true,
wantErr: true,
setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) {
// Make the client no-ack timeout quick for the test.
oldAckWindow := uploadAckWindow
uploadAckWindow = 100 * time.Millisecond
t.Cleanup(func() { uploadAckWindow = oldAckWindow })
uploadHash := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) {
t.Error("received request to v1 endpoint")
http.Error(w, "not found", http.StatusNotFound)
})
mux.HandleFunc("POST /v2/record", func(w http.ResponseWriter, r *http.Request) {
// Force the status to send to unblock the client waiting
// for it.
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
// Consume the whole request body but don't send any acks
// back.
hash := sha256.New()
if _, err := io.Copy(hash, r.Body); err != nil {
t.Error(err)
}
// Goes in the channel buffer, non-blocking.
uploadHash <- hash.Sum(nil)
// Block until the parent test case ends to prevent the
// request termination. We want to exercise the ack
// tracking logic specifically.
ctx, cancel := context.WithCancel(r.Context())
t.Cleanup(cancel)
<-ctx.Done()
})
mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {})
return mux, uploadHash
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
mux, uploadHash := tt.setup(t)
srv := httptest.NewUnstartedServer(mux)
if tt.http2 {
// Wire up h2c-compatible HTTP/2 server. This is optional
// because the v1 recorder didn't support HTTP/2 and we try to
// mimic that.
h2s := &http2.Server{}
srv.Config.Handler = h2c.NewHandler(mux, h2s)
if err := http2.ConfigureServer(srv.Config, h2s); err != nil {
t.Errorf("configuring HTTP/2 support in server: %v", err)
}
}
srv.Start()
t.Cleanup(srv.Close)
d := new(net.Dialer)
ctx := context.Background()
w, _, errc, err := ConnectToRecorder(ctx, []netip.AddrPort{netip.MustParseAddrPort(srv.Listener.Addr().String())}, d.DialContext)
if err != nil {
t.Fatalf("ConnectToRecorder: %v", err)
}
// Send some random data and hash it to compare with the recorded
// data hash.
hash := sha256.New()
const numBytes = 1 << 20 // 1MB
if _, err := io.CopyN(io.MultiWriter(w, hash), rand.Reader, numBytes); err != nil {
t.Fatalf("writing recording data: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("closing recording stream: %v", err)
}
if err := <-errc; err != nil && !tt.wantErr {
t.Fatalf("error from the channel: %v", err)
} else if err == nil && tt.wantErr {
t.Fatalf("did not receive expected error from the channel")
}
if recv, sent := <-uploadHash, hash.Sum(nil); !bytes.Equal(recv, sent) {
t.Errorf("mismatch in recording data hash, sent %x, received %x", sent, recv)
}
})
}
}

View File

@@ -1170,7 +1170,7 @@ func (ss *sshSession) run() {
if err != nil && !errors.Is(err, io.EOF) {
isErrBecauseProcessExited := processDone.Load() && errors.Is(err, syscall.EIO)
if !isErrBecauseProcessExited {
logf("stdout copy: %v, %T", err)
logf("stdout copy: %v", err)
ss.cancelCtx(err)
}
}
@@ -1520,9 +1520,14 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) {
go func() {
err := <-errChan
if err == nil {
// Success.
ss.logf("recording: finished uploading recording")
return
select {
case <-ss.ctx.Done():
// Success.
ss.logf("recording: finished uploading recording")
return
default:
err = errors.New("recording upload ended before the SSH session")
}
}
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
lastAttempt := attempts[len(attempts)-1]

View File

@@ -33,6 +33,8 @@ import (
"time"
gossh "github.com/tailscale/golang-x-crypto/ssh"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/memnet"
@@ -481,10 +483,9 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
}
var handler http.HandlerFunc
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
handler(w, r)
}))
defer recordingServer.Close()
})
s := &server{
logf: t.Logf,
@@ -533,9 +534,10 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
{
name: "upload-fails-after-starting",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
r.Body.Read(make([]byte, 1))
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusInternalServerError)
},
sshCommand: "echo hello && sleep 1 && echo world",
wantClientOutput: "\r\n\r\nsession terminated\r\n\r\n",
@@ -548,6 +550,7 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.logf = t.Logf
tstest.Replace(t, &handler, tt.handler)
sc, dc := memnet.NewTCPConn(src, dst, 1024)
var wg sync.WaitGroup
@@ -597,12 +600,12 @@ func TestMultipleRecorders(t *testing.T) {
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
}
done := make(chan struct{})
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
defer close(done)
io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer recordingServer.Close()
w.(http.Flusher).Flush()
io.ReadAll(r.Body)
})
badRecorder, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
@@ -610,15 +613,9 @@ func TestMultipleRecorders(t *testing.T) {
badRecorderAddr := badRecorder.Addr().String()
badRecorder.Close()
badRecordingServer500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer badRecordingServer500.Close()
badRecordingServer200 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer badRecordingServer200.Close()
badRecordingServer500 := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
s := &server{
logf: t.Logf,
@@ -630,7 +627,6 @@ func TestMultipleRecorders(t *testing.T) {
Recorders: []netip.AddrPort{
netip.MustParseAddrPort(badRecorderAddr),
netip.MustParseAddrPort(badRecordingServer500.Listener.Addr().String()),
netip.MustParseAddrPort(badRecordingServer200.Listener.Addr().String()),
netip.MustParseAddrPort(recordingServer.Listener.Addr().String()),
},
OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{
@@ -701,19 +697,21 @@ func TestSSHRecordingNonInteractive(t *testing.T) {
}
var recording []byte
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
defer cancel()
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
var err error
recording, err = io.ReadAll(r.Body)
if err != nil {
t.Error(err)
return
}
}))
defer recordingServer.Close()
})
s := &server{
logf: logger.Discard,
logf: t.Logf,
lb: &localState{
sshEnabled: true,
matchingRule: newSSHRule(
@@ -1299,3 +1297,22 @@ func TestStdOsUserUserAssumptions(t *testing.T) {
t.Errorf("os/user.User has %v fields; this package assumes %v", got, want)
}
}
func mockRecordingServer(t *testing.T, handleRecord http.HandlerFunc) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(http.ResponseWriter, *http.Request) {
t.Errorf("v1 recording endpoint called")
})
mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {})
mux.HandleFunc("POST /v2/record", handleRecord)
h2s := &http2.Server{}
srv := httptest.NewUnstartedServer(h2c.NewHandler(mux, h2s))
if err := http2.ConfigureServer(srv.Config, h2s); err != nil {
t.Errorf("configuring HTTP/2 support in recording server: %v", err)
}
srv.Start()
t.Cleanup(srv.Close)
return srv
}

View File

@@ -142,7 +142,7 @@ type CapabilityVersion int
// - 97: 2024-06-06: Client understands NodeAttrDisableSplitDNSWhenNoCustomResolvers
// - 98: 2024-06-13: iOS/tvOS clients may provide serial number as part of posture information
// - 99: 2024-06-14: Client understands NodeAttrDisableLocalDNSOverrideViaNRPT
// - 100: 2024-06-18: Client supports filtertype.Match.SrcCaps (issue #12542)
// - 100: 2024-06-18: Initial support for filtertype.Match.SrcCaps - actually usable in capver 109 (issue #12542)
// - 101: 2024-07-01: Client supports SSH agent forwarding when handling connections with /bin/su
// - 102: 2024-07-12: NodeAttrDisableMagicSockCryptoRouting support
// - 103: 2024-07-24: Client supports NodeAttrDisableCaptivePortalDetection
@@ -150,7 +150,9 @@ type CapabilityVersion int
// - 105: 2024-08-05: Fixed SSH behavior on systems that use busybox (issue #12849)
// - 106: 2024-09-03: fix panic regression from cryptokey routing change (65fe0ba7b5)
// - 107: 2024-10-30: add App Connector to conffile (PR #13942)
const CurrentCapabilityVersion CapabilityVersion = 107
// - 108: 2024-11-08: Client sends ServicesHash in Hostinfo, understands c2n GET /vip-services.
// - 109: 2024-11-18: Client supports filtertype.Match.SrcCaps (issue #12542)
const CurrentCapabilityVersion CapabilityVersion = 109
type StableID string
@@ -820,6 +822,7 @@ type Hostinfo struct {
Userspace opt.Bool `json:",omitempty"` // if the client is running in userspace (netstack) mode
UserspaceRouter opt.Bool `json:",omitempty"` // if the client's subnet router is running in userspace (netstack) mode
AppConnector opt.Bool `json:",omitempty"` // if the client is running the app-connector service
ServicesHash string `json:",omitempty"` // opaque hash of the most recent list of tailnet services, change in hash indicates config should be fetched via c2n
// Location represents geographical location data about a
// Tailscale host. Location is optional and only set if
@@ -830,6 +833,26 @@ type Hostinfo struct {
// require changes to Hostinfo.Equal.
}
// VIPService represents a service created on a tailnet from the
// perspective of a node providing that service. These services
// have an virtual IP (VIP) address pair distinct from the node's IPs.
type VIPService struct {
// Name is the name of the service, of the form `svc:dns-label`.
// See CheckServiceName for a validation func.
// Name uniquely identifies a service on a particular tailnet,
// and so also corresponds uniquely to the pair of IP addresses
// belonging to the VIP service.
Name string
// Ports specify which ProtoPorts are made available by this node
// on the service's IPs.
Ports []ProtoPortRange
// Active specifies whether new requests for the service should be
// sent to this node by control.
Active bool
}
// TailscaleSSHEnabled reports whether or not this node is acting as a
// Tailscale SSH server.
func (hi *Hostinfo) TailscaleSSHEnabled() bool {
@@ -1429,6 +1452,11 @@ const (
// user groups as Kubernetes user groups. This capability is read by
// peers that are Tailscale Kubernetes operator instances.
PeerCapabilityKubernetes PeerCapability = "tailscale.com/cap/kubernetes"
// PeerCapabilityServicesDestination grants a peer the ability to serve as
// a destination for a set of given VIP services, which is provided as the
// value of this key in NodeCapMap.
PeerCapabilityServicesDestination PeerCapability = "tailscale.com/cap/services-destination"
)
// NodeCapMap is a map of capabilities to their optional values. It is valid for

View File

@@ -183,6 +183,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
Userspace opt.Bool
UserspaceRouter opt.Bool
AppConnector opt.Bool
ServicesHash string
Location *Location
}{})

View File

@@ -66,6 +66,7 @@ func TestHostinfoEqual(t *testing.T) {
"Userspace",
"UserspaceRouter",
"AppConnector",
"ServicesHash",
"Location",
}
if have := fieldsOf(reflect.TypeFor[Hostinfo]()); !reflect.DeepEqual(have, hiHandles) {
@@ -240,6 +241,16 @@ func TestHostinfoEqual(t *testing.T) {
&Hostinfo{AppConnector: opt.Bool("false")},
false,
},
{
&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
true,
},
{
&Hostinfo{ServicesHash: "084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0"},
&Hostinfo{},
false,
},
}
for i, tt := range tests {
got := tt.a.Equal(tt.b)

View File

@@ -318,6 +318,7 @@ func (v HostinfoView) Cloud() string { return v.ж.Clou
func (v HostinfoView) Userspace() opt.Bool { return v.ж.Userspace }
func (v HostinfoView) UserspaceRouter() opt.Bool { return v.ж.UserspaceRouter }
func (v HostinfoView) AppConnector() opt.Bool { return v.ж.AppConnector }
func (v HostinfoView) ServicesHash() string { return v.ж.ServicesHash }
func (v HostinfoView) Location() *Location {
if v.ж.Location == nil {
return nil
@@ -365,6 +366,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
Userspace opt.Bool
UserspaceRouter opt.Bool
AppConnector opt.Bool
ServicesHash string
Location *Location
}{})

View File

@@ -832,7 +832,7 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
w.WriteHeader(200)
for {
if resBytes, ok := s.takeRawMapMessage(req.NodeKey); ok {
if err := s.sendMapMsg(w, mkey, compress, resBytes); err != nil {
if err := s.sendMapMsg(w, compress, resBytes); err != nil {
s.logf("sendMapMsg of raw message: %v", err)
return
}
@@ -864,7 +864,7 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
s.logf("json.Marshal: %v", err)
return
}
if err := s.sendMapMsg(w, mkey, compress, resBytes); err != nil {
if err := s.sendMapMsg(w, compress, resBytes); err != nil {
return
}
}
@@ -895,7 +895,7 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
}
break keepAliveLoop
case <-keepAliveTimerCh:
if err := s.sendMapMsg(w, mkey, compress, keepAliveMsg); err != nil {
if err := s.sendMapMsg(w, compress, keepAliveMsg); err != nil {
return
}
}
@@ -1060,7 +1060,7 @@ func (s *Server) takeRawMapMessage(nk key.NodePublic) (mapResJSON []byte, ok boo
return mapResJSON, true
}
func (s *Server) sendMapMsg(w http.ResponseWriter, mkey key.MachinePublic, compress bool, msg any) error {
func (s *Server) sendMapMsg(w http.ResponseWriter, compress bool, msg any) error {
resBytes, err := s.encode(compress, msg)
if err != nil {
return err

View File

@@ -6,9 +6,10 @@ package tsweb
import (
"context"
"net/http"
"time"
"tailscale.com/util/ctxkey"
"tailscale.com/util/fastuuid"
"tailscale.com/util/rands"
)
// RequestID is an opaque identifier for a HTTP request, used to correlate
@@ -41,10 +42,12 @@ 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" + fastuuid.NewUUID().String())
// Return a string of the form "REQ-<VersionByte><...>"
// Previously we returned "REQ-1<UUIDString>".
// Now we return "REQ-2" version, where the "2" doubles as the year 2YYY
// in a leading date.
now := time.Now().UTC()
return RequestID("REQ-" + now.Format("20060102150405") + rands.HexString(16))
}
// SetRequestID is an HTTP middleware that injects a RequestID in the

View File

@@ -1307,6 +1307,28 @@ func TestBucket(t *testing.T) {
}
}
func TestGenerateRequestID(t *testing.T) {
t0 := time.Now()
got := GenerateRequestID()
t.Logf("Got: %q", got)
if !strings.HasPrefix(string(got), "REQ-2") {
t.Errorf("expect REQ-2 prefix; got %q", got)
}
const wantLen = len("REQ-2024112022140896f8ead3d3f3be27")
if len(got) != wantLen {
t.Fatalf("len = %d; want %d", len(got), wantLen)
}
d := got[len("REQ-"):][:14]
timeBack, err := time.Parse("20060102150405", string(d))
if err != nil {
t.Fatalf("parsing time back: %v", err)
}
elapsed := timeBack.Sub(t0)
if elapsed > 3*time.Second { // allow for slow github actions runners :)
t.Fatalf("time back was %v; want within 3s", elapsed)
}
}
func ExampleMiddlewareStack() {
// setHeader returns a middleware that sets header k = vs.
setHeader := func(k string, vs ...string) Middleware {

View File

@@ -1,56 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package fastuuid implements a UUID construction using an in process CSPRNG.
package fastuuid
import (
crand "crypto/rand"
"encoding/binary"
"io"
"math/rand/v2"
"sync"
"github.com/google/uuid"
)
// NewUUID returns a new UUID using a pool of generators, good for highly
// concurrent use.
func NewUUID() uuid.UUID {
g := pool.Get().(*generator)
defer pool.Put(g)
return g.newUUID()
}
var pool = sync.Pool{
New: func() any {
return newGenerator()
},
}
type generator struct {
rng rand.ChaCha8
}
func seed() [32]byte {
var r [32]byte
if _, err := io.ReadFull(crand.Reader, r[:]); err != nil {
panic(err)
}
return r
}
func newGenerator() *generator {
return &generator{
rng: *rand.NewChaCha8(seed()),
}
}
func (g *generator) newUUID() uuid.UUID {
var u uuid.UUID
binary.NativeEndian.PutUint64(u[:8], g.rng.Uint64())
binary.NativeEndian.PutUint64(u[8:], g.rng.Uint64())
u[6] = (u[6] & 0x0f) | 0x40 // Version 4
u[8] = (u[8] & 0x3f) | 0x80 // Variant 10
return u
}

View File

@@ -1,72 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package fastuuid
import (
"testing"
"github.com/google/uuid"
)
func TestNewUUID(t *testing.T) {
g := pool.Get().(*generator)
defer pool.Put(g)
u := g.newUUID()
if u[6] != (u[6]&0x0f)|0x40 {
t.Errorf("version bits are incorrect")
}
if u[8] != (u[8]&0x3f)|0x80 {
t.Errorf("variant bits are incorrect")
}
}
func BenchmarkBasic(b *testing.B) {
b.Run("NewUUID", func(b *testing.B) {
for range b.N {
NewUUID()
}
})
b.Run("uuid.New-unpooled", func(b *testing.B) {
uuid.DisableRandPool()
for range b.N {
uuid.New()
}
})
b.Run("uuid.New-pooled", func(b *testing.B) {
uuid.EnableRandPool()
for range b.N {
uuid.New()
}
})
}
func BenchmarkParallel(b *testing.B) {
b.Run("NewUUID", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
NewUUID()
}
})
})
b.Run("uuid.New-unpooled", func(b *testing.B) {
uuid.DisableRandPool()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
uuid.New()
}
})
})
b.Run("uuid.New-pooled", func(b *testing.B) {
uuid.EnableRandPool()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
uuid.New()
}
})
})
}

View File

@@ -6,6 +6,7 @@
package multierr
import (
"context"
"errors"
"slices"
"strings"
@@ -134,3 +135,40 @@ func Range(err error, fn func(error) bool) bool {
}
return true
}
// DeduplicateContextErrors returns a new slice of errors with at most one
// occurrence of each [context.Canceled] or [context.DeadlineExceeded], if one
// or more of them are present in the input slice.
//
// All other non-nil errors are returned as-is; nil errors are skipped.
func DeduplicateContextErrors(errs []error) []error {
// preserve nil/non-nil distinction
if errs == nil {
return nil
} else if len(errs) == 0 {
return []error{}
}
var (
ret []error
sawCanceled, sawDeadline bool
)
for _, err := range errs {
if err == nil {
continue
}
if errors.Is(err, context.Canceled) {
if sawCanceled {
continue
}
sawCanceled = true
} else if errors.Is(err, context.DeadlineExceeded) {
if sawDeadline {
continue
}
sawDeadline = true
}
ret = append(ret, err)
}
return ret
}

View File

@@ -4,6 +4,7 @@
package multierr_test
import (
"context"
"errors"
"fmt"
"io"
@@ -107,6 +108,60 @@ func TestRange(t *testing.T) {
})), want)
}
func TestDeduplicateContextErrors(t *testing.T) {
testError := errors.New("test error")
tests := []struct {
name string
input []error
want []error
}{
{name: "nil", input: nil, want: nil},
{name: "empty", input: []error{}, want: []error{}},
{name: "single", input: []error{testError}, want: []error{testError}},
{
name: "duplicate_non_context",
input: []error{testError, testError},
want: []error{testError, testError},
},
{
name: "single_context",
input: []error{context.Canceled},
want: []error{context.Canceled},
},
{
name: "duplicate_context",
input: []error{testError, context.Canceled, context.Canceled},
want: []error{testError, context.Canceled},
},
{
name: "duplicate_context_mixed",
input: []error{
testError,
context.Canceled,
context.Canceled,
testError,
context.DeadlineExceeded,
context.DeadlineExceeded,
},
want: []error{
testError,
context.Canceled,
testError,
context.DeadlineExceeded,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := multierr.DeduplicateContextErrors(tt.input)
if diff := cmp.Diff(tt.want, got, cmpopts.EquateErrors()); diff != "" {
t.Errorf("DeduplicateContextErrors() mismatch (-want +got):\n%s", diff)
}
})
}
}
var sink error
func BenchmarkEmpty(b *testing.B) {

View File

@@ -11,6 +11,7 @@ import (
"sync/atomic"
"time"
"tailscale.com/util/syspolicy/internal"
"tailscale.com/util/syspolicy/internal/loggerx"
"tailscale.com/util/syspolicy/setting"
@@ -447,3 +448,9 @@ func (p *Policy) Close() {
go p.closeInternal()
}
}
func setForTest[T any](tb internal.TB, target *T, newValue T) {
oldValue := *target
tb.Cleanup(func() { *target = oldValue })
*target = newValue
}

View File

@@ -574,9 +574,6 @@ func TestPolicyChangeHasChanged(t *testing.T) {
}
func TestChangePolicySetting(t *testing.T) {
setForTest(t, &policyReloadMinDelay, 100*time.Millisecond)
setForTest(t, &policyReloadMaxDelay, 500*time.Millisecond)
// Register policy settings used in this test.
settingA := setting.NewDefinition("TestSettingA", setting.DeviceSetting, setting.StringValue)
settingB := setting.NewDefinition("TestSettingB", setting.DeviceSetting, setting.StringValue)
@@ -589,6 +586,10 @@ func TestChangePolicySetting(t *testing.T) {
if _, err := RegisterStoreForTest(t, "TestSource", setting.DeviceScope, store); err != nil {
t.Fatalf("Failed to register policy store: %v", err)
}
setForTest(t, &policyReloadMinDelay, 100*time.Millisecond)
setForTest(t, &policyReloadMaxDelay, 500*time.Millisecond)
policy, err := policyForTest(t, setting.DeviceScope)
if err != nil {
t.Fatalf("Failed to get effective policy: %v", err)
@@ -978,9 +979,3 @@ func policyForTest(tb testing.TB, target setting.PolicyScope) (*Policy, error) {
})
return policy, nil
}
func setForTest[T any](tb testing.TB, target *T, newValue T) {
oldValue := *target
tb.Cleanup(func() { *target = oldValue })
*target = newValue
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"sync"
"sync/atomic"
"time"
"tailscale.com/util/syspolicy/internal"
"tailscale.com/util/syspolicy/setting"
@@ -33,6 +34,9 @@ func RegisterStore(name string, scope setting.PolicyScope, store source.Store) (
// RegisterStoreForTest is like [RegisterStore], but unregisters the store when
// tb and all its subtests complete.
func RegisterStoreForTest(tb internal.TB, name string, scope setting.PolicyScope, store source.Store) (*StoreRegistration, error) {
setForTest(tb, &policyReloadMinDelay, 10*time.Millisecond)
setForTest(tb, &policyReloadMaxDelay, 500*time.Millisecond)
reg, err := RegisterStore(name, scope, store)
if err == nil {
tb.Cleanup(func() {

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