1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

HETZNER: provider maintenance (#2258)

Signed-off-by: Jakob Ackermann <das7pad@outlook.com>
This commit is contained in:
Jakob Ackermann
2023-04-03 22:26:05 +01:00
committed by GitHub
parent 45a5c2d4bd
commit e230ade9c2
7 changed files with 185 additions and 322 deletions

View File

@@ -34,7 +34,7 @@ If a feature is definitively not supported for whatever reason, we would also li
| [`GCLOUD`](providers/gcloud.md) | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ | ❌ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | | [`GCLOUD`](providers/gcloud.md) | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ | ❌ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ |
| [`GCORE`](providers/gcore.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❔ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | | [`GCORE`](providers/gcore.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❔ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| [`HEDNS`](providers/hedns.md) | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | | [`HEDNS`](providers/hedns.md) | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| [`HETZNER`](providers/hetzner.md) | ❌ | ✅ | ❌ | ❌ | ✅ | | ❌ | | ❌ | | ✅ | ❌ | ✅ | | ✅ | ✅ | ✅ | ✅ | | [`HETZNER`](providers/hetzner.md) | ❌ | ✅ | ❌ | ❌ | ✅ | | ❌ | | ❌ | | ✅ | ❌ | ✅ | | ✅ | ✅ | ✅ | ✅ |
| [`HEXONET`](providers/hexonet.md) | ❌ | ✅ | ✅ | ❌ | ✅ | ❔ | ❔ | ❔ | ✅ | ❔ | ✅ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | | [`HEXONET`](providers/hexonet.md) | ❌ | ✅ | ✅ | ❌ | ✅ | ❔ | ❔ | ❔ | ✅ | ❔ | ✅ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ |
| [`HOSTINGDE`](providers/hostingde.md) | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [`HOSTINGDE`](providers/hostingde.md) | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`INTERNETBS`](providers/internetbs.md) | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ | | [`INTERNETBS`](providers/internetbs.md) | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ |

View File

@@ -65,69 +65,36 @@ At this time you cannot update SOA records via DNSControl.
### Rate Limiting ### Rate Limiting
Hetzner is rate limiting requests in multiple tiers: per Hour, per Minute and Hetzner is rate limiting requests quite heavily.
per Second.
Depending on how many requests you are planning to perform, you can adjust the The rate limit and remaining quota is advertised in the API response headers.
delay between requests in order to stay within your quota.
The setting `optimize_for_rate_limit_quota` controls this behavior and accepts DNSControl will burst through half of the quota, and then it spreads the
a case-insensitive value of requests evenly throughout the remaining window. This allows you to move fast
- `Hour` and be able to revert accidental changes to the DNS config in a timely manner.
- `Minute`
- `Second`
The default for `optimize_for_rate_limit_quota` is `Second`.
Example: Your per minute quota is 60 requests and in your settings you
specified `Minute`. DNSControl will perform at most one request per second.
DNSControl will emit a warning in case it breaches the next quota.
In your `creds.json` for all `HETZNER` provider entries:
{% code title="creds.json" %}
```json
{
"hetzner": {
"TYPE": "HETZNER",
"api_key": "your-api-key",
"optimize_for_rate_limit_quota": "Minute"
}
}
```
{% endcode %}
Every response from the Hetzner DNS Console API includes your limits: Every response from the Hetzner DNS Console API includes your limits:
```shell ```shell
curl --silent --include \ curl --silent --include \
--header 'Auth-API-Token: ...' \ --header 'Auth-API-Token: ...' \
https://dns.hetzner.com/api/v1/zones \ https://dns.hetzner.com/api/v1/zones
| grep x-ratelimit-limit
x-ratelimit-limit-second: 3 Access-Control-Allow-Origin *
x-ratelimit-limit-minute: 42 Content-Type application/json; charset=utf-8
x-ratelimit-limit-hour: 1337 Date Sat, 01 Apr 2023 00:00:00 GMT
Ratelimit-Limit 42
Ratelimit-Remaining 33
Ratelimit-Reset 7
Vary Origin
X-Ratelimit-Limit-Minute 42
X-Ratelimit-Remaining-Minute 33
``` ```
With the above values, DNSControl will not delay the next 12 requests (until it
hits `Ratelimit-Remaining: 21 # 42/2`) and then slow down requests with a
delay of `7s/22 ≈ 300ms` between requests (about 3 requests per second).
Performing these 12 requests might take longer than 7s, at which point the
quota resets and DNSControl will burst through the quota again.
Every DNSControl invocation starts from scratch in regard to rate-limiting. DNSControl will retry rate-limited requests (status 429) and respect the
In case you are frequently invoking DNSControl, you will likely hit a limit for advertised `Retry-After` delay.
any first request.
You can either use an out-of-bound delay (e.g. `$ sleep 1`), or specify
`start_with_default_rate_limit` in the settings of the provider.
With `start_with_default_rate_limit` DNSControl uses a quota equivalent to
`x-ratelimit-limit-second: 1` until it could parse the actual quota from an
API response.
In your `creds.json` for all `HETZNER` provider entries:
{% code title="creds.json" %}
```json
{
"hetzner": {
"TYPE": "HETZNER",
"api_key": "your-api-key",
"start_with_default_rate_limit": "true"
}
}
```
{% endcode %}

View File

@@ -132,9 +132,7 @@
"HETZNER": { "HETZNER": {
"TYPE": "HETZNER", "TYPE": "HETZNER",
"api_key": "$HETZNER_API_KEY", "api_key": "$HETZNER_API_KEY",
"domain": "$HETZNER_DOMAIN", "domain": "$HETZNER_DOMAIN"
"optimize_for_rate_limit_quota": "Hour",
"start_with_default_rate_limit": "true"
}, },
"HEXONET": { "HEXONET": {
"TYPE": "HEXONET", "TYPE": "HEXONET",

View File

@@ -7,7 +7,6 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/StackExchange/dnscontrol/v3/pkg/printer" "github.com/StackExchange/dnscontrol/v3/pkg/printer"
@@ -23,42 +22,8 @@ type hetznerProvider struct {
requestRateLimiter requestRateLimiter requestRateLimiter requestRateLimiter
} }
func checkIsLockedSystemRecord(record record) error { func parseHeaderAsSeconds(header http.Header, headerName string, fallback time.Duration) (time.Duration, error) {
if record.Type == "SOA" { retryAfter, err := parseHeaderAsInt(header, headerName, int64(fallback/time.Second))
// 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 getHomogenousDelay(headers http.Header, quotaName string) (time.Duration, error) {
var unit time.Duration
var unitHeader string
switch quotaName {
case "hour":
unit = time.Hour
unitHeader = "Hour"
case "minute":
unit = time.Minute
unitHeader = "Minute"
case "second":
unit = time.Second
unitHeader = "Second"
}
quota, err := parseHeaderAsInt(headers, "X-Ratelimit-Limit-"+unitHeader)
if err != nil {
return 0, err
}
delay := time.Duration(int64(unit) / quota)
return delay, nil
}
func getRetryAfterDelay(header http.Header) (time.Duration, error) {
retryAfter, err := parseHeaderAsInt(header, "Retry-After")
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -66,21 +31,18 @@ func getRetryAfterDelay(header http.Header) (time.Duration, error) {
return delay, nil return delay, nil
} }
func parseHeaderAsInt(headers http.Header, headerName string) (int64, error) { func parseHeaderAsInt(headers http.Header, headerName string, fallback int64) (int64, error) {
value, ok := headers[headerName] v := headers.Get(headerName)
if !ok { if v == "" {
return 0, fmt.Errorf("header %q is missing", headerName) return fallback, nil
} }
return strconv.ParseInt(value[0], 10, 0) if i, err := strconv.ParseInt(v, 10, 64); err == nil {
return i, nil
}
return 0, fmt.Errorf("expected header %q to contain number, got %q", headerName, v)
} }
func (api *hetznerProvider) bulkCreateRecords(records []record) error { func (api *hetznerProvider) bulkCreateRecords(records []record) error {
for _, record := range records {
if err := checkIsLockedSystemRecord(record); err != nil {
return err
}
}
request := bulkCreateRecordsRequest{ request := bulkCreateRecordsRequest{
Records: records, Records: records,
} }
@@ -88,33 +50,12 @@ func (api *hetznerProvider) bulkCreateRecords(records []record) error {
} }
func (api *hetznerProvider) bulkUpdateRecords(records []record) error { func (api *hetznerProvider) bulkUpdateRecords(records []record) error {
for _, record := range records {
if err := checkIsLockedSystemRecord(record); err != nil {
return err
}
}
request := bulkUpdateRecordsRequest{ request := bulkUpdateRecordsRequest{
Records: records, Records: records,
} }
return api.request("/records/bulk", "PUT", request, nil, nil) return api.request("/records/bulk", "PUT", request, nil, nil)
} }
func (api *hetznerProvider) 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, nil)
}
func (api *hetznerProvider) createZone(name string) error { func (api *hetznerProvider) createZone(name string) error {
request := createZoneRequest{ request := createZoneRequest{
Name: name, Name: name,
@@ -122,39 +63,36 @@ func (api *hetznerProvider) createZone(name string) error {
return api.request("/zones", "POST", request, nil, nil) return api.request("/zones", "POST", request, nil, nil)
} }
func (api *hetznerProvider) deleteRecord(record record) error { func (api *hetznerProvider) deleteRecord(record *record) error {
if err := checkIsLockedSystemRecord(record); err != nil {
return err
}
url := fmt.Sprintf("/records/%s", record.ID) url := fmt.Sprintf("/records/%s", record.ID)
return api.request(url, "DELETE", nil, nil, nil) return api.request(url, "DELETE", nil, nil, nil)
} }
func (api *hetznerProvider) getAllRecords(domain string) ([]record, error) { func (api *hetznerProvider) getAllRecords(domain string) ([]record, error) {
zone, err := api.getZone(domain) z, err := api.getZone(domain)
if err != nil { if err != nil {
return nil, err return nil, err
} }
page := 1 page := 1
records := make([]record, 0) var records []record
for { for {
response := &getAllRecordsResponse{} response := getAllRecordsResponse{}
url := fmt.Sprintf("/records?zone_id=%s&per_page=100&page=%d", zone.ID, page) url := fmt.Sprintf("/records?zone_id=%s&per_page=100&page=%d", z.ID, page)
if err := api.request(url, "GET", nil, response, nil); err != nil { if err = api.request(url, "GET", nil, &response, nil); err != nil {
return nil, fmt.Errorf("failed fetching zone records for %q: %w", domain, err) return nil, fmt.Errorf("failed fetching zone records for %q: %w", domain, err)
} }
for _, record := range response.Records { if records == nil {
if record.TTL == nil { records = make([]record, 0, response.Meta.Pagination.TotalEntries)
record.TTL = &zone.TTL }
for _, r := range response.Records {
if r.TTL == nil {
r.TTL = &z.TTL
} }
if r.Type == "SOA" {
if checkIsLockedSystemRecord(record) != nil { // SOA records are not available for editing, hide them.
// Some records are not available for updating, hide them.
continue continue
} }
records = append(records, r)
records = append(records, record)
} }
// meta.pagination may not be present. In that case LastPage is 0 and below the current page number. // meta.pagination may not be present. In that case LastPage is 0 and below the current page number.
if page >= response.Meta.Pagination.LastPage { if page >= response.Meta.Pagination.LastPage {
@@ -169,7 +107,7 @@ func (api *hetznerProvider) getAllZones() error {
if api.zones != nil { if api.zones != nil {
return nil return nil
} }
zones := map[string]zone{} var zones map[string]zone
page := 1 page := 1
statusOK := func(code int) bool { statusOK := func(code int) bool {
switch code { switch code {
@@ -183,13 +121,16 @@ func (api *hetznerProvider) getAllZones() error {
} }
} }
for { for {
response := &getAllZonesResponse{} response := getAllZonesResponse{}
url := fmt.Sprintf("/zones?per_page=100&page=%d", page) url := fmt.Sprintf("/zones?per_page=100&page=%d", page)
if err := api.request(url, "GET", nil, response, statusOK); err != nil { if err := api.request(url, "GET", nil, &response, statusOK); err != nil {
return fmt.Errorf("failed fetching zones: %w", err) return fmt.Errorf("failed fetching zones: %w", err)
} }
for _, zone := range response.Zones { if zones == nil {
zones[zone.Name] = zone zones = make(map[string]zone, response.Meta.Pagination.TotalEntries)
}
for _, z := range response.Zones {
zones[z.Name] = z
} }
// meta.pagination may not be present. In that case LastPage is 0 and below the current page number. // meta.pagination may not be present. In that case LastPage is 0 and below the current page number.
if page >= response.Meta.Pagination.LastPage { if page >= response.Meta.Pagination.LastPage {
@@ -205,11 +146,11 @@ func (api *hetznerProvider) getZone(name string) (*zone, error) {
if err := api.getAllZones(); err != nil { if err := api.getAllZones(); err != nil {
return nil, err return nil, err
} }
zone, ok := api.zones[name] z, ok := api.zones[name]
if !ok { if !ok {
return nil, fmt.Errorf("%q is not a zone in this HETZNER account", name) return nil, fmt.Errorf("%q is not a zone in this HETZNER account", name)
} }
return &zone, nil return &z, nil
} }
func (api *hetznerProvider) request(endpoint string, method string, request interface{}, target interface{}, statusOK func(code int) bool) error { func (api *hetznerProvider) request(endpoint string, method string, request interface{}, target interface{}, statusOK func(code int) bool) error {
@@ -233,120 +174,92 @@ func (api *hetznerProvider) request(endpoint string, method string, request inte
} }
req.Header.Add("Auth-API-Token", api.apiKey) req.Header.Add("Auth-API-Token", api.apiKey)
api.requestRateLimiter.beforeRequest() api.requestRateLimiter.delayRequest()
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
api.requestRateLimiter.afterRequest()
if err != nil { if err != nil {
return err return err
} }
cleanupResponseBody := func() { cleanupResponseBody := func() {
err := resp.Body.Close() err2 := resp.Body.Close()
if err != nil { if err2 != nil {
printer.Printf("failed closing response body: %q\n", err) printer.Printf("failed closing response body: %q\n", err2)
} }
} }
api.requestRateLimiter.handleResponse(*resp) retry, err := api.requestRateLimiter.handleResponse(resp)
// retry the request when rate-limited if err != nil {
if resp.StatusCode == 429 { cleanupResponseBody()
api.requestRateLimiter.handleRateLimitedRequest() return err
}
if retry {
cleanupResponseBody() cleanupResponseBody()
continue continue
} }
defer cleanupResponseBody()
if !statusOK(resp.StatusCode) { if !statusOK(resp.StatusCode) {
data, _ := io.ReadAll(resp.Body) data, _ := io.ReadAll(resp.Body)
printer.Printf(string(data)) printer.Println(string(data))
cleanupResponseBody()
return fmt.Errorf("bad status code from HETZNER: %d not 200", resp.StatusCode) return fmt.Errorf("bad status code from HETZNER: %d not 200", resp.StatusCode)
} }
if target == nil { if target == nil {
cleanupResponseBody()
return nil return nil
} }
decoder := json.NewDecoder(resp.Body) err = json.NewDecoder(resp.Body).Decode(target)
return decoder.Decode(target) cleanupResponseBody()
}
}
func (api *hetznerProvider) startRateLimited() {
// _Now_ is the best reference we can get for the last request.
// Head-On-Head invocations of DNSControl benefit from fewer initial
// rate-limited requests.
api.requestRateLimiter.lastRequest = time.Now()
// use the default delay until we have had a chance to parse limits.
api.requestRateLimiter.setDefaultDelay()
}
func (api *hetznerProvider) updateRecord(record record) error {
if err := checkIsLockedSystemRecord(record); err != nil {
return err return err
} }
url := fmt.Sprintf("/records/%s", record.ID)
return api.request(url, "PUT", record, nil, nil)
} }
type requestRateLimiter struct { type requestRateLimiter struct {
delay time.Duration delay time.Duration
lastRequest time.Time lastRequest time.Time
optimizeForRateLimitQuota string
} }
func (requestRateLimiter *requestRateLimiter) afterRequest() { func (rrl *requestRateLimiter) delayRequest() {
requestRateLimiter.lastRequest = time.Now() time.Sleep(time.Until(rrl.lastRequest.Add(rrl.delay)))
// When not rate-limited, include network/server latency in delay.
rrl.lastRequest = time.Now()
} }
func (requestRateLimiter *requestRateLimiter) beforeRequest() { func (rrl *requestRateLimiter) handleResponse(resp *http.Response) (bool, error) {
if requestRateLimiter.delay == 0 { if resp.StatusCode == http.StatusTooManyRequests {
return printer.Printf("Rate-Limited. Consider contacting the Hetzner Support for raising your quota. URL: %q, Headers: %q\n", resp.Request.URL, resp.Header)
}
time.Sleep(time.Until(requestRateLimiter.lastRequest.Add(requestRateLimiter.delay)))
}
func (requestRateLimiter *requestRateLimiter) setDefaultDelay() { retryAfter, err := parseHeaderAsSeconds(resp.Header, "Retry-After", time.Second)
// default to a rate-limit of 1 req/s -- the next response should update it. if err != nil {
requestRateLimiter.delay = time.Second return false, err
}
func (requestRateLimiter *requestRateLimiter) setOptimizeForRateLimitQuota(quota string) error {
quotaNormalized := strings.ToLower(quota)
switch quotaNormalized {
case "hour", "minute", "second":
requestRateLimiter.optimizeForRateLimitQuota = quotaNormalized
case "":
requestRateLimiter.optimizeForRateLimitQuota = "second"
default:
return fmt.Errorf("%q is not a valid quota, expected 'Hour', 'Minute', 'Second' or unset", quota)
}
return nil
}
func (requestRateLimiter *requestRateLimiter) handleRateLimitedRequest() {
message := "Rate-Limited, consider bumping the setting 'optimize_for_rate_limit_quota': %q -> %q"
switch requestRateLimiter.optimizeForRateLimitQuota {
case "hour":
message = "Rate-Limited, you are already using the slowest request rate. Consider contacting the Hetzner Support for raising your quota."
case "minute":
message = fmt.Sprintf(message, "Minute", "Hour")
case "second":
message = fmt.Sprintf(message, "Second", "Minute")
}
printer.Printf(message)
}
func (requestRateLimiter *requestRateLimiter) handleResponse(resp http.Response) {
homogenousDelay, err := getHomogenousDelay(resp.Header, requestRateLimiter.optimizeForRateLimitQuota)
if err != nil {
requestRateLimiter.setDefaultDelay()
return
}
delay := homogenousDelay
if resp.StatusCode == 429 {
retryAfterDelay, err := getRetryAfterDelay(resp.Header)
if err == nil {
delay = retryAfterDelay
} }
rrl.delay = retryAfter
// When rate-limited, exclude network/server latency from delay.
rrl.lastRequest = time.Now()
return true, nil
} else {
limit, err := parseHeaderAsInt(resp.Header, "Ratelimit-Limit", 1)
if err != nil {
return false, err
}
remaining, err := parseHeaderAsInt(resp.Header, "Ratelimit-Remaining", 1)
if err != nil {
return false, err
}
reset, err := parseHeaderAsSeconds(resp.Header, "Ratelimit-Reset", 0)
if err != nil {
return false, err
}
if remaining == 0 {
// Quota exhausted. Wait until quota resets.
rrl.delay = reset
} else if remaining > limit/2 {
// Burst through half of the quota, ...
rrl.delay = 0
} else {
// ... then spread requests evenly throughout the window.
rrl.delay = reset / time.Duration(remaining+1)
}
return false, nil
} }
requestRateLimiter.delay = delay
} }

View File

@@ -11,7 +11,7 @@ import (
func AuditRecords(records []*models.RecordConfig) []error { func AuditRecords(records []*models.RecordConfig) []error {
a := rejectif.Auditor{} a := rejectif.Auditor{}
a.Add("CAA", rejectif.CaaTargetContainsWhitespace) // Last verified xxxx-xx-xx a.Add("CAA", rejectif.CaaTargetContainsWhitespace) // Last verified 2023-04-01
return a.Audit(records) return a.Audit(records)
} }

View File

@@ -13,12 +13,16 @@ import (
) )
var features = providers.DocumentationNotes{ var features = providers.DocumentationNotes{
providers.CanAutoDNSSEC: providers.Cannot(),
providers.CanGetZones: providers.Can(), providers.CanGetZones: providers.Can(),
providers.CanUseAlias: providers.Cannot(), providers.CanUseAlias: providers.Cannot(),
providers.CanUseCAA: providers.Can(), providers.CanUseCAA: providers.Can(),
providers.CanUseDS: providers.Cannot(), providers.CanUseDS: providers.Can(),
providers.CanUseDSForChildren: providers.Cannot(),
providers.CanUseLOC: providers.Cannot(), providers.CanUseLOC: providers.Cannot(),
providers.CanUseNAPTR: providers.Cannot(),
providers.CanUsePTR: providers.Cannot(), providers.CanUsePTR: providers.Cannot(),
providers.CanUseSOA: providers.Cannot(),
providers.CanUseSRV: providers.Can(), providers.CanUseSRV: providers.Can(),
providers.CanUseSSHFP: providers.Cannot(), providers.CanUseSSHFP: providers.Cannot(),
providers.CanUseTLSA: providers.Can(), providers.CanUseTLSA: providers.Can(),
@@ -37,29 +41,14 @@ func init() {
// New creates a new API handle. // New creates a new API handle.
func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) {
if settings["api_key"] == "" { apiKey := settings["api_key"]
if apiKey == "" {
return nil, fmt.Errorf("missing HETZNER api_key") return nil, fmt.Errorf("missing HETZNER api_key")
} }
api := &hetznerProvider{} return &hetznerProvider{
apiKey: apiKey,
api.apiKey = settings["api_key"] }, nil
if settings["rate_limited"] == "true" {
// backwards compatibility
settings["start_with_default_rate_limit"] = "true"
}
if settings["start_with_default_rate_limit"] == "true" {
api.startRateLimited()
}
quota := settings["optimize_for_rate_limit_quota"]
err := api.requestRateLimiter.setOptimizeForRateLimitQuota(quota)
if err != nil {
return nil, fmt.Errorf("unexpected value for optimize_for_rate_limit_quota: %w", err)
}
return api, nil
} }
// EnsureZoneExists creates a zone if it does not exist // EnsureZoneExists creates a zone if it does not exist
@@ -116,28 +105,29 @@ func (api *hetznerProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mo
return nil, err return nil, err
} }
zone, err := api.getZone(domain) z, err := api.getZone(domain)
if err != nil { if err != nil {
return nil, err return nil, err
} }
corrections = make([]*models.Correction, 0, len(del)+1+1)
for _, m := range del { for _, m := range del {
record := m.Existing.Original.(*record) r := m.Existing.Original.(*record)
corr := &models.Correction{ corr := &models.Correction{
Msg: m.String(), Msg: m.String(),
F: func() error { F: func() error {
return api.deleteRecord(*record) return api.deleteRecord(r)
}, },
} }
corrections = append(corrections, corr) corrections = append(corrections, corr)
} }
var createRecords []record createRecords := make([]record, len(create))
createDescription := []string{"Batch creation of records:"} createDescription := make([]string, len(create)+1)
for _, m := range create { createDescription[0] = "Batch creation of records:"
record := fromRecordConfig(m.Desired, zone) for i, m := range create {
createRecords = append(createRecords, *record) createRecords[i] = fromRecordConfig(m.Desired, z)
createDescription = append(createDescription, m.String()) createDescription[i+1] = m.String()
} }
if len(createRecords) > 0 { if len(createRecords) > 0 {
corr := &models.Correction{ corr := &models.Correction{
@@ -149,14 +139,14 @@ func (api *hetznerProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mo
corrections = append(corrections, corr) corrections = append(corrections, corr)
} }
var modifyRecords []record modifyRecords := make([]record, len(modify))
modifyDescription := []string{"Batch modification of records:"} modifyDescription := make([]string, len(modify)+1)
for _, m := range modify { modifyDescription[0] = "Batch modification of records:"
id := m.Existing.Original.(*record).ID for i, m := range modify {
record := fromRecordConfig(m.Desired, zone) r := fromRecordConfig(m.Desired, z)
record.ID = id r.ID = m.Existing.Original.(*record).ID
modifyRecords = append(modifyRecords, *record) modifyRecords[i] = r
modifyDescription = append(modifyDescription, m.String()) modifyDescription[i+1] = m.String()
} }
if len(modifyRecords) > 0 { if len(modifyRecords) > 0 {
corr := &models.Correction{ corr := &models.Correction{
@@ -173,13 +163,13 @@ func (api *hetznerProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mo
// GetNameservers returns the nameservers for a domain. // GetNameservers returns the nameservers for a domain.
func (api *hetznerProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { func (api *hetznerProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
zone, err := api.getZone(domain) z, err := api.getZone(domain)
if err != nil { if err != nil {
return nil, err return nil, err
} }
nameserver := make([]*models.Nameserver, len(zone.NameServers)) nameserver := make([]*models.Nameserver, len(z.NameServers))
for i := range zone.NameServers { for i, s := range z.NameServers {
nameserver[i] = &models.Nameserver{Name: zone.NameServers[i]} nameserver[i] = &models.Nameserver{Name: s}
} }
return nameserver, nil return nameserver, nil
} }
@@ -192,7 +182,10 @@ func (api *hetznerProvider) GetZoneRecords(domain string) (models.Records, error
} }
existingRecords := make([]*models.RecordConfig, len(records)) existingRecords := make([]*models.RecordConfig, len(records))
for i := range records { for i := range records {
existingRecords[i] = toRecordConfig(domain, &records[i]) existingRecords[i], err = toRecordConfig(domain, &records[i])
if err != nil {
return nil, err
}
} }
return existingRecords, nil return existingRecords, nil
} }
@@ -202,9 +195,9 @@ func (api *hetznerProvider) ListZones() ([]string, error) {
if err := api.getAllZones(); err != nil { if err := api.getAllZones(); err != nil {
return nil, err return nil, err
} }
var zones []string zones := make([]string, 0, len(api.zones))
for i := range api.zones { for domain := range api.zones {
zones = append(zones, i) zones = append(zones, domain)
} }
return zones, nil return zones, nil
} }

View File

@@ -14,14 +14,6 @@ type bulkUpdateRecordsRequest struct {
Records []record `json:"records"` Records []record `json:"records"`
} }
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 { type createZoneRequest struct {
Name string `json:"name"` Name string `json:"name"`
} }
@@ -30,7 +22,8 @@ type getAllRecordsResponse struct {
Records []record `json:"records"` Records []record `json:"records"`
Meta struct { Meta struct {
Pagination struct { Pagination struct {
LastPage int `json:"last_page"` LastPage int `json:"last_page"`
TotalEntries int `json:"total_entries"`
} `json:"pagination"` } `json:"pagination"`
} `json:"meta"` } `json:"meta"`
} }
@@ -39,70 +32,69 @@ type getAllZonesResponse struct {
Zones []zone `json:"zones"` Zones []zone `json:"zones"`
Meta struct { Meta struct {
Pagination struct { Pagination struct {
LastPage int `json:"last_page"` LastPage int `json:"last_page"`
TotalEntries int `json:"total_entries"`
} `json:"pagination"` } `json:"pagination"`
} `json:"meta"` } `json:"meta"`
} }
type record struct { type record struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
TTL *int `json:"ttl"` TTL *uint32 `json:"ttl"`
Type string `json:"type"` Type string `json:"type"`
Value string `json:"value"` Value string `json:"value"`
ZoneID string `json:"zone_id"` ZoneID string `json:"zone_id"`
} }
type zone struct { type zone struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
NameServers []string `json:"ns"` NameServers []string `json:"ns"`
TTL int `json:"ttl"` TTL uint32 `json:"ttl"`
} }
func fromRecordConfig(in *models.RecordConfig, zone *zone) *record { func fromRecordConfig(in *models.RecordConfig, zone *zone) record {
ttl := int(in.TTL) r := record{
record := &record{
Name: in.GetLabel(), Name: in.GetLabel(),
Type: in.Type, Type: in.Type,
Value: in.GetTargetCombined(), Value: in.GetTargetCombined(),
TTL: &ttl, TTL: &in.TTL,
ZoneID: zone.ID, ZoneID: zone.ID,
} }
if record.Type == "TXT" && len(in.TxtStrings) == 1 { if r.Type == "TXT" && len(in.TxtStrings) == 1 {
// HACK: HETZNER rejects values that fit into 255 bytes w/o quotes, // HACK: HETZNER rejects values that fit into 255 bytes w/o quotes,
// but do not fit w/ added quotes (via GetTargetCombined()). // but do not fit w/ added quotes (via GetTargetCombined()).
// Sending the raw, non-quoted value works for the comprehensive // Sending the raw, non-quoted value works for the comprehensive
// suite of integrations tests. // suite of integrations tests.
// The HETZNER validation does not provide helpful error messages. // The HETZNER validation does not provide helpful error messages.
// {"error":{"message":"422 Unprocessable Entity: missing: ; ","code":422}} // {"error":{"message":"422 Unprocessable Entity: missing: ; ","code":422}}
// Last checked: 2023-04-01
valueNotQuoted := in.TxtStrings[0] valueNotQuoted := in.TxtStrings[0]
if len(valueNotQuoted) == 254 || len(valueNotQuoted) == 255 { if len(valueNotQuoted) == 254 || len(valueNotQuoted) == 255 {
record.Value = valueNotQuoted r.Value = valueNotQuoted
} }
} }
return record return r
} }
func toRecordConfig(domain string, record *record) *models.RecordConfig { func toRecordConfig(domain string, r *record) (*models.RecordConfig, error) {
rc := &models.RecordConfig{ rc := models.RecordConfig{
Type: record.Type, Type: r.Type,
TTL: uint32(*record.TTL), TTL: *r.TTL,
Original: record, Original: r,
} }
rc.SetLabel(record.Name, domain) rc.SetLabel(r.Name, domain)
value := record.Value value := r.Value
// HACK: Hetzner is inserting a trailing space after multiple, quoted values. // HACK: Hetzner is inserting a trailing space after multiple, quoted values.
// NOTE: The actual DNS answer does not contain the space. // NOTE: The actual DNS answer does not contain the space.
if record.Type == "TXT" && len(value) > 0 && value[len(value)-1] == ' ' { // Last checked: 2023-04-01
if r.Type == "TXT" && len(value) > 0 && value[len(value)-1] == ' ' {
// Per RFC 1035 spaces outside quoted values are irrelevant. // Per RFC 1035 spaces outside quoted values are irrelevant.
value = strings.TrimRight(value, " ") value = strings.TrimRight(value, " ")
} }
return &rc, rc.PopulateFromString(r.Type, value, domain)
_ = rc.PopulateFromString(record.Type, value, domain)
return rc
} }