diff --git a/OWNERS b/OWNERS index 7b898408f..d57483f0b 100644 --- a/OWNERS +++ b/OWNERS @@ -12,6 +12,7 @@ providers/digitalocean @Deraen providers/dnsimple @onlyhavecans providers/dnsmadeeasy @vojtad providers/doh @mikenz +providers/domainnameshop @SimenBai providers/easyname @tresni providers/exoscale @pierre-emmanuelJ providers/gandi_v5 @TomOnTime diff --git a/README.md b/README.md index bb7932608..080b4437a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Currently supported DNS providers: - DNS Made Easy - DNSimple - DigitalOcean + - DomainNameShop (domeneshop) - Exoscale - Gandi - Google DNS diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 50501c069..090640bf6 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -19,6 +19,7 @@
DNSIMPLE
DNSMADEEASY
DNSOVERHTTPS
+
DOMAINNAMESHOP
EASYNAME
EXOSCALE
GANDI_V5
@@ -101,6 +102,9 @@ + + + @@ -215,6 +219,9 @@ + + + @@ -338,6 +345,9 @@ + + + @@ -451,6 +461,9 @@ + + + @@ -538,6 +551,9 @@ + + + @@ -619,6 +635,9 @@ + + + @@ -722,6 +741,9 @@ + + + @@ -817,6 +839,9 @@ + + + @@ -880,6 +905,9 @@ + + + @@ -949,6 +977,9 @@ + + + @@ -1056,6 +1087,9 @@ + + + @@ -1141,6 +1175,9 @@ + + + @@ -1240,6 +1277,7 @@ + R53_ALIAS @@ -1263,6 +1301,7 @@ + @@ -1315,6 +1354,7 @@ + @@ -1367,6 +1407,9 @@ + + + @@ -1458,6 +1501,7 @@ + dual host @@ -1497,6 +1541,9 @@ + + + @@ -1606,6 +1653,9 @@ + + + @@ -1735,6 +1785,9 @@ + + + @@ -1850,6 +1903,9 @@ + + + diff --git a/docs/_providers/domainnameshop.md b/docs/_providers/domainnameshop.md new file mode 100644 index 000000000..7d5163582 --- /dev/null +++ b/docs/_providers/domainnameshop.md @@ -0,0 +1,46 @@ +--- +name: DomainNameShop +title: DomainNameShop Provider +layout: default +jsId: DOMAINNAMESHOP +--- +# DOMAINNAMESHOP Provider + +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `DOMAINNAMESHOP` +along with your [DomainNameShop Token and Secret](https://www.domeneshop.no/admin?view=api). + +Example: + +```json +{ + "mydomainnameshop": { + "TYPE": "DOMAINNAMESHOP", + "token": "your-domainnameshop-token", + "secret": "your-domainnameshop-secret" + } +} +``` + +## Metadata +This provider does not recognize any special metadata fields unique to DomainNameShop. + +## Usage +An example `dnsconfig.js` configuration: + +```js +var REG_NONE = NewRegistrar("none"); +var DSP_DOMAINNAMESHOP = NewDnsProvider("mydomainnameshop"); + +D("example.tld", REG_NONE, DnsProvider(DSP_DOMAINNAMESHOP), + A("test", "1.2.3.4") +); +``` + +## Activation +[Create API Token and secret](https://www.domeneshop.no/admin?view=api) + +## Limitations + +- DomainNameShop DNS only supports TTLs which are a multiple of 60. \ No newline at end of file diff --git a/docs/provider-list.md b/docs/provider-list.md index 166ace495..15d92b724 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -82,6 +82,7 @@ Providers in this category and their maintainers are: * `DNSOVERHTTPS` @mikenz * `DNSIMPLE` @onlyhavecans * `DNSMADEEASY` @vojtad +* `DOMAINNAMESHOP` @SimenBai * `EASYNAME` @tresni * `EXOSCALE` @pierre-emmanuelJ * `GANDI_V5` @TomOnTime diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 4352bd3fc..2599cc259 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -204,5 +204,10 @@ "TRANSIP": { "AccessToken": "$TRANSIP_ACCESS_TOKEN", "domain": "$TRANSIP_DOMAIN" + }, + "DOMAINNAMESHOP": { + "token": "$DOMAINNAMESHOP_TOKEN", + "secret": "$DOMAINNAMESHOP_SECRET", + "domain": "$DOMAINNAMESHOP_DOMAIN" } } diff --git a/providers/_all/all.go b/providers/_all/all.go index 3cddb67ed..467003734 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -17,6 +17,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/dnsimple" _ "github.com/StackExchange/dnscontrol/v3/providers/dnsmadeeasy" _ "github.com/StackExchange/dnscontrol/v3/providers/doh" + _ "github.com/StackExchange/dnscontrol/v3/providers/domainnameshop" _ "github.com/StackExchange/dnscontrol/v3/providers/easyname" _ "github.com/StackExchange/dnscontrol/v3/providers/exoscale" _ "github.com/StackExchange/dnscontrol/v3/providers/gandiv5" diff --git a/providers/domainnameshop/api.go b/providers/domainnameshop/api.go new file mode 100644 index 000000000..8b04d9252 --- /dev/null +++ b/providers/domainnameshop/api.go @@ -0,0 +1,223 @@ +package domainnameshop + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "golang.org/x/net/idna" +) + +var rootAPIURI = "https://api.domeneshop.no/v0" + +func (api *domainNameShopProvider) getDomains(domainName string) ([]domainResponse, error) { + client := &http.Client{} + + req, err := http.NewRequest(http.MethodGet, rootAPIURI+"/domains?domain="+domainName, nil) + if err != nil { + return nil, err + } + + req.SetBasicAuth(api.Token, api.Secret) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var domainResp []domainResponse + err = json.NewDecoder(resp.Body).Decode(&domainResp) + if err != nil { + return nil, err + } + + if domainName != "" && domainName != domainResp[0].Domain { + return nil, fmt.Errorf("invalid domain name: %q != %q", domainName, domainResp[0].Domain) + } + return domainResp, nil +} + +func (api *domainNameShopProvider) getDomainID(domainName string) (string, error) { + domainResp, err := api.getDomains(domainName) + if err != nil { + return "", err + } + return strconv.Itoa(domainResp[0].ID), nil +} + +func (api *domainNameShopProvider) getNS(domainName string) ([]string, error) { + domainResp, err := api.getDomains(domainName) + if err != nil { + return nil, err + } + return domainResp[0].Nameservers, nil +} + +func (api *domainNameShopProvider) getDNS(domainName string) ([]domainNameShopRecord, error) { + domainID, err := api.getDomainID(domainName) + if err != nil { + return nil, err + } + + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, rootAPIURI+"/domains/"+domainID+"/dns", nil) + if err != nil { + return nil, err + } + + req.SetBasicAuth(api.Token, api.Secret) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var domainResponse []domainNameShopRecord + err = json.NewDecoder(resp.Body).Decode(&domainResponse) + if err != nil { + return nil, err + } + + // Post processing of the data received. Converting to correct types and setting default values. + for i := range domainResponse { + // Convert priority from string to Uint, defaulting to 0 + record := &domainResponse[i] + priority, err := strconv.ParseUint(record.Priority, 10, 16) + if err != nil { + record.ActualPriority = 0 + } + record.ActualPriority = uint16(priority) + + // Convert port from string to Uint, defaulting to 0 + port, err := strconv.ParseUint(record.Port, 10, 16) + if err != nil { + record.ActualPort = 0 + } + record.ActualPort = uint16(port) + + // Convert weight from string ti Uint, defaulting to 0 + weight, err := strconv.ParseUint(record.Weight, 10, 16) + if err != nil { + record.ActualWeight = 0 + } + record.ActualWeight = uint16(weight) + + // Converting the CAA flag from string to correct value + if record.Type == "CAA" { + CaaFlag, err := strconv.ParseUint(record.ActualCAAFlag, 10, 8) + if err != nil { + record.CAAFlag = 0 + } + record.CAAFlag = CaaFlag + } + + // Transform data field to punycode if CNAME + if record.Type == "CNAME" { + punycodeData, err := idna.ToASCII(record.Data) + if err != nil { + return nil, err + } + record.Data = punycodeData + if !strings.HasSuffix(record.Data, ".") { + record.Data += "." + } + } + + // Normalize the TTL. + record.TTL = uint16(fixTTL(uint32(record.TTL))) + + // Add domain id + (&domainResponse[i]).DomainID = domainID + } + + ns, err := api.getNS(domainName) + if err != nil { + return nil, err + } + + // Adds NS as records + for _, nameserver := range ns { + domainResponse = append(domainResponse, domainNameShopRecord{ + ID: 0, + Host: "@", + TTL: 300, + Type: "NS", + Data: nameserver + ".", + DomainID: domainID, + }) + } + + return domainResponse, nil +} + +func (api *domainNameShopProvider) deleteRecord(domainID string, recordID string) error { + return api.sendChangeRequest(http.MethodDelete, rootAPIURI+"/domains/"+domainID+"/dns/"+recordID, nil) +} + +func (api *domainNameShopProvider) CreateRecord(domainName string, dnsR *domainNameShopRecord) error { + domainID, err := api.getDomainID(domainName) + if err != nil { + return err + } + + payloadBuf := new(bytes.Buffer) + err = json.NewEncoder(payloadBuf).Encode(&dnsR) + if err != nil { + return err + } + + return api.sendChangeRequest(http.MethodPost, rootAPIURI+"/domains/"+domainID+"/dns", payloadBuf) +} + +func (api *domainNameShopProvider) UpdateRecord(dnsR *domainNameShopRecord) error { + domainID := dnsR.DomainID + recordID := strconv.Itoa(dnsR.ID) + + payloadBuf := new(bytes.Buffer) + json.NewEncoder(payloadBuf).Encode(&dnsR) + + return api.sendChangeRequest(http.MethodPut, rootAPIURI+"/domains/"+domainID+"/dns/"+recordID, payloadBuf) +} + +func (api *domainNameShopProvider) sendChangeRequest(method string, uri string, payload *bytes.Buffer) error { + client := &http.Client{} + + var req *http.Request + var err error + if payload != nil { + req, err = http.NewRequest(method, uri, payload) + } else { + req, err = http.NewRequest(method, uri, nil) + } + + if err != nil { + return err + } + + req.SetBasicAuth(api.Token, api.Secret) + resp, err := client.Do(req) + if err != nil { + return err + } + + switch resp.StatusCode { + case 201: + // Record is deleted + return nil + case 204: + //Update successful + return nil + case 400: + return fmt.Errorf("DNS record failed validation") + case 403: + return fmt.Errorf("not authorized") + case 404: + return fmt.Errorf("does not exist") + case 409: + return fmt.Errorf("collision") + default: + return fmt.Errorf("unknown statuscode: %v", resp.StatusCode) + } +} diff --git a/providers/domainnameshop/convert.go b/providers/domainnameshop/convert.go new file mode 100644 index 000000000..cae6dc4f1 --- /dev/null +++ b/providers/domainnameshop/convert.go @@ -0,0 +1,95 @@ +package domainnameshop + +import ( + "strconv" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/miekg/dns/dnsutil" +) + +func toRecordConfig(domain string, currentRecord *domainNameShopRecord) *models.RecordConfig { + name := dnsutil.AddOrigin(currentRecord.Host, domain) + + target := currentRecord.Data + + t := &models.RecordConfig{ + Type: currentRecord.Type, + TTL: fixTTL(uint32(currentRecord.TTL)), + MxPreference: uint16(currentRecord.ActualPriority), + SrvPriority: uint16(currentRecord.ActualPriority), + SrvWeight: uint16(currentRecord.ActualWeight), + SrvPort: uint16(currentRecord.ActualPort), + Original: currentRecord, + CaaTag: currentRecord.CAATag, + CaaFlag: uint8(currentRecord.CAAFlag), + } + + t.SetTarget(target) + t.SetLabelFromFQDN(name, domain) + + switch rtype := currentRecord.Type; rtype { + case "TXT": + t.SetTargetTXT(target) + case "CAA": + if currentRecord.CAATag == "0" { + t.CaaTag = "issue" + } else if currentRecord.CAATag == "1" { + t.CaaTag = "issuewild" + } else { + t.CaaTag = "iodef" + } + default: + // nothing additional required + } + return t +} + +func (api *domainNameShopProvider) fromRecordConfig(domainName string, rc *models.RecordConfig) (*domainNameShopRecord, error) { + domainID, err := api.getDomainID(domainName) + if err != nil { + return nil, err + } + + data := "" + if rc.Type == "TXT" { + data = rc.GetTargetTXTJoined() + } else { + data = rc.GetTargetField() + } + + dnsR := &domainNameShopRecord{ + ID: 0, + Host: rc.GetLabel(), + TTL: uint16(fixTTL(rc.TTL)), + Type: rc.Type, + Data: data, + Weight: strconv.Itoa(int(rc.SrvWeight)), + Port: strconv.Itoa(int(rc.SrvPort)), + ActualWeight: rc.SrvWeight, + ActualPort: rc.SrvPort, + CAAFlag: uint64(int(rc.CaaFlag)), + ActualCAAFlag: strconv.Itoa(int(rc.CaaFlag)), + DomainID: domainID, + } + + switch rc.Type { + case "CAA": + // Actual CAA FLAG + switch rc.CaaTag { + case "issue": + dnsR.CAATag = "0" + case "issuewild": + dnsR.CAATag = "1" + case "iodef": + dnsR.CAATag = "2" + } + case "MX": + dnsR.Priority = strconv.Itoa(int(rc.MxPreference)) + case "SRV": + dnsR.Priority = strconv.Itoa(int(rc.SrvPriority)) + default: + // pass through + } + + return dnsR, nil +} diff --git a/providers/domainnameshop/dns.go b/providers/domainnameshop/dns.go new file mode 100644 index 000000000..7b699f30c --- /dev/null +++ b/providers/domainnameshop/dns.go @@ -0,0 +1,131 @@ +package domainnameshop + +import ( + "fmt" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" +) + +func (api *domainNameShopProvider) GetZoneRecords(domain string) (models.Records, error) { + records, err := api.getDNS(domain) + if err != nil { + return nil, err + } + + var existingRecords []*models.RecordConfig + for i := range records { + rC := toRecordConfig(domain, &records[i]) + existingRecords = append(existingRecords, rC) + } + + return existingRecords, nil +} + +func (api *domainNameShopProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc.Punycode() + existingRecords, err := api.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + + // Normalize + models.PostProcessRecords(existingRecords) + + // Merge TXT strings to one string + for _, rc := range dc.Records { + if rc.HasFormatIdenticalToTXT() { + rc.SetTargetTXT(strings.Join(rc.TxtStrings, "")) + } + } + + // Domainnameshop doesn't allow arbitrary TTLs they must be a multiple of 60. + for _, record := range dc.Records { + record.TTL = fixTTL(record.TTL) + } + + differ := diff.New(dc) + _, create, delete, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + + var corrections = []*models.Correction{} + + // Delete record + for _, r := range delete { + domainID := r.Existing.Original.(*domainNameShopRecord).DomainID + recordID := strconv.Itoa(r.Existing.Original.(*domainNameShopRecord).ID) + + corr := &models.Correction{ + Msg: fmt.Sprintf("%s, record id: %s", r.String(), recordID), + F: func() error { return api.deleteRecord(domainID, recordID) }, + } + corrections = append(corrections, corr) + } + + // Create records + for _, r := range create { + // Retrieve the domain name that is targeted. I.e. example.com instead of sub.example.com + domainName := strings.Replace(r.Desired.GetLabelFQDN(), r.Desired.GetLabel()+".", "", -1) + + dnsR, err := api.fromRecordConfig(domainName, r.Desired) + if err != nil { + return nil, err + } + + corr := &models.Correction{ + Msg: r.String(), + F: func() error { return api.CreateRecord(domainName, dnsR) }, + } + + corrections = append(corrections, corr) + } + + for _, r := range modify { + domainName := strings.Replace(r.Desired.GetLabelFQDN(), r.Desired.GetLabel()+".", "", -1) + + dnsR, err := api.fromRecordConfig(domainName, r.Desired) + if err != nil { + return nil, err + } + + dnsR.ID = r.Existing.Original.(*domainNameShopRecord).ID + + corr := &models.Correction{ + Msg: r.String(), + F: func() error { return api.UpdateRecord(dnsR) }, + } + + corrections = append(corrections, corr) + } + + return corrections, nil +} + +func (api *domainNameShopProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + ns, err := api.getNS(domain) + if err != nil { + return nil, err + } + return models.ToNameservers(ns) +} + +const minAllowedTTL = 60 +const maxAllowedTTL = 604800 +const multiplierTTL = 60 + +func fixTTL(ttl uint32) uint32 { + // if the TTL is larger than the largest allowed value, return the largest allowed value + if ttl > maxAllowedTTL { + return maxAllowedTTL + } else if ttl < 60 { + return minAllowedTTL + } + + // Return closest rounded down possible + + return (ttl / multiplierTTL) * multiplierTTL +} diff --git a/providers/domainnameshop/dns_test.go b/providers/domainnameshop/dns_test.go new file mode 100644 index 000000000..dc8f51f11 --- /dev/null +++ b/providers/domainnameshop/dns_test.go @@ -0,0 +1,27 @@ +package domainnameshop + +import ( + "testing" +) + +func TestFixTTL(t *testing.T) { + for i, test := range []struct { + given, expected uint32 + }{ + {1, minAllowedTTL}, + {multiplierTTL*5 - 1, multiplierTTL * 4}, + {maxAllowedTTL + 1, maxAllowedTTL}, + {0, 60}, + {59, 60}, + {60, 60}, + {61, 60}, + {119, 60}, + {120, 120}, + {121, 120}, + } { + found := fixTTL(test.given) + if found != test.expected { + t.Errorf("Test %d: Expected %d, but was %d", i, test.expected, found) + } + } +} diff --git a/providers/domainnameshop/domainnameshopProvider.go b/providers/domainnameshop/domainnameshopProvider.go new file mode 100644 index 000000000..47e09bb99 --- /dev/null +++ b/providers/domainnameshop/domainnameshopProvider.go @@ -0,0 +1,101 @@ +package domainnameshop + +import ( + "encoding/json" + "fmt" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/providers" +) + +/** + +DomainNameShop Provider + +Info required in 'creds.json': + - token API Token + - secret API Secret + +*/ + +type domainNameShopProvider struct { + Token string // The API token + Secret string // The API secret +} + +var features = providers.DocumentationNotes{ + providers.CanAutoDNSSEC: providers.Cannot(), // Maybe there is support for it + providers.CanGetZones: providers.Unimplemented(), // + providers.CanUseAlias: providers.Unimplemented(), // Can possibly be implemented, needs further research + providers.CanUseCAA: providers.Can(), + providers.CanUseDS: providers.Unimplemented(), // Seems to support but needs to be implemented + providers.CanUseDSForChildren: providers.Unimplemented(), // Seems to support but needs to be implemented + providers.CanUseNAPTR: providers.Cannot(), // Does not seem to support it + providers.CanUsePTR: providers.Unimplemented(), // Seems to support but needs to be implemented + providers.CanUseSOA: providers.Cannot(), // Does not seem to support it + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Cannot(), // Does not seem to support it + providers.CanUseTLSA: providers.Unimplemented(), // Seems to support but needs to be implemented + providers.DocCreateDomains: providers.Unimplemented(), // Not tested + providers.DocDualHost: providers.Unimplemented(), // Not tested + providers.DocOfficiallySupported: providers.Cannot(), +} + +// Register with the dnscontrol system. +// This establishes the name (all caps), and the function to call to initialize it. +func init() { + fns := providers.DspFuncs{ + Initializer: newDomainNameShopProvider, + RecordAuditor: auditRecords, + } + + providers.RegisterDomainServiceProviderType("DOMAINNAMESHOP", fns, features) +} + +// newDomainNameShopProvider creates a DomainNameShop specific DNS provider. +func newDomainNameShopProvider(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + if conf["token"] == "" { + return nil, fmt.Errorf("no DomainNameShop token provided") + } else if conf["secret"] == "" { + return nil, fmt.Errorf("no DomainNameShop secret provided") + } + + api := &domainNameShopProvider{ + Token: conf["token"], + Secret: conf["secret"], + } + + // Consider testing if creds work + return api, nil +} + +func auditRecords(records []*models.RecordConfig) error { + return nil +} + +type domainResponse struct { + ID int `json:"id"` + Domain string `json:"domain"` + Nameservers []string `json:"nameservers"` +} + +// The Actual fields are the values in the right format according to what is needed for RecordConfig. +// While the values without Actual are the values directly as received from the DomainNameShop API. +// This is done to make it easier to use the values at later points. +type domainNameShopRecord struct { + ID int `json:"id"` + Host string `json:"host"` + TTL uint16 `json:"ttl,omitempty"` + Type string `json:"type"` + Data string `json:"data"` + Priority string `json:"priority,omitempty"` + ActualPriority uint16 + Weight string `json:"weight,omitempty"` + ActualWeight uint16 + Port string `json:"port,omitempty"` + ActualPort uint16 + CAATag string `json:"tag,omitempty"` + ActualCAAFlag string `json:"flags,omitempty"` + CAAFlag uint64 + DomainID string +}