dnscontrol/providers/ns1/records.go
2025-07-10 10:51:43 -04:00

220 lines
7.1 KiB
Go

package ns1
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
"gopkg.in/ns1/ns1-go.v2/rest"
"gopkg.in/ns1/ns1-go.v2/rest/model/dns"
"gopkg.in/ns1/ns1-go.v2/rest/model/filter"
)
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (n *nsone) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
z, _, err := n.Zones.Get(domain, true)
if err != nil && errors.Is(err, rest.ErrZoneMissing) {
// if we get here, zone wasn't created, but we ended up continuing regardless.
// This should be revisited, but for now let's get out early with a relevant message
// one case: preview --no-populate
printer.Warnf("GetZonerecords: Zone %s not created in NS1. Either create manually or ensure dnscontrol can create it.\n", domain)
return nil, err
}
if err != nil {
return nil, err
}
found := models.Records{}
for _, r := range z.Records {
zrs, err := convert(r, domain)
if err != nil {
return nil, err
}
found = append(found, zrs...)
}
return found, nil
}
// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
func (n *nsone) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) {
var corrections []*models.Correction
domain := dc.Name
// add DNSSEC-related corrections
if dnssecCorrections := n.getDomainCorrectionsDNSSEC(domain, dc.AutoDNSSEC); dnssecCorrections != nil {
corrections = append(corrections, dnssecCorrections)
}
changes, actualChangeCount, err := diff2.ByRecordSet(existingRecords, dc, nil)
if err != nil {
return nil, 0, err
}
for _, change := range changes {
key := change.Key
recs := change.New
desc := strings.Join(change.Msgs, "\n")
switch change.Type {
case diff2.REPORT:
corrections = append(corrections, &models.Correction{Msg: change.MsgsJoined})
case diff2.CREATE:
corrections = append(corrections, &models.Correction{
Msg: desc,
F: func() error { return n.add(recs, dc.Name) },
})
case diff2.CHANGE:
corrections = append(corrections, &models.Correction{
Msg: desc,
F: func() error { return n.modify(recs, dc.Name) },
})
case diff2.DELETE:
corrections = append(corrections, &models.Correction{
Msg: desc,
F: func() error { return n.remove(key, dc.Name) },
})
default:
panic(fmt.Sprintf("unhandled inst.Type %s", change.Type))
}
}
return corrections, actualChangeCount, nil
}
func (n *nsone) add(recs models.Records, domain string) error {
for rtr := 0; ; rtr++ {
httpResp, err := n.Records.Create(buildRecord(recs, domain, ""))
if httpResp.StatusCode == http.StatusTooManyRequests && rtr < clientRetries {
continue
}
return err
}
}
func (n *nsone) remove(key models.RecordKey, domain string) error {
for rtr := 0; ; rtr++ {
httpResp, err := n.Records.Delete(domain, key.NameFQDN, key.Type)
if httpResp.StatusCode == http.StatusTooManyRequests && rtr < clientRetries {
continue
}
return err
}
}
func (n *nsone) modify(recs models.Records, domain string) error {
for rtr := 0; ; rtr++ {
httpResp, err := n.Records.Update(buildRecord(recs, domain, ""))
if httpResp.StatusCode == http.StatusTooManyRequests && rtr < clientRetries {
continue
}
return err
}
}
func buildRecord(recs models.Records, domain string, id string) *dns.Record {
r := recs[0]
rec := &dns.Record{
Domain: r.GetLabelFQDN(),
Type: r.Type,
ID: id,
TTL: int(r.TTL),
Zone: domain,
Filters: []*filter.Filter{}, // Work through a bug in the NS1 API library that causes 400 Input validation failed (Value None for field '<obj>.filters' is not of type array)
}
for _, r := range recs {
if r.Type == "MX" {
rec.AddAnswer(&dns.Answer{Rdata: strings.Fields(fmt.Sprintf("%d %v", r.MxPreference, r.GetTargetField()))})
} else if r.Type == "TXT" {
rec.AddAnswer(&dns.Answer{Rdata: []string{r.GetTargetTXTJoined()}})
} else if r.Type == "CAA" {
rec.AddAnswer(&dns.Answer{
Rdata: []string{
strconv.FormatUint(uint64(r.CaaFlag), 10),
r.CaaTag,
r.GetTargetField(),
},
})
} else if r.Type == "SRV" {
rec.AddAnswer(&dns.Answer{Rdata: strings.Fields(fmt.Sprintf("%d %d %d %v", r.SrvPriority, r.SrvWeight, r.SrvPort, r.GetTargetField()))})
} else if r.Type == "NAPTR" {
rec.AddAnswer(&dns.Answer{Rdata: []string{
strconv.Itoa(int(r.NaptrOrder)),
strconv.Itoa(int(r.NaptrPreference)),
r.NaptrFlags,
r.NaptrService,
r.NaptrRegexp,
r.GetTargetField(),
}})
} else if r.Type == "DS" {
rec.AddAnswer(&dns.Answer{Rdata: []string{
strconv.Itoa(int(r.DsKeyTag)),
strconv.Itoa(int(r.DsAlgorithm)),
strconv.Itoa(int(r.DsDigestType)),
r.DsDigest,
}})
} else if r.Type == "SVCB" || r.Type == "HTTPS" {
rec.AddAnswer(&dns.Answer{Rdata: []string{
strconv.Itoa(int(r.SvcPriority)),
r.GetTargetField(),
r.SvcParams,
}})
} else if r.Type == "TLSA" {
rec.AddAnswer(&dns.Answer{Rdata: []string{
strconv.Itoa(int(r.TlsaUsage)),
strconv.Itoa(int(r.TlsaSelector)),
strconv.Itoa(int(r.TlsaMatchingType)),
r.GetTargetField(),
}})
} else {
rec.AddAnswer(&dns.Answer{Rdata: strings.Fields(r.GetTargetField())})
}
}
return rec
}
func convert(zr *dns.ZoneRecord, domain string) ([]*models.RecordConfig, error) {
found := []*models.RecordConfig{}
for _, ans := range zr.ShortAns {
rec := &models.RecordConfig{
TTL: uint32(zr.TTL),
Original: zr,
}
rec.SetLabelFromFQDN(zr.Domain, domain)
switch rtype := zr.Type; rtype {
case "DNSKEY", "RRSIG":
// if a zone is enabled for DNSSEC, NS1 autoconfigures DNSKEY & RRSIG records.
// these entries are not modifiable via the API though, so we have to ignore them while converting.
// ie. API returns "405 Operation on DNSSEC record is not allowed" on such operations
continue
case "ALIAS":
rec.Type = rtype
if err := rec.SetTarget(ans); err != nil {
return nil, fmt.Errorf("unparsable %s record received from ns1: %w", rtype, err)
}
case "CAA":
// dnscontrol expects quotes around multivalue CAA entries, API doesn't add them
xAns := strings.SplitN(ans, " ", 3)
if err := rec.SetTargetCAAStrings(xAns[0], xAns[1], xAns[2]); err != nil {
return nil, fmt.Errorf("unparsable %s record received from ns1: %w", rtype, err)
}
case "REDIRECT":
// NS1 returns REDIRECTs as records, but there is only one and dummy answer:
// "NS1 MANAGED RECORD"
// Redirects are managed via a different API endpoint https://api.nsone.net/v1/redirect
// It also involves cert management
// We may simpply ignore REDIRECTs for now until we support it
printer.Warnf("NS1_REDIRECT is NOT supported by dnscontrol and all existing redirects are ignored.\n")
continue
default:
if err := rec.PopulateFromString(rtype, ans, domain); err != nil {
return nil, fmt.Errorf("unparsable record received from ns1: %w", err)
}
}
found = append(found, rec)
}
return found, nil
}