Skip to content
12 changes: 11 additions & 1 deletion documentation/provider/linode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" %}
Expand All @@ -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)):

Expand Down Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion providers/linode/auditrecords.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
16 changes: 7 additions & 9 deletions providers/linode/linodeProvider.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strings"

Expand Down Expand Up @@ -47,8 +46,6 @@ var allowedTTLValues = []uint32{
2419200, // 4 weeks
}

var srvRegexp = regexp.MustCompile(`^_(?P<Service>\w+)\.\_(?P<Protocol>\w+)$`)

// linodeProvider is the handle for this provider.
type linodeProvider struct {
client *http.Client
Expand Down Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions providers/linode/srvlabel.go
Original file line number Diff line number Diff line change
@@ -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
}
91 changes: 91 additions & 0 deletions providers/linode/srvlabel_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
}
Loading