1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

New provider: Linode (#268)

This commit is contained in:
Koen Vlaswinkel
2017-11-15 05:08:06 +01:00
committed by Tom Limoncelli
parent 25df50634d
commit 9a44e785ac
10 changed files with 672 additions and 1 deletions

1
OWNERS
View File

@ -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

View File

@ -20,6 +20,7 @@ Currently supported DNS providers:
- DNSimple
- Gandi
- Google
- Linode
- Namecheap
- Name.com
- NS1

View File

@ -13,6 +13,7 @@
<th class="rotate"><div><span>DNSIMPLE</span></div></th>
<th class="rotate"><div><span>GANDI</span></div></th>
<th class="rotate"><div><span>GCLOUD</span></div></th>
<th class="rotate"><div><span>LINODE</span></div></th>
<th class="rotate"><div><span>NAMECHEAP</span></div></th>
<th class="rotate"><div><span>NAMEDOTCOM</span></div></th>
<th class="rotate"><div><span>NS1</span></div></th>
@ -49,6 +50,9 @@
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
@ -91,6 +95,9 @@
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
@ -157,6 +164,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
</tr>
<tr>
<th class="row-header" style="text-decoration: underline;" data-toggle="tooltip" data-container="body" data-placement="top" title="Provider supports some kind of ALIAS, ANAME or flattened CNAME record type">ALIAS</th>
@ -173,6 +183,7 @@
</td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -214,6 +225,7 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="The namecheap web console allows you to make SRV records, but their api does not let you read or set them">
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
</td>
@ -253,6 +265,7 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -288,6 +301,7 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -317,6 +331,7 @@
</td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -350,6 +365,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Doesn&#39;t allow control of apex NS records">
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
</td>
@ -391,6 +409,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Requires domain registered through their service">
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
</td>
@ -436,6 +457,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>

57
docs/_providers/linode.md Normal file
View File

@ -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.

View File

@ -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
<li>GoDaddy (<a href="https://github.com/StackExchange/dnscontrol/issues/145">#145</a>)</li>
<li>Hurricane Electric (dns.he.net) (<a href="https://github.com/StackExchange/dnscontrol/issues/118">#118</a>)</li>
<li>INWX (<a href="https://github.com/StackExchange/dnscontrol/issues/254">#254</a>)</li>
<li>Linode (<a href="https://github.com/StackExchange/dnscontrol/issues/121">#121</a>)</li>
<li>NameSilo (<a href="https://github.com/StackExchange/dnscontrol/issues/220">#220</a>)</li>
<li>OVH (<a href="https://github.com/StackExchange/dnscontrol/issues/143">#143</a>)</li>
</ul>

View File

@ -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"

View File

@ -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"

245
providers/linode/api.go Normal file
View File

@ -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"`
}

View File

@ -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<Service>\w+)\.\_(?P<Protocol>\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]
}

View File

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