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) | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ | ❌ | ❔ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ |
| [`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) | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ |

View File

@@ -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.

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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