mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-10 17:38:13 +08:00
NEW PROVIDER: HETZNER DNS Console (#904)
* HETZNER: implement the provider for Hetzner DNS Console Signed-off-by: Jakob Ackermann <das7pad@outlook.com> * HETZNER: apply review feedback - add domain into error messages - insert sub-strings using `%q` - insert sub-errors using `%w` - change api.getZone() signature to return a (potentially `nil`) Zone pointer instead of a (potentially empty) Zone value - sort imports and confirm with `$ goimports -w providers/hetzner/` - use exact 'api_key' term in error message of settings validation - add blank line for logic separation - drop internal record id from correction messages Co-Authored-By: Tom Limoncelli <tlimoncelli@stackoverflow.com> Signed-off-by: Jakob Ackermann <das7pad@outlook.com> * HETZNER: add request rate-limiting handling There are a limited number of data-points on how their rate-limiting works at this time. I deduce from my account to others and use a fixed/ constant backoff of 1s as the initial delay. Thereafter exponential increase with factor 2 (not needed at this time). Hetzner has not made any official statements on rate-limiting, so this is guesswork only. Signed-off-by: Jakob Ackermann <das7pad@outlook.com> * HETZNER: address golint complaints - baseUrl -> baseURL - mark Record as private -> record - mark Zone as private -> zone - mark RequestRateLimiter as private -> requestRateLimiter - capitalize Id fields as ID - keep delay logic on same level, move return out of branch Signed-off-by: Jakob Ackermann <das7pad@outlook.com> * HETZNER: rate_limited: init the response timestamp on requestRateLimiter Signed-off-by: Jakob Ackermann <das7pad@outlook.com> * HETZNER: requestRateLimiter: align local variable with struct name Signed-off-by: Jakob Ackermann <das7pad@outlook.com> Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
This commit is contained in:
parent
3a2b1b2f7b
commit
2b50af0cbc
11 changed files with 626 additions and 1 deletions
1
OWNERS
1
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
|
||||
|
|
|
@ -27,6 +27,7 @@ Currently supported DNS providers:
|
|||
- Exoscale
|
||||
- Gandi
|
||||
- Google DNS
|
||||
- Hetzner
|
||||
- HEXONET
|
||||
- Hurricane Electric DNS
|
||||
- INWX
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
<th class="rotate"><div><span>GANDI_V5</span></div></th>
|
||||
<th class="rotate"><div><span>GCLOUD</span></div></th>
|
||||
<th class="rotate"><div><span>HEDNS</span></div></th>
|
||||
<th class="rotate"><div><span>HETZNER</span></div></th>
|
||||
<th class="rotate"><div><span>HEXONET</span></div></th>
|
||||
<th class="rotate"><div><span>INTERNETBS</span></div></th>
|
||||
<th class="rotate"><div><span>INWX</span></div></th>
|
||||
|
@ -86,6 +87,9 @@
|
|||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Actively maintained provider module.">
|
||||
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -182,6 +186,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -272,6 +279,9 @@
|
|||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -353,6 +363,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Using ALIAS is possible through our extended DNS (X-DNS) service. Feel free to get in touch with us.">
|
||||
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -416,6 +429,7 @@
|
|||
</td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="info" data-toggle="tooltip" data-container="body" data-placement="top" title="Supported by INWX but not implemented yet.">
|
||||
<i class="fa fa-circle-o text-info" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -480,6 +494,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
|
@ -550,6 +567,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -617,6 +637,7 @@
|
|||
</td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -676,6 +697,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success" data-toggle="tooltip" data-container="body" data-placement="top" title="SRV records with empty targets are not supported">
|
||||
<i class="fa has-tooltip fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -753,6 +777,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="success">
|
||||
|
@ -814,6 +841,9 @@
|
|||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -873,6 +903,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -918,6 +951,7 @@
|
|||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Using ALIAS is possible through our extended DNS (X-DNS) service. Feel free to get in touch with us.">
|
||||
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -959,6 +993,7 @@
|
|||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -1002,6 +1037,9 @@
|
|||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="info" data-toggle="tooltip" data-container="body" data-placement="top" title="DS records are only supported at the apex and require a different API call that hasn't been implemented yet.">
|
||||
|
@ -1062,6 +1100,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td><i class="fa fa-minus dim"></i></td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
|
@ -1145,6 +1186,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -1247,6 +1291,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="danger">
|
||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
@ -1324,6 +1371,9 @@
|
|||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="success">
|
||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td class="info">
|
||||
<i class="fa fa-circle-o text-info" aria-hidden="true"></i>
|
||||
</td>
|
||||
|
|
73
docs/_providers/hetzner.md
Normal file
73
docs/_providers/hetzner.md
Normal file
|
@ -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 %}
|
|
@ -81,6 +81,7 @@ Maintainers of contributed providers:
|
|||
* `EXOSCALE` @pierre-emmanuelJ
|
||||
* `GANDI_V5` @TomOnTime
|
||||
* `HEDNS` @rblenkinsopp
|
||||
* `HETZNER` @das7pad
|
||||
* `HEXONET` @papakai
|
||||
* `INTERNETBS` @pragmaton
|
||||
* `INWX` @svenpeter42
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
232
providers/hetzner/api.go
Normal file
232
providers/hetzner/api.go
Normal file
|
@ -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))
|
||||
}
|
173
providers/hetzner/hetznerProvider.go
Normal file
173
providers/hetzner/hetznerProvider.go
Normal file
|
@ -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
|
||||
}
|
88
providers/hetzner/types.go
Normal file
88
providers/hetzner/types.go
Normal file
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue