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 @@ + + + ALIAS @@ -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) + } + } +}