1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

Bugfixed: NO_PURGE now works on all diff2 providers (#2084)

This commit is contained in:
Tom Limoncelli
2023-02-19 12:33:08 -05:00
committed by GitHub
parent c012164cd4
commit fc3a217dc1
26 changed files with 768 additions and 460 deletions

View File

@ -750,6 +750,7 @@ func makeTests(t *testing.T) []*TestGroup {
// This is a strange one. It adds a new record to an existing
// label but the pre-existing label has its TTL change.
testgroup("add to label and change orig ttl",
not("NAMEDOTCOM"), // Known bug: https://github.com/StackExchange/dnscontrol/issues/2088
tc("Setup", ttl(a("www", "5.6.7.8"), 400)),
tc("Add at same label, new ttl", ttl(a("www", "5.6.7.8"), 700), ttl(a("www", "1.2.3.4"), 700)),
),

View File

@ -19,11 +19,13 @@ type DomainConfig struct {
Records Records `json:"records"`
Nameservers []*Nameserver `json:"nameservers,omitempty"`
KeepUnknown bool `json:"keepunknown,omitempty"`
EnsureAbsent Records `json:"recordsabsent,omitempty"` // ENSURE_ABSENT
KeepUnknown bool `json:"keepunknown,omitempty"` // NO_PURGE
IgnoredNames []*IgnoreName `json:"ignored_names,omitempty"`
IgnoredTargets []*IgnoreTarget `json:"ignored_targets,omitempty"`
Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"`
UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"`
Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` // UNMANAGED()
UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` // DISABLE_UNMANAGED_SAFETY_CHECK
AutoDNSSEC string `json:"auto_dnssec,omitempty"` // "", "on", "off"
//DNSSEC bool `json:"dnssec,omitempty"`
@ -36,14 +38,6 @@ type DomainConfig struct {
DNSProviderInstances []*DNSProviderInstance `json:"-"`
}
// UnmanagedConfig describes an UNMANAGED() rule.
type UnmanagedConfig struct {
Label string `json:"label_pattern"` // Glob pattern for matching labels.
RType string `json:"rType_pattern"` // Comma-separated list of DNS Resource Types.
typeMap map[string]bool // map of RTypes or len()=0 for all
Target string `json:"target_pattern"` // Glob pattern for matching targets.
}
// Copy returns a deep copy of the DomainConfig.
func (dc *DomainConfig) Copy() (*DomainConfig, error) {
newDc := &DomainConfig{}

View File

@ -184,6 +184,9 @@ func (rc *RecordConfig) UnmarshalJSON(b []byte) error {
TxtStrings []string `json:"txtstrings,omitempty"` // TxtStrings stores all strings (including the first). Target stores only the first one.
R53Alias map[string]string `json:"r53_alias,omitempty"`
AzureAlias map[string]string `json:"azure_alias,omitempty"`
EnsureAbsent bool `json:"ensure_absent,omitempty"` // Override NO_PURGE and delete this record
// NB(tlim): If anyone can figure out how to do this without listing all
// the fields, please let us know!
}{}

30
models/recorddb.go Normal file
View File

@ -0,0 +1,30 @@
package models
// Functions that make it easier to deal with a group of records.
type RecordDB struct {
labelAndTypeMap map[RecordKey]struct{}
}
// NewRecordDBFromRecords creates a RecordDB from a list of RecordConfig.
func NewRecordDBFromRecords(recs Records, zone string) *RecordDB {
result := &RecordDB{}
//fmt.Printf("DEBUG: BUILDING RecordDB: zone=%v\n", zone)
result.labelAndTypeMap = make(map[RecordKey]struct{}, len(recs))
for _, rec := range recs {
//fmt.Printf(" DEBUG: Adding %+v\n", rec.Key())
result.labelAndTypeMap[rec.Key()] = struct{}{}
}
//fmt.Printf("DEBUG: BUILDING RecordDB: DONE!\n")
return result
}
// ContainsLT returns true if recdb contains rec. Matching is done
// on the record's label and type (i.e. the RecordKey)
func (recdb *RecordDB) ContainsLT(rec *RecordConfig) bool {
_, ok := recdb.labelAndTypeMap[rec.Key()]
//fmt.Printf("DEBUG: ContainsLT(%q) = %v (%v)\n", rec.Key(), ok, recdb)
return ok
}

20
models/unmanaged.go Normal file
View File

@ -0,0 +1,20 @@
package models
import (
"github.com/gobwas/glob"
)
// UnmanagedConfig describes an UNMANAGED() rule.
type UnmanagedConfig struct {
// Glob pattern for matching labels.
LabelPattern string `json:"label_pattern,omitempty"`
LabelGlob glob.Glob `json:"-"` // Compiled version
// Comma-separated list of DNS Resource Types.
RTypePattern string `json:"rType_pattern,omitempty"`
RTypeMap map[string]struct{} `json:"-"` // map of RTypes or len()=0 for all
// Glob pattern for matching targets.
TargetPattern string `json:"target_pattern,omitempty"`
TargetGlob glob.Glob `json:"-"` // Compiled version
}

View File

@ -65,8 +65,9 @@ func (d *differCompat) IncrementalDiff(existing []*models.RecordConfig) (unchang
cor := Correlation{d: d.OldDiffer}
switch inst.Type {
case diff2.REPORT:
// Sadly the NewCompat function doesn't have a way to do this.
// Purge reports are silently skipped.
// Sadly the NewCompat function doesn't have an equivalent. We
// just output the messages now.
fmt.Println(inst.MsgsJoined)
case diff2.CREATE:
cor.Desired = inst.New[0]
create = append(create, cor)

View File

@ -109,10 +109,8 @@ func NewCompareConfig(origin string, existing, desired models.Records, compFn Co
func (cc *CompareConfig) VerifyCNAMEAssertions() {
// In theory these assertions do not need to be tested as they test
// something that can not happen. In my head I've proved this to be
// true. That said, a little paranoia is healthy. Those familiar
// with the Therac-25 accident will agree:
// https://hackaday.com/2015/10/26/killed-by-a-machine-the-therac-25/
// something that can not happen. In my I've proved this to be
// true. That said, a little paranoia is healthy.
// According to the RFCs if a label has a CNAME, it can not have any other
// records at that label... even other CNAMEs. Therefore, we need to be
@ -141,15 +139,28 @@ func (cc *CompareConfig) VerifyCNAMEAssertions() {
for _, ld := range cc.ldata {
for j, td := range ld.tdata {
if td.rType == "CNAME" {
// This assertion doesn't hold for a site that permits a
// recordset with both CNAMEs and other records, such as
// Cloudflare.
// Therefore, we skip the test if we aren't deleting
// everything at the recordset or creating it from scratch.
if len(td.existingTargets) != 0 && len(td.desiredTargets) != 0 {
continue
}
if len(td.existingTargets) != 0 {
//fmt.Printf("DEBUG: cname in existing: index=%d\n", j)
if j != 0 {
panic("should not happen: (CNAME not in first position)")
}
}
if len(td.desiredTargets) != 0 {
//fmt.Printf("DEBUG: cname in desired: index=%d\n", j)
//fmt.Printf("DEBUG: highest: index=%d\n", j)
if j != highest(ld.tdata) {
panic("should not happen: (CNAME not in last position)")
}
@ -215,8 +226,6 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
for _, rec := range z.Records {
//label := rec.NameFQDN
//rtype := rec.Type
key := rec.Key()
label := key.NameFQDN
rtype := key.Type
@ -225,12 +234,10 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
// Are we seeing this label for the first time?
var labelIdx int
if _, ok := cc.labelMap[label]; !ok {
//fmt.Printf("DEBUG: I haven't see label=%v before. Adding.\n", label)
cc.labelMap[label] = true
cc.ldata = append(cc.ldata, &labelConfig{label: label})
labelIdx = highest(cc.ldata)
} else {
// find label in cc.ldata:
for k, v := range cc.ldata {
if v.label == label {
labelIdx = k
@ -240,12 +247,9 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
}
// Are we seeing this label+rtype for the first time?
//key := rec.Key()
if _, ok := cc.keyMap[key]; !ok {
//fmt.Printf("DEBUG: I haven't see key=%v before. Adding.\n", key)
cc.keyMap[key] = true
x := cc.ldata[labelIdx]
//fmt.Printf("DEBUG: appending rtype=%v\n", rtype)
x.tdata = append(x.tdata, &rTypeConfig{rType: rtype})
}
var rtIdx int
@ -256,7 +260,6 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
break
}
}
//fmt.Printf("DEBUG: found rtype=%v at index %d\n", rtype, rtIdx)
// Now it is safe to add/modify the records.

View File

@ -1,7 +1,6 @@
package diff2
import (
"encoding/json"
"strings"
"testing"
@ -9,11 +8,6 @@ import (
"github.com/kylelemons/godebug/diff"
)
func prettyPrint(i interface{}) string {
s, _ := json.MarshalIndent(i, "", "\t")
return string(s)
}
func TestNewCompareConfig(t *testing.T) {
type args struct {
origin string

View File

@ -8,6 +8,7 @@ package diff2
import (
"bytes"
"fmt"
"strings"
"github.com/StackExchange/dnscontrol/v3/models"
)
@ -96,18 +97,7 @@ General instructions:
//
// Examples include:
func ByRecordSet(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// dc stores the desired state.
desired := dc.Records
var err error
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED()
if err != nil {
return nil, err
}
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByRecordSet(cc)
return processPurge(instructions, !dc.KeepUnknown), nil
return byHelper(analyzeByRecordSet, existing, dc, compFunc)
}
// ByLabel takes two lists of records (existing and desired) and
@ -119,18 +109,7 @@ func ByRecordSet(existing models.Records, dc *models.DomainConfig, compFunc Comp
//
// Examples include:
func ByLabel(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// dc stores the desired state.
desired := dc.Records
var err error
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED()
if err != nil {
return nil, err
}
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByLabel(cc)
return processPurge(instructions, !dc.KeepUnknown), nil
return byHelper(analyzeByLabel, existing, dc, compFunc)
}
// ByRecord takes two lists of records (existing and desired) and
@ -146,61 +125,82 @@ func ByLabel(existing models.Records, dc *models.DomainConfig, compFunc Comparab
//
// Examples include: INWX
func ByRecord(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// dc stores the desired state.
desired := dc.Records
var err error
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED()
if err != nil {
return nil, err
}
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByRecord(cc)
return processPurge(instructions, !dc.KeepUnknown), nil
return byHelper(analyzeByRecord, existing, dc, compFunc)
}
// ByZone takes two lists of records (existing and desired) and
// returns text one would output to users describing the change.
// returns text to output to users describing the change, a bool
// indicating if there were any changes, and a possible err value.
//
// Use this with DNS providers whose API updates the entire zone at a
// time. That is, to make any change (1 record or many) the entire DNS
// time. That is, to make any change (even just 1 record) the entire DNS
// zone is uploaded.
//
// The user should see a list of changes as if individual records were
// updated.
// The user should see a list of changes as if individual records were updated.
//
// The caller of this function should:
// Example usage:
//
// changed, msgs := diff2.ByZone(existing, desired, origin, nil
// fmt.Sprintf("CREATING ZONEFILE FOR THE FIRST TIME: dir/example.com.zone"))
// if changed {
// // output msgs
// // generate the zone using the "desired" records
// }
// msgs, changes, err := diff2.ByZone(foundRecords, dc, nil)
// if err != nil {
// return nil, err
// }
// if changes {
// // Generate a "correction" that uploads the entire zone.
// // (dc.Records are the new records for the zone).
// }
//
// Example providers include: BIND
func ByZone(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) ([]string, bool, error) {
// dc stores the desired state.
if len(existing) == 0 {
// Nothing previously existed. No need to output a list of individual changes.
return nil, true, nil
}
desired := dc.Records
var err error
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED()
// Only return the messages.
instructions, err := byHelper(analyzeByRecord, existing, dc, compFunc)
return justMsgs(instructions), len(instructions) != 0, err
}
//
// byHelper does 90% of the work for the By*() calls.
func byHelper(fn func(cc *CompareConfig) ChangeList, existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// Process NO_PURGE/ENSURE_ABSENT and UNMANAGED/IGNORE_*.
desired, msgs, err := handsoff(
dc.Name,
existing, dc.Records, dc.EnsureAbsent,
dc.Unmanaged,
dc.UnmanagedUnsafe,
dc.KeepUnknown,
)
if err != nil {
return nil, false, err
return nil, err
}
// Regroup existing/desiredd for easy comparison:
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByRecord(cc)
instructions = processPurge(instructions, !dc.KeepUnknown)
return justMsgs(instructions), len(instructions) != 0, nil
// Analyze and generate the instructions:
instructions := fn(cc)
// If we have msgs, create a change to output them:
if len(msgs) != 0 {
chg := Change{
Type: REPORT,
Msgs: msgs,
MsgsJoined: strings.Join(msgs, "\n"),
}
_ = chg
instructions = append([]Change{chg}, instructions...)
}
return instructions, nil
}
// Stringify the datastructures for easier debugging
func (c Change) String() string {
var buf bytes.Buffer
b := &buf

View File

@ -18,7 +18,7 @@ func groupbyRSet(recs models.Records, origin string) []recset {
// Sort the NameFQDN to a consistent order. The actual sort methodology
// doesn't matter as long as equal values are adjacent.
// Use the PrettySort ordering so that the records are in a nice order.
// Use the PrettySort ordering so that the records are extra pretty.
pretty := prettyzone.PrettySort(recs, origin, 0, nil)
recs = pretty.Records

251
pkg/diff2/handsoff.go Normal file
View File

@ -0,0 +1,251 @@
package diff2
// This file implements the features that tell DNSControl "hands off"
// foreign-controlled (or shared-control) DNS records. i.e. the
// NO_PURGE, ENSURE_ABSENT, IGNORE_*, and UNMANAGED features.
import (
"fmt"
"strings"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/gobwas/glob"
)
/*
# How do NO_PURGE, IGNORE_*, ENSURE_ABSENT and friends work?
## Terminology:
* "existing" refers to the records downloaded from the provider via the API.
* "desired" refers to the records generated from dnsconfig.js.
* "absences" refers to a list of records tagged with ASSURE_ABSENT.
## What are the features?
There are 2 ways to tell DNSControl not to touch existing records in a domain,
and 1 way to make exceptions.
* NO_PURGE: Tells DNSControl not to delete records in a domain.
* New records will be created
* Existing records (matched on label:rtype) will be modified.
* FYI: This means you can't have a label with two A records, one controlled
by DNSControl and one controlled by an external system.
* UNMANAGED(labelglob, typelist, targetglob):
* "If an existing record matches this pattern, don't touch it!""
* IGNORE_NAME(foo, bar) is the same as UNMANAGED(foo, bar, "*")
* IGNORE_TARGET(foo) is the same as UNMANAGED("*", "*", foo)
* FYI: You CAN have a label with two A records, one controlled by
DNSControl and one controlled by an external system. DNSControl would
need to have an UNMANAGED() statement with a targetglob that matches
the external system's target values.
* ASSURE_ABSENT: Override NO_PURGE for specific records. i.e. delete them even
though NO_PURGE is enabled.
* If any of these records are in desired (matched on
label:rtype:target), remove them. This takes priority over
NO_PURGE/UNMANAGED/IGNORE*.
## Implementation premise
The fundamental premise is "if you don't want it deleted, copy it to the
'desired' list." So, for example, if you want to IGNORE_NAME("www"), then you
find any records with the label "www" in "existing" and copy them to "desired".
As a result, the diff2 algorithm won't delete them because they are desired!
This is different than in the old system (pkg/diff) which would generate the
diff but but then do a bunch of checking to see if the record was one that
shouldn't be deleted. Or, in the case of NO_PURGE, would simply not do the
deletions. This was complex because there were many edge cases to deal with.
It was often also wrong. For example, if a provider updates all records in a
RecordSet at once, you shouldn't NOT update the record.
## Implementation
Here is how we intend to implement these features:
UNMANAGED is implemented as:
* Take the list of existing records. If any match one of the UNMANAGED glob
patterns, add it to the "ignored list".
* If any item on the "ignored list" is also in "desired" (match on
label:rtype), output a warning (defeault) or declare an error (if
DISABLE_UNMANAGED_SAFETY_CHECK is true).
* When we're done, add the "ignore list" records to desired.
NO_PURGE + ENSURE_ABSENT is implemented as:
* Take the list of existing records. If any do not appear in desired, add them
to desired UNLESS they appear in absences.
* "appear in desired" is done by matching on label:type.
* "appear in absences" is done by matching on label:type:target.
The actual implementation combines this all into one loop:
foreach rec in existing:
if rec matches_any_unmanaged_pattern:
if rec in desired:
if "DISABLE_UNMANAGED_SAFETY_CHECK" is false:
Display a warning.
else
Return an error.
Add rec to "ignored list"
else:
if NO_PURGE:
if rec NOT in desired: (matched on label:type)
if rec NOT in absences: (matched on label:type:target)
Add rec to "foreign list"
Append "ignored list" to "desired".
Append "foreign list" to "desired".
*/
// handsoff processes the IGNORE_*/UNMANAGED/NO_PURGE/ENSURE_ABSENT features.
func handsoff(
domain string,
existing, desired, absences models.Records,
unmanagedConfigs []*models.UnmanagedConfig,
unmanagedSafely bool,
noPurge bool,
) (models.Records, []string, error) {
var msgs []string
// Prep the globs:
err := compileUnmanagedConfigs(unmanagedConfigs)
if err != nil {
return nil, nil, err
}
// Process UNMANAGE/IGNORE_* and NO_PURGE features:
ignorable, foreign := processIgnoreAndNoPurge(domain, existing, desired, absences, unmanagedConfigs, noPurge)
if len(foreign) != 0 {
msgs = append(msgs, fmt.Sprintf("INFO: %d records not being deleted because of NO_PURGE:", len(foreign)))
for _, r := range foreign {
msgs = append(msgs, fmt.Sprintf(" %s. %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted()))
}
}
if len(ignorable) != 0 {
msgs = append(msgs, fmt.Sprintf("INFO: %d records not being deleted because of IGNORE*():", len(ignorable)))
for _, r := range ignorable {
msgs = append(msgs, fmt.Sprintf(" %s %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted()))
}
}
// Check for invalid use of IGNORE_*.
conflicts := findConflicts(unmanagedConfigs, desired)
if len(conflicts) != 0 {
msgs = append(msgs, fmt.Sprintf("INFO: %d records that are both IGNORE*()'d and not ignored:", len(conflicts)))
for _, r := range conflicts {
msgs = append(msgs, fmt.Sprintf(" %s %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted()))
}
if unmanagedSafely {
return nil, nil, fmt.Errorf(strings.Join(msgs, "\n") +
"ERROR: Unsafe to continue. Add DISABLE_UNMANAGED_SAFETY_CHECK to D() to override")
}
}
// Add the ignored/foreign items to the desired list so they are not deleted:
desired = append(desired, ignorable...)
desired = append(desired, foreign...)
return desired, msgs, nil
}
// processIgnoreAndNoPurge processes the IGNORE_*()/UNMANAGED() and NO_PURGE/ENSURE_ABSENT_REC() features.
func processIgnoreAndNoPurge(domain string, existing, desired, absences models.Records, unmanagedConfigs []*models.UnmanagedConfig, noPurge bool) (models.Records, models.Records) {
var ignorable, foreign models.Records
desiredDB := models.NewRecordDBFromRecords(desired, domain)
absentDB := models.NewRecordDBFromRecords(absences, domain)
compileUnmanagedConfigs(unmanagedConfigs)
for _, rec := range existing {
if matchAll(unmanagedConfigs, rec) {
ignorable = append(ignorable, rec)
} else {
if noPurge {
// Is this a candidate for purging?
if !desiredDB.ContainsLT(rec) {
// Yes, but not if it is an exception!
if !absentDB.ContainsLT(rec) {
foreign = append(foreign, rec)
}
}
}
}
}
return ignorable, foreign
}
// findConflicts takes a list of recs and a list of (compiled) UnmanagedConfigs
// and reports if any of the recs match any of the configs.
func findConflicts(uconfigs []*models.UnmanagedConfig, recs models.Records) models.Records {
var conflicts models.Records
for _, rec := range recs {
if matchAll(uconfigs, rec) {
conflicts = append(conflicts, rec)
}
}
return conflicts
}
// compileUnmanagedConfigs prepares a slice of UnmanagedConfigs so they can be used.
func compileUnmanagedConfigs(configs []*models.UnmanagedConfig) error {
var err error
for i := range configs {
c := configs[i]
if c.LabelPattern == "" || c.LabelPattern == "*" {
c.LabelGlob = nil // nil indicates "always match"
} else {
c.LabelGlob, err = glob.Compile(c.LabelPattern)
if err != nil {
return err
}
}
c.RTypeMap = make(map[string]struct{})
if c.RTypePattern != "*" && c.RTypePattern != "" {
for _, part := range strings.Split(c.RTypePattern, ",") {
part = strings.TrimSpace(part)
c.RTypeMap[part] = struct{}{}
}
}
if c.TargetPattern == "" || c.TargetPattern == "*" {
c.TargetGlob = nil // nil indicates "always match"
} else {
c.TargetGlob, err = glob.Compile(c.TargetPattern)
if err != nil {
return err
}
}
}
return nil
}
// matchAll returns true if rec matches any of the uconfigs.
func matchAll(uconfigs []*models.UnmanagedConfig, rec *models.RecordConfig) bool {
for _, uc := range uconfigs {
if matchLabel(uc.LabelGlob, rec.GetLabel()) &&
matchType(uc.RTypeMap, rec.Type) &&
matchTarget(uc.TargetGlob, rec.GetLabel()) {
return true
}
}
return false
}
func matchLabel(labelGlob glob.Glob, labelName string) bool {
if labelGlob == nil {
return true
}
return labelGlob.Match(labelName)
}
func matchType(typeMap map[string]struct{}, typeName string) bool {
if len(typeMap) == 0 {
return true
}
_, ok := typeMap[typeName]
return ok
}
func matchTarget(targetGlob glob.Glob, targetName string) bool {
if targetGlob == nil {
return true
}
return targetGlob.Match(targetName)
}

237
pkg/diff2/handsoff_test.go Normal file
View File

@ -0,0 +1,237 @@
package diff2
import (
"fmt"
"strings"
"testing"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/js"
"github.com/miekg/dns"
testifyrequire "github.com/stretchr/testify/require"
)
// parseZoneContents is copied verbatium from providers/bind/bindProvider.go
// because import cycles and... tests shouldn't depend on huge modules.
func parseZoneContents(content string, zoneName string, zonefileName string) (models.Records, error) {
zp := dns.NewZoneParser(strings.NewReader(content), zoneName, zonefileName)
foundRecords := models.Records{}
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
rec, err := models.RRtoRC(rr, zoneName)
if err != nil {
return nil, err
}
foundRecords = append(foundRecords, &rec)
}
if err := zp.Err(); err != nil {
return nil, fmt.Errorf("error while parsing '%v': %w", zonefileName, err)
}
return foundRecords, nil
}
func showRecs(recs models.Records) string {
result := ""
for _, rec := range recs {
result += (rec.GetLabel() +
" " + rec.Type +
" " + rec.GetTargetRFC1035Quoted() +
"\n")
}
return result
}
func handsoffHelper(t *testing.T, existingZone, desiredJs string, noPurge bool, resultWanted string) {
t.Helper()
existing, err := parseZoneContents(existingZone, "f.com", "no_file_name")
if err != nil {
panic(err)
}
//fmt.Printf("DEBUG: existing=%s\n", showRecs(existing))
dnsconfig, err := js.ExecuteJavascriptString([]byte(desiredJs), false, nil)
if err != nil {
panic(err)
}
dc := dnsconfig.FindDomain("f.com")
desired := dc.Records
absences := dc.EnsureAbsent
unmanagedConfigs := dc.Unmanaged
// BUG(tlim): For some reason ExecuteJavascriptString() isn't setting the NameFQDN on records.
// This fixes up the records. It is a crass workaround. We should find the real
// cause and fix it.
for i, j := range desired {
desired[i].SetLabel(j.GetLabel(), "f.com")
}
for i, j := range absences {
absences[i].SetLabel(j.GetLabel(), "f.com")
}
ignored, purged := processIgnoreAndNoPurge(
"f.com",
existing, desired,
absences,
unmanagedConfigs,
noPurge,
)
ignoredRecs := showRecs(ignored)
purgedRecs := showRecs(purged)
resultActual := "IGNORED:\n" + ignoredRecs + "FOREIGN:\n" + purgedRecs
resultWanted = strings.TrimSpace(resultWanted) + "\n"
resultActual = strings.TrimSpace(resultActual) + "\n"
existingTxt := showRecs(existing)
desiredTxt := showRecs(desired)
debugTxt := "EXISTING:\n" + existingTxt + "DESIRED:\n" + desiredTxt
if resultWanted != resultActual {
testifyrequire.Equal(t,
resultActual,
resultWanted,
"GOT =\n```\n%s```\nWANT=\n```%s```\nINPUTS=\n```\n%s\n```\n",
resultActual,
resultWanted,
debugTxt)
}
}
func Test_purge_empty(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
{})
`
handsoffHelper(t, existingZone, desiredJs, false, `
IGNORED:
FOREIGN:
`)
}
func Test_purge_1(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 2.2.2.2
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
{})
`
handsoffHelper(t, existingZone, desiredJs, false, `
IGNORED:
FOREIGN:
`)
}
func Test_nopurge_1(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
FOREIGN:
foo3 A 3.3.3.3
`)
}
func Test_absent_1(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
A("foo3", "3.3.3.3", ENSURE_ABSENT_REC()),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
FOREIGN:
`)
}
func Test_ignore_lab(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
foo3 IN MX 10 mymx.example.com.
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
IGNORE_NAME("foo3"),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
foo3 A 3.3.3.3
foo3 MX 10 mymx.example.com.
FOREIGN:
`)
}
func Test_ignore_labAndType(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
foo3 IN MX 10 mymx.example.com.
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
A("foo3", "3.3.3.3"),
IGNORE_NAME("foo3", "MX"),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
foo3 MX 10 mymx.example.com.
FOREIGN:
`)
}
func Test_ignore_target(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
_2222222222222222.cr IN CNAME _333333.nnn.acm-validations.aws.
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
MX("foo3", 10, "mymx.example.com."),
IGNORE_TARGET('**.acm-validations.aws.', 'CNAME'),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
FOREIGN:
_2222222222222222.cr CNAME _333333.nnn.acm-validations.aws.
`)
}

View File

@ -1,40 +0,0 @@
package diff2
import "strings"
func processPurge(instructions ChangeList, nopurge bool) ChangeList {
if nopurge {
return instructions
}
// TODO(tlim): This can probably be done without allocations but it
// works and I won't want to prematurely optimize.
var msgs []string
newinstructions := make(ChangeList, 0, len(instructions))
for _, j := range instructions {
if j.Type == DELETE {
msgs = append(msgs, j.Msgs...)
continue
}
newinstructions = append(newinstructions, j)
}
// Report what would have been purged
if len(msgs) != 0 {
for i := range msgs {
msgs[i] = "NO_PURGE: Skipping " + msgs[i]
}
msgs = append([]string{"NO_PURGE Activated! Skipping these actions:"}, msgs...)
newinstructions = append(newinstructions, Change{
Type: REPORT,
Msgs: msgs,
MsgsJoined: strings.Join(msgs, "\n"),
})
}
return newinstructions
}

View File

@ -1,129 +0,0 @@
package diff2
import (
"fmt"
"strings"
"github.com/gobwas/glob"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
)
func handsoff(
existing, desired models.Records,
unmanaged []*models.UnmanagedConfig,
beSafe bool,
) (models.Records, error) {
// What foreign items should we ignore?
foreign, err := manyQueries(existing, unmanaged)
if err != nil {
return nil, err
}
if len(foreign) != 0 {
printer.Printf("INFO: Foreign records being ignored: (%d)\n", len(foreign))
for i, r := range foreign {
printer.Printf("- % 4d: %s %s %s\n", i, r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted())
}
}
// What desired items might conflict?
conflicts, err := manyQueries(desired, unmanaged)
if err != nil {
return nil, err
}
if len(conflicts) != 0 {
level := "WARN"
if beSafe {
level = "ERROR"
}
printer.Printf("%s: dnsconfig.js records that overlap MANAGED: (%d)\n", level, len(conflicts))
for i, r := range conflicts {
printer.Printf("- % 4d: %s %s %s\n", i, r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted())
}
if beSafe {
return nil, fmt.Errorf("ERROR: Unsafe to continue. Add DISABLE_UNMANAGED_SAFETY_CHECK to D() to override")
}
}
// Add the foreign items to the desired list.
// (Rather than literally ignoring them, we just add them to the desired list
// and all the diffing algorithms become more simple.)
desired = append(desired, foreign...)
return desired, nil
}
func manyQueries(rcs models.Records, queries []*models.UnmanagedConfig) (result models.Records, err error) {
for _, q := range queries {
lab := q.Label
if lab == "" {
lab = "*"
}
glabel, err := glob.Compile(lab)
if err != nil {
return nil, err
}
targ := q.Target
if targ == "" {
targ = "*"
}
gtarget, err := glob.Compile(targ)
if err != nil {
return nil, err
}
hasRType := compileTypeGlob(q.RType)
for _, rc := range rcs {
if match(rc, glabel, gtarget, hasRType) {
result = append(result, rc)
}
}
}
return result, nil
}
func compileTypeGlob(g string) map[string]bool {
m := map[string]bool{}
for _, j := range strings.Split(g, ",") {
m[strings.TrimSpace(j)] = true
}
return m
}
func match(rc *models.RecordConfig, glabel, gtarget glob.Glob, hasRType map[string]bool) bool {
//printer.Printf("DEBUG: match(%v, %v, %v, %v)\n", rc.NameFQDN, glabel, gtarget, hasRType)
// _ = glabel.Match(rc.NameFQDN)
// _ = matchType(rc.Type, hasRType)
// x := rc.GetTargetField()
// _ = gtarget.Match(x)
if !glabel.Match(rc.NameFQDN) {
//printer.Printf("DEBUG: REJECTED LABEL: %s:%v\n", rc.NameFQDN, glabel)
return false
} else if !matchType(rc.Type, hasRType) {
//printer.Printf("DEBUG: REJECTED TYPE: %s:%v\n", rc.Type, hasRType)
return false
} else if gtarget == nil {
return true
} else if !gtarget.Match(rc.GetTargetField()) {
//printer.Printf("DEBUG: REJECTED TARGET: %v:%v\n", rc.GetTargetField(), gtarget)
return false
}
return true
}
func matchType(s string, hasRType map[string]bool) bool {
//printer.Printf("DEBUG: matchType map=%v\n", hasRType)
if len(hasRType) == 0 {
return true
}
_, ok := hasRType[s]
return ok
}

View File

@ -1,147 +0,0 @@
package diff2
import (
"testing"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/gobwas/glob"
)
var rmapNil map[string]bool
var rmapAMX = map[string]bool{
"A": true,
"MX": true,
}
var rmapCNAME = map[string]bool{
"CNAME": true,
}
func Test_match(t *testing.T) {
testRecLammaA1234 := makeRec("lamma", "A", "1.2.3.4")
type args struct {
rc *models.RecordConfig
glabel glob.Glob
gtarget glob.Glob
hasRType map[string]bool
}
tests := []struct {
name string
args args
want bool
}{
{
name: "match3",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapAMX,
gtarget: glob.MustCompile("1.2.3.*"),
},
want: true,
},
{
name: "match2",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapAMX,
gtarget: nil,
},
want: true,
},
{
name: "match1",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapNil,
gtarget: nil,
},
want: true,
},
{
name: "reject1",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("yyyy"),
hasRType: rmapAMX,
gtarget: glob.MustCompile("1.2.3.*"),
},
want: false,
},
{
name: "reject2",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapCNAME,
gtarget: glob.MustCompile("1.2.3.*"),
},
want: false,
},
{
name: "reject3",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapAMX,
gtarget: glob.MustCompile("zzzzz"),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := match(tt.args.rc, tt.args.glabel, tt.args.gtarget, tt.args.hasRType); got != tt.want {
t.Errorf("match() = %v, want %v", got, tt.want)
}
})
}
}
func Test_matchType(t *testing.T) {
type args struct {
s string
hasRType map[string]bool
}
tests := []struct {
name string
args args
want bool
}{
{
name: "matchCNAME",
args: args{"CNAME", rmapCNAME},
want: true,
},
{
name: "rejectCNAME",
args: args{"MX", rmapCNAME},
want: false,
},
{
name: "matchNIL",
args: args{"CNAME", rmapNil},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchType(tt.args.s, tt.args.hasRType); got != tt.want {
t.Errorf("matchType() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -104,6 +104,7 @@ function newDomain(name, registrar) {
registrar: registrar,
meta: {},
records: [],
recordsabsent: [],
dnsProviders: {},
defaultTTL: 0,
nameservers: [],
@ -614,7 +615,6 @@ function IGNORE_NAME(name, rTypes) {
d.unmanaged.push({
label_pattern: name,
rType_pattern: rTypes,
target_pattern: '*',
});
};
}
@ -632,7 +632,6 @@ function IGNORE_TARGET(target, rType) {
return function (d) {
d.ignored_targets.push({ pattern: target, type: rType });
d.unmanaged.push({
label_pattern: '*',
rType_pattern: rType,
target_pattern: target,
});
@ -660,6 +659,22 @@ function NO_PURGE(d) {
d.KeepUnknown = true;
}
// ENSURE_ABSENT_REC()
// Usage: A("foo", "1.2.3.4", ENSURE_ABSENT_REC())
function ENSURE_ABSENT_REC() {
return function (r) {
r.ensure_absent = true;
};
}
// ENSURE_ABSENT()
// Usage: ENSURE_ABSENT(A("foo", "1.2.3.4"))
// (BROKEN. COMMENTED OUT UNTIL IT IS FIXED.)
// function ENSURE_ABSENT(r) {
// //console.log(r);
// return r;
// }
// AUTODNSSEC
// Permitted values are:
// "" Do not modify the setting (the default)
@ -678,15 +693,6 @@ function AUTODNSSEC(d) {
}
function UNMANAGED(label_pattern, rType_pattern, target_pattern) {
if (rType_pattern === undefined) {
rType_pattern = '*';
}
if (rType_pattern === "") {
rType_pattern = '*';
}
if (target_pattern === undefined) {
target_pattern = '*';
}
return function (d) {
d.unmanaged.push({
label_pattern: label_pattern,
@ -835,7 +841,15 @@ function recordBuilder(type, opts) {
}
}
d.records.push(record);
// Now we finally have the record. If it is a normal record, we add
// it to "records". If it is an ENSURE_ABSENT record, we add it to
// the ensure_absent list.
if (record.ensure_absent) {
d.recordsabsent.push(record);
} else {
d.records.push(record);
}
return record;
};
};

View File

@ -45,6 +45,11 @@ func ExecuteJavascript(file string, devMode bool, variables map[string]string) (
// Record the directory path leading up to this file.
currentDirectory = filepath.Dir(file)
return ExecuteJavascriptString(script, devMode, variables)
}
func ExecuteJavascriptString(script []byte, devMode bool, variables map[string]string) (*models.DNSConfig, error) {
vm := otto.New()
l := loop.New(vm)

View File

@ -46,45 +46,37 @@
"unmanaged": [
{
"label_pattern": "testignore",
"rType_pattern": "*",
"target_pattern": "*"
"rType_pattern": "*"
},
{
"label_pattern": "testignore2",
"rType_pattern": "A",
"target_pattern": "*"
"rType_pattern": "A"
},
{
"label_pattern": "testignore3",
"rType_pattern": "A, CNAME, TXT",
"target_pattern": "*"
"rType_pattern": "A, CNAME, TXT"
},
{
"label_pattern": "testignore4",
"rType_pattern": "*",
"target_pattern": "*"
"rType_pattern": "*"
},
{
"label_pattern": "*",
"rType_pattern": "CNAME",
"target_pattern": "testtarget"
},
{
"label_pattern": "legacyignore",
"rType_pattern": "*",
"target_pattern": "*"
"rType_pattern": "*"
},
{
"label_pattern": "@",
"rType_pattern": "*",
"target_pattern": "*"
"rType_pattern": "*"
},
{
"label_pattern": "*",
"rType_pattern": "CNAME",
"target_pattern": "@"
}
]
}
]
}
}

View File

@ -16,10 +16,9 @@
"unmanaged": [
{
"label_pattern": "\\*.testignore",
"rType_pattern": "*",
"target_pattern": "*"
"rType_pattern": "*"
}
]
}
]
}
}

View File

@ -1,6 +1,9 @@
D("foo.com", "none"
, UNMANAGED("one")
, UNMANAGED("two", "A, CNAME")
, UNMANAGED("three", "TXT", "findme")
, UNMANAGED("notype", "", "targglob")
, UNMANAGED("", "", "targetGlob1")
, UNMANAGED("", "CNAME", "")
, UNMANAGED("", "A", "targetGlob3")
, UNMANAGED("lab4")
, UNMANAGED("notype", "", "targetGlob5")
, UNMANAGED("lab6", "A, CNAME")
, UNMANAGED("lab7", "TXT", "targetGlob7")
);

View File

@ -1,34 +1,40 @@
{
"registrars": [],
"dns_providers": [],
"domains": [
{
"name": "foo.com",
"registrar": "none",
"dnsProviders": {},
"name": "foo.com",
"records": [],
"registrar": "none",
"unmanaged": [
{
"label_pattern": "one",
"rType_pattern": "*",
"target_pattern": "*"
"target_pattern": "targetGlob1"
},
{
"label_pattern": "two",
"rType_pattern": "A, CNAME",
"target_pattern": "*"
"rType_pattern": "CNAME"
},
{
"label_pattern": "three",
"rType_pattern": "TXT",
"target_pattern": "findme"
"rType_pattern": "A",
"target_pattern": "targetGlob3"
},
{
"label_pattern": "lab4"
},
{
"label_pattern": "notype",
"rType_pattern": "*",
"target_pattern": "targglob"
"target_pattern": "targetGlob5"
},
{
"label_pattern": "lab6",
"rType_pattern": "A, CNAME"
},
{
"label_pattern": "lab7",
"rType_pattern": "TXT",
"target_pattern": "targetGlob7"
}
]
}
]
}
],
"registrars": []
}

View File

@ -0,0 +1,5 @@
D("example.com", "none",
A("normal", "1.1.1.1"),
A("helper", "2.2.2.2", ENSURE_ABSENT_REC()),
//ENSURE_ABSENT(A("wrapped", "3.3.3.3")),
{});

View File

@ -0,0 +1,25 @@
{
"dns_providers": [],
"domains": [
{
"dnsProviders": {},
"name": "example.com",
"records": [
{
"name": "normal",
"target": "1.1.1.1",
"type": "A"
}
],
"recordsabsent": [
{
"name": "helper",
"target": "2.2.2.2",
"type": "A"
}
],
"registrar": "none"
}
],
"registrars": []
}

29
pkg/recorddb/recorddb.go Normal file
View File

@ -0,0 +1,29 @@
package recorddb
import "github.com/StackExchange/dnscontrol/v3/models"
// Functions that make it easier to deal with
// a group of records.
//
type RecordDB = struct {
labelAndTypeMap map[models.RecordKey]struct{}
}
func NewFromRecords(recs models.Records) *RecordDB {
result := &RecordDB{}
result.labelAndTypeMap = make(map[models.RecordKey]struct{}, len(recs))
for _, rec := range recs {
result.labelAndTypeMap[rec.Key()] = struct{}{}
}
return result
}
// ContainsLT returns true if recdb contains rec. Matching is done
// on the record's label and type (i.e. the RecordKey)
//func (recdb RecordDB) ContainsLT(rec *models.RecordConfig) bool {
// _, ok := recdb.labelAndTypeMap[rec.Key()]
// return ok
//}

View File

@ -153,7 +153,6 @@ func (c *bindProvider) ListZones() ([]string, error) {
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) {
foundRecords := models.Records{}
if _, err := os.Stat(c.directory); os.IsNotExist(err) {
printer.Printf("\nWARNING: BIND directory %q does not exist!\n", c.directory)
@ -177,10 +176,17 @@ func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) {
}
c.zoneFileFound = true
zp := dns.NewZoneParser(strings.NewReader(string(content)), domain, c.zonefile)
zonefileName := c.zonefile
return ParseZoneContents(string(content), domain, zonefileName)
}
func ParseZoneContents(content string, zoneName string, zonefileName string) (models.Records, error) {
zp := dns.NewZoneParser(strings.NewReader(content), zoneName, zonefileName)
foundRecords := models.Records{}
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
rec, err := models.RRtoRC(rr, domain)
rec, err := models.RRtoRC(rr, zoneName)
if err != nil {
return nil, err
}
@ -188,7 +194,7 @@ func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) {
}
if err := zp.Err(); err != nil {
return nil, fmt.Errorf("error while parsing '%v': %w", c.zonefile, err)
return nil, fmt.Errorf("error while parsing '%v': %w", zonefileName, err)
}
return foundRecords, nil
}

View File

@ -489,7 +489,7 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode
switch inst.Type {
case diff2.REPORT:
corrections = append(corrections, &models.Correction{Msg: inst.MsgsJoined})
chg = r53Types.Change{}
case diff2.CREATE:
fallthrough
@ -586,8 +586,9 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode
func reorderInstructions(changes diff2.ChangeList) diff2.ChangeList {
var main, tail diff2.ChangeList
for _, change := range changes {
//if change.Key.Type == "R53_ALIAS" {
if strings.HasPrefix(change.Key.Type, "R53_ALIAS_") {
// Reports should be early in the list.
// R53_ALIAS_ records should go to the tail.
if change.Type != diff2.REPORT && strings.HasPrefix(change.Key.Type, "R53_ALIAS_") {
tail = append(tail, change)
} else {
main = append(main, change)
@ -889,6 +890,11 @@ func (b *changeBatcher) Next() bool {
c := &b.changes[end]
// Check that we won't exceed 1000 ResourceRecords in the request.
if c.ResourceRecordSet == nil {
end++
continue
}
rrsetSize := len(c.ResourceRecordSet.ResourceRecords)
if c.Action == r53Types.ChangeActionUpsert {
// "When the value of the Action element is UPSERT, each ResourceRecord element is counted twice."