dnscontrol/models/domain.go
Tom Limoncelli a0288bd759
bug(split horizon): Domains with split horizons not working (#3895)
Fixes https://github.com/StackExchange/dnscontrol/issues/3894

# Issue

* Split horizon DNS broke in 4.28.0
* Insufficient automated testing to detect this in the future 

# Resolution

* domain.PostProcess() was called twice. The first time the tag was
properly parsed, removing the tag from DomainConfig.Name and putting it
in DomainConfig.Tag. The second time DomainConfig.Name no longer had the
tag and .Tag was set to "".
* The JSON output of DomainConfig doesn't output .UniqueName. If it had,
this bug would have been noticed prior to release. Test updated to
include that field.
2025-12-05 11:41:16 -05:00

246 lines
9.4 KiB
Go

package models
import (
"fmt"
"sync"
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
"github.com/qdm12/reprint"
"golang.org/x/net/idna"
)
const (
DomainTag = "dnscontrol_tag" // A copy of DomainConfig.Tag
DomainUniqueName = "dnscontrol_uniquename" // A copy of DomainConfig.UniqueName
DomainNameRaw = "dnscontrol_nameraw" // A copy of DomainConfig.NameRaw
DomainNameASCII = "dnscontrol_nameascii" // A copy of DomainConfig.NameASCII
DomainNameUnicode = "dnscontrol_nameunicode" // A copy of DomainConfig.NameUnicode
)
// DomainConfig describes a DNS domain (technically a DNS zone).
type DomainConfig struct {
Name string `json:"name"` // NO trailing "." Converted to IDN (punycode) early in the pipeline.
NameRaw string `json:"-"` // name as entered by user in dnsconfig.js
NameUnicode string `json:"-"` // name in Unicode format
Tag string `json:"tag,omitempty"` // Split horizon tag.
UniqueName string `json:"uniquename"` // .Name + "!" + .Tag (no !tag added if tag is "")
RegistrarName string `json:"registrar"`
DNSProviderNames map[string]int `json:"dnsProviders"`
Metadata map[string]string `json:"meta,omitempty"`
Records Records `json:"records"`
Nameservers []*Nameserver `json:"nameservers,omitempty"`
NameserversMutex sync.Mutex `json:"-"`
EnsureAbsent Records `json:"recordsabsent,omitempty"` // ENSURE_ABSENT
KeepUnknown bool `json:"keepunknown,omitempty"` // NO_PURGE
Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` // IGNORE()
UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` // DISABLE_IGNORE_SAFETY_CHECK
IgnoreExternalDNS bool `json:"ignore_external_dns,omitempty"` // IGNORE_EXTERNAL_DNS
ExternalDNSPrefix string `json:"external_dns_prefix,omitempty"` // IGNORE_EXTERNAL_DNS prefix
AutoDNSSEC string `json:"auto_dnssec,omitempty"` // "", "on", "off"
// DNSSEC bool `json:"dnssec,omitempty"`
// These fields contain instantiated provider instances once everything is linked up.
// This linking is in two phases:
// 1. Metadata (name/type) is available just from the dnsconfig. Validation can use that.
// 2. Final driver instances are loaded after we load credentials. Any actual provider interaction requires that.
RegistrarInstance *RegistrarInstance `json:"-"`
DNSProviderInstances []*DNSProviderInstance `json:"-"`
// Raw user-input from dnsconfig.js that will be processed into RecordConfigs later:
RawRecords []RawRecordConfig `json:"rawrecords,omitempty"`
// Pending work to do for each provider. Provider may be a registrar or DSP.
pendingCorrectionsMutex sync.Mutex // Protect pendingCorrections*
pendingCorrections map[string][]*Correction // Work to be done for each provider
pendingCorrectionsOrder []string // Call the providers in this order
pendingActualChangeCount map[string]int // Number of changes to report (cumulative)
pendingPopulateCorrections map[string][]*Correction // Corrections for zone creations at each provider
}
// PostProcess performs and post-processing required after running dnsconfig.js and loading the result.
// It is called by dns.go's PostProcess() function.
func (dc *DomainConfig) PostProcess() {
// Ensure the metadata map is initialized.
if dc.Metadata == nil {
dc.Metadata = map[string]string{}
}
// Turn the user-supplied name into the fixed forms.
ff := domaintags.MakeDomainNameVarieties(dc.Name)
dc.Tag, dc.NameRaw, dc.Name, dc.NameUnicode, dc.UniqueName = ff.Tag, ff.NameRaw, ff.NameASCII, ff.NameUnicode, ff.UniqueName
// Store the FixForms is Metadata so we don't have to change the signature of every function that might need them.
// This is a bit ugly but avoids a huge refactor. Please avoid using these to make the future refactor easier.
if dc.Tag != "" {
dc.Metadata[DomainTag] = dc.Tag
}
//dc.Metadata[DomainNameRaw] = dc.NameRaw
//dc.Metadata[DomainNameASCII] = dc.Name
//dc.Metadata[DomainNameUnicode] = dc.NameUnicode
dc.Metadata[DomainUniqueName] = dc.UniqueName
}
// GetSplitHorizonNames returns the domain's name, uniquename, and tag.
// Deprecated: use .Name, .Uniquename, and .Tag directly instead.
func (dc *DomainConfig) GetSplitHorizonNames() (name, uniquename, tag string) {
return dc.Name, dc.UniqueName, dc.Tag
}
// GetUniqueName returns the domain's uniquename.
// Deprecated: dc.UniqueName directly instead.
func (dc *DomainConfig) GetUniqueName() (uniquename string) {
return dc.UniqueName
}
// Copy returns a deep copy of the DomainConfig.
func (dc *DomainConfig) Copy() (*DomainConfig, error) {
newDc := &DomainConfig{}
err := reprint.FromTo(dc, newDc) // Deep copy
return newDc, err
}
// Filter removes all records that don't match the filter f.
func (dc *DomainConfig) Filter(f func(r *RecordConfig) bool) {
recs := []*RecordConfig{}
for _, r := range dc.Records {
if f(r) {
recs = append(recs, r)
}
}
dc.Records = recs
}
// Punycode will convert all records to punycode format.
// It will encode:
// - Name
// - NameFQDN
// - Target (CNAME and MX only)
func (dc *DomainConfig) Punycode() error {
for _, rec := range dc.Records {
if rec.IsModernType() {
continue // Modern types handle punycode themselves.
}
// Update the label:
t, err := idna.ToASCII(rec.GetLabelFQDN())
if err != nil {
return err
}
rec.SetLabelFromFQDN(t, dc.Name)
// Set the target:
switch rec.Type { // #rtype_variations
case "ALIAS", "MX", "NS", "CNAME", "DNAME", "PTR", "SRV", "URL", "URL301", "FRAME", "R53_ALIAS", "AKAMAICDN", "AKAMAITLC", "CLOUDNS_WR", "PORKBUN_URLFWD", "BUNNY_DNS_RDR":
// These rtypes are hostnames, therefore need to be converted (unlike, for example, an AAAA record)
t, err := idna.ToASCII(rec.GetTargetField())
if err != nil {
return err
}
if err := rec.SetTarget(t); err != nil {
return err
}
case "CLOUDFLAREAPI_SINGLE_REDIRECT", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE", "ADGUARDHOME_A_PASSTHROUGH", "ADGUARDHOME_AAAA_PASSTHROUGH":
if err := rec.SetTarget(rec.GetTargetField()); err != nil {
return err
}
case "A", "AAAA", "CAA", "DHCID", "DNSKEY", "DS", "HTTPS", "LOC", "LUA", "NAPTR", "OPENPGPKEY", "SMIMEA", "SOA", "SSHFP", "SVCB", "TXT", "TLSA", "AZURE_ALIAS":
// Nothing to do.
default:
return fmt.Errorf("Punycode rtype %v unimplemented", rec.Type)
}
}
return nil
}
// StoreCorrections accumulates corrections in a thread-safe way.
func (dc *DomainConfig) StoreCorrections(providerName string, corrections []*Correction) {
dc.pendingCorrectionsMutex.Lock()
defer dc.pendingCorrectionsMutex.Unlock()
if dc.pendingCorrections == nil {
// First time storing anything.
dc.pendingCorrections = make(map[string]([]*Correction))
dc.pendingCorrections[providerName] = corrections
dc.pendingCorrectionsOrder = []string{providerName}
} else if c, ok := dc.pendingCorrections[providerName]; !ok {
// First time key used
dc.pendingCorrections[providerName] = corrections
dc.pendingCorrectionsOrder = []string{providerName}
} else {
// Add to existing.
dc.pendingCorrections[providerName] = append(c, corrections...)
dc.pendingCorrectionsOrder = append(dc.pendingCorrectionsOrder, providerName)
}
}
// GetCorrections returns the accumulated corrections for providerName.
func (dc *DomainConfig) GetCorrections(providerName string) []*Correction {
dc.pendingCorrectionsMutex.Lock()
defer dc.pendingCorrectionsMutex.Unlock()
if dc.pendingCorrections == nil {
// First time storing anything.
return nil
}
if c, ok := dc.pendingCorrections[providerName]; ok {
return c
}
return nil
}
// IncrementChangeCount accumulates change count in a thread-safe way.
func (dc *DomainConfig) IncrementChangeCount(providerName string, delta int) {
dc.pendingCorrectionsMutex.Lock()
defer dc.pendingCorrectionsMutex.Unlock()
if dc.pendingActualChangeCount == nil {
// First time storing anything.
dc.pendingActualChangeCount = make(map[string](int))
}
dc.pendingActualChangeCount[providerName] += delta
}
// GetChangeCount accumulates change count in a thread-safe way.
func (dc *DomainConfig) GetChangeCount(providerName string) int {
dc.pendingCorrectionsMutex.Lock()
defer dc.pendingCorrectionsMutex.Unlock()
return dc.pendingActualChangeCount[providerName]
}
// StorePopulateCorrections accumulates corrections in a thread-safe way.
func (dc *DomainConfig) StorePopulateCorrections(providerName string, corrections []*Correction) {
dc.pendingCorrectionsMutex.Lock()
defer dc.pendingCorrectionsMutex.Unlock()
if dc.pendingPopulateCorrections == nil {
dc.pendingPopulateCorrections = make(map[string][]*Correction, 1)
}
dc.pendingPopulateCorrections[providerName] = append(dc.pendingPopulateCorrections[providerName], corrections...)
}
// GetPopulateCorrections returns zone corrections in a thread-safe way.
func (dc *DomainConfig) GetPopulateCorrections(providerName string) []*Correction {
dc.pendingCorrectionsMutex.Lock()
defer dc.pendingCorrectionsMutex.Unlock()
return dc.pendingPopulateCorrections[providerName]
}
// DomainNameVarieties returns the domain's names in various forms.
func (dc *DomainConfig) DomainNameVarieties() *domaintags.DomainNameVarieties {
return &domaintags.DomainNameVarieties{
NameRaw: dc.NameRaw,
NameASCII: dc.Name,
NameUnicode: dc.NameUnicode,
UniqueName: dc.UniqueName,
Tag: dc.Tag,
HasBang: dc.Tag != "",
}
}