From 02e6a49bb800bc9277810caa7d757d724e2bd1d2 Mon Sep 17 00:00:00 2001 From: Kordian Bruck Date: Fri, 17 Apr 2020 19:58:44 +0200 Subject: [PATCH] NEW PROVIDER: NETCUP (DNS) (#718) * Add support for netcup DNS api. * Add documentation page. * Update reference to new version path. * Add OWNERS entry for netcup. * Add credentials for integration test. Netcup does not support PTRs. Fix parsing/formating of SRV records. * Skip integration tests that are not supported. * Use single quotes in JS code. --- OWNERS | 1 + docs/_providers/netcup.md | 38 +++++++ docs/provider-list.md | 1 + integrationTest/integration_test.go | 8 +- integrationTest/providers.json | 6 ++ providers/_all/all.go | 1 + providers/netcup/api.go | 160 ++++++++++++++++++++++++++++ providers/netcup/netcupProvider.go | 136 +++++++++++++++++++++++ providers/netcup/types.go | 147 +++++++++++++++++++++++++ 9 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 docs/_providers/netcup.md create mode 100644 providers/netcup/api.go create mode 100644 providers/netcup/netcupProvider.go create mode 100644 providers/netcup/types.go diff --git a/OWNERS b/OWNERS index a3c5139fc..e89e0621c 100644 --- a/OWNERS +++ b/OWNERS @@ -12,6 +12,7 @@ providers/internetbs @pragmaton providers/linode @koesie10 providers/namecheap @captncraig # providers/namedotcom +providers/netcup @kordianbruck providers/ns1 @captncraig # providers/route53 # providers/softlayer diff --git a/docs/_providers/netcup.md b/docs/_providers/netcup.md new file mode 100644 index 000000000..90515c64c --- /dev/null +++ b/docs/_providers/netcup.md @@ -0,0 +1,38 @@ +--- +name: Netcup +title: Netcup Provider +layout: default +jsId: Netcup +--- +# Netcup Provider + +## Configuration +In your credentials file, you must provide your [api key, password and your customer number](https://www.netcup-wiki.de/wiki/CCP_API#Authentifizierung). + +{% highlight json %} +{ + "netcup": { + "api-key": "abc12345", + "api-password": "abc12345", + "customer-number": "123456" + } +} +{% endhighlight %} + +## Usage +Example Javascript: + +{% highlight js %} +var REG_NONE = NewRegistrar('none', 'NONE') +var NETCUP = NewDnsProvider('netcup' 'NETCUP'); + +D('example.tld', REG_NONE, DnsProvider(NETCUP), + A('test','1.2.3.4') +); +{%endhighlight%} + + +## Caveats +Netcup does not allow any TTLs to be set for individual records. Thus in +the diff/preview it will always show a TTL of 0. `NS` records are also +not currently supported. \ No newline at end of file diff --git a/docs/provider-list.md b/docs/provider-list.md index dc687ee98..a45b75ada 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -80,6 +80,7 @@ Maintainers of contributed providers: * `INTERNETBS` @pragmaton * `LINODE` @koesie10 * `NAMECHEAP` @captncraig +* `NETCUP` @kordianbruck * `NS1` @captncraig * `OCTODNS` @TomOnTime * `OPENSRS` @pierre-emmanuelJ diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index 9f0147f5c..2275ddb66 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -610,14 +610,15 @@ func makeTests(t *testing.T) []*TestGroup { ), testgroup("Null MX", - not("AZURE_DNS", "GANDI_V5", "NAMEDOTCOM", "DIGITALOCEAN"), // These providers don't support RFC 7505 + not("AZURE_DNS", "GANDI_V5", "NAMEDOTCOM", "DIGITALOCEAN", "NETCUP"), // These providers don't support RFC 7505 tc("Null MX", mx("@", 0, ".")), ), testgroup("NS", - not("DNSIMPLE", "EXOSCALE"), + not("DNSIMPLE", "EXOSCALE", "NETCUP"), // DNSIMPLE: Does not support NS records nor subdomains. // EXOSCALE: FILL IN + // Netcup: NS records not currently supported. tc("NS for subdomain", ns("xyz", "ns2.foo.com.")), tc("Dual NS for subdomain", ns("xyz", "ns2.foo.com."), ns("xyz", "ns1.foo.com.")), tc("NS Record pointing to @", ns("foo", "**current-domain**")), @@ -648,7 +649,8 @@ func makeTests(t *testing.T) []*TestGroup { tc("Change a TXT with ws at end", txt("foo", "with space at end ")), ), - testgroup("empty TXT", not("DNSIMPLE", "CLOUDFLAREAPI"), + testgroup("empty TXT", + not("DNSIMPLE", "CLOUDFLAREAPI", "NETCUP"), tc("TXT with empty str", txt("foo1", "")), // https://github.com/StackExchange/dnscontrol/issues/598 // We decided that permitting the TXT target to be an empty diff --git a/integrationTest/providers.json b/integrationTest/providers.json index e223c213a..7828d05da 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -77,6 +77,12 @@ "apiuser": "$NAMEDOTCOM_USER", "domain": "$NAMEDOTCOM_DOMAIN" }, + "NETCUP": { + "api-key": "$NETCUP_KEY", + "api-password": "$NETCUP_PASSWORD", + "customer-number": "$NETCUP_CUSTOMER_NUMBER", + "domain": "$NETCUP_DOMAIN" + }, "NS1": { "api_token": "$NS1_TOKEN", "domain": "$NS1_DOMAIN" diff --git a/providers/_all/all.go b/providers/_all/all.go index d813b6f82..5f182d939 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -18,6 +18,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/linode" _ "github.com/StackExchange/dnscontrol/v3/providers/namecheap" _ "github.com/StackExchange/dnscontrol/v3/providers/namedotcom" + _ "github.com/StackExchange/dnscontrol/v3/providers/netcup" _ "github.com/StackExchange/dnscontrol/v3/providers/ns1" _ "github.com/StackExchange/dnscontrol/v3/providers/octodns" _ "github.com/StackExchange/dnscontrol/v3/providers/opensrs" diff --git a/providers/netcup/api.go b/providers/netcup/api.go new file mode 100644 index 000000000..0529b3553 --- /dev/null +++ b/providers/netcup/api.go @@ -0,0 +1,160 @@ +package netcup + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +const ( + endpoint = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" +) + +type api struct { + domainIndex map[string]string + nameserversNames []string + credentials struct { + apikey string + customernumber string + sessionId string + } +} + +func (api *api) createRecord(domain string, rec *record) error { + rec.Delete = false + data := paramUpdateRecords{ + Key: api.credentials.apikey, + SessionId: api.credentials.sessionId, + CustomerNumber: api.credentials.customernumber, + DomainName: domain, + RecordSet: records{Records: []record{ + *rec, + }}, + } + _, err := api.get("updateDnsRecords", data) + if err != nil { + return fmt.Errorf("error while trying to create a record: %s", err) + } + return nil +} + +func (api *api) deleteRecord(domain string, rec *record) error { + rec.Delete = true + data := paramUpdateRecords{ + Key: api.credentials.apikey, + SessionId: api.credentials.sessionId, + CustomerNumber: api.credentials.customernumber, + DomainName: domain, + RecordSet: records{Records: []record{ + *rec, + }}, + } + _, err := api.get("updateDnsRecords", data) + if err != nil { + return fmt.Errorf("error while trying to delete a record: %s", err) + } + return nil +} + +func (api *api) modifyRecord(domain string, rec *record) error { + rec.Delete = false + data := paramUpdateRecords{ + Key: api.credentials.apikey, + SessionId: api.credentials.sessionId, + CustomerNumber: api.credentials.customernumber, + DomainName: domain, + RecordSet: records{Records: []record{ + *rec, + }}, + } + _, err := api.get("updateDnsRecords", data) + if err != nil { + return fmt.Errorf("error while trying to modify a record: %s", err) + } + return nil +} + +func (api *api) getRecords(domain string) ([]record, error) { + data := paramGetRecords{ + Key: api.credentials.apikey, + SessionId: api.credentials.sessionId, + CustomerNumber: api.credentials.customernumber, + DomainName: domain, + } + rawJson, err := api.get("infoDnsRecords", data) + if err != nil { + return nil, fmt.Errorf("Error while trying to login to netcup: %s", err) + } + + resp := &records{} + json.Unmarshal(rawJson, &resp) + return resp.Records, nil +} + +func (api *api) login(apikey, password, customernumber string) error { + data := paramLogin{ + Key: apikey, + Password: password, + CustomerNumber: customernumber, + } + rawJson, err := api.get("login", data) + if err != nil { + return fmt.Errorf("Error while trying to login to netcup: %s", err) + } + + resp := &responseLogin{} + json.Unmarshal(rawJson, &resp) + api.credentials.apikey = apikey + api.credentials.customernumber = customernumber + api.credentials.sessionId = resp.SessionId + return nil +} + +func (api *api) logout() error { + data := paramLogout{ + Key: api.credentials.apikey, + SessionId: api.credentials.sessionId, + CustomerNumber: api.credentials.customernumber, + } + _, err := api.get("logout", data) + if err != nil { + return fmt.Errorf("Error while trying to logout from netcup: %s", err) + } + api.credentials.apikey, api.credentials.sessionId, api.credentials.customernumber = "", "", "" + return nil +} + +func (api *api) get(action string, params interface{}) (json.RawMessage, error) { + reqParam := request{ + Action: action, + Param: params, + } + reqJson, _ := json.Marshal(reqParam) + + client := &http.Client{} + req, _ := http.NewRequest("POST", endpoint, bytes.NewBuffer(reqJson)) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + bodyString, _ := ioutil.ReadAll(resp.Body) + + respData := &response{} + err = json.Unmarshal(bodyString, &respData) + + // Yeah, netcup implemented an empty recordset as an error - don't ask. + if action == "infoDnsRecords" && respData.StatusCode == 5029 { + emptyRecords, _ := json.Marshal(records{}) + return emptyRecords, nil + } + + // Check for any errors and log them + if respData.StatusCode != 2000 && (action == "") { + return nil, fmt.Errorf("Netcup API error: %v\n%v\n", reqParam, respData) + } + + return respData.Data, nil +} diff --git a/providers/netcup/netcupProvider.go b/providers/netcup/netcupProvider.go new file mode 100644 index 000000000..19eed85e5 --- /dev/null +++ b/providers/netcup/netcupProvider.go @@ -0,0 +1,136 @@ +package netcup + +import ( + "encoding/json" + "fmt" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/providers" +) + +var features = providers.DocumentationNotes{ + providers.DocCreateDomains: providers.Cannot(), + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), + providers.CanUsePTR: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUseTXTMulti: providers.Can(), + providers.CanGetZones: providers.Cannot(), +} + +func init() { + providers.RegisterDomainServiceProviderType("NETCUP", New, features) +} + +func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + if settings["api-key"] == "" || settings["api-password"] == "" || settings["customer-number"] == "" { + return nil, fmt.Errorf("missing netcup login parameters") + } + + api := &api{} + err := api.login(settings["api-key"], settings["api-password"], settings["customer-number"]) + if err != nil { + return nil, fmt.Errorf("login to netcup DNS failed, please check your credentials: %v", err) + } + return api, nil +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (api *api) GetZoneRecords(domain string) (models.Records, error) { + records, err := api.getRecords(domain) + if err != nil { + return nil, err + } + existingRecords := make([]*models.RecordConfig, len(records)) + for i := range records { + existingRecords[i] = toRecordConfig(domain, &records[i]) + } + return existingRecords, nil +} + +// GetNameservers returns the nameservers for a domain. +// As netcup doesn't support setting nameservers over this API, these are static. +// Domains not managed by netcup DNS will return an error +func (api *api) GetNameservers(domain string) ([]*models.Nameserver, error) { + return models.ToNameservers([]string{ + "root-dns.netcup.net", + "second-dns.netcup.net", + "third-dns.netcup.net", + }) +} + +// GetDomainCorrections returns the corrections for a domain. +func (api *api) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc, err := dc.Copy() + if err != nil { + return nil, err + } + + dc.Punycode() + domain := dc.Name + + // Setting the TTL is not supported for netcup + for _, r := range dc.Records { + r.TTL = 0 + } + + // Filter out types we can't modify (like NS) + newRecords := models.Records{} + for _, r := range dc.Records { + if r.Type != "NS" { + newRecords = append(newRecords, r) + } + } + dc.Records = newRecords + + // Check existing set + existingRecords, err := api.GetZoneRecords(domain) + if err != nil { + return nil, err + } + + // Normalize + models.PostProcessRecords(existingRecords) + 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 { + req := m.Existing.Original.(*record) + corr := &models.Correction{ + Msg: fmt.Sprintf("%s, Netcup ID: %s", m.String(), req.Id), + F: func() error { + return api.deleteRecord(domain, req) + }, + } + corrections = append(corrections, corr) + } + + for _, m := range create { + req := fromRecordConfig(m.Desired) + corr := &models.Correction{ + Msg: m.String(), + F: func() error { + return api.createRecord(domain, req) + }, + } + corrections = append(corrections, corr) + } + for _, m := range modify { + id := m.Existing.Original.(*record).Id + req := fromRecordConfig(m.Desired) + req.Id = id + corr := &models.Correction{ + Msg: fmt.Sprintf("%s, Netcup ID: %s: ", m.String(), id), + F: func() error { + return api.modifyRecord(domain, req) + }, + } + corrections = append(corrections, corr) + } + + return corrections, nil +} diff --git a/providers/netcup/types.go b/providers/netcup/types.go new file mode 100644 index 000000000..8ca9c128a --- /dev/null +++ b/providers/netcup/types.go @@ -0,0 +1,147 @@ +package netcup + +import ( + "encoding/json" + "fmt" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/miekg/dns/dnsutil" + "strconv" + "strings" +) + +type request struct { + Action string `json:"action"` + Param interface{} `json:"param"` +} + +type paramLogin struct { + Key string `json:"apikey"` + Password string `json:"apipassword"` + CustomerNumber string `json:"customernumber"` +} + +type paramLogout struct { + Key string `json:"apikey"` + SessionId string `json:"apisessionid"` + CustomerNumber string `json:"customernumber"` +} + +type paramGetRecords struct { + Key string `json:"apikey"` + SessionId string `json:"apisessionid"` + CustomerNumber string `json:"customernumber"` + DomainName string `json:"domainname"` +} + +type paramUpdateRecords struct { + Key string `json:"apikey"` + SessionId string `json:"apisessionid"` + CustomerNumber string `json:"customernumber"` + DomainName string `json:"domainname"` + RecordSet records `json:"dnsrecordset"` +} + +type records struct { + Records []record `json:"dnsrecords"` +} + +type record struct { + Id string `json:"id"` + Hostname string `json:"hostname"` + Type string `json:"type"` + Priority string `json:"priority"` + Destination string `json:"destination"` + Delete bool `json:"deleterecord"` + State string `json:"state"` +} + +type response struct { + ServerRequestId string `json:"serverrequestid"` + ClientRequestId string `json:"clientrequestid"` + Action string `json:"action"` + Status string `json:"status"` + StatusCode int `json:"statuscode"` + ShortMessage string `json:"shortmessage"` + LongMessage string `json:"longmessage"` + Data json.RawMessage `json:"responsedata"` +} + +type responseLogin struct { + SessionId string `json:"apisessionid"` +} + +func toRecordConfig(domain string, r *record) *models.RecordConfig { + priority, _ := strconv.ParseUint(r.Priority, 10, 32) + + rc := &models.RecordConfig{ + Type: r.Type, + TTL: uint32(0), + MxPreference: uint16(priority), + SrvPriority: uint16(priority), + SrvWeight: uint16(0), + SrvPort: uint16(0), + Original: r, + } + rc.SetLabel(r.Hostname, domain) + + switch rtype := r.Type; rtype { // #rtype_variations + case "TXT": + _ = rc.SetTargetTXT(r.Destination) + case "NS", "ALIAS", "CNAME", "MX": + _ = rc.SetTarget(dnsutil.AddOrigin(r.Destination+".", domain)) + case "SRV": + parts := strings.Split(r.Destination, " ") + priority, _ := strconv.ParseUint(parts[0], 10, 16) + weight, _ := strconv.ParseUint(parts[1], 10, 16) + port, _ := strconv.ParseUint(parts[2], 10, 16) + rc.SrvPriority = uint16(priority) + rc.SrvWeight = uint16(weight) + rc.SrvPort = uint16(port) + _ = rc.SetTarget(parts[3]) + case "CAA": + parts := strings.Split(r.Destination, " ") + caaFlag, _ := strconv.ParseUint(parts[0], 10, 32) + rc.CaaFlag = uint8(caaFlag) + rc.CaaTag = parts[1] + _ = rc.SetTarget(strings.Trim(parts[2], "\"")) + default: + _ = rc.SetTarget(r.Destination) + } + + return rc +} + +func fromRecordConfig(in *models.RecordConfig) *record { + rc := &record{ + Hostname: in.GetLabel(), + Type: in.Type, + Destination: in.GetTargetField(), + Delete: false, + State: "", + } + + switch rc.Type { // #rtype_variations + case "A", "AAAA", "PTR", "TXT", "SOA", "ALIAS": + // Nothing special. + case "CNAME": + rc.Destination = strings.TrimSuffix(in.GetTargetField(), ".") + case "NS": + return nil // API ignores NS records + case "MX": + rc.Destination = strings.TrimSuffix(in.GetTargetField(), ".") + rc.Priority = strconv.Itoa(int(in.MxPreference)) + case "SRV": + rc.Destination = strconv.Itoa(int(in.SrvPriority)) + " " + strconv.Itoa(int(in.SrvWeight)) + " " + strconv.Itoa(int(in.SrvPort)) + " " + in.Target + case "CAA": + rc.Destination = strconv.Itoa(int(in.CaaFlag)) + " " + in.CaaTag + " \"" + in.GetTargetField() + "\"" + case "TLSA": + rc.Destination = strconv.Itoa(int(in.TlsaUsage)) + " " + strconv.Itoa(int(in.TlsaSelector)) + " " + strconv.Itoa(int(in.TlsaMatchingType)) + case "SSHFP": + rc.Destination = strconv.Itoa(int(in.SshfpAlgorithm)) + " " + strconv.Itoa(int(in.SshfpFingerprint)) + default: + msg := fmt.Sprintf("ClouDNS.toReq rtype %v unimplemented", rc.Type) + panic(msg) + // We panic so that we quickly find any switch statements + } + return rc +}