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

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.
This commit is contained in:
Kordian Bruck
2020-04-17 19:58:44 +02:00
committed by GitHub
parent 24b7d0641e
commit 02e6a49bb8
9 changed files with 495 additions and 3 deletions

1
OWNERS
View File

@ -12,6 +12,7 @@ providers/internetbs @pragmaton
providers/linode @koesie10 providers/linode @koesie10
providers/namecheap @captncraig providers/namecheap @captncraig
# providers/namedotcom # providers/namedotcom
providers/netcup @kordianbruck
providers/ns1 @captncraig providers/ns1 @captncraig
# providers/route53 # providers/route53
# providers/softlayer # providers/softlayer

38
docs/_providers/netcup.md Normal file
View File

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

View File

@ -80,6 +80,7 @@ Maintainers of contributed providers:
* `INTERNETBS` @pragmaton * `INTERNETBS` @pragmaton
* `LINODE` @koesie10 * `LINODE` @koesie10
* `NAMECHEAP` @captncraig * `NAMECHEAP` @captncraig
* `NETCUP` @kordianbruck
* `NS1` @captncraig * `NS1` @captncraig
* `OCTODNS` @TomOnTime * `OCTODNS` @TomOnTime
* `OPENSRS` @pierre-emmanuelJ * `OPENSRS` @pierre-emmanuelJ

View File

@ -610,14 +610,15 @@ func makeTests(t *testing.T) []*TestGroup {
), ),
testgroup("Null MX", 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, ".")), tc("Null MX", mx("@", 0, ".")),
), ),
testgroup("NS", testgroup("NS",
not("DNSIMPLE", "EXOSCALE"), not("DNSIMPLE", "EXOSCALE", "NETCUP"),
// DNSIMPLE: Does not support NS records nor subdomains. // DNSIMPLE: Does not support NS records nor subdomains.
// EXOSCALE: FILL IN // EXOSCALE: FILL IN
// Netcup: NS records not currently supported.
tc("NS for subdomain", ns("xyz", "ns2.foo.com.")), 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("Dual NS for subdomain", ns("xyz", "ns2.foo.com."), ns("xyz", "ns1.foo.com.")),
tc("NS Record pointing to @", ns("foo", "**current-domain**")), 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 ")), 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", "")), tc("TXT with empty str", txt("foo1", "")),
// https://github.com/StackExchange/dnscontrol/issues/598 // https://github.com/StackExchange/dnscontrol/issues/598
// We decided that permitting the TXT target to be an empty // We decided that permitting the TXT target to be an empty

View File

@ -77,6 +77,12 @@
"apiuser": "$NAMEDOTCOM_USER", "apiuser": "$NAMEDOTCOM_USER",
"domain": "$NAMEDOTCOM_DOMAIN" "domain": "$NAMEDOTCOM_DOMAIN"
}, },
"NETCUP": {
"api-key": "$NETCUP_KEY",
"api-password": "$NETCUP_PASSWORD",
"customer-number": "$NETCUP_CUSTOMER_NUMBER",
"domain": "$NETCUP_DOMAIN"
},
"NS1": { "NS1": {
"api_token": "$NS1_TOKEN", "api_token": "$NS1_TOKEN",
"domain": "$NS1_DOMAIN" "domain": "$NS1_DOMAIN"

View File

@ -18,6 +18,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v3/providers/linode" _ "github.com/StackExchange/dnscontrol/v3/providers/linode"
_ "github.com/StackExchange/dnscontrol/v3/providers/namecheap" _ "github.com/StackExchange/dnscontrol/v3/providers/namecheap"
_ "github.com/StackExchange/dnscontrol/v3/providers/namedotcom" _ "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/ns1"
_ "github.com/StackExchange/dnscontrol/v3/providers/octodns" _ "github.com/StackExchange/dnscontrol/v3/providers/octodns"
_ "github.com/StackExchange/dnscontrol/v3/providers/opensrs" _ "github.com/StackExchange/dnscontrol/v3/providers/opensrs"

160
providers/netcup/api.go Normal file
View File

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

View File

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

147
providers/netcup/types.go Normal file
View File

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