package vultr import ( "context" "fmt" "strings" "sync" "golang.org/x/oauth2" "github.com/libdns/libdns" "github.com/vultr/govultr/v3" ) // vultrDuplicateErr is matched against Vultr API response text. // May need updating if Vultr changes their error message wording. const vultrDuplicateErr = "Duplicate records" type Client struct { vultr *govultr.Client // mutex serializes write operations (add/remove/update). // getDNSEntries does NOT acquire this lock because it is called // from within write methods that already hold it. mutex sync.Mutex } func (p *Provider) getClient() error { if p.client.vultr == nil { oauth_cfg := &oauth2.Config{} oauth_token_source := oauth_cfg.TokenSource(context.TODO(), &oauth2.Token{AccessToken: p.APIToken}) p.client.vultr = govultr.NewClient(oauth2.NewClient(context.TODO(), oauth_token_source)) } return nil } func (p *Provider) getDNSEntries(ctx context.Context, domain string) ([]libdns.Record, error) { p.getClient() listOptions := &govultr.ListOptions{} var records []libdns.Record for { dns_entries, meta, _, err := p.client.vultr.DomainRecord.List(ctx, domain, listOptions) if err != nil { return records, err } for _, entry := range dns_entries { record := fromAPIRecord(entry, domain) records = append(records, record) } if meta.Links.Next == "" { break } listOptions.Cursor = meta.Links.Next } return records, nil } func (p *Provider) addDNSRecord(ctx context.Context, domain string, r libdns.Record) (libdns.Record, error) { p.client.mutex.Lock() defer p.client.mutex.Unlock() p.getClient() domainRecordReq := toDomainRecordReq(r) rec, _, err := p.client.vultr.DomainRecord.Create(ctx, domain, &domainRecordReq) if err != nil { if !strings.Contains(err.Error(), vultrDuplicateErr) { return r, err } // Duplicate record exists — find it and update with the new data. // This handles stale ACME challenge TXT records left from previous runs. records, lookupErr := p.getDNSEntries(ctx, domain) if lookupErr != nil { return r, fmt.Errorf("duplicate record and could not look up existing: %w", lookupErr) } rr := r.RR() var existingID string for _, existing := range records { exRR := existing.RR() if exRR.Name == rr.Name && exRR.Type == rr.Type { if vr, ok := existing.(VultrRecord); ok { existingID = vr.ID break } } } if existingID == "" { return r, fmt.Errorf("duplicate record reported but not found in zone: %w", err) } updateErr := p.client.vultr.DomainRecord.Update(ctx, domain, existingID, &domainRecordReq) if updateErr != nil { return r, fmt.Errorf("failed to update duplicate record %s: %w", existingID, updateErr) } return fromLibdnsRecord(r, existingID), nil } record := fromLibdnsRecord(r, rec.ID) return record, nil } func (p *Provider) removeDNSRecord(ctx context.Context, domain string, record libdns.Record) (libdns.Record, error) { p.client.mutex.Lock() defer p.client.mutex.Unlock() p.getClient() recordId, err := getRecordId(record) if err != nil { // try to get the ID from API if we don't have it records, lookupErr := p.getDNSEntries(ctx, domain) if lookupErr != nil { return record, fmt.Errorf("could not get record ID from API: %w", lookupErr) } rr := record.RR() for _, rec := range records { recRR := rec.RR() // Match by name, type, AND data to avoid deleting the wrong record // when multiple records share the same name (e.g. _acme-challenge TXT). if recRR.Name == rr.Name && recRR.Type == rr.Type && recRR.Data == rr.Data { if vr, ok := rec.(VultrRecord); ok { recordId = vr.ID break } } } // Fall back to name+type match if exact data match wasn't found if recordId == "" { for _, rec := range records { recRR := rec.RR() if recRR.Name == rr.Name && recRR.Type == rr.Type { if vr, ok := rec.(VultrRecord); ok { recordId = vr.ID break } } } } if recordId == "" { return record, fmt.Errorf("no matching record found for %s %s in zone %s", rr.Type, rr.Name, domain) } } err = p.client.vultr.DomainRecord.Delete(ctx, domain, recordId) if err != nil { return record, err } return record, nil } func (p *Provider) updateDNSRecord(ctx context.Context, domain string, record libdns.Record) (libdns.Record, error) { p.client.mutex.Lock() defer p.client.mutex.Unlock() p.getClient() recordId, err := getRecordId(record) if err != nil { // try to get the ID from API if we don't have it records, lookupErr := p.getDNSEntries(ctx, domain) if lookupErr != nil { return record, fmt.Errorf("could not get record ID from API: %w", lookupErr) } rr := record.RR() for _, rec := range records { recRR := rec.RR() // Match by name+type+data first for precision if recRR.Name == rr.Name && recRR.Type == rr.Type && recRR.Data == rr.Data { if vr, ok := rec.(VultrRecord); ok { recordId = vr.ID break } } } // Fall back to name+type if exact data match not found if recordId == "" { for _, rec := range records { recRR := rec.RR() if recRR.Name == rr.Name && recRR.Type == rr.Type { if vr, ok := rec.(VultrRecord); ok { recordId = vr.ID break } } } } if recordId == "" { return record, fmt.Errorf("no matching record found for %s %s in zone %s", rr.Type, rr.Name, domain) } } domainRecordReq := toDomainRecordReq(record) err = p.client.vultr.DomainRecord.Update(ctx, domain, recordId, &domainRecordReq) if err != nil { return record, err } return record, nil } func (p *Provider) getDNSZones(ctx context.Context) ([]libdns.Zone, error) { p.client.mutex.Lock() defer p.client.mutex.Unlock() p.getClient() listOptions := &govultr.ListOptions{} var zones []libdns.Zone for { dns_zones, meta, _, err := p.client.vultr.Domain.List(ctx, listOptions) if err != nil { return zones, err } for _, entry := range dns_zones { zone := libdns.Zone{ Name: entry.Domain, } zones = append(zones, zone) } if meta.Links.Next == "" { break } listOptions.Cursor = meta.Links.Next } return zones, nil }