mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-05-11 05:55:12 +00: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:
1
OWNERS
1
OWNERS
@ -11,6 +11,7 @@ providers/dnsimple @aeden
|
|||||||
providers/gandi_v5 @TomOnTime
|
providers/gandi_v5 @TomOnTime
|
||||||
# providers/gcloud
|
# providers/gcloud
|
||||||
providers/hedns @rblenkinsopp
|
providers/hedns @rblenkinsopp
|
||||||
|
providers/hetzner @das7pad
|
||||||
providers/hexonet @papakai
|
providers/hexonet @papakai
|
||||||
providers/internetbs @pragmaton
|
providers/internetbs @pragmaton
|
||||||
providers/inwx @svenpeter42
|
providers/inwx @svenpeter42
|
||||||
|
@ -27,6 +27,7 @@ Currently supported DNS providers:
|
|||||||
- Exoscale
|
- Exoscale
|
||||||
- Gandi
|
- Gandi
|
||||||
- Google DNS
|
- Google DNS
|
||||||
|
- Hetzner
|
||||||
- HEXONET
|
- HEXONET
|
||||||
- Hurricane Electric DNS
|
- Hurricane Electric DNS
|
||||||
- INWX
|
- INWX
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
<th class="rotate"><div><span>GANDI_V5</span></div></th>
|
<th class="rotate"><div><span>GANDI_V5</span></div></th>
|
||||||
<th class="rotate"><div><span>GCLOUD</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>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>HEXONET</span></div></th>
|
||||||
<th class="rotate"><div><span>INTERNETBS</span></div></th>
|
<th class="rotate"><div><span>INTERNETBS</span></div></th>
|
||||||
<th class="rotate"><div><span>INWX</span></div></th>
|
<th class="rotate"><div><span>INWX</span></div></th>
|
||||||
@ -86,6 +87,9 @@
|
|||||||
<td class="danger">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</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.">
|
<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>
|
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -182,6 +186,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="success">
|
||||||
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="danger">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -272,6 +279,9 @@
|
|||||||
<td class="danger">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="danger">
|
||||||
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -353,6 +363,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</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.">
|
<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>
|
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -416,6 +429,7 @@
|
|||||||
</td>
|
</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><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.">
|
<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>
|
<i class="fa fa-circle-o text-info" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -480,6 +494,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</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><i class="fa fa-minus dim"></i></td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
@ -550,6 +567,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="danger">
|
||||||
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -617,6 +637,7 @@
|
|||||||
</td>
|
</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><i class="fa fa-minus dim"></i></td>
|
||||||
|
<td><i class="fa fa-minus dim"></i></td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -676,6 +697,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</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">
|
<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>
|
<i class="fa has-tooltip fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -753,6 +777,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</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><i class="fa fa-minus dim"></i></td>
|
<td><i class="fa fa-minus dim"></i></td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
@ -814,6 +841,9 @@
|
|||||||
<td class="danger">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="danger">
|
||||||
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -873,6 +903,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="danger">
|
||||||
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</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><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.">
|
<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>
|
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</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><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">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -1002,6 +1037,9 @@
|
|||||||
<td class="danger">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</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><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.">
|
<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">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</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><i class="fa fa-minus dim"></i></td>
|
||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
@ -1145,6 +1186,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="success">
|
||||||
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="danger">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -1247,6 +1291,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="success">
|
||||||
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="danger">
|
<td class="danger">
|
||||||
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
<i class="fa fa-times text-danger" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
@ -1324,6 +1371,9 @@
|
|||||||
<td class="success">
|
<td class="success">
|
||||||
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="success">
|
||||||
|
<i class="fa fa-check text-success" aria-hidden="true"></i>
|
||||||
|
</td>
|
||||||
<td class="info">
|
<td class="info">
|
||||||
<i class="fa fa-circle-o text-info" aria-hidden="true"></i>
|
<i class="fa fa-circle-o text-info" aria-hidden="true"></i>
|
||||||
</td>
|
</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
|
* `EXOSCALE` @pierre-emmanuelJ
|
||||||
* `GANDI_V5` @TomOnTime
|
* `GANDI_V5` @TomOnTime
|
||||||
* `HEDNS` @rblenkinsopp
|
* `HEDNS` @rblenkinsopp
|
||||||
|
* `HETZNER` @das7pad
|
||||||
* `HEXONET` @papakai
|
* `HEXONET` @papakai
|
||||||
* `INTERNETBS` @pragmaton
|
* `INTERNETBS` @pragmaton
|
||||||
* `INWX` @svenpeter42
|
* `INWX` @svenpeter42
|
||||||
|
@ -693,7 +693,7 @@ func makeTests(t *testing.T) []*TestGroup {
|
|||||||
),
|
),
|
||||||
|
|
||||||
testgroup("empty TXT",
|
testgroup("empty TXT",
|
||||||
not("CLOUDFLAREAPI", "HEXONET", "INWX", "NETCUP"),
|
not("CLOUDFLAREAPI", "HETZNER", "HEXONET", "INWX", "NETCUP"),
|
||||||
tc("TXT with empty str", txt("foo1", "")),
|
tc("TXT with empty str", txt("foo1", "")),
|
||||||
// https://github.com/StackExchange/dnscontrol/issues/598
|
// https://github.com/StackExchange/dnscontrol/issues/598
|
||||||
// We decided that permitting the TXT target to be an empty
|
// We decided that permitting the TXT target to be an empty
|
||||||
|
@ -69,6 +69,11 @@
|
|||||||
"totp-key": "$HEDNS_TOTP_SECRET",
|
"totp-key": "$HEDNS_TOTP_SECRET",
|
||||||
"username": "$HEDNS_USERNAME"
|
"username": "$HEDNS_USERNAME"
|
||||||
},
|
},
|
||||||
|
"HETZNER": {
|
||||||
|
"api_key": "$HETZNER_API_KEY",
|
||||||
|
"domain": "$HETZNER_DOMAIN",
|
||||||
|
"rate_limited": "true"
|
||||||
|
},
|
||||||
"HEXONET": {
|
"HEXONET": {
|
||||||
"apientity": "$HEXONET_ENTITY",
|
"apientity": "$HEXONET_ENTITY",
|
||||||
"apilogin": "$HEXONET_UID",
|
"apilogin": "$HEXONET_UID",
|
||||||
|
@ -18,6 +18,7 @@ import (
|
|||||||
_ "github.com/StackExchange/dnscontrol/v3/providers/gandi_v5"
|
_ "github.com/StackExchange/dnscontrol/v3/providers/gandi_v5"
|
||||||
_ "github.com/StackExchange/dnscontrol/v3/providers/gcloud"
|
_ "github.com/StackExchange/dnscontrol/v3/providers/gcloud"
|
||||||
_ "github.com/StackExchange/dnscontrol/v3/providers/hedns"
|
_ "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/hexonet"
|
||||||
_ "github.com/StackExchange/dnscontrol/v3/providers/internetbs"
|
_ "github.com/StackExchange/dnscontrol/v3/providers/internetbs"
|
||||||
_ "github.com/StackExchange/dnscontrol/v3/providers/inwx"
|
_ "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
|
||||||
|
}
|
Reference in New Issue
Block a user