libdns-vultr/client.go
Ryan Malloy a62ba66e05 Handle duplicate DNS records with upsert semantics
When Vultr's API returns "Duplicate records are not allowed" on
DomainRecord.Create, find the existing record by name+type and update
it instead of failing. This prevents ACME certificate issuance from
breaking when stale _acme-challenge TXT records survive a Caddy
restart.

Also fix record lookup in removeDNSRecord and updateDNSRecord to
match by name+type+data instead of name-only or data-only, avoiding
deletion of the wrong record when multiple records share the same
name (common with _acme-challenge across LE and ZeroSSL issuers).
2026-03-08 16:14:38 -06:00

236 lines
5.5 KiB
Go

package vultr
import (
"context"
"fmt"
"strings"
"sync"
"golang.org/x/oauth2"
"github.com/libdns/libdns"
"github.com/vultr/govultr/v3"
)
type Client struct {
vultr *govultr.Client
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(), "Duplicate records") {
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", err)
}
rr := r.RR()
var existingID string
for _, existing := range records {
exRR := existing.RR()
if exRR.Name == rr.Name && exRR.Type == rr.Type {
existingID = existing.(VultrRecord).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 {
recordId = rec.(VultrRecord).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 {
recordId = rec.(VultrRecord).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 {
recordId = rec.(VultrRecord).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 {
recordId = rec.(VultrRecord).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
}