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:
@@ -34,7 +34,7 @@ If a feature is definitively not supported for whatever reason, we would also li
|
||||
| [`GCLOUD`](providers/gcloud.md) | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ | ❌ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [`GCORE`](providers/gcore.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❔ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [`HEDNS`](providers/hedns.md) | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [`HETZNER`](providers/hetzner.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ❔ | ❌ | ❔ | ❌ | ❔ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [`HETZNER`](providers/hetzner.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [`HEXONET`](providers/hexonet.md) | ❌ | ✅ | ✅ | ❌ | ✅ | ❔ | ❔ | ❔ | ✅ | ❔ | ✅ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ |
|
||||
| [`HOSTINGDE`](providers/hostingde.md) | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [`INTERNETBS`](providers/internetbs.md) | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ |
|
||||
|
@@ -65,69 +65,36 @@ At this time you cannot update SOA records via DNSControl.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Hetzner is rate limiting requests in multiple tiers: per Hour, per Minute and
|
||||
per Second.
|
||||
Hetzner is rate limiting requests quite heavily.
|
||||
|
||||
Depending on how many requests you are planning to perform, you can adjust the
|
||||
delay between requests in order to stay within your quota.
|
||||
The rate limit and remaining quota is advertised in the API response headers.
|
||||
|
||||
The setting `optimize_for_rate_limit_quota` controls this behavior and accepts
|
||||
a case-insensitive value of
|
||||
- `Hour`
|
||||
- `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 %}
|
||||
DNSControl will burst through half of the quota, and then it spreads the
|
||||
requests evenly throughout the remaining window. This allows you to move fast
|
||||
and be able to revert accidental changes to the DNS config in a timely manner.
|
||||
|
||||
Every response from the Hetzner DNS Console API includes your limits:
|
||||
|
||||
```shell
|
||||
curl --silent --include \
|
||||
--header 'Auth-API-Token: ...' \
|
||||
https://dns.hetzner.com/api/v1/zones \
|
||||
| grep x-ratelimit-limit
|
||||
x-ratelimit-limit-second: 3
|
||||
x-ratelimit-limit-minute: 42
|
||||
x-ratelimit-limit-hour: 1337
|
||||
https://dns.hetzner.com/api/v1/zones
|
||||
|
||||
Access-Control-Allow-Origin *
|
||||
Content-Type application/json; charset=utf-8
|
||||
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.
|
||||
In case you are frequently invoking DNSControl, you will likely hit a limit for
|
||||
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 %}
|
||||
DNSControl will retry rate-limited requests (status 429) and respect the
|
||||
advertised `Retry-After` delay.
|
||||
|
@@ -132,9 +132,7 @@
|
||||
"HETZNER": {
|
||||
"TYPE": "HETZNER",
|
||||
"api_key": "$HETZNER_API_KEY",
|
||||
"domain": "$HETZNER_DOMAIN",
|
||||
"optimize_for_rate_limit_quota": "Hour",
|
||||
"start_with_default_rate_limit": "true"
|
||||
"domain": "$HETZNER_DOMAIN"
|
||||
},
|
||||
"HEXONET": {
|
||||
"TYPE": "HEXONET",
|
||||
|
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
|
||||
@@ -23,42 +22,8 @@ type hetznerProvider struct {
|
||||
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 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")
|
||||
func parseHeaderAsSeconds(header http.Header, headerName string, fallback time.Duration) (time.Duration, error) {
|
||||
retryAfter, err := parseHeaderAsInt(header, headerName, int64(fallback/time.Second))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -66,21 +31,18 @@ func getRetryAfterDelay(header http.Header) (time.Duration, error) {
|
||||
return delay, nil
|
||||
}
|
||||
|
||||
func parseHeaderAsInt(headers http.Header, headerName string) (int64, error) {
|
||||
value, ok := headers[headerName]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("header %q is missing", headerName)
|
||||
func parseHeaderAsInt(headers http.Header, headerName string, fallback int64) (int64, error) {
|
||||
v := headers.Get(headerName)
|
||||
if v == "" {
|
||||
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 {
|
||||
for _, record := range records {
|
||||
if err := checkIsLockedSystemRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
request := bulkCreateRecordsRequest{
|
||||
Records: records,
|
||||
}
|
||||
@@ -88,33 +50,12 @@ func (api *hetznerProvider) bulkCreateRecords(records []record) error {
|
||||
}
|
||||
|
||||
func (api *hetznerProvider) bulkUpdateRecords(records []record) error {
|
||||
for _, record := range records {
|
||||
if err := checkIsLockedSystemRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
request := bulkUpdateRecordsRequest{
|
||||
Records: records,
|
||||
}
|
||||
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 {
|
||||
request := createZoneRequest{
|
||||
Name: name,
|
||||
@@ -122,39 +63,36 @@ func (api *hetznerProvider) createZone(name string) error {
|
||||
return api.request("/zones", "POST", request, nil, nil)
|
||||
}
|
||||
|
||||
func (api *hetznerProvider) deleteRecord(record record) error {
|
||||
if err := checkIsLockedSystemRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (api *hetznerProvider) deleteRecord(record *record) error {
|
||||
url := fmt.Sprintf("/records/%s", record.ID)
|
||||
return api.request(url, "DELETE", nil, nil, nil)
|
||||
}
|
||||
|
||||
func (api *hetznerProvider) getAllRecords(domain string) ([]record, error) {
|
||||
zone, err := api.getZone(domain)
|
||||
z, err := api.getZone(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := 1
|
||||
records := make([]record, 0)
|
||||
var records []record
|
||||
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, nil); err != nil {
|
||||
response := getAllRecordsResponse{}
|
||||
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 {
|
||||
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 records == nil {
|
||||
records = make([]record, 0, response.Meta.Pagination.TotalEntries)
|
||||
}
|
||||
|
||||
if checkIsLockedSystemRecord(record) != nil {
|
||||
// Some records are not available for updating, hide them.
|
||||
for _, r := range response.Records {
|
||||
if r.TTL == nil {
|
||||
r.TTL = &z.TTL
|
||||
}
|
||||
if r.Type == "SOA" {
|
||||
// SOA records are not available for editing, hide them.
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
records = append(records, r)
|
||||
}
|
||||
// meta.pagination may not be present. In that case LastPage is 0 and below the current page number.
|
||||
if page >= response.Meta.Pagination.LastPage {
|
||||
@@ -169,7 +107,7 @@ func (api *hetznerProvider) getAllZones() error {
|
||||
if api.zones != nil {
|
||||
return nil
|
||||
}
|
||||
zones := map[string]zone{}
|
||||
var zones map[string]zone
|
||||
page := 1
|
||||
statusOK := func(code int) bool {
|
||||
switch code {
|
||||
@@ -183,13 +121,16 @@ func (api *hetznerProvider) getAllZones() error {
|
||||
}
|
||||
}
|
||||
for {
|
||||
response := &getAllZonesResponse{}
|
||||
response := getAllZonesResponse{}
|
||||
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)
|
||||
}
|
||||
for _, zone := range response.Zones {
|
||||
zones[zone.Name] = zone
|
||||
if zones == nil {
|
||||
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.
|
||||
if page >= response.Meta.Pagination.LastPage {
|
||||
@@ -205,11 +146,11 @@ func (api *hetznerProvider) getZone(name string) (*zone, error) {
|
||||
if err := api.getAllZones(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zone, ok := api.zones[name]
|
||||
z, ok := api.zones[name]
|
||||
if !ok {
|
||||
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 {
|
||||
@@ -233,120 +174,92 @@ func (api *hetznerProvider) request(endpoint string, method string, request inte
|
||||
}
|
||||
req.Header.Add("Auth-API-Token", api.apiKey)
|
||||
|
||||
api.requestRateLimiter.beforeRequest()
|
||||
api.requestRateLimiter.delayRequest()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
api.requestRateLimiter.afterRequest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupResponseBody := func() {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
printer.Printf("failed closing response body: %q\n", err)
|
||||
err2 := resp.Body.Close()
|
||||
if err2 != nil {
|
||||
printer.Printf("failed closing response body: %q\n", err2)
|
||||
}
|
||||
}
|
||||
|
||||
api.requestRateLimiter.handleResponse(*resp)
|
||||
// retry the request when rate-limited
|
||||
if resp.StatusCode == 429 {
|
||||
api.requestRateLimiter.handleRateLimitedRequest()
|
||||
retry, err := api.requestRateLimiter.handleResponse(resp)
|
||||
if err != nil {
|
||||
cleanupResponseBody()
|
||||
return err
|
||||
}
|
||||
if retry {
|
||||
cleanupResponseBody()
|
||||
continue
|
||||
}
|
||||
|
||||
defer cleanupResponseBody()
|
||||
if !statusOK(resp.StatusCode) {
|
||||
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)
|
||||
}
|
||||
if target == nil {
|
||||
cleanupResponseBody()
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
return decoder.Decode(target)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
err = json.NewDecoder(resp.Body).Decode(target)
|
||||
cleanupResponseBody()
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("/records/%s", record.ID)
|
||||
return api.request(url, "PUT", record, nil, nil)
|
||||
}
|
||||
|
||||
type requestRateLimiter struct {
|
||||
delay time.Duration
|
||||
lastRequest time.Time
|
||||
optimizeForRateLimitQuota string
|
||||
}
|
||||
|
||||
func (requestRateLimiter *requestRateLimiter) afterRequest() {
|
||||
requestRateLimiter.lastRequest = time.Now()
|
||||
func (rrl *requestRateLimiter) delayRequest() {
|
||||
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() {
|
||||
if requestRateLimiter.delay == 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Until(requestRateLimiter.lastRequest.Add(requestRateLimiter.delay)))
|
||||
}
|
||||
func (rrl *requestRateLimiter) handleResponse(resp *http.Response) (bool, error) {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
printer.Printf("Rate-Limited. Consider contacting the Hetzner Support for raising your quota. URL: %q, Headers: %q\n", resp.Request.URL, resp.Header)
|
||||
|
||||
func (requestRateLimiter *requestRateLimiter) setDefaultDelay() {
|
||||
// default to a rate-limit of 1 req/s -- the next response should update it.
|
||||
requestRateLimiter.delay = time.Second
|
||||
}
|
||||
|
||||
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)
|
||||
retryAfter, err := parseHeaderAsSeconds(resp.Header, "Retry-After", time.Second)
|
||||
if err != nil {
|
||||
requestRateLimiter.setDefaultDelay()
|
||||
return
|
||||
return false, err
|
||||
}
|
||||
rrl.delay = retryAfter
|
||||
|
||||
delay := homogenousDelay
|
||||
if resp.StatusCode == 429 {
|
||||
retryAfterDelay, err := getRetryAfterDelay(resp.Header)
|
||||
if err == nil {
|
||||
delay = retryAfterDelay
|
||||
// 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
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ import (
|
||||
func AuditRecords(records []*models.RecordConfig) []error {
|
||||
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)
|
||||
}
|
||||
|
@@ -13,12 +13,16 @@ import (
|
||||
)
|
||||
|
||||
var features = providers.DocumentationNotes{
|
||||
providers.CanAutoDNSSEC: providers.Cannot(),
|
||||
providers.CanGetZones: providers.Can(),
|
||||
providers.CanUseAlias: providers.Cannot(),
|
||||
providers.CanUseCAA: providers.Can(),
|
||||
providers.CanUseDS: providers.Cannot(),
|
||||
providers.CanUseDS: providers.Can(),
|
||||
providers.CanUseDSForChildren: providers.Cannot(),
|
||||
providers.CanUseLOC: providers.Cannot(),
|
||||
providers.CanUseNAPTR: providers.Cannot(),
|
||||
providers.CanUsePTR: providers.Cannot(),
|
||||
providers.CanUseSOA: providers.Cannot(),
|
||||
providers.CanUseSRV: providers.Can(),
|
||||
providers.CanUseSSHFP: providers.Cannot(),
|
||||
providers.CanUseTLSA: providers.Can(),
|
||||
@@ -37,29 +41,14 @@ func init() {
|
||||
|
||||
// New creates a new API handle.
|
||||
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")
|
||||
}
|
||||
|
||||
api := &hetznerProvider{}
|
||||
|
||||
api.apiKey = settings["api_key"]
|
||||
|
||||
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
|
||||
return &hetznerProvider{
|
||||
apiKey: apiKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnsureZoneExists creates a zone if it does not exist
|
||||
@@ -116,28 +105,29 @@ func (api *hetznerProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zone, err := api.getZone(domain)
|
||||
z, err := api.getZone(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
corrections = make([]*models.Correction, 0, len(del)+1+1)
|
||||
for _, m := range del {
|
||||
record := m.Existing.Original.(*record)
|
||||
r := m.Existing.Original.(*record)
|
||||
corr := &models.Correction{
|
||||
Msg: m.String(),
|
||||
F: func() error {
|
||||
return api.deleteRecord(*record)
|
||||
return api.deleteRecord(r)
|
||||
},
|
||||
}
|
||||
corrections = append(corrections, corr)
|
||||
}
|
||||
|
||||
var createRecords []record
|
||||
createDescription := []string{"Batch creation of records:"}
|
||||
for _, m := range create {
|
||||
record := fromRecordConfig(m.Desired, zone)
|
||||
createRecords = append(createRecords, *record)
|
||||
createDescription = append(createDescription, m.String())
|
||||
createRecords := make([]record, len(create))
|
||||
createDescription := make([]string, len(create)+1)
|
||||
createDescription[0] = "Batch creation of records:"
|
||||
for i, m := range create {
|
||||
createRecords[i] = fromRecordConfig(m.Desired, z)
|
||||
createDescription[i+1] = m.String()
|
||||
}
|
||||
if len(createRecords) > 0 {
|
||||
corr := &models.Correction{
|
||||
@@ -149,14 +139,14 @@ func (api *hetznerProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mo
|
||||
corrections = append(corrections, corr)
|
||||
}
|
||||
|
||||
var modifyRecords []record
|
||||
modifyDescription := []string{"Batch modification of records:"}
|
||||
for _, m := range modify {
|
||||
id := m.Existing.Original.(*record).ID
|
||||
record := fromRecordConfig(m.Desired, zone)
|
||||
record.ID = id
|
||||
modifyRecords = append(modifyRecords, *record)
|
||||
modifyDescription = append(modifyDescription, m.String())
|
||||
modifyRecords := make([]record, len(modify))
|
||||
modifyDescription := make([]string, len(modify)+1)
|
||||
modifyDescription[0] = "Batch modification of records:"
|
||||
for i, m := range modify {
|
||||
r := fromRecordConfig(m.Desired, z)
|
||||
r.ID = m.Existing.Original.(*record).ID
|
||||
modifyRecords[i] = r
|
||||
modifyDescription[i+1] = m.String()
|
||||
}
|
||||
if len(modifyRecords) > 0 {
|
||||
corr := &models.Correction{
|
||||
@@ -173,13 +163,13 @@ func (api *hetznerProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mo
|
||||
|
||||
// GetNameservers returns the nameservers for a domain.
|
||||
func (api *hetznerProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
|
||||
zone, err := api.getZone(domain)
|
||||
z, 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]}
|
||||
nameserver := make([]*models.Nameserver, len(z.NameServers))
|
||||
for i, s := range z.NameServers {
|
||||
nameserver[i] = &models.Nameserver{Name: s}
|
||||
}
|
||||
return nameserver, nil
|
||||
}
|
||||
@@ -192,7 +182,10 @@ func (api *hetznerProvider) GetZoneRecords(domain string) (models.Records, error
|
||||
}
|
||||
existingRecords := make([]*models.RecordConfig, len(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
|
||||
}
|
||||
@@ -202,9 +195,9 @@ func (api *hetznerProvider) ListZones() ([]string, error) {
|
||||
if err := api.getAllZones(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var zones []string
|
||||
for i := range api.zones {
|
||||
zones = append(zones, i)
|
||||
zones := make([]string, 0, len(api.zones))
|
||||
for domain := range api.zones {
|
||||
zones = append(zones, domain)
|
||||
}
|
||||
return zones, nil
|
||||
}
|
||||
|
@@ -14,14 +14,6 @@ type bulkUpdateRecordsRequest struct {
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -31,6 +23,7 @@ type getAllRecordsResponse struct {
|
||||
Meta struct {
|
||||
Pagination struct {
|
||||
LastPage int `json:"last_page"`
|
||||
TotalEntries int `json:"total_entries"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
@@ -40,6 +33,7 @@ type getAllZonesResponse struct {
|
||||
Meta struct {
|
||||
Pagination struct {
|
||||
LastPage int `json:"last_page"`
|
||||
TotalEntries int `json:"total_entries"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
@@ -47,7 +41,7 @@ type getAllZonesResponse struct {
|
||||
type record struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TTL *int `json:"ttl"`
|
||||
TTL *uint32 `json:"ttl"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
@@ -57,52 +51,50 @@ type zone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NameServers []string `json:"ns"`
|
||||
TTL int `json:"ttl"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
}
|
||||
|
||||
func fromRecordConfig(in *models.RecordConfig, zone *zone) *record {
|
||||
ttl := int(in.TTL)
|
||||
record := &record{
|
||||
func fromRecordConfig(in *models.RecordConfig, zone *zone) record {
|
||||
r := record{
|
||||
Name: in.GetLabel(),
|
||||
Type: in.Type,
|
||||
Value: in.GetTargetCombined(),
|
||||
TTL: &ttl,
|
||||
TTL: &in.TTL,
|
||||
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,
|
||||
// but do not fit w/ added quotes (via GetTargetCombined()).
|
||||
// Sending the raw, non-quoted value works for the comprehensive
|
||||
// suite of integrations tests.
|
||||
// The HETZNER validation does not provide helpful error messages.
|
||||
// {"error":{"message":"422 Unprocessable Entity: missing: ; ","code":422}}
|
||||
// Last checked: 2023-04-01
|
||||
valueNotQuoted := in.TxtStrings[0]
|
||||
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 {
|
||||
rc := &models.RecordConfig{
|
||||
Type: record.Type,
|
||||
TTL: uint32(*record.TTL),
|
||||
Original: record,
|
||||
func toRecordConfig(domain string, r *record) (*models.RecordConfig, error) {
|
||||
rc := models.RecordConfig{
|
||||
Type: r.Type,
|
||||
TTL: *r.TTL,
|
||||
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.
|
||||
// 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.
|
||||
value = strings.TrimRight(value, " ")
|
||||
}
|
||||
|
||||
_ = rc.PopulateFromString(record.Type, value, domain)
|
||||
|
||||
return rc
|
||||
return &rc, rc.PopulateFromString(r.Type, value, domain)
|
||||
}
|
||||
|
Reference in New Issue
Block a user