diff --git a/cmd/k8s-operator/e2e/egress_test.go b/cmd/k8s-operator/e2e/egress_test.go new file mode 100644 index 000000000..9f0a530ce --- /dev/null +++ b/cmd/k8s-operator/e2e/egress_test.go @@ -0,0 +1,227 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package e2e + +import ( + "fmt" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + kube "tailscale.com/k8s-operator" + tsapi "tailscale.com/k8s-operator/apis/v1alpha1" + "tailscale.com/tstest" +) + +const egressPort = 80 + +// See [TestMain] for test requirements. +func TestEgress(t *testing.T) { + if tnClient == nil { + t.Skip("TestEgress requires a working tailnet client") + } + t.Parallel() + + t.Run("IPv4", func(t *testing.T) { + if !clusterIPv4Support { + t.Skip("cluster does not support IPv4") + } + svc := egressService(generateName("test-egress"), map[string]string{ + "tailscale.com/tailnet-ip": tnTarget.ipv4, + }) + createAndCleanup(t, kubeClient, svc) + waitForEgress(t, svc.Name, kube.SvcIsReady) + testEgressIsReachable(t, ns, svc.Name) + }) + + t.Run("IPv6", func(t *testing.T) { + if !clusterIPv6Support { + t.Skip("cluster does not support IPv6") + } + svc := egressService(generateName("test-egress"), map[string]string{ + "tailscale.com/tailnet-ip": tnTarget.ipv6, + }) + createAndCleanup(t, kubeClient, svc) + waitForEgress(t, svc.Name, kube.SvcIsReady) + testEgressIsReachable(t, ns, svc.Name) + }) + + t.Run("FQDN", func(t *testing.T) { + svc := egressService(generateName("test-egress"), map[string]string{ + "tailscale.com/tailnet-fqdn": tnTarget.fqdn, + }) + createAndCleanup(t, kubeClient, svc) + waitForEgress(t, svc.Name, kube.SvcIsReady) + testEgressIsReachable(t, ns, svc.Name) + }) +} + +// See [TestMain] for test requirements. +func TestHAEgress(t *testing.T) { + if tnClient == nil { + t.Skip("TestHAEgress requires a working tailnet client") + } + t.Parallel() + + pg := &tsapi.ProxyGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateName("egress"), + }, + Spec: tsapi.ProxyGroupSpec{ + Type: tsapi.ProxyGroupTypeEgress, + }, + } + createAndCleanup(t, kubeClient, pg) + + t.Run("IPv4", func(t *testing.T) { + if !clusterIPv4Support { + t.Skip("cluster does not support IPv4") + } + svc := egressService(generateName("test-egress"), map[string]string{ + "tailscale.com/tailnet-ip": tnTarget.ipv4, + "tailscale.com/proxy-group": pg.Name, + }) + createAndCleanup(t, kubeClient, svc) + waitForEgress(t, svc.Name, pgEgressReady) + testEgressIsReachable(t, ns, svc.Name) + }) + + t.Run("IPv6", func(t *testing.T) { + if !clusterIPv6Support { + t.Skip("cluster does not support IPv6") + } + svc := egressService(generateName("test-egress"), map[string]string{ + "tailscale.com/tailnet-ip": tnTarget.ipv6, + "tailscale.com/proxy-group": pg.Name, + }) + createAndCleanup(t, kubeClient, svc) + waitForEgress(t, svc.Name, pgEgressReady) + testEgressIsReachable(t, ns, svc.Name) + }) + + t.Run("FQDN", func(t *testing.T) { + svc := egressService(generateName("test-egress"), map[string]string{ + "tailscale.com/tailnet-fqdn": tnTarget.fqdn, + "tailscale.com/proxy-group": pg.Name, + }) + createAndCleanup(t, kubeClient, svc) + waitForEgress(t, svc.Name, pgEgressReady) + testEgressIsReachable(t, ns, svc.Name) + }) +} + +// See [TestMain] for test requirements. +func TestHAEgressMultiTailnet(t *testing.T) { + if tnClient == nil || secondTNClient == nil { + t.Skip("TestHAEgressMultiTailnet requires a working tailnet client for a first and second tailnet") + } + t.Parallel() + + pg := &tsapi.ProxyGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateName("egress"), + }, + Spec: tsapi.ProxyGroupSpec{ + Type: tsapi.ProxyGroupTypeEgress, + Tailnet: "second-tailnet", + }, + } + createAndCleanup(t, kubeClient, pg) + if err := verifyProxyGroupTailnet(t, pg, secondTNClient); err != nil { + t.Fatalf("verifying ProxyGroup %s is registered to the correct tailnet: %v", pg.Name, err) + } + + svc := egressService(generateName("test-egress"), map[string]string{ + "tailscale.com/tailnet-fqdn": secondTNTarget.fqdn, + "tailscale.com/proxy-group": pg.Name, + }) + createAndCleanup(t, kubeClient, svc) + waitForEgress(t, svc.Name, pgEgressReady) + testEgressIsReachable(t, ns, svc.Name) +} + +func egressService(name string, annotations map[string]string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Annotations: annotations, + }, + Spec: corev1.ServiceSpec{ + ExternalName: "placeholder", + Type: corev1.ServiceTypeExternalName, + Ports: []corev1.ServicePort{ + { + Name: "http", + Protocol: corev1.ProtocolTCP, + Port: egressPort, + }, + }, + }, + } +} + +func pgEgressReady(svc *corev1.Service) bool { + cond := kube.GetServiceCondition(svc, tsapi.EgressSvcReady) + return cond != nil && cond.Status == metav1.ConditionTrue +} + +func waitForEgress(t *testing.T, svcName string, ready func(*corev1.Service) bool) { + t.Helper() + if err := tstest.WaitFor(5*time.Minute, func() error { + svc := &corev1.Service{ObjectMeta: objectMeta(ns, svcName)} + if err := get(t.Context(), kubeClient, svc); err != nil { + return err + } + if ready(svc) { + t.Logf("Service %s is ready", svcName) + return nil + } + return fmt.Errorf("Service %s is not ready yet", svcName) + }); err != nil { + t.Fatalf("error waiting for Service %s to become ready: %v", svcName, err) + } +} + +func testEgressIsReachable(t *testing.T, namespace, svcName string) { + t.Helper() + url := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", svcName, namespace, egressPort) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateName("curl"), + Namespace: namespace, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "curl", + Image: "curlimages/curl", + Command: []string{"sh", "-c", fmt.Sprintf( + `for i in $(seq 1 10); do `+ + `code=$(curl -s -o /dev/null -w "%%{http_code}" --max-time 5 %q); `+ + `[ "$code" = "200" ] && exit 0; sleep 2; done; exit 1`, url)}, + }, + }, + }, + } + createAndCleanup(t, kubeClient, pod) + + if err := tstest.WaitFor(2*time.Minute, func() error { + p := &corev1.Pod{ObjectMeta: objectMeta(namespace, pod.Name)} + if err := get(t.Context(), kubeClient, p); err != nil { + return err + } + if p.Status.Phase == corev1.PodSucceeded { + t.Logf("curl pod %s succeeded", pod.Name) + return nil + } + return fmt.Errorf("curl pod %s phase: %s", pod.Name, p.Status.Phase) + }); err != nil { + t.Fatalf("egress service %s/%s not reachable: %v", + namespace, svcName, err) + } +} diff --git a/cmd/k8s-operator/e2e/helpers.go b/cmd/k8s-operator/e2e/helpers.go index e01821c23..b72a939cb 100644 --- a/cmd/k8s-operator/e2e/helpers.go +++ b/cmd/k8s-operator/e2e/helpers.go @@ -9,9 +9,15 @@ "fmt" "net/http" "strings" + "testing" "time" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + tsapi "tailscale.com/k8s-operator/apis/v1alpha1" + "tailscale.com/kube/kubetypes" "tailscale.com/tsnet" + "tailscale.com/tstest" ) func generateName(prefix string) string { @@ -29,3 +35,53 @@ func newHTTPClient(cl *tsnet.Server) *http.Client { }, } } + +// verifyProxyGroupTailnet verifies that a ProxyGroup is registered to the correct tailnet. +// This is done by getting the expected tailnet domain for the tailnet client, +// and comparing this with the actual device fqdn in the ProxyGroup state secret. +func verifyProxyGroupTailnet(t *testing.T, pg *tsapi.ProxyGroup, cl *tsnet.Server) error { + t.Helper() + // Determine the expected tailnet Magic DNS Name. + lc, err := cl.LocalClient() + if err != nil { + return err + } + status, err := lc.Status(t.Context()) + if err != nil { + return err + } + _, expectedTailnet, ok := strings.Cut(strings.TrimSuffix(status.Self.DNSName, "."), ".") + if !ok { + return fmt.Errorf("unexpected DNSName format %q", status.Self.DNSName) + } + // Read the device FQDN from the first state secret for the ProxyGroup, + // and verify that this matches the expected tailnet. + if err := tstest.WaitFor(3*time.Minute, func() error { + var secrets corev1.SecretList + if err := kubeClient.List(t.Context(), &secrets, + client.InNamespace("tailscale"), + client.MatchingLabels{ + kubetypes.LabelSecretType: kubetypes.LabelSecretTypeState, + "tailscale.com/parent-resource-type": "proxygroup", + "tailscale.com/parent-resource": pg.Name, + }, + ); err != nil { + return err + } + if len(secrets.Items) == 0 { + return fmt.Errorf("no state secrets found for ProxyGroup %q yet", pg.Name) + } + fqdn := strings.TrimSuffix(string(secrets.Items[0].Data[kubetypes.KeyDeviceFQDN]), ".") + _, tailnet, ok := strings.Cut(fqdn, ".") + if !ok { + return fmt.Errorf("ProxyGroup %q: device FQDN %q has no domain yet", pg.Name, fqdn) + } + if tailnet != expectedTailnet { + return fmt.Errorf("ProxyGroup %q on wrong tailnet: got domain %q, want %q", pg.Name, tailnet, expectedTailnet) + } + return nil + }); err != nil { + return fmt.Errorf("ProxyGroup %q not on expected tailnet: %v", pg.Name, err) + } + return nil +} diff --git a/cmd/k8s-operator/e2e/ingress_test.go b/cmd/k8s-operator/e2e/ingress_test.go index bef24ca5a..67dfd1b0b 100644 --- a/cmd/k8s-operator/e2e/ingress_test.go +++ b/cmd/k8s-operator/e2e/ingress_test.go @@ -21,7 +21,6 @@ kube "tailscale.com/k8s-operator" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/kube/kubetypes" - "tailscale.com/tsnet" "tailscale.com/tstest" "tailscale.com/util/httpm" ) @@ -31,6 +30,7 @@ func TestL3Ingress(t *testing.T) { if tnClient == nil { t.Skip("TestL3Ingress requires a working tailnet client") } + t.Parallel() // Apply nginx nginx := nginxDeployment(ns) @@ -109,6 +109,7 @@ func TestL3HAIngress(t *testing.T) { if tnClient == nil { t.Skip("TestL3HAIngress requires a working tailnet client") } + t.Parallel() // Apply nginx. nginx := nginxDeployment(ns) @@ -187,6 +188,7 @@ func TestL7Ingress(t *testing.T) { if tnClient == nil { t.Skip("TestL7Ingress requires a working tailnet client") } + t.Parallel() // Apply nginx Deployment and Service. nginx := nginxDeployment(ns) @@ -229,6 +231,7 @@ func TestL7HAIngress(t *testing.T) { if tnClient == nil { t.Skip("TestL7HAIngress requires a working tailnet client") } + t.Parallel() // Apply nginx Deployment and Service. nginx := nginxDeployment(ns) @@ -282,6 +285,7 @@ func TestL7HAIngressMultiTailnet(t *testing.T) { if tnClient == nil || secondTNClient == nil { t.Skip("TestL7HAIngressMultiTailnet requires a working tailnet client for a first and second tailnet") } + t.Parallel() // Apply nginx Deployment and Service. nginx := nginxDeployment(ns) @@ -486,59 +490,6 @@ func testIngressIsReachable(t *testing.T, httpClient *http.Client, url string) e }); err != nil { return fmt.Errorf("error trying to reach %s: %w", url, err) } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status from %s: %d", url, resp.StatusCode) - } - return nil -} - -// verifyProxyGroupTailnet verifies that a ProxyGroup is registered to the correct tailnet. -// This is done by getting the expected tailnet domain for the tailnet client, -// and comparing this with the actual device fqdn in the ProxyGroup state secret. -func verifyProxyGroupTailnet(t *testing.T, pg *tsapi.ProxyGroup, cl *tsnet.Server) error { - t.Helper() - // Determine the expected tailnet Magic DNS Name. - lc, err := cl.LocalClient() - if err != nil { - return err - } - status, err := lc.Status(t.Context()) - if err != nil { - return err - } - _, expectedTailnet, ok := strings.Cut(strings.TrimSuffix(status.Self.DNSName, "."), ".") - if !ok { - return fmt.Errorf("unexpected DNSName format %q", status.Self.DNSName) - } - // Read the device FQDN from the first state secret for the ProxyGroup, - // and verify that this matches the expected tailnet. - if err := tstest.WaitFor(3*time.Minute, func() error { - var secrets corev1.SecretList - if err := kubeClient.List(t.Context(), &secrets, - client.InNamespace("tailscale"), - client.MatchingLabels{ - kubetypes.LabelSecretType: kubetypes.LabelSecretTypeState, - "tailscale.com/parent-resource-type": "proxygroup", - "tailscale.com/parent-resource": pg.Name, - }, - ); err != nil { - return err - } - if len(secrets.Items) == 0 { - return fmt.Errorf("no state secrets found for ProxyGroup %q yet", pg.Name) - } - fqdn := strings.TrimSuffix(string(secrets.Items[0].Data[kubetypes.KeyDeviceFQDN]), ".") - _, tailnet, ok := strings.Cut(fqdn, ".") - if !ok { - return fmt.Errorf("ProxyGroup %q: device FQDN %q has no domain yet", pg.Name, fqdn) - } - if tailnet != expectedTailnet { - return fmt.Errorf("ProxyGroup %q on wrong tailnet: got domain %q, want %q", pg.Name, tailnet, expectedTailnet) - } - return nil - }); err != nil { - return fmt.Errorf("ProxyGroup %q not on expected tailnet: %v", pg.Name, err) - } return nil } diff --git a/cmd/k8s-operator/e2e/proxy_test.go b/cmd/k8s-operator/e2e/proxy_test.go index 3caf1c91d..70ccdb21a 100644 --- a/cmd/k8s-operator/e2e/proxy_test.go +++ b/cmd/k8s-operator/e2e/proxy_test.go @@ -24,6 +24,7 @@ func TestProxy(t *testing.T) { if tnClient == nil { t.Skip("TestProxy requires a working tailnet client") } + t.Parallel() // Create role and role binding to allow a group we'll impersonate to do stuff. createAndCleanup(t, kubeClient, &rbacv1.Role{ diff --git a/cmd/k8s-operator/e2e/proxygrouppolicy_test.go b/cmd/k8s-operator/e2e/proxygrouppolicy_test.go index 0e73394d5..72373dfba 100644 --- a/cmd/k8s-operator/e2e/proxygrouppolicy_test.go +++ b/cmd/k8s-operator/e2e/proxygrouppolicy_test.go @@ -20,12 +20,21 @@ func TestProxyGroupPolicy(t *testing.T) { if tnClient == nil { t.Skip("TestProxyGroupPolicy requires a working tailnet client") } + t.Parallel() + + // Run in a dedicated namespace so the ValidatingAdmissionPolicies the + // operator generates for our ProxyGroupPolicies don't affect Services/Ingresses + // created by the other parallel tests in the default namespace. + pgPolicyNs := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: generateName("test-pgpolicy")}, + } + createAndCleanup(t, kubeClient, pgPolicyNs) // Apply deny-all policy denyAllPolicy := &tsapi.ProxyGroupPolicy{ ObjectMeta: metav1.ObjectMeta{ Name: "deny-all", - Namespace: metav1.NamespaceDefault, + Namespace: pgPolicyNs.Name, }, Spec: tsapi.ProxyGroupPolicySpec{ Ingress: []string{}, @@ -36,12 +45,12 @@ func TestProxyGroupPolicy(t *testing.T) { createAndCleanup(t, kubeClient, denyAllPolicy) <-time.After(time.Second * 2) - // Attempt to create an egress Service within the default namespace, the above policy should + // Attempt to create an egress Service within the namespace, the above policy should // reject it. egressService := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "egress-to-proxy-group", - Namespace: metav1.NamespaceDefault, + Namespace: pgPolicyNs.Name, Annotations: map[string]string{ "tailscale.com/tailnet-fqdn": "test.something.ts.net", "tailscale.com/proxy-group": "test", @@ -69,12 +78,12 @@ func TestProxyGroupPolicy(t *testing.T) { t.Fatal("expected error when creating egress service") } - // Attempt to create an ingress Service within the default namespace, the above policy should + // Attempt to create an ingress Service within the namespace, the above policy should // reject it. ingressService := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "ingress-to-proxy-group", - Namespace: metav1.NamespaceDefault, + Namespace: pgPolicyNs.Name, Annotations: map[string]string{ "tailscale.com/proxy-group": "test", }, @@ -101,11 +110,11 @@ func TestProxyGroupPolicy(t *testing.T) { t.Fatal("expected error when creating ingress service") } - // Attempt to create an Ingress within the default namespace, the above policy should reject it + // Attempt to create an Ingress within the namespace, the above policy should reject it ingress := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: "ingress-to-proxy-group", - Namespace: metav1.NamespaceDefault, + Namespace: pgPolicyNs.Name, Annotations: map[string]string{ "tailscale.com/proxy-group": "test", }, @@ -142,7 +151,7 @@ func TestProxyGroupPolicy(t *testing.T) { allowTestPolicy := &tsapi.ProxyGroupPolicy{ ObjectMeta: metav1.ObjectMeta{ Name: "allow-test", - Namespace: metav1.NamespaceDefault, + Namespace: pgPolicyNs.Name, }, Spec: tsapi.ProxyGroupPolicySpec{ Ingress: []string{"test"}, diff --git a/cmd/k8s-operator/e2e/setup.go b/cmd/k8s-operator/e2e/setup.go index 0d4ca80ad..c6fadc7da 100644 --- a/cmd/k8s-operator/e2e/setup.go +++ b/cmd/k8s-operator/e2e/setup.go @@ -11,9 +11,12 @@ "crypto/x509" _ "embed" "encoding/json" + "errors" "flag" "fmt" "io" + "log" + "net" "net/http" "net/url" "os" @@ -50,6 +53,7 @@ "sigs.k8s.io/controller-runtime/pkg/client" klog "sigs.k8s.io/controller-runtime/pkg/log" kzap "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" "sigs.k8s.io/kind/pkg/cluster" "sigs.k8s.io/kind/pkg/cluster/nodeutils" "sigs.k8s.io/kind/pkg/cmd" @@ -73,11 +77,15 @@ var ( tsClient *tailscale.Client // For API calls to control. tnClient *tsnet.Server // For testing real tailnet traffic on first tailnet. + tnTarget tailnetTarget // Egress target on the first tailnet. secondTSClient *tailscale.Client // For API calls to the secondary tailnet (_second_tailnet). secondTNClient *tsnet.Server // For testing real tailnet traffic on second tailnet. + secondTNTarget tailnetTarget // Egress target on the second tailnet. restCfg *rest.Config // For constructing a client-go client if necessary. kubeClient client.WithWatch // For k8s API calls. clusterLoginServer string + clusterIPv4Support bool // whether the test cluster supports IPv4. + clusterIPv6Support bool // whether the test cluster supports IPv6. //go:embed certs/pebble.minica.crt pebbleMiniCACert []byte @@ -136,6 +144,11 @@ func runTests(m *testing.M) (int, error) { if !slices.Contains(clusters, kindClusterName) { if err := kindProvider.Create(kindClusterName, + cluster.CreateWithV1Alpha4Config(&v1alpha4.Cluster{ + Networking: v1alpha4.Networking{ + IPFamily: v1alpha4.DualStackFamily, + }, + }), cluster.CreateWithWaitForReady(5*time.Minute), cluster.CreateWithKubeconfigPath(kubeconfig), cluster.CreateWithNodeImage("kindest/node:v1.35.0"), @@ -161,6 +174,10 @@ func runTests(m *testing.M) (int, error) { return 0, fmt.Errorf("error creating Kubernetes client: %w", err) } + if err := detectClusterIPFamilies(ctx, logger, kubeClient); err != nil { + return 0, fmt.Errorf("error detecting cluster IP families: %w", err) + } + var ( clientID, clientSecret string // OAuth client for the first tailnet (for the operator to use). caPaths []string // Extra CA cert file paths to add to images. @@ -514,6 +531,10 @@ func runTests(m *testing.M) (int, error) { return 0, err } defer tnClient.Close() + tnTarget, err = startTailnetHTTPServer(ctx, tnClient) + if err != nil { + return 0, fmt.Errorf("failed to start tailnet HTTP server on first tailnet: %w", err) + } secondTNClient = &tsnet.Server{ ControlURL: secondTSClient.BaseURL.String(), @@ -527,6 +548,10 @@ func runTests(m *testing.M) (int, error) { return 0, err } defer secondTNClient.Close() + secondTNTarget, err = startTailnetHTTPServer(ctx, secondTNClient) + if err != nil { + return 0, fmt.Errorf("failed to start tailnet HTTP server on second tailnet: %w", err) + } // Create the tailnet Secret in the tailscale namespace. secret := &corev1.Secret{ @@ -842,6 +867,88 @@ func createOrUpdate(ctx context.Context, cl client.Client, obj client.Object) er return nil } +// detectClusterIPFamilies determines which IP families the cluster supports by +// creating a throwaway ClusterIP Service with PreferDualStack and reading back +// the IP families the API server assigns. +func detectClusterIPFamilies(ctx context.Context, logger *zap.SugaredLogger, cl client.Client) error { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateName("ipfamily-probe"), + Namespace: ns, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + IPFamilyPolicy: new(corev1.IPFamilyPolicyPreferDualStack), + Ports: []corev1.ServicePort{ + {Name: "probe", Protocol: corev1.ProtocolTCP, Port: 80}, + }, + }, + } + if err := cl.Create(ctx, svc); err != nil { + return fmt.Errorf("failed to create IP family Service: %w", err) + } + defer func() { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := cl.Delete(ctx, svc); err != nil { + logger.Warnf("failed to clean up IP family Service %s/%s: %v", svc.Namespace, svc.Name, err) + } + }() + + for _, ip := range svc.Spec.IPFamilies { + switch ip { + case corev1.IPv4Protocol: + clusterIPv4Support = true + case corev1.IPv6Protocol: + clusterIPv6Support = true + } + } + if !clusterIPv4Support && !clusterIPv6Support { + return fmt.Errorf("Service %s/%s reported no IP families", svc.Namespace, svc.Name) + } + return nil +} + +// tailnetTarget holds the FQDN, IPv4, and IPv6 addresses of the tailnet +// HTTP server used as the egress target. +type tailnetTarget struct { + fqdn, ipv4, ipv6 string +} + +// startTailnetHTTPServer starts an HTTP server that returns the tailnet FQDN, IPv4, +// and IPv6 addresses of the created node. Used as an egress target in tests. +func startTailnetHTTPServer(ctx context.Context, cl *tsnet.Server) (tailnetTarget, error) { + ln, err := cl.Listen("tcp", ":80") + if err != nil { + return tailnetTarget{}, fmt.Errorf("failed to listen on tailnet: %w", err) + } + go func() { + if err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })); err != nil && !errors.Is(err, net.ErrClosed) { + log.Printf("tailnet HTTP server exited: %v", err) + } + }() + + lc, err := cl.LocalClient() + if err != nil { + return tailnetTarget{}, fmt.Errorf("failed to get local client: %w", err) + } + status, err := lc.StatusWithoutPeers(ctx) + if err != nil { + return tailnetTarget{}, fmt.Errorf("failed to get status: %w", err) + } + target := tailnetTarget{fqdn: strings.TrimSuffix(status.Self.DNSName, ".")} + for _, ip := range status.TailscaleIPs { + if ip.Is4() { + target.ipv4 = ip.String() + } else { + target.ipv6 = ip.String() + } + } + return target, nil +} + // createTailnet creates a new tailnet and returns a tailscale.Client // authenticated against it using the bootstrap credentials included in the // creation response.