/* Copyright 2020 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package godaddy import ( "context" "encoding/json" "fmt" "slices" "strings" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultTTL = 600 gdCreate = 0 gdReplace = 1 gdDelete = 2 domainsURI = "/v1/domains?statuses=ACTIVE,PENDING_DNS_ACTIVE" ) var actionNames = []string{ "create", "replace", "delete", } type gdClient interface { Patch(string, interface{}, interface{}) error Post(string, interface{}, interface{}) error Put(string, interface{}, interface{}) error Get(string, interface{}) error Delete(string, interface{}) error } // GDProvider declare GoDaddy provider type GDProvider struct { provider.BaseProvider domainFilter *endpoint.DomainFilter client gdClient ttl int64 DryRun bool } type gdEndpoint struct { endpoint *endpoint.Endpoint action int } type gdRecordField struct { Data string `json:"data"` Name string `json:"name"` TTL int64 `json:"ttl"` Type string `json:"type"` Port *int `json:"port,omitempty"` Priority *int `json:"priority,omitempty"` Weight *int64 `json:"weight,omitempty"` Protocol *string `json:"protocol,omitempty"` Service *string `json:"service,omitempty"` } type gdReplaceRecordField struct { Data string `json:"data"` TTL int64 `json:"ttl"` Port *int `json:"port,omitempty"` Priority *int `json:"priority,omitempty"` Weight *int64 `json:"weight,omitempty"` Protocol *string `json:"protocol,omitempty"` Service *string `json:"service,omitempty"` } type gdRecords struct { records []gdRecordField changed bool zone string } type gdZone struct { CreatedAt string Domain string DomainID int64 ExpirationProtected bool Expires string ExposeWhois bool HoldRegistrar bool Locked bool NameServers *[]string Privacy bool RenewAuto bool RenewDeadline string Renewable bool Status string TransferProtected bool } type gdZoneIDName map[string]*gdRecords func (z gdZoneIDName) add(zoneID string, zoneRecord *gdRecords) { z[zoneID] = zoneRecord } func (z gdZoneIDName) findZoneRecord(hostname string) (string, *gdRecords) { var suitableZoneID string var suitableZoneRecord *gdRecords for zoneID, zoneRecord := range z { if hostname == zoneRecord.zone || strings.HasSuffix(hostname, "."+zoneRecord.zone) { if suitableZoneRecord == nil || len(zoneRecord.zone) > len(suitableZoneRecord.zone) { suitableZoneID = zoneID suitableZoneRecord = zoneRecord } } } return suitableZoneID, suitableZoneRecord } // NewGoDaddyProvider initializes a new GoDaddy DNS based Provider. func NewGoDaddyProvider(_ context.Context, domainFilter *endpoint.DomainFilter, ttl int64, apiKey, apiSecret string, useOTE, dryRun bool) (*GDProvider, error) { client, err := NewClient(useOTE, apiKey, apiSecret) if err != nil { return nil, err } return &GDProvider{ client: client, domainFilter: domainFilter, ttl: maxOf(defaultTTL, ttl), DryRun: dryRun, }, nil } func (p *GDProvider) zones() ([]string, error) { zones := []gdZone{} filteredZones := []string{} if err := p.client.Get(domainsURI, &zones); err != nil { return nil, err } for _, zone := range zones { if p.domainFilter.Match(zone.Domain) { filteredZones = append(filteredZones, zone.Domain) log.Debugf("GoDaddy: %s zone found", zone.Domain) } } log.Infof("GoDaddy: %d zones found", len(filteredZones)) return filteredZones, nil } func (p *GDProvider) zonesRecords(ctx context.Context, all bool) ([]string, []gdRecords, error) { var allRecords []gdRecords zones, err := p.zones() if err != nil { return nil, nil, err } if len(zones) == 0 { allRecords = []gdRecords{} } else if len(zones) == 1 { record, err := p.records(&ctx, zones[0], all) if err != nil { return nil, nil, err } allRecords = append(allRecords, *record) } else { chRecords := make(chan gdRecords, len(zones)) eg, ctx := errgroup.WithContext(ctx) for _, zoneName := range zones { zone := zoneName eg.Go(func() error { record, err := p.records(&ctx, zone, all) if err != nil { return err } chRecords <- *record return nil }) } if err := eg.Wait(); err != nil { return nil, nil, err } close(chRecords) for records := range chRecords { allRecords = append(allRecords, records) } } return zones, allRecords, nil } func (p *GDProvider) records(_ *context.Context, zone string, all bool) (*gdRecords, error) { var recordsIds []gdRecordField log.Debugf("GoDaddy: Getting records for %s", zone) if err := p.client.Get(fmt.Sprintf("/v1/domains/%s/records", zone), &recordsIds); err != nil { return nil, err } if all { return &gdRecords{ zone: zone, records: recordsIds, }, nil } results := &gdRecords{ zone: zone, records: make([]gdRecordField, 0, len(recordsIds)), } for _, rec := range recordsIds { if provider.SupportedRecordType(rec.Type) { log.Debugf("GoDaddy: Record %s for %s is %+v", rec.Name, zone, rec) results.records = append(results.records, rec) } else { log.Infof("GoDaddy: Ignore record %s for %s is %+v", rec.Name, zone, rec) } } return results, nil } func (p *GDProvider) groupByNameAndType(zoneRecords []gdRecords) []*endpoint.Endpoint { endpoints := []*endpoint.Endpoint{} // group supported records by name and type groupsByZone := map[string]map[string][]gdRecordField{} for _, zone := range zoneRecords { groups := map[string][]gdRecordField{} groupsByZone[zone.zone] = groups for _, r := range zone.records { groupBy := fmt.Sprintf("%s - %s", r.Type, r.Name) if _, ok := groups[groupBy]; !ok { groups[groupBy] = []gdRecordField{} } groups[groupBy] = append(groups[groupBy], r) } } // create single endpoint with all the targets for each name/type for zoneName, groups := range groupsByZone { for _, records := range groups { targets := []string{} for _, record := range records { targets = append(targets, record.Data) } var recordName string if records[0].Name == "@" { recordName = strings.TrimPrefix(zoneName, ".") } else { recordName = strings.TrimPrefix(fmt.Sprintf("%s.%s", records[0].Name, zoneName), ".") } ep := endpoint.NewEndpointWithTTL( recordName, records[0].Type, endpoint.TTL(records[0].TTL), targets..., ) endpoints = append(endpoints, ep) } } return endpoints } // Records returns the list of records in all relevant zones. func (p *GDProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { _, records, err := p.zonesRecords(ctx, false) if err != nil { return nil, err } endpoints := p.groupByNameAndType(records) log.Infof("GoDaddy: %d endpoints have been found", len(endpoints)) return endpoints, nil } func (p *GDProvider) appendChange(action int, endpoints []*endpoint.Endpoint, allChanges []gdEndpoint) []gdEndpoint { for _, e := range endpoints { allChanges = append(allChanges, gdEndpoint{ action: action, endpoint: e, }) } return allChanges } func (p *GDProvider) changeAllRecords(endpoints []gdEndpoint, zoneRecords []*gdRecords) error { zoneNameIDMapper := gdZoneIDName{} for _, zoneRecord := range zoneRecords { zoneNameIDMapper.add(zoneRecord.zone, zoneRecord) } for _, e := range endpoints { dnsName := e.endpoint.DNSName zone, zoneRecord := zoneNameIDMapper.findZoneRecord(dnsName) if zone == "" { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", dnsName) } else { dnsName = strings.TrimSuffix(dnsName, "."+zone) if dnsName == zone { dnsName = "" } if e.endpoint.RecordType == endpoint.RecordTypeA && (len(dnsName) == 0) { dnsName = "@" } e.endpoint.RecordTTL = endpoint.TTL(maxOf(defaultTTL, int64(e.endpoint.RecordTTL))) if err := zoneRecord.applyEndpoint(e.action, p.client, *e.endpoint, dnsName, p.DryRun); err != nil { log.Errorf("Unable to apply change %s on record %s type %s, %v", actionNames[e.action], dnsName, e.endpoint.RecordType, err) return err } } } return nil } // ApplyChanges applies a given set of changes in a given zone. func (p *GDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { if countTargets(changes) == 0 { return nil } _, records, err := p.zonesRecords(ctx, true) if err != nil { return err } changedZoneRecords := make([]*gdRecords, len(records)) for i := range records { changedZoneRecords[i] = &records[i] } var allChanges []gdEndpoint allChanges = p.appendChange(gdDelete, changes.Delete, allChanges) iOldSkip := make(map[int]bool) iNewSkip := make(map[int]bool) updateOld := changes.UpdateOld() updateNew := changes.UpdateNew() for iOld, recOld := range updateOld { for iNew, recNew := range updateNew { if recOld.DNSName == recNew.DNSName && recOld.RecordType == recNew.RecordType { ReplaceEndpoints := []*endpoint.Endpoint{recNew} allChanges = p.appendChange(gdReplace, ReplaceEndpoints, allChanges) iOldSkip[iOld] = true iNewSkip[iNew] = true break } } } for iOld, recOld := range updateOld { _, found := iOldSkip[iOld] if found { continue } for iNew, recNew := range updateNew { _, found := iNewSkip[iNew] if found { continue } if recOld.DNSName != recNew.DNSName { continue } DeleteEndpoints := []*endpoint.Endpoint{recOld} CreateEndpoints := []*endpoint.Endpoint{recNew} allChanges = p.appendChange(gdDelete, DeleteEndpoints, allChanges) allChanges = p.appendChange(gdCreate, CreateEndpoints, allChanges) break } } allChanges = p.appendChange(gdCreate, changes.Create, allChanges) log.Infof("GoDaddy: %d changes will be done", len(allChanges)) if err = p.changeAllRecords(allChanges, changedZoneRecords); err != nil { return err } return nil } func (p *gdRecords) addRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { var response GDErrorResponse for _, target := range endpoint.Targets { change := gdRecordField{ Type: endpoint.RecordType, Name: dnsName, TTL: int64(endpoint.RecordTTL), Data: target, } p.records = append(p.records, change) p.changed = true log.Debugf("GoDaddy: Add an entry %s to zone %s", change.String(), p.zone) if dryRun { log.Infof("[DryRun] - Add record %s.%s of type %s %s", change.Name, p.zone, change.Type, toString(change)) } else if err := client.Patch(fmt.Sprintf("/v1/domains/%s/records", p.zone), []gdRecordField{change}, &response); err != nil { log.Errorf("Add record %s.%s of type %s failed: %s", change.Name, p.zone, change.Type, response) return err } } return nil } func (p *gdRecords) replaceRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { changed := []gdReplaceRecordField{} records := []string{} for _, target := range endpoint.Targets { change := gdRecordField{ Type: endpoint.RecordType, Name: dnsName, TTL: int64(endpoint.RecordTTL), Data: target, } for index, record := range p.records { if record.Type == change.Type && record.Name == change.Name { p.records[index] = change p.changed = true } } records = append(records, target) changed = append(changed, gdReplaceRecordField{ Data: change.Data, TTL: change.TTL, Port: change.Port, Priority: change.Priority, Weight: change.Weight, Protocol: change.Protocol, Service: change.Service, }) } var response GDErrorResponse if dryRun { log.Infof("[DryRun] - Replace record %s.%s of type %s %s", dnsName, p.zone, endpoint.RecordType, records) return nil } log.Debugf("Replace record %s.%s of type %s %s", dnsName, p.zone, endpoint.RecordType, records) if err := client.Put(fmt.Sprintf("/v1/domains/%s/records/%s/%s", p.zone, endpoint.RecordType, dnsName), changed, &response); err != nil { log.Errorf("Replace record %s.%s of type %s failed: %v", dnsName, p.zone, endpoint.RecordType, response) return err } return nil } // Remove one record from the record list func (p *gdRecords) deleteRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { records := []string{} for _, target := range endpoint.Targets { change := gdRecordField{ Type: endpoint.RecordType, Name: dnsName, TTL: int64(endpoint.RecordTTL), Data: target, } records = append(records, target) log.Debugf("GoDaddy: Delete an entry %s from zone %s", change.String(), p.zone) deleteIndex := -1 for index, record := range p.records { if record.Type == change.Type && record.Name == change.Name && record.Data == change.Data { deleteIndex = index break } } if deleteIndex >= 0 { p.records[deleteIndex] = p.records[len(p.records)-1] p.records = p.records[:len(p.records)-1] p.changed = true } } if dryRun { log.Infof("[DryRun] - Delete record %s.%s of type %s %s", dnsName, p.zone, endpoint.RecordType, records) return nil } var response GDErrorResponse if err := client.Delete(fmt.Sprintf("/v1/domains/%s/records/%s/%s", p.zone, endpoint.RecordType, dnsName), &response); err != nil { log.Errorf("Delete record %s.%s of type %s failed: %v", dnsName, p.zone, endpoint.RecordType, response) return err } return nil } func (p *gdRecords) applyEndpoint(action int, client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { switch action { case gdCreate: return p.addRecord(client, endpoint, dnsName, dryRun) case gdReplace: return p.replaceRecord(client, endpoint, dnsName, dryRun) case gdDelete: return p.deleteRecord(client, endpoint, dnsName, dryRun) } return nil } func (c gdRecordField) String() string { return fmt.Sprintf("%s %d IN %s %s", c.Name, c.TTL, c.Type, c.Data) } func countTargets(p *plan.Changes) int { changes := [][]*endpoint.Endpoint{p.Create, p.UpdateNew(), p.UpdateOld(), p.Delete} count := 0 for _, endpoints := range changes { for _, ep := range endpoints { count += len(ep.Targets) } } return count } func maxOf(vars ...int64) int64 { return slices.Max(vars) } func toString(obj interface{}) string { b, err := json.MarshalIndent(obj, "", " ") if err != nil { return fmt.Sprintf("<%v>", err) } return string(b) }