// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause //go:build !plan9 package main import ( "context" "encoding/json" "fmt" "testing" "github.com/google/go-cmp/cmp" "go.uber.org/zap" corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" operatorutils "tailscale.com/k8s-operator" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/kube/kubetypes" "tailscale.com/tstest" "tailscale.com/types/ptr" ) func TestDNSRecordsReconciler(t *testing.T) { // Preconfigure a cluster with a DNSConfig dnsConfig := &tsapi.DNSConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"}, Spec: tsapi.DNSConfigSpec{ Nameserver: &tsapi.Nameserver{}, }} ing := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: "ts-ingress", Namespace: "test", }, Spec: networkingv1.IngressSpec{ IngressClassName: ptr.To("tailscale"), }, Status: networkingv1.IngressStatus{ LoadBalancer: networkingv1.IngressLoadBalancerStatus{ Ingress: []networkingv1.IngressLoadBalancerIngress{{ Hostname: "cluster.ingress.ts.net"}}, }, }, } cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords", Namespace: "tailscale"}} fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). WithObjects(cm). WithObjects(dnsConfig). WithObjects(ing). WithStatusSubresource(dnsConfig, ing). Build() zl, err := zap.NewDevelopment() if err != nil { t.Fatal(err) } cl := tstest.NewClock(tstest.ClockOpts{}) // Set the ready condition of the DNSConfig mustUpdateStatus(t, fc, "", "test", func(c *tsapi.DNSConfig) { operatorutils.SetDNSConfigCondition(c, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated, 0, cl, zl.Sugar()) }) dnsRR := &dnsRecordsReconciler{ Client: fc, logger: zl.Sugar(), tsNamespace: "tailscale", } // 1. DNS record is created for an egress proxy configured via // tailscale.com/tailnet-fqdn annotation egressSvcFQDN := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "egress-fqdn", Namespace: "test", Annotations: map[string]string{"tailscale.com/tailnet-fqdn": "foo.bar.ts.net"}, }, Spec: corev1.ServiceSpec{ ExternalName: "unused", Type: corev1.ServiceTypeExternalName, }, } headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7", discoveryv1.AddressTypeIPv4) epv6 := endpointSliceForService(headlessForEgressSvcFQDN, "2600:1900:4011:161:0:d:0:d", discoveryv1.AddressTypeIPv6) mustCreate(t, fc, egressSvcFQDN) mustCreate(t, fc, headlessForEgressSvcFQDN) mustCreate(t, fc, ep) mustCreate(t, fc, epv6) expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service // ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7 wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}} // IPv6 endpoint is currently ignored expectHostsRecords(t, fc, wantHosts) // 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's // value changes mustUpdate(t, fc, "test", "egress-fqdn", func(svc *corev1.Service) { svc.Annotations["tailscale.com/tailnet-fqdn"] = "baz.bar.ts.net" }) expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service wantHosts = map[string][]string{"baz.bar.ts.net": {"10.9.8.7"}} expectHostsRecords(t, fc, wantHosts) // 3. DNS record is updated if the IP address of the proxy Pod changes. ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4", discoveryv1.AddressTypeIPv4) mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { ep.Endpoints[0].Addresses = []string{"10.6.5.4"} }) expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service wantHosts = map[string][]string{"baz.bar.ts.net": {"10.6.5.4"}} expectHostsRecords(t, fc, wantHosts) // 4. DNS record is created for an ingress proxy configured via Ingress headlessForIngress := headlessSvcForParent(ing, "ingress") ep = endpointSliceForService(headlessForIngress, "10.9.8.7", discoveryv1.AddressTypeIPv4) mustCreate(t, fc, headlessForIngress) mustCreate(t, fc, ep) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service wantHosts["cluster.ingress.ts.net"] = []string{"10.9.8.7"} expectHostsRecords(t, fc, wantHosts) // 5. DNS records are updated if Ingress's MagicDNS name changes (i.e users changed spec.tls.hosts[0]) t.Log("test case 5") mustUpdateStatus(t, fc, "test", "ts-ingress", func(ing *networkingv1.Ingress) { ing.Status.LoadBalancer.Ingress[0].Hostname = "another.ingress.ts.net" }) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service delete(wantHosts, "cluster.ingress.ts.net") wantHosts["another.ingress.ts.net"] = []string{"10.9.8.7"} expectHostsRecords(t, fc, wantHosts) // 6. DNS records are updated if Ingress proxy's Pod IP changes mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { ep.Endpoints[0].Addresses = []string{"7.8.9.10"} }) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"} expectHostsRecords(t, fc, wantHosts) // 7. A not-ready Endpoint is removed from DNS config. mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { ep.Endpoints[0].Conditions.Ready = ptr.To(false) ep.Endpoints = append(ep.Endpoints, discoveryv1.Endpoint{ Addresses: []string{"1.2.3.4"}, }) }) expectReconciled(t, dnsRR, "tailscale", "ts-ingress") wantHosts["another.ingress.ts.net"] = []string{"1.2.3.4"} expectHostsRecords(t, fc, wantHosts) // 8. DNS record is created for ProxyGroup egress using ClusterIP Service IP instead of Pod IPs t.Log("test case 8: ProxyGroup egress") // Create the parent ExternalName service with tailnet-fqdn annotation parentEgressSvc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "external-service", Namespace: "default", Annotations: map[string]string{ AnnotationTailnetTargetFQDN: "external-service.example.ts.net", }, }, Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeExternalName, ExternalName: "unused", }, } mustCreate(t, fc, parentEgressSvc) proxyGroupEgressSvc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "ts-proxygroup-egress-abcd1", Namespace: "tailscale", Labels: map[string]string{ kubetypes.LabelManaged: "true", LabelParentName: "external-service", LabelParentNamespace: "default", LabelParentType: "svc", labelProxyGroup: "test-proxy-group", labelSvcType: typeEgress, }, }, Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeClusterIP, ClusterIP: "10.0.100.50", // This IP should be used in DNS, not Pod IPs Ports: []corev1.ServicePort{{ Port: 443, TargetPort: intstr.FromInt(10443), // Port mapping }}, }, } // Create EndpointSlice with Pod IPs (these should NOT be used in DNS records) proxyGroupEps := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Name: "ts-proxygroup-egress-abcd1-ipv4", Namespace: "tailscale", Labels: map[string]string{ discoveryv1.LabelServiceName: "ts-proxygroup-egress-abcd1", kubetypes.LabelManaged: "true", LabelParentName: "external-service", LabelParentNamespace: "default", LabelParentType: "svc", labelProxyGroup: "test-proxy-group", labelSvcType: typeEgress, }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{{ Addresses: []string{"10.1.0.100", "10.1.0.101", "10.1.0.102"}, // Pod IPs that should NOT be used Conditions: discoveryv1.EndpointConditions{ Ready: ptr.To(true), Serving: ptr.To(true), Terminating: ptr.To(false), }, }}, Ports: []discoveryv1.EndpointPort{{ Port: ptr.To(int32(10443)), }}, } mustCreate(t, fc, proxyGroupEgressSvc) mustCreate(t, fc, proxyGroupEps) expectReconciled(t, dnsRR, "tailscale", "ts-proxygroup-egress-abcd1") // Verify DNS record uses ClusterIP Service IP, not Pod IPs wantHosts["external-service.example.ts.net"] = []string{"10.0.100.50"} expectHostsRecords(t, fc, wantHosts) // 9. ProxyGroup egress DNS record updates when ClusterIP changes t.Log("test case 9: ProxyGroup egress ClusterIP change") mustUpdate(t, fc, "tailscale", "ts-proxygroup-egress-abcd1", func(svc *corev1.Service) { svc.Spec.ClusterIP = "10.0.100.51" }) expectReconciled(t, dnsRR, "tailscale", "ts-proxygroup-egress-abcd1") wantHosts["external-service.example.ts.net"] = []string{"10.0.100.51"} expectHostsRecords(t, fc, wantHosts) // 10. Test ProxyGroup service deletion and DNS cleanup t.Log("test case 10: ProxyGroup egress service deletion") mustDeleteAll(t, fc, proxyGroupEgressSvc) expectReconciled(t, dnsRR, "tailscale", "ts-proxygroup-egress-abcd1") delete(wantHosts, "external-service.example.ts.net") expectHostsRecords(t, fc, wantHosts) } func TestDNSRecordsReconcilerErrorCases(t *testing.T) { zl, err := zap.NewDevelopment() if err != nil { t.Fatal(err) } dnsRR := &dnsRecordsReconciler{ logger: zl.Sugar(), } testSvc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "test"}, Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP}, } // Test invalid IP format testSvc.Spec.ClusterIP = "invalid-ip" _, err = dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar()) if err == nil { t.Error("expected error for invalid IP format") } // Test valid IP testSvc.Spec.ClusterIP = "10.0.100.50" _, err = dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar()) if err != nil { t.Errorf("unexpected error for valid IP: %v", err) } } func headlessSvcForParent(o client.Object, typ string) *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: o.GetName(), Namespace: "tailscale", Labels: map[string]string{ kubetypes.LabelManaged: "true", LabelParentName: o.GetName(), LabelParentNamespace: o.GetNamespace(), LabelParentType: typ, }, }, Spec: corev1.ServiceSpec{ ClusterIP: "None", Type: corev1.ServiceTypeClusterIP, Selector: map[string]string{"foo": "bar"}, }, } } func endpointSliceForService(svc *corev1.Service, ip string, fam discoveryv1.AddressType) *discoveryv1.EndpointSlice { return &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%s-%s", svc.Name, string(fam)), Namespace: svc.Namespace, Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name}, }, AddressType: fam, Endpoints: []discoveryv1.Endpoint{{ Addresses: []string{ip}, Conditions: discoveryv1.EndpointConditions{ Ready: ptr.To(true), Serving: ptr.To(true), Terminating: ptr.To(false), }, }}, } } func expectHostsRecords(t *testing.T, cl client.Client, wantsHosts map[string][]string) { t.Helper() cm := new(corev1.ConfigMap) if err := cl.Get(context.Background(), types.NamespacedName{Name: "dnsrecords", Namespace: "tailscale"}, cm); err != nil { t.Fatalf("getting dnsconfig ConfigMap: %v", err) } if cm.Data == nil { t.Fatal("dnsconfig ConfigMap has no data") } dnsConfigString, ok := cm.Data[operatorutils.DNSRecordsCMKey] if !ok { t.Fatal("dnsconfig ConfigMap does not contain dnsconfig") } dnsConfig := &operatorutils.Records{} if err := json.Unmarshal([]byte(dnsConfigString), dnsConfig); err != nil { t.Fatalf("unmarshaling dnsconfig: %v", err) } if diff := cmp.Diff(dnsConfig.IP4, wantsHosts); diff != "" { t.Fatalf("unexpected dns config (-got +want):\n%s", diff) } }