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