From 5a1c3d21347056084252e0e5360cf4c08790899d Mon Sep 17 00:00:00 2001 From: Laurendus Date: Sat, 8 Sep 2018 14:08:07 +0200 Subject: [PATCH] Add DNS Provider for netcup (#610) --- cli.go | 1 + providers/dns/dns_providers.go | 3 + providers/dns/netcup/client.go | 327 ++++++++++++++++++++++++++++ providers/dns/netcup/client_test.go | 220 +++++++++++++++++++ providers/dns/netcup/netcup.go | 116 ++++++++++ providers/dns/netcup/netcup_test.go | 62 ++++++ 6 files changed, 729 insertions(+) create mode 100644 providers/dns/netcup/client.go create mode 100644 providers/dns/netcup/client_test.go create mode 100644 providers/dns/netcup/netcup.go create mode 100644 providers/dns/netcup/netcup_test.go diff --git a/cli.go b/cli.go index e4d0fd0e..53c83d83 100644 --- a/cli.go +++ b/cli.go @@ -219,6 +219,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tmanual:\tnone") fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") fmt.Fprintln(w, "\tnamedotcom:\tNAMECOM_USERNAME, NAMECOM_API_TOKEN") + fmt.Fprintln(w, "\tnetcup:\tNETCUP_CUSTOMER_NUMBER, NETCUP_API_KEY, NETCUP_API_PASSWORD") fmt.Fprintln(w, "\tnifcloud:\tNIFCLOUD_ACCESS_KEY_ID, NIFCLOUD_SECRET_ACCESS_KEY") fmt.Fprintln(w, "\trackspace:\tRACKSPACE_USER, RACKSPACE_API_KEY") fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 973b8053..e932e77c 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -29,6 +29,7 @@ import ( "github.com/xenolf/lego/providers/dns/linode" "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/namedotcom" + "github.com/xenolf/lego/providers/dns/netcup" "github.com/xenolf/lego/providers/dns/nifcloud" "github.com/xenolf/lego/providers/dns/ns1" "github.com/xenolf/lego/providers/dns/otc" @@ -95,6 +96,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) return namecheap.NewDNSProvider() case "namedotcom": return namedotcom.NewDNSProvider() + case "netcup": + return netcup.NewDNSProvider() case "nifcloud": return nifcloud.NewDNSProvider() case "rackspace": diff --git a/providers/dns/netcup/client.go b/providers/dns/netcup/client.go new file mode 100644 index 00000000..e498d694 --- /dev/null +++ b/providers/dns/netcup/client.go @@ -0,0 +1,327 @@ +package netcup + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/xenolf/lego/acme" +) + +// netcupBaseURL for reaching the jSON-based API-Endpoint of netcup +const netcupBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" + +// success response status +const success = "success" + +// Request wrapper as specified in netcup wiki +// needed for every request to netcup API around *Msg +// https://www.netcup-wiki.de/wiki/CCP_API#Anmerkungen_zu_JSON-Requests +type Request struct { + Action string `json:"action"` + Param interface{} `json:"param"` +} + +// LoginMsg as specified in netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php#login +type LoginMsg struct { + CustomerNumber string `json:"customernumber"` + APIKey string `json:"apikey"` + APIPassword string `json:"apipassword"` + ClientRequestID string `json:"clientrequestid,omitempty"` +} + +// LogoutMsg as specified in netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php#logout +type LogoutMsg struct { + CustomerNumber string `json:"customernumber"` + APIKey string `json:"apikey"` + APISessionID string `json:"apisessionid"` + ClientRequestID string `json:"clientrequestid,omitempty"` +} + +// UpdateDNSRecordsMsg as specified in netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php#updateDnsRecords +type UpdateDNSRecordsMsg struct { + DomainName string `json:"domainname"` + CustomerNumber string `json:"customernumber"` + APIKey string `json:"apikey"` + APISessionID string `json:"apisessionid"` + ClientRequestID string `json:"clientrequestid,omitempty"` + DNSRecordSet DNSRecordSet `json:"dnsrecordset"` +} + +// DNSRecordSet as specified in netcup WSDL +// needed in UpdateDNSRecordsMsg +// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecordset +type DNSRecordSet struct { + DNSRecords []DNSRecord `json:"dnsrecords"` +} + +// InfoDNSRecordsMsg as specified in netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php#infoDnsRecords +type InfoDNSRecordsMsg struct { + DomainName string `json:"domainname"` + CustomerNumber string `json:"customernumber"` + APIKey string `json:"apikey"` + APISessionID string `json:"apisessionid"` + ClientRequestID string `json:"clientrequestid,omitempty"` +} + +// DNSRecord as specified in netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecord +type DNSRecord struct { + ID int `json:"id,string,omitempty"` + Hostname string `json:"hostname"` + RecordType string `json:"type"` + Priority string `json:"priority,omitempty"` + Destination string `json:"destination"` + DeleteRecord bool `json:"deleterecord,omitempty"` + State string `json:"state,omitempty"` +} + +// ResponseMsg as specified in netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Responsemessage +type ResponseMsg struct { + ServerRequestID string `json:"serverrequestid"` + ClientRequestID string `json:"clientrequestid,omitempty"` + Action string `json:"action"` + Status string `json:"status"` + StatusCode int `json:"statuscode"` + ShortMessage string `json:"shortmessage"` + LongMessage string `json:"longmessage"` + ResponseData ResponseData `json:"responsedata,omitempty"` +} + +// LogoutResponseMsg similar to ResponseMsg +// allows empty ResponseData field whilst unmarshaling +type LogoutResponseMsg struct { + ServerRequestID string `json:"serverrequestid"` + ClientRequestID string `json:"clientrequestid,omitempty"` + Action string `json:"action"` + Status string `json:"status"` + StatusCode int `json:"statuscode"` + ShortMessage string `json:"shortmessage"` + LongMessage string `json:"longmessage"` + ResponseData string `json:"responsedata,omitempty"` +} + +// ResponseData to enable correct unmarshaling of ResponseMsg +type ResponseData struct { + APISessionID string `json:"apisessionid"` + DNSRecords []DNSRecord `json:"dnsrecords"` +} + +// Client netcup DNS client +type Client struct { + customerNumber string + apiKey string + apiPassword string + client *http.Client +} + +// NewClient creates a netcup DNS client +func NewClient(httpClient *http.Client, customerNumber string, apiKey string, apiPassword string) *Client { + client := http.DefaultClient + if httpClient != nil { + client = httpClient + } + + return &Client{ + customerNumber: customerNumber, + apiKey: apiKey, + apiPassword: apiPassword, + client: client, + } +} + +// Login performs the login as specified by the netcup WSDL +// returns sessionID needed to perform remaining actions +// https://ccp.netcup.net/run/webservice/servers/endpoint.php +func (c *Client) Login() (string, error) { + payload := &Request{ + Action: "login", + Param: &LoginMsg{ + CustomerNumber: c.customerNumber, + APIKey: c.apiKey, + APIPassword: c.apiPassword, + ClientRequestID: "", + }, + } + + response, err := c.sendRequest(payload) + if err != nil { + return "", fmt.Errorf("netcup: error sending request to DNS-API, %v", err) + } + + var r ResponseMsg + + err = json.Unmarshal(response, &r) + if err != nil { + return "", fmt.Errorf("netcup: error decoding response of DNS-API, %v", err) + } + if r.Status != success { + return "", fmt.Errorf("netcup: error logging into DNS-API, %v", r.LongMessage) + } + return r.ResponseData.APISessionID, nil +} + +// Logout performs the logout with the supplied sessionID as specified by the netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php +func (c *Client) Logout(sessionID string) error { + payload := &Request{ + Action: "logout", + Param: &LogoutMsg{ + CustomerNumber: c.customerNumber, + APIKey: c.apiKey, + APISessionID: sessionID, + ClientRequestID: "", + }, + } + + response, err := c.sendRequest(payload) + if err != nil { + return fmt.Errorf("netcup: error logging out of DNS-API: %v", err) + } + + var r LogoutResponseMsg + + err = json.Unmarshal(response, &r) + if err != nil { + return fmt.Errorf("netcup: error logging out of DNS-API: %v", err) + } + + if r.Status != success { + return fmt.Errorf("netcup: error logging out of DNS-API: %v", r.ShortMessage) + } + return nil +} + +// UpdateDNSRecord performs an update of the DNSRecords as specified by the netcup WSDL +// https://ccp.netcup.net/run/webservice/servers/endpoint.php +func (c *Client) UpdateDNSRecord(sessionID, domainName string, record DNSRecord) error { + payload := &Request{ + Action: "updateDnsRecords", + Param: UpdateDNSRecordsMsg{ + DomainName: domainName, + CustomerNumber: c.customerNumber, + APIKey: c.apiKey, + APISessionID: sessionID, + ClientRequestID: "", + DNSRecordSet: DNSRecordSet{DNSRecords: []DNSRecord{record}}, + }, + } + + response, err := c.sendRequest(payload) + if err != nil { + return fmt.Errorf("netcup: %v", err) + } + + var r ResponseMsg + + err = json.Unmarshal(response, &r) + if err != nil { + return fmt.Errorf("netcup: %v", err) + } + + if r.Status != success { + return fmt.Errorf("netcup: %s: %+v", r.ShortMessage, r) + } + return nil +} + +// GetDNSRecords retrieves all dns records of an DNS-Zone as specified by the netcup WSDL +// returns an array of DNSRecords +// https://ccp.netcup.net/run/webservice/servers/endpoint.php +func (c *Client) GetDNSRecords(hostname, apiSessionID string) ([]DNSRecord, error) { + payload := &Request{ + Action: "infoDnsRecords", + Param: InfoDNSRecordsMsg{ + DomainName: hostname, + CustomerNumber: c.customerNumber, + APIKey: c.apiKey, + APISessionID: apiSessionID, + ClientRequestID: "", + }, + } + + response, err := c.sendRequest(payload) + if err != nil { + return nil, fmt.Errorf("netcup: %v", err) + } + + var r ResponseMsg + + err = json.Unmarshal(response, &r) + if err != nil { + return nil, fmt.Errorf("netcup: %v", err) + } + + if r.Status != success { + return nil, fmt.Errorf("netcup: %s", r.ShortMessage) + } + return r.ResponseData.DNSRecords, nil + +} + +// sendRequest marshals given body to JSON, send the request to netcup API +// and returns body of response +func (c *Client) sendRequest(payload interface{}) ([]byte, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("netcup: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, netcupBaseURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("netcup: %v", err) + } + req.Close = true + + req.Header.Set("content-type", "application/json") + req.Header.Set("User-Agent", acme.UserAgent) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("netcup: %v", err) + } + + if resp.StatusCode > 299 { + return nil, fmt.Errorf("netcup: API request failed with HTTP Status code %d", resp.StatusCode) + } + + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("netcup: read of response body failed, %v", err) + } + defer resp.Body.Close() + + return body, nil +} + +// GetDNSRecordIdx searches a given array of DNSRecords for a given DNSRecord +// equivalence is determined by Destination and RecortType attributes +// returns index of given DNSRecord in given array of DNSRecords +func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) { + for index, element := range records { + if record.Destination == element.Destination && record.RecordType == element.RecordType { + return index, nil + } + } + return -1, fmt.Errorf("netcup: no DNS Record found") +} + +// CreateTxtRecord uses the supplied values to return a DNSRecord of type TXT for the dns-01 challenge +func CreateTxtRecord(hostname, value string) DNSRecord { + return DNSRecord{ + ID: 0, + Hostname: hostname, + RecordType: "TXT", + Priority: "", + Destination: value, + DeleteRecord: false, + State: "", + } +} diff --git a/providers/dns/netcup/client_test.go b/providers/dns/netcup/client_test.go new file mode 100644 index 00000000..6fd446c4 --- /dev/null +++ b/providers/dns/netcup/client_test.go @@ -0,0 +1,220 @@ +package netcup + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/xenolf/lego/acme" +) + +func TestClientAuth(t *testing.T) { + if !testLive { + t.Skip("skipping live test") + } + + // Setup + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword) + + for i := 1; i < 4; i++ { + i := i + t.Run("Test"+string(i), func(t *testing.T) { + t.Parallel() + + sessionID, err := client.Login() + assert.NoError(t, err) + + err = client.Logout(sessionID) + assert.NoError(t, err) + }) + } + +} + +func TestClientGetDnsRecords(t *testing.T) { + if !testLive { + t.Skip("skipping live test") + } + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword) + + // Setup + sessionID, err := client.Login() + assert.NoError(t, err) + + fqdn, _, _ := acme.DNS01Record(testDomain, "123d==") + + zone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + assert.NoError(t, err, "error finding DNSZone") + + zone = acme.UnFqdn(zone) + + // TestMethod + _, err = client.GetDNSRecords(zone, sessionID) + assert.NoError(t, err) + + // Tear down + err = client.Logout(sessionID) + assert.NoError(t, err) +} + +func TestClientUpdateDnsRecord(t *testing.T) { + if !testLive { + t.Skip("skipping live test") + } + + // Setup + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword) + + sessionID, err := client.Login() + assert.NoError(t, err) + + fqdn, _, _ := acme.DNS01Record(testDomain, "123d==") + + zone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + assert.NoError(t, err, fmt.Errorf("error finding DNSZone, %v", err)) + + hostname := strings.Replace(fqdn, "."+zone, "", 1) + + record := CreateTxtRecord(hostname, "asdf5678") + + // test + zone = acme.UnFqdn(zone) + + err = client.UpdateDNSRecord(sessionID, zone, record) + assert.NoError(t, err) + + records, err := client.GetDNSRecords(zone, sessionID) + assert.NoError(t, err) + + recordIdx, err := GetDNSRecordIdx(records, record) + assert.NoError(t, err) + + assert.Equal(t, record.Hostname, records[recordIdx].Hostname) + assert.Equal(t, record.RecordType, records[recordIdx].RecordType) + assert.Equal(t, record.Destination, records[recordIdx].Destination) + assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord) + + records[recordIdx].DeleteRecord = true + + // Tear down + err = client.UpdateDNSRecord(sessionID, testDomain, records[recordIdx]) + assert.NoError(t, err, "Did not remove record! Please do so yourself.") + + err = client.Logout(sessionID) + assert.NoError(t, err) +} + +func TestClientGetDnsRecordIdx(t *testing.T) { + records := []DNSRecord{ + { + ID: 12345, + Hostname: "asdf", + RecordType: "TXT", + Priority: "0", + Destination: "randomtext", + DeleteRecord: false, + State: "yes", + }, + { + ID: 23456, + Hostname: "@", + RecordType: "A", + Priority: "0", + Destination: "127.0.0.1", + DeleteRecord: false, + State: "yes", + }, + { + ID: 34567, + Hostname: "dfgh", + RecordType: "CNAME", + Priority: "0", + Destination: "example.com", + DeleteRecord: false, + State: "yes", + }, + { + ID: 45678, + Hostname: "fghj", + RecordType: "MX", + Priority: "10", + Destination: "mail.example.com", + DeleteRecord: false, + State: "yes", + }, + } + + testCases := []struct { + desc string + record DNSRecord + expectError bool + }{ + { + desc: "simple", + record: DNSRecord{ + ID: 12345, + Hostname: "asdf", + RecordType: "TXT", + Priority: "0", + Destination: "randomtext", + DeleteRecord: false, + State: "yes", + }, + }, + { + desc: "wrong Destination", + record: DNSRecord{ + ID: 12345, + Hostname: "asdf", + RecordType: "TXT", + Priority: "0", + Destination: "wrong", + DeleteRecord: false, + State: "yes", + }, + expectError: true, + }, + { + desc: "record type CNAME", + record: DNSRecord{ + ID: 12345, + Hostname: "asdf", + RecordType: "CNAME", + Priority: "0", + Destination: "randomtext", + DeleteRecord: false, + State: "yes", + }, + expectError: true, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + idx, err := GetDNSRecordIdx(records, test.record) + if test.expectError { + assert.Error(t, err) + assert.Equal(t, -1, idx) + } else { + assert.NoError(t, err) + assert.Equal(t, records[idx], test.record) + } + }) + } +} diff --git a/providers/dns/netcup/netcup.go b/providers/dns/netcup/netcup.go new file mode 100644 index 00000000..e7cc4c6b --- /dev/null +++ b/providers/dns/netcup/netcup.go @@ -0,0 +1,116 @@ +// Package netcup implements a DNS Provider for solving the DNS-01 challenge using the netcup DNS API. +package netcup + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + client *Client +} + +// NewDNSProvider returns a DNSProvider instance configured for netcup. +// Credentials must be passed in the environment variables: NETCUP_CUSTOMER_NUMBER, +// NETCUP_API_KEY, NETCUP_API_PASSWORD +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("NETCUP_CUSTOMER_NUMBER", "NETCUP_API_KEY", "NETCUP_API_PASSWORD") + if err != nil { + return nil, fmt.Errorf("netcup: %v", err) + } + + return NewDNSProviderCredentials(values["NETCUP_CUSTOMER_NUMBER"], values["NETCUP_API_KEY"], values["NETCUP_API_PASSWORD"]) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for netcup. +func NewDNSProviderCredentials(customer, key, password string) (*DNSProvider, error) { + if customer == "" || key == "" || password == "" { + return nil, fmt.Errorf("netcup: netcup credentials missing") + } + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + return &DNSProvider{ + client: NewClient(httpClient, customer, key, password), + }, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge +func (d *DNSProvider) Present(domainName, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domainName, keyAuth) + + zone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("netcup: failed to find DNSZone, %v", err) + } + + sessionID, err := d.client.Login() + if err != nil { + return err + } + + hostname := strings.Replace(fqdn, "."+zone, "", 1) + record := CreateTxtRecord(hostname, value) + + err = d.client.UpdateDNSRecord(sessionID, acme.UnFqdn(zone), record) + if err != nil { + if errLogout := d.client.Logout(sessionID); errLogout != nil { + return fmt.Errorf("failed to add TXT-Record: %v; %v", err, errLogout) + } + return fmt.Errorf("failed to add TXT-Record: %v", err) + } + + return d.client.Logout(sessionID) +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domainname, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domainname, keyAuth) + + zone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("failed to find DNSZone, %v", err) + } + + sessionID, err := d.client.Login() + if err != nil { + return err + } + + hostname := strings.Replace(fqdn, "."+zone, "", 1) + + zone = acme.UnFqdn(zone) + + records, err := d.client.GetDNSRecords(zone, sessionID) + if err != nil { + return err + } + + record := CreateTxtRecord(hostname, value) + + idx, err := GetDNSRecordIdx(records, record) + if err != nil { + return err + } + + records[idx].DeleteRecord = true + + err = d.client.UpdateDNSRecord(sessionID, zone, records[idx]) + if err != nil { + if errLogout := d.client.Logout(sessionID); errLogout != nil { + return fmt.Errorf("%v; %v", err, errLogout) + } + return err + } + + return d.client.Logout(sessionID) +} diff --git a/providers/dns/netcup/netcup_test.go b/providers/dns/netcup/netcup_test.go new file mode 100644 index 00000000..1c8c1b8a --- /dev/null +++ b/providers/dns/netcup/netcup_test.go @@ -0,0 +1,62 @@ +package netcup + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/xenolf/lego/acme" +) + +var ( + testLive bool + testCustomerNumber string + testAPIKey string + testAPIPassword string + testDomain string +) + +func init() { + testCustomerNumber = os.Getenv("NETCUP_CUSTOMER_NUMBER") + testAPIKey = os.Getenv("NETCUP_API_KEY") + testAPIPassword = os.Getenv("NETCUP_API_PASSWORD") + testDomain = os.Getenv("NETCUP_DOMAIN") + + if len(testCustomerNumber) > 0 && len(testAPIKey) > 0 && len(testAPIPassword) > 0 && len(testDomain) > 0 { + testLive = true + } +} + +func TestDNSProviderPresentAndCleanup(t *testing.T) { + if !testLive { + t.Skip("skipping live test") + } + + p, err := NewDNSProvider() + assert.NoError(t, err) + + fqdn, _, _ := acme.DNS01Record(testDomain, "123d==") + + zone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + assert.NoError(t, err, "error finding DNSZone") + + zone = acme.UnFqdn(zone) + + testCases := []string{ + zone, + "sub." + zone, + "*." + zone, + "*.sub." + zone, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("domain(%s)", tc), func(t *testing.T) { + err = p.Present(tc, "987d", "123d==") + assert.NoError(t, err) + + err = p.CleanUp(tc, "987d", "123d==") + assert.NoError(t, err, "Did not clean up! Please remove record yourself.") + }) + } +}