diff --git a/OWNERS b/OWNERS
index d09584223..94ff95f50 100644
--- a/OWNERS
+++ b/OWNERS
@@ -11,6 +11,7 @@ providers/dnsimple @aeden
providers/gandi_v5 @TomOnTime
# providers/gcloud
providers/hedns @rblenkinsopp
+providers/hetzner @das7pad
providers/hexonet @papakai
providers/internetbs @pragmaton
providers/inwx @svenpeter42
diff --git a/README.md b/README.md
index 92c207e87..174e89ab2 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@ Currently supported DNS providers:
- Exoscale
- Gandi
- Google DNS
+ - Hetzner
- HEXONET
- Hurricane Electric DNS
- INWX
diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html
index 39f4ad1c0..bea0e6491 100644
--- a/docs/_includes/matrix.html
+++ b/docs/_includes/matrix.html
@@ -21,6 +21,7 @@
GANDI_V5 |
GCLOUD |
HEDNS |
+ HETZNER |
HEXONET |
INTERNETBS |
INWX |
@@ -86,6 +87,9 @@
|
+
+
+ |
|
@@ -182,6 +186,9 @@
|
+
+
+ |
|
@@ -272,6 +279,9 @@
|
+
+
+ |
|
@@ -353,6 +363,9 @@
|
+
+
+ |
|
@@ -416,6 +429,7 @@
|
|
+ |
|
@@ -480,6 +494,9 @@
|
+
+
+ |
|
@@ -550,6 +567,9 @@
|
|
+
+
+ |
|
@@ -617,6 +637,7 @@
|
|
+ |
|
@@ -676,6 +697,9 @@
|
+
+
+ |
|
@@ -753,6 +777,9 @@
|
+
+
+ |
|
|
@@ -814,6 +841,9 @@
|
|
+
+
+ |
|
@@ -873,6 +903,9 @@
|
+
+
+ |
|
@@ -918,6 +951,7 @@
|
|
|
+ |
|
@@ -959,6 +993,7 @@
|
|
|
+ |
|
@@ -1002,6 +1037,9 @@
|
+
+
+ |
|
|
@@ -1062,6 +1100,9 @@
|
|
+
+
+ |
|
@@ -1145,6 +1186,9 @@
|
|
+
+
+ |
|
@@ -1247,6 +1291,9 @@
|
+
+
+ |
|
@@ -1324,6 +1371,9 @@
|
+
+
+ |
|
diff --git a/docs/_providers/hetzner.md b/docs/_providers/hetzner.md
new file mode 100644
index 000000000..99f6228a2
--- /dev/null
+++ b/docs/_providers/hetzner.md
@@ -0,0 +1,73 @@
+---
+name: Hetzner DNS Console
+title: Hetzner DNS Console
+layout: default
+jsId: HETZNER
+---
+
+# Hetzner DNS Console Provider
+
+## Configuration
+
+In your credentials file, you must provide a
+[Hetzner API Key](https://dns.hetzner.com/settings/api-token).
+
+{% highlight json %}
+{
+ "hetzner": {
+ "api_key": "your-api-key"
+ }
+}
+{% endhighlight %}
+
+## Metadata
+
+This provider does not recognize any special metadata fields unique to Hetzner
+ DNS Console.
+
+## Usage
+
+Example Javascript:
+
+{% highlight js %}
+var REG_NONE = NewRegistrar('none', 'NONE');
+var HETZNER = NewDnsProvider("hetzner", "HETZNER");
+
+D("example.tld", REG_NONE, DnsProvider(HETZNER),
+ A("test","1.2.3.4")
+);
+{%endhighlight%}
+
+## Activation
+
+Create a new API Key in the
+[Hetzner DNS Console](https://dns.hetzner.com/settings/api-token).
+
+## Caveats
+
+### SOA
+
+Hetzner DNS Console does not allow changing the SOA record via their API.
+There is an alternative method using an import of a full BIND file, but this
+ approach does not play nice with incremental changes or ignored records.
+At this time you cannot update SOA records via DNSControl.
+
+### Rate Limiting
+
+In case you are frequently seeing messages about being rate-limited:
+
+{% highlight txt %}
+WARNING: request rate-limited, constant back-off is now at 1s.
+{% endhighlight %}
+
+You may want to enable the `rate_limited` mode by default.
+
+In your `creds.json` for all `HETZNER` provider entries:
+{% highlight json %}
+{
+ "hetzner": {
+ "rate_limited": "true",
+ "api_key": "your-api-key"
+ }
+}
+{% endhighlight %}
diff --git a/docs/provider-list.md b/docs/provider-list.md
index 37eafb1ec..d53676814 100644
--- a/docs/provider-list.md
+++ b/docs/provider-list.md
@@ -81,6 +81,7 @@ Maintainers of contributed providers:
* `EXOSCALE` @pierre-emmanuelJ
* `GANDI_V5` @TomOnTime
* `HEDNS` @rblenkinsopp
+* `HETZNER` @das7pad
* `HEXONET` @papakai
* `INTERNETBS` @pragmaton
* `INWX` @svenpeter42
diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go
index c53a8dbe2..542a8a039 100644
--- a/integrationTest/integration_test.go
+++ b/integrationTest/integration_test.go
@@ -693,7 +693,7 @@ func makeTests(t *testing.T) []*TestGroup {
),
testgroup("empty TXT",
- not("CLOUDFLAREAPI", "HEXONET", "INWX", "NETCUP"),
+ not("CLOUDFLAREAPI", "HETZNER", "HEXONET", "INWX", "NETCUP"),
tc("TXT with empty str", txt("foo1", "")),
// https://github.com/StackExchange/dnscontrol/issues/598
// We decided that permitting the TXT target to be an empty
diff --git a/integrationTest/providers.json b/integrationTest/providers.json
index ee97b4855..6bf602808 100644
--- a/integrationTest/providers.json
+++ b/integrationTest/providers.json
@@ -69,6 +69,11 @@
"totp-key": "$HEDNS_TOTP_SECRET",
"username": "$HEDNS_USERNAME"
},
+ "HETZNER": {
+ "api_key": "$HETZNER_API_KEY",
+ "domain": "$HETZNER_DOMAIN",
+ "rate_limited": "true"
+ },
"HEXONET": {
"apientity": "$HEXONET_ENTITY",
"apilogin": "$HEXONET_UID",
diff --git a/providers/_all/all.go b/providers/_all/all.go
index 2ce06459a..4ce697ff8 100644
--- a/providers/_all/all.go
+++ b/providers/_all/all.go
@@ -18,6 +18,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v3/providers/gandi_v5"
_ "github.com/StackExchange/dnscontrol/v3/providers/gcloud"
_ "github.com/StackExchange/dnscontrol/v3/providers/hedns"
+ _ "github.com/StackExchange/dnscontrol/v3/providers/hetzner"
_ "github.com/StackExchange/dnscontrol/v3/providers/hexonet"
_ "github.com/StackExchange/dnscontrol/v3/providers/internetbs"
_ "github.com/StackExchange/dnscontrol/v3/providers/inwx"
diff --git a/providers/hetzner/api.go b/providers/hetzner/api.go
new file mode 100644
index 000000000..f4eda7581
--- /dev/null
+++ b/providers/hetzner/api.go
@@ -0,0 +1,232 @@
+package hetzner
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "time"
+)
+
+const (
+ baseURL = "https://dns.hetzner.com/api/v1"
+)
+
+type api struct {
+ apiKey string
+ zones map[string]zone
+ 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 (api *api) 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)
+}
+
+func (api *api) createZone(name string) error {
+ request := createZoneRequest{
+ Name: name,
+ }
+ return api.request("/zones", "POST", request, nil)
+}
+
+func (api *api) deleteRecord(record record) error {
+ if err := checkIsLockedSystemRecord(record); err != nil {
+ return err
+ }
+
+ url := fmt.Sprintf("/records/%s", record.ID)
+ return api.request(url, "DELETE", nil, nil)
+}
+
+func (api *api) getAllRecords(domain string) ([]record, error) {
+ zone, err := api.getZone(domain)
+ if err != nil {
+ return nil, err
+ }
+ page := 1
+ records := make([]record, 0)
+ 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); 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 checkIsLockedSystemRecord(record) != nil {
+ // Some records are not available for updating, hide them.
+ continue
+ }
+
+ records = append(records, record)
+ }
+ // 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 *api) getAllZones() error {
+ if api.zones != nil {
+ return nil
+ }
+ zones := map[string]zone{}
+ page := 1
+ for {
+ response := &getAllZonesResponse{}
+ url := fmt.Sprintf("/zones?per_page=100&page=%d", page)
+ if err := api.request(url, "GET", nil, response); err != nil {
+ return fmt.Errorf("failed fetching zones: %w", err)
+ }
+ for _, zone := range response.Zones {
+ zones[zone.Name] = zone
+ }
+ // 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 *api) getZone(name string) (*zone, error) {
+ if err := api.getAllZones(); err != nil {
+ return nil, err
+ }
+ zone, ok := api.zones[name]
+ if !ok {
+ return nil, fmt.Errorf("%q is not a zone in this HETZNER account", name)
+ }
+ return &zone, nil
+}
+
+func (api *api) request(endpoint string, method string, request interface{}, target interface{}) error {
+ 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)
+
+ for {
+ api.requestRateLimiter.beforeRequest()
+ resp, err := http.DefaultClient.Do(req)
+ api.requestRateLimiter.afterRequest()
+ if err != nil {
+ return err
+ }
+ cleanupResponseBody := func() {
+ err := resp.Body.Close()
+ if err != nil {
+ fmt.Println(fmt.Sprintf("failed closing response body: %q", err))
+ }
+ }
+
+ if resp.StatusCode == 429 {
+ api.requestRateLimiter.handleRateLimitedRequest()
+ cleanupResponseBody()
+ continue
+ }
+
+ defer cleanupResponseBody()
+ if resp.StatusCode != 200 {
+ data, _ := ioutil.ReadAll(resp.Body)
+ fmt.Println(string(data))
+ return fmt.Errorf("bad status code from HETZNER: %d not 200", resp.StatusCode)
+ }
+ if target == nil {
+ return nil
+ }
+ decoder := json.NewDecoder(resp.Body)
+ return decoder.Decode(target)
+ }
+}
+
+func (api *api) startRateLimited() {
+ // Simulate a request that is getting a 429 response.
+ api.requestRateLimiter.afterRequest()
+ api.requestRateLimiter.bumpDelay()
+}
+
+func (api *api) updateRecord(record record) error {
+ if err := checkIsLockedSystemRecord(record); err != nil {
+ return err
+ }
+
+ url := fmt.Sprintf("/records/%s", record.ID)
+ return api.request(url, "PUT", record, nil)
+}
+
+type requestRateLimiter struct {
+ delay time.Duration
+ lastRequest time.Time
+}
+
+func (requestRateLimiter *requestRateLimiter) afterRequest() {
+ requestRateLimiter.lastRequest = time.Now()
+}
+
+func (requestRateLimiter *requestRateLimiter) beforeRequest() {
+ if requestRateLimiter.delay == 0 {
+ return
+ }
+ time.Sleep(time.Until(requestRateLimiter.lastRequest.Add(requestRateLimiter.delay)))
+}
+
+func (requestRateLimiter *requestRateLimiter) bumpDelay() string {
+ var backoffType string
+ if requestRateLimiter.delay == 0 {
+ // At the time this provider was implemented (2020-10-18),
+ // one request per second could go though when rate-limited.
+ requestRateLimiter.delay = time.Second
+ backoffType = "constant"
+ } else {
+ // The initial assumption of 1 req/s may no hold true forever.
+ // Future proof this provider, use exponential back-off.
+ requestRateLimiter.delay = requestRateLimiter.delay * 2
+ backoffType = "exponential"
+ }
+ return backoffType
+}
+
+func (requestRateLimiter *requestRateLimiter) handleRateLimitedRequest() {
+ backoffType := requestRateLimiter.bumpDelay()
+ fmt.Println(fmt.Sprintf("WARNING: request rate-limited, %s back-off is now at %s.", backoffType, requestRateLimiter.delay))
+}
diff --git a/providers/hetzner/hetznerProvider.go b/providers/hetzner/hetznerProvider.go
new file mode 100644
index 000000000..501e6f051
--- /dev/null
+++ b/providers/hetzner/hetznerProvider.go
@@ -0,0 +1,173 @@
+package hetzner
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/StackExchange/dnscontrol/v3/models"
+ "github.com/StackExchange/dnscontrol/v3/pkg/diff"
+ "github.com/StackExchange/dnscontrol/v3/providers"
+)
+
+var features = providers.DocumentationNotes{
+ providers.DocCreateDomains: providers.Can(),
+ providers.DocDualHost: providers.Can(),
+ providers.DocOfficiallySupported: providers.Cannot(),
+ providers.CanGetZones: providers.Can(),
+ providers.CanUseAlias: providers.Cannot(),
+ providers.CanUseCAA: providers.Can(),
+ providers.CanUseDS: providers.Cannot(),
+ providers.CanUsePTR: providers.Cannot(),
+ providers.CanUseSRV: providers.Can(),
+ providers.CanUseSSHFP: providers.Cannot(),
+ providers.CanUseTLSA: providers.Cannot(),
+ providers.CanUseTXTMulti: providers.Cannot(),
+}
+
+func init() {
+ providers.RegisterDomainServiceProviderType("HETZNER", New, features)
+}
+
+// New creates a new API handle.
+func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) {
+ if settings["api_key"] == "" {
+ return nil, fmt.Errorf("missing HETZNER api_key")
+ }
+
+ api := &api{}
+
+ api.apiKey = settings["api_key"]
+
+ if settings["rate_limited"] == "true" {
+ api.startRateLimited()
+ }
+
+ return api, nil
+}
+
+// EnsureDomainExists creates the domain if it does not exist.
+func (api *api) EnsureDomainExists(domain string) error {
+ domains, err := api.ListZones()
+ if err != nil {
+ return err
+ }
+
+ for _, d := range domains {
+ if d == domain {
+ return nil
+ }
+ }
+
+ return api.createZone(domain)
+}
+
+// GetDomainCorrections returns the corrections for a domain.
+func (api *api) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
+ dc, err := dc.Copy()
+ if err != nil {
+ return nil, err
+ }
+
+ err = dc.Punycode()
+ if err != nil {
+ return nil, err
+ }
+ domain := dc.Name
+
+ // Get existing records
+ existingRecords, err := api.GetZoneRecords(domain)
+ if err != nil {
+ return nil, err
+ }
+
+ // Normalize
+ models.PostProcessRecords(existingRecords)
+
+ differ := diff.New(dc)
+ _, create, del, modify, err := differ.IncrementalDiff(existingRecords)
+ if err != nil {
+ return nil, err
+ }
+
+ var corrections []*models.Correction
+
+ zone, err := api.getZone(domain)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, m := range del {
+ record := m.Existing.Original.(*record)
+ corr := &models.Correction{
+ Msg: m.String(),
+ F: func() error {
+ return api.deleteRecord(*record)
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+
+ for _, m := range create {
+ record := fromRecordConfig(m.Desired, zone)
+ corr := &models.Correction{
+ Msg: m.String(),
+ F: func() error {
+ return api.createRecord(*record)
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+
+ for _, m := range modify {
+ id := m.Existing.Original.(*record).ID
+ record := fromRecordConfig(m.Desired, zone)
+ record.ID = id
+ corr := &models.Correction{
+ Msg: m.String(),
+ F: func() error {
+ return api.updateRecord(*record)
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+
+ return corrections, nil
+}
+
+// GetNameservers returns the nameservers for a domain.
+func (api *api) GetNameservers(domain string) ([]*models.Nameserver, error) {
+ zone, 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]}
+ }
+ return nameserver, nil
+}
+
+// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
+func (api *api) GetZoneRecords(domain string) (models.Records, error) {
+ records, err := api.getAllRecords(domain)
+ if err != nil {
+ return nil, err
+ }
+ existingRecords := make([]*models.RecordConfig, len(records))
+ for i := range records {
+ existingRecords[i] = toRecordConfig(domain, &records[i])
+ }
+ return existingRecords, nil
+}
+
+// ListZones lists the zones on this account.
+func (api *api) ListZones() ([]string, error) {
+ if err := api.getAllZones(); err != nil {
+ return nil, err
+ }
+ var zones []string
+ for i := range api.zones {
+ zones = append(zones, i)
+ }
+ return zones, nil
+}
diff --git a/providers/hetzner/types.go b/providers/hetzner/types.go
new file mode 100644
index 000000000..d72fadd21
--- /dev/null
+++ b/providers/hetzner/types.go
@@ -0,0 +1,88 @@
+package hetzner
+
+import (
+ "github.com/StackExchange/dnscontrol/v3/models"
+)
+
+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"`
+}
+
+type getAllRecordsResponse struct {
+ Records []record `json:"records"`
+ Meta struct {
+ Pagination struct {
+ LastPage int `json:"last_page"`
+ } `json:"pagination"`
+ } `json:"meta"`
+}
+
+type getAllZonesResponse struct {
+ Zones []zone `json:"zones"`
+ Meta struct {
+ Pagination struct {
+ LastPage int `json:"last_page"`
+ } `json:"pagination"`
+ } `json:"meta"`
+}
+
+type record struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ TTL *int `json:"ttl"`
+ Type string `json:"type"`
+ Value string `json:"value"`
+ ZoneID string `json:"zone_id"`
+}
+
+type zone struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ NameServers []string `json:"ns"`
+ TTL int `json:"ttl"`
+}
+
+func fromRecordConfig(in *models.RecordConfig, zone *zone) *record {
+ ttl := int(in.TTL)
+ record := &record{
+ Name: in.GetLabel(),
+ Type: in.Type,
+ Value: in.GetTargetField(),
+ TTL: &ttl,
+ ZoneID: zone.ID,
+ }
+
+ switch record.Type {
+ case "TXT":
+ // Cannot use `in.GetTargetCombined()` for TXTs:
+ // Their validation would complain about a missing `;`.
+ // Test case: single_TXT:Create_a_255-byte_TXT
+ // {"error":{"message":"422 Unprocessable Entity: missing: ; ","code":422}}
+ record.Value = in.GetTargetField()
+ default:
+ record.Value = in.GetTargetCombined()
+ }
+
+ return record
+}
+
+func toRecordConfig(domain string, record *record) *models.RecordConfig {
+ rc := &models.RecordConfig{
+ Type: record.Type,
+ TTL: uint32(*record.TTL),
+ Original: record,
+ }
+ rc.SetLabel(record.Name, domain)
+
+ _ = rc.PopulateFromString(record.Type, record.Value, domain)
+
+ return rc
+}