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.
253 lines
6.1 KiB
Go
253 lines
6.1 KiB
Go
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
|
|
}
|