hostingde: fix client fails if customer has no access to dns-groups (#809)

This commit is contained in:
jkahrs 2019-02-26 15:25:34 +01:00 committed by Ludovic Fernandez
parent 7f6b708439
commit a144800896
3 changed files with 268 additions and 124 deletions

View file

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

View file

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

View file

@ -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 zones 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"`
}