diff --git a/providers/dns/hostingde/client.go b/providers/dns/hostingde/client.go index 6f2c9635..b1a23a36 100644 --- a/providers/dns/hostingde/client.go +++ b/providers/dns/hostingde/client.go @@ -2,112 +2,102 @@ package hostingde import ( "bytes" + "context" "encoding/json" "errors" "fmt" "io/ioutil" "net/http" + "time" + + "github.com/cenkalti/backoff" ) const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json" -// 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"` -} +// https://www.hosting.de/api/?json#list-zoneconfigs +func (d *DNSProvider) listZoneConfigs(findRequest ZoneConfigsFindRequest) (*ZoneConfigsFindResponse, error) { + uri := defaultBaseURL + "/zoneConfigsFind" -// 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"` -} + findResponse := &ZoneConfigsFindResponse{} -// 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"` -} - -func (d *DNSProvider) updateZone(updateRequest ZoneUpdateRequest) (*ZoneUpdateResponse, error) { - body, err := json.Marshal(updateRequest) + rawResp, err := d.post(uri, findRequest, findResponse) if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPost, defaultBaseURL+"/zoneUpdate", bytes.NewReader(body)) + if len(findResponse.Response.Data) == 0 { + return nil, fmt.Errorf("%v: %s", err, toUnreadableBodyMessage(uri, rawResp)) + } + + if findResponse.Status != "success" && findResponse.Status != "pending" { + return findResponse, errors.New(toUnreadableBodyMessage(uri, rawResp)) + } + + return findResponse, nil +} + +// https://www.hosting.de/api/?json#updating-zones +func (d *DNSProvider) updateZone(updateRequest ZoneUpdateRequest) (*ZoneUpdateResponse, error) { + uri := defaultBaseURL + "/zoneUpdate" + + // but we'll need the ID later to delete the record + updateResponse := &ZoneUpdateResponse{} + + rawResp, err := d.post(uri, updateRequest, updateResponse) + if err != nil { + return nil, err + } + + if updateResponse.Status != "success" && updateResponse.Status != "pending" { + return nil, errors.New(toUnreadableBodyMessage(uri, rawResp)) + } + + return updateResponse, nil +} + +func (d *DNSProvider) getZone(findRequest ZoneConfigsFindRequest) (*ZoneConfig, error) { + ctx, cancel := context.WithCancel(context.Background()) + + var zoneConfig *ZoneConfig + + operation := func() error { + findResponse, err := d.listZoneConfigs(findRequest) + if err != nil { + cancel() + return err + } + + if findResponse.Response.Data[0].Status != "active" { + return fmt.Errorf("unexpected status: %q", findResponse.Response.Data[0].Status) + } + + zoneConfig = &findResponse.Response.Data[0] + + return nil + } + + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = 3 * time.Second + bo.MaxInterval = 10 * bo.InitialInterval + bo.MaxElapsedTime = 100 * bo.InitialInterval + + // retry in case the zone was edited recently and is not yet active + err := backoff.Retry(operation, backoff.WithContext(bo, ctx)) + if err != nil { + return nil, err + } + + return zoneConfig, nil +} + +func (d *DNSProvider) post(uri string, request interface{}, response interface{}) ([]byte, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, uri, bytes.NewReader(body)) if err != nil { return nil, err } @@ -121,23 +111,17 @@ func (d *DNSProvider) updateZone(updateRequest ZoneUpdateRequest) (*ZoneUpdateRe content, err := ioutil.ReadAll(resp.Body) if err != nil { - return nil, errors.New(toUnreadableBodyMessage(req, content)) + return nil, errors.New(toUnreadableBodyMessage(uri, content)) } - // Everything looks good; but we'll need the ID later to delete the record - updateResponse := &ZoneUpdateResponse{} - err = json.Unmarshal(content, updateResponse) + err = json.Unmarshal(content, response) if err != nil { - return nil, fmt.Errorf("%v: %s", err, toUnreadableBodyMessage(req, content)) + return nil, fmt.Errorf("%v: %s", err, toUnreadableBodyMessage(uri, content)) } - if updateResponse.Status != "success" && updateResponse.Status != "pending" { - return updateResponse, errors.New(toUnreadableBodyMessage(req, content)) - } - - return updateResponse, nil + return content, 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)) +func toUnreadableBodyMessage(uri string, rawBody []byte) string { + return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", uri, string(rawBody)) } diff --git a/providers/dns/hostingde/hostingde.go b/providers/dns/hostingde/hostingde.go index 738771bf..bba7bec0 100644 --- a/providers/dns/hostingde/hostingde.go +++ b/providers/dns/hostingde/hostingde.go @@ -87,7 +87,24 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value := dns01.GetRecord(domain, keyAuth) - rec := []RecordsAddRequest{{ + // get the ZoneConfig for that domain + zonesFind := ZoneConfigsFindRequest{ + Filter: Filter{ + Field: "zoneName", + Value: domain, + }, + Limit: 1, + Page: 1, + } + zonesFind.AuthToken = d.config.APIKey + + zoneConfig, err := d.getZone(zonesFind) + if err != nil { + return fmt.Errorf("hostingde: %v", err) + } + zoneConfig.Name = d.config.ZoneName + + rec := []DNSRecord{{ Type: "TXT", Name: dns01.UnFqdn(fqdn), Content: value, @@ -95,12 +112,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { }} req := ZoneUpdateRequest{ - AuthToken: d.config.APIKey, - ZoneConfigSelector: ZoneConfigSelector{ - Name: d.config.ZoneName, - }, + ZoneConfig: *zoneConfig, RecordsToAdd: rec, } + req.AuthToken = d.config.APIKey resp, err := d.updateZone(req) if err != nil { @@ -126,35 +141,41 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, value := dns01.GetRecord(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{{ + rec := []DNSRecord{{ Type: "TXT", Name: dns01.UnFqdn(fqdn), - Content: value, - ID: recordID, + Content: `"` + value + `"`, }} - req := ZoneUpdateRequest{ - AuthToken: d.config.APIKey, - ZoneConfigSelector: ZoneConfigSelector{ - Name: d.config.ZoneName, + // get the ZoneConfig for that domain + zonesFind := ZoneConfigsFindRequest{ + Filter: Filter{ + Field: "zoneName", + Value: domain, }, + Limit: 1, + Page: 1, + } + zonesFind.AuthToken = d.config.APIKey + + zoneConfig, err := d.getZone(zonesFind) + if err != nil { + return fmt.Errorf("hostingde: %v", err) + } + zoneConfig.Name = d.config.ZoneName + + req := ZoneUpdateRequest{ + ZoneConfig: *zoneConfig, RecordsToDelete: rec, } + req.AuthToken = d.config.APIKey // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, fqdn) d.recordIDsMu.Unlock() - _, err := d.updateZone(req) + _, err = d.updateZone(req) if err != nil { return fmt.Errorf("hostingde: %v", err) } diff --git a/providers/dns/hostingde/model.go b/providers/dns/hostingde/model.go new file mode 100644 index 00000000..9c67784b --- /dev/null +++ b/providers/dns/hostingde/model.go @@ -0,0 +1,139 @@ +package hostingde + +import "encoding/json" + +// APIError represents an error in an API response. +// https://www.hosting.de/api/?json#warnings-and-errors +type APIError 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"` +} + +// Filter is used to filter FindRequests to the API. +// https://www.hosting.de/api/?json#filter-object +type Filter struct { + Field string `json:"field"` + Value string `json:"value"` +} + +// Sort is used to sort FindRequests from the API. +// https://www.hosting.de/api/?json#filtering-and-sorting +type Sort struct { + Field string `json:"zoneName"` + Order string `json:"order"` +} + +// Metadata represents the metadata in an API response. +// https://www.hosting.de/api/?json#metadata-object +type Metadata struct { + ClientTransactionID string `json:"clientTransactionId"` + ServerTransactionID string `json:"serverTransactionId"` +} + +// ZoneConfig The ZoneConfig object defines a zone. +// https://www.hosting.de/api/?json#the-zoneconfig-object +type ZoneConfig struct { + ID string `json:"id"` + AccountID string `json:"accountId"` + Status string `json:"status"` + Name string `json:"name"` + NameUnicode string `json:"nameUnicode"` + MasterIP string `json:"masterIp"` + Type string `json:"type"` + EMailAddress string `json:"emailAddress"` + ZoneTransferWhitelist []string `json:"zoneTransferWhitelist"` + LastChangeDate string `json:"lastChangeDate"` + DNSServerGroupID string `json:"dnsServerGroupId"` + DNSSecMode string `json:"dnsSecMode"` + SOAValues *SOAValues `json:"soaValues,omitempty"` + TemplateValues json.RawMessage `json:"templateValues,omitempty"` +} + +// SOAValues The SOA values object contains the time (seconds) used in a zone’s SOA record. +// https://www.hosting.de/api/?json#the-soa-values-object +type SOAValues struct { + Refresh int `json:"refresh"` + Retry int `json:"retry"` + Expire int `json:"expire"` + TTL int `json:"ttl"` + NegativeTTL int `json:"negativeTtl"` +} + +// DNSRecord The DNS Record object is part of a zone. It is used to manage DNS resource records. +// https://www.hosting.de/api/?json#the-record-object +type DNSRecord struct { + ID string `json:"id,omitempty"` + ZoneID string `json:"zoneId,omitempty"` + RecordTemplateID string `json:"recordTemplateId,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl,omitempty"` + Priority int `json:"priority,omitempty"` + LastChangeDate string `json:"lastChangeDate,omitempty"` +} + +// Zone The Zone Object. +// https://www.hosting.de/api/?json#the-zone-object +type Zone struct { + Records []DNSRecord `json:"records"` + ZoneConfig ZoneConfig `json:"zoneConfig"` +} + +// ZoneUpdateRequest represents a API ZoneUpdate request. +// https://www.hosting.de/api/?json#updating-zones +type ZoneUpdateRequest struct { + BaseRequest + ZoneConfig `json:"zoneConfig"` + RecordsToAdd []DNSRecord `json:"recordsToAdd"` + RecordsToDelete []DNSRecord `json:"recordsToDelete"` +} + +// ZoneUpdateResponse represents a response from the API. +// https://www.hosting.de/api/?json#updating-zones +type ZoneUpdateResponse struct { + BaseResponse + Response Zone `json:"response"` +} + +// ZoneConfigsFindRequest represents a API ZonesFind request. +// https://www.hosting.de/api/?json#list-zoneconfigs +type ZoneConfigsFindRequest struct { + BaseRequest + Filter Filter `json:"filter"` + Limit int `json:"limit"` + Page int `json:"page"` + Sort *Sort `json:"sort,omitempty"` +} + +// ZoneConfigsFindResponse represents the API response for ZoneConfigsFind. +// https://www.hosting.de/api/?json#list-zoneconfigs +type ZoneConfigsFindResponse struct { + BaseResponse + Response struct { + Limit int `json:"limit"` + Page int `json:"page"` + TotalEntries int `json:"totalEntries"` + TotalPages int `json:"totalPages"` + Type string `json:"type"` + Data []ZoneConfig `json:"data"` + } `json:"response"` +} + +// BaseResponse Common response struct. +// https://www.hosting.de/api/?json#responses +type BaseResponse struct { + Errors []APIError `json:"errors"` + Metadata Metadata `json:"metadata"` + Warnings []string `json:"warnings"` + Status string `json:"status"` +} + +// BaseRequest Common request struct. +type BaseRequest struct { + AuthToken string `json:"authToken"` +}