diff --git a/go.mod b/go.mod index 12d74aeb3..9c4cda626 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/digitalocean/godo v1.83.0 github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c github.com/dnsimple/dnsimple-go v0.71.1 - github.com/exoscale/egoscale v0.89.0 + github.com/exoscale/egoscale v0.90.2 github.com/go-acme/lego v2.7.2+incompatible github.com/go-gandi/go-gandi v0.5.0 github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe diff --git a/go.sum b/go.sum index 363acd82e..d87e505dd 100644 --- a/go.sum +++ b/go.sum @@ -198,8 +198,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/exoscale/egoscale v0.89.0 h1:YEA3pG97j14diC6okttXMOH9mLwlMuv/184uZyUGxhk= -github.com/exoscale/egoscale v0.89.0/go.mod h1:wyXE5zrnFynMXA0jMhwQqSe24CfUhmBk2WI5wFZcq6Y= +github.com/exoscale/egoscale v0.90.2 h1:oGSJy5Dxbcn5m5F0/DcnU4WXJg+2j3g+UgEu4yyKG9M= +github.com/exoscale/egoscale v0.90.2/go.mod h1:NDhQbdGNKwnLVC2YGTB6ds9WIPw+V5ckvEEV8ho7pFE= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 9c1fd672e..42f839381 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -78,9 +78,10 @@ }, "EXOSCALE": { "apikey": "$EXOSCALE_API_KEY", - "dns-endpoint": "https://api.exoscale.ch/dns", + "dns-endpoint": "https://api.exoscale.com/v2", "domain": "$EXOSCALE_DOMAIN", - "secretkey": "$EXOSCALE_SECRET_KEY" + "secretkey": "$EXOSCALE_SECRET_KEY", + "apizone": "ch-gva-2" }, "GANDI_V5": { "apikey": "$GANDI_V5_APIKEY", diff --git a/providers/exoscale/auditrecords.go b/providers/exoscale/auditrecords.go index 2713c7f96..4cfdc1a3a 100644 --- a/providers/exoscale/auditrecords.go +++ b/providers/exoscale/auditrecords.go @@ -17,5 +17,7 @@ func AuditRecords(records []*models.RecordConfig) []error { a.Add("SRV", rejectif.SrvHasNullTarget) // Last verified 2020-12-28 + a.Add("TXT", rejectif.TxtHasUnpairedDoubleQuotes) // Last verified 2022-09-14 + return a.Audit(records) } diff --git a/providers/exoscale/exoscaleProvider.go b/providers/exoscale/exoscaleProvider.go index 0a9f4ce41..a7cdfdba7 100644 --- a/providers/exoscale/exoscaleProvider.go +++ b/providers/exoscale/exoscaleProvider.go @@ -3,25 +3,54 @@ package exoscale import ( "context" "encoding/json" + "errors" "fmt" + "strconv" "strings" + egoscale "github.com/exoscale/egoscale/v2" + "github.com/StackExchange/dnscontrol/v3/models" "github.com/StackExchange/dnscontrol/v3/pkg/diff" "github.com/StackExchange/dnscontrol/v3/pkg/printer" "github.com/StackExchange/dnscontrol/v3/providers" - "github.com/exoscale/egoscale" ) +const ( + defaultAPIZone = "ch-gva-2" +) + +// ErrDomainNotFound error indicates domain name is not managed by Exoscale. +var ErrDomainNotFound = errors.New("domain not found") + type exoscaleProvider struct { - client *egoscale.Client + client *egoscale.Client + apiZone string } // NewExoscale creates a new Exoscale DNS provider. func NewExoscale(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { endpoint, apiKey, secretKey := m["dns-endpoint"], m["apikey"], m["secretkey"] - return &exoscaleProvider{client: egoscale.NewClient(endpoint, apiKey, secretKey)}, nil + client, err := egoscale.NewClient( + apiKey, + secretKey, + egoscale.ClientOptWithAPIEndpoint(endpoint), + ) + if err != nil { + return nil, err + } + + provider := exoscaleProvider{ + client: client, + apiZone: defaultAPIZone, + } + + if z, ok := m["apizone"]; ok { + provider.apiZone = z + } + + return &provider, nil } var features = providers.DocumentationNotes{ @@ -45,15 +74,9 @@ func init() { } // EnsureDomainExists returns an error if domain doesn't exist. -func (c *exoscaleProvider) EnsureDomainExists(domain string) error { - ctx := context.Background() - _, err := c.client.GetDomain(ctx, domain) - if err != nil { - _, err := c.client.CreateDomain(ctx, domain) - if err != nil { - return err - } - } +func (c *exoscaleProvider) EnsureDomainExists(domainName string) error { + _, err := c.findDomainByName(domainName) + return err } @@ -74,47 +97,92 @@ func (c *exoscaleProvider) GetZoneRecords(domain string) (models.Records, error) func (c *exoscaleProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() + domain, err := c.findDomainByName(dc.Name) + if err != nil { + return nil, err + } + + domainID := *domain.ID + ctx := context.Background() - records, err := c.client.GetRecords(ctx, dc.Name) + records, err := c.client.ListDNSDomainRecords(ctx, c.apiZone, domainID) if err != nil { return nil, err } existingRecords := make([]*models.RecordConfig, 0, len(records)) for _, r := range records { - if r.RecordType == "SOA" || r.RecordType == "NS" { + if r.ID == nil { continue } - if r.Name == "" { - r.Name = "@" + + recordID := *r.ID + + record, err := c.client.GetDNSDomainRecord(ctx, c.apiZone, domainID, recordID) + if err != nil { + return nil, err } - if r.RecordType == "CNAME" || r.RecordType == "MX" || r.RecordType == "ALIAS" || r.RecordType == "SRV" { - r.Content += "." + + // nil pointers are not expected, but just to be on the safe side... + var rtype, rcontent, rname string + if record.Type == nil { + continue + } + rtype = *record.Type + if record.Content != nil { + rcontent = *record.Content + } + if record.Name != nil { + rname = *record.Name + } + + if rtype == "SOA" || rtype == "NS" { + continue + } + if rname == "" { + t := "@" + record.Name = &t + } + if rtype == "CNAME" || rtype == "MX" || rtype == "ALIAS" || rtype == "SRV" { + t := rcontent + "." + // for SRV records we need to aditionally prefix target with priority, which API handles as separate field. + if rtype == "SRV" && record.Priority != nil { + t = fmt.Sprintf("%d %s", *record.Priority, t) + } + rcontent = t } // exoscale adds these odd txt records that mirror the alias records. // they seem to manage them on deletes and things, so we'll just pretend they don't exist - if r.RecordType == "TXT" && strings.HasPrefix(r.Content, "ALIAS for ") { + if rtype == "TXT" && strings.HasPrefix(rcontent, "ALIAS for ") { continue } - rec := &models.RecordConfig{ - TTL: uint32(r.TTL), - Original: r, + + rc := &models.RecordConfig{ + Original: record, } - rec.SetLabel(r.Name, dc.Name) - switch rtype := r.RecordType; rtype { + if record.TTL != nil { + rc.TTL = uint32(*record.TTL) + } + rc.SetLabel(rname, dc.Name) + + switch rtype { case "ALIAS", "URL": - rec.Type = r.RecordType - rec.SetTarget(r.Content) + rc.Type = rtype + rc.SetTarget(rcontent) case "MX": - if err := rec.SetTargetMX(uint16(r.Prio), r.Content); err != nil { - return nil, fmt.Errorf("unparsable record received from exoscale: %w", err) + var prio uint16 + if record.Priority != nil { + prio = uint16(*record.Priority) } + err = rc.SetTargetMX(prio, rcontent) default: - if err := rec.PopulateFromString(r.RecordType, r.Content, dc.Name); err != nil { - return nil, fmt.Errorf("unparsable record received from exoscale: %w", err) - } + err = rc.PopulateFromString(rtype, rcontent, dc.Name) } - existingRecords = append(existingRecords, rec) + if err != nil { + return nil, fmt.Errorf("unparsable record received from exoscale: %w", err) + } + + existingRecords = append(existingRecords, rc) } removeOtherNS(dc) @@ -130,27 +198,27 @@ func (c *exoscaleProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mod var corrections = []*models.Correction{} for _, del := range delete { - rec := del.Existing.Original.(egoscale.DNSRecord) + record := del.Existing.Original.(*egoscale.DNSDomainRecord) corrections = append(corrections, &models.Correction{ Msg: del.String(), - F: c.deleteRecordFunc(rec.ID, dc.Name), + F: c.deleteRecordFunc(*record.ID, domainID), }) } for _, cre := range create { - rec := cre.Desired + rc := cre.Desired corrections = append(corrections, &models.Correction{ Msg: cre.String(), - F: c.createRecordFunc(rec, dc.Name), + F: c.createRecordFunc(rc, domainID), }) } for _, mod := range modify { - old := mod.Existing.Original.(egoscale.DNSRecord) + old := mod.Existing.Original.(*egoscale.DNSDomainRecord) new := mod.Desired corrections = append(corrections, &models.Correction{ Msg: mod.String(), - F: c.updateRecordFunc(&old, new, dc.Name), + F: c.updateRecordFunc(old, new, domainID), }) } @@ -158,88 +226,128 @@ func (c *exoscaleProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*mod } // Returns a function that can be invoked to create a record in a zone. -func (c *exoscaleProvider) createRecordFunc(rc *models.RecordConfig, domainName string) func() error { +func (c *exoscaleProvider) createRecordFunc(rc *models.RecordConfig, domainID string) func() error { return func() error { - client := c.client - target := rc.GetTargetCombined() name := rc.GetLabel() + var prio *int64 if rc.Type == "MX" { target = rc.GetTargetField() + + if rc.MxPreference != 0 { + p := int64(rc.MxPreference) + prio = &p + } + } + + if rc.Type == "SRV" { + // API wants priority as a separate argument, here we will strip it from combined target. + sp := strings.Split(target, " ") + target = strings.Join(sp[1:], " ") + p, err := strconv.ParseInt(sp[0], 10, 64) + if err != nil { + return err + } + prio = &p } if rc.Type == "NS" && (name == "@" || name == "") { name = "*" } - record := egoscale.DNSRecord{ - Name: name, - RecordType: rc.Type, - Content: target, - TTL: int(rc.TTL), - Prio: int(rc.MxPreference), - } - ctx := context.Background() - _, err := client.CreateRecord(ctx, domainName, record) - if err != nil { - return err + record := egoscale.DNSDomainRecord{ + Name: &name, + Type: &rc.Type, + Content: &target, + Priority: prio, } - return nil + if rc.TTL != 0 { + ttl := int64(rc.TTL) + record.TTL = &ttl + } + + _, err := c.client.CreateDNSDomainRecord(context.Background(), c.apiZone, domainID, &record) + + return err } } // Returns a function that can be invoked to delete a record in a zone. -func (c *exoscaleProvider) deleteRecordFunc(recordID int64, domainName string) func() error { +func (c *exoscaleProvider) deleteRecordFunc(recordID, domainID string) func() error { return func() error { - client := c.client - - ctx := context.Background() - if err := client.DeleteRecord(ctx, domainName, recordID); err != nil { - return err - } - - return nil - + return c.client.DeleteDNSDomainRecord( + context.Background(), + c.apiZone, + domainID, + &egoscale.DNSDomainRecord{ID: &recordID}, + ) } } // Returns a function that can be invoked to update a record in a zone. -func (c *exoscaleProvider) updateRecordFunc(old *egoscale.DNSRecord, rc *models.RecordConfig, domainName string) func() error { +func (c *exoscaleProvider) updateRecordFunc(record *egoscale.DNSDomainRecord, rc *models.RecordConfig, domainID string) func() error { return func() error { - client := c.client - target := rc.GetTargetCombined() name := rc.GetLabel() if rc.Type == "MX" { target = rc.GetTargetField() + + if rc.MxPreference != 0 { + p := int64(rc.MxPreference) + record.Priority = &p + } + } + + if rc.Type == "SRV" { + // API wants priority as separate argument, here we will strip it from combined target. + sp := strings.Split(target, " ") + target = strings.Join(sp[1:], " ") + p, err := strconv.ParseInt(sp[0], 10, 64) + if err != nil { + return err + } + record.Priority = &p } if rc.Type == "NS" && (name == "@" || name == "") { name = "*" } - record := egoscale.UpdateDNSRecord{ - Name: name, - RecordType: rc.Type, - Content: target, - TTL: int(rc.TTL), - Prio: int(rc.MxPreference), - ID: old.ID, + record.Name = &name + record.Type = &rc.Type + record.Content = &target + if rc.TTL != 0 { + ttl := int64(rc.TTL) + record.TTL = &ttl } - ctx := context.Background() - _, err := client.UpdateRecord(ctx, domainName, record) - if err != nil { - return err - } - - return nil + return c.client.UpdateDNSDomainRecord( + context.Background(), + c.apiZone, + domainID, + record, + ) } } +func (c *exoscaleProvider) findDomainByName(name string) (*egoscale.DNSDomain, error) { + domains, err := c.client.ListDNSDomains(context.Background(), c.apiZone) + if err != nil { + return nil, err + } + + for _, domain := range domains { + if domain.UnicodeName != nil && domain.ID != nil && *domain.UnicodeName == name { + return &domain, nil + } + } + + return nil, ErrDomainNotFound +} + func defaultNSSUffix(defNS string) bool { return (strings.HasSuffix(defNS, ".exoscale.io.") || strings.HasSuffix(defNS, ".exoscale.com.") ||