package hetzner import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strconv" "time" "github.com/StackExchange/dnscontrol/v4/pkg/printer" ) const ( baseURL = "https://dns.hetzner.com/api/v1" ) type hetznerProvider struct { apiKey string zones map[string]zone requestRateLimiter requestRateLimiter } 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 } delay := time.Duration(retryAfter * int64(time.Second)) return delay, nil } func parseHeaderAsInt(headers http.Header, headerName string, fallback int64) (int64, error) { v := headers.Get(headerName) if v == "" { return fallback, nil } 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 { request := bulkCreateRecordsRequest{ Records: records, } return api.request("/records/bulk", "POST", request, nil, nil) } func (api *hetznerProvider) bulkUpdateRecords(records []record) error { request := bulkUpdateRecordsRequest{ Records: records, } return api.request("/records/bulk", "PUT", request, nil, nil) } func (api *hetznerProvider) createZone(name string) error { request := createZoneRequest{ Name: name, } return api.request("/zones", "POST", request, nil, nil) } 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) { z, err := api.getZone(domain) if err != nil { return nil, err } page := 1 var records []record for { 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) } if records == nil { records = make([]record, 0, response.Meta.Pagination.TotalEntries) } 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, 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 { break } page++ } return records, nil } func (api *hetznerProvider) getAllZones() error { if api.zones != nil { return nil } var zones map[string]zone page := 1 statusOK := func(code int) bool { switch code { case http.StatusOK: return true case http.StatusNotFound: // Accept a 404 when requesting the first page return page == 1 default: return false } } for { response := getAllZonesResponse{} url := fmt.Sprintf("/zones?per_page=100&page=%d", page) if err := api.request(url, "GET", nil, &response, statusOK); err != nil { return fmt.Errorf("failed fetching zones: %w", err) } 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 { break } page++ } api.zones = zones return nil } func (api *hetznerProvider) getZone(name string) (*zone, error) { if err := api.getAllZones(); err != nil { return nil, err } z, ok := api.zones[name] if !ok { return nil, fmt.Errorf("%q is not a zone in this HETZNER account", name) } return &z, nil } func (api *hetznerProvider) request(endpoint string, method string, request interface{}, target interface{}, statusOK func(code int) bool) error { if statusOK == nil { statusOK = func(code int) bool { return code == http.StatusOK } } for { var requestBody io.Reader if request != nil { requestBodySerialised, err := json.Marshal(request) if err != nil { return err } requestBody = bytes.NewBuffer(requestBodySerialised) } req, err := http.NewRequest(method, baseURL+endpoint, requestBody) if err != nil { return err } req.Header.Add("Auth-API-Token", api.apiKey) api.requestRateLimiter.delayRequest() resp, err := http.DefaultClient.Do(req) if err != nil { return err } cleanupResponseBody := func() { err2 := resp.Body.Close() if err2 != nil { printer.Printf("failed closing response body: %q\n", err2) } } retry, err := api.requestRateLimiter.handleResponse(resp) if err != nil { cleanupResponseBody() return err } if retry { cleanupResponseBody() continue } if !statusOK(resp.StatusCode) { data, _ := io.ReadAll(resp.Body) printer.Println(string(data)) cleanupResponseBody() return fmt.Errorf("bad status code from HETZNER: %d not 200", resp.StatusCode) } if target == nil { cleanupResponseBody() return nil } err = json.NewDecoder(resp.Body).Decode(target) cleanupResponseBody() return err } } type requestRateLimiter struct { delay time.Duration lastRequest time.Time } 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 (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) retryAfter, err := parseHeaderAsSeconds(resp.Header, "Retry-After", time.Second) if err != nil { return false, err } rrl.delay = retryAfter // When rate-limited, exclude network/server latency from delay. rrl.lastRequest = time.Now() return true, nil } 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 }