From 83e2300e01226dcb006946873ca5434291fb16ef Mon Sep 17 00:00:00 2001 From: evs-ch Date: Sat, 15 Sep 2018 23:25:14 +0200 Subject: [PATCH] Add DNS provider for hosting.de (#624) --- cli.go | 1 + providers/dns/dns_providers.go | 3 + providers/dns/hostingde/client.go | 91 ++++++++++ providers/dns/hostingde/hostingde.go | 209 ++++++++++++++++++++++ providers/dns/hostingde/hostingde_test.go | 104 +++++++++++ 5 files changed, 408 insertions(+) create mode 100644 providers/dns/hostingde/client.go create mode 100644 providers/dns/hostingde/hostingde.go create mode 100644 providers/dns/hostingde/hostingde_test.go diff --git a/cli.go b/cli.go index 2ff318ab..f1ddb9d1 100644 --- a/cli.go +++ b/cli.go @@ -218,6 +218,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tgandiv5:\tGANDIV5_API_KEY") fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT, GCE_SERVICE_ACCOUNT_FILE") fmt.Fprintln(w, "\tglesys:\tGLESYS_API_USER, GLESYS_API_KEY") + fmt.Fprintln(w, "\thostingde:\tHOSTINGDE_API_KEY, HOSTINGDE_ZONE_NAME") fmt.Fprintln(w, "\tiij:\tIIJ_API_ACCESS_KEY, IIJ_API_SECRET_KEY, IIJ_DO_SERVICE_CODE") fmt.Fprintln(w, "\tlinode:\tLINODE_API_KEY") fmt.Fprintln(w, "\tlightsail:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, DNS_ZONE") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 5ef34da5..38a1fd4c 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -25,6 +25,7 @@ import ( "github.com/xenolf/lego/providers/dns/gcloud" "github.com/xenolf/lego/providers/dns/glesys" "github.com/xenolf/lego/providers/dns/godaddy" + "github.com/xenolf/lego/providers/dns/hostingde" "github.com/xenolf/lego/providers/dns/iij" "github.com/xenolf/lego/providers/dns/lightsail" "github.com/xenolf/lego/providers/dns/linode" @@ -87,6 +88,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) return gcloud.NewDNSProvider() case "godaddy": return godaddy.NewDNSProvider() + case "hostingde": + return hostingde.NewDNSProvider() case "iij": return iij.NewDNSProvider() case "lightsail": diff --git a/providers/dns/hostingde/client.go b/providers/dns/hostingde/client.go new file mode 100644 index 00000000..0bee9ccc --- /dev/null +++ b/providers/dns/hostingde/client.go @@ -0,0 +1,91 @@ +package hostingde + +// RecordsAddRequest represents a DNS record to add +type RecordsAddRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` +} + +// RecordsDeleteRequest represents a DNS record to remove +type RecordsDeleteRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + ID string `json:"id"` +} + +// ZoneConfigObject represents the ZoneConfig-section of a hosting.de API response. +type ZoneConfigObject struct { + AccountID string `json:"accountId"` + EmailAddress string `json:"emailAddress"` + ID string `json:"id"` + LastChangeDate string `json:"lastChangeDate"` + MasterIP string `json:"masterIp"` + Name string `json:"name"` + NameUnicode string `json:"nameUnicode"` + SOAValues struct { + Expire int `json:"expire"` + NegativeTTL int `json:"negativeTtl"` + Refresh int `json:"refresh"` + Retry int `json:"retry"` + Serial string `json:"serial"` + TTL int `json:"ttl"` + } `json:"soaValues"` + Status string `json:"status"` + TemplateValues string `json:"templateValues"` + Type string `json:"type"` + ZoneTransferWhitelist []string `json:"zoneTransferWhitelist"` +} + +// ZoneUpdateError represents an error in a ZoneUpdateResponse +type ZoneUpdateError struct { + Code int `json:"code"` + ContextObject string `json:"contextObject"` + ContextPath string `json:"contextPath"` + Details []string `json:"details"` + Text string `json:"text"` + Value string `json:"value"` +} + +// ZoneUpdateMetadata represents the metadata in a ZoneUpdateResponse +type ZoneUpdateMetadata struct { + ClientTransactionID string `json:"clientTransactionId"` + ServerTransactionID string `json:"serverTransactionId"` +} + +// ZoneUpdateResponse represents a response from hosting.de API +type ZoneUpdateResponse struct { + Errors []ZoneUpdateError `json:"errors"` + Metadata ZoneUpdateMetadata `json:"metadata"` + Warnings []string `json:"warnings"` + Status string `json:"status"` + Response struct { + Records []struct { + Content string `json:"content"` + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + LastChangeDate string `json:"lastChangeDate"` + Priority int `json:"priority"` + RecordTemplateID string `json:"recordTemplateId"` + ZoneConfigID string `json:"zoneConfigId"` + TTL int `json:"ttl"` + } `json:"records"` + ZoneConfig ZoneConfigObject `json:"zoneConfig"` + } `json:"response"` +} + +// ZoneConfigSelector represents a "minimal" ZoneConfig object used in hosting.de API requests +type ZoneConfigSelector struct { + Name string `json:"name"` +} + +// ZoneUpdateRequest represents a hosting.de API ZoneUpdate request +type ZoneUpdateRequest struct { + AuthToken string `json:"authToken"` + ZoneConfigSelector `json:"zoneConfig"` + RecordsToAdd []RecordsAddRequest `json:"recordsToAdd"` + RecordsToDelete []RecordsDeleteRequest `json:"recordsToDelete"` +} diff --git a/providers/dns/hostingde/hostingde.go b/providers/dns/hostingde/hostingde.go new file mode 100644 index 00000000..7f8280aa --- /dev/null +++ b/providers/dns/hostingde/hostingde.go @@ -0,0 +1,209 @@ +// Package hostingde implements a DNS provider for solving the DNS-01 +// challenge using hosting.de. +package hostingde + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "sync" + "time" + + "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" +) + +const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json" + +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + ZoneName string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("HOSTINGDE_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("HOSTINGDE_PROPAGATION_TIMEOUT", 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond("HOSTINGDE_POLLING_INTERVAL", 2*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("HOSTINGDE_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + config *Config + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for hosting.de. +// Credentials must be passed in the environment variables: +// HOSTINGDE_ZONE_NAME and HOSTINGDE_API_KEY +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("HOSTINGDE_API_KEY", "HOSTINGDE_ZONE_NAME") + if err != nil { + return nil, fmt.Errorf("hostingde: %v", err) + } + + config := NewDefaultConfig() + config.APIKey = values["HOSTINGDE_API_KEY"] + config.ZoneName = values["HOSTINGDE_ZONE_NAME"] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for hosting.de. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("hostingde: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("hostingde: API key missing") + } + + if config.ZoneName == "" { + return nil, errors.New("hostingde: Zone Name missing") + } + + return &DNSProvider{ + config: config, + recordIDs: make(map[string]string), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfil the dns-01 challenge +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + rec := []RecordsAddRequest{{ + Type: "TXT", + Name: acme.UnFqdn(fqdn), + Content: value, + TTL: d.config.TTL, + }} + + req := ZoneUpdateRequest{ + AuthToken: d.config.APIKey, + ZoneConfigSelector: ZoneConfigSelector{ + Name: d.config.ZoneName, + }, + RecordsToAdd: rec, + } + + resp, err := d.updateZone(req) + if err != nil { + return fmt.Errorf("hostingde: %v", err) + } + + for _, record := range resp.Response.Records { + if record.Name == acme.UnFqdn(fqdn) && record.Content == fmt.Sprintf(`"%s"`, value) { + d.recordIDsMu.Lock() + d.recordIDs[fqdn] = record.ID + d.recordIDsMu.Unlock() + } + } + + if d.recordIDs[fqdn] == "" { + return fmt.Errorf("hostingde: error getting ID of just created record, for domain %s", domain) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + // get the record's unique ID from when we created it + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[fqdn] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("hostingde: unknown record ID for %q", fqdn) + } + + rec := []RecordsDeleteRequest{{ + Type: "TXT", + Name: acme.UnFqdn(fqdn), + Content: value, + ID: recordID, + }} + + req := ZoneUpdateRequest{ + AuthToken: d.config.APIKey, + ZoneConfigSelector: ZoneConfigSelector{ + Name: d.config.ZoneName, + }, + RecordsToDelete: rec, + } + + // Delete record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, fqdn) + d.recordIDsMu.Unlock() + + _, err := d.updateZone(req) + if err != nil { + return fmt.Errorf("hostingde: %v", err) + } + return nil +} + +func (d *DNSProvider) updateZone(updateRequest ZoneUpdateRequest) (*ZoneUpdateResponse, error) { + body, err := json.Marshal(updateRequest) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, defaultBaseURL+"/zoneUpdate", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + resp, err := d.config.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error querying API: %v", err) + } + + defer resp.Body.Close() + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.New(toUnreadableBodyMessage(req, content)) + } + + // Everything looks good; but we'll need the ID later to delete the record + updateResponse := &ZoneUpdateResponse{} + err = json.Unmarshal(content, updateResponse) + if err != nil { + return nil, fmt.Errorf("%v: %s", err, toUnreadableBodyMessage(req, content)) + } + + if updateResponse.Status != "success" && updateResponse.Status != "pending" { + return updateResponse, errors.New(toUnreadableBodyMessage(req, content)) + } + + return updateResponse, nil +} + +func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { + return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) +} diff --git a/providers/dns/hostingde/hostingde_test.go b/providers/dns/hostingde/hostingde_test.go new file mode 100644 index 00000000..c948c2aa --- /dev/null +++ b/providers/dns/hostingde/hostingde_test.go @@ -0,0 +1,104 @@ +package hostingde + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + hostingdeLiveTest bool + hostingdeAPIKey string + hostingdeZone string + hostingdeDomain string +) + +func init() { + hostingdeAPIKey = os.Getenv("HOSTINGDE_API_KEY") + hostingdeZone = os.Getenv("HOSTINGDE_ZONE_NAME") + hostingdeDomain = os.Getenv("HOSTINGDE_DOMAIN") + if len(hostingdeZone) > 0 && len(hostingdeAPIKey) > 0 && len(hostingdeDomain) > 0 { + hostingdeLiveTest = true + } +} + +func restoreEnv() { + os.Setenv("HOSTINGDE_ZONE_NAME", hostingdeZone) + os.Setenv("HOSTINGDE_API_KEY", hostingdeAPIKey) +} + +func TestNewDNSProviderValid(t *testing.T) { + os.Setenv("HOSTINGDE_ZONE_NAME", "") + os.Setenv("HOSTINGDE_API_KEY", "") + defer restoreEnv() + + config := NewDefaultConfig() + config.APIKey = "123" + config.ZoneName = "example.com" + + _, err := NewDNSProviderConfig(config) + assert.NoError(t, err) +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + defer restoreEnv() + os.Setenv("HOSTINGDE_ZONE_NAME", "example.com") + os.Setenv("HOSTINGDE_API_KEY", "123") + + _, err := NewDNSProvider() + assert.NoError(t, err) +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + defer restoreEnv() + os.Setenv("HOSTINGDE_ZONE_NAME", "") + os.Setenv("HOSTINGDE_API_KEY", "") + + _, err := NewDNSProvider() + assert.EqualError(t, err, "hostingde: some credentials information are missing: HOSTINGDE_API_KEY,HOSTINGDE_ZONE_NAME") +} + +func TestNewDNSProviderMissingCredErrSingle(t *testing.T) { + defer restoreEnv() + os.Setenv("HOSTINGDE_ZONE_NAME", "example.com") + + _, err := NewDNSProvider() + assert.EqualError(t, err, "hostingde: some credentials information are missing: HOSTINGDE_API_KEY") +} + +func TestHostingdePresent(t *testing.T) { + if !hostingdeLiveTest { + t.Skip("skipping live test") + } + + config := NewDefaultConfig() + config.APIKey = hostingdeZone + config.ZoneName = hostingdeAPIKey + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + err = provider.Present(hostingdeDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestHostingdeCleanUp(t *testing.T) { + if !hostingdeLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 2) + + config := NewDefaultConfig() + config.APIKey = hostingdeZone + config.ZoneName = hostingdeAPIKey + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + err = provider.CleanUp(hostingdeDomain, "", "123d==") + assert.NoError(t, err) +}