From fbb892059f36dcb50143f1c8465134b88b88559c Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Sun, 17 May 2026 19:05:06 -0400 Subject: [PATCH 01/15] Use RCv3 in diff2 --- models/record.go | 14 +++++++ pkg/rtype/ds.go | 11 ++++++ pkg/rtype/rp.go | 15 ++++++++ pkg/rtypecontrol/fixlegacy.go | 69 ++++++++++++++++++++++++++++++++--- 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/models/record.go b/models/record.go index a6d1d3a370..988124dcb2 100644 --- a/models/record.go +++ b/models/record.go @@ -6,6 +6,7 @@ import ( "log" "strings" + dnsv2 "codeberg.org/miekg/dns" "github.com/DNSControl/dnscontrol/v4/pkg/txtutil" "github.com/jinzhu/copier" dnsv1 "github.com/miekg/dns" @@ -16,9 +17,22 @@ import ( // RecordConfig stores a DNS record whether it was created from data downloaded from // a provider's API ("actual") or from user input in dndsconfig.js ("desired"). type RecordConfig struct { + // Type is the DNS record type (rtype), all caps, "A", "MX", etc. Type string `json:"type"` + // TypeNum is the assigned number of the record's type. 1 for A, 5 for CNAME, etc. See dnsv2.TypeToString and dnsv2.StringToType. + TypeNum uint16 `json:"typenum"` + + // RDATA is (the fields of the record). + RDATA dnsv2.RDATA `json:"rdata"` + + // ComparableV3 is an opaque string that can be used to compare two + // RecordConfigs for equality. Typically this is the Zonefile line minus the + // label and TTL. + // The V3 distingues itself from .Comparable and all other legacy systems that we're leaving in place for now. + ComparableV3 any `json:"comparablev3"` + // TTL is the DNS record's TTL in seconds. 0 means provider default. TTL uint32 `json:"ttl,omitempty"` diff --git a/pkg/rtype/ds.go b/pkg/rtype/ds.go index 37acd88f99..5986b32158 100644 --- a/pkg/rtype/ds.go +++ b/pkg/rtype/ds.go @@ -3,6 +3,7 @@ package rtype import ( "fmt" + dnsrdatav2 "codeberg.org/miekg/dns/rdata" "github.com/DNSControl/dnscontrol/v4/models" "github.com/DNSControl/dnscontrol/v4/pkg/domaintags" "github.com/DNSControl/dnscontrol/v4/pkg/rtypecontrol" @@ -50,6 +51,16 @@ func (handle *DS) FromStruct(dcn *domaintags.DomainNameVarieties, rec *models.Re } rec.F = &DS{*ds} + // Hack to deal with the fact that fixlegacy.go can't import rtype. + switch rec.F.(type) { + case *DS: + rec.RDATA = dnsrdatav2.DS{KeyTag: rec.F.(*DS).KeyTag, Algorithm: rec.F.(*DS).Algorithm, DigestType: rec.F.(*DS).DigestType, Digest: rec.F.(*DS).Digest} + case *dnsv1.DS: + rec.RDATA = dnsrdatav2.DS{KeyTag: rec.F.(*dnsv1.DS).KeyTag, Algorithm: rec.F.(*dnsv1.DS).Algorithm, DigestType: rec.F.(*dnsv1.DS).DigestType, Digest: rec.F.(*dnsv1.DS).Digest} + default: + panic(fmt.Sprintf("unexpected type for DS.FromStruct: %T", rec.F)) + } + rec.ZonefilePartial = rec.GetTargetRFC1035Quoted() rec.Comparable = rec.ZonefilePartial diff --git a/pkg/rtype/rp.go b/pkg/rtype/rp.go index 919e160ec3..c8108b224f 100644 --- a/pkg/rtype/rp.go +++ b/pkg/rtype/rp.go @@ -3,6 +3,8 @@ package rtype import ( "fmt" + dnsv2 "codeberg.org/miekg/dns" + dnsrdatav2 "codeberg.org/miekg/dns/rdata" "github.com/DNSControl/dnscontrol/v4/models" "github.com/DNSControl/dnscontrol/v4/pkg/domaintags" "github.com/DNSControl/dnscontrol/v4/pkg/rtypecontrol" @@ -49,6 +51,19 @@ func (handle *RP) FromStruct(dcn *domaintags.DomainNameVarieties, rec *models.Re rec.ZonefilePartial = rec.GetTargetRFC1035Quoted() rec.Comparable = rec.ZonefilePartial + // Hack to deal with the fact that fixlegacy.go can't import rtype. + switch rec.F.(type) { + case *RP: + rec.RDATA = dnsrdatav2.RP{Mbox: rec.F.(*RP).Mbox, Txt: rec.F.(*RP).Txt} + case *dnsv1.RP: + rec.RDATA = dnsrdatav2.RP{Mbox: rec.F.(*dnsv1.RP).Mbox, Txt: rec.F.(*dnsv1.RP).Txt} + default: + panic(fmt.Sprintf("unexpected type for RP.FromStruct: %T", rec.F)) + } + + rec.TypeNum = dnsv2.TypeRP + rec.ComparableV3 = rec.RDATA.(dnsrdatav2.RP).String() + handle.CopyToLegacyFields(rec) return nil } diff --git a/pkg/rtypecontrol/fixlegacy.go b/pkg/rtypecontrol/fixlegacy.go index 12a335c521..6e7ec68373 100644 --- a/pkg/rtypecontrol/fixlegacy.go +++ b/pkg/rtypecontrol/fixlegacy.go @@ -1,6 +1,12 @@ package rtypecontrol -import "github.com/DNSControl/dnscontrol/v4/models" +import ( + "fmt" + + dnsutilv2 "codeberg.org/miekg/dns/dnsutil" + dnsrdatav2 "codeberg.org/miekg/dns/rdata" + "github.com/DNSControl/dnscontrol/v4/models" +) // FixLegacyDC populates .F to compenstate for providers that have not been // updated to support RecordConfigV2 when creating RecordConfig. @@ -25,12 +31,63 @@ func FixLegacyRecords(recs *models.Records) { // FixLegacyRecord populates .F to compenstate for providers that have not been // updated to support RecordConfigV2 when creating RecordConfig. func FixLegacyRecord(rec *models.RecordConfig) { - // Populate .F if needed: + // Populate .F if needed: (legacy) // That is... If rec.F == nil and this is a "modern" type. - if rec.F != nil { - return + if rec.F == nil { + if fixer, ok := Func[rec.Type]; ok { + fixer.CopyFromLegacyFields(rec) + } } - if fixer, ok := Func[rec.Type]; ok { - fixer.CopyFromLegacyFields(rec) + + // Populate .RDATA if needed: + if rec.RDATA == nil { + + // The .RDATA structure itself. + switch rec.Type { + case "A": + rec.RDATA = dnsrdatav2.A{Addr: rec.GetTargetIP()} + case "AAAA": + rec.RDATA = dnsrdatav2.AAAA{Addr: rec.GetTargetIP()} + + case "CAA": + rec.RDATA = dnsrdatav2.CAA{Flag: rec.CaaFlag, Tag: rec.CaaTag, Value: rec.GetTargetField()} + case "CNAME": + rec.RDATA = dnsrdatav2.CNAME{Target: rec.GetTargetField()} + + case "HTTPS": + //rec.RDATA = dnsrdatav2.HTTPS{Priority: rec.HttpsPriority, Target: rec.GetTargetField()} + + case "MX": + rec.RDATA = dnsrdatav2.MX{Preference: rec.MxPreference, Mx: rec.GetTargetField()} + + case "RP": + // no-op. See pkg/rtype/rp.go:FromStruct. + + case "SOA": + rec.RDATA = dnsrdatav2.SOA{Ns: rec.GetTargetField(), Mbox: rec.SoaMbox, Serial: rec.SoaSerial, Refresh: rec.SoaRefresh, Retry: rec.SoaRetry, Expire: rec.SoaExpire, Minttl: rec.SoaMinttl} + case "SRV": + rec.RDATA = dnsrdatav2.SRV{Priority: rec.SrvPriority, Weight: rec.SrvWeight, Port: rec.SrvPort, Target: rec.GetTargetField()} + + case "TXT": + rec.RDATA = dnsrdatav2.TXT{Txt: []string{rec.GetTargetField()}} + + default: + panic(fmt.Sprintf("RDATA CONVERSION NOT IMPLEMENTED TYPE=%q", rec.Type)) + } + + if rec.RDATA != nil { + + // TypeNum: + tn, err := dnsutilv2.StringToType(rec.Type) + if err != nil { + panic("fix me") + } + rec.TypeNum = tn + + // Comparable: + rec.Comparable = fmt.Sprintf("%s", rec.RDATA) + + } + } } From 444d61c77ea61e1f6eb2f66edb935b6c4c28ee8a Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Sun, 17 May 2026 20:55:45 -0400 Subject: [PATCH 02/15] SVCB and HTTPS now supports --- integrationTest/helpers_integration_test.go | 23 +++++++++ models/t_svcb.go | 54 +++++++++++++++++++++ pkg/rtypecontrol/fixlegacy.go | 8 ++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/integrationTest/helpers_integration_test.go b/integrationTest/helpers_integration_test.go index 63bf2d5e0b..24e5e09dcb 100644 --- a/integrationTest/helpers_integration_test.go +++ b/integrationTest/helpers_integration_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + dnsv2 "codeberg.org/miekg/dns" "github.com/DNSControl/dnscontrol/v4/models" "github.com/DNSControl/dnscontrol/v4/pkg/domaintags" "github.com/DNSControl/dnscontrol/v4/pkg/nameservers" @@ -520,6 +521,19 @@ func https(name string, priority uint16, target string, params string) *models.R r := makeRec(name, target, "HTTPS") r.SvcPriority = priority r.SvcParams = params + + // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + rty := dnsv2.TypeSVCB + cp := params + if strings.Contains(cp, "ech=IGNORE") { + cp = strings.ReplaceAll(cp, "ech=IGNORE", "") + } + rrv2, err := dnsv2.NewData(rty, fmt.Sprintf("%d %s %s", priority, target, cp)) + if err != nil { + panic(fmt.Sprintf("could not parse SVCB record: %s (%d %s %s)", err, priority, target, cp)) + } + r.RDATA = rrv2 + return r } @@ -652,6 +666,15 @@ func svcb(name string, priority uint16, target string, params string) *models.Re r := makeRec(name, target, "SVCB") r.SvcPriority = priority r.SvcParams = params + + // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + rty := dnsv2.TypeSVCB + rrv2, err := dnsv2.NewData(rty, fmt.Sprintf("%d %s %s", priority, target, params)) + if err != nil { + panic(fmt.Sprintf("could not parse SVCB record: %s", err)) + } + r.RDATA = rrv2 + return r } diff --git a/models/t_svcb.go b/models/t_svcb.go index a4b0033ebd..bc054db824 100644 --- a/models/t_svcb.go +++ b/models/t_svcb.go @@ -4,6 +4,9 @@ import ( "fmt" "strings" + dnsv2 "codeberg.org/miekg/dns" + dnsrdatav2 "codeberg.org/miekg/dns/rdata" + svcbv2 "codeberg.org/miekg/dns/svcb" dnsv1 "github.com/miekg/dns" ) @@ -24,6 +27,14 @@ func (rc *RecordConfig) SetTargetSVCB(priority uint16, target string, params []d if rc.Type != "SVCB" && rc.Type != "HTTPS" { panic("assertion failed: SetTargetSVCB called when .Type is not SVCB or HTTPS") } + + // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + valuev2, err := convertSVCBv1v2(params) + if err != nil { + return fmt.Errorf("failed to convert SVCB parameters from v1 to v2: %w", err) + } + rc.RDATA = dnsrdatav2.SVCB{Priority: rc.SvcPriority, Target: target, Value: valuev2} + return nil } @@ -36,6 +47,23 @@ func (rc *RecordConfig) SetTargetSVCBString(origin, contents string) error { if err != nil { return fmt.Errorf("could not parse SVCB record: %w", err) } + + // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + var rty uint16 + switch record.(type) { + case *dnsv1.HTTPS: + rty = dnsv1.TypeHTTPS + case *dnsv1.SVCB: + rty = dnsv1.TypeSVCB + default: + return fmt.Errorf("unexpected record type after parsing SVCB record: %T", record) + } + rrv2, err := dnsv2.NewData(rty, contents, origin) + if err != nil { + return fmt.Errorf("could not parse SVCB record: %w", err) + } + rc.RDATA = rrv2 + switch r := record.(type) { case *dnsv1.HTTPS: return rc.SetTargetSVCB(r.Priority, r.Target, r.Value) @@ -44,3 +72,29 @@ func (rc *RecordConfig) SetTargetSVCBString(origin, contents string) error { } return nil } + +func convertSVCBv1v2(params []dnsv1.SVCBKeyValue) ([]svcbv2.Pair, error) { + var value []svcbv2.Pair + for _, kv := range params { + k := kv.Key().String() + keyCode := svcbv2.StringToKey(k) + v := kv.String() + + pairFn := svcbv2.KeyToPair(keyCode) + if pairFn == nil { + return nil, fmt.Errorf("failed to lookup svc key: %s", k) + } + pair := pairFn() + if svcbv2.PairToKey(pair) != keyCode { + return nil, fmt.Errorf("key constant is not in sync: %s", keyCode) + } + err := svcbv2.Parse(pair, v, "") + if err != nil { + return nil, fmt.Errorf("failed to parse svc pair: %s", k) + } + + value = append(value, pair) + } + + return value, nil +} diff --git a/pkg/rtypecontrol/fixlegacy.go b/pkg/rtypecontrol/fixlegacy.go index 6e7ec68373..7643f3bd19 100644 --- a/pkg/rtypecontrol/fixlegacy.go +++ b/pkg/rtypecontrol/fixlegacy.go @@ -55,19 +55,25 @@ func FixLegacyRecord(rec *models.RecordConfig) { rec.RDATA = dnsrdatav2.CNAME{Target: rec.GetTargetField()} case "HTTPS": - //rec.RDATA = dnsrdatav2.HTTPS{Priority: rec.HttpsPriority, Target: rec.GetTargetField()} + // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB + panic("HTTPS should already be converted to RDATA") case "MX": rec.RDATA = dnsrdatav2.MX{Preference: rec.MxPreference, Mx: rec.GetTargetField()} case "RP": // no-op. See pkg/rtype/rp.go:FromStruct. + panic("RP should already be converted to RDATA") case "SOA": rec.RDATA = dnsrdatav2.SOA{Ns: rec.GetTargetField(), Mbox: rec.SoaMbox, Serial: rec.SoaSerial, Refresh: rec.SoaRefresh, Retry: rec.SoaRetry, Expire: rec.SoaExpire, Minttl: rec.SoaMinttl} case "SRV": rec.RDATA = dnsrdatav2.SRV{Priority: rec.SrvPriority, Weight: rec.SrvWeight, Port: rec.SrvPort, Target: rec.GetTargetField()} + case "SVCB": + // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB + panic("SVCB should already be converted to RDATA") + case "TXT": rec.RDATA = dnsrdatav2.TXT{Txt: []string{rec.GetTargetField()}} From 59c9ffa7db1ed1468fbcd72f3b6fb8c81cbb8ec6 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Mon, 18 May 2026 21:18:32 -0400 Subject: [PATCH 03/15] Native types converted --- pkg/rtypecontrol/fixlegacy.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/rtypecontrol/fixlegacy.go b/pkg/rtypecontrol/fixlegacy.go index 7643f3bd19..cad63b9153 100644 --- a/pkg/rtypecontrol/fixlegacy.go +++ b/pkg/rtypecontrol/fixlegacy.go @@ -54,21 +54,49 @@ func FixLegacyRecord(rec *models.RecordConfig) { case "CNAME": rec.RDATA = dnsrdatav2.CNAME{Target: rec.GetTargetField()} + case "DHCID": + rec.RDATA = dnsrdatav2.DHCID{Digest: rec.GetTargetField()} + case "DNAME": + rec.RDATA = dnsrdatav2.DNAME{Target: rec.GetTargetField()} + case "DNSKEY": + rec.RDATA = dnsrdatav2.DNSKEY{Flags: rec.DnskeyFlags, Protocol: rec.DnskeyProtocol, Algorithm: rec.DnskeyAlgorithm, PublicKey: rec.GetTargetField()} + case "HTTPS": // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB panic("HTTPS should already be converted to RDATA") + case "LOC": + rec.RDATA = dnsrdatav2.LOC{Version: rec.LocVersion, Size: rec.LocSize, HorizPre: rec.LocHorizPre, VertPre: rec.LocVertPre, Latitude: rec.LocLatitude, Longitude: rec.LocLongitude, Altitude: rec.LocAltitude} + case "MX": rec.RDATA = dnsrdatav2.MX{Preference: rec.MxPreference, Mx: rec.GetTargetField()} + case "NS": + rec.RDATA = dnsrdatav2.NS{Ns: rec.GetTargetField()} + case "NAPTR": + rec.RDATA = dnsrdatav2.NAPTR{Order: rec.NaptrOrder, Preference: rec.NaptrPreference, Flags: rec.NaptrFlags, Service: rec.NaptrService, Regexp: rec.NaptrRegexp, Replacement: rec.GetTargetField()} + + case "OPENPGPKEY": + rec.RDATA = dnsrdatav2.OPENPGPKEY{PublicKey: rec.GetTargetField()} + + case "PTR": + rec.RDATA = dnsrdatav2.PTR{Ptr: rec.GetTargetField()} + case "RP": // no-op. See pkg/rtype/rp.go:FromStruct. panic("RP should already be converted to RDATA") + case "SMIMEA": + rec.RDATA = dnsrdatav2.SMIMEA{Usage: rec.SmimeaUsage, Selector: rec.SmimeaSelector, MatchingType: rec.SmimeaMatchingType, Certificate: rec.GetTargetField()} case "SOA": rec.RDATA = dnsrdatav2.SOA{Ns: rec.GetTargetField(), Mbox: rec.SoaMbox, Serial: rec.SoaSerial, Refresh: rec.SoaRefresh, Retry: rec.SoaRetry, Expire: rec.SoaExpire, Minttl: rec.SoaMinttl} case "SRV": rec.RDATA = dnsrdatav2.SRV{Priority: rec.SrvPriority, Weight: rec.SrvWeight, Port: rec.SrvPort, Target: rec.GetTargetField()} + case "SSHFP": + rec.RDATA = dnsrdatav2.SSHFP{Algorithm: rec.SshfpAlgorithm, Type: rec.SshfpFingerprint, FingerPrint: rec.GetTargetField()} + + case "TLSA": + rec.RDATA = dnsrdatav2.TLSA{Usage: rec.TlsaUsage, Selector: rec.TlsaSelector, MatchingType: rec.TlsaMatchingType, Certificate: rec.GetTargetField()} case "SVCB": // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB From 0532d066600733689235981f41ce749859a115eb Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 19 May 2026 09:40:22 -0400 Subject: [PATCH 04/15] wip! --- integrationTest/helpers_integration_test.go | 2 ++ models/record.go | 2 +- pkg/diff2/compareconfig.go | 8 +++++-- pkg/rtypecontrol/fixlegacy.go | 24 ++++++++++----------- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/integrationTest/helpers_integration_test.go b/integrationTest/helpers_integration_test.go index 24e5e09dcb..5e941e6e9a 100644 --- a/integrationTest/helpers_integration_test.go +++ b/integrationTest/helpers_integration_test.go @@ -533,6 +533,7 @@ func https(name string, priority uint16, target string, params string) *models.R panic(fmt.Sprintf("could not parse SVCB record: %s (%d %s %s)", err, priority, target, cp)) } r.RDATA = rrv2 + r.ComparableV3 = fmt.Sprintf("%s", r.RDATA) return r } @@ -674,6 +675,7 @@ func svcb(name string, priority uint16, target string, params string) *models.Re panic(fmt.Sprintf("could not parse SVCB record: %s", err)) } r.RDATA = rrv2 + r.ComparableV3 = fmt.Sprintf("%s", r.RDATA) return r } diff --git a/models/record.go b/models/record.go index 988124dcb2..987ed3c00c 100644 --- a/models/record.go +++ b/models/record.go @@ -31,7 +31,7 @@ type RecordConfig struct { // RecordConfigs for equality. Typically this is the Zonefile line minus the // label and TTL. // The V3 distingues itself from .Comparable and all other legacy systems that we're leaving in place for now. - ComparableV3 any `json:"comparablev3"` + ComparableV3 string `json:"comparablev3"` // TTL is the DNS record's TTL in seconds. 0 means provider default. TTL uint32 `json:"ttl,omitempty"` diff --git a/pkg/diff2/compareconfig.go b/pkg/diff2/compareconfig.go index 1153d5feb5..d0488c3a67 100644 --- a/pkg/diff2/compareconfig.go +++ b/pkg/diff2/compareconfig.go @@ -170,8 +170,12 @@ func (cc *CompareConfig) verifyCNAMEAssertions() { // Generate a string that can be used to compare this record to others // for equality. func mkCompareBlobs(rc *models.RecordConfig, f func(*models.RecordConfig) string) (string, string) { - // Start with the comparable string - comp := rc.ToComparableNoTTL() + // // Start with the comparable string + // comp := rc.ToComparableNoTTL() + comp := rc.ComparableV3 + if comp == "" { + panic(fmt.Sprintf("mkCompareBlobs: record %s has empty ComparableV3", rc)) + } // If the custom function exists, add its output if f != nil { diff --git a/pkg/rtypecontrol/fixlegacy.go b/pkg/rtypecontrol/fixlegacy.go index cad63b9153..c264916bfe 100644 --- a/pkg/rtypecontrol/fixlegacy.go +++ b/pkg/rtypecontrol/fixlegacy.go @@ -60,6 +60,9 @@ func FixLegacyRecord(rec *models.RecordConfig) { rec.RDATA = dnsrdatav2.DNAME{Target: rec.GetTargetField()} case "DNSKEY": rec.RDATA = dnsrdatav2.DNSKEY{Flags: rec.DnskeyFlags, Protocol: rec.DnskeyProtocol, Algorithm: rec.DnskeyAlgorithm, PublicKey: rec.GetTargetField()} + case "DS": + // no-op. See pkg/rtype/ds.go:FromStruct. + panic("DS should already be converted to RDATA") case "HTTPS": // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB @@ -109,19 +112,16 @@ func FixLegacyRecord(rec *models.RecordConfig) { panic(fmt.Sprintf("RDATA CONVERSION NOT IMPLEMENTED TYPE=%q", rec.Type)) } - if rec.RDATA != nil { - - // TypeNum: - tn, err := dnsutilv2.StringToType(rec.Type) - if err != nil { - panic("fix me") - } - rec.TypeNum = tn - - // Comparable: - rec.Comparable = fmt.Sprintf("%s", rec.RDATA) - + // TypeNum: + tn, err := dnsutilv2.StringToType(rec.Type) + if err != nil { + panic("fix me") } + rec.TypeNum = tn + + // Comparable: + rec.ComparableV3 = fmt.Sprintf("%s", rec.RDATA) + fmt.Printf("DEBUG: COMPARE for %s --- %s\n", rec.Type, rec.ComparableV3) } } From 42ec97ead2a88b0fe4547de9aec4c2face070ff9 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 19 May 2026 09:49:56 -0400 Subject: [PATCH 05/15] REFACTOR: Add V3 fields to RecordConfig --- models/record.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/models/record.go b/models/record.go index 4c7ab33b86..b045054f9b 100644 --- a/models/record.go +++ b/models/record.go @@ -6,6 +6,7 @@ import ( "log" "strings" + dnsv2 "codeberg.org/miekg/dns" "github.com/DNSControl/dnscontrol/v4/pkg/txtutil" "github.com/jinzhu/copier" dnsv1 "github.com/miekg/dns" @@ -16,9 +17,25 @@ import ( // RecordConfig stores a DNS record whether it was created from data downloaded from // a provider's API ("actual") or from user input in dndsconfig.js ("desired"). type RecordConfig struct { + // Type is the DNS record type (rtype), all caps, "A", "MX", etc. Type string `json:"type"` + // TypeNum is the assigned number of the record's type. 1 for A, 5 for CNAME, etc. See dnsv2.TypeToString and dnsv2.StringToType. + // NB(tlim): Not currently used. Placeholder for future feature. + TypeNum uint16 `json:"typenum,omitempty"` + + // RDATA is (the fields of the record). + // NB(tlim): Not currently used. Placeholder for future feature. + RDATA dnsv2.RDATA `json:"rdata,omitempty"` + + // ComparableV3 is an opaque string that can be used to compare two + // RecordConfigs for equality. Typically this is the Zonefile line + // minus the label and TTL. + // The V3 distingues itself from .Comparable, which it will eventually replace. + // NB(tlim): Not currently used. Placeholder for future feature. + ComparableV3 string `json:"comparablev3,omitempty"` + // TTL is the DNS record's TTL in seconds. 0 means provider default. TTL uint32 `json:"ttl,omitempty"` @@ -353,6 +370,7 @@ func (rc *RecordConfig) ToComparableNoTTL() string { return fmt.Sprintf("rtype=%s rdata=%s", rc.UnknownTypeName, rc.target) case "HTTPS", "SVCB": return rc.targetCombinedSVCBRaw() + } return rc.GetTargetCombined() } From 429112e505e95d5429f922a7dde984d62393e3b4 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 19 May 2026 10:04:10 -0400 Subject: [PATCH 06/15] m --- integrationTest/helpers_integration_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/integrationTest/helpers_integration_test.go b/integrationTest/helpers_integration_test.go index 5e941e6e9a..7f4f1c3ca4 100644 --- a/integrationTest/helpers_integration_test.go +++ b/integrationTest/helpers_integration_test.go @@ -533,7 +533,11 @@ func https(name string, priority uint16, target string, params string) *models.R panic(fmt.Sprintf("could not parse SVCB record: %s (%d %s %s)", err, priority, target, cp)) } r.RDATA = rrv2 - r.ComparableV3 = fmt.Sprintf("%s", r.RDATA) + old := fmt.Sprintf("%s", r.RDATA) + r.ComparableV3 = r.RDATA.String() + if r.ComparableV3 != old { + panic("DEBUG CV3") + } return r } From 4c27250b28ae50f4e14c8faad1986cb523d6e42a Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 19 May 2026 17:27:25 -0400 Subject: [PATCH 07/15] diff2: fixing analyze_test.go --- pkg/diff2/analyze_test.go | 17 +++++++++++++++++ pkg/diff2/compareconfig.go | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/diff2/analyze_test.go b/pkg/diff2/analyze_test.go index 59db66a120..edb1e94c7d 100644 --- a/pkg/diff2/analyze_test.go +++ b/pkg/diff2/analyze_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" + dnsv2 "codeberg.org/miekg/dns" + dnsutilv2 "codeberg.org/miekg/dns/dnsutil" "github.com/DNSControl/dnscontrol/v4/models" "github.com/fatih/color" "github.com/kylelemons/godebug/diff" @@ -63,6 +65,21 @@ func makeRec(label, rtype, content string) *models.RecordConfig { if err := r.PopulateFromString(rtype, content, origin); err != nil { panic(err) } + + // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + tn, err := dnsutilv2.StringToType(rtype) + if err != nil { + panic(fmt.Sprintf("BUG: HackFixRecord: %s IN %s %v", r.Name, r.Type, r)) + } + r.TypeNum = tn + rrv2, err := dnsv2.NewData(tn, content, origin+".") + if err != nil { + panic(fmt.Sprintf("could not parse: %s IN %s %s: %s", r.Name, rtype, content, err)) + } + r.RDATA = rrv2 + r.ComparableV3 = fmt.Sprintf("%s", r.RDATA) + // End of hack + return &r } diff --git a/pkg/diff2/compareconfig.go b/pkg/diff2/compareconfig.go index d0488c3a67..92729ca4cb 100644 --- a/pkg/diff2/compareconfig.go +++ b/pkg/diff2/compareconfig.go @@ -174,7 +174,7 @@ func mkCompareBlobs(rc *models.RecordConfig, f func(*models.RecordConfig) string // comp := rc.ToComparableNoTTL() comp := rc.ComparableV3 if comp == "" { - panic(fmt.Sprintf("mkCompareBlobs: record %s has empty ComparableV3", rc)) + panic(fmt.Sprintf("mkCompareBlobs: record %s IN %s %s has empty ComparableV3", rc.NameFQDN, rc.Type, rc)) } // If the custom function exists, add its output From 674e9b02237b29c1dcf926b5ad6ee132681f92d7 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 19 May 2026 17:34:57 -0400 Subject: [PATCH 08/15] linting --- integrationTest/helpers_integration_test.go | 4 ++-- pkg/diff2/analyze_test.go | 2 +- pkg/rtypecontrol/fixlegacy.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integrationTest/helpers_integration_test.go b/integrationTest/helpers_integration_test.go index 7f4f1c3ca4..46cb9dc6c6 100644 --- a/integrationTest/helpers_integration_test.go +++ b/integrationTest/helpers_integration_test.go @@ -533,7 +533,7 @@ func https(name string, priority uint16, target string, params string) *models.R panic(fmt.Sprintf("could not parse SVCB record: %s (%d %s %s)", err, priority, target, cp)) } r.RDATA = rrv2 - old := fmt.Sprintf("%s", r.RDATA) + old := r.RDATA.String() r.ComparableV3 = r.RDATA.String() if r.ComparableV3 != old { panic("DEBUG CV3") @@ -679,7 +679,7 @@ func svcb(name string, priority uint16, target string, params string) *models.Re panic(fmt.Sprintf("could not parse SVCB record: %s", err)) } r.RDATA = rrv2 - r.ComparableV3 = fmt.Sprintf("%s", r.RDATA) + r.ComparableV3 = r.RDATA.String() return r } diff --git a/pkg/diff2/analyze_test.go b/pkg/diff2/analyze_test.go index edb1e94c7d..9a6f4ff576 100644 --- a/pkg/diff2/analyze_test.go +++ b/pkg/diff2/analyze_test.go @@ -77,7 +77,7 @@ func makeRec(label, rtype, content string) *models.RecordConfig { panic(fmt.Sprintf("could not parse: %s IN %s %s: %s", r.Name, rtype, content, err)) } r.RDATA = rrv2 - r.ComparableV3 = fmt.Sprintf("%s", r.RDATA) + r.ComparableV3 = r.RDATA.String() // End of hack return &r diff --git a/pkg/rtypecontrol/fixlegacy.go b/pkg/rtypecontrol/fixlegacy.go index c264916bfe..f6e5673ff3 100644 --- a/pkg/rtypecontrol/fixlegacy.go +++ b/pkg/rtypecontrol/fixlegacy.go @@ -120,7 +120,7 @@ func FixLegacyRecord(rec *models.RecordConfig) { rec.TypeNum = tn // Comparable: - rec.ComparableV3 = fmt.Sprintf("%s", rec.RDATA) + rec.ComparableV3 = rec.RDATA.String() fmt.Printf("DEBUG: COMPARE for %s --- %s\n", rec.Type, rec.ComparableV3) } From ddf89c43b22e21c7772d1f05dbbf6f46111a24dc Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 19 May 2026 17:37:13 -0400 Subject: [PATCH 09/15] empty From aac371c60b5b754023dcf0a19ada4d4383fd3d17 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Wed, 20 May 2026 15:09:42 -0400 Subject: [PATCH 10/15] CICD: Add pipefail to catch all errors --- .github/workflows/pr_integration_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index 6c7dd6096d..8240cdf5c2 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -295,6 +295,7 @@ jobs: echo "DO_WORKERS=$DO_WORKERS" >> $GITHUB_ENV - name: Run integration tests for ${{ matrix.provider }} provider + shell: bash -eo pipefail {0} run: |- # echo "END: Running tests 0 to $END" From b68b682316a3cadc22216be1fdb4d041a8f6c8f4 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Sun, 24 May 2026 11:19:15 -0400 Subject: [PATCH 11/15] CLOUDFLAREAPI: Reduce integration tests to match the free tier (#4325) CC @tresni @allixsenos @jzhang-sre # Issue Some tests fail because they use features that are not available in the "free tier" account we use for testing. # Resolution Reduce the tests to avoid them. The following features will no longer be regularly tested: * CNAME flattening * Tags * "Single redirects" that use the "Matches" subcommand NOTE: Would you like these features to be tested regularly? We'd gladly accept the donation to enable us to buy a "business plan"-level account. (Cloudflare, if you're listening, we'd love a partnership!) --- .github/workflows/pr_integration_tests.yml | 8 +--- documentation/provider/cloudflareapi.md | 4 ++ integrationTest/helpers_integration_test.go | 42 ++++++++++----------- integrationTest/helpers_test.go | 2 +- integrationTest/integration_test.go | 15 +++++--- 5 files changed, 37 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index 8240cdf5c2..9bee5fe642 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -259,7 +259,6 @@ jobs: run: | END=3 echo "END: Default set to $END" - DO_WORKERS=false # Only for PRs if [[ "${{ github.event_name }}" == "pull_request" ]]; then @@ -268,7 +267,6 @@ jobs: if [[ " $LABELS " =~ " longtest " ]]; then END=999 echo "END: Pull request + longtest label. END=$END" - DO_WORKERS=true else echo "END: longtest not set. No change." fi @@ -283,7 +281,6 @@ jobs: if [[ "${{ github.ref_name }}" == "main" ]]; then END=999 echo "END: github.ref_name is main. END=$END" - DO_WORKERS=true fi # VERCEL it limited to 100 updates per hour. Therefore we never run more than the first few tests. if [[ "${{ matrix.provider }}" == "VERCEL" ]]; then @@ -292,7 +289,6 @@ jobs: fi echo "END: Final value: END=$END" echo "END=$END" >> $GITHUB_ENV - echo "DO_WORKERS=$DO_WORKERS" >> $GITHUB_ENV - name: Run integration tests for ${{ matrix.provider }} provider shell: bash -eo pipefail {0} @@ -307,8 +303,8 @@ jobs: -timeout 40m -v \ -verbose \ -profile ${{ matrix.provider }} \ - -cfworkers=$DO_WORKERS \ - -cfredirect=false \ + -cfworkers=true \ + -cfredirect=true \ -cfflatten=false \ -cftags=false \ -end $END \ diff --git a/documentation/provider/cloudflareapi.md b/documentation/provider/cloudflareapi.md index cece74799b..5ea737678d 100644 --- a/documentation/provider/cloudflareapi.md +++ b/documentation/provider/cloudflareapi.md @@ -2,6 +2,10 @@ This is the provider for [Cloudflare](https://www.cloudflare.com/). ## Important notes +* The following features are not regularly tested: (free tier accounts don't support these features. Contact the project if you'd like to sponsor a higher tier) + * CNAME flattening + * Tags + * "Single redirects" that use the "Matches" subcommand * SPF records are silently converted to RecordType `TXT` as Cloudflare API fails otherwise. See [DNSControl/dnscontrol#446](https://github.com/DNSControl/dnscontrol/issues/446). * This provider currently fails if there are more than 1000 corrections on one domain. This only affects "push". This usually when moving a domain with many records to Cloudflare. Try commenting out most records, then uncomment groups of 999. Typical updates are less than 1000 corrections and will not trigger this bug. See [DNSControl/dnscontrol#1440](https://github.com/DNSControl/dnscontrol/issues/1440). * DNS records that Cloudflare injects and maintains are ignored. That includes SOA records, NS records at the domain's apex, and the MX/DKIM records created as part of Cloudflare mail routing. diff --git a/integrationTest/helpers_integration_test.go b/integrationTest/helpers_integration_test.go index 46cb9dc6c6..79f6ffc076 100644 --- a/integrationTest/helpers_integration_test.go +++ b/integrationTest/helpers_integration_test.go @@ -448,27 +448,27 @@ func bunnyPullZone(name, pullZoneID string) *models.RecordConfig { return makeRec(name, pullZoneID, "BUNNY_DNS_PZ") } -func cfRedir(pattern, target string) *models.RecordConfig { - rec, err := rtypecontrol.NewRecordConfigFromRaw(rtypecontrol.FromRawOpts{ - Type: "CF_REDIRECT", - TTL: 1, - Args: []any{pattern, target}, - DCN: globalDCN, - }) - panicOnErr(err) - return rec -} - -func cfRedirTemp(pattern, target string) *models.RecordConfig { - rec, err := rtypecontrol.NewRecordConfigFromRaw(rtypecontrol.FromRawOpts{ - Type: "CF_TEMP_REDIRECT", - TTL: 1, - Args: []any{pattern, target}, - DCN: globalDCN, - }) - panicOnErr(err) - return rec -} +// func cfRedir(pattern, target string) *models.RecordConfig { +// rec, err := rtypecontrol.NewRecordConfigFromRaw(rtypecontrol.FromRawOpts{ +// Type: "CF_REDIRECT", +// TTL: 1, +// Args: []any{pattern, target}, +// DCN: globalDCN, +// }) +// panicOnErr(err) +// return rec +// } + +// func cfRedirTemp(pattern, target string) *models.RecordConfig { +// rec, err := rtypecontrol.NewRecordConfigFromRaw(rtypecontrol.FromRawOpts{ +// Type: "CF_TEMP_REDIRECT", +// TTL: 1, +// Args: []any{pattern, target}, +// DCN: globalDCN, +// }) +// panicOnErr(err) +// return rec +// } func aghAPassthrough(pattern, target string) *models.RecordConfig { r := makeRec(pattern, target, "ADGUARDHOME_A_PASSTHROUGH") diff --git a/integrationTest/helpers_test.go b/integrationTest/helpers_test.go index ead9caf579..2b7fee96ca 100644 --- a/integrationTest/helpers_test.go +++ b/integrationTest/helpers_test.go @@ -17,7 +17,7 @@ import ( var ( providerFlag = flag.String("provider", "", "Provider to run (if empty, deduced from -profile)") profileFlag = flag.String("profile", "", "Entry in profiles.json to use (if empty, copied from -provider)") - enableCFWorkers = flag.Bool("cfworkers", true, "enable CF worker tests (default true)") + enableCFWorkers = flag.Bool("cfworkers", false, "enable CF worker tests (default false)") enableCFRedirectMode = flag.Bool("cfredirect", false, "enable CF SingleRedirect tests (default false)") enableCFFlatten = flag.Bool("cfflatten", false, "enable CF CNAME flattening tests (requires paid plan, default false)") enableCFTags = flag.Bool("cftags", false, "enable CF tag tests (requires paid plan, default false)") diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index a33d8b1a71..3517aab4d9 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -1231,12 +1231,15 @@ func makeTests() []*TestGroup { // go test -v -verbose -profile CLOUDFLAREAPI -cfredirect=true // Convert: Test Single Redirects - testgroup("CF_REDIRECT_CONVERT", - only("CLOUDFLAREAPI"), - alltrue(cfSingleRedirectEnabled()), - tc("start301", cfRedir("cnn.**current-domain**/*", "https://www.cnn.com/$1")), - tc("convert302", cfRedirTemp("cnn.**current-domain**/*", "https://www.cnn.com/$1")), - ), + // This test is commented out because of this error: + // "helpers_integration_test.go:241: not entitled: the use of operator Matches is not allowed, a Business plan or a WAF Advanced plan is required" + // There's no obvious way to have this test only run when a Business plan is used. + // testgroup("CF_REDIRECT_CONVERT", + // only("CLOUDFLAREAPI"), + // alltrue(cfSingleRedirectEnabled()), + // tc("start301", cfRedir("cnn.**current-domain**/*", "https://www.cnn.com/$1")), + // tc("convert302", cfRedirTemp("cnn.**current-domain**/*", "https://www.cnn.com/$1")), + // ), testgroup("CLOUDFLAREAPI_SINGLE_REDIRECT", only("CLOUDFLAREAPI"), From 52cbb6e3f4af062046b49cac2a099d705b89d0b8 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Sun, 24 May 2026 12:59:48 -0400 Subject: [PATCH 12/15] BUGFIX: ech=s (#4324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Issue Bug: https://github.com/DNSControl/dnscontrol/issues/3804 **Describe the bug** When using `HTTPS("…", 1, ".", "alpn=h3 … ech=IGNORE")`, but ech has not yet been set by the owner, then dnscontrol keeps saying it needs to change it … by adding a space that isn't there every run… **To Reproduce** 1. Create a HTTPS record with [`ech=IGNORE`](https://docs.dnscontrol.org/language-reference/domain-modifiers/https) - `+ CREATE ccccc.example.com HTTPS 1 . alpn="h3" ttl=1` 3. run dnscontrol again - `± MODIFY ccccc.example.com HTTPS (1 . alpn="h3" ttl=1) -> (1 . alpn="h3" ttl=1) id=X` 5. Observe changes, every, run… - `± MODIFY ccccc.example.com HTTPS (1 . alpn="h3" ttl=1) -> (1 . alpn="h3" ttl=1) id=X` **Expected behavior** 1. `+ CREATE ccccc.example.com HTTPS 1 . alpn="h3" ttl=1` - observe no double space between value and ttl 2. re-run - observe no change to record **DNS Provider** - Cloudflare **Additional context** The ECH field needs to be managed by whoever manages the certs (probably the webserver), and when making these records but before first use means it doesn't have a value. Note that this issue was missed in testing because [ech has a value](https://github.com/StackExchange/dnscontrol/blob/6ef064877859f3dbc096112c8896160af9e91e3f/integrationTest/integration_test.go#L296) there… # Resolution Remove the quotes in that one situation. --- pkg/diff2/analyze.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/diff2/analyze.go b/pkg/diff2/analyze.go index 8dee29d84e..93fd4ee1f7 100644 --- a/pkg/diff2/analyze.go +++ b/pkg/diff2/analyze.go @@ -267,7 +267,7 @@ func diffTargets(existing, desired []targetConfig) ChangeList { var unquoted, quoted string if _, ok := echs[v.rec.NameFQDN]; ok { unquoted = fmt.Sprintf("ech=%s", echs[v.rec.NameFQDN]) - quoted = fmt.Sprintf("ech=%q", echs[v.rec.NameFQDN]) + quoted = fmt.Sprintf("ech=%s", echs[v.rec.NameFQDN]) } else { unquoted = "" quoted = "" From 4e2a923331caada04b94856f35ac064fa141e230 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Mon, 25 May 2026 08:24:00 -0400 Subject: [PATCH 13/15] CICD: Skip failing HEDNS test (#4329) # Issue HEDNS fails the `testgroup("Ech")`, test. # Resolution Skip this test for now. Bug https://github.com/DNSControl/dnscontrol/issues/4328 opened to track this. --- integrationTest/integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index 3517aab4d9..fdb1f4ee76 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -317,6 +317,7 @@ func makeTests() []*TestGroup { // // Let's just ignore ECH test for Vercel for now. "VERCEL", + "HEDNS", // BUG: https://github.com/DNSControl/dnscontrol/issues/4328 ), tc("Create a HTTPS record", https("@", 1, "example.com.", "alpn=h2,h3")), tc("Add an ECH key", https("@", 1, "example.com.", "alpn=h2,h3 ech=some+base64+encoded+value///")), From 92e8c82c274b5ca98c73bc890996f5f02237d89a Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 26 May 2026 20:03:49 -0400 Subject: [PATCH 14/15] m --- commands/getZones.go | 2 +- commands/init.go | 2 +- integrationTest/helpers_integration_test.go | 30 ++++ integrationTest/provider_test.go | 3 + models/t_svcb.go | 1 + pkg/diff2/externaldns.go | 2 +- pkg/rtype/ds.go | 1 + pkg/rtypecontrol/fixlegacy.go | 185 ++++++++++---------- pkg/zonerecs/zonerecords.go | 2 +- providers/bind/bindProvider.go | 2 + providers/bind/soa.go | 4 + 11 files changed, 137 insertions(+), 97 deletions(-) diff --git a/commands/getZones.go b/commands/getZones.go index 7fc5d11fd4..77c00db9f8 100644 --- a/commands/getZones.go +++ b/commands/getZones.go @@ -220,7 +220,7 @@ func GetZone(args GetZoneArgs) error { if err != nil { return fmt.Errorf("failed GetZone gzr: %w", err) } - rtypecontrol.FixLegacyRecords(&recs) // Call this after GetZoneRecords() to fix providers that haven't been updated for RecordConfigV2. + rtypecontrol.FixLegacyRecords(&recs, zone) // Call this after GetZoneRecords() to fix providers that haven't been updated for RecordConfigV2. zoneRecs[i] = recs } diff --git a/commands/init.go b/commands/init.go index df31ecc0a9..979b0ea78b 100644 --- a/commands/init.go +++ b/commands/init.go @@ -322,7 +322,7 @@ func fetchZoneRecordsReal(entry InitCredsEntry, zone string) (models.Records, er if err != nil { return nil, err } - rtypecontrol.FixLegacyRecords(&recs) + rtypecontrol.FixLegacyRecords(&recs, zone) return recs, nil } diff --git a/integrationTest/helpers_integration_test.go b/integrationTest/helpers_integration_test.go index 79f6ffc076..465693b7b9 100644 --- a/integrationTest/helpers_integration_test.go +++ b/integrationTest/helpers_integration_test.go @@ -14,6 +14,7 @@ import ( "time" dnsv2 "codeberg.org/miekg/dns" + dnsrdatav2 "codeberg.org/miekg/dns/rdata" "github.com/DNSControl/dnscontrol/v4/models" "github.com/DNSControl/dnscontrol/v4/pkg/domaintags" "github.com/DNSControl/dnscontrol/v4/pkg/nameservers" @@ -577,6 +578,21 @@ func makeRec(name, target, typ string) *models.RecordConfig { } SetLabel(r, name, "**current-domain**.") r.MustSetTarget(target) + + r.FixUp(globalDCN.NameASCII) // Populates .RDATA and .TypeNum if needed. + // // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + // switch typ { + // case "CNAME": + // r.RDATA = &dnsrdatav2.CNAME{Target: r.GetTargetField()} + // r.ComparableV3 = r.RDATA.String() + // case "NS": + // r.RDATA = &dnsrdatav2.NS{Ns: r.GetTargetField()} + // r.ComparableV3 = r.RDATA.String() + // case "DS": + // r.RDATA = &dnsrdatav2.DS{KeyTag: r.DsKeyTag, Algorithm: r.DsAlgorithm, DigestType: r.DsDigestType, Digest: r.DsDigest} + // r.ComparableV3 = r.RDATA.String() + // } + return r } @@ -652,6 +668,20 @@ func smimea(name string, usage, selector, matchingtype uint8, target string) *mo func soa(name string, ns, mbox string, serial, refresh, retry, expire, minttl uint32) *models.RecordConfig { r := makeRec(name, "", "SOA") panicOnErr(r.SetTargetSOA(ns, mbox, serial, refresh, retry, expire, minttl)) + + // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + r.RDATA = &dnsrdatav2.SOA{ + Ns: ns, + Mbox: mbox, + Serial: serial, + Refresh: refresh, + Retry: retry, + Expire: expire, + Minttl: minttl, + } + r.TypeNum = dnsv2.TypeSOA + r.ComparableV3 = fmt.Sprintf("%s %s %d %d %d %d %d", ns, mbox, serial, refresh, retry, expire, minttl) + return r } diff --git a/integrationTest/provider_test.go b/integrationTest/provider_test.go index 220d520fe9..858d3ddc2f 100644 --- a/integrationTest/provider_test.go +++ b/integrationTest/provider_test.go @@ -58,6 +58,9 @@ func TestDualProviders(t *testing.T) { run() // run again to make sure no corrections t.Log("Running again to ensure stability") + for _, r := range dc.Records { + r.FixUp(dc.Name) + } rs, cs, actualChangeCount, err := zonerecs.CorrectZoneRecords(p, dc) if err != nil { t.Fatal(err) diff --git a/models/t_svcb.go b/models/t_svcb.go index 5932387415..e55991690a 100644 --- a/models/t_svcb.go +++ b/models/t_svcb.go @@ -41,6 +41,7 @@ func (rc *RecordConfig) SetTargetSVCB(priority uint16, target string, params []d return fmt.Errorf("failed to convert SVCB parameters from v1 to v2: %w", err) } rc.RDATA = dnsrdatav2.SVCB{Priority: rc.SvcPriority, Target: target, Value: valuev2} + rc.ComparableV3 = rc.RDATA.String() return nil } diff --git a/pkg/diff2/externaldns.go b/pkg/diff2/externaldns.go index 72ba61249d..b8e5c06a40 100644 --- a/pkg/diff2/externaldns.go +++ b/pkg/diff2/externaldns.go @@ -33,7 +33,7 @@ type externalDNSManagedRecord struct { // isExternalDNSTxtRecord checks if a TXT record is an external-dns ownership record. // It returns true and the managed record info if it is, false otherwise. // customPrefix is an optional prefix that external-dns was configured with (e.g., "extdns-"). -func isExternalDNSTxtRecord(rec *models.RecordConfig, domain string, customPrefix string) (bool, *externalDNSManagedRecord) { +func isExternalDNSTxtRecord(rec *models.RecordConfig, _ string, customPrefix string) (bool, *externalDNSManagedRecord) { if rec.Type != "TXT" { return false, nil } diff --git a/pkg/rtype/ds.go b/pkg/rtype/ds.go index 5986b32158..d3b81096b4 100644 --- a/pkg/rtype/ds.go +++ b/pkg/rtype/ds.go @@ -60,6 +60,7 @@ func (handle *DS) FromStruct(dcn *domaintags.DomainNameVarieties, rec *models.Re default: panic(fmt.Sprintf("unexpected type for DS.FromStruct: %T", rec.F)) } + rec.ComparableV3 = rec.RDATA.String() rec.ZonefilePartial = rec.GetTargetRFC1035Quoted() rec.Comparable = rec.ZonefilePartial diff --git a/pkg/rtypecontrol/fixlegacy.go b/pkg/rtypecontrol/fixlegacy.go index f6e5673ff3..2449270904 100644 --- a/pkg/rtypecontrol/fixlegacy.go +++ b/pkg/rtypecontrol/fixlegacy.go @@ -1,10 +1,6 @@ package rtypecontrol import ( - "fmt" - - dnsutilv2 "codeberg.org/miekg/dns/dnsutil" - dnsrdatav2 "codeberg.org/miekg/dns/rdata" "github.com/DNSControl/dnscontrol/v4/models" ) @@ -14,7 +10,7 @@ import ( // called. Those functions can't call it directly because that would cause an // import cycle. func FixLegacyDC(dc *models.DomainConfig) { - FixLegacyRecords(&dc.Records) + FixLegacyRecords(&dc.Records, dc.Name) } // FixLegacyRecords populates .F to compenstate for providers that have not been @@ -22,15 +18,16 @@ func FixLegacyDC(dc *models.DomainConfig) { // It is called anywhere provider.GetZoneRecords() is called. GetZoneRecords() // can't call it directly because that would involve modifying every provider. // Instead, providers should be fixed to generate records properly. -func FixLegacyRecords(recs *models.Records) { +func FixLegacyRecords(recs *models.Records, origin string) { for _, rec := range *recs { - FixLegacyRecord(rec) + FixLegacyRecord(rec, origin) } } // FixLegacyRecord populates .F to compenstate for providers that have not been // updated to support RecordConfigV2 when creating RecordConfig. -func FixLegacyRecord(rec *models.RecordConfig) { +func FixLegacyRecord(rec *models.RecordConfig, origin string) { + //fmt.Printf("DEBUG: FixLegacyRecord for %s %s\n", rec.Type, rec.GetTargetField()) // Populate .F if needed: (legacy) // That is... If rec.F == nil and this is a "modern" type. if rec.F == nil { @@ -39,89 +36,91 @@ func FixLegacyRecord(rec *models.RecordConfig) { } } - // Populate .RDATA if needed: - if rec.RDATA == nil { - - // The .RDATA structure itself. - switch rec.Type { - case "A": - rec.RDATA = dnsrdatav2.A{Addr: rec.GetTargetIP()} - case "AAAA": - rec.RDATA = dnsrdatav2.AAAA{Addr: rec.GetTargetIP()} - - case "CAA": - rec.RDATA = dnsrdatav2.CAA{Flag: rec.CaaFlag, Tag: rec.CaaTag, Value: rec.GetTargetField()} - case "CNAME": - rec.RDATA = dnsrdatav2.CNAME{Target: rec.GetTargetField()} - - case "DHCID": - rec.RDATA = dnsrdatav2.DHCID{Digest: rec.GetTargetField()} - case "DNAME": - rec.RDATA = dnsrdatav2.DNAME{Target: rec.GetTargetField()} - case "DNSKEY": - rec.RDATA = dnsrdatav2.DNSKEY{Flags: rec.DnskeyFlags, Protocol: rec.DnskeyProtocol, Algorithm: rec.DnskeyAlgorithm, PublicKey: rec.GetTargetField()} - case "DS": - // no-op. See pkg/rtype/ds.go:FromStruct. - panic("DS should already be converted to RDATA") - - case "HTTPS": - // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB - panic("HTTPS should already be converted to RDATA") - - case "LOC": - rec.RDATA = dnsrdatav2.LOC{Version: rec.LocVersion, Size: rec.LocSize, HorizPre: rec.LocHorizPre, VertPre: rec.LocVertPre, Latitude: rec.LocLatitude, Longitude: rec.LocLongitude, Altitude: rec.LocAltitude} - - case "MX": - rec.RDATA = dnsrdatav2.MX{Preference: rec.MxPreference, Mx: rec.GetTargetField()} - - case "NS": - rec.RDATA = dnsrdatav2.NS{Ns: rec.GetTargetField()} - case "NAPTR": - rec.RDATA = dnsrdatav2.NAPTR{Order: rec.NaptrOrder, Preference: rec.NaptrPreference, Flags: rec.NaptrFlags, Service: rec.NaptrService, Regexp: rec.NaptrRegexp, Replacement: rec.GetTargetField()} - - case "OPENPGPKEY": - rec.RDATA = dnsrdatav2.OPENPGPKEY{PublicKey: rec.GetTargetField()} - - case "PTR": - rec.RDATA = dnsrdatav2.PTR{Ptr: rec.GetTargetField()} - - case "RP": - // no-op. See pkg/rtype/rp.go:FromStruct. - panic("RP should already be converted to RDATA") - - case "SMIMEA": - rec.RDATA = dnsrdatav2.SMIMEA{Usage: rec.SmimeaUsage, Selector: rec.SmimeaSelector, MatchingType: rec.SmimeaMatchingType, Certificate: rec.GetTargetField()} - case "SOA": - rec.RDATA = dnsrdatav2.SOA{Ns: rec.GetTargetField(), Mbox: rec.SoaMbox, Serial: rec.SoaSerial, Refresh: rec.SoaRefresh, Retry: rec.SoaRetry, Expire: rec.SoaExpire, Minttl: rec.SoaMinttl} - case "SRV": - rec.RDATA = dnsrdatav2.SRV{Priority: rec.SrvPriority, Weight: rec.SrvWeight, Port: rec.SrvPort, Target: rec.GetTargetField()} - case "SSHFP": - rec.RDATA = dnsrdatav2.SSHFP{Algorithm: rec.SshfpAlgorithm, Type: rec.SshfpFingerprint, FingerPrint: rec.GetTargetField()} - - case "TLSA": - rec.RDATA = dnsrdatav2.TLSA{Usage: rec.TlsaUsage, Selector: rec.TlsaSelector, MatchingType: rec.TlsaMatchingType, Certificate: rec.GetTargetField()} - - case "SVCB": - // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB - panic("SVCB should already be converted to RDATA") - - case "TXT": - rec.RDATA = dnsrdatav2.TXT{Txt: []string{rec.GetTargetField()}} - - default: - panic(fmt.Sprintf("RDATA CONVERSION NOT IMPLEMENTED TYPE=%q", rec.Type)) - } - - // TypeNum: - tn, err := dnsutilv2.StringToType(rec.Type) - if err != nil { - panic("fix me") - } - rec.TypeNum = tn - - // Comparable: - rec.ComparableV3 = rec.RDATA.String() - fmt.Printf("DEBUG: COMPARE for %s --- %s\n", rec.Type, rec.ComparableV3) - - } + rec.FixUp(origin) // Populates .RDATA and .TypeNum if needed. + + // // Populate .RDATA if needed: + // if rec.RDATA == nil { + + // // The .RDATA structure itself. + // switch rec.Type { + // case "A": + // rec.RDATA = dnsrdatav2.A{Addr: rec.GetTargetIP()} + // case "AAAA": + // rec.RDATA = dnsrdatav2.AAAA{Addr: rec.GetTargetIP()} + + // case "CAA": + // rec.RDATA = dnsrdatav2.CAA{Flag: rec.CaaFlag, Tag: rec.CaaTag, Value: rec.GetTargetField()} + // case "CNAME": + // rec.RDATA = dnsrdatav2.CNAME{Target: rec.GetTargetField()} + + // case "DHCID": + // rec.RDATA = dnsrdatav2.DHCID{Digest: rec.GetTargetField()} + // case "DNAME": + // rec.RDATA = dnsrdatav2.DNAME{Target: rec.GetTargetField()} + // case "DNSKEY": + // rec.RDATA = dnsrdatav2.DNSKEY{Flags: rec.DnskeyFlags, Protocol: rec.DnskeyProtocol, Algorithm: rec.DnskeyAlgorithm, PublicKey: rec.GetTargetField()} + // case "DS": + // // no-op. See pkg/rtype/ds.go:FromStruct. + // panic("DS should already be converted to RDATA") + + // case "HTTPS": + // // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB + // panic("HTTPS should already be converted to RDATA") + + // case "LOC": + // rec.RDATA = dnsrdatav2.LOC{Version: rec.LocVersion, Size: rec.LocSize, HorizPre: rec.LocHorizPre, VertPre: rec.LocVertPre, Latitude: rec.LocLatitude, Longitude: rec.LocLongitude, Altitude: rec.LocAltitude} + + // case "MX": + // rec.RDATA = dnsrdatav2.MX{Preference: rec.MxPreference, Mx: rec.GetTargetField()} + + // case "NS": + // rec.RDATA = dnsrdatav2.NS{Ns: rec.GetTargetField()} + // case "NAPTR": + // rec.RDATA = dnsrdatav2.NAPTR{Order: rec.NaptrOrder, Preference: rec.NaptrPreference, Flags: rec.NaptrFlags, Service: rec.NaptrService, Regexp: rec.NaptrRegexp, Replacement: rec.GetTargetField()} + + // case "OPENPGPKEY": + // rec.RDATA = dnsrdatav2.OPENPGPKEY{PublicKey: rec.GetTargetField()} + + // case "PTR": + // rec.RDATA = dnsrdatav2.PTR{Ptr: rec.GetTargetField()} + + // case "RP": + // // no-op. See pkg/rtype/rp.go:FromStruct. + // panic("RP should already be converted to RDATA") + + // case "SMIMEA": + // rec.RDATA = dnsrdatav2.SMIMEA{Usage: rec.SmimeaUsage, Selector: rec.SmimeaSelector, MatchingType: rec.SmimeaMatchingType, Certificate: rec.GetTargetField()} + // case "SOA": + // rec.RDATA = dnsrdatav2.SOA{Ns: rec.GetTargetField(), Mbox: rec.SoaMbox, Serial: rec.SoaSerial, Refresh: rec.SoaRefresh, Retry: rec.SoaRetry, Expire: rec.SoaExpire, Minttl: rec.SoaMinttl} + // case "SRV": + // rec.RDATA = dnsrdatav2.SRV{Priority: rec.SrvPriority, Weight: rec.SrvWeight, Port: rec.SrvPort, Target: rec.GetTargetField()} + // case "SSHFP": + // rec.RDATA = dnsrdatav2.SSHFP{Algorithm: rec.SshfpAlgorithm, Type: rec.SshfpFingerprint, FingerPrint: rec.GetTargetField()} + + // case "TLSA": + // rec.RDATA = dnsrdatav2.TLSA{Usage: rec.TlsaUsage, Selector: rec.TlsaSelector, MatchingType: rec.TlsaMatchingType, Certificate: rec.GetTargetField()} + + // case "SVCB": + // // no-op. See pkg/rtype/t_svcb.go:SetTargetSVCB + // panic("SVCB should already be converted to RDATA") + + // case "TXT": + // rec.RDATA = dnsrdatav2.TXT{Txt: []string{rec.GetTargetField()}} + + // default: + // panic(fmt.Sprintf("RDATA CONVERSION NOT IMPLEMENTED TYPE=%q", rec.Type)) + // } + + // // TypeNum: + // tn, err := dnsutilv2.StringToType(rec.Type) + // if err != nil { + // panic("fix me") + // } + // rec.TypeNum = tn + + // // Comparable: + // rec.ComparableV3 = rec.RDATA.String() + // fmt.Printf("DEBUG: COMPARE for %s --- %s\n", rec.Type, rec.ComparableV3) + + //} } diff --git a/pkg/zonerecs/zonerecords.go b/pkg/zonerecs/zonerecords.go index d77ecd1a02..1812d49222 100644 --- a/pkg/zonerecs/zonerecords.go +++ b/pkg/zonerecs/zonerecords.go @@ -13,7 +13,7 @@ func CorrectZoneRecords(driver models.DNSProvider, dc *models.DomainConfig) ([]* if err != nil { return nil, nil, 0, err } - rtypecontrol.FixLegacyRecords(&existingRecords) // Call this after GetZoneRecords() to fix providers that haven't been updated for RecordConfigV2. + rtypecontrol.FixLegacyRecords(&existingRecords, dc.Name) // Call this after GetZoneRecords() to fix providers that haven't been updated for RecordConfigV2. // downcase models.Downcase(existingRecords) diff --git a/providers/bind/bindProvider.go b/providers/bind/bindProvider.go index 9803cb0ae6..6bc24fa55f 100644 --- a/providers/bind/bindProvider.go +++ b/providers/bind/bindProvider.go @@ -268,6 +268,8 @@ func ParseZoneContents(content string, zoneName string, zonefileName string) (mo } } + rec.FixUp(zoneName) // hack + foundRecords = append(foundRecords, &rec) } diff --git a/providers/bind/soa.go b/providers/bind/soa.go index d0b687a19e..bcf9d4ace0 100644 --- a/providers/bind/soa.go +++ b/providers/bind/soa.go @@ -1,6 +1,7 @@ package bind import ( + "fmt" "strings" "github.com/DNSControl/dnscontrol/v4/models" @@ -43,6 +44,9 @@ func makeSoa(origin string, defSoa *SoaDefaults, existing, desired *models.Recor panic(err) // Should never happen. } + // Hack to set .RDATA without importing miekg/dns in pkg/rtypecontrol/fixlegacy.go + soaRec.ComparableV3 = fmt.Sprintf("%s %s %d %d %d %d %d", soaRec.GetTargetField(), soaRec.SoaMbox, soaRec.SoaSerial, soaRec.SoaRefresh, soaRec.SoaRetry, soaRec.SoaExpire, soaRec.SoaMinttl) + return &soaRec, generateSerial(soaRec.SoaSerial) } From 45d6bf4945eecca02944de02a5d83e39c65e2f92 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 26 May 2026 20:04:50 -0400 Subject: [PATCH 15/15] m --- models/fixhack.go | 110 ++++++++++++++++++++++++++++++++++++++ pkg/privatetypes/alias.go | 5 ++ 2 files changed, 115 insertions(+) create mode 100644 models/fixhack.go create mode 100644 pkg/privatetypes/alias.go diff --git a/models/fixhack.go b/models/fixhack.go new file mode 100644 index 0000000000..4e17ddee84 --- /dev/null +++ b/models/fixhack.go @@ -0,0 +1,110 @@ +package models + +import ( + "fmt" + + dnsutilv2 "codeberg.org/miekg/dns/dnsutil" + dnsrdatav2 "codeberg.org/miekg/dns/rdata" + "github.com/DNSControl/dnscontrol/v4/pkg/privatetypes" + dnsv1 "github.com/miekg/dns" + dnsutilv1 "github.com/miekg/dns/dnsutil" +) + +// FixUp populates the "V3 Fields": .TypeNum, .RDATA and .ComparableV3. +func (rc *RecordConfig) FixUp(origin string) { + + // TypeNum: + if rc.TypeNum == 0 && rc.Type != "ALIAS" { + tn, err := dnsutilv2.StringToType(rc.Type) + if err != nil { + panic(fmt.Sprintf("BUG: Unknown type %s", rc.Type)) + } + rc.TypeNum = tn + } + + // Populate .RDATA if needed: + if rc.RDATA == nil { + + switch rc.Type { + case "A": + rc.RDATA = dnsrdatav2.A{Addr: rc.GetTargetIP()} + case "ALIAS": + rc.RDATA = privatetypes.ALIAS{Target: rc.GetTargetField()} + case "AAAA": + rc.RDATA = dnsrdatav2.AAAA{Addr: rc.GetTargetIP()} + + case "CAA": + rc.RDATA = dnsrdatav2.CAA{Flag: rc.CaaFlag, Tag: rc.CaaTag, Value: rc.GetTargetField()} + case "CNAME": + targ := dnsutilv1.AddOrigin(rc.GetTargetField(), origin) + rc.RDATA = dnsrdatav2.CNAME{Target: targ} + case "DHCID": + rc.RDATA = dnsrdatav2.DHCID{Digest: rc.GetTargetField()} + case "DNAME": + rc.RDATA = dnsrdatav2.DNAME{Target: rc.GetTargetField()} + case "DNSKEY": + rc.RDATA = dnsrdatav2.DNSKEY{Flags: rc.DnskeyFlags, Protocol: rc.DnskeyProtocol, Algorithm: rc.DnskeyAlgorithm, PublicKey: rc.GetTargetField()} + case "DS": + rc.RDATA = dnsrdatav2.DS{KeyTag: rc.DsKeyTag, Algorithm: rc.DsAlgorithm, DigestType: rc.DsDigestType, Digest: rc.GetTargetField()} + + case "HTTPS": + valuev2, err := convertSVCBv1v2(rc.GetSVCBValue()) + if err != nil { + panic("BUG: Failed to convert SVCB value to v2: " + err.Error()) + } + rc.RDATA = dnsrdatav2.SVCB{Priority: rc.SvcPriority, Target: rc.GetTargetField(), Value: valuev2} + rc.ComparableV3 = rc.RDATA.String() + + case "LOC": + rc.RDATA = dnsrdatav2.LOC{Version: rc.LocVersion, Size: rc.LocSize, HorizPre: rc.LocHorizPre, VertPre: rc.LocVertPre, Latitude: rc.LocLatitude, Longitude: rc.LocLongitude, Altitude: rc.LocAltitude} + + case "MX": + rc.RDATA = dnsrdatav2.MX{Preference: rc.MxPreference, Mx: rc.GetTargetField()} + + case "NS": + rc.RDATA = dnsrdatav2.NS{Ns: rc.GetTargetField()} + case "NAPTR": + rc.RDATA = dnsrdatav2.NAPTR{Order: rc.NaptrOrder, Preference: rc.NaptrPreference, Flags: rc.NaptrFlags, Service: rc.NaptrService, Regexp: rc.NaptrRegexp, Replacement: rc.GetTargetField()} + + case "OPENPGPKEY": + rc.RDATA = dnsrdatav2.OPENPGPKEY{PublicKey: rc.GetTargetField()} + + case "PTR": + rc.RDATA = dnsrdatav2.PTR{Ptr: rc.GetTargetField()} + + case "RP": + rc.RDATA = dnsrdatav2.RP{Mbox: rc.F.(dnsv1.RP).Mbox, Txt: rc.F.(dnsv1.RP).Txt} + + case "SMIMEA": + rc.RDATA = dnsrdatav2.SMIMEA{Usage: rc.SmimeaUsage, Selector: rc.SmimeaSelector, MatchingType: rc.SmimeaMatchingType, Certificate: rc.GetTargetField()} + case "SOA": + rc.RDATA = dnsrdatav2.SOA{Ns: rc.GetTargetField(), Mbox: rc.SoaMbox, Serial: rc.SoaSerial, Refresh: rc.SoaRefresh, Retry: rc.SoaRetry, Expire: rc.SoaExpire, Minttl: rc.SoaMinttl} + case "SRV": + rc.RDATA = dnsrdatav2.SRV{Priority: rc.SrvPriority, Weight: rc.SrvWeight, Port: rc.SrvPort, Target: rc.GetTargetField()} + case "SSHFP": + rc.RDATA = dnsrdatav2.SSHFP{Algorithm: rc.SshfpAlgorithm, Type: rc.SshfpFingerprint, FingerPrint: rc.GetTargetField()} + case "SVCB": + valuev2, err := convertSVCBv1v2(rc.GetSVCBValue()) + if err != nil { + panic("BUG: Failed to convert SVCB value to v2: " + err.Error()) + } + rc.RDATA = dnsrdatav2.SVCB{Priority: rc.SvcPriority, Target: rc.GetTargetField(), Value: valuev2} + rc.ComparableV3 = rc.RDATA.String() + + case "TLSA": + rc.RDATA = dnsrdatav2.TLSA{Usage: rc.TlsaUsage, Selector: rc.TlsaSelector, MatchingType: rc.TlsaMatchingType, Certificate: rc.GetTargetField()} + + case "TXT": + rc.RDATA = dnsrdatav2.TXT{Txt: []string{rc.GetTargetField()}} + + default: + panic(fmt.Sprintf("RDATA CONVERSION NOT IMPLEMENTED TYPE=%q", rc.Type)) + } + } + + // .ComparableV3: + if rc.ComparableV3 == "" { + rc.ComparableV3 = rc.RDATA.String() + //fmt.Printf("DEBUG: COMPARE for %s --- %s\n", rc.Type, rc.ComparableV3) + } +} diff --git a/pkg/privatetypes/alias.go b/pkg/privatetypes/alias.go new file mode 100644 index 0000000000..2237fd2b84 --- /dev/null +++ b/pkg/privatetypes/alias.go @@ -0,0 +1,5 @@ +package privatetypes + +type ALIAS = struct { + Target string +}