mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-11 09:59:59 +08:00
ca5ef2d4ac
Co-authored-by: Tom Limoncelli <tal@whatexit.org>
268 lines
6.7 KiB
Go
268 lines
6.7 KiB
Go
package hetzner
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/StackExchange/dnscontrol/v3/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
|
|
}
|