From 2b50af0cbcc14d57e8fefd74c572740cbf560b06 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 22 Oct 2020 15:44:21 +0200 Subject: [PATCH] NEW PROVIDER: HETZNER DNS Console (#904) * HETZNER: implement the provider for Hetzner DNS Console Signed-off-by: Jakob Ackermann * HETZNER: apply review feedback - add domain into error messages - insert sub-strings using `%q` - insert sub-errors using `%w` - change api.getZone() signature to return a (potentially `nil`) Zone pointer instead of a (potentially empty) Zone value - sort imports and confirm with `$ goimports -w providers/hetzner/` - use exact 'api_key' term in error message of settings validation - add blank line for logic separation - drop internal record id from correction messages Co-Authored-By: Tom Limoncelli Signed-off-by: Jakob Ackermann * HETZNER: add request rate-limiting handling There are a limited number of data-points on how their rate-limiting works at this time. I deduce from my account to others and use a fixed/ constant backoff of 1s as the initial delay. Thereafter exponential increase with factor 2 (not needed at this time). Hetzner has not made any official statements on rate-limiting, so this is guesswork only. Signed-off-by: Jakob Ackermann * HETZNER: address golint complaints - baseUrl -> baseURL - mark Record as private -> record - mark Zone as private -> zone - mark RequestRateLimiter as private -> requestRateLimiter - capitalize Id fields as ID - keep delay logic on same level, move return out of branch Signed-off-by: Jakob Ackermann * HETZNER: rate_limited: init the response timestamp on requestRateLimiter Signed-off-by: Jakob Ackermann * HETZNER: requestRateLimiter: align local variable with struct name Signed-off-by: Jakob Ackermann Co-authored-by: Tom Limoncelli --- OWNERS | 1 + README.md | 1 + docs/_includes/matrix.html | 50 ++++++ docs/_providers/hetzner.md | 73 +++++++++ docs/provider-list.md | 1 + integrationTest/integration_test.go | 2 +- integrationTest/providers.json | 5 + providers/_all/all.go | 1 + providers/hetzner/api.go | 232 +++++++++++++++++++++++++++ providers/hetzner/hetznerProvider.go | 173 ++++++++++++++++++++ providers/hetzner/types.go | 88 ++++++++++ 11 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 docs/_providers/hetzner.md create mode 100644 providers/hetzner/api.go create mode 100644 providers/hetzner/hetznerProvider.go create mode 100644 providers/hetzner/types.go diff --git a/OWNERS b/OWNERS index d09584223..94ff95f50 100644 --- a/OWNERS +++ b/OWNERS @@ -11,6 +11,7 @@ providers/dnsimple @aeden providers/gandi_v5 @TomOnTime # providers/gcloud providers/hedns @rblenkinsopp +providers/hetzner @das7pad providers/hexonet @papakai providers/internetbs @pragmaton providers/inwx @svenpeter42 diff --git a/README.md b/README.md index 92c207e87..174e89ab2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Currently supported DNS providers: - Exoscale - Gandi - Google DNS + - Hetzner - HEXONET - Hurricane Electric DNS - INWX diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 39f4ad1c0..bea0e6491 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -21,6 +21,7 @@
GANDI_V5
GCLOUD
HEDNS
+
HETZNER
HEXONET
INTERNETBS
INWX
@@ -86,6 +87,9 @@ + + + @@ -182,6 +186,9 @@ + + + @@ -272,6 +279,9 @@ + + + @@ -353,6 +363,9 @@ + + + @@ -416,6 +429,7 @@ + @@ -480,6 +494,9 @@ + + + @@ -550,6 +567,9 @@ + + + @@ -617,6 +637,7 @@ + @@ -676,6 +697,9 @@ + + + @@ -753,6 +777,9 @@ + + + @@ -814,6 +841,9 @@ + + + @@ -873,6 +903,9 @@ + + + @@ -918,6 +951,7 @@ + @@ -959,6 +993,7 @@ + @@ -1002,6 +1037,9 @@ + + + @@ -1062,6 +1100,9 @@ + + + @@ -1145,6 +1186,9 @@ + + + @@ -1247,6 +1291,9 @@ + + + @@ -1324,6 +1371,9 @@ + + + diff --git a/docs/_providers/hetzner.md b/docs/_providers/hetzner.md new file mode 100644 index 000000000..99f6228a2 --- /dev/null +++ b/docs/_providers/hetzner.md @@ -0,0 +1,73 @@ +--- +name: Hetzner DNS Console +title: Hetzner DNS Console +layout: default +jsId: HETZNER +--- + +# Hetzner DNS Console Provider + +## Configuration + +In your credentials file, you must provide a +[Hetzner API Key](https://dns.hetzner.com/settings/api-token). + +{% highlight json %} +{ + "hetzner": { + "api_key": "your-api-key" + } +} +{% endhighlight %} + +## Metadata + +This provider does not recognize any special metadata fields unique to Hetzner + DNS Console. + +## Usage + +Example Javascript: + +{% highlight js %} +var REG_NONE = NewRegistrar('none', 'NONE'); +var HETZNER = NewDnsProvider("hetzner", "HETZNER"); + +D("example.tld", REG_NONE, DnsProvider(HETZNER), + A("test","1.2.3.4") +); +{%endhighlight%} + +## Activation + +Create a new API Key in the +[Hetzner DNS Console](https://dns.hetzner.com/settings/api-token). + +## Caveats + +### SOA + +Hetzner DNS Console does not allow changing the SOA record via their API. +There is an alternative method using an import of a full BIND file, but this + approach does not play nice with incremental changes or ignored records. +At this time you cannot update SOA records via DNSControl. + +### Rate Limiting + +In case you are frequently seeing messages about being rate-limited: + +{% highlight txt %} +WARNING: request rate-limited, constant back-off is now at 1s. +{% endhighlight %} + +You may want to enable the `rate_limited` mode by default. + +In your `creds.json` for all `HETZNER` provider entries: +{% highlight json %} +{ + "hetzner": { + "rate_limited": "true", + "api_key": "your-api-key" + } +} +{% endhighlight %} diff --git a/docs/provider-list.md b/docs/provider-list.md index 37eafb1ec..d53676814 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -81,6 +81,7 @@ Maintainers of contributed providers: * `EXOSCALE` @pierre-emmanuelJ * `GANDI_V5` @TomOnTime * `HEDNS` @rblenkinsopp +* `HETZNER` @das7pad * `HEXONET` @papakai * `INTERNETBS` @pragmaton * `INWX` @svenpeter42 diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index c53a8dbe2..542a8a039 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -693,7 +693,7 @@ func makeTests(t *testing.T) []*TestGroup { ), testgroup("empty TXT", - not("CLOUDFLAREAPI", "HEXONET", "INWX", "NETCUP"), + not("CLOUDFLAREAPI", "HETZNER", "HEXONET", "INWX", "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 diff --git a/integrationTest/providers.json b/integrationTest/providers.json index ee97b4855..6bf602808 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -69,6 +69,11 @@ "totp-key": "$HEDNS_TOTP_SECRET", "username": "$HEDNS_USERNAME" }, + "HETZNER": { + "api_key": "$HETZNER_API_KEY", + "domain": "$HETZNER_DOMAIN", + "rate_limited": "true" + }, "HEXONET": { "apientity": "$HEXONET_ENTITY", "apilogin": "$HEXONET_UID", diff --git a/providers/_all/all.go b/providers/_all/all.go index 2ce06459a..4ce697ff8 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -18,6 +18,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/gandi_v5" _ "github.com/StackExchange/dnscontrol/v3/providers/gcloud" _ "github.com/StackExchange/dnscontrol/v3/providers/hedns" + _ "github.com/StackExchange/dnscontrol/v3/providers/hetzner" _ "github.com/StackExchange/dnscontrol/v3/providers/hexonet" _ "github.com/StackExchange/dnscontrol/v3/providers/internetbs" _ "github.com/StackExchange/dnscontrol/v3/providers/inwx" diff --git a/providers/hetzner/api.go b/providers/hetzner/api.go new file mode 100644 index 000000000..f4eda7581 --- /dev/null +++ b/providers/hetzner/api.go @@ -0,0 +1,232 @@ +package hetzner + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "time" +) + +const ( + baseURL = "https://dns.hetzner.com/api/v1" +) + +type api struct { + apiKey string + zones map[string]zone + requestRateLimiter requestRateLimiter +} + +func checkIsLockedSystemRecord(record record) error { + if record.Type == "SOA" { + // The upload of a BIND zone file can change the SOA record. + // Implementing this edge case this is too complex for now. + return fmt.Errorf("SOA records are locked in HETZNER zones. They are hence not available for updating") + } + return nil +} + +func (api *api) createRecord(record record) error { + if err := checkIsLockedSystemRecord(record); err != nil { + return err + } + + request := createRecordRequest{ + Name: record.Name, + TTL: *record.TTL, + Type: record.Type, + Value: record.Value, + ZoneID: record.ZoneID, + } + return api.request("/records", "POST", request, nil) +} + +func (api *api) createZone(name string) error { + request := createZoneRequest{ + Name: name, + } + return api.request("/zones", "POST", request, nil) +} + +func (api *api) deleteRecord(record record) error { + if err := checkIsLockedSystemRecord(record); err != nil { + return err + } + + url := fmt.Sprintf("/records/%s", record.ID) + return api.request(url, "DELETE", nil, nil) +} + +func (api *api) getAllRecords(domain string) ([]record, error) { + zone, err := api.getZone(domain) + if err != nil { + return nil, err + } + page := 1 + records := make([]record, 0) + for { + response := &getAllRecordsResponse{} + url := fmt.Sprintf("/records?zone_id=%s&per_page=100&page=%d", zone.ID, page) + if err := api.request(url, "GET", nil, response); err != nil { + return nil, fmt.Errorf("failed fetching zone records for %q: %w", domain, err) + } + for _, record := range response.Records { + if record.TTL == nil { + record.TTL = &zone.TTL + } + + if checkIsLockedSystemRecord(record) != nil { + // Some records are not available for updating, hide them. + continue + } + + records = append(records, record) + } + // meta.pagination may not be present. In that case LastPage is 0 and below the current page number. + if page >= response.Meta.Pagination.LastPage { + break + } + page++ + } + return records, nil +} + +func (api *api) getAllZones() error { + if api.zones != nil { + return nil + } + zones := map[string]zone{} + page := 1 + for { + response := &getAllZonesResponse{} + url := fmt.Sprintf("/zones?per_page=100&page=%d", page) + if err := api.request(url, "GET", nil, response); err != nil { + return fmt.Errorf("failed fetching zones: %w", err) + } + for _, zone := range response.Zones { + zones[zone.Name] = zone + } + // meta.pagination may not be present. In that case LastPage is 0 and below the current page number. + if page >= response.Meta.Pagination.LastPage { + break + } + page++ + } + api.zones = zones + return nil +} + +func (api *api) getZone(name string) (*zone, error) { + if err := api.getAllZones(); err != nil { + return nil, err + } + zone, ok := api.zones[name] + if !ok { + return nil, fmt.Errorf("%q is not a zone in this HETZNER account", name) + } + return &zone, nil +} + +func (api *api) request(endpoint string, method string, request interface{}, target interface{}) error { + var requestBody io.Reader + if request != nil { + requestBodySerialised, err := json.Marshal(request) + if err != nil { + return err + } + requestBody = bytes.NewBuffer(requestBodySerialised) + } + req, err := http.NewRequest(method, baseURL+endpoint, requestBody) + if err != nil { + return err + } + req.Header.Add("Auth-API-Token", api.apiKey) + + for { + api.requestRateLimiter.beforeRequest() + resp, err := http.DefaultClient.Do(req) + api.requestRateLimiter.afterRequest() + if err != nil { + return err + } + cleanupResponseBody := func() { + err := resp.Body.Close() + if err != nil { + fmt.Println(fmt.Sprintf("failed closing response body: %q", err)) + } + } + + if resp.StatusCode == 429 { + api.requestRateLimiter.handleRateLimitedRequest() + cleanupResponseBody() + continue + } + + defer cleanupResponseBody() + if resp.StatusCode != 200 { + data, _ := ioutil.ReadAll(resp.Body) + fmt.Println(string(data)) + return fmt.Errorf("bad status code from HETZNER: %d not 200", resp.StatusCode) + } + if target == nil { + return nil + } + decoder := json.NewDecoder(resp.Body) + return decoder.Decode(target) + } +} + +func (api *api) startRateLimited() { + // Simulate a request that is getting a 429 response. + api.requestRateLimiter.afterRequest() + api.requestRateLimiter.bumpDelay() +} + +func (api *api) updateRecord(record record) error { + if err := checkIsLockedSystemRecord(record); err != nil { + return err + } + + url := fmt.Sprintf("/records/%s", record.ID) + return api.request(url, "PUT", record, nil) +} + +type requestRateLimiter struct { + delay time.Duration + lastRequest time.Time +} + +func (requestRateLimiter *requestRateLimiter) afterRequest() { + requestRateLimiter.lastRequest = time.Now() +} + +func (requestRateLimiter *requestRateLimiter) beforeRequest() { + if requestRateLimiter.delay == 0 { + return + } + time.Sleep(time.Until(requestRateLimiter.lastRequest.Add(requestRateLimiter.delay))) +} + +func (requestRateLimiter *requestRateLimiter) bumpDelay() string { + var backoffType string + if requestRateLimiter.delay == 0 { + // At the time this provider was implemented (2020-10-18), + // one request per second could go though when rate-limited. + requestRateLimiter.delay = time.Second + backoffType = "constant" + } else { + // The initial assumption of 1 req/s may no hold true forever. + // Future proof this provider, use exponential back-off. + requestRateLimiter.delay = requestRateLimiter.delay * 2 + backoffType = "exponential" + } + return backoffType +} + +func (requestRateLimiter *requestRateLimiter) handleRateLimitedRequest() { + backoffType := requestRateLimiter.bumpDelay() + fmt.Println(fmt.Sprintf("WARNING: request rate-limited, %s back-off is now at %s.", backoffType, requestRateLimiter.delay)) +} diff --git a/providers/hetzner/hetznerProvider.go b/providers/hetzner/hetznerProvider.go new file mode 100644 index 000000000..501e6f051 --- /dev/null +++ b/providers/hetzner/hetznerProvider.go @@ -0,0 +1,173 @@ +package hetzner + +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.Can(), + providers.DocDualHost: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDS: providers.Cannot(), + providers.CanUsePTR: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseTLSA: providers.Cannot(), + providers.CanUseTXTMulti: providers.Cannot(), +} + +func init() { + providers.RegisterDomainServiceProviderType("HETZNER", New, features) +} + +// New creates a new API handle. +func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + if settings["api_key"] == "" { + return nil, fmt.Errorf("missing HETZNER api_key") + } + + api := &api{} + + api.apiKey = settings["api_key"] + + if settings["rate_limited"] == "true" { + api.startRateLimited() + } + + return api, nil +} + +// EnsureDomainExists creates the domain if it does not exist. +func (api *api) EnsureDomainExists(domain string) error { + domains, err := api.ListZones() + if err != nil { + return err + } + + for _, d := range domains { + if d == domain { + return nil + } + } + + return api.createZone(domain) +} + +// 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 + } + + err = dc.Punycode() + if err != nil { + return nil, err + } + domain := dc.Name + + // Get existing records + existingRecords, err := api.GetZoneRecords(domain) + if err != nil { + return nil, err + } + + // Normalize + models.PostProcessRecords(existingRecords) + + differ := diff.New(dc) + _, create, del, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + + var corrections []*models.Correction + + zone, err := api.getZone(domain) + if err != nil { + return nil, err + } + + for _, m := range del { + record := m.Existing.Original.(*record) + corr := &models.Correction{ + Msg: m.String(), + F: func() error { + return api.deleteRecord(*record) + }, + } + corrections = append(corrections, corr) + } + + for _, m := range create { + record := fromRecordConfig(m.Desired, zone) + corr := &models.Correction{ + Msg: m.String(), + F: func() error { + return api.createRecord(*record) + }, + } + corrections = append(corrections, corr) + } + + for _, m := range modify { + id := m.Existing.Original.(*record).ID + record := fromRecordConfig(m.Desired, zone) + record.ID = id + corr := &models.Correction{ + Msg: m.String(), + F: func() error { + return api.updateRecord(*record) + }, + } + corrections = append(corrections, corr) + } + + return corrections, nil +} + +// GetNameservers returns the nameservers for a domain. +func (api *api) GetNameservers(domain string) ([]*models.Nameserver, error) { + zone, err := api.getZone(domain) + if err != nil { + return nil, err + } + nameserver := make([]*models.Nameserver, len(zone.NameServers)) + for i := range zone.NameServers { + nameserver[i] = &models.Nameserver{Name: zone.NameServers[i]} + } + return nameserver, 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.getAllRecords(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 +} + +// ListZones lists the zones on this account. +func (api *api) ListZones() ([]string, error) { + if err := api.getAllZones(); err != nil { + return nil, err + } + var zones []string + for i := range api.zones { + zones = append(zones, i) + } + return zones, nil +} diff --git a/providers/hetzner/types.go b/providers/hetzner/types.go new file mode 100644 index 000000000..d72fadd21 --- /dev/null +++ b/providers/hetzner/types.go @@ -0,0 +1,88 @@ +package hetzner + +import ( + "github.com/StackExchange/dnscontrol/v3/models" +) + +type createRecordRequest struct { + Name string `json:"name"` + TTL int `json:"ttl"` + Type string `json:"type"` + Value string `json:"value"` + ZoneID string `json:"zone_id"` +} + +type createZoneRequest struct { + Name string `json:"name"` +} + +type getAllRecordsResponse struct { + Records []record `json:"records"` + Meta struct { + Pagination struct { + LastPage int `json:"last_page"` + } `json:"pagination"` + } `json:"meta"` +} + +type getAllZonesResponse struct { + Zones []zone `json:"zones"` + Meta struct { + Pagination struct { + LastPage int `json:"last_page"` + } `json:"pagination"` + } `json:"meta"` +} + +type record struct { + ID string `json:"id"` + Name string `json:"name"` + TTL *int `json:"ttl"` + Type string `json:"type"` + Value string `json:"value"` + ZoneID string `json:"zone_id"` +} + +type zone struct { + ID string `json:"id"` + Name string `json:"name"` + NameServers []string `json:"ns"` + TTL int `json:"ttl"` +} + +func fromRecordConfig(in *models.RecordConfig, zone *zone) *record { + ttl := int(in.TTL) + record := &record{ + Name: in.GetLabel(), + Type: in.Type, + Value: in.GetTargetField(), + TTL: &ttl, + ZoneID: zone.ID, + } + + switch record.Type { + case "TXT": + // Cannot use `in.GetTargetCombined()` for TXTs: + // Their validation would complain about a missing `;`. + // Test case: single_TXT:Create_a_255-byte_TXT + // {"error":{"message":"422 Unprocessable Entity: missing: ; ","code":422}} + record.Value = in.GetTargetField() + default: + record.Value = in.GetTargetCombined() + } + + return record +} + +func toRecordConfig(domain string, record *record) *models.RecordConfig { + rc := &models.RecordConfig{ + Type: record.Type, + TTL: uint32(*record.TTL), + Original: record, + } + rc.SetLabel(record.Name, domain) + + _ = rc.PopulateFromString(record.Type, record.Value, domain) + + return rc +}