Compare commits

...

1 Commits

Author SHA1 Message Date
0a771772e3 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).

Use comma-ok type assertions throughout to prevent panics if the
record type invariant is ever broken. Extract the Vultr error
string to a named constant. Document the mutex contract.
2026-03-08 16:35:06 -06:00

109
client.go
View File

@ -3,6 +3,7 @@ package vultr
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"sync" "sync"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -11,8 +12,15 @@ import (
"github.com/vultr/govultr/v3" "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 { type Client struct {
vultr *govultr.Client 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 mutex sync.Mutex
} }
@ -64,7 +72,39 @@ func (p *Provider) addDNSRecord(ctx context.Context, domain string, r libdns.Rec
rec, _, err := p.client.vultr.DomainRecord.Create(ctx, domain, &domainRecordReq) rec, _, err := p.client.vultr.DomainRecord.Create(ctx, domain, &domainRecordReq)
if err != nil { if err != nil {
return r, err 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) record := fromLibdnsRecord(r, rec.ID)
@ -81,16 +121,40 @@ func (p *Provider) removeDNSRecord(ctx context.Context, domain string, record li
recordId, err := getRecordId(record) recordId, err := getRecordId(record)
if err != nil { if err != nil {
// try to get the ID from API if we don't have it // try to get the ID from API if we don't have it
records, err := p.getDNSEntries(ctx, domain) records, lookupErr := p.getDNSEntries(ctx, domain)
if err != nil { if lookupErr != nil {
return record, fmt.Errorf("could not get record ID from API") return record, fmt.Errorf("could not get record ID from API: %w", lookupErr)
} }
rr := record.RR()
for _, rec := range records { for _, rec := range records {
if rec.RR().Name == record.RR().Name { recRR := rec.RR()
recordId = rec.(VultrRecord).ID // 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) err = p.client.vultr.DomainRecord.Delete(ctx, domain, recordId)
@ -110,16 +174,39 @@ func (p *Provider) updateDNSRecord(ctx context.Context, domain string, record li
recordId, err := getRecordId(record) recordId, err := getRecordId(record)
if err != nil { if err != nil {
// try to get the ID from API if we don't have it // try to get the ID from API if we don't have it
records, err := p.getDNSEntries(ctx, domain) records, lookupErr := p.getDNSEntries(ctx, domain)
if err != nil { if lookupErr != nil {
return record, fmt.Errorf("could not get record ID from API") return record, fmt.Errorf("could not get record ID from API: %w", lookupErr)
} }
rr := record.RR()
for _, rec := range records { for _, rec := range records {
if rec.RR().Data == record.RR().Data { recRR := rec.RR()
recordId = rec.(VultrRecord).ID // 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) domainRecordReq := toDomainRecordReq(record)