mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-09 21:55:57 +08:00
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>
264 lines
7.9 KiB
Go
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()
|
|
}
|