HETZNER_V2: Add provider for Hetzner DNS API (#3837)

Closes https://github.com/StackExchange/dnscontrol/issues/3787

This PR is adding a `HETZNER_V2` provider for the "new" Hetzner DNS API.

Testing:
- The integration tests are passing.
- Manual testing:
  - `preview` (see diff for existing zone)
- `preview --populate-on-preview` (see full diff for newly created zone)
  - `push` (see full diff; no diff after push)
- `push` (see full diff; no diff after push to newly created zone --
i.e. single pass and done)

```js
var REG_NONE = NewRegistrar('none')
var DSP = NewDnsProvider('HETZNER_V2')

D('testing-2025-11-14-7.dev', REG_NONE, DnsProvider(DSP),
    A('@', '127.0.0.1')
)
```

<details>

```
# push for newly created zone
CONCURRENTLY checking for 1 zone(s)
SERIALLY checking for 0 zone(s)
Waiting for concurrent checking(s) to complete...DONE
******************** Domain: testing-2025-11-14-7.dev
1 correction (HETZNER_V2)
#1: Ensuring zone "testing-2025-11-14-7.dev" exists in "HETZNER_V2"
SUCCESS!
CONCURRENTLY gathering records of 1 zone(s)
SERIALLY gathering records of 0 zone(s)
Waiting for concurrent gathering(s) to complete...DONE
******************** Domain: testing-2025-11-14-7.dev
4 corrections (HETZNER_V2)
#1: ± MODIFY-TTL testing-2025-11-14-7.dev NS helium.ns.hetzner.de. ttl=(3600->300)
± MODIFY-TTL testing-2025-11-14-7.dev NS hydrogen.ns.hetzner.com. ttl=(3600->300)
± MODIFY-TTL testing-2025-11-14-7.dev NS oxygen.ns.hetzner.com. ttl=(3600->300)
SUCCESS!
#2: + CREATE testing-2025-11-14-7.dev A 127.0.0.1 ttl=300
SUCCESS!
Done. 5 corrections.
```
</details>

Feedback for @jooola and @LKaemmerling:
- The SDK was very useful in getting 80% there! Nice! 🎉 
- Footgun:
- The `result` values are not "up-to-date" after waiting for an
`Action`, e.g. `Zone.AuthoritativeNameservers.Assigned` is not set when
`Client.Zone.Create()` returns and the following "wait" will not update
it.
- Taking a step back here: Waiting for an `Action` with a separate SDK
call does not seem very natural to me. Does the SDK-user need to know
that you are processing operations asynchronous? (Which seems like an
implementation detail to me, something that the SDK could abstrct over.)
Can `Client.Zone.Create()` return the final `Zone` instead of the
intermediate result?
- Features missing compared to the DNS Console, in priority order:
- It is no longer possible to remove your provided name servers from the
root/apex. Use-case: dual-home/multi-home zone with fewer than three
servers from Hetzner. I'm operating one of these and cannot migrate over
until this is fixed.
- Performance regression due to lack of bulk create/modify. E.g. [one of
the test
suites](a71b89e5a2/integrationTest/integration_test.go (L619))
spends about 4.5 minutes on making creating 100 record-sets and then
another 4 minutes for deleting them in sequence again. With your async
API, these are `create 2*100 + delete 2*100 = 400` API calls.
Previously, these were `create 1 + delete 100 = 101` API calls. Are you
planning on adding batch processing again?
- Usability nits
- Compared to other record-set based APIs, upserts for record-sets are
missing. This applies to records of a record-set and the ttl of the
record-set (see separate SDK calls for the cases `diff2.CREATE` vs
`diff2.CHANGE` and two calls in `diff2.CHANGE` for updating the TTL vs
records).
- Some SDK methods return an `Action` (e.g. `Zone.ChangeRRSetTTL()`),
others wrap the `Action` in a struct (`Client.Zone.CreateRRSet()`) --
even when the struct has a single field (`ZoneRRSetDeleteResult`).

---------

Co-authored-by: "Jonas L." <jooola@users.noreply.github.com>
Co-authored-by: "Lukas Kämmerling" <LKaemmerling@users.noreply.github.com>
Co-authored-by: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com>
This commit is contained in:
Jakob Ackermann 2025-11-30 15:14:54 +01:00 committed by GitHub
parent 1b2f5d4d34
commit 1e67585e8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 381 additions and 3 deletions

View file

@ -52,7 +52,7 @@ jobs:
Write-Host "Integration test providers: $Providers"
echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT
env:
PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','JOKER','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']"
PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','HEDNS','HETZNER_V2','HEXONET','HUAWEICLOUD','INWX','JOKER','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']"
ENV_CONTEXT: ${{ toJson(env) }}
VARS_CONTEXT: ${{ toJson(vars) }}
SECRETS_CONTEXT: ${{ toJson(secrets) }}
@ -87,6 +87,7 @@ jobs:
GANDI_V5_DOMAIN: ${{ vars.GANDI_V5_DOMAIN }}
GCLOUD_DOMAIN: ${{ vars.GCLOUD_DOMAIN }}
HEDNS_DOMAIN: ${{ vars.HEDNS_DOMAIN }}
HETZNER_V2_DOMAIN: ${{ vars.HETZNER_V2_DOMAIN }}
HEXONET_DOMAIN: ${{ vars.HEXONET_DOMAIN }}
HUAWEICLOUD_DOMAIN: ${{ vars.HUAWEICLOUD_DOMAIN }}
JOKER_DOMAIN: ${{ vars.JOKER_DOMAIN }}
@ -154,6 +155,8 @@ jobs:
HEDNS_TOTP_SECRET: ${{ secrets.HEDNS_TOTP_SECRET }}
HEDNS_USERNAME: ${{ secrets.HEDNS_USERNAME }}
#
HETZNER_V2_API_TOKEN: ${{ secrets.HETZNER_V2_API_TOKEN }}
#
HEXONET_ENTITY: ${{ secrets.HEXONET_ENTITY }}
HEXONET_PW: ${{ secrets.HEXONET_PW }}
HEXONET_UID: ${{ secrets.HEXONET_UID }}

View file

@ -39,7 +39,7 @@ changelog:
regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$"
order: 1
- title: 'Provider-specific changes:'
regexp: "(?i)((adguardhome|akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|joker|linode|loopia|luadns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*"
regexp: "(?i)((adguardhome|akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hetznerv2|hexonet|hostingde|huaweicloud|inwx|joker|linode|loopia|luadns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*"
order: 2
- title: 'Documentation:'
regexp: "(?i)^.*(docs)[(\\w)]*:+.*$"

1
OWNERS
View file

@ -25,6 +25,7 @@ providers/gcloud @riyadhalnur
providers/gcore @xddxdd
providers/hedns @rblenkinsopp
providers/hetzner @das7pad
providers/hetznerv2 @das7pad
providers/hexonet @KaiSchwarz-cnic
providers/hostingde @juliusrickert
providers/huaweicloud @huihuimoe

View file

@ -137,7 +137,8 @@
* [Gandi_v5](provider/gandi_v5.md)
* [Gcore](provider/gcore.md)
* [Google Cloud DNS](provider/gcloud.md)
* [Hetzner DNS Console](provider/hetzner.md)
* [Hetzner DNS API](provider/hetzner_v2.md)
* [Hetzner DNS Console (legacy)](provider/hetzner.md)
* [HEXONET](provider/hexonet.md)
* [hosting.de](provider/hostingde.md)
* [Huawei Cloud DNS](provider/huaweicloud.md)

View file

@ -0,0 +1,54 @@
## Configuration
To use this provider, add an entry to `creds.json` with `TYPE` set to `HETZNER_V2`
along with a [Hetzner API Token](https://docs.hetzner.cloud/reference/cloud#getting-started).
Example:
{% code title="creds.json" %}
```json
{
"hetzner_v2": {
"TYPE": "HETZNER_V2",
"api_token": "your-api-token"
}
}
```
{% endcode %}
## Metadata
This provider does not recognize any special metadata fields unique to Hetzner DNS API.
## Usage
An example configuration:
{% code title="dnsconfig.js" %}
```javascript
var REG_NONE = NewRegistrar("none");
var DSP_HETZNER = NewDnsProvider("hetzner_v2");
D("example.com", REG_NONE, DnsProvider(DSP_HETZNER),
A("test", "1.2.3.4"),
);
```
{% endcode %}
## Activation
Create a new API Key in the
[Hetzner Console](https://docs.hetzner.cloud/reference/cloud#getting-started).
## Caveats
### NS
Removing the Hetzner provided NS records at the root is not possible.
### SOA
Hetzner DNS API 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.

View file

@ -52,6 +52,7 @@ Jump to a table:
| [`GCORE`](gcore.md) | ❌ | ✅ | ❌ |
| [`HEDNS`](hedns.md) | ❌ | ✅ | ❌ |
| [`HETZNER`](hetzner.md) | ❌ | ✅ | ❌ |
| [`HETZNER_V2`](hetzner_v2.md) | ❌ | ✅ | ❌ |
| [`HEXONET`](hexonet.md) | ❌ | ✅ | ✅ |
| [`HOSTINGDE`](hostingde.md) | ❌ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ✅ | ❌ |
@ -112,6 +113,7 @@ Jump to a table:
| [`GCORE`](gcore.md) | ✅ | ✅ | ✅ | ✅ |
| [`HEDNS`](hedns.md) | ❔ | ✅ | ✅ | ✅ |
| [`HETZNER`](hetzner.md) | ✅ | ✅ | ✅ | ✅ |
| [`HETZNER_V2`](hetzner_v2.md) | ✅ | ✅ | ✅ | ✅ |
| [`HEXONET`](hexonet.md) | ❔ | ✅ | ✅ | ❔ |
| [`HOSTINGDE`](hostingde.md) | ❔ | ✅ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ✅ | ✅ | ✅ |
@ -169,6 +171,7 @@ Jump to a table:
| [`GCORE`](gcore.md) | ✅ | ❔ | ❌ | ✅ | ❔ |
| [`HEDNS`](hedns.md) | ✅ | ❔ | ✅ | ✅ | ❌ |
| [`HETZNER`](hetzner.md) | ❌ | ❔ | ❌ | ❌ | ❌ |
| [`HETZNER_V2`](hetzner_v2.md) | ❌ | ❔ | ❌ | ✅ | ❌ |
| [`HEXONET`](hexonet.md) | ❌ | ❔ | ❔ | ✅ | ❔ |
| [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ❌ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ❔ | ❌ | ❌ | ❌ |
@ -223,6 +226,7 @@ Jump to a table:
| [`GCORE`](gcore.md) | ❔ | ❌ | ✅ | ✅ |
| [`HEDNS`](hedns.md) | ❔ | ✅ | ✅ | ✅ |
| [`HETZNER`](hetzner.md) | ❔ | ❌ | ✅ | ❔ |
| [`HETZNER_V2`](hetzner_v2.md) | ❔ | ❌ | ✅ | ✅ |
| [`HEXONET`](hexonet.md) | ❔ | ❔ | ✅ | ❔ |
| [`HOSTINGDE`](hostingde.md) | ❔ | ❌ | ✅ | ❔ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❌ | ✅ | ❌ |
@ -276,6 +280,7 @@ Jump to a table:
| [`GCORE`](gcore.md) | ✅ | ✅ | ❔ | ❌ | ❌ |
| [`HEDNS`](hedns.md) | ✅ | ✅ | ❔ | ✅ | ❌ |
| [`HETZNER`](hetzner.md) | ✅ | ❔ | ❔ | ❌ | ✅ |
| [`HETZNER_V2`](hetzner_v2.md) | ✅ | ✅ | ❔ | ❌ | ✅ |
| [`HEXONET`](hexonet.md) | ✅ | ❔ | ❔ | ❔ | ✅ |
| [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ❔ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ✅ | ❌ | ❔ | ❌ | ❌ |
@ -320,6 +325,7 @@ Jump to a table:
| [`GCORE`](gcore.md) | ✅ | ❔ | ❌ |
| [`HEDNS`](hedns.md) | ❌ | ❔ | ❌ |
| [`HETZNER`](hetzner.md) | ❌ | ❔ | ✅ |
| [`HETZNER_V2`](hetzner_v2.md) | ❌ | ❔ | ✅ |
| [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❔ | ❌ |
| [`INWX`](inwx.md) | ✅ | ❔ | ❔ |

9
go.mod
View file

@ -66,6 +66,7 @@ require (
github.com/fbiville/markdown-table-formatter v0.3.0
github.com/google/go-cmp v0.7.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/hetznercloud/hcloud-go/v2 v2.30.0
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174
github.com/kylelemons/godebug v1.1.0
github.com/luadns/luadns-go v0.3.0
@ -97,8 +98,10 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deepmap/oapi-codegen v1.9.1 // indirect
@ -126,10 +129,15 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
@ -147,6 +155,7 @@ require (
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect

22
go.sum
View file

@ -78,6 +78,8 @@ github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 h1:2vQTbEJvFsyd1VefzZ34GUkUD6TkJleYYJh9/25WBE4=
github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9/go.mod h1:bqqNsI2akL+lLWyApkYY0cxquWPKwEBU0Wd3chi3TEg=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
@ -90,6 +92,8 @@ github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.7 h1:Jk7u
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.7/go.mod h1:FnQtD0+Q/1NZxi0eEWN+3ZRyMsE9vzSB3YjyunkbKD0=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 h1:RvyTDU0VmnUBd3Qm2i6irEXtCR2KRIxnRlD8l+5z/DY=
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18/go.mod h1:a6n4wXFHbMW0iJFxHIJR4PkgG5krP52nOVCBU0m+Obw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@ -229,6 +233,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hetznercloud/hcloud-go/v2 v2.30.0 h1:fgAUtCCw4PbJNSs9XPLHVu0//dTNMbPq8P/48ovmdG8=
github.com/hetznercloud/hcloud-go/v2 v2.30.0/go.mod h1:zv7x2kM7xyJ5mW/+y4HbfxQYhk8TE57ypTa1hofsYdw=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174 h1:FBlx7E5rl8doUTbizt+DXR0zU05Mu2oEYvc/2GMB7pc=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY=
@ -246,6 +252,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00=
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -300,6 +308,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g=
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
@ -333,7 +343,15 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494/go.mod h1:yipyliwI08eQ6XwDm1fEwKPdF/xdbkiHtrU+1Hg+vc4=
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
@ -426,6 +444,10 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View file

@ -176,6 +176,11 @@
"api_key": "$HETZNER_API_KEY",
"domain": "$HETZNER_DOMAIN"
},
"HETZNER_V2": {
"TYPE": "HETZNER_V2",
"api_token": "$HETZNER_V2_API_TOKEN",
"domain": "$HETZNER_V2_DOMAIN"
},
"HEXONET": {
"TYPE": "HEXONET",
"apientity": "$HEXONET_ENTITY",

View file

@ -30,6 +30,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v4/providers/gcore"
_ "github.com/StackExchange/dnscontrol/v4/providers/hedns"
_ "github.com/StackExchange/dnscontrol/v4/providers/hetzner"
_ "github.com/StackExchange/dnscontrol/v4/providers/hetznerv2"
_ "github.com/StackExchange/dnscontrol/v4/providers/hexonet"
_ "github.com/StackExchange/dnscontrol/v4/providers/hostingde"
_ "github.com/StackExchange/dnscontrol/v4/providers/huaweicloud"

View file

@ -0,0 +1,12 @@
package hetznerv2
import (
"github.com/StackExchange/dnscontrol/v4/models"
)
// AuditRecords returns a list of errors corresponding to the records
// that aren't supported by this provider. If all records are
// supported, an empty list is returned.
func AuditRecords(_ []*models.RecordConfig) []error {
return nil
}

View file

@ -0,0 +1,264 @@
package hetznerv2
import (
"context"
"encoding/json"
"errors"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"golang.org/x/net/idna"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
"github.com/StackExchange/dnscontrol/v4/pkg/version"
"github.com/StackExchange/dnscontrol/v4/pkg/zonecache"
"github.com/StackExchange/dnscontrol/v4/providers"
)
var features = providers.DocumentationNotes{
// The default for unlisted capabilities is 'Cannot'.
// See providers/capabilities.go for the entire list of capabilities.
providers.CanAutoDNSSEC: providers.Cannot(),
providers.CanConcur: providers.Can(),
providers.CanGetZones: providers.Can(),
providers.CanOnlyDiff1Features: providers.Can(),
providers.CanUseAlias: providers.Cannot(),
providers.CanUseCAA: providers.Can(),
providers.CanUseDS: providers.Can(),
providers.CanUseDSForChildren: providers.Cannot(),
providers.CanUseLOC: providers.Cannot(),
providers.CanUseNAPTR: providers.Cannot(),
providers.CanUsePTR: providers.Can(),
providers.CanUseSOA: providers.Cannot(),
providers.CanUseSRV: providers.Can(),
providers.CanUseSVCB: providers.Can(),
providers.CanUseHTTPS: providers.Can(),
providers.CanUseSSHFP: providers.Cannot(),
providers.CanUseTLSA: providers.Can(),
providers.DocCreateDomains: providers.Can(),
providers.DocOfficiallySupported: providers.Cannot(),
providers.DocDualHost: providers.Can(),
}
func init() {
const providerName = "HETZNER_V2"
const providerMaintainer = "@das7pad"
fns := providers.DspFuncs{
Initializer: New,
RecordAuditor: AuditRecords,
}
providers.RegisterDomainServiceProviderType(providerName, fns, features)
providers.RegisterMaintainer(providerName, providerMaintainer)
}
// New creates a new API handle.
func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) {
apiToken := settings["api_token"]
if apiToken == "" {
return nil, errors.New("missing HETZNER_V2 api_token")
}
h := &hetznerv2Provider{
client: hcloud.NewClient(
hcloud.WithToken(apiToken),
hcloud.WithApplication("dnscontrol", version.Version()),
),
}
h.zoneCache = zonecache.New(h.fetchAllZones)
return h, nil
}
type hetznerv2Provider struct {
zoneCache zonecache.ZoneCache[*hcloud.Zone]
client *hcloud.Client
}
// fetchAllZones is used by the zonecache.ZoneCache.
func (h *hetznerv2Provider) fetchAllZones() (map[string]*hcloud.Zone, error) {
flat, err := h.client.Zone.All(context.Background())
if err != nil {
return nil, err
}
zones := make(map[string]*hcloud.Zone, len(flat))
for _, z := range flat {
zones[z.Name] = z
}
return zones, nil
}
// EnsureZoneExists creates a zone if it does not exist
func (h *hetznerv2Provider) EnsureZoneExists(domain string, _ map[string]string) error {
encoded, err := idna.ToASCII(domain)
if err != nil {
return err
}
if ok, err2 := h.zoneCache.HasZone(encoded); err2 != nil || ok {
return err2
}
result, _, err := h.client.Zone.Create(context.Background(), hcloud.ZoneCreateOpts{
Name: encoded,
Mode: hcloud.ZoneModePrimary,
})
if err != nil {
return err
}
err = h.client.Action.WaitFor(context.Background(), result.Action)
if err != nil {
return err
}
z, _, err := h.client.Zone.GetByID(context.Background(), result.Zone.ID)
if err != nil {
return err
}
h.zoneCache.SetZone(encoded, z)
return nil
}
// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
func (h *hetznerv2Provider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) {
encoded, err := idna.ToASCII(dc.Name)
if err != nil {
return nil, 0, err
}
z, err := h.zoneCache.GetZone(encoded)
if err != nil {
return nil, 0, err
}
// Hetzner Cloud has a "ByRecordSet" API for DNS.
// At each label:rtype pair, we either delete all records or UPSERT the desired records.
instructions, actualChangeCount, err := diff2.ByRecordSet(existingRecords, dc, nil)
if err != nil {
return nil, 0, err
}
var reports []*models.Correction
for _, instruction := range instructions {
switch instruction.Type {
case diff2.REPORT:
reports = append(reports, &models.Correction{
Msg: instruction.MsgsJoined,
})
continue
case diff2.CREATE:
first := instruction.New[0]
ttl := int(first.TTL)
opts := hcloud.ZoneRRSetCreateOpts{
Name: first.Name,
Type: hcloud.ZoneRRSetType(first.Type),
TTL: &ttl,
}
for _, r := range instruction.New {
opts.Records = append(opts.Records, hcloud.ZoneRRSetRecord{
Value: r.GetTargetCombinedFunc(txtutil.EncodeQuoted),
})
}
reports = append(reports, &models.Correction{
F: func() error {
_, _, err2 := h.client.Zone.CreateRRSet(context.Background(), z, opts)
return err2
},
Msg: instruction.MsgsJoined,
})
case diff2.CHANGE:
rrSet := instruction.Old[0].Original.(*hcloud.ZoneRRSet)
reports = append(reports, &models.Correction{
F: func() error {
if instruction.New[0].TTL != instruction.Old[0].TTL {
ttl := int(instruction.New[0].TTL)
opts := hcloud.ZoneRRSetChangeTTLOpts{TTL: &ttl}
_, _, err2 := h.client.Zone.ChangeRRSetTTL(context.Background(), rrSet, opts)
if err2 != nil {
return err2
}
}
opts := hcloud.ZoneRRSetSetRecordsOpts{}
for _, r := range instruction.New {
opts.Records = append(opts.Records, hcloud.ZoneRRSetRecord{
Value: r.GetTargetCombinedFunc(txtutil.EncodeQuoted),
})
}
_, _, err2 := h.client.Zone.SetRRSetRecords(context.Background(), rrSet, opts)
return err2
},
Msg: instruction.MsgsJoined,
})
case diff2.DELETE:
reports = append(reports, &models.Correction{
F: func() error {
rc := instruction.Old[0].Original.(*hcloud.ZoneRRSet)
_, _, err2 := h.client.Zone.DeleteRRSet(context.Background(), rc)
return err2
},
Msg: instruction.MsgsJoined,
})
}
}
return reports, actualChangeCount, nil
}
// GetNameservers returns the nameservers for a domain.
func (h *hetznerv2Provider) GetNameservers(domain string) ([]*models.Nameserver, error) {
encoded, err := idna.ToASCII(domain)
if err != nil {
return nil, err
}
z, err := h.zoneCache.GetZone(encoded)
if err != nil {
return nil, err
}
return models.ToNameserversStripTD(z.AuthoritativeNameservers.Assigned)
}
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (h *hetznerv2Provider) GetZoneRecords(domain string, _ map[string]string) (models.Records, error) {
encoded, err := idna.ToASCII(domain)
if err != nil {
return nil, err
}
z, err := h.zoneCache.GetZone(encoded)
if err != nil {
return nil, err
}
opts := hcloud.ZoneRRSetListOpts{}
opts.PerPage = 100
records, err := h.client.Zone.AllRRSetsWithOpts(context.Background(), z, opts)
if err != nil {
return nil, err
}
existingRecords := make([]*models.RecordConfig, 0, len(records))
for _, rrSet := range records {
if rrSet.Type == hcloud.ZoneRRSetTypeSOA {
// SOA records are not available for editing, hide them.
continue
}
base := models.RecordConfig{
Type: string(rrSet.Type),
Original: rrSet,
}
base.SetLabel(rrSet.Name, z.Name)
if rrSet.TTL != nil {
base.TTL = uint32(*rrSet.TTL)
} else {
base.TTL = uint32(z.TTL)
}
for _, r := range rrSet.Records {
rc := base
if err = rc.PopulateFromStringFunc(rc.Type, r.Value, z.Name, txtutil.ParseQuoted); err != nil {
return nil, err
}
existingRecords = append(existingRecords, &rc)
}
}
return existingRecords, nil
}
// ListZones lists the zones on this account.
func (h *hetznerv2Provider) ListZones() ([]string, error) {
return h.zoneCache.GetZoneNames()
}