k8s-operator: add IPv6 support for DNS records

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 <rajsinghcpre@gmail.com>
This commit is contained in:
Raj Singh 2025-07-31 11:27:11 -05:00
parent a9f3fd1c67
commit c2fdac5a35
9 changed files with 405 additions and 91 deletions

View File

@ -31,6 +31,9 @@
tsNetDomain = "ts.net" tsNetDomain = "ts.net"
// addr is the the address that the UDP and TCP listeners will listen on. // addr is the the address that the UDP and TCP listeners will listen on.
addr = ":1053" 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 // The following constants are specific to the nameserver configuration
// provided by a mounted Kubernetes Configmap. The Configmap mounted at // provided by a mounted Kubernetes Configmap. The Configmap mounted at
@ -39,9 +42,9 @@
kubeletMountedConfigLn = "..data" 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 // 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 // a ConfigMap mounted at /config that should contain the host records. It
// dynamically reconfigures its in-memory mappings as the contents of the // dynamically reconfigures its in-memory mappings as the contents of the
// mounted ConfigMap changes. // mounted ConfigMap changes.
@ -60,6 +63,9 @@ type nameserver struct {
// ip4 are the in-memory hostname -> IP4 mappings that the nameserver // ip4 are the in-memory hostname -> IP4 mappings that the nameserver
// uses to respond to A record queries. // uses to respond to A record queries.
ip4 map[dnsname.FQDN][]net.IP 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() { func main() {
@ -98,16 +104,13 @@ func main() {
tcpSig <- s // stop the TCP listener 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. // the nameserver's in-memory records.
// - If an A record query is received and the // - For A queries: returns IPv4 addresses if available, NXDOMAIN if the name doesn't exist
// nameserver's in-memory records contain records for the queried domain name, // - For AAAA queries: returns IPv6 addresses if available, NOERROR with no data if only
// return a success response. // IPv4 exists (per RFC 4074), or NXDOMAIN if the name doesn't exist at all
// - If an A record query is received, but the // - For invalid domain names: returns Format Error
// nameserver's in-memory records do not contain records for the queried domain name, // - For other record types: returns Not Implemented
// 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.
func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) { func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
h := func(w dns.ResponseWriter, r *dns.Msg) { h := func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg) m := new(dns.Msg)
@ -135,35 +138,19 @@ func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
m.RecursionAvailable = false m.RecursionAvailable = false
ips := n.lookupIP4(fqdn) ips := n.lookupIP4(fqdn)
if ips == nil || len(ips) == 0 { if len(ips) == 0 {
// As we are the authoritative nameserver for MagicDNS // 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 a record for this MagicDNS
// name, it does not exist. // name, it does not exist.
m = m.SetRcode(r, dns.RcodeNameError) m = m.SetRcode(r, dns.RcodeNameError)
return 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 { 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.SetRcode(r, dns.RcodeSuccess)
m.Answer = append(m.Answer, rr) m.Answer = append(m.Answer, rr)
} }
case dns.TypeAAAA: 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 q := r.Question[0].Name
fqdn, err := dnsname.ToFQDN(q) fqdn, err := dnsname.ToFQDN(q)
if err != nil { 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 // single source of truth for MagicDNS names by
// non-tailnet Kubernetes workloads. // non-tailnet Kubernetes workloads.
m.Authoritative = true m.Authoritative = true
ips := n.lookupIP4(fqdn) m.RecursionAvailable = false
if len(ips) == 0 {
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 // 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. // name, it does not exist.
m = m.SetRcode(r, dns.RcodeNameError) m = m.SetRcode(r, dns.RcodeNameError)
return 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) m.SetRcode(r, dns.RcodeSuccess)
default: default:
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String()) 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) log.Printf("error reading nameserver's configuration: %v", err)
return 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") log.Print("nameserver's configuration is empty, any in-memory records will be unset")
n.mu.Lock() n.mu.Lock()
n.ip4 = make(map[dnsname.FQDN][]net.IP) n.ip4 = make(map[dnsname.FQDN][]net.IP)
n.ip6 = make(map[dnsname.FQDN][]net.IP)
n.mu.Unlock() n.mu.Unlock()
return nil return nil
} }
@ -249,30 +250,63 @@ func (n *nameserver) resetRecords() error {
} }
ip4 := make(map[dnsname.FQDN][]net.IP) ip4 := make(map[dnsname.FQDN][]net.IP)
ip6 := make(map[dnsname.FQDN][]net.IP)
defer func() { defer func() {
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
n.ip4 = ip4 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") log.Print("nameserver's configuration contains no records, any in-memory records will be unset")
return nil return nil
} }
// Process IPv4 records
for fqdn, ips := range dnsCfg.IP4 { for fqdn, ips := range dnsCfg.IP4 {
fqdn, err := dnsname.ToFQDN(fqdn) fqdn, err := dnsname.ToFQDN(fqdn)
if err != nil { if err != nil {
log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err) 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 continue // one invalid hostname should not break the whole nameserver
} }
var validIPs []net.IP
for _, ipS := range ips { for _, ipS := range ips {
ip := net.ParseIP(ipS).To4() ip := net.ParseIP(ipS).To4()
if ip == nil { // To4 returns nil if IP is not a IPv4 address 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) 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 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 return nil
@ -377,3 +411,15 @@ func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP {
f := n.ip4[fqdn] f := n.ip4[fqdn]
return f 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.Lock()
defer n.mu.Unlock()
f := n.ip6[fqdn]
return f
}

View File

@ -19,6 +19,7 @@ func TestNameserver(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
ip4 map[dnsname.FQDN][]net.IP ip4 map[dnsname.FQDN][]net.IP
ip6 map[dnsname.FQDN][]net.IP
query *dns.Msg query *dns.Msg
wantResp *dns.Msg wantResp *dns.Msg
}{ }{
@ -112,6 +113,49 @@ func TestNameserver(t *testing.T) {
Authoritative: true, Authoritative: true,
}}, }},
}, },
{
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", name: "CNAME record query",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
@ -133,6 +177,7 @@ func TestNameserver(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ns := &nameserver{ ns := &nameserver{
ip4: tt.ip4, ip4: tt.ip4,
ip6: tt.ip6,
} }
handler := ns.handleFunc() handler := ns.handleFunc()
fakeRespW := &fakeResponseWriter{} fakeRespW := &fakeResponseWriter{}
@ -149,43 +194,63 @@ func TestResetRecords(t *testing.T) {
name string name string
config []byte config []byte
hasIp4 map[dnsname.FQDN][]net.IP hasIp4 map[dnsname.FQDN][]net.IP
hasIp6 map[dnsname.FQDN][]net.IP
wantsIp4 map[dnsname.FQDN][]net.IP wantsIp4 map[dnsname.FQDN][]net.IP
wantsIp6 map[dnsname.FQDN][]net.IP
wantsErr bool wantsErr bool
}{ }{
{ {
name: "previously empty nameserver.ip4 gets set", name: "previously empty nameserver.ip4 gets set",
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`), 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}}}, 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", name: "nameserver.ip4 gets reset",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, 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"]}}`), 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}}}, 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", name: "configuration with incompatible version",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, 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"]}}`), 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}}}, wantsIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
wantsIp6: nil,
wantsErr: true, wantsErr: true,
}, },
{ {
name: "nameserver.ip4 gets reset to empty config when no configuration is provided", 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}}}, hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
wantsIp4: make(map[dnsname.FQDN][]net.IP), 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", 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}}}, hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
config: []byte(`{"version": "v1alpha1", "ip4": {}}`), config: []byte(`{"version": "v1alpha1", "ip4": {}}`),
wantsIp4: make(map[dnsname.FQDN][]net.IP), 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ns := &nameserver{ ns := &nameserver{
ip4: tt.hasIp4, ip4: tt.hasIp4,
ip6: tt.hasIp6,
configReader: func() ([]byte, error) { return tt.config, nil }, configReader: func() ([]byte, error) { return tt.config, nil },
} }
if err := ns.resetRecords(); err == nil == tt.wantsErr { 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 != "" { if diff := cmp.Diff(ns.ip4, tt.wantsIp4); diff != "" {
t.Fatalf("unexpected nameserver.ip4 contents (-got +want): \n%s", 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)
}
}) })
} }
} }

View File

@ -52,7 +52,6 @@ spec:
using its MagicDNS name, you must also annotate the Ingress resource with using its MagicDNS name, you must also annotate the Ingress resource with
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
ensure that the proxy created for the Ingress listens on its Pod IP address. 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 type: object
required: required:
- spec - spec

View File

@ -347,7 +347,6 @@ spec:
using its MagicDNS name, you must also annotate the Ingress resource with using its MagicDNS name, you must also annotate the Ingress resource with
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
ensure that the proxy created for the Ingress listens on its Pod IP address. 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: properties:
apiVersion: apiVersion:
description: |- description: |-

View File

@ -40,10 +40,10 @@
// dnsRecordsReconciler knows how to update dnsrecords ConfigMap with DNS // dnsRecordsReconciler knows how to update dnsrecords ConfigMap with DNS
// records. // records.
// The records that it creates are: // The records that it creates are:
// - For tailscale Ingress, a mapping of the Ingress's MagicDNSName to the IP address of // - For tailscale Ingress, a mapping of the Ingress's MagicDNSName to the IP addresses
// the ingress proxy Pod. // (both IPv4 and IPv6) of the ingress proxy Pod.
// - For egress proxies configured via tailscale.com/tailnet-fqdn annotation, a // - 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 // Records will only be created if there is exactly one ready
// tailscale.com/v1alpha1.DNSConfig instance in the cluster (so that we know // 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 // 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 // ingress.status.loadBalancer.ingress.hostname field and the proxy Pod IP addresses
// retrieved from the EndpointSlice associated with this Service, i.e // 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 // For egress, the record is a mapping between tailscale.com/tailnet-fqdn
// annotation and the proxy Pod IP addresses, retrieved from the EndpointSlice // annotation and the proxy Pod IP addresses, retrieved from the EndpointSlice
// associated with this Service, i.e // 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 // For ProxyGroup egress, the record is a mapping between tailscale.com/magic-dnsname
// annotation and the ClusterIP Service IP (which provides portmapping), i.e // annotation and the ClusterIP Service IPs (which provides portmapping), i.e
// Records{IP4: {<tailscale.com/magic-dnsname>: <[ClusterIP Service IP]>} // Records{IP4: {<magic-dnsname>: <[IPv4 ClusterIPs]>}, IP6: {<magic-dnsname>: <[IPv6 ClusterIPs]>}}
// //
// If records need to be created for this proxy, maybeProvision will also: // If records need to be created for this proxy, maybeProvision will also:
// - update the Service with a tailscale.com/magic-dnsname annotation // - 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 // 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 { if err != nil {
return fmt.Errorf("error getting target IPs: %w", err) 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.") logger.Debugf("No target IP addresses available yet. We will reconcile again once they are available.")
return nil return nil
} }
updateFunc := func(rec *operatorutils.Records) { 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 { if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS records: %w", err) 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 // 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 // ConfigMap, the ConfigMap does not exist, or the Service does not have
// tailscale.com/magic-dnsname annotation, just remove the finalizer. // 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) ix := slices.Index(proxySvc.Finalizers, dnsRecordsRecocilerFinalizer)
if ix == -1 { if ix == -1 {
logger.Debugf("no finalizer, nothing to do") logger.Debugf("no finalizer, nothing to do")
return nil return nil
} }
cm := &corev1.ConfigMap{} 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) { if apierrors.IsNotFound(err) {
logger.Debug("'dnsrecords' ConfigMap not found") logger.Debug("'dnsrecords' ConfigMap not found")
return h.removeProxySvcFinalizer(ctx, proxySvc) return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
} }
if err != nil { if err != nil {
return fmt.Errorf("error retrieving 'dnsrecords' ConfigMap: %w", err) return fmt.Errorf("error retrieving 'dnsrecords' ConfigMap: %w", err)
} }
if cm.Data == nil { if cm.Data == nil {
logger.Debug("'dnsrecords' ConfigMap contains no records") logger.Debug("'dnsrecords' ConfigMap contains no records")
return h.removeProxySvcFinalizer(ctx, proxySvc) return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
} }
_, ok := cm.Data[operatorutils.DNSRecordsCMKey] _, ok := cm.Data[operatorutils.DNSRecordsCMKey]
if !ok { if !ok {
logger.Debug("'dnsrecords' ConfigMap contains no records") 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 == "" { if fqdn == "" {
return h.removeProxySvcFinalizer(ctx, proxySvc) return dnsRR.removeProxySvcFinalizer(ctx, proxySvc)
} }
logger.Infof("removing DNS record for MagicDNS name %s", fqdn) logger.Infof("removing DNS record for MagicDNS name %s", fqdn)
updateFunc := func(rec *operatorutils.Records) { updateFunc := func(rec *operatorutils.Records) {
delete(rec.IP4, fqdn) 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 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 { 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] != "" 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. // 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) { if dnsRR.isProxyGroupEgressService(proxySvc) {
return dnsRR.getClusterIPServiceIPs(proxySvc, logger) return dnsRR.getClusterIPServiceIPs(proxySvc, logger)
} }
return dnsRR.getPodIPs(ctx, proxySvc, logger) return dnsRR.getPodIPs(ctx, proxySvc, logger)
} }
// getClusterIPServiceIPs returns the ClusterIP of a ProxyGroup egress Service. // getClusterIPServiceIPs returns the ClusterIPs of a ProxyGroup egress Service.
func (dnsRR *dnsRecordsReconciler) getClusterIPServiceIPs(proxySvc *corev1.Service, logger *zap.SugaredLogger) ([]string, error) { // 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" { if proxySvc.Spec.ClusterIP == "" || proxySvc.Spec.ClusterIP == "None" {
logger.Debugf("ProxyGroup egress ClusterIP Service does not have a ClusterIP yet.") logger.Debugf("ProxyGroup egress ClusterIP Service does not have a ClusterIP yet.")
return nil, nil return nil, nil, nil
} }
// Validate that ClusterIP is a valid IPv4 address
if !net.IsIPv4String(proxySvc.Spec.ClusterIP) { var ip4s, ip6s []string
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) // 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}
} }
logger.Debugf("Using ClusterIP Service IP %s for ProxyGroup egress DNS record", proxySvc.Spec.ClusterIP)
return []string{proxySvc.Spec.ClusterIP}, nil 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)
}
}
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. // 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, error) { 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 // Get the Pod IP addresses for the proxy from the EndpointSlices for
// the headless Service. The Service can have multiple EndpointSlices // the headless Service. The Service can have multiple EndpointSlices
// associated with it, for example in dual-stack clusters. // 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 labels := map[string]string{discoveryv1.LabelServiceName: proxySvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
var eps = new(discoveryv1.EndpointSliceList) var eps = new(discoveryv1.EndpointSliceList)
if err := dnsRR.List(ctx, eps, client.InNamespace(dnsRR.tsNamespace), client.MatchingLabels(labels)); err != nil { 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 { if len(eps.Items) == 0 {
logger.Debugf("proxy's Service EndpointSlice does not yet exist.") 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 // 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 // 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. // It is also possible that multiple EndpointSlices have overlapping addresses.
// https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#duplicate-endpoints // 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 { 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 { for _, ep := range slice.Endpoints {
if !epIsReady(&ep) { if !epIsReady(&ep) {
logger.Debugf("Endpoint with addresses %v appears not ready to receive traffic %v", ep.Addresses, ep.Conditions.String()) logger.Debugf("Endpoint with addresses %v appears not ready to receive traffic %v", ep.Addresses, ep.Conditions.String())
continue continue
} }
for _, ip := range ep.Addresses { for _, ip := range ep.Addresses {
if !net.IsIPv4String(ip) { switch slice.AddressType {
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip) case discoveryv1.AddressTypeIPv4:
} else { if net.IsIPv4String(ip) {
ips.Add(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 { if ip4s.Len() == 0 && ip6s.Len() == 0 {
logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses.") logger.Debugf("EndpointSlice for the Service contains no IP addresses.")
return nil, nil return nil, nil, nil
} }
return ips.Slice(), nil return ip4s.Slice(), ip6s.Slice(), nil
} }

View File

@ -99,8 +99,9 @@ func TestDNSRecordsReconciler(t *testing.T) {
mustCreate(t, fc, epv6) mustCreate(t, fc, epv6)
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service 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 // 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 wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
expectHostsRecords(t, fc, wantHosts) 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 // 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
// value changes // value changes
@ -271,17 +272,148 @@ func TestDNSRecordsReconcilerErrorCases(t *testing.T) {
// Test invalid IP format // Test invalid IP format
testSvc.Spec.ClusterIP = "invalid-ip" testSvc.Spec.ClusterIP = "invalid-ip"
_, err = dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar()) _, _, err = dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar())
if err == nil { if err == nil {
t.Error("expected error for invalid IP format") t.Error("expected error for invalid IP format")
} }
// Test valid IP // Test valid IP
testSvc.Spec.ClusterIP = "10.0.100.50" testSvc.Spec.ClusterIP = "10.0.100.50"
_, err = dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar()) ip4s, ip6s, err := dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar())
if err != nil { if err != nil {
t.Errorf("unexpected error for valid IP: %v", err) 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 { 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) 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)
}
}

View File

@ -193,7 +193,6 @@ NB: if you want cluster workloads to be able to refer to Tailscale Ingress
using its MagicDNS name, you must also annotate the Ingress resource with using its MagicDNS name, you must also annotate the Ingress resource with
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
ensure that the proxy created for the Ingress listens on its Pod IP address. 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.

View File

@ -45,7 +45,6 @@
// using its MagicDNS name, you must also annotate the Ingress resource with // using its MagicDNS name, you must also annotate the Ingress resource with
// tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to // tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
// ensure that the proxy created for the Ingress listens on its Pod IP address. // 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 DNSConfig struct { type DNSConfig struct {
metav1.TypeMeta `json:",inline"` metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"` metav1.ObjectMeta `json:"metadata,omitempty"`

View File

@ -27,6 +27,11 @@ type Records struct {
Version string `json:"version"` Version string `json:"version"`
// IP4 contains a mapping of DNS names to IPv4 address(es). // IP4 contains a mapping of DNS names to IPv4 address(es).
IP4 map[string][]string `json:"ip4"` IP4 map[string][]string `json:"ip4"`
// IP6 contains a mapping of DNS names to IPv6 address(es).
// This field is optional and will be omitted from JSON if empty.
// It enables dual-stack DNS support in Kubernetes clusters.
// +optional
IP6 map[string][]string `json:"ip6,omitempty"`
} }
// TailscaledConfigFileName returns a tailscaled config file name in // TailscaledConfigFileName returns a tailscaled config file name in