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