diff --git a/documentation/provider/linode.md b/documentation/provider/linode.md index 7a2510eddd..02573408b0 100644 --- a/documentation/provider/linode.md +++ b/documentation/provider/linode.md @@ -17,9 +17,11 @@ Example: {% endcode %} ## Metadata + This provider does not recognize any special metadata fields unique to Linode. ## Usage + An example configuration: {% code title="dnsconfig.js" %} @@ -34,9 +36,11 @@ D("example.com", REG_NONE, DnsProvider(DSP_LINODE), {% endcode %} ## Activation + [Create Personal Access Token](https://cloud.linode.com/profile/tokens) ## Caveats + Linode does not allow all TTLs, but only a specific subset of TTLs. The following TTLs are supported ([source](https://www.linode.com/docs/api/domains/#domains-list__responses)): @@ -64,7 +68,13 @@ the _default TTL_. The provider will automatically round up your TTL to one of these values. For example, 600 seconds would become 3600 seconds, but 300 seconds would stay 300 seconds. -Linode requires [`SRV`](../language-reference/domain-modifiers/SRV.md) records to have a non-zero priority. +Linode validates labels of [`SRV`](../language-reference/domain-modifiers/SRV.md) +records more strictly than DNSControl, only permitting certain service names +and protocols. Most issues will be caught at `check` and `preview` but certain +errors will not be caught until DNSControl `push` as they rely on the API +rejecting the update. Also be aware that some invalid service names are +silently corrected by Linode, in which case every `push` will try to undo the +correction. All of these caveats only affect invalid inputs. ## Feature Summary diff --git a/providers/linode/auditrecords.go b/providers/linode/auditrecords.go index 3a86ac664a..b64ca22fac 100644 --- a/providers/linode/auditrecords.go +++ b/providers/linode/auditrecords.go @@ -11,8 +11,18 @@ import ( func AuditRecords(records models.Records) []error { a := rejectif.Auditor{} - a.Add("CAA", rejectif.CaaFlagIsNonZero) // Last verified 2026-07-28 + a.Add("CAA", rejectif.CaaFlagIsNonZero) // Last verified 2026-07-28 + a.Add("CAA", rejectif.CaaTargetContainsWhitespace) // Last verified 2023-01-15 + a.Add("SRV", srvHasInvalidLabel) // Last verified 2026-08-28 + return a.Audit(records) } + +// srvHasInvalidLabel rejects SRV records whose label is not valid. Linode +// stores SRV records by their service and protocol, therefore a malformed label +// cannot be represented. +func srvHasInvalidLabel(rc *models.RecordConfig) error { + return validateSrvLabel(rc.GetLabel()) +} diff --git a/providers/linode/linodeProvider.go b/providers/linode/linodeProvider.go index 188e07ac7b..ad2bcff8e4 100644 --- a/providers/linode/linodeProvider.go +++ b/providers/linode/linodeProvider.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/url" - "regexp" "sort" "strings" @@ -47,8 +46,6 @@ var allowedTTLValues = []uint32{ 2419200, // 4 weeks } -var srvRegexp = regexp.MustCompile(`^_(?P\w+)\.\_(?P\w+)$`) - // linodeProvider is the handle for this provider. type linodeProvider struct { client *http.Client @@ -358,13 +355,14 @@ func toReq(dc *models.DomainConfig, rc *models.RecordConfig) (*recordEditRequest req.Weight = int(f.Weight) req.Port = int(f.Port) - // From softlayer provider - // This is to support SRV, it doesn't work yet for Linode - result := srvRegexp.FindStringSubmatch(req.Name) - if len(result) != 3 { - return nil, fmt.Errorf("SRV Record must match format \"_service._protocol\" not %s", req.Name) + // The label has already been validated by AuditRecords(). + // NB(tlim): The fact that Linode expects the client to do this + // extraction is a good example of how not to design a protocol. It's + // asking the same data to be sent twice, which multiplies the edge cases. + serviceName, protocol, err := extractSrvLabelValues(req.Name) + if err != nil { + return nil, err } - serviceName, protocol := result[1], strings.ToLower(result[2]) req.Protocol = protocol req.Service = serviceName diff --git a/providers/linode/srvlabel.go b/providers/linode/srvlabel.go new file mode 100644 index 0000000000..37e60b1790 --- /dev/null +++ b/providers/linode/srvlabel.go @@ -0,0 +1,51 @@ +package linode + +import ( + "fmt" + "regexp" + "strings" +) + +// srvLabelRegexp matches a valid SRV record label: "_service._protocol", +// optionally followed by a subdomain (e.g. "_smtp._tcp.sub.domain"). This is +// used both by AuditRecords to validate labels and by toReq to extract Service +// and Protocol from the labels. +var srvLabelRegexp = regexp.MustCompile(`^_([[:alnum:]-_]+)\._([[:alnum:]_])`) + +func validateSrvLabel(label string) error { + _, _, err := validateSrvLabelHelper(label) + return err +} + +func extractSrvLabelValues(label string) (string, string, error) { + service, protocol, err := validateSrvLabelHelper(label) + if err != nil { + return "", "", err + } + + /* + We make no attempt at validating service name or protocol beyond + that srvLabelRegexp allows. + + The user will get a reject during "push", which isn't optimal, but we can't + attempt to emulate Linode's algorithm, which could change without notice. + + Attempts to document Linode's algorithm are here: + https://github.com/DNSControl/dnscontrol/issues/4812#issuecomment-5444147757 + + */ + + return service[1:], protocol[1:], nil +} + +func validateSrvLabelHelper(label string) (string, string, error) { + parts := strings.SplitN(label, ".", 2) + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid label %q: fewer than 2 parts", label) + } + front := label[0 : len(parts[0])+1+len(parts[1])] + if !srvLabelRegexp.MatchString(front) { + return "", "", fmt.Errorf("invalid label %q", label) + } + return parts[0], parts[1], nil +} diff --git a/providers/linode/srvlabel_test.go b/providers/linode/srvlabel_test.go new file mode 100644 index 0000000000..d0050c5071 --- /dev/null +++ b/providers/linode/srvlabel_test.go @@ -0,0 +1,91 @@ +package linode + +import ( + "testing" +) + +// invalidSrvLabels is a list of invalid labels, for use in tests. +var invalidSrvLabels = []string{ + // Each of these will be tried plain plus ".sub" and plus ".sub.domain". + "notasrv", // just plain wrong + "_notasrv", // just plain wrong + "_foo&._tcp", // '&' is not a word character or hyphen + "smtp._tcp", // missing the leading underscore + "_smtp.tcp", // missing the leading underscore +} + +// srvLabelTests is a list of valid labels, with expected service and protocol extraction. +var srvLabelTests = []struct { + label string + service string + protocol string +}{ + // Each of these will be tried plain plus ".sub" and plus ".sub.domain". + {label: "_muble._udp-lite", service: "muble", protocol: "udp-lite"}, + {label: "_sip._udp", service: "sip", protocol: "udp"}, + {label: "_smtp._tcp", service: "smtp", protocol: "tcp"}, + {label: "_xmpp-server._tcp", service: "xmpp-server", protocol: "tcp"}, + // This weird case is accepted by Linode when subdomains in use. + // For example `_tcp._smtp.sub.domain` or `_tcp._smtp.sub-domain`. + {label: "_tcp._smtp", service: "tcp", protocol: "smtp"}, + // Underscores are permitted within the service/protocol tokens. + {label: "_foo_bar._tcp", service: "foo_bar", protocol: "tcp"}, + // Permitted, but Linode will correct, which causes an update loop. We're ok + // with that because anything else would require us to emulate the Linode + // algorithm exactly, which is impossible because we don't have their source + // code and we can't magically stay in sync with future changes they may + // make. We fix this kind of thing by documenting it. + {label: "__smtp._tcp", service: "_smtp", protocol: "tcp"}, + {label: "_sm_tp._tcp", service: "sm_tp", protocol: "tcp"}, + {label: "_smtp_._tcp", service: "smtp_", protocol: "tcp"}, + {label: "_smtp.__tcp", service: "smtp", protocol: "_tcp"}, +} + +func TestSRVLabel(t *testing.T) { + + for _, l := range invalidSrvLabels { + for _, suffix := range []string{"", ".sub", ".sub.dom"} { + label := l + suffix + t.Run("invalid/"+label, func(t *testing.T) { + err := validateSrvLabel(label) + if err == nil { + t.Errorf("expected %q to be an invalid SRV label, but it was accepted", label) + } + }) + } + } + + for _, tc := range srvLabelTests { + for _, suffix := range []string{"", ".sub", ".sub.domain"} { + label := tc.label + suffix + t.Run("valid/"+label, func(t *testing.T) { + err := validateSrvLabel(label) + if err != nil { + t.Errorf("expected %q to be a valid SRV label, but it was rejected: %v", label, err) + } + }) + } + } +} + +// TestExtractSrvParts verifies that srvLabelRegexp (the same regexp that +// validates the label) extracts the service and protocol properly. +func TestExtractSrvParts(t *testing.T) { + for _, tc := range srvLabelTests { + for _, suffix := range []string{"", ".sub", ".sub.domain"} { + label := tc.label + suffix + t.Run(label, func(t *testing.T) { + service, protocol, err := extractSrvLabelValues(label) + if err != nil { + t.Fatalf("expected %q to be validated but it failed", tc.label) + } + if service != tc.service { + t.Errorf("service = %q, want %q", service, tc.service) + } + if protocol != tc.protocol+suffix { + t.Errorf("protocol = %q, want %q", protocol, tc.protocol) + } + }) + } + } +}