Compare commits

..

18 Commits

Author SHA1 Message Date
Brad Fitzpatrick
c084e3f6ec net/netcheck: respect DERPRegion.Avoid on initial probe plan too
As found by @jwhited/@raggi.

Updates #8603
Updates #13969
Updates tailscale/corp#24697

Change-Id: I32bb412a06e46a5fc154d87147e75363cf0d5407
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-20 10:11:51 -08: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
52 changed files with 1853 additions and 374 deletions

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

@@ -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

@@ -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

@@ -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

@@ -4703,6 +4703,14 @@ rules:
- patch
- update
- watch
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

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

@@ -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

@@ -70,6 +70,8 @@ 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: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
},
@@ -228,6 +230,8 @@ 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: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},

View File

@@ -213,6 +213,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 +501,7 @@ var watchIPNArgs struct {
netmap bool
initial bool
showPrivateKey bool
rateLimit bool
count int
}
@@ -511,6 +513,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

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 {

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"
@@ -2156,10 +2157,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 +2780,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
@@ -4888,6 +4883,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
@@ -7485,3 +7488,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"
@@ -4464,3 +4465,90 @@ 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
}
})
}
}

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

@@ -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

@@ -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, gotMethod, a.wantsMethod)
}
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

@@ -202,20 +202,6 @@ func (r *Resolver) LookupIP(ctx context.Context, host string) (ip, v6 netip.Addr
r.dlogf("returning %d static results", len(allIPs))
return
}
// Hard-code this to avoid extra work, DNS fallbacks, etc.
if host == "localhost" {
r.dlogf("host is localhost")
// TODO: @raggi mentioned that some distributions don't use
// 127.0.0.1 as the localhost IP; should we check the interface
// address to determine this?
ip = netip.AddrFrom4([4]byte{127, 0, 0, 1})
v6 = netip.IPv6Loopback()
allIPs = []netip.Addr{ip, v6}
err = nil
return
}
if ip, err := netip.ParseAddr(host); err == nil {
ip = ip.Unmap()
r.dlogf("%q is an IP", host)

View File

@@ -11,7 +11,6 @@ import (
"net"
"net/netip"
"reflect"
"slices"
"testing"
"time"
@@ -241,51 +240,3 @@ func TestShouldTryBootstrap(t *testing.T) {
})
}
}
func TestLocalhost(t *testing.T) {
tstest.Replace(t, &debug, func() bool { return true })
r := &Resolver{
Logf: t.Logf,
Forward: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
// always return an error to force fallback
return nil, errors.New("some error")
},
},
LookupIPFallback: func(ctx context.Context, host string) ([]netip.Addr, error) {
t.Errorf("unexpected call to LookupIPFallback(%q)", host)
return nil, errors.New("unimplemented")
},
}
// Just overriding the 'Dial' function in the *net.Resolver isn't
// enough, because the Go resolver will read /etc/hosts and return
// localhost from that.
//
// Abuse the IP cache to insert a fake localhost entry pointing to some
// invalid IP; if we get this back, we know that we didn't hit our
// hard-coded "localhost" logic.
invalid4 := netip.MustParseAddr("169.254.169.254")
invalid6 := netip.MustParseAddr("fe80::1")
r.addIPCache("localhost", invalid4, invalid6, []netip.Addr{invalid4, invalid6}, 24*time.Hour)
ip4, ip6, allIPs, err := r.LookupIP(context.Background(), "localhost")
if err != nil {
t.Fatal(err)
}
localhost4 := netip.MustParseAddr("127.0.0.1")
localhost6 := netip.MustParseAddr("::1")
if ip4 != localhost4 {
t.Errorf("ip4 got %q; want %q", ip4, localhost4)
}
if ip6 != localhost6 {
t.Errorf("ip6 got %q; want %q", ip6, localhost6)
}
if !slices.Equal(allIPs, []netip.Addr{localhost4, localhost6}) {
t.Errorf("allIPs got %q; want %q", allIPs, []netip.Addr{localhost4, localhost6})
}
}

View File

@@ -541,7 +541,7 @@ func makeProbePlanInitial(dm *tailcfg.DERPMap, ifState *netmon.State) (plan prob
plan = make(probePlan)
for _, reg := range dm.Regions {
if len(reg.Nodes) == 0 {
if reg.Avoid || len(reg.Nodes) == 0 {
continue
}

View File

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

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

@@ -17,6 +17,7 @@ import (
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"unsafe"
@@ -128,9 +129,10 @@ func Login(logf logger.Logf, srcName string, u *user.User, capLevel CapabilityLe
if err != nil {
return nil, err
}
tokenCloseOnce := sync.OnceFunc(func() { token.Close() })
defer func() {
if err != nil {
token.Close()
tokenCloseOnce()
}
}()
@@ -162,6 +164,7 @@ func Login(logf logger.Logf, srcName string, u *user.User, capLevel CapabilityLe
sessToken.Close()
}
}()
tokenCloseOnce()
}
userProfile, err := winutil.LoadUserProfile(sessToken, u)

View File

@@ -7,6 +7,7 @@ package version
import (
"fmt"
"runtime/debug"
"strconv"
"strings"
tailscaleroot "tailscale.com"
@@ -169,3 +170,42 @@ func majorMinorPatch() string {
ret, _, _ := strings.Cut(Short(), "-")
return ret
}
func isValidLongWithTwoRepos(v string) bool {
s := strings.Split(v, "-")
if len(s) != 3 {
return false
}
hexChunk := func(s string) bool {
if len(s) < 6 {
return false
}
for i := range len(s) {
b := s[i]
if (b < '0' || b > '9') && (b < 'a' || b > 'f') {
return false
}
}
return true
}
v, t, g := s[0], s[1], s[2]
if !strings.HasPrefix(t, "t") || !strings.HasPrefix(g, "g") ||
!hexChunk(t[1:]) || !hexChunk(g[1:]) {
return false
}
nums := strings.Split(v, ".")
if len(nums) != 3 {
return false
}
for i, n := range nums {
bits := 8
if i == 2 {
bits = 16
}
if _, err := strconv.ParseUint(n, 10, bits); err != nil {
return false
}
}
return true
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build tailscale_go && android
package version
import "fmt"
func init() {
// For official Android builds using the tailscale_go toolchain,
// panic if the builder is screwed up and we fail to stamp a valid
// version string.
if !isValidLongWithTwoRepos(Long()) {
panic(fmt.Sprintf("malformed version.Long value %q", Long()))
}
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package version
import "testing"
func TestIsValidLongWithTwoRepos(t *testing.T) {
tests := []struct {
long string
want bool
}{
{"1.2.3-t01234abcde-g01234abcde", true},
{"1.2.259-t01234abcde-g01234abcde", true}, // big patch version
{"1.2.3-t01234abcde", false}, // missing repo
{"1.2.3-g01234abcde", false}, // missing repo
{"-t01234abcde-g01234abcde", false},
{"1.2.3", false},
{"1.2.3-t01234abcde-g", false},
{"1.2.3-t01234abcde-gERRBUILDINFO", false},
}
for _, tt := range tests {
if got := isValidLongWithTwoRepos(tt.long); got != tt.want {
t.Errorf("IsValidLongWithTwoRepos(%q) = %v; want %v", tt.long, got, tt.want)
}
}
}

View File

@@ -202,16 +202,17 @@ func New(matches []Match, capTest CapTestFunc, localNets, logIPs *netipx.IPSet,
}
f := &Filter{
logf: logf,
matches4: matchesFamily(matches, netip.Addr.Is4),
matches6: matchesFamily(matches, netip.Addr.Is6),
cap4: capMatchesFunc(matches, netip.Addr.Is4),
cap6: capMatchesFunc(matches, netip.Addr.Is6),
local4: ipset.FalseContainsIPFunc(),
local6: ipset.FalseContainsIPFunc(),
logIPs4: ipset.FalseContainsIPFunc(),
logIPs6: ipset.FalseContainsIPFunc(),
state: state,
logf: logf,
matches4: matchesFamily(matches, netip.Addr.Is4),
matches6: matchesFamily(matches, netip.Addr.Is6),
cap4: capMatchesFunc(matches, netip.Addr.Is4),
cap6: capMatchesFunc(matches, netip.Addr.Is6),
local4: ipset.FalseContainsIPFunc(),
local6: ipset.FalseContainsIPFunc(),
logIPs4: ipset.FalseContainsIPFunc(),
logIPs6: ipset.FalseContainsIPFunc(),
state: state,
srcIPHasCap: capTest,
}
if localNets != nil {
p := localNets.Prefixes()