Skip to content

Commit 4ea0ad4

Browse files
CNR: add support for DNAME, DHCID, and SVCB record types (#4077)
<!-- ## Before submiting a pull request Please make sure you've run the following commands from the root directory. bin/generate-all.sh (this runs commands like "go generate", fixes formatting, and so on) ## Release changelog section Help keep the release changelog clear by pre-naming the proper section in the GitHub pull request title. Some examples: * CICD: Add required GHA permissions for goreleaser * DOCS: Fixed providers with "contributor support" table * ROUTE53: Allow R53_ALIAS records to enable target health evaluation More examples/context can be found in the file .goreleaser.yml under the 'build' > 'changelog' key. !-->
1 parent 878229a commit 4ea0ad4

File tree

4 files changed

+61
-26
lines changed

4 files changed

+61
-26
lines changed

documentation/provider/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ Jump to a table:
169169
| [`BUNNY_DNS`](bunnydns.md) ||||||
170170
| [`CLOUDFLAREAPI`](cloudflareapi.md) ||||||
171171
| [`CLOUDNS`](cloudns.md) ||||||
172-
| [`CNR`](cnr.md) || | |||
172+
| [`CNR`](cnr.md) || | |||
173173
| [`DESEC`](desec.md) ||||||
174174
| [`DIGITALOCEAN`](digitalocean.md) ||||||
175175
| [`DNSCALE`](dnscale.md) ||||||
@@ -229,7 +229,7 @@ Jump to a table:
229229
| [`BUNNY_DNS`](bunnydns.md) |||||
230230
| [`CLOUDFLAREAPI`](cloudflareapi.md) |||||
231231
| [`CLOUDNS`](cloudns.md) |||||
232-
| [`CNR`](cnr.md) | ||| |
232+
| [`CNR`](cnr.md) | ||| |
233233
| [`CSCGLOBAL`](cscglobal.md) |||||
234234
| [`DESEC`](desec.md) |||||
235235
| [`DIGITALOCEAN`](digitalocean.md) |||||

providers/cnr/auditrecords.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cnr
22

33
import (
4+
"errors"
5+
"strings"
6+
47
"github.com/StackExchange/dnscontrol/v4/models"
58
"github.com/StackExchange/dnscontrol/v4/pkg/rejectif"
69
)
@@ -17,5 +20,17 @@ func AuditRecords(records []*models.RecordConfig) []error {
1720

1821
a.Add("SRV", rejectif.SrvHasNullTarget) // Last verified 2020-12-28
1922

23+
a.Add("DNAME", dnameHasWildcardLabel) // Last verified 2026-02-10
24+
2025
return a.Audit(records)
2126
}
27+
28+
// dnameHasWildcardLabel detects DNAME records with wildcard labels.
29+
// Wildcard DNAME records are not allowed per RFC 4592 Section 4.4.
30+
func dnameHasWildcardLabel(rc *models.RecordConfig) error {
31+
label := rc.GetLabel()
32+
if label == "*" || strings.HasPrefix(label, "*.") || strings.HasSuffix(label, ".*") || strings.Contains(label, ".*.") {
33+
return errors.New("DNAME records with wildcard labels are not supported.")
34+
}
35+
return nil
36+
}

providers/cnr/cnrProvider.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,20 @@ var features = providers.DocumentationNotes{
3636
providers.CanUseAlias: providers.Can(),
3737
// providers.CanUseAzureAlias: providers.Cannot(), // can only be supported by Azure provider
3838
providers.CanUseCAA: providers.Can(),
39-
providers.CanUseDHCID: providers.Cannot("Ask for this feature."),
40-
providers.CanUseDNAME: providers.Cannot("Ask for this feature."),
39+
providers.CanUseDHCID: providers.Can(),
40+
providers.CanUseDNAME: providers.Can(),
4141
providers.CanUseDNSKEY: providers.Unimplemented("Ask for this feature."),
4242
providers.CanUseDS: providers.Unimplemented("Ask for this feature."),
4343
providers.CanUseDSForChildren: providers.Unimplemented("Ask for this feature."), // CanUseDS implies CanUseDSForChildren
4444
providers.CanUseHTTPS: providers.Cannot("Managed via (Query|Add|Modify|Delete)WebFwd API call. Data not accessible via the resource records list. Hard to integrate this into DNSControl by that."),
45-
providers.CanUseLOC: providers.Cannot("Ask for this feature."),
45+
providers.CanUseLOC: providers.Can(),
4646
providers.CanUseNAPTR: providers.Can(),
4747
providers.CanUsePTR: providers.Can(),
4848
// providers.CanUseRoute53Alias: providers.Cannot(), // can only be supported by AWS Route53 provider
4949
providers.CanUseSOA: providers.Cannot("The SOA record is managed on the DNSZone directly. Data only accessible via StatusDNSZone Request, not via the resource records list. Hard to integrate this into DNSControl by that."), // supported by bind, honstingde
5050
providers.CanUseSRV: providers.Can("SRV records with empty targets are not supported"),
5151
providers.CanUseSSHFP: providers.Can(),
52-
providers.CanUseSVCB: providers.Cannot("Ask for this feature."),
52+
providers.CanUseSVCB: providers.Can(),
5353
providers.CanUseTLSA: providers.Can(),
5454
}
5555

providers/cnr/records.go

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77
"maps"
88
"os"
9-
"regexp"
109
"slices"
1110
"strconv"
1211
"strings"
@@ -16,6 +15,13 @@ import (
1615
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
1716
)
1817

18+
// dotSuffixTypes lists record types whose content requires a trailing dot
19+
// to be appended when returned by the API without one.
20+
var dotSuffixTypes = map[string]bool{
21+
"ALIAS": true, "CNAME": true, "DNAME": true,
22+
"MX": true, "NS": true, "SRV": true, "PTR": true,
23+
}
24+
1925
// Record covers an individual DNS resource record.
2026
type Record struct {
2127
// DomainName is the zone that the record belongs to.
@@ -26,7 +32,7 @@ type Record struct {
2632
Host string
2733
// FQDN is the Fully Qualified Domain Name. It is the combination of the host and the domain name. It always ends in a ".". FQDN is ignored in CreateRecord, specify via the Host field instead.
2834
Fqdn string
29-
// Type is one of the following: A, AAAA, ANAME, ALIAS, CNAME, MX, NS, SRV, or TXT.
35+
// Type is the DNS record type (e.g. A, AAAA, CNAME, MX, LOC, SVCB, etc.).
3036
Type string
3137
// Answer is either the IP address for A or AAAA records; the target for ANAME, CNAME, MX, or NS records; the text for TXT records.
3238
// For SRV records, answer has the following format: "{weight} {port} {target}" e.g. "1 5061 sip.example.org".
@@ -152,6 +158,12 @@ func toRecord(r *Record, origin string) *models.RecordConfig {
152158
panic(fmt.Errorf("unparsable SRV record received from centralnic reseller API: %w", err))
153159
}
154160
}
161+
case "LOC", "SVCB":
162+
// SetTargetLOCString and SetTargetSVCBString internally format as "%s. TYPE %s",
163+
// so we strip the trailing dot from r.Fqdn to avoid a double dot.
164+
if err := rc.PopulateFromStringFunc(r.Type, r.Answer, strings.TrimSuffix(r.Fqdn, "."), txtutil.ParseQuoted); err != nil {
165+
panic(fmt.Errorf("unparsable %s record received from centralnic reseller API: %w", r.Type, err))
166+
}
155167
default: // "A", "AAAA", "ANAME", "ALIAS", "CNAME", "NS", "TXT", "CAA", "TLSA", "PTR"
156168
if err := rc.PopulateFromStringFunc(r.Type, r.Answer, fqdn, txtutil.ParseQuoted); err != nil {
157169
panic(fmt.Errorf("unparsable record received from centralnic reseller API: %w", err))
@@ -175,7 +187,7 @@ func (n *Client) updateZoneBy(params map[string]any, domain string) error {
175187
return nil
176188
}
177189

178-
// deleteRecordString constructs the record string based on the provided Record.
190+
// getRecords queries the API for all resource records of a zone.
179191
func (n *Client) getRecords(domain string) ([]*Record, error) {
180192
var records []*Record
181193

@@ -237,14 +249,9 @@ func (n *Client) getRecords(domain string) ([]*Record, error) {
237249
// Parse the TTL string to an unsigned integer
238250
priority, _ := strconv.ParseUint(data["PRIO"], 10, 32)
239251

240-
// Add dot to Answer if supported by the record type
241-
pattern := `^ALIAS|CNAME|MX|NS|SRV|PTR$`
242-
re, err := regexp.Compile(pattern)
243-
if err != nil {
244-
return nil, fmt.Errorf("error compiling regex in getRecords: %w", err)
245-
}
246-
if re.MatchString(data["TYPE"]) && !strings.HasSuffix(data["CONTENT"], ".") {
247-
data["CONTENT"] = data["CONTENT"] + "."
252+
// Add trailing dot to Answer for record types that require it
253+
if dotSuffixTypes[data["TYPE"]] && !strings.HasSuffix(data["CONTENT"], ".") {
254+
data["CONTENT"] += "."
248255
}
249256

250257
// Only append domain if it's not already a fully qualified domain name
@@ -279,21 +286,29 @@ func (n *Client) createRecordString(rc *models.RecordConfig, domain string) (str
279286
answer := ""
280287

281288
switch rc.Type { // #rtype_variations
282-
case "A", "AAAA", "ANAME", "ALIAS", "CNAME", "MX", "NS", "PTR":
289+
case "A", "AAAA", "ANAME", "ALIAS", "CNAME", "DHCID", "DNAME", "MX", "NS", "PTR":
283290
answer = rc.GetTargetField()
284-
if domain == host {
285-
host = host + "."
291+
case "LOC":
292+
// Use GetTargetCombined() which returns the properly formatted LOC string
293+
// via the dns library (e.g. "52 14 5.000 N 000 08 50.000 E 10.00m 0.00m 0.00m 0.00m")
294+
parts := strings.Fields(rc.GetTargetCombined())
295+
altitude, _ := strconv.ParseFloat(strings.TrimSuffix(parts[8], "m"), 64)
296+
size, _ := strconv.ParseFloat(strings.TrimSuffix(parts[9], "m"), 64)
297+
hp, _ := strconv.ParseFloat(strings.TrimSuffix(parts[10], "m"), 64)
298+
vp, _ := strconv.ParseFloat(strings.TrimSuffix(parts[11], "m"), 64)
299+
answer = fmt.Sprintf("%s %s %s %s %s %s %s %s %.2fm %.2fm %.2fm %.2fm",
300+
parts[0], parts[1], parts[2], parts[3],
301+
parts[4], parts[5], parts[6], parts[7],
302+
altitude, size, hp, vp)
303+
case "SVCB":
304+
answer = fmt.Sprintf("%d %s", rc.SvcPriority, rc.GetTargetField())
305+
if rc.SvcParams != "" {
306+
answer += " " + rc.SvcParams
286307
}
287308
case "SSHFP":
288309
answer = fmt.Sprintf(`%v %v %s`, rc.SshfpAlgorithm, rc.SshfpFingerprint, rc.GetTargetField())
289-
if domain == host {
290-
host = host + "."
291-
}
292310
case "NAPTR":
293311
answer = fmt.Sprintf(`%v %v "%v" "%v" "%v" %v`, rc.NaptrOrder, rc.NaptrPreference, rc.NaptrFlags, rc.NaptrService, rc.NaptrRegexp, rc.GetTargetField())
294-
if domain == host {
295-
host = host + "."
296-
}
297312
case "TLSA":
298313
answer = fmt.Sprintf(`%v %v %v %s`, rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType, rc.GetTargetField())
299314
case "CAA":
@@ -313,6 +328,11 @@ func (n *Client) createRecordString(rc *models.RecordConfig, domain string) (str
313328
// that have not been updated for a new RR type.
314329
}
315330

331+
// Apex records need a trailing dot on the host to avoid ambiguity
332+
if domain == host {
333+
host += "."
334+
}
335+
316336
str := host + " " + strconv.FormatUint(uint64(rc.TTL), 10) + " "
317337

318338
if rc.Type != "NS" { // TODO

0 commit comments

Comments
 (0)