summaryrefslogtreecommitdiffhomepage
path: root/cmd
diff options
context:
space:
mode:
authorRaj Singh <raj@tailscale.com>2025-09-25 11:20:23 -0400
committerRaj Singh <raj@tailscale.com>2025-09-25 11:20:23 -0400
commit9515b46feaa45b0d82f33b7cd15194b3db3272a8 (patch)
tree8c5978a0dd523458c8393f73cbf23a86b00a09da /cmd
parenta9f3fd1c67ca427aceee708f319a0a12df6a5de8 (diff)
downloadtailscale-ipv6-nameserver.tar.xz
tailscale-ipv6-nameserver.zip
k8s-operator: add IPv6 support for DNS recordsipv6-nameserver
This change adds full IPv6 support to the Kubernetes operator's DNS functionality, enabling dual-stack and IPv6-only cluster support. Fixes #16633 Signed-off-by: Raj Singh <raj@tailscale.com>
Diffstat (limited to 'cmd')
-rw-r--r--cmd/k8s-nameserver/main.go122
-rw-r--r--cmd/k8s-nameserver/main_test.go68
-rw-r--r--cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml1
-rw-r--r--cmd/k8s-operator/deploy/manifests/operator.yaml1
-rw-r--r--cmd/k8s-operator/dnsrecords.go138
-rw-r--r--cmd/k8s-operator/dnsrecords_test.go165
6 files changed, 403 insertions, 92 deletions
diff --git a/cmd/k8s-nameserver/main.go b/cmd/k8s-nameserver/main.go
index ca4b44935..84e65452d 100644
--- a/cmd/k8s-nameserver/main.go
+++ b/cmd/k8s-nameserver/main.go
@@ -31,6 +31,9 @@ const (
tsNetDomain = "ts.net"
// addr is the the address that the UDP and TCP listeners will listen on.
addr = ":1053"
+ // defaultTTL is the default TTL for DNS records in seconds.
+ // Set to 0 to disable caching. Can be increased when usage patterns are better understood.
+ defaultTTL = 0
// The following constants are specific to the nameserver configuration
// provided by a mounted Kubernetes Configmap. The Configmap mounted at
@@ -39,9 +42,9 @@ const (
kubeletMountedConfigLn = "..data"
)
-// nameserver is a simple nameserver that responds to DNS queries for A records
+// nameserver is a simple nameserver that responds to DNS queries for A and AAAA records
// for ts.net domain names over UDP or TCP. It serves DNS responses from
-// in-memory IPv4 host records. It is intended to be deployed on Kubernetes with
+// in-memory IPv4 and IPv6 host records. It is intended to be deployed on Kubernetes with
// a ConfigMap mounted at /config that should contain the host records. It
// dynamically reconfigures its in-memory mappings as the contents of the
// mounted ConfigMap changes.
@@ -56,10 +59,13 @@ type nameserver struct {
// in-memory records.
configWatcher <-chan string
- mu sync.Mutex // protects following
+ mu sync.RWMutex // protects following
// ip4 are the in-memory hostname -> IP4 mappings that the nameserver
// uses to respond to A record queries.
ip4 map[dnsname.FQDN][]net.IP
+ // ip6 are the in-memory hostname -> IP6 mappings that the nameserver
+ // uses to respond to AAAA record queries.
+ ip6 map[dnsname.FQDN][]net.IP
}
func main() {
@@ -98,16 +104,13 @@ func main() {
tcpSig <- s // stop the TCP listener
}
-// handleFunc is a DNS query handler that can respond to A record queries from
+// handleFunc is a DNS query handler that can respond to A and AAAA record queries from
// the nameserver's in-memory records.
-// - If an A record query is received and the
-// nameserver's in-memory records contain records for the queried domain name,
-// return a success response.
-// - If an A record query is received, but the
-// nameserver's in-memory records do not contain records for the queried domain name,
-// return NXDOMAIN.
-// - If an A record query is received, but the queried domain name is not valid, return Format Error.
-// - If a query is received for any other record type than A, return Not Implemented.
+// - For A queries: returns IPv4 addresses if available, NXDOMAIN if the name doesn't exist
+// - For AAAA queries: returns IPv6 addresses if available, NOERROR with no data if only
+// IPv4 exists (per RFC 4074), or NXDOMAIN if the name doesn't exist at all
+// - For invalid domain names: returns Format Error
+// - For other record types: returns Not Implemented
func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
h := func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
@@ -135,35 +138,19 @@ func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
m.RecursionAvailable = false
ips := n.lookupIP4(fqdn)
- if ips == nil || len(ips) == 0 {
+ if len(ips) == 0 {
// As we are the authoritative nameserver for MagicDNS
// names, if we do not have a record for this MagicDNS
// name, it does not exist.
m = m.SetRcode(r, dns.RcodeNameError)
return
}
- // TODO (irbekrm): TTL is currently set to 0, meaning
- // that cluster workloads will not cache the DNS
- // records. Revisit this in future when we understand
- // the usage patterns better- is it putting too much
- // load on kube DNS server or is this fine?
for _, ip := range ips {
- rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, A: ip}
+ rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: defaultTTL}, A: ip}
m.SetRcode(r, dns.RcodeSuccess)
m.Answer = append(m.Answer, rr)
}
case dns.TypeAAAA:
- // TODO (irbekrm): add IPv6 support.
- // The nameserver currently does not support IPv6
- // (records are not being created for IPv6 Pod addresses).
- // However, we can expect that some callers will
- // nevertheless send AAAA queries.
- // We have to return NOERROR if a query is received for
- // an AAAA record for a DNS name that we have an A
- // record for- else the caller might not follow with an
- // A record query.
- // https://github.com/tailscale/tailscale/issues/12321
- // https://datatracker.ietf.org/doc/html/rfc4074
q := r.Question[0].Name
fqdn, err := dnsname.ToFQDN(q)
if err != nil {
@@ -174,14 +161,27 @@ func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
// single source of truth for MagicDNS names by
// non-tailnet Kubernetes workloads.
m.Authoritative = true
- ips := n.lookupIP4(fqdn)
- if len(ips) == 0 {
+ m.RecursionAvailable = false
+
+ ips := n.lookupIP6(fqdn)
+ // Also check if we have IPv4 records to determine correct response code.
+ // If the name exists (has A records) but no AAAA records, we return NOERROR
+ // per RFC 4074. If the name doesn't exist at all, we return NXDOMAIN.
+ ip4s := n.lookupIP4(fqdn)
+
+ if len(ips) == 0 && len(ip4s) == 0 {
// As we are the authoritative nameserver for MagicDNS
- // names, if we do not have a record for this MagicDNS
+ // names, if we do not have any record for this MagicDNS
// name, it does not exist.
m = m.SetRcode(r, dns.RcodeNameError)
return
}
+
+ // Return IPv6 addresses if available
+ for _, ip := range ips {
+ rr := &dns.AAAA{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: defaultTTL}, AAAA: ip}
+ m.Answer = append(m.Answer, rr)
+ }
m.SetRcode(r, dns.RcodeSuccess)
default:
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String())
@@ -231,10 +231,11 @@ func (n *nameserver) resetRecords() error {
log.Printf("error reading nameserver's configuration: %v", err)
return err
}
- if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 {
+ if len(dnsCfgBytes) == 0 {
log.Print("nameserver's configuration is empty, any in-memory records will be unset")
n.mu.Lock()
n.ip4 = make(map[dnsname.FQDN][]net.IP)
+ n.ip6 = make(map[dnsname.FQDN][]net.IP)
n.mu.Unlock()
return nil
}
@@ -249,30 +250,63 @@ func (n *nameserver) resetRecords() error {
}
ip4 := make(map[dnsname.FQDN][]net.IP)
+ ip6 := make(map[dnsname.FQDN][]net.IP)
defer func() {
n.mu.Lock()
defer n.mu.Unlock()
n.ip4 = ip4
+ n.ip6 = ip6
}()
- if len(dnsCfg.IP4) == 0 {
+ if len(dnsCfg.IP4) == 0 && len(dnsCfg.IP6) == 0 {
log.Print("nameserver's configuration contains no records, any in-memory records will be unset")
return nil
}
+ // Process IPv4 records
for fqdn, ips := range dnsCfg.IP4 {
fqdn, err := dnsname.ToFQDN(fqdn)
if err != nil {
log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err)
continue // one invalid hostname should not break the whole nameserver
}
+ var validIPs []net.IP
for _, ipS := range ips {
ip := net.ParseIP(ipS).To4()
if ip == nil { // To4 returns nil if IP is not a IPv4 address
log.Printf("invalid nameserver's configuration: %v does not appear to be an IPv4 address; skipping this record", ipS)
continue // one invalid IP address should not break the whole nameserver
}
- ip4[fqdn] = []net.IP{ip}
+ validIPs = append(validIPs, ip)
+ }
+ if len(validIPs) > 0 {
+ ip4[fqdn] = validIPs
+ }
+ }
+
+ // Process IPv6 records
+ for fqdn, ips := range dnsCfg.IP6 {
+ fqdn, err := dnsname.ToFQDN(fqdn)
+ if err != nil {
+ log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err)
+ continue // one invalid hostname should not break the whole nameserver
+ }
+ var validIPs []net.IP
+ for _, ipS := range ips {
+ ip := net.ParseIP(ipS)
+ if ip == nil {
+ log.Printf("invalid nameserver's configuration: %v does not appear to be a valid IP address; skipping this record", ipS)
+ continue
+ }
+ // Check if it's a valid IPv6 address
+ if ip.To4() != nil {
+ log.Printf("invalid nameserver's configuration: %v appears to be IPv4 but was in IPv6 records; skipping this record", ipS)
+ continue
+ }
+ validIPs = append(validIPs, ip.To16())
+ }
+ if len(validIPs) > 0 {
+ ip6[fqdn] = validIPs
}
}
return nil
@@ -372,8 +406,20 @@ func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP {
if n.ip4 == nil {
return nil
}
- n.mu.Lock()
- defer n.mu.Unlock()
+ n.mu.RLock()
+ defer n.mu.RUnlock()
f := n.ip4[fqdn]
return f
}
+
+// lookupIP6 returns any IPv6 addresses for the given FQDN from nameserver's
+// in-memory records.
+func (n *nameserver) lookupIP6(fqdn dnsname.FQDN) []net.IP {
+ if n.ip6 == nil {
+ return nil
+ }
+ n.mu.RLock()
+ defer n.mu.RUnlock()
+ f := n.ip6[fqdn]
+ return f
+}
diff --git a/cmd/k8s-nameserver/main_test.go b/cmd/k8s-nameserver/main_test.go
index d9a33c4fa..bca010048 100644
--- a/cmd/k8s-nameserver/main_test.go
+++ b/cmd/k8s-nameserver/main_test.go
@@ -19,6 +19,7 @@ func TestNameserver(t *testing.T) {
tests := []struct {
name string
ip4 map[dnsname.FQDN][]net.IP
+ ip6 map[dnsname.FQDN][]net.IP
query *dns.Msg
wantResp *dns.Msg
}{
@@ -113,6 +114,49 @@ func TestNameserver(t *testing.T) {
}},
},
{
+ name: "AAAA record query with IPv6 record",
+ ip6: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {net.ParseIP("2001:db8::1")}},
+ query: &dns.Msg{
+ Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
+ MsgHdr: dns.MsgHdr{Id: 1, RecursionDesired: true},
+ },
+ wantResp: &dns.Msg{
+ Answer: []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{
+ Name: "foo.bar.com", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0},
+ AAAA: net.ParseIP("2001:db8::1")}},
+ Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
+ MsgHdr: dns.MsgHdr{
+ Id: 1,
+ Rcode: dns.RcodeSuccess,
+ RecursionAvailable: false,
+ RecursionDesired: true,
+ Response: true,
+ Opcode: dns.OpcodeQuery,
+ Authoritative: true,
+ }},
+ },
+ {
+ name: "Dual-stack: both A and AAAA records exist",
+ ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("dual.bar.com."): {{10, 0, 0, 1}}},
+ ip6: map[dnsname.FQDN][]net.IP{dnsname.FQDN("dual.bar.com."): {net.ParseIP("2001:db8::1")}},
+ query: &dns.Msg{
+ Question: []dns.Question{{Name: "dual.bar.com", Qtype: dns.TypeAAAA}},
+ MsgHdr: dns.MsgHdr{Id: 1},
+ },
+ wantResp: &dns.Msg{
+ Answer: []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{
+ Name: "dual.bar.com", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0},
+ AAAA: net.ParseIP("2001:db8::1")}},
+ Question: []dns.Question{{Name: "dual.bar.com", Qtype: dns.TypeAAAA}},
+ MsgHdr: dns.MsgHdr{
+ Id: 1,
+ Rcode: dns.RcodeSuccess,
+ Response: true,
+ Opcode: dns.OpcodeQuery,
+ Authoritative: true,
+ }},
+ },
+ {
name: "CNAME record query",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
query: &dns.Msg{
@@ -133,6 +177,7 @@ func TestNameserver(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ns := &nameserver{
ip4: tt.ip4,
+ ip6: tt.ip6,
}
handler := ns.handleFunc()
fakeRespW := &fakeResponseWriter{}
@@ -149,43 +194,63 @@ func TestResetRecords(t *testing.T) {
name string
config []byte
hasIp4 map[dnsname.FQDN][]net.IP
+ hasIp6 map[dnsname.FQDN][]net.IP
wantsIp4 map[dnsname.FQDN][]net.IP
+ wantsIp6 map[dnsname.FQDN][]net.IP
wantsErr bool
}{
{
name: "previously empty nameserver.ip4 gets set",
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
+ wantsIp6: make(map[dnsname.FQDN][]net.IP),
},
{
name: "nameserver.ip4 gets reset",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
+ wantsIp6: make(map[dnsname.FQDN][]net.IP),
},
{
name: "configuration with incompatible version",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
config: []byte(`{"version": "v1beta1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
wantsIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
+ wantsIp6: nil,
wantsErr: true,
},
{
name: "nameserver.ip4 gets reset to empty config when no configuration is provided",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
wantsIp4: make(map[dnsname.FQDN][]net.IP),
+ wantsIp6: make(map[dnsname.FQDN][]net.IP),
},
{
name: "nameserver.ip4 gets reset to empty config when the provided configuration is empty",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
config: []byte(`{"version": "v1alpha1", "ip4": {}}`),
wantsIp4: make(map[dnsname.FQDN][]net.IP),
+ wantsIp6: make(map[dnsname.FQDN][]net.IP),
+ },
+ {
+ name: "nameserver.ip6 gets set",
+ config: []byte(`{"version": "v1alpha1", "ip6": {"foo.bar.com": ["2001:db8::1"]}}`),
+ wantsIp4: make(map[dnsname.FQDN][]net.IP),
+ wantsIp6: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {net.ParseIP("2001:db8::1")}},
+ },
+ {
+ name: "dual-stack configuration",
+ config: []byte(`{"version": "v1alpha1", "ip4": {"dual.bar.com": ["10.0.0.1"]}, "ip6": {"dual.bar.com": ["2001:db8::1"]}}`),
+ wantsIp4: map[dnsname.FQDN][]net.IP{"dual.bar.com.": {{10, 0, 0, 1}}},
+ wantsIp6: map[dnsname.FQDN][]net.IP{"dual.bar.com.": {net.ParseIP("2001:db8::1")}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := &nameserver{
ip4: tt.hasIp4,
+ ip6: tt.hasIp6,
configReader: func() ([]byte, error) { return tt.config, nil },
}
if err := ns.resetRecords(); err == nil == tt.wantsErr {
@@ -194,6 +259,9 @@ func TestResetRecords(t *testing.T) {
if diff := cmp.Diff(ns.ip4, tt.wantsIp4); diff != "" {
t.Fatalf("unexpected nameserver.ip4 contents (-got +want): \n%s", diff)
}
+ if diff := cmp.Diff(ns.ip6, tt.wantsIp6); diff != "" {
+ t.Fatalf("unexpected nameserver.ip6 contents (-got +want): \n%s", diff)
+ }
})
}
}
diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
index bffad47f9..b047e11a7 100644
--- a/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
+++ b/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
@@ -52,7 +52,6 @@ spec:
using its MagicDNS name, you must also annotate the Ingress resource with
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
ensure that the proxy created for the Ingress listens on its Pod IP address.
- NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.
type: object
required:
- spec
diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml
index 175f2a7fb..3b47ef74b 100644
--- a/cmd/k8s-operator/deploy/manifests/operator.yaml
+++ b/cmd/k8s-operator/deploy/manifests/operator.yaml
@@ -347,7 +347,6 @@ spec:
using its MagicDNS name, you must also annotate the Ingress resource with
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
ensure that the proxy created for the Ingress listens on its Pod IP address.
- NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.
properties:
apiVersion:
description: |-
diff --git a/cmd/k8s-operator/dnsrecords.go b/cmd/k8s-operator/dnsrecords.go
index 54c1584c6..1a9395aa0 100644
--- a/cmd/k8s-operator/dnsrecords.go
+++ b/cmd/k8s-operator/dnsrecords.go
@@ -40,10 +40,10 @@ const (
// dnsRecordsReconciler knows how to update dnsrecords ConfigMap with DNS
// records.
// The records that it creates are:
-// - For tailscale Ingress, a mapping of the Ingress's MagicDNSName to the IP address of
-// the ingress proxy Pod.
+// - For tailscale Ingress, a mapping of the Ingress's MagicDNSName to the IP addresses
+// (both IPv4 and IPv6) of the ingress proxy Pod.
// - For egress proxies configured via tailscale.com/tailnet-fqdn annotation, a
-// mapping of the tailnet FQDN to the IP address of the egress proxy Pod.
+// mapping of the tailnet FQDN to the IP addresses (both IPv4 and IPv6) of the egress proxy Pod.
//
// Records will only be created if there is exactly one ready
// tailscale.com/v1alpha1.DNSConfig instance in the cluster (so that we know
@@ -122,16 +122,16 @@ func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.
// For Ingress, the record is a mapping between the MagicDNSName of the Ingress, retrieved from
// ingress.status.loadBalancer.ingress.hostname field and the proxy Pod IP addresses
// retrieved from the EndpointSlice associated with this Service, i.e
-// Records{IP4: <MagicDNS name of the Ingress>: <[IPs of the ingress proxy Pods]>}
+// Records{IP4: {<MagicDNS name>: <[IPv4 addresses]>}, IP6: {<MagicDNS name>: <[IPv6 addresses]>}}
//
// For egress, the record is a mapping between tailscale.com/tailnet-fqdn
// annotation and the proxy Pod IP addresses, retrieved from the EndpointSlice
// associated with this Service, i.e
-// Records{IP4: {<tailscale.com/tailnet-fqdn>: <[IPs of the egress proxy Pods]>}
+// Records{IP4: {<tailnet-fqdn>: <[IPv4 addresses]>}, IP6: {<tailnet-fqdn>: <[IPv6 addresses]>}}
//
// For ProxyGroup egress, the record is a mapping between tailscale.com/magic-dnsname
-// annotation and the ClusterIP Service IP (which provides portmapping), i.e
-// Records{IP4: {<tailscale.com/magic-dnsname>: <[ClusterIP Service IP]>}
+// annotation and the ClusterIP Service IPs (which provides portmapping), i.e
+// Records{IP4: {<magic-dnsname>: <[IPv4 ClusterIPs]>}, IP6: {<magic-dnsname>: <[IPv6 ClusterIPs]>}}
//
// If records need to be created for this proxy, maybeProvision will also:
// - update the Service with a tailscale.com/magic-dnsname annotation
@@ -178,17 +178,22 @@ func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, proxySvc
}
// Get the IP addresses for the DNS record
- ips, err := dnsRR.getTargetIPs(ctx, proxySvc, logger)
+ ip4s, ip6s, err := dnsRR.getTargetIPs(ctx, proxySvc, logger)
if err != nil {
return fmt.Errorf("error getting target IPs: %w", err)
}
- if len(ips) == 0 {
+ if len(ip4s) == 0 && len(ip6s) == 0 {
logger.Debugf("No target IP addresses available yet. We will reconcile again once they are available.")
return nil
}
updateFunc := func(rec *operatorutils.Records) {
- mak.Set(&rec.IP4, fqdn, ips)
+ if len(ip4s) > 0 {
+ mak.Set(&rec.IP4, fqdn, ip4s)
+ }
+ if len(ip6s) > 0 {
+ mak.Set(&rec.IP6, fqdn, ip6s)
+ }
}
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS records: %w", err)
@@ -212,42 +217,45 @@ func epIsReady(ep *discoveryv1.Endpoint) bool {
// has been removed from the Service. If the record is not found in the
// ConfigMap, the ConfigMap does not exist, or the Service does not have
// tailscale.com/magic-dnsname annotation, just remove the finalizer.
-func (h *dnsRecordsReconciler) maybeCleanup(ctx context.Context, proxySvc *corev1.Service, logger *zap.SugaredLogger) error {
+func (dnsRR *dnsRecordsReconciler) maybeCleanup(ctx context.Context, proxySvc *corev1.Service, logger *zap.SugaredLogger) error {
ix := slices.Index(proxySvc.Finalizers, dnsRecordsRecocilerFinalizer)
if ix == -1 {
logger.Debugf("no finalizer, nothing to do")
return nil
}
cm := &corev1.ConfigMap{}
- err := h.Client.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: h.tsNamespace}, cm)
+ err := dnsRR.Client.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, cm)
if apierrors.IsNotFound(err) {
logger.Debug("'dnsrecords' ConfigMap not found")
- return h.removeProxySvcFinalizer(ctx, proxySvc)
+ return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
}
if err != nil {
return fmt.Errorf("error retrieving 'dnsrecords' ConfigMap: %w", err)
}
if cm.Data == nil {
logger.Debug("'dnsrecords' ConfigMap contains no records")
- return h.removeProxySvcFinalizer(ctx, proxySvc)
+ return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
}
_, ok := cm.Data[operatorutils.DNSRecordsCMKey]
if !ok {
logger.Debug("'dnsrecords' ConfigMap contains no records")
- return h.removeProxySvcFinalizer(ctx, proxySvc)
+ return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
}
- fqdn, _ := proxySvc.GetAnnotations()[annotationTSMagicDNSName]
+ fqdn := proxySvc.GetAnnotations()[annotationTSMagicDNSName]
if fqdn == "" {
- return h.removeProxySvcFinalizer(ctx, proxySvc)
+ return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
}
logger.Infof("removing DNS record for MagicDNS name %s", fqdn)
updateFunc := func(rec *operatorutils.Records) {
delete(rec.IP4, fqdn)
+ if rec.IP6 != nil {
+ delete(rec.IP6, fqdn)
+ }
}
- if err = h.updateDNSConfig(ctx, updateFunc); err != nil {
+ if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS config: %w", err)
}
- return h.removeProxySvcFinalizer(ctx, proxySvc)
+ return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
}
func (dnsRR *dnsRecordsReconciler) removeProxySvcFinalizer(ctx context.Context, proxySvc *corev1.Service) error {
@@ -383,72 +391,106 @@ func (dnsRR *dnsRecordsReconciler) parentSvcTargetsFQDN(ctx context.Context, svc
return parentSvc.Annotations[AnnotationTailnetTargetFQDN] != ""
}
-// getTargetIPs returns the IP addresses that should be used for DNS records
+// getTargetIPs returns the IPv4 and IPv6 addresses that should be used for DNS records
// for the given proxy Service.
-func (dnsRR *dnsRecordsReconciler) getTargetIPs(ctx context.Context, proxySvc *corev1.Service, logger *zap.SugaredLogger) ([]string, error) {
+func (dnsRR *dnsRecordsReconciler) getTargetIPs(ctx context.Context, proxySvc *corev1.Service, logger *zap.SugaredLogger) ([]string, []string, error) {
if dnsRR.isProxyGroupEgressService(proxySvc) {
return dnsRR.getClusterIPServiceIPs(proxySvc, logger)
}
return dnsRR.getPodIPs(ctx, proxySvc, logger)
}
-// getClusterIPServiceIPs returns the ClusterIP of a ProxyGroup egress Service.
-func (dnsRR *dnsRecordsReconciler) getClusterIPServiceIPs(proxySvc *corev1.Service, logger *zap.SugaredLogger) ([]string, error) {
+// getClusterIPServiceIPs returns the ClusterIPs of a ProxyGroup egress Service.
+// It separates IPv4 and IPv6 addresses for dual-stack services.
+func (dnsRR *dnsRecordsReconciler) getClusterIPServiceIPs(proxySvc *corev1.Service, logger *zap.SugaredLogger) ([]string, []string, error) {
+ // Handle services with no ClusterIP
if proxySvc.Spec.ClusterIP == "" || proxySvc.Spec.ClusterIP == "None" {
logger.Debugf("ProxyGroup egress ClusterIP Service does not have a ClusterIP yet.")
- return nil, nil
+ return nil, nil, nil
+ }
+
+ var ip4s, ip6s []string
+
+ // Check all ClusterIPs for dual-stack support
+ clusterIPs := proxySvc.Spec.ClusterIPs
+ if len(clusterIPs) == 0 && proxySvc.Spec.ClusterIP != "" {
+ // Fallback to single ClusterIP for backward compatibility
+ clusterIPs = []string{proxySvc.Spec.ClusterIP}
}
- // Validate that ClusterIP is a valid IPv4 address
- if !net.IsIPv4String(proxySvc.Spec.ClusterIP) {
- logger.Debugf("ClusterIP %s is not a valid IPv4 address", proxySvc.Spec.ClusterIP)
- return nil, fmt.Errorf("ClusterIP %s is not a valid IPv4 address", proxySvc.Spec.ClusterIP)
+
+ for _, ip := range clusterIPs {
+ if net.IsIPv4String(ip) {
+ ip4s = append(ip4s, ip)
+ logger.Debugf("Using IPv4 ClusterIP %s for ProxyGroup egress DNS record", ip)
+ } else if net.IsIPv6String(ip) {
+ ip6s = append(ip6s, ip)
+ logger.Debugf("Using IPv6 ClusterIP %s for ProxyGroup egress DNS record", ip)
+ } else {
+ logger.Debugf("ClusterIP %s is not a valid IP address", ip)
+ }
}
- logger.Debugf("Using ClusterIP Service IP %s for ProxyGroup egress DNS record", proxySvc.Spec.ClusterIP)
- return []string{proxySvc.Spec.ClusterIP}, nil
+
+ if len(ip4s) == 0 && len(ip6s) == 0 {
+ return nil, nil, fmt.Errorf("no valid ClusterIPs found")
+ }
+
+ return ip4s, ip6s, nil
}
-// getPodIPs returns Pod IP addresses from EndpointSlices for non-ProxyGroup Services.
-func (dnsRR *dnsRecordsReconciler) getPodIPs(ctx context.Context, proxySvc *corev1.Service, logger *zap.SugaredLogger) ([]string, error) {
+// getPodIPs returns Pod IPv4 and IPv6 addresses from EndpointSlices for non-ProxyGroup Services.
+func (dnsRR *dnsRecordsReconciler) getPodIPs(ctx context.Context, proxySvc *corev1.Service, logger *zap.SugaredLogger) ([]string, []string, error) {
// Get the Pod IP addresses for the proxy from the EndpointSlices for
// the headless Service. The Service can have multiple EndpointSlices
// associated with it, for example in dual-stack clusters.
labels := map[string]string{discoveryv1.LabelServiceName: proxySvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
var eps = new(discoveryv1.EndpointSliceList)
if err := dnsRR.List(ctx, eps, client.InNamespace(dnsRR.tsNamespace), client.MatchingLabels(labels)); err != nil {
- return nil, fmt.Errorf("error listing EndpointSlices for the proxy's Service: %w", err)
+ return nil, nil, fmt.Errorf("error listing EndpointSlices for the proxy's Service: %w", err)
}
if len(eps.Items) == 0 {
logger.Debugf("proxy's Service EndpointSlice does not yet exist.")
- return nil, nil
+ return nil, nil, nil
}
// Each EndpointSlice for a Service can have a list of endpoints that each
// can have multiple addresses - these are the IP addresses of any Pods
- // selected by that Service. Pick all the IPv4 addresses.
+ // selected by that Service. Separate IPv4 and IPv6 addresses.
// It is also possible that multiple EndpointSlices have overlapping addresses.
// https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#duplicate-endpoints
- ips := make(set.Set[string], 0)
+ ip4s := make(set.Set[string], 0)
+ ip6s := make(set.Set[string], 0)
for _, slice := range eps.Items {
- if slice.AddressType != discoveryv1.AddressTypeIPv4 {
- logger.Infof("EndpointSlice is for AddressType %s, currently only IPv4 address type is supported", slice.AddressType)
- continue
- }
for _, ep := range slice.Endpoints {
if !epIsReady(&ep) {
logger.Debugf("Endpoint with addresses %v appears not ready to receive traffic %v", ep.Addresses, ep.Conditions.String())
continue
}
for _, ip := range ep.Addresses {
- if !net.IsIPv4String(ip) {
- logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
- } else {
- ips.Add(ip)
+ switch slice.AddressType {
+ case discoveryv1.AddressTypeIPv4:
+ if net.IsIPv4String(ip) {
+ ip4s.Add(ip)
+ } else {
+ logger.Debugf("EndpointSlice with AddressType IPv4 contains non-IPv4 address %q, ignoring", ip)
+ }
+ case discoveryv1.AddressTypeIPv6:
+ if net.IsIPv6String(ip) {
+ // Strip zone ID if present (e.g., fe80::1%eth0 -> fe80::1)
+ if idx := strings.IndexByte(ip, '%'); idx != -1 {
+ ip = ip[:idx]
+ }
+ ip6s.Add(ip)
+ } else {
+ logger.Debugf("EndpointSlice with AddressType IPv6 contains non-IPv6 address %q, ignoring", ip)
+ }
+ default:
+ logger.Debugf("EndpointSlice is for unsupported AddressType %s, skipping", slice.AddressType)
}
}
}
}
- if ips.Len() == 0 {
- logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses.")
- return nil, nil
+ if ip4s.Len() == 0 && ip6s.Len() == 0 {
+ logger.Debugf("EndpointSlice for the Service contains no IP addresses.")
+ return nil, nil, nil
}
- return ips.Slice(), nil
+ return ip4s.Slice(), ip6s.Slice(), nil
}
diff --git a/cmd/k8s-operator/dnsrecords_test.go b/cmd/k8s-operator/dnsrecords_test.go
index 51dfb9049..13898078f 100644
--- a/cmd/k8s-operator/dnsrecords_test.go
+++ b/cmd/k8s-operator/dnsrecords_test.go
@@ -99,8 +99,9 @@ func TestDNSRecordsReconciler(t *testing.T) {
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)
+ wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
+ wantHostsIPv6 := map[string][]string{"foo.bar.ts.net": {"2600:1900:4011:161:0:d:0:d"}}
+ expectHostsRecordsWithIPv6(t, fc, wantHosts, wantHostsIPv6)
// 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
// value changes
@@ -271,17 +272,148 @@ func TestDNSRecordsReconcilerErrorCases(t *testing.T) {
// Test invalid IP format
testSvc.Spec.ClusterIP = "invalid-ip"
- _, err = dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar())
+ _, _, 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())
+ ip4s, ip6s, err := dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar())
if err != nil {
t.Errorf("unexpected error for valid IP: %v", err)
}
+ if len(ip4s) != 1 || ip4s[0] != "10.0.100.50" {
+ t.Errorf("expected IPv4 address 10.0.100.50, got %v", ip4s)
+ }
+ if len(ip6s) != 0 {
+ t.Errorf("expected no IPv6 addresses, got %v", ip6s)
+ }
+}
+
+func TestDNSRecordsReconcilerDualStack(t *testing.T) {
+ // Test dual-stack (IPv4 and IPv6) scenarios
+ zl, err := zap.NewDevelopment()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Preconfigure cluster with DNSConfig
+ dnsCfg := &tsapi.DNSConfig{
+ ObjectMeta: metav1.ObjectMeta{Name: "test"},
+ TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"},
+ Spec: tsapi.DNSConfigSpec{Nameserver: &tsapi.Nameserver{}},
+ }
+ dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{
+ Type: string(tsapi.NameserverReady),
+ Status: metav1.ConditionTrue,
+ })
+
+ // Create dual-stack ingress
+ ing := &networkingv1.Ingress{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "dual-stack-ingress",
+ Namespace: "test",
+ },
+ Spec: networkingv1.IngressSpec{
+ IngressClassName: ptr.To("tailscale"),
+ },
+ Status: networkingv1.IngressStatus{
+ LoadBalancer: networkingv1.IngressLoadBalancerStatus{
+ Ingress: []networkingv1.IngressLoadBalancerIngress{
+ {Hostname: "dual-stack.example.ts.net"},
+ },
+ },
+ },
+ }
+
+ headlessSvc := headlessSvcForParent(ing, "ingress")
+ headlessSvc.Name = "ts-dual-stack-ingress"
+ headlessSvc.SetLabels(map[string]string{
+ kubetypes.LabelManaged: "true",
+ LabelParentName: "dual-stack-ingress",
+ LabelParentNamespace: "test",
+ LabelParentType: "ingress",
+ })
+
+ // Create both IPv4 and IPv6 endpoints
+ epv4 := endpointSliceForService(headlessSvc, "10.1.2.3", discoveryv1.AddressTypeIPv4)
+ epv6 := endpointSliceForService(headlessSvc, "2001:db8::1", discoveryv1.AddressTypeIPv6)
+
+ dnsRRDualStack := &dnsRecordsReconciler{
+ tsNamespace: "tailscale",
+ logger: zl.Sugar(),
+ }
+
+ // Create the dnsrecords ConfigMap
+ cm := &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: operatorutils.DNSRecordsCMName,
+ Namespace: "tailscale",
+ },
+ }
+
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithObjects(dnsCfg, ing, headlessSvc, epv4, epv6, cm).
+ WithStatusSubresource(dnsCfg).
+ Build()
+
+ dnsRRDualStack.Client = fc
+
+ // Test dual-stack service records
+ expectReconciled(t, dnsRRDualStack, "tailscale", "ts-dual-stack-ingress")
+
+ wantIPv4 := map[string][]string{"dual-stack.example.ts.net": {"10.1.2.3"}}
+ wantIPv6 := map[string][]string{"dual-stack.example.ts.net": {"2001:db8::1"}}
+ expectHostsRecordsWithIPv6(t, fc, wantIPv4, wantIPv6)
+
+ // Test ProxyGroup with dual-stack ClusterIPs
+ // First create parent service
+ parentEgressSvc := &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "pg-service",
+ Namespace: "tailscale",
+ Annotations: map[string]string{
+ AnnotationTailnetTargetFQDN: "pg-service.example.ts.net",
+ },
+ },
+ Spec: corev1.ServiceSpec{
+ Type: corev1.ServiceTypeExternalName,
+ ExternalName: "unused",
+ },
+ }
+
+ proxyGroupSvc := &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "ts-proxygroup-dualstack",
+ Namespace: "tailscale",
+ Labels: map[string]string{
+ kubetypes.LabelManaged: "true",
+ labelProxyGroup: "test-pg",
+ labelSvcType: typeEgress,
+ LabelParentName: "pg-service",
+ LabelParentNamespace: "tailscale",
+ LabelParentType: "svc",
+ },
+ Annotations: map[string]string{
+ annotationTSMagicDNSName: "pg-service.example.ts.net",
+ },
+ },
+ Spec: corev1.ServiceSpec{
+ Type: corev1.ServiceTypeClusterIP,
+ ClusterIP: "10.96.0.100",
+ ClusterIPs: []string{"10.96.0.100", "2001:db8::100"},
+ },
+ }
+
+ mustCreate(t, fc, parentEgressSvc)
+ mustCreate(t, fc, proxyGroupSvc)
+ expectReconciled(t, dnsRRDualStack, "tailscale", "ts-proxygroup-dualstack")
+
+ wantIPv4["pg-service.example.ts.net"] = []string{"10.96.0.100"}
+ wantIPv6["pg-service.example.ts.net"] = []string{"2001:db8::100"}
+ expectHostsRecordsWithIPv6(t, fc, wantIPv4, wantIPv6)
}
func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
@@ -344,3 +476,28 @@ func expectHostsRecords(t *testing.T, cl client.Client, wantsHosts map[string][]
t.Fatalf("unexpected dns config (-got +want):\n%s", diff)
}
}
+
+func expectHostsRecordsWithIPv6(t *testing.T, cl client.Client, wantsHostsIPv4, wantsHostsIPv6 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, wantsHostsIPv4); diff != "" {
+ t.Fatalf("unexpected IPv4 dns config (-got +want):\n%s", diff)
+ }
+ if diff := cmp.Diff(dnsConfig.IP6, wantsHostsIPv6); diff != "" {
+ t.Fatalf("unexpected IPv6 dns config (-got +want):\n%s", diff)
+ }
+}