dnscontrol/providers/hetznerv2/hetznerv2Provider.go
Jakob Ackermann 1e67585e8f
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>
2025-11-30 09:14:54 -05:00

264 lines
7.9 KiB
Go

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()
}