/* 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 ovh import ( "context" "errors" "fmt" "net/url" "slices" "strconv" "strings" "time" "github.com/miekg/dns" "github.com/ovh/go-ovh/ovh" "github.com/patrickmn/go-cache" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "go.uber.org/ratelimit" ) const ( defaultTTL = 0 ovhCreate = iota ovhDelete ovhUpdate ) var ( // ErrRecordToMutateNotFound when ApplyChange has to update/delete and didn't found the record in the existing zone (Change with no record ID) ErrRecordToMutateNotFound = errors.New("record to mutate not found in current zone") ) // OVHProvider is an implementation of Provider for OVH DNS. type OVHProvider struct { provider.BaseProvider client ovhClient apiRateLimiter ratelimit.Limiter domainFilter *endpoint.DomainFilter // DryRun enables dry-run mode DryRun bool // EnableCNAMERelativeTarget controls if CNAME target should be sent with relative format. // Previous implementations of the OVHProvider always added a final dot as for absolut format. // Default value is false, all CNAME are transformed into absolut format. // Setting this to true will allow relative format to be sent to DNS zone. EnableCNAMERelativeTarget bool // UseCache controls if the OVHProvider will cache records in memory, and serve them // without recontacting the OVHcloud API if the SOA of the domain zone hasn't changed. // Note that, when disabling cache, OVHcloud API has rate-limiting that will hit if // your refresh rate/number of records is too big, which might cause issue with the // provider. // Default value: true UseCache bool lastRunRecords []ovhRecord lastRunZones []string cacheInstance *cache.Cache dnsClient dnsClient } type ovhClient interface { PostWithContext(context.Context, string, any, any) error PutWithContext(context.Context, string, any, any) error GetWithContext(context.Context, string, any) error DeleteWithContext(context.Context, string, any) error } type dnsClient interface { ExchangeContext(ctx context.Context, m *dns.Msg, a string) (*dns.Msg, time.Duration, error) } type ovhRecordFields struct { ovhRecordFieldUpdate FieldType string `json:"fieldType"` } type ovhRecordFieldUpdate struct { SubDomain string `json:"subDomain"` TTL int64 `json:"ttl"` Target string `json:"target"` } type ovhRecord struct { ovhRecordFields ID uint64 `json:"id"` Zone string `json:"zone"` } func (r ovhRecord) String() string { return "record#" + strconv.Itoa(int(r.ID)) + ": " + r.FieldType + " | " + r.SubDomain + " => " + r.Target + " (" + strconv.Itoa(int(r.TTL)) + ")" } type ovhChange struct { ovhRecord Action int } // NewOVHProvider initializes a new OVH DNS based Provider. func NewOVHProvider(ctx context.Context, domainFilter *endpoint.DomainFilter, endpoint string, apiRateLimit int, enableCNAMERelative, dryRun bool) (*OVHProvider, error) { client, err := ovh.NewEndpointClient(endpoint) if err != nil { return nil, err } client.UserAgent = externaldns.UserAgent() return &OVHProvider{ client: client, domainFilter: domainFilter, apiRateLimiter: ratelimit.New(apiRateLimit), DryRun: dryRun, cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), dnsClient: new(dns.Client), UseCache: true, EnableCNAMERelativeTarget: enableCNAMERelative, }, nil } // Records returns the list of records in all relevant zones. func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, records, err := p.zonesRecords(ctx) if err != nil { return nil, err } p.lastRunRecords = records p.lastRunZones = zones endpoints := ovhGroupByNameAndType(records) log.Infof("OVH: %d endpoints have been found", len(endpoints)) return endpoints, nil } func planChangesByZoneName(zones []string, changes *plan.Changes) map[string]*plan.Changes { zoneNameIDMapper := provider.ZoneIDName{} for _, zone := range zones { zoneNameIDMapper.Add(zone, zone) } output := map[string]*plan.Changes{} for _, endpt := range changes.Delete { _, zoneName := zoneNameIDMapper.FindZone(endpt.DNSName) if _, ok := output[zoneName]; !ok { output[zoneName] = &plan.Changes{} } output[zoneName].Delete = append(output[zoneName].Delete, endpt) } for _, endpt := range changes.Create { _, zoneName := zoneNameIDMapper.FindZone(endpt.DNSName) if _, ok := output[zoneName]; !ok { output[zoneName] = &plan.Changes{} } output[zoneName].Create = append(output[zoneName].Create, endpt) } for _, change := range changes.Update { // NOTE: DNSName of `change.Old` and `change.New` will be equivalent _, zoneName := zoneNameIDMapper.FindZone(change.Old.DNSName) if _, ok := output[zoneName]; !ok { output[zoneName] = &plan.Changes{} } output[zoneName].Update = append(output[zoneName].Update, change) } return output } func (p *OVHProvider) computeSingleZoneChanges(_ context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) ([]ovhChange, error) { allChanges := []ovhChange{} var computedChanges []ovhChange computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhCreate, changes.Create, zoneName, existingRecords) allChanges = append(allChanges, computedChanges...) computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhDelete, changes.Delete, zoneName, existingRecords) allChanges = append(allChanges, computedChanges...) var err error computedChanges, err = p.newOvhChangeUpdate(changes.Update, zoneName, existingRecords) if err != nil { return nil, err } allChanges = append(allChanges, computedChanges...) return allChanges, nil } func (p *OVHProvider) handleSingleZoneUpdate(ctx context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) error { allChanges, err := p.computeSingleZoneChanges(ctx, zoneName, existingRecords, changes) if err != nil { return err } log.Infof("OVH: %q: %d changes will be done", zoneName, len(allChanges)) eg, ctxErrGroup := errgroup.WithContext(ctx) for _, change := range allChanges { eg.Go(func() error { return p.change(ctxErrGroup, change) }) } err = eg.Wait() // do not refresh zone if errors: some records might haven't been processed yet, hence the zone will be in an inconsistent state // if modification of the zone was in error, invalidating the cache to make sure next run will start freshly if err == nil { err = p.refresh(ctx, zoneName) } else { p.invalidateCache(zoneName) } return err } // ApplyChanges applies a given set of changes in a given zone. func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, records := p.lastRunZones, p.lastRunRecords defer func() { p.lastRunRecords = []ovhRecord{} p.lastRunZones = []string{} }() if log.IsLevelEnabled(log.DebugLevel) { for _, change := range changes.Create { log.Debugf("OVH: changes CREATE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } for _, change := range changes.UpdateOld() { log.Debugf("OVH: changes UPDATEOLD dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } for _, change := range changes.UpdateNew() { log.Debugf("OVH: changes UPDATENEW dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } for _, change := range changes.Delete { log.Debugf("OVH: changes DELETE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } } changesByZoneName := planChangesByZoneName(zones, changes) eg, ctx := errgroup.WithContext(ctx) for zoneName, changes := range changesByZoneName { eg.Go(func() error { return p.handleSingleZoneUpdate(ctx, zoneName, records, changes) }) } if err := eg.Wait(); err != nil { return provider.NewSoftError(err) } return nil } func (p *OVHProvider) refresh(ctx context.Context, zone string) error { log.Debugf("OVH: Refresh %s zone", zone) // Zone has been altered so we invalidate the cache // so that the next run will reload it. p.invalidateCache(zone) p.apiRateLimiter.Take() if p.DryRun { log.Infof("OVH: Dry-run: Would have refresh DNS zone %q", zone) return nil } if err := p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/refresh", url.PathEscape(zone)), nil, nil); err != nil { return provider.NewSoftError(err) } return nil } func (p *OVHProvider) change(ctx context.Context, change ovhChange) error { p.apiRateLimiter.Take() switch change.Action { case ovhCreate: log.Debugf("OVH: Add an entry to %s", change.String()) if p.DryRun { log.Infof("OVH: Dry-run: Would have created a DNS record for zone %s", change.Zone) return nil } return p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(change.Zone)), change.ovhRecordFields, nil) case ovhDelete: if change.ID == 0 { return ErrRecordToMutateNotFound } log.Debugf("OVH: Delete an entry to %s", change.String()) if p.DryRun { log.Infof("OVH: Dry-run: Would have deleted a DNS record for zone %s", change.Zone) return nil } return p.client.DeleteWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), nil) case ovhUpdate: if change.ID == 0 { return ErrRecordToMutateNotFound } log.Debugf("OVH: Update an entry to %s", change.String()) if p.DryRun { log.Infof("OVH: Dry-run: Would have updated a DNS record for zone %s", change.Zone) return nil } return p.client.PutWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), change.ovhRecordFieldUpdate, nil) default: return nil } } func (p *OVHProvider) invalidateCache(zone string) { p.cacheInstance.Delete(zone + "#soa") } func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) { var allRecords []ovhRecord zones, err := p.zones(ctx) if err != nil { return nil, nil, provider.NewSoftError(err) } chRecords := make(chan []ovhRecord, len(zones)) eg, ctx := errgroup.WithContext(ctx) for _, zone := range zones { eg.Go(func() error { return p.records(ctx, &zone, chRecords) }) } if err := eg.Wait(); err != nil { return nil, nil, provider.NewSoftError(err) } close(chRecords) for records := range chRecords { allRecords = append(allRecords, records...) } return zones, allRecords, nil } func (p *OVHProvider) zones(ctx context.Context) ([]string, error) { var zones []string var filteredZones []string p.apiRateLimiter.Take() if err := p.client.GetWithContext(ctx, "/domain/zone", &zones); err != nil { return nil, err } for _, zoneName := range zones { if p.domainFilter == nil || p.domainFilter.Match(zoneName) { filteredZones = append(filteredZones, zoneName) } } log.Infof("OVH: %d zones found", len(filteredZones)) return filteredZones, nil } type ovhSoa struct { Server string `json:"server"` Serial uint32 `json:"serial"` records []ovhRecord } func (p *OVHProvider) records(ctx context.Context, zone *string, records chan<- []ovhRecord) error { var recordsIds []uint64 ovhRecords := make([]ovhRecord, len(recordsIds)) eg, ctxErrGroup := errgroup.WithContext(ctx) if p.UseCache { if cachedSoaItf, ok := p.cacheInstance.Get(*zone + "#soa"); ok { cachedSoa := cachedSoaItf.(ovhSoa) log.Debugf("OVH: zone %s: Checking SOA against %v", *zone, cachedSoa.Serial) m := new(dns.Msg) m.SetQuestion(dns.Fqdn(*zone), dns.TypeSOA) in, _, err := p.dnsClient.ExchangeContext(ctx, m, strings.TrimSuffix(cachedSoa.Server, ".")+":53") if err == nil { if s, ok := in.Answer[0].(*dns.SOA); ok { if s.Serial == cachedSoa.Serial { log.Debugf("OVH: zone %s: SOA from cache is valid", *zone) records <- cachedSoa.records return nil } } } p.invalidateCache(*zone) } } log.Debugf("OVH: Getting records for %s from API", *zone) p.apiRateLimiter.Take() var soa ovhSoa if p.UseCache { if err := p.client.GetWithContext(ctx, "/domain/zone/"+url.PathEscape(*zone)+"/soa", &soa); err != nil { return err } } if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(*zone)), &recordsIds); err != nil { return err } chRecords := make(chan ovhRecord, len(recordsIds)) for _, id := range recordsIds { eg.Go(func() error { return p.record(ctxErrGroup, zone, id, chRecords) }) } if err := eg.Wait(); err != nil { return err } close(chRecords) for record := range chRecords { ovhRecords = append(ovhRecords, record) } if p.UseCache { soa.records = ovhRecords _ = p.cacheInstance.Add(*zone+"#soa", soa, cache.DefaultExpiration) } records <- ovhRecords return nil } func (p *OVHProvider) record(ctx context.Context, zone *string, id uint64, records chan<- ovhRecord) error { record := ovhRecord{} log.Debugf("OVH: Getting record %d for %s", id, *zone) p.apiRateLimiter.Take() if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(*zone), id), &record); err != nil { return err } if provider.SupportedRecordType(record.FieldType) { log.Debugf("OVH: Record %d for %s is %+v", id, *zone, record) records <- record } return nil } func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint { endpoints := []*endpoint.Endpoint{} // group supported records by name and type groups := map[string][]ovhRecord{} for _, r := range records { groupBy := r.Zone + "//" + r.SubDomain + "//" + r.FieldType if _, ok := groups[groupBy]; !ok { groups[groupBy] = []ovhRecord{} } groups[groupBy] = append(groups[groupBy], r) } // create single endpoint with all the targets for each name/type for _, records := range groups { var targets []string for _, record := range records { targets = append(targets, record.Target) } ep := endpoint.NewEndpointWithTTL( strings.TrimPrefix(records[0].SubDomain+"."+records[0].Zone, "."), records[0].FieldType, endpoint.TTL(records[0].TTL), targets..., ) endpoints = append(endpoints, ep) } return endpoints } func (p *OVHProvider) newOvhChangeCreateDelete(action int, endpoints []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, []ovhRecord) { var ovhChanges []ovhChange var toDeleteIds []int for _, e := range endpoints { for _, target := range e.Targets { change := ovhChange{ Action: action, ovhRecord: ovhRecord{ Zone: zone, ovhRecordFields: ovhRecordFields{ FieldType: e.RecordType, ovhRecordFieldUpdate: ovhRecordFieldUpdate{ SubDomain: convertDNSNameIntoSubDomain(e.DNSName, zone), TTL: defaultTTL, Target: target, }, }, }, } p.formatCNAMETarget(&change) if e.RecordTTL.IsConfigured() { change.TTL = int64(e.RecordTTL) } // The Zone might have multiple records with the same target. In order to avoid applying the action to the // same OVH record, we remove a record from the list when a match is found. if action == ovhDelete { for i, rec := range existingRecords { if rec.Zone == change.Zone && rec.SubDomain == change.SubDomain && rec.FieldType == change.FieldType && rec.Target == change.Target && !slices.Contains(toDeleteIds, i) { change.ID = rec.ID toDeleteIds = append(toDeleteIds, i) break } } } ovhChanges = append(ovhChanges, change) } } if len(toDeleteIds) > 0 { // Copy the records because we need to mutate the list. newExistingRecords := make([]ovhRecord, 0, len(existingRecords)-len(toDeleteIds)) for id := range existingRecords { if slices.Contains(toDeleteIds, id) { continue } newExistingRecords = append(newExistingRecords, existingRecords[id]) } existingRecords = newExistingRecords } return ovhChanges, existingRecords } func convertDNSNameIntoSubDomain(DNSName string, zoneName string) string { if DNSName == zoneName { return "" } return strings.TrimSuffix(DNSName, "."+zoneName) } func normalizeDNSName(dnsName string) string { return strings.TrimSpace(strings.ToLower(dnsName)) } func (p *OVHProvider) newOvhChangeUpdate(updates []*plan.Update, zone string, existingRecords []ovhRecord) ([]ovhChange, error) { zoneNameIDMapper := provider.ZoneIDName{} zoneNameIDMapper.Add(zone, zone) oldEndpointByTypeAndName := map[string]*endpoint.Endpoint{} newEndpointByTypeAndName := map[string]*endpoint.Endpoint{} oldRecordsInZone := map[string][]ovhRecord{} for _, update := range updates { // NOTE: DNSName and RecordType of `update.Old` and `update.New` will be equivalent sub := convertDNSNameIntoSubDomain(update.Old.DNSName, zone) oldEndpointByTypeAndName[normalizeDNSName(update.Old.RecordType+"//"+sub)] = update.Old newEndpointByTypeAndName[normalizeDNSName(update.New.RecordType+"//"+sub)] = update.New } for id := range oldEndpointByTypeAndName { for _, record := range existingRecords { if id == normalizeDNSName(record.FieldType+"//"+record.SubDomain) { oldRecordsInZone[id] = append(oldRecordsInZone[id], record) } } } var changes []ovhChange for id := range oldEndpointByTypeAndName { oldRecords := slices.Clone(oldRecordsInZone[id]) endpointsNew, ok := newEndpointByTypeAndName[id] if !ok { return nil, errors.New("unrecoverable error: couldn't find the matching record in the update.New") } var toInsertTarget []string for _, target := range endpointsNew.Targets { var toDelete = -1 for i, record := range oldRecords { if target == record.Target { toDelete = i break } } if toDelete >= 0 { oldRecords = slices.Delete(oldRecords, toDelete, toDelete+1) } else { toInsertTarget = append(toInsertTarget, target) } } createChangeConvertedToUpdateChange := []int{} for i, target := range toInsertTarget { if len(oldRecords) == 0 { break } record := oldRecords[0] oldRecords = slices.Delete(oldRecords, 0, 1) record.Target = target if endpointsNew.RecordTTL.IsConfigured() { record.TTL = int64(endpointsNew.RecordTTL) } else { record.TTL = defaultTTL } change := ovhChange{ Action: ovhUpdate, ovhRecord: record, } p.formatCNAMETarget(&change) changes = append(changes, change) createChangeConvertedToUpdateChange = append(createChangeConvertedToUpdateChange, i) } newToInsertTarget := make([]string, 0, len(toInsertTarget)-len(createChangeConvertedToUpdateChange)) for i := range toInsertTarget { if slices.Contains(createChangeConvertedToUpdateChange, i) { continue } newToInsertTarget = append(newToInsertTarget, toInsertTarget[i]) } toInsertTarget = newToInsertTarget if len(toInsertTarget) > 0 { for _, target := range toInsertTarget { recordTTL := int64(defaultTTL) if endpointsNew.RecordTTL.IsConfigured() { recordTTL = int64(endpointsNew.RecordTTL) } change := ovhChange{ Action: ovhCreate, ovhRecord: ovhRecord{ Zone: zone, ovhRecordFields: ovhRecordFields{ FieldType: endpointsNew.RecordType, ovhRecordFieldUpdate: ovhRecordFieldUpdate{ SubDomain: convertDNSNameIntoSubDomain(endpointsNew.DNSName, zone), TTL: recordTTL, Target: target, }, }, }, } p.formatCNAMETarget(&change) changes = append(changes, change) } } if len(oldRecords) > 0 { for i := range oldRecords { changes = append(changes, ovhChange{ Action: ovhDelete, ovhRecord: oldRecords[i], }) } } } return changes, nil } func (c *ovhChange) String() string { var action string switch c.Action { case ovhCreate: action = "create" case ovhUpdate: action = "update" case ovhDelete: action = "delete" default: action = "unknown" } if c.ID != 0 { return fmt.Sprintf("%s zone (ID : %d) action(%s) : %s %d IN %s %s", c.Zone, c.ID, action, c.SubDomain, c.TTL, c.FieldType, c.Target) } return fmt.Sprintf("%s zone action(%s) : %s %d IN %s %s", c.Zone, action, c.SubDomain, c.TTL, c.FieldType, c.Target) } func (p *OVHProvider) formatCNAMETarget(change *ovhChange) { if change.FieldType != endpoint.RecordTypeCNAME { return } if p.EnableCNAMERelativeTarget { return } if strings.HasSuffix(change.Target, ".") { return } change.Target += "." }