diff --git a/api/v1alpha1/dnsrecordset_types.go b/api/v1alpha1/dnsrecordset_types.go index 98eabd9..9bcf55c 100644 --- a/api/v1alpha1/dnsrecordset_types.go +++ b/api/v1alpha1/dnsrecordset_types.go @@ -7,7 +7,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=A;AAAA;CNAME;TXT;MX;SRV;CAA;NS;SOA;PTR;TLSA;HTTPS;SVCB +// +kubebuilder:validation:Enum=A;AAAA;CNAME;TXT;MX;SRV;CAA;NS;SOA;PTR;TLSA;HTTPS;SVCB;ALIAS;SSHFP;NAPTR type RRType string const ( @@ -24,6 +24,9 @@ const ( RRTypeTLSA RRType = "TLSA" RRTypeHTTPS RRType = "HTTPS" RRTypeSVCB RRType = "SVCB" + RRTypeALIAS RRType = "ALIAS" + RRTypeSSHFP RRType = "SSHFP" + RRTypeNAPTR RRType = "NAPTR" ) // DNSRecordSetSpec defines the desired state of DNSRecordSet @@ -81,12 +84,60 @@ type RecordEntry struct { // +optional PTR *PTRRecordSpec `json:"ptr,omitempty"` + + // +optional + ALIAS *ALIASRecordSpec `json:"alias,omitempty"` + + // +optional + SSHFP *SSHFPRecordSpec `json:"sshfp,omitempty"` + + // +optional + NAPTR *NAPTRRecordSpec `json:"naptr,omitempty"` +} + +// ALIASRecordSpec is a PowerDNS-specific RR type that behaves like a CNAME at apex. +// Content is a hostname (FQDN or relative) and will be normalized to an absolute name in providers. +type ALIASRecordSpec struct { + Content string `json:"content"` } type PTRRecordSpec struct { Content string `json:"content"` } +type SSHFPRecordSpec struct { + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=255 + Algorithm uint8 `json:"algorithm"` + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=255 + Type uint8 `json:"type"` + // Fingerprint is the hex fingerprint data. + // +kubebuilder:validation:MinLength=1 + Fingerprint string `json:"fingerprint"` +} + +type NAPTRRecordSpec struct { + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=65535 + Order uint16 `json:"order"` + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=65535 + Preference uint16 `json:"preference"` + // Flags is typically "S", "A", "U", or "". + // +optional + Flags string `json:"flags,omitempty"` + // Services is the service field (often something like "E2U+sip"). + // +optional + Services string `json:"services,omitempty"` + // Regexp is the substitution expression (often empty). + // +optional + Regexp string `json:"regexp,omitempty"` + // Replacement is the next domain name (FQDN/relative) or ".". + // +kubebuilder:validation:MinLength=1 + Replacement string `json:"replacement"` +} + type TXTRecordSpec struct { Content string `json:"content"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8e034e5..dc4168b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,21 @@ func (in *AAAARecordSpec) DeepCopy() *AAAARecordSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ALIASRecordSpec) DeepCopyInto(out *ALIASRecordSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ALIASRecordSpec. +func (in *ALIASRecordSpec) DeepCopy() *ALIASRecordSpec { + if in == nil { + return nil + } + out := new(ALIASRecordSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ARecordSpec) DeepCopyInto(out *ARecordSpec) { *out = *in @@ -589,6 +604,21 @@ func (in *MXRecordSpec) DeepCopy() *MXRecordSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NAPTRRecordSpec) DeepCopyInto(out *NAPTRRecordSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NAPTRRecordSpec. +func (in *NAPTRRecordSpec) DeepCopy() *NAPTRRecordSpec { + if in == nil { + return nil + } + out := new(NAPTRRecordSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NSRecordSpec) DeepCopyInto(out *NSRecordSpec) { *out = *in @@ -712,6 +742,21 @@ func (in *RecordEntry) DeepCopyInto(out *RecordEntry) { *out = new(PTRRecordSpec) **out = **in } + if in.ALIAS != nil { + in, out := &in.ALIAS, &out.ALIAS + *out = new(ALIASRecordSpec) + **out = **in + } + if in.SSHFP != nil { + in, out := &in.SSHFP, &out.SSHFP + *out = new(SSHFPRecordSpec) + **out = **in + } + if in.NAPTR != nil { + in, out := &in.NAPTR, &out.NAPTR + *out = new(NAPTRRecordSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordEntry. @@ -754,6 +799,21 @@ func (in *SRVRecordSpec) DeepCopy() *SRVRecordSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SSHFPRecordSpec) DeepCopyInto(out *SSHFPRecordSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SSHFPRecordSpec. +func (in *SSHFPRecordSpec) DeepCopy() *SSHFPRecordSpec { + if in == nil { + return nil + } + out := new(SSHFPRecordSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StaticNS) DeepCopyInto(out *StaticNS) { *out = *in diff --git a/config/agent/manager.yaml b/config/agent/manager.yaml index e949830..c45c9e0 100644 --- a/config/agent/manager.yaml +++ b/config/agent/manager.yaml @@ -163,6 +163,30 @@ spec: drop: - "ALL" add: ["NET_BIND_SERVICE"] + - name: pdns-recursor + image: powerdns/pdns-recursor-51:latest + imagePullPolicy: IfNotPresent + command: ["/bin/sh","-c"] + args: + - | + set -eu; + exec pdns_recursor \ + --daemon=no \ + --disable-syslog=yes \ + --local-address=127.0.0.1,::1 \ + --local-port=5300 \ + --allow-from=127.0.0.1/32,::1/128 + volumeMounts: + - name: pdns-recursor-run + mountPath: /var/run/pdns-recursor + securityContext: + runAsUser: 953 + runAsGroup: 953 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - "ALL" - name: lightningstream image: powerdns/lightningstream:main imagePullPolicy: IfNotPresent @@ -227,6 +251,8 @@ spec: name: agent-server-config - name: pdns-shared emptyDir: {} + - name: pdns-recursor-run + emptyDir: {} - name: lightningstream-config configMap: name: lightningstream-config diff --git a/config/agent/pdns.conf b/config/agent/pdns.conf index 6bf5a10..11f4ffd 100644 --- a/config/agent/pdns.conf +++ b/config/agent/pdns.conf @@ -22,6 +22,11 @@ webserver-port=8082 api=yes # api-key will be passed via CLI using /run/pdns/api-key +# ALIAS support: authoritative expansion requires a recursive resolver. +# We run a local recursor sidecar on 127.0.0.1:5300. +resolver=127.0.0.1:5300 +expand-alias=yes + zone-cache-refresh-interval=0 zone-metadata-cache-ttl=0 diff --git a/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml b/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml index 2000afb..f804075 100644 --- a/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml +++ b/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml @@ -80,6 +80,9 @@ spec: - TLSA - HTTPS - SVCB + - ALIAS + - SSHFP + - NAPTR type: string records: description: Records contains one or more owner names with values @@ -105,6 +108,16 @@ spec: required: - content type: object + alias: + description: |- + ALIASRecordSpec is a PowerDNS-specific RR type that behaves like a CNAME at apex. + Content is a hostname (FQDN or relative) and will be normalized to an absolute name in providers. + properties: + content: + type: string + required: + - content + type: object caa: properties: flag: @@ -170,6 +183,37 @@ spec: minLength: 1 pattern: ^(@|[A-Za-z0-9*._-]+)$ type: string + naptr: + properties: + flags: + description: Flags is typically "S", "A", "U", or "". + type: string + order: + maximum: 65535 + minimum: 0 + type: integer + preference: + maximum: 65535 + minimum: 0 + type: integer + regexp: + description: Regexp is the substitution expression (often + empty). + type: string + replacement: + description: Replacement is the next domain name (FQDN/relative) + or ".". + minLength: 1 + type: string + services: + description: Services is the service field (often something + like "E2U+sip"). + type: string + required: + - order + - preference + - replacement + type: object ns: properties: content: @@ -240,6 +284,25 @@ spec: - target - weight type: object + sshfp: + properties: + algorithm: + maximum: 255 + minimum: 0 + type: integer + fingerprint: + description: Fingerprint is the hex fingerprint data. + minLength: 1 + type: string + type: + maximum: 255 + minimum: 0 + type: integer + required: + - algorithm + - fingerprint + - type + type: object svcb: properties: params: diff --git a/config/crd/bases/dns.networking.miloapis.com_dnszonediscoveries.yaml b/config/crd/bases/dns.networking.miloapis.com_dnszonediscoveries.yaml index 543156d..141c95c 100644 --- a/config/crd/bases/dns.networking.miloapis.com_dnszonediscoveries.yaml +++ b/config/crd/bases/dns.networking.miloapis.com_dnszonediscoveries.yaml @@ -151,6 +151,9 @@ spec: - TLSA - HTTPS - SVCB + - ALIAS + - SSHFP + - NAPTR type: string records: description: |- @@ -178,6 +181,16 @@ spec: required: - content type: object + alias: + description: |- + ALIASRecordSpec is a PowerDNS-specific RR type that behaves like a CNAME at apex. + Content is a hostname (FQDN or relative) and will be normalized to an absolute name in providers. + properties: + content: + type: string + required: + - content + type: object caa: properties: flag: @@ -243,6 +256,38 @@ spec: minLength: 1 pattern: ^(@|[A-Za-z0-9*._-]+)$ type: string + naptr: + properties: + flags: + description: Flags is typically "S", "A", "U", or + "". + type: string + order: + maximum: 65535 + minimum: 0 + type: integer + preference: + maximum: 65535 + minimum: 0 + type: integer + regexp: + description: Regexp is the substitution expression + (often empty). + type: string + replacement: + description: Replacement is the next domain name (FQDN/relative) + or ".". + minLength: 1 + type: string + services: + description: Services is the service field (often + something like "E2U+sip"). + type: string + required: + - order + - preference + - replacement + type: object ns: properties: content: @@ -313,6 +358,25 @@ spec: - target - weight type: object + sshfp: + properties: + algorithm: + maximum: 255 + minimum: 0 + type: integer + fingerprint: + description: Fingerprint is the hex fingerprint data. + minLength: 1 + type: string + type: + maximum: 255 + minimum: 0 + type: integer + required: + - algorithm + - fingerprint + - type + type: object svcb: properties: params: diff --git a/internal/pdns/client.go b/internal/pdns/client.go index 63c03c1..66a4d19 100644 --- a/internal/pdns/client.go +++ b/internal/pdns/client.go @@ -350,6 +350,16 @@ func buildRRSets(zone string, rs dnsv1alpha1.DNSRecordSet) []rrset { r.Records = append(r.Records, rrsetRecord{Content: target, Disabled: false}) } + case dnsv1alpha1.RRTypeALIAS: + if rec.ALIAS == nil { + continue + } + target := strings.TrimSpace(rec.ALIAS.Content) + target = qualifyIfNeeded(target) + if target != "" { + r.Records = append(r.Records, rrsetRecord{Content: target, Disabled: false}) + } + case dnsv1alpha1.RRTypeTXT: if rec.TXT == nil { continue @@ -450,21 +460,46 @@ func buildRRSets(zone string, rs dnsv1alpha1.DNSRecordSet) []rrset { r.Records = []rrsetRecord{{Content: line, Disabled: false}} case dnsv1alpha1.RRTypePTR: - // Adjust this once you have a typed PTR field in RecordEntry. - // For example, if you add: - // PTR *PTRRecordSpec `json:"ptr,omitempty"` - // and PTRRecordSpec has Content string: - // - // if rec.PTR != nil { - // v := strings.TrimSpace(rec.PTR.Content) - // if v != "" { - // r.Records = append(r.Records, rrsetRecord{ - // Content: qualifyIfNeeded(v), - // Disabled: false, - // }) - // } - // } - continue + if rec.PTR == nil { + continue + } + v := strings.TrimSpace(rec.PTR.Content) + if v != "" { + // PTR rdata is a domain name; normalize to absolute (trailing dot). + r.Records = append(r.Records, rrsetRecord{ + Content: qualifyIfNeeded(v), + Disabled: false, + }) + } + + case dnsv1alpha1.RRTypeSSHFP: + if rec.SSHFP == nil { + continue + } + fp := strings.TrimSpace(rec.SSHFP.Fingerprint) + if fp != "" { + line := fmt.Sprintf("%d %d %s", rec.SSHFP.Algorithm, rec.SSHFP.Type, fp) + r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false}) + } + + case dnsv1alpha1.RRTypeNAPTR: + if rec.NAPTR == nil { + continue + } + flags := quoteIfNeeded(strings.TrimSpace(rec.NAPTR.Flags)) + services := quoteIfNeeded(strings.TrimSpace(rec.NAPTR.Services)) + regexp := quoteIfNeeded(strings.TrimSpace(rec.NAPTR.Regexp)) + repl := strings.TrimSpace(rec.NAPTR.Replacement) + switch repl { + case "": + continue + case ".": + // literal dot must be preserved (root/no replacement) + default: + repl = qualifyIfNeeded(repl) + } + line := fmt.Sprintf("%d %d %s %s %s %s", rec.NAPTR.Order, rec.NAPTR.Preference, flags, services, regexp, repl) + r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false}) case dnsv1alpha1.RRTypeTLSA: if rec.TLSA == nil { diff --git a/internal/pdns/pdns_integration_test.go b/internal/pdns/pdns_integration_test.go index 5fc148f..9c6d789 100644 --- a/internal/pdns/pdns_integration_test.go +++ b/internal/pdns/pdns_integration_test.go @@ -142,6 +142,10 @@ func TestPDNS_EndToEnd_AllTypes(t *testing.T) { apply(dnsv1alpha1.RRTypeCNAME, dnsv1alpha1.RecordEntry{Name: "alias", TTL: &ttl, CNAME: &dnsv1alpha1.CNAMERecordSpec{Content: "www." + zone + "."}}, ) + // ALIAS (PowerDNS-specific) + apply(dnsv1alpha1.RRTypeALIAS, + dnsv1alpha1.RecordEntry{Name: "@", TTL: &ttl, ALIAS: &dnsv1alpha1.ALIASRecordSpec{Content: "www." + zone + "."}}, + ) // TXT (quoted) apply(dnsv1alpha1.RRTypeTXT, dnsv1alpha1.RecordEntry{Name: "txt", TTL: &ttl, TXT: &dnsv1alpha1.TXTRecordSpec{Content: "hello world"}}, @@ -171,10 +175,18 @@ func TestPDNS_EndToEnd_AllTypes(t *testing.T) { SOA: &dnsv1alpha1.SOARecordSpec{MName: "ns1.example.net.", RName: "hostmaster.example.net."}, }, ) - // // PTR (we’ll add it under a label in the same zone; PDNS doesn’t enforce reverse-zone semantics) - // apply(dnsv1alpha1.RRTypePTR, - // dnsv1alpha1.RecordEntry{Name: "ptrhost", TTL: &ttl, Raw: []string{"target." + zone + "."}}, - // ) + // PTR (we’ll add it under a label in the same zone; PDNS doesn’t enforce reverse-zone semantics) + apply(dnsv1alpha1.RRTypePTR, + dnsv1alpha1.RecordEntry{Name: "ptrhost", TTL: &ttl, PTR: &dnsv1alpha1.PTRRecordSpec{Content: "target." + zone + "."}}, + ) + // SSHFP + apply(dnsv1alpha1.RRTypeSSHFP, + dnsv1alpha1.RecordEntry{Name: "ssh", TTL: &ttl, SSHFP: &dnsv1alpha1.SSHFPRecordSpec{Algorithm: 1, Type: 1, Fingerprint: "abcdef"}}, + ) + // NAPTR + apply(dnsv1alpha1.RRTypeNAPTR, + dnsv1alpha1.RecordEntry{Name: "naptr", TTL: &ttl, NAPTR: &dnsv1alpha1.NAPTRRecordSpec{Order: 100, Preference: 10, Flags: "U", Services: "E2U+sip", Regexp: "!^.*$!sip:info@" + zone + "!", Replacement: "."}}, + ) // TLSA apply(dnsv1alpha1.RRTypeTLSA, dnsv1alpha1.RecordEntry{Name: "_443._tcp", TTL: &ttl, TLSA: &dnsv1alpha1.TLSARecordSpec{Usage: 3, Selector: 1, MatchingType: 1, CertData: "ABCD"}}, @@ -256,6 +268,11 @@ func TestPDNS_EndToEnd_AllTypes(t *testing.T) { t.Fatalf("CNAME alias got=%v", got) } + // ALIAS + if got := get("ALIAS", "@"); len(got) != 1 || stripTrailingDot(got[0]) != "www."+zone { + t.Fatalf("ALIAS @ got=%v", got) + } + // TXT (we compare without quotes) if got := get("TXT", "txt"); len(got) != 1 || stripq(got[0]) != "hello world" { t.Fatalf("TXT txt got=%v", got) @@ -296,10 +313,20 @@ func TestPDNS_EndToEnd_AllTypes(t *testing.T) { } } - // // PTR - // if got := get("PTR", "ptrhost"); len(got) != 1 || got[0] != "target."+zone+"." { - // t.Fatalf("PTR ptrhost got=%v", got) - // } + // PTR + if got := get("PTR", "ptrhost"); len(got) != 1 || got[0] != "target."+zone+"." { + t.Fatalf("PTR ptrhost got=%v", got) + } + + // SSHFP + if got := get("SSHFP", "ssh"); len(got) != 1 || got[0] != "1 1 abcdef" { + t.Fatalf("SSHFP ssh got=%v", got) + } + + // NAPTR (we expect provider-quoted fields) + if got := get("NAPTR", "naptr"); len(got) != 1 || got[0] != `100 10 "U" "E2U+sip" "!^.*$!sip:info@`+zone+`!" .` { + t.Fatalf("NAPTR naptr got=%v", got) + } // TLSA if got := get("TLSA", "_443._tcp"); len(got) != 1 || got[0] != "3 1 1 abcd" { diff --git a/internal/pdns/pdns_test.go b/internal/pdns/pdns_test.go index b08c838..eadfe9a 100644 --- a/internal/pdns/pdns_test.go +++ b/internal/pdns/pdns_test.go @@ -182,6 +182,24 @@ func TestBuildRRSets_NormalizationAndFormats(t *testing.T) { t.Fatalf("CNAME rrset unexpected: %#v", rr) } + // ALIAS (PowerDNS-specific): normalize target to absolute (trailing dot) + rsALIAS := dnsv1alpha1.DNSRecordSet{ + Spec: dnsv1alpha1.DNSRecordSetSpec{ + RecordType: dnsv1alpha1.RRTypeALIAS, + Records: []dnsv1alpha1.RecordEntry{ + { + Name: "@", + TTL: &ttl, + ALIAS: &dnsv1alpha1.ALIASRecordSpec{Content: "target.example.net"}, + }, + }, + }, + } + rr = buildRRSets("example.com", rsALIAS) + if rr[0].Type != "ALIAS" || rr[0].Name != "example.com." || rr[0].Records[0].Content != "target.example.net." { + t.Fatalf("ALIAS rrset unexpected: %#v", rr) + } + // TXT: quoted rsTXT := dnsv1alpha1.DNSRecordSet{ Spec: dnsv1alpha1.DNSRecordSetSpec{ @@ -316,6 +334,60 @@ func TestBuildRRSets_NormalizationAndFormats(t *testing.T) { t.Fatalf("SOA serial shape unexpected: %q", parts[2]) } + // PTR: normalize target to absolute (trailing dot) + rsPTR := dnsv1alpha1.DNSRecordSet{ + Spec: dnsv1alpha1.DNSRecordSetSpec{ + RecordType: dnsv1alpha1.RRTypePTR, + Records: []dnsv1alpha1.RecordEntry{ + { + Name: "ptrhost", + TTL: &ttl, + PTR: &dnsv1alpha1.PTRRecordSpec{Content: "target.example.net"}, + }, + }, + }, + } + rr = buildRRSets("example.com", rsPTR) + if rr[0].Type != "PTR" || rr[0].Records[0].Content != "target.example.net." { + t.Fatalf("PTR rrset unexpected: %#v", rr) + } + + // SSHFP + rsSSHFP := dnsv1alpha1.DNSRecordSet{ + Spec: dnsv1alpha1.DNSRecordSetSpec{ + RecordType: dnsv1alpha1.RRTypeSSHFP, + Records: []dnsv1alpha1.RecordEntry{ + { + Name: "ssh", + TTL: &ttl, + SSHFP: &dnsv1alpha1.SSHFPRecordSpec{Algorithm: 1, Type: 1, Fingerprint: "abcdef"}, + }, + }, + }, + } + rr = buildRRSets("example.com", rsSSHFP) + if rr[0].Type != "SSHFP" || rr[0].Records[0].Content != "1 1 abcdef" { + t.Fatalf("SSHFP rrset unexpected: %#v", rr) + } + + // NAPTR (quoted flags/services/regexp; replacement preserves "." literal) + rsNAPTR := dnsv1alpha1.DNSRecordSet{ + Spec: dnsv1alpha1.DNSRecordSetSpec{ + RecordType: dnsv1alpha1.RRTypeNAPTR, + Records: []dnsv1alpha1.RecordEntry{ + { + Name: "naptr", + TTL: &ttl, + NAPTR: &dnsv1alpha1.NAPTRRecordSpec{Order: 100, Preference: 10, Flags: "U", Services: "E2U+sip", Regexp: "!^.*$!sip:info@example.com!", Replacement: "."}, + }, + }, + }, + } + rr = buildRRSets("example.com", rsNAPTR) + if rr[0].Type != "NAPTR" || rr[0].Records[0].Content != `100 10 "U" "E2U+sip" "!^.*$!sip:info@example.com!" .` { + t.Fatalf("NAPTR rrset unexpected: %#v", rr) + } + // TLSA: straight join rsTLSA := dnsv1alpha1.DNSRecordSet{ Spec: dnsv1alpha1.DNSRecordSetSpec{