diff --git a/OWNERS b/OWNERS
index 872199d07..71cb6a4be 100644
--- a/OWNERS
+++ b/OWNERS
@@ -5,6 +5,7 @@ providers/digitalocean @Deraen
providers/dnsimple @aeden
providers/gandi @TomOnTime
# providers/gcloud
+providers/linode @koesie10
providers/namecheap @captncraig
# providers/namedotcom
providers/ns1 @captncraig
diff --git a/README.md b/README.md
index 5a09311ca..88270317b 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ Currently supported DNS providers:
- DNSimple
- Gandi
- Google
+ - Linode
- Namecheap
- Name.com
- NS1
diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html
index acc718d79..40df3fb03 100644
--- a/docs/_includes/matrix.html
+++ b/docs/_includes/matrix.html
@@ -13,6 +13,7 @@
DNSIMPLE |
GANDI |
GCLOUD |
+ LINODE |
NAMECHEAP |
NAMEDOTCOM |
NS1 |
@@ -49,6 +50,9 @@
|
+
+
+ |
|
@@ -91,6 +95,9 @@
|
+
+
+ |
|
@@ -157,6 +164,9 @@
|
+
+
+ |
@@ -173,6 +183,7 @@
|
|
+ |
|
@@ -214,6 +225,7 @@
|
+ |
|
@@ -253,6 +265,7 @@
|
+ |
|
@@ -288,6 +301,7 @@
|
+ |
|
@@ -317,6 +331,7 @@
|
|
+ |
|
@@ -350,6 +365,9 @@
|
+
+
+ |
|
@@ -391,6 +409,9 @@
|
+
+
+ |
|
@@ -436,6 +457,9 @@
|
+
+
+ |
|
diff --git a/docs/_providers/linode.md b/docs/_providers/linode.md
new file mode 100644
index 000000000..9f7f34e2a
--- /dev/null
+++ b/docs/_providers/linode.md
@@ -0,0 +1,57 @@
+---
+name: Linode
+title: Linode Provider
+layout: default
+jsId: LINODE
+---
+# Linode Provider
+
+## Configuration
+In your credentials file, you must provide your
+[Linode Personal Access Token](https://cloud.linode.com/profile/tokens)
+
+{% highlight json %}
+{
+ "linode": {
+ "token": "your-linode-personal-access-token"
+ }
+}
+{% endhighlight %}
+
+## Metadata
+This provider does not recognize any special metadata fields unique to Linode.
+
+## Usage
+Example Javascript:
+
+{% highlight js %}
+var REG_NONE = NewRegistrar('none', 'NONE')
+var LINODE = NewDnsProvider("linode", "LINODE");
+
+D("example.tld", REG_NONE, DnsProvider(LINODE),
+ A("test","1.2.3.4")
+);
+{%endhighlight%}
+
+## 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://github.com/linode/manager/blob/master/src/domains/components/SelectDNSSeconds.js)):
+
+- 300
+- 3600
+- 7200
+- 14400
+- 28800
+- 57600
+- 86400
+- 172800
+- 345600
+- 604800
+- 1209600
+- 2419200
+
+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.
diff --git a/docs/provider-list.md b/docs/provider-list.md
index 3b77b73fe..1b60b8d55 100644
--- a/docs/provider-list.md
+++ b/docs/provider-list.md
@@ -63,6 +63,7 @@ Maintainers of contributed providers:
* digital ocean @Deraen
* dnsimple @aeden
* gandi @TomOnTime
+* Linode @koesie10
* namecheap @captncraig
* ns1 @captncraig
* OVH @masterzen
@@ -83,7 +84,6 @@ code to support this provider, please re-open the issue. We'd be glad to help in
GoDaddy (#145)
Hurricane Electric (dns.he.net) (#118)
INWX (#254)
- Linode (#121)
NameSilo (#220)
OVH (#143)
diff --git a/integrationTest/providers.json b/integrationTest/providers.json
index 1bd5f3a98..c2d427101 100644
--- a/integrationTest/providers.json
+++ b/integrationTest/providers.json
@@ -36,6 +36,12 @@
"private_key": "$GCLOUD_PRIVATEKEY",
"project_id": "$GCLOUD_PROJECT"
},
+ "LINODE": {
+ "COMMENT": "25: Linode's hostname validation does not allow the target domain TLD",
+ "token": "$LINODE_TOKEN",
+ "domain": "$LINODE_DOMAIN",
+ "knownFailures": "25"
+ },
"NS1": {
"domain": "$NS1_DOMAIN",
"api_token": "$NS1_TOKEN"
diff --git a/providers/_all/all.go b/providers/_all/all.go
index b1c0f28a6..ee84cfe2c 100644
--- a/providers/_all/all.go
+++ b/providers/_all/all.go
@@ -10,6 +10,7 @@ import (
_ "github.com/StackExchange/dnscontrol/providers/dnsimple"
_ "github.com/StackExchange/dnscontrol/providers/gandi"
_ "github.com/StackExchange/dnscontrol/providers/gcloud"
+ _ "github.com/StackExchange/dnscontrol/providers/linode"
_ "github.com/StackExchange/dnscontrol/providers/namecheap"
_ "github.com/StackExchange/dnscontrol/providers/namedotcom"
_ "github.com/StackExchange/dnscontrol/providers/ns1"
diff --git a/providers/linode/api.go b/providers/linode/api.go
new file mode 100644
index 000000000..11f018a46
--- /dev/null
+++ b/providers/linode/api.go
@@ -0,0 +1,245 @@
+package linode
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+const (
+ mediaType = "application/json"
+ defaultBaseURL = "https://api.linode.com/v4/"
+ domainsPath = "domains"
+)
+
+func (c *LinodeApi) fetchDomainList() error {
+ c.domainIndex = map[string]int{}
+ page := 1
+ for {
+ dr := &domainResponse{}
+ endpoint := fmt.Sprintf("%s?page=%d", domainsPath, page)
+ if err := c.get(endpoint, dr); err != nil {
+ return fmt.Errorf("Error fetching domain list from Linode: %s", err)
+ }
+ for _, domain := range dr.Data {
+ c.domainIndex[domain.Domain] = domain.ID
+ }
+ if len(dr.Data) == 0 || dr.Page >= dr.Pages {
+ break
+ }
+ page++
+ }
+ return nil
+}
+
+func (c *LinodeApi) getRecords(id int) ([]domainRecord, error) {
+ records := []domainRecord{}
+ page := 1
+ for {
+ dr := &recordResponse{}
+ endpoint := fmt.Sprintf("%s/%d/records?page=%d", domainsPath, id, page)
+ if err := c.get(endpoint, dr); err != nil {
+ return nil, fmt.Errorf("Error fetching record list from Linode: %s", err)
+ }
+
+ records = append(records, dr.Data...)
+
+ if len(dr.Data) == 0 || dr.Page >= dr.Pages {
+ break
+ }
+ page++
+ }
+
+ return records, nil
+}
+
+func (c *LinodeApi) createRecord(domainID int, rec *recordEditRequest) (*domainRecord, error) {
+ endpoint := fmt.Sprintf("%s/%d/records", domainsPath, domainID)
+
+ req, err := c.newRequest(http.MethodPost, endpoint, rec)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, c.handleErrors(resp)
+ }
+
+ record := &domainRecord{}
+
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+ if err := decoder.Decode(record); err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
+
+func (c *LinodeApi) modifyRecord(domainID, recordID int, rec *recordEditRequest) error {
+ endpoint := fmt.Sprintf("%s/%d/records/%d", domainsPath, domainID, recordID)
+
+ req, err := c.newRequest(http.MethodPut, endpoint, rec)
+ if err != nil {
+ return err
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return c.handleErrors(resp)
+ }
+
+ return nil
+}
+
+func (c *LinodeApi) deleteRecord(domainID, recordID int) error {
+ endpoint := fmt.Sprintf("%s/%d/records/%d", domainsPath, domainID, recordID)
+ req, err := c.newRequest(http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return c.handleErrors(resp)
+ }
+
+ return nil
+}
+
+func (c *LinodeApi) newRequest(method, endpoint string, body interface{}) (*http.Request, error) {
+ rel, err := url.Parse(endpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ u := c.baseURL.ResolveReference(rel)
+
+ buf := new(bytes.Buffer)
+ if body != nil {
+ err = json.NewEncoder(buf).Encode(body)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ req, err := http.NewRequest(method, u.String(), buf)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", mediaType)
+ req.Header.Add("Accept", mediaType)
+ return req, nil
+}
+
+func (c *LinodeApi) get(endpoint string, target interface{}) error {
+ req, err := c.newRequest(http.MethodGet, endpoint, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return c.handleErrors(resp)
+ }
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+ return decoder.Decode(target)
+}
+
+func (c *LinodeApi) handleErrors(resp *http.Response) error {
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ errs := &errorResponse{}
+
+ if err := decoder.Decode(errs); err != nil {
+ return fmt.Errorf("Bad status code from Linode: %d not 200. Failed to decode response.", resp.StatusCode)
+ }
+
+ buf := bytes.NewBufferString(fmt.Sprintf("Bad status code from Linode: %d not 200.", resp.StatusCode))
+
+ for _, err := range errs.Errors {
+ buf.WriteString("\n- ")
+
+ if err.Field != "" {
+ buf.WriteString(err.Field)
+ buf.WriteString(": ")
+ }
+
+ buf.WriteString(err.Reason)
+ }
+
+ return errors.New(buf.String())
+}
+
+type basicResponse struct {
+ Results int `json:"results"`
+ Pages int `json:"pages"`
+ Page int `json:"page"`
+}
+
+type domainResponse struct {
+ basicResponse
+ Data []struct {
+ ID int `json:"id"`
+ Domain string `json:"domain"`
+ } `json:"data"`
+}
+
+type recordResponse struct {
+ basicResponse
+ Data []domainRecord `json:"data"`
+}
+
+type domainRecord struct {
+ ID int `json:"id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Target string `json:"target"`
+ Priority uint16 `json:"priority"`
+ Weight uint16 `json:"weight"`
+ Port uint16 `json:"port"`
+ Service string `json:"service"`
+ Protocol string `json:"protocol"`
+ TTLSec uint32 `json:"ttl_sec"`
+}
+
+type recordEditRequest struct {
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Target string `json:"target,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ Port int `json:"port,omitempty"`
+ Service string `json:"service,omitempty"`
+ Protocol string `json:"protocol,omitempty"`
+ // Documented as field `ttl` in the documentation, but in reality `ttl_sec` should be used
+ TTL int `json:"ttl_sec,omitempty"`
+}
+
+type errorResponse struct {
+ Errors []struct {
+ Field string `json:"field"`
+ Reason string `json:"reason"`
+ } `json:"errors"`
+}
diff --git a/providers/linode/linodeProvider.go b/providers/linode/linodeProvider.go
new file mode 100644
index 000000000..888407df8
--- /dev/null
+++ b/providers/linode/linodeProvider.go
@@ -0,0 +1,313 @@
+package linode
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/StackExchange/dnscontrol/models"
+ "github.com/StackExchange/dnscontrol/providers"
+ "github.com/StackExchange/dnscontrol/providers/diff"
+ "github.com/miekg/dns/dnsutil"
+
+ "net/url"
+
+ "golang.org/x/oauth2"
+ "regexp"
+ "strings"
+)
+
+/*
+
+Linode API DNS provider:
+
+Info required in `creds.json`:
+ - token
+
+*/
+
+var allowedTTLValues = []uint32{
+ 300, // 5 minutes
+ 3600, // 1 hour
+ 7200, // 2 hours
+ 14400, // 4 hours
+ 28800, // 8 hours
+ 57600, // 16 hours
+ 86400, // 1 day
+ 172800, // 2 days
+ 345600, // 4 days
+ 604800, // 1 week
+ 1209600, // 2 weeks
+ 2419200, // 4 weeks
+}
+
+var srvRegexp = regexp.MustCompile(`^_(?P\w+)\.\_(?P\w+)$`)
+
+type LinodeApi struct {
+ client *http.Client
+ baseURL *url.URL
+ domainIndex map[string]int
+}
+
+var defaultNameServerNames = []string{
+ "ns1.linode.com",
+ "ns2.linode.com",
+ "ns3.linode.com",
+ "ns4.linode.com",
+ "ns5.linode.com",
+}
+
+func NewLinode(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
+ if m["token"] == "" {
+ return nil, fmt.Errorf("Linode Token must be provided.")
+ }
+
+ ctx := context.Background()
+ client := oauth2.NewClient(
+ ctx,
+ oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m["token"]}),
+ )
+
+ baseURL, err := url.Parse(defaultBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("Linode base URL not valid")
+ }
+
+ api := &LinodeApi{client: client, baseURL: baseURL}
+
+ // Get a domain to validate the token
+ if err := api.fetchDomainList(); err != nil {
+ return nil, err
+ }
+
+ return api, nil
+}
+
+var docNotes = providers.DocumentationNotes{
+ providers.DocOfficiallySupported: providers.Cannot(),
+ providers.DocDualHost: providers.Cannot(),
+}
+
+func init() {
+ // SRV support is in this provider, but Linode doesn't seem to support it properly
+ providers.RegisterDomainServiceProviderType("LINODE", NewLinode, docNotes)
+}
+
+func (api *LinodeApi) GetNameservers(domain string) ([]*models.Nameserver, error) {
+ return models.StringsToNameservers(defaultNameServerNames), nil
+}
+
+func (api *LinodeApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
+ dc, err := dc.Copy()
+ if err != nil {
+ return nil, err
+ }
+
+ dc.Punycode()
+
+ if api.domainIndex == nil {
+ if err := api.fetchDomainList(); err != nil {
+ return nil, err
+ }
+ }
+ domainID, ok := api.domainIndex[dc.Name]
+ if !ok {
+ return nil, fmt.Errorf("%s not listed in domains for Linode account", dc.Name)
+ }
+
+ records, err := api.getRecords(domainID)
+ if err != nil {
+ return nil, err
+ }
+
+ existingRecords := make([]*models.RecordConfig, len(records), len(records)+len(defaultNameServerNames))
+ for i := range records {
+ existingRecords[i] = toRc(dc, &records[i])
+ }
+
+ // Linode always has read-only NS servers, but these are not mentioned in the API response
+ // https://github.com/linode/manager/blob/edd99dc4e1be5ab8190f243c3dbf8b830716255e/src/constants.js#L184
+ for _, name := range defaultNameServerNames {
+ existingRecords = append(existingRecords, &models.RecordConfig{
+ NameFQDN: dc.Name,
+ Type: "NS",
+ Target: name,
+ Original: &domainRecord{},
+ })
+ }
+
+ // Normalize
+ models.Downcase(existingRecords)
+
+ // Linode doesn't allow selecting an arbitrary TTL, only a set of predefined values
+ // We need to make sure we don't change it every time if it is as close as it's going to get
+ // By experimentation, Linode always rounds up. 300 -> 300, 301 -> 3600.
+ // https://github.com/linode/manager/blob/edd99dc4e1be5ab8190f243c3dbf8b830716255e/src/domains/components/SelectDNSSeconds.js#L19
+ for _, record := range dc.Records {
+ record.TTL = fixTTL(record.TTL)
+ }
+
+ differ := diff.New(dc)
+ _, create, del, modify := differ.IncrementalDiff(existingRecords)
+
+ var corrections []*models.Correction
+
+ // Deletes first so changing type works etc.
+ for _, m := range del {
+ id := m.Existing.Original.(*domainRecord).ID
+ if id == 0 { // Skip ID 0, these are the default nameservers always present
+ continue
+ }
+ corr := &models.Correction{
+ Msg: fmt.Sprintf("%s, Linode ID: %d", m.String(), id),
+ F: func() error {
+ return api.deleteRecord(domainID, id)
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+ for _, m := range create {
+ req, err := toReq(dc, m.Desired)
+ if err != nil {
+ return nil, err
+ }
+ j, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+ corr := &models.Correction{
+ Msg: fmt.Sprintf("%s: %s", m.String(), string(j)),
+ F: func() error {
+ record, err := api.createRecord(domainID, req)
+ if err != nil {
+ return err
+ }
+ // TTL isn't saved when creating a record, so we will need to modify it immediately afterwards
+ return api.modifyRecord(domainID, record.ID, req)
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+ for _, m := range modify {
+ id := m.Existing.Original.(*domainRecord).ID
+ if id == 0 { // Skip ID 0, these are the default nameservers always present
+ continue
+ }
+ req, err := toReq(dc, m.Desired)
+ if err != nil {
+ return nil, err
+ }
+ j, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+ corr := &models.Correction{
+ Msg: fmt.Sprintf("%s, Linode ID: %d: %s", m.String(), id, string(j)),
+ F: func() error {
+ return api.modifyRecord(domainID, id, req)
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+
+ return corrections, nil
+}
+
+func toRc(dc *models.DomainConfig, r *domainRecord) *models.RecordConfig {
+ // This handles "@" etc.
+ name := dnsutil.AddOrigin(r.Name, dc.Name)
+
+ target := r.Target
+ // Make target FQDN (#rtype_variations)
+ if r.Type == "CNAME" || r.Type == "MX" || r.Type == "NS" || r.Type == "SRV" {
+ target = dnsutil.AddOrigin(target+".", dc.Name)
+ }
+
+ return &models.RecordConfig{
+ NameFQDN: name,
+ Type: r.Type,
+ Target: target,
+ TTL: r.TTLSec,
+ MxPreference: r.Priority,
+ SrvPriority: r.Priority,
+ SrvWeight: r.Weight,
+ SrvPort: uint16(r.Port),
+ Original: r,
+ }
+}
+
+func toReq(dc *models.DomainConfig, rc *models.RecordConfig) (*recordEditRequest, error) {
+ req := &recordEditRequest{
+ Type: rc.Type,
+ Name: dnsutil.TrimDomainName(rc.NameFQDN, dc.Name),
+ Target: rc.Target,
+ TTL: int(rc.TTL),
+ Priority: 0,
+ Port: int(rc.SrvPort),
+ Weight: int(rc.SrvWeight),
+ }
+
+ // Linode doesn't use "@", it uses an empty name
+ if req.Name == "@" {
+ req.Name = ""
+ }
+
+ // Linode uses the same property for MX and SRV priority
+ switch rc.Type { // #rtype_variations
+ case "A", "AAAA", "NS", "PTR", "TXT", "SOA", "TLSA", "CAA":
+ // Nothing special.
+ case "MX":
+ req.Priority = int(rc.MxPreference)
+ req.Target = fixTarget(req.Target, dc.Name)
+ case "SRV":
+ req.Priority = int(rc.SrvPriority)
+
+ // 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)
+ }
+
+ var serviceName, protocol string = result[1], strings.ToLower(result[2])
+
+ req.Protocol = protocol
+ req.Service = serviceName
+ req.Name = ""
+ case "CNAME":
+ req.Target = fixTarget(req.Target, dc.Name)
+ default:
+ msg := fmt.Sprintf("linode.toReq rtype %v unimplemented", rc.Type)
+ panic(msg)
+ // We panic so that we quickly find any switch statements
+ // that have not been updated for a new RR type.
+ }
+
+ return req, nil
+}
+
+func fixTarget(target, domain string) string {
+ // Linode always wants a fully qualified target name
+ if target[len(target)-1] == '.' {
+ return target[:len(target)-1]
+ } else {
+ return fmt.Sprintf("%s.%s", target, domain)
+ }
+}
+
+func fixTTL(ttl uint32) uint32 {
+ // if the TTL is larger than the largest allowed value, return the largest allowed value
+ if ttl > allowedTTLValues[len(allowedTTLValues)-1] {
+ return allowedTTLValues[len(allowedTTLValues)-1]
+ }
+
+ for _, v := range allowedTTLValues {
+ if v >= ttl {
+ return v
+ }
+ }
+
+ return allowedTTLValues[0]
+}
diff --git a/providers/linode/linodeProvider_test.go b/providers/linode/linodeProvider_test.go
new file mode 100644
index 000000000..3ec92cc34
--- /dev/null
+++ b/providers/linode/linodeProvider_test.go
@@ -0,0 +1,23 @@
+package linode
+
+import (
+ "testing"
+)
+
+func TestFixTTL(t *testing.T) {
+ for i, test := range []struct {
+ given, expected uint32
+ }{
+ {299, 300},
+ {300, 300},
+ {301, 3600},
+ {2419202, 2419200},
+ {600, 3600},
+ {3600, 3600},
+ } {
+ found := fixTTL(test.given)
+ if found != test.expected {
+ t.Errorf("Test %d: Expected %d, but was %d", i, test.expected, found)
+ }
+ }
+}