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) | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ | ❌ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ |
|
| [`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) | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ |
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user