cmd/k8s-operator/e2e: add egress tests (#20648)

Add end-to-end tests for egress to a tailnet target via its FQDN, IPv4 and
IPv6 addresses.

Update the kind cluster used in tests to support dual-stack (to allow
testing egress to both IPv4 and IPv6 tailnet targets). For pre-existing
clusters, detect on test setup which IP families the cluster supports.
IPv4/IPv6 subtests are skipped if not supported.

Run the end-to-end tests in parallel to improve the speed of test runs. To
enable this, run TestProxyGroupPolicy in a dedicated namespace so its
ValidatingAdmissionPolicies don't affect other parallel tests sharing the
same namespace.

Updates tailscale/corp#34834

Signed-off-by: Becky Pauley <becky@tailscale.com>
This commit is contained in:
BeckyPauley
2026-07-28 18:21:44 +01:00
committed by GitHub
parent 5927c1864f
commit 122ed8a44f
6 changed files with 413 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"},

View File

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