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/namecheap @captncraig
# providers/namedotcom
providers/netcup @kordianbruck
providers/ns1 @captncraig
# providers/route53
# 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
* `LINODE` @koesie10
* `NAMECHEAP` @captncraig
* `NETCUP` @kordianbruck
* `NS1` @captncraig
* `OCTODNS` @TomOnTime
* `OPENSRS` @pierre-emmanuelJ

View File

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

View File

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

View File

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

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
}