BUGFIX: IDN support is broken for domain names (#3845)

# Issue

Fixes https://github.com/StackExchange/dnscontrol/issues/3842

CC @das7pad

# Resolution

Convert domain.Name to IDN earlier in the pipeline. Hack the --domains
processing to convert everything to IDN.

* Domain names are now stored 3 ways: The original input from
dnsconfig.js, canonical IDN format (`xn--...`), and Unicode format. All
are downcased. Providers that haven't been updated will receive the IDN
format instead of the original input format. This might break some
providers but only for users with unicode in their D("domain.tld").
PLEASE TEST YOUR PROVIDER.
* BIND filename formatting options have been added to access the new
formats.

# Breaking changes

* BIND zonefiles may change. The default used the name input in the D()
statement. It now defaults to the IDN name + "!tag" if there is a tag.
* Providers that are not IDN-aware may break (hopefully only if they
weren't processing IDN already)

---------

Co-authored-by: Jakob Ackermann <das7pad@outlook.com>
This commit is contained in:
Tom Limoncelli 2025-11-29 12:17:44 -05:00 committed by GitHub
parent 9aad2926fb
commit 1b2f5d4d34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 623 additions and 470 deletions

View file

@ -303,39 +303,3 @@ func (args *FilterArgs) flags() []cli.Flag {
},
}
}
// domainInList takes a domain and a list of domains and returns true if the
// domain is in the list, accounting for wildcards and tags.
func domainInList(domain string, list []string) bool {
for _, item := range list {
if item == domain {
return true
}
if strings.HasPrefix(item, "*") && strings.HasSuffix(domain, item[1:]) {
return true
}
filterDom, filterTag, isFilterTagged := strings.Cut(item, "!")
splitDom, domainTag, isDomainTagged := strings.Cut(domain, "!")
if splitDom == filterDom {
if isDomainTagged {
if filterTag == "*" {
return true
}
if domainTag == "" && !isFilterTagged {
// domain example.com! == filter example.com
return true
}
if isFilterTagged && domainTag == filterTag {
return true
}
}
if isFilterTagged {
if filterTag == "" && !isDomainTagged {
// filter example.com! == domain example.com
return true
}
}
}
}
return false
}

View file

@ -1,127 +0,0 @@
package commands
import "testing"
func Test_domainInList(t *testing.T) {
type args struct {
domain string
list []string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "small",
args: args{
domain: "foo.com",
list: []string{"foo.com"},
},
want: true,
},
{
name: "big",
args: args{
domain: "foo.com",
list: []string{"example.com", "foo.com", "baz.com"},
},
want: true,
},
{
name: "missing",
args: args{
domain: "foo.com",
list: []string{"bar.com"},
},
want: false,
},
{
name: "wildcard",
args: args{
domain: "*.10.in-addr.arpa",
list: []string{"bar.com", "10.in-addr.arpa", "example.com"},
},
want: false,
},
{
name: "wildcardmissing",
args: args{
domain: "*.10.in-addr.arpa",
list: []string{"bar.com", "6.in-addr.arpa", "example.com"},
},
want: false,
},
{
name: "tagged",
args: args{
domain: "foo.com!bar",
list: []string{"foo.com"},
},
want: false,
},
{
name: "taggedWildcard",
args: args{
domain: "foo.com!bar",
list: []string{"foo.com!*"},
},
want: true,
},
{
name: "taggedWildcardMatchesEmpty",
args: args{
domain: "foo.com!",
list: []string{"foo.com!*"},
},
want: true,
},
{
name: "taggedWildcardNotMatchUntagged",
args: args{
domain: "foo.com",
list: []string{"foo.com!*"},
},
want: false,
},
{
name: "taggedEmtpy",
args: args{
domain: "foo.com",
list: []string{"foo.com!"},
},
want: true,
},
{
name: "domainTaggedEmtpy",
args: args{
domain: "foo.com!",
list: []string{"foo.com"},
},
want: true,
},
{
name: "filterTaggedNoMatch",
args: args{
domain: "foo.com",
list: []string{"foo.com!foo"},
},
want: false,
},
{
name: "domainTaggedNoMatch",
args: args{
domain: "foo.com!foo",
list: []string{"foo.com"},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := domainInList(tt.args.domain, tt.args.list); got != tt.want {
t.Errorf("domainInList() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -200,7 +200,10 @@ func GetZone(args GetZoneArgs) error {
// fetch all of the records
zoneRecs := make([]models.Records, len(zones))
for i, zone := range zones {
recs, err := provider.GetZoneRecords(zone, nil)
recs, err := provider.GetZoneRecords(zone,
map[string]string{
models.DomainUniqueName: zone,
})
if err != nil {
return fmt.Errorf("failed GetZone gzr: %w", err)
}

View file

@ -63,7 +63,7 @@ func testFormat(t *testing.T, domain, format string) {
// Read the expected result
want, err := os.ReadFile(expectedFilename)
if err != nil {
log.Fatal(fmt.Errorf("can't read expected %q: %w", outfile.Name(), err))
log.Fatal(fmt.Errorf("can't read expected %q: %w", expectedFilename, err))
}
if w, g := string(want), string(got); w != g {

View file

@ -20,6 +20,7 @@ import (
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/bindserial"
"github.com/StackExchange/dnscontrol/v4/pkg/credsfile"
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
"github.com/StackExchange/dnscontrol/v4/pkg/nameservers"
"github.com/StackExchange/dnscontrol/v4/pkg/normalize"
"github.com/StackExchange/dnscontrol/v4/pkg/notifications"
@ -288,7 +289,7 @@ func prun(args PPreviewArgs, push bool, interactive bool, out printer.CLI, repor
continue // Do not emit noise when zone exists
}
if !started {
out.StartDomain(zone.GetUniqueName())
out.StartDomain(zone)
started = true
}
skip := skipProvider(provider.Name, providersToProcess)
@ -351,7 +352,7 @@ func prun(args PPreviewArgs, push bool, interactive bool, out printer.CLI, repor
// Now we know what to do, print or do the tasks.
out.PrintfIf(fullMode, "PHASE 3: CORRECTIONS\n")
for _, zone := range zonesToProcess {
out.StartDomain(zone.GetUniqueName())
out.StartDomain(zone)
// Process DNS provider changes:
providersToProcess := whichProvidersToProcess(zone.DNSProviderInstances, args.Providers)
@ -400,29 +401,16 @@ func prun(args PPreviewArgs, push bool, interactive bool, out printer.CLI, repor
return nil
}
// func countActions(corrections []*models.Correction) int {
// r := 0
// for _, c := range corrections {
// if c.F != nil {
// r++
// }
// }
// return r
//}
// whichZonesToProcess takes a list of DomainConfigs and a filter string and
// returns a list of DomainConfigs whose metadata[DomainUniqueName] matched the
// returns a list of DomainConfigs whose Domain.UniqueName matched the
// filter. The filter string is a comma-separated list of domain names. If the
// filter string is empty or "all", all domains are returned.
func whichZonesToProcess(domains []*models.DomainConfig, filter string) []*models.DomainConfig {
if filter == "" || filter == "all" {
return domains
}
fh := domaintags.CompilePermitList(filter)
permitList := strings.Split(filter, ",")
var picked []*models.DomainConfig
for _, domain := range domains {
if domainInList(domain.GetUniqueName(), permitList) {
if fh.Permitted(domain.GetUniqueName()) {
picked = append(picked, domain)
}
}

View file

@ -23,7 +23,7 @@ func Test_whichZonesToProcess(t *testing.T) {
}
for _, dc := range allDC {
dc.UpdateSplitHorizonNames()
dc.PostProcess()
}
type args struct {

View file

@ -22,7 +22,8 @@ Example:
{
"bind": {
"TYPE": "BIND",
"directory": "myzones"
"directory": "myzones",
"filenameformat": "%U.zone"
}
}
```
@ -89,10 +90,13 @@ file name is the name as specified in the `D()` function plus ".zone".
The filenameformat is a string with a few printf-like `%` verbs:
* `%U` the domain name as specified in `D()`
* `%D` the domain name without any split horizon tag (the "example.com" part of "example.com!tag")
* `%T` the split horizon tag, or "" (the "tag" part of "example.com!tag")
* `%?x` this returns `x` if the split horizon tag is non-null, otherwise nothing. `x` can be any printable.
* The domain name without tag (the `example.com` part of `example.com!tag`):
* `%D` as specified in `D()` (no IDN conversion, but downcased)
* `%I` converted to IDN/Punycode (`xn--...`) and downcased.
* `%N` converted to Unicode (downcased first)
* `%T` the split horizon tag, or "" (the `tag` part of `example.com!tag`)
* `%?x` this returns `x` if the split horizon tag is non-null, otherwise nothing. `x` can be any printable but is usually `!`.
* `%U` short for "%I%?!%T". This is the universal, canonical, name for the domain used for comparisons within DNSControl. This is best for filenames which is why it is used in the default.
* `%%` `%`
* ordinary characters (not `%`) are copied unchanged to the output stream
* FYI: format strings must not end with an incomplete `%` or `%?`
@ -101,19 +105,17 @@ Typical values:
* `%U.zone` (The default)
* `example.com.zone` or `example.com!tag.zone`
* `%T%*U%D.zone` (optional tag and `_` + domain + `.zone`)
* `%T%?_%I.zone` (optional tag and `_` + domain + `.zone`)
* `tag_example.com.zone` or `example.com.zone`
* `db_%T%?_%D`
* `db_inside_example.com` or `db_example.com`
* `db_%D`
* `db_example.com`
The last example will generate the same name for both
`D("example.com!inside")` and `D("example.com!outside")`. This
assumes two BIND providers are configured in `creds.json`, each with
a different `directory` setting. Otherwise `dnscontrol` will write
both domains to the same file, flapping between the two back and
forth.
{% hint style="warning" %}
**Warning** DNSControl will not warn you if two zones generate the same
filename. Instead, each will write to the same place. The content would end up
flapping back and forth between the two. The best way to prevent this is to
always include the tag (`%T`) or use `%U` which includes the tag.
{% endhint %}
(new in v4.2.0) `dnscontrol push` will create subdirectories along the path to
the filename. This includes both the portion of the path created by the

View file

@ -40,7 +40,7 @@ func getDomainConfigWithNameservers(t *testing.T, prv providers.DNSServiceProvid
dc := &models.DomainConfig{
Name: domainName,
}
dc.UpdateSplitHorizonNames()
dc.PostProcess()
// fix up nameservers
ns, err := prv.GetNameservers(domainName)
@ -148,6 +148,8 @@ func makeChanges(t *testing.T, prv providers.DNSServiceProvider, dc *models.Doma
return
}
//fmt.Printf("DEBUG: Running test %q: Names %q %q %q\n", desc, dom.Name, dom.NameRaw, dom.NameUnicode)
// get and run corrections for first time
_, corrections, actualChangeCount, err := zonerecs.CorrectZoneRecords(prv, dom)
if err != nil {

View file

@ -105,17 +105,10 @@ type Correction struct {
Msg string
}
// DomainContainingFQDN finds the best domain from the dns config for the given record fqdn.
// It will chose the domain whose name is the longest suffix match for the fqdn.
func (config *DNSConfig) DomainContainingFQDN(fqdn string) *DomainConfig {
fqdn = strings.TrimSuffix(fqdn, ".")
longestLength := 0
var d *DomainConfig
for _, dom := range config.Domains {
if (dom.Name == fqdn || strings.HasSuffix(fqdn, "."+dom.Name)) && len(dom.Name) > longestLength {
longestLength = len(dom.Name)
d = dom
// PostProcess performs and post-processing required after running dnsconfig.js and loading the result.
func (config *DNSConfig) PostProcess() error {
for _, domain := range config.Domains {
domain.PostProcess()
}
}
return d
return nil
}

View file

@ -2,28 +2,33 @@ package models
import (
"fmt"
"strings"
"sync"
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
"github.com/qdm12/reprint"
"golang.org/x/net/idna"
)
const (
// DomainUniqueName is the full `example.com!tag` name`
DomainUniqueName = "dnscontrol_uniquename"
// DomainTag is the tag part of `example.com!tag` name
DomainTag = "dnscontrol_tag"
DomainTag = "dnscontrol_tag" // A copy of DomainConfig.Tag
DomainUniqueName = "dnscontrol_uniquename" // A copy of DomainConfig.UniqueName
DomainNameRaw = "dnscontrol_nameraw" // A copy of DomainConfig.NameRaw
DomainNameIDN = "dnscontrol_nameidn" // A copy of DomainConfig.NameIDN
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 "."
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:"-"` // .Name + "!" + .Tag
RegistrarName string `json:"registrar"`
DNSProviderNames map[string]int `json:"dnsProviders"`
// Metadata[DomainUniqueName] // .Name + "!" + .Tag
// Metadata[DomainTag] // split horizon tag
Metadata map[string]string `json:"meta,omitempty"`
Records Records `json:"records"`
Nameservers []*Nameserver `json:"nameservers,omitempty"`
@ -56,43 +61,39 @@ type DomainConfig struct {
pendingPopulateCorrections map[string][]*Correction // Corrections for zone creations at each provider
}
// GetSplitHorizonNames returns the domain's name, uniquename, and tag.
func (dc *DomainConfig) GetSplitHorizonNames() (name, uniquename, tag string) {
return dc.Name, dc.Metadata[DomainUniqueName], dc.Metadata[DomainTag]
}
// GetUniqueName returns the domain's uniquename.
func (dc *DomainConfig) GetUniqueName() (uniquename string) {
return dc.Metadata[DomainUniqueName]
}
// UpdateSplitHorizonNames updates the split horizon fields
// (uniquename and tag) based on name.
func (dc *DomainConfig) UpdateSplitHorizonNames() {
name, unique, tag := dc.GetSplitHorizonNames()
if unique == "" {
unique = name
}
if tag == "" {
l := strings.SplitN(name, "!", 2)
if len(l) == 2 {
name = l[0]
tag = l[1]
}
if tag == "" {
// ensure empty tagged domain is treated as untagged
unique = name
}
}
dc.Name = name
// 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{}
}
dc.Metadata[DomainUniqueName] = unique
dc.Metadata[DomainTag] = tag
// Turn the user-supplied name into the fixed forms.
ff := domaintags.MakeDomainFixForms(dc.Name)
dc.Tag, dc.NameRaw, dc.Name, dc.NameUnicode, dc.UniqueName = ff.Tag, ff.NameRaw, ff.NameIDN, 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[DomainNameIDN] = 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.

View file

@ -1,68 +0,0 @@
package models
import (
"testing"
)
func Test_UpdateSplitHorizonNames(t *testing.T) {
tests := []struct {
name string
dc *DomainConfig
expected *DomainConfig
}{
{
name: "testNoTag",
dc: &DomainConfig{
Name: "example.com",
},
expected: &DomainConfig{
Name: "example.com",
Metadata: map[string]string{
DomainUniqueName: "example.com",
DomainTag: "",
},
},
},
{
name: "testEmptyTag",
dc: &DomainConfig{
Name: "example.com!",
},
expected: &DomainConfig{
Name: "example.com",
Metadata: map[string]string{
DomainUniqueName: "example.com",
DomainTag: "",
},
},
},
{
name: "testWithTag",
dc: &DomainConfig{
Name: "example.com!john",
},
expected: &DomainConfig{
Name: "example.com",
Metadata: map[string]string{
DomainUniqueName: "example.com!john",
DomainTag: "john",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.dc.UpdateSplitHorizonNames()
if tt.dc.Name != tt.expected.Name {
t.Errorf("expected name %s, got %s", tt.expected.Name, tt.dc.Name)
}
if tt.dc.Metadata[DomainUniqueName] != tt.expected.Metadata[DomainUniqueName] {
t.Errorf("expected unique name %s, got %s", tt.expected.Metadata[DomainUniqueName], tt.dc.Metadata[DomainUniqueName])
}
if tt.dc.Metadata[DomainTag] != tt.expected.Metadata[DomainTag] {
t.Errorf("expected tag %s, got %s", tt.expected.Metadata[DomainTag], tt.dc.Metadata[DomainTag])
}
})
}
}

View file

@ -23,7 +23,7 @@ func (rc *RecordConfig) SetTargetCAA(flag uint8, tag string, target string) erro
// Per: https://www.iana.org/assignments/pkix-parameters/pkix-parameters.xhtml#caa-properties excluding reserved tags
allowedTags := []string{"issue", "issuewild", "iodef", "contactemail", "contactphone", "issuemail", "issuevmc"}
if !slices.Contains(allowedTags, tag) {
return fmt.Errorf("CAA tag (%v) is not one of the valid types.", tag)
return fmt.Errorf("CAA tag (%v) is not one of the valid types", tag)
}
return nil

View file

@ -0,0 +1,81 @@
package domaintags
import (
"strings"
"golang.org/x/net/idna"
)
// DomainFixedForms stores the various fixed forms of a domain name and tag.
type DomainFixedForms struct {
NameRaw string // "originalinput.com" (name as input by the user, lowercased (no tag))
NameIDN string // "punycode.com"
NameUnicode string // "unicode.com" (converted to downcase BEFORE unicode conversion)
UniqueName string // "punycode.com!tag"
Tag string // The tag portion of `example.com!tag`
HasBang bool // Was there a "!" in the input when creating this struct?
}
// MakeDomainFixedForms turns the user-supplied name into the fixed forms.
// * .Tag: the domain tag (of "example.com!tag")
// * .NameRaw: lowercase version of how the user input the name in dnsconfig.js.
// * .Name: punycode version, downcased.
// * .NameUnicode: unicode version of the name, downcased.
// * .UniqueName: "example.com!tag" unique across the entire config.
func MakeDomainFixForms(n string) DomainFixedForms {
var err error
var tag, nameRaw, nameIDN, nameUnicode, uniqueName string
var hasBang bool
// Split tag from name.
p := strings.SplitN(n, "!", 2)
if len(p) == 2 {
tag = p[1]
hasBang = true
} else {
tag = ""
hasBang = false
}
nameRaw = strings.ToLower(p[0])
if strings.HasPrefix(n, nameRaw) {
// Avoid pointless duplication.
nameRaw = n[0:len(nameRaw)]
}
nameIDN, err = idna.ToASCII(nameRaw)
if err != nil {
nameIDN = nameRaw // Fallback to raw name on error.
} else {
// Avoid pointless duplication.
if nameIDN == nameRaw {
nameIDN = nameRaw
}
}
nameUnicode, err = idna.ToUnicode(nameRaw)
if err != nil {
nameUnicode = nameRaw // Fallback to raw name on error.
} else {
// Avoid pointless duplication.
if nameUnicode == nameRaw {
nameUnicode = nameRaw
}
}
if hasBang {
uniqueName = nameIDN + "!" + tag
} else {
uniqueName = nameIDN
}
return DomainFixedForms{
Tag: tag,
NameRaw: nameRaw,
NameIDN: nameIDN,
NameUnicode: nameUnicode,
UniqueName: uniqueName,
HasBang: hasBang,
}
}

View file

@ -0,0 +1,133 @@
package domaintags
import (
"testing"
)
func Test_MakeDomainFixForms(t *testing.T) {
tests := []struct {
name string
input string
wantTag string
wantNameRaw string
wantNameIDN string
wantNameUnicode string
wantUniqueName string
wantHasBang bool
}{
{
name: "simple domain",
input: "example.com",
wantTag: "",
wantNameRaw: "example.com",
wantNameIDN: "example.com",
wantNameUnicode: "example.com",
wantUniqueName: "example.com",
wantHasBang: false,
},
{
name: "domain with tag",
input: "example.com!mytag",
wantTag: "mytag",
wantNameRaw: "example.com",
wantNameIDN: "example.com",
wantNameUnicode: "example.com",
wantUniqueName: "example.com!mytag",
wantHasBang: true,
},
{
name: "domain with empty tag",
input: "example.com!",
wantTag: "",
wantNameRaw: "example.com",
wantNameIDN: "example.com",
wantNameUnicode: "example.com",
wantUniqueName: "example.com!",
wantHasBang: true,
},
{
name: "unicode domain",
input: "उदाहरण.com",
wantTag: "",
wantNameRaw: "उदाहरण.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com",
wantHasBang: false,
},
{
name: "unicode domain with tag",
input: "उदाहरण.com!mytag",
wantTag: "mytag",
wantNameRaw: "उदाहरण.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag",
wantHasBang: true,
},
{
name: "punycode domain",
input: "xn--p1b6ci4b4b3a.com",
wantTag: "",
wantNameRaw: "xn--p1b6ci4b4b3a.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com",
wantHasBang: false,
},
{
name: "punycode domain with tag",
input: "xn--p1b6ci4b4b3a.com!mytag",
wantTag: "mytag",
wantNameRaw: "xn--p1b6ci4b4b3a.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag",
wantHasBang: true,
},
{
name: "mixed case domain",
input: "Example.COM",
wantTag: "",
wantNameRaw: "example.com",
wantNameIDN: "example.com",
wantNameUnicode: "example.com",
wantUniqueName: "example.com",
wantHasBang: false,
},
{
name: "mixed case domain with tag",
input: "Example.COM!MyTag",
wantTag: "MyTag",
wantNameRaw: "example.com",
wantNameIDN: "example.com",
wantNameUnicode: "example.com",
wantUniqueName: "example.com!MyTag",
wantHasBang: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MakeDomainFixForms(tt.input)
if got.Tag != tt.wantTag {
t.Errorf("MakeDomainFixForms() gotTag = %v, want %v", got.Tag, tt.wantTag)
}
if got.NameRaw != tt.wantNameRaw {
t.Errorf("MakeDomainFixForms() gotNameRaw = %v, want %v", got.NameRaw, tt.wantNameRaw)
}
if got.NameIDN != tt.wantNameIDN {
t.Errorf("MakeDomainFixForms() gotNameIDN = %v, want %v", got.NameIDN, tt.wantNameIDN)
}
if got.NameUnicode != tt.wantNameUnicode {
t.Errorf("MakeDomainFixForms() gotNameUnicode = %v, want %v", got.NameUnicode, tt.wantNameUnicode)
}
if got.UniqueName != tt.wantUniqueName {
t.Errorf("MakeDomainFixForms() gotUniqueName = %v, want %v", got.UniqueName, tt.wantUniqueName)
}
if got.HasBang != tt.wantHasBang {
t.Errorf("MakeDomainFixForms() gotHasTag = %v, want %v", got.HasBang, tt.wantHasBang)
}
})
}
}

View file

@ -0,0 +1,91 @@
package domaintags
import (
"strings"
)
type PermitList struct {
// If the permit list is "all" or "".
all bool
items []DomainFixedForms
}
// CompilePermitList compiles a list of domain strings into a PermitList structure. The
func CompilePermitList(s string) PermitList {
s = strings.TrimSpace(s)
if s == "" || s == "*" || strings.ToLower(s) == "all" {
return PermitList{all: true}
}
sl := PermitList{}
for _, l := range strings.Split(s, ",") {
l = strings.TrimSpace(l)
if l == "" { // Skip empty entries. They match nothing.
continue
}
ff := MakeDomainFixForms(l)
if ff.HasBang && ff.NameIDN == "" { // Treat empty name as wildcard.
ff.NameIDN = "*"
}
sl.items = append(sl.items, ff)
}
return sl
}
func (pl *PermitList) Permitted(domToCheck string) bool {
// If the permit list is "all", everything is permitted.
if pl.all {
return true
}
domToCheckFF := MakeDomainFixForms(domToCheck)
for _, filterItem := range pl.items {
// Special case: filter=example.com!* does not match example.com (no tag)
if filterItem.Tag == "*" && !domToCheckFF.HasBang {
continue
}
// Special case: filter=example.com!* does not match example.com! (empty tag)
if filterItem.Tag == "*" && domToCheckFF.HasBang && domToCheckFF.Tag == "" {
continue
}
// Special case: filter=example.com! does not match example.com!tag
if filterItem.HasBang && filterItem.Tag == "" && domToCheckFF.HasBang && domToCheckFF.Tag != "" {
continue
}
// Skip if tags don't match
if (filterItem.Tag != "*") && (domToCheckFF.Tag != filterItem.Tag) {
continue
}
// Now that we know the tag matches, we can focus on the name.
// `*!tag` or `*` matches everything.
if filterItem.NameIDN == "*" {
return true
}
// If the name starts with "*." then match the suffix.
if strings.HasPrefix(filterItem.NameIDN, "*.") {
// example.com matches *.example.com
if domToCheckFF.NameIDN == filterItem.NameIDN[2:] || domToCheckFF.NameUnicode == filterItem.NameUnicode[2:] {
return true
}
// foo.example.com matches *.example.com
if strings.HasSuffix(domToCheckFF.NameIDN, filterItem.NameIDN[1:]) || strings.HasSuffix(domToCheckFF.NameUnicode, filterItem.NameUnicode[1:]) {
return true
}
}
// No wildcards? Exact match.
if filterItem.NameIDN == domToCheckFF.NameIDN || filterItem.NameUnicode == domToCheckFF.NameUnicode {
return true
}
}
return false
}

View file

@ -0,0 +1,110 @@
package domaintags
import "testing"
func TestPermitList_Permitted(t *testing.T) {
// MakeDomainFixForms is not exported, so we can't directly use it here
// to create complex test cases with IDNs easily without duplicating its logic.
// However, the existing tests cover a wide range of practical scenarios.
// For the purpose of this test, we'll assume MakeDomainFixForms works as expected
// and focus on the logic of the Permitted method itself.
testCases := []struct {
name string
permitList string
domain string
expected bool
}{
// "all" or empty permit list
{"all permits everything", "all", "example.com", true},
{"all permits everything with tag", "all", "example.com!tag1", true},
{"empty string permits everything", "", "example.com", true},
{"whitespace string permits everything", " ", "example.com", true},
// Simple exact matches
{"exact match", "example.com", "example.com", true},
{"exact match with tag", "example.com!tag1", "example.com!tag1", true},
{"exact mismatch domain", "example.com", "google.com", false},
{"exact mismatch tag", "example.com!tag1", "example.com!tag2", false},
{"exact mismatch domain with tag", "example.com!tag1", "google.com!tag1", false},
{"domain with tag not in list without tag", "example.com", "example.com!tag1", false},
{"domain without tag not in list with tag", "example.com!tag1", "example.com", false},
// Wildcard domain name
{"wildcard domain matches", "*!tag1", "example.com!tag1", true},
{"wildcard domain mismatch tag", "*!tag1", "example.com!tag2", false},
{"wildcard domain no tag", "*!tag1", "example.com", false},
{"wildcard domain and tag", "*", "example.com!tag1", true},
{"wildcard domain and tag no tag", "*", "example.com", true},
// Wildcard tag
{"wildcard tag matches", "example.com!*", "example.com!tag1", true},
{"wildcard tag matches no tag", "example.com!*", "example.com", false},
{"wildcard tag mismatch domain", "example.com!*", "google.com!tag1", false},
// Suffix matching
{"suffix match base domain", "*.example.com", "example.com", true},
{"suffix match subdomain", "*.example.com", "foo.example.com", true},
{"suffix match another subdomain", "*.example.com", "foo.bar.example.com", true},
{"suffix mismatch different domain", "*.example.com", "google.com", false},
{"suffix mismatch partial", "*.example.com", "badexample.com", false},
{"suffix match with tag", "*.example.com!tag1", "foo.example.com!tag1", true},
{"suffix match base domain with tag", "*.example.com!tag1", "example.com!tag1", true},
{"suffix mismatch tag", "*.example.com!tag1", "foo.example.com!tag2", false},
{"suffix mismatch domain with tag", "*.example.com!tag1", "google.com!tag1", false},
// Multiple items in list
{"multiple items first match", "google.com,example.com", "google.com", true},
{"multiple items second match", "google.com,example.com", "example.com", true},
{"multiple items no match", "google.com,example.com", "other.com", false},
{"multiple items with tags match", "google.com!tag1,example.com!tag2", "example.com!tag2", true},
{"multiple items with tags mismatch", "google.com!tag1,example.com!tag2", "example.com!tag1", false},
{"multiple complex items match", "a.com,*.b.com!tag1,c.com!*", "foo.b.com!tag1", true},
{"multiple complex items match 2", "a.com,*.b.com!tag1,c.com!*", "c.com!anytag", true},
{"multiple complex items no match", "a.com,*.b.com!tag1,c.com!*", "foo.b.com!tag2", false},
// IDN/Unicode cases (assuming MakeDomainFixForms works)
{"IDN exact match punycode", "xn--e1a4c.com", "xn--e1a4c.com", true}, // д.com
{"IDN exact match unicode", "д.com", "д.com", true},
{"IDN mixed match", "xn--d1a.com", "д.com", true},
{"IDN mixed match reversed", "д.com", "xn--d1a.com", true},
{"IDN suffix match punycode", "*.xn--e1a4c.com", "sub.xn--e1a4c.com", true},
{"IDN suffix match unicode", "*.д.com", "sub.д.com", true},
{"IDN suffix match mixed", "*.xn--d1a.com", "sub.д.com", true},
{"IDN suffix match mixed reversed", "*.д.com", "sub.xn--d1a.com", true},
{"IDN suffix match base", "*.д.com", "д.com", true},
// Edge cases
{"empty list", " ", "example.com", true}, // TrimSpace makes it "", which is "all"
{"list with empty items", "one.com,,two.com", "one.com", true},
{"list with empty items 2", "one.com,,two.com", "two.com", true},
{"list with empty items no match", "one.com,,two.com", "three.com", false},
{"no match on empty list", "nonexistent", "example.com", false},
// Weird backwards compatibility with no tag being different than empty tag
{"empty tag vs no tag mismatch", "example.com", "example.com!foo", false},
// testMultiFilterTaggedWildcard
{"testMultiFilterTaggedWildcard_0", "example.com!*", "example.com!", false},
{"testMultiFilterTaggedWildcard_1", "example.com!*", "example.com", false},
{"testMultiFilterTaggedWildcard_2", "example.com!*", "example.net", false},
{"testMultiFilterTaggedWildcard_3", "example.com!*", "example.com!george", true},
{"testMultiFilterTaggedWildcard_4", "example.com!*", "example.com!john", true},
// testFilterEmptyTagAndNoTag
{"testFilterEmptyTagAndNoTag_0", "example.com!,example.com", "example.com!", true},
{"testFilterEmptyTagAndNoTag_1", "example.com!,example.com", "example.com", true},
{"testFilterEmptyTagAndNoTag_2", "example.com!,example.com", "example.net", false},
{"testFilterEmptyTagAndNoTag_3", "example.com!,example.com", "example.com!tag", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pl := CompilePermitList(tc.permitList)
got := pl.Permitted(tc.domain)
if got != tc.expected {
t.Errorf("PermitList(%q).Permitted(%q) = %v; want %v", tc.permitList, tc.domain, got, tc.expected)
}
})
}
}

View file

@ -36,6 +36,8 @@ Back-port the ACTUAL results to the expected results:
(This is dangerous. You may be committing buggy results to the "expected" files. Carefully inspect the resulting PR.)
```
find . -type f -name \*.ACTUAL -print -delete
go test -count=1 ./...
cd parse_tests
fmtjson *.json *.json.ACTUAL
for i in *.ACTUAL ; do f=$(basename $i .ACTUAL) ; cp $i $f ; done

View file

@ -119,6 +119,12 @@ func ExecuteJavascriptString(script []byte, devMode bool, variables map[string]s
if err = json.Unmarshal([]byte(str), conf); err != nil {
return nil, err
}
err = conf.PostProcess()
if err != nil {
return nil, err
}
return conf, nil
}

View file

@ -19,7 +19,6 @@ import (
const (
testDir = "pkg/js/parse_tests"
errorDir = "pkg/js/error_tests"
)
func init() {
@ -49,9 +48,6 @@ func TestParsedFiles(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, dc := range conf.Domains {
dc.UpdateSplitHorizonNames()
}
errs := normalize.ValidateAndNormalizeConfig(conf)
if len(errs) != 0 {
@ -115,8 +111,7 @@ func TestParsedFiles(t *testing.T) {
var dCount int
for _, dc := range conf.Domains {
var zoneFile string
dc.UpdateSplitHorizonNames()
if dc.Metadata[models.DomainTag] != "" {
if dc.Tag != "" {
zoneFile = filepath.Join(testDir, testName, dc.GetUniqueName()+".zone")
} else {
zoneFile = filepath.Join(testDir, testName, dc.Name+".zone")

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -11,7 +11,6 @@
"Cloudflare": 0
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",
@ -56,7 +55,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "diff2.com"
},
"name": "diff2.com",

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo1.com"
},
"name": "foo1.com",
@ -29,7 +28,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "inny"
},
"name": "inny",
@ -54,7 +52,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "com.inny"
},
"name": "com.inny",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "2.1.in-addr.arpa"
},
"name": "2.1.in-addr.arpa",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "sortfoo.com"
},
"name": "sortfoo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "nothing.com"
},
"name": "nothing.com",
@ -15,7 +14,6 @@
"auto_dnssec": "on",
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "with.com"
},
"name": "with.com",
@ -26,7 +24,6 @@
"auto_dnssec": "off",
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "without.com"
},
"name": "without.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",
@ -38,7 +37,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "bar.foo.com"
},
"name": "bar.foo.com",
@ -65,7 +63,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.edu"
},
"name": "foo.edu",

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.net"
},
"name": "foo.net",
@ -70,7 +69,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.tld"
},
"name": "foo.tld",
@ -104,7 +102,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "bar.foo.tld"
},
"name": "bar.foo.tld",
@ -138,7 +135,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.help"
},
"name": "foo.help",
@ -181,7 +177,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "bar.foo.help"
},
"name": "bar.foo.help",
@ -224,7 +219,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.here"
},
"name": "foo.here",
@ -283,7 +277,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "example.com"
},
"name": "example.com",
@ -342,10 +335,9 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "d\u00fcsseldorf.example.net"
"dnscontrol_uniquename": "xn--dsseldorf-q9a.example.net"
},
"name": "d\u00fcsseldorf.example.net",
"name": "xn--dsseldorf-q9a.example.net",
"records": [
{
"filepos": "[line:94:5]",
@ -417,10 +409,9 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "\u00fc.example.net"
"dnscontrol_uniquename": "xn--tda.example.net"
},
"name": "\u00fc.example.net",
"name": "xn--tda.example.net",
"records": [
{
"filepos": "[line:116:5]",
@ -492,7 +483,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "example.tld"
},
"name": "example.tld",

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "domain.tld"
},
"name": "domain.tld",

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "domain.tld"
},
"name": "domain.tld",
@ -114,7 +113,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "sub.domain.tld"
},
"name": "sub.domain.tld",

View file

@ -11,7 +11,6 @@
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "3.2.1.in-addr.arpa"
},
"name": "3.2.1.in-addr.arpa",

View file

@ -11,7 +11,6 @@
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "8.9.in-addr.arpa"
},
"name": "8.9.in-addr.arpa",
@ -39,7 +38,6 @@
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "example.com"
},
"name": "example.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com",
"ns_ttl": "86400"
},
@ -15,7 +14,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "bar.com",
"ns_ttl": "300"
},

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -11,7 +11,6 @@
"Cloudflare": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -19,7 +19,6 @@
"otherconfig": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "example.com"
},
"name": "example.com",
@ -66,7 +65,8 @@
"type": "A"
}
],
"registrar": "Third-Party"
"registrar": "Third-Party",
"tag": "inside"
},
{
"dnsProviders": {
@ -86,14 +86,14 @@
"type": "A"
}
],
"registrar": "Third-Party"
"registrar": "Third-Party",
"tag": "outside"
},
{
"dnsProviders": {
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "example.net"
},
"name": "example.net",
@ -147,7 +147,8 @@
"type": "A"
}
],
"registrar": "Third-Party"
"registrar": "Third-Party",
"tag": "inside"
},
{
"dnsProviders": {
@ -181,14 +182,14 @@
"type": "A"
}
],
"registrar": "Third-Party"
"registrar": "Third-Party",
"tag": "outside"
},
{
"dnsProviders": {
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "empty.example.net"
},
"name": "empty.example.net",
@ -215,8 +216,7 @@
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "example-b.net"
"dnscontrol_uniquename": "example-b.net!"
},
"name": "example-b.net",
"records": [

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -24,7 +24,8 @@
"type": "A"
}
],
"registrar": "Third-Party"
"registrar": "Third-Party",
"tag": "external"
},
{
"dnsProviders": {
@ -51,7 +52,8 @@
"type": "A"
}
],
"registrar": "Third-Party"
"registrar": "Third-Party",
"tag": "internal"
}
],
"registrars": [

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com",
"zone_id": "Z2FTEDLFRTZ"
},
@ -37,7 +36,8 @@
"type": "R53_ALIAS"
}
],
"registrar": "none"
"registrar": "none",
"tag": "internal"
}
],
"registrars": []

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "unsafe.com"
},
"name": "unsafe.com",
@ -15,7 +14,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "safe.com"
},
"name": "safe.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "example.com"
},
"name": "example.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "a9993e364706816aba3e25717850c26c9cd0d89d"
},
"name": "a9993e364706816aba3e25717850c26c9cd0d89d",

View file

@ -11,7 +11,6 @@
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "6.10.in-addr.arpa"
},
"name": "6.10.in-addr.arpa",

View file

@ -11,7 +11,6 @@
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "d.c.b.a.1.1.0.2.ip6.arpa"
},
"name": "d.c.b.a.1.1.0.2.ip6.arpa",
@ -38,7 +37,6 @@
"bind": -1
},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "8.b.d.0.1.0.0.2.ip6.arpa"
},
"name": "8.b.d.0.1.0.0.2.ip6.arpa",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -4,7 +4,6 @@
{
"dnsProviders": {},
"meta": {
"dnscontrol_tag": "",
"dnscontrol_uniquename": "foo.com"
},
"name": "foo.com",

View file

@ -32,6 +32,10 @@ func TestImportTransform(t *testing.T) {
cfg := &models.DNSConfig{
Domains: []*models.DomainConfig{src, dst},
}
err := cfg.PostProcess()
if err != nil {
t.Fatal(err)
}
if errs := ValidateAndNormalizeConfig(cfg); len(errs) != 0 {
for _, err := range errs {
t.Error(err)

View file

@ -584,10 +584,6 @@ func ValidateAndNormalizeConfig(config *models.DNSConfig) (errs []error) {
// processSplitHorizonDomains finds "domain.tld!tag" domains and pre-processes them.
func processSplitHorizonDomains(config *models.DNSConfig) error {
// Parse out names and tags.
for _, d := range config.Domains {
d.UpdateSplitHorizonNames()
}
// Verify uniquenames are unique
seen := map[string]bool{}

View file

@ -13,7 +13,7 @@ import (
// CLI is an abstraction around the CLI.
type CLI interface {
Printer
StartDomain(domain string)
StartDomain(dc *models.DomainConfig)
StartDNSProvider(name string, skip bool)
EndProvider(name string, numCorrections int, err error)
EndProvider2(name string, numCorrections int)
@ -89,8 +89,12 @@ type ConsolePrinter struct {
}
// StartDomain is called at the start of each domain.
func (c ConsolePrinter) StartDomain(domain string) {
fmt.Fprintf(c.Writer, "******************** Domain: %s\n", domain)
func (c ConsolePrinter) StartDomain(dc *models.DomainConfig) {
if dc.Name == dc.NameUnicode {
fmt.Fprintf(c.Writer, "******************** Domain: %s\n", dc.Name)
} else {
fmt.Fprintf(c.Writer, "******************** Domain: %s (%s)\n", dc.NameUnicode, dc.Name)
}
}
// PrintCorrection is called to print/format each correction.

View file

@ -24,6 +24,7 @@ import (
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/bindserial"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
"github.com/StackExchange/dnscontrol/v4/pkg/prettyzone"
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
"github.com/StackExchange/dnscontrol/v4/providers"
@ -167,20 +168,23 @@ func (c *bindProvider) GetZoneRecords(domain string, meta map[string]string) (mo
if _, err := os.Stat(c.directory); os.IsNotExist(err) {
printer.Printf("\nWARNING: BIND directory %q does not exist! (will create)\n", c.directory)
}
_, okTag := meta[models.DomainTag]
_, okUnique := meta[models.DomainUniqueName]
if !okTag && !okUnique {
// This layering violation is needed for tests only.
// Otherwise, this is set already.
// Note: In this situation there is no "uniquename" or "tag".
zonefile = filepath.Join(c.directory,
makeFileName(c.filenameformat, domain, domain, ""))
} else {
zonefile = filepath.Join(c.directory,
makeFileName(c.filenameformat,
meta[models.DomainUniqueName], domain, meta[models.DomainTag]),
)
ff := domaintags.DomainFixedForms{
Tag: meta[models.DomainTag],
NameRaw: meta[models.DomainNameRaw],
NameIDN: domain,
NameUnicode: meta[models.DomainNameUnicode],
UniqueName: meta[models.DomainUniqueName],
}
zonefile = filepath.Join(c.directory,
makeFileName(
c.filenameformat,
ff,
),
)
//fmt.Printf("DEBUG: Reading zonefile %q\n", zonefile)
//fmt.Printf("DEBUG: Meta %+v\n", meta)
//fmt.Printf("DEBUG: Domain Names %+v\n", ff)
content, err := os.ReadFile(zonefile)
if os.IsNotExist(err) {
// If the file doesn't exist, that's not an error. Just informational.
@ -273,8 +277,16 @@ func (c *bindProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, foundR
}
zonefile = filepath.Join(c.directory,
makeFileName(c.filenameformat,
dc.Metadata[models.DomainUniqueName], dc.Name, dc.Metadata[models.DomainTag]),
makeFileName(
c.filenameformat,
domaintags.DomainFixedForms{
Tag: dc.Tag,
NameRaw: dc.NameRaw,
NameIDN: dc.Name,
NameUnicode: dc.NameUnicode,
UniqueName: dc.UniqueName,
},
),
)
// We only change the serial number if there is a change.

View file

@ -3,18 +3,23 @@ package bind
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
)
// makeFileName uses format to generate a zone's filename. See the
func makeFileName(format, uniquename, domain, tag string) string {
// fmt.Printf("DEBUG: makeFileName(%q, %q, %q, %q)\n", format, uniquename, domain, tag)
func makeFileName(format string, ff domaintags.DomainFixedForms) string {
//fmt.Printf("DEBUG: makeFileName(%q, %+v)\n", format, ff)
nameRaw := ff.NameRaw
nameIDN := ff.NameIDN
nameUnicode := ff.NameUnicode
uniquename := ff.UniqueName
tag := ff.Tag
if format == "" {
fmt.Fprintf(os.Stderr, "BUG: makeFileName called with null format\n")
return uniquename
panic("BUG: makeFileName called with null format")
}
var b bytes.Buffer
@ -36,11 +41,17 @@ func makeFileName(format, uniquename, domain, tag string) string {
tok = tokens[pos]
switch tok {
case "D":
b.WriteString(domain)
b.WriteString(nameRaw)
case "T":
b.WriteString(tag)
case "U":
b.WriteString(uniquename)
case "I":
b.WriteString(nameIDN)
case "N":
b.WriteString(nameUnicode)
case "%":
b.WriteString("%")
case "?":
if pos == lastpos {
b.WriteString("%(format may not end in %?)")

View file

@ -3,12 +3,25 @@ package bind
import (
"reflect"
"testing"
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
)
func Test_makeFileName(t *testing.T) {
uu := "uni"
dd := "domy"
tt := "tagy"
ff := domaintags.DomainFixedForms{
NameRaw: "raw",
NameIDN: "idn",
NameUnicode: "unicode",
UniqueName: "unique!taga",
Tag: "tagb",
}
tagless := domaintags.DomainFixedForms{
NameRaw: "raw",
NameIDN: "idn",
NameUnicode: "unicode",
UniqueName: "unique",
Tag: "",
}
fmtDefault := "%U.zone"
fmtBasic := "%U - %T - %D"
fmtBk1 := "db_%U" // Something I've seen in books on DNS
@ -20,34 +33,41 @@ func Test_makeFileName(t *testing.T) {
type args struct {
format string
uniquename string
domain string
tag string
ff domaintags.DomainFixedForms
}
tests := []struct {
name string
args args
want string
}{
{"literal", args{"literal", uu, dd, tt}, "literal"},
{"basic", args{fmtBasic, uu, dd, tt}, "uni - tagy - domy"},
{"solo", args{"%D", uu, dd, tt}, "domy"},
{"front", args{"%Daaa", uu, dd, tt}, "domyaaa"},
{"tail", args{"bbb%D", uu, dd, tt}, "bbbdomy"},
{"def", args{fmtDefault, uu, dd, tt}, "uni.zone"},
{"bk1", args{fmtBk1, uu, dd, tt}, "db_uni"},
{"bk2", args{fmtBk2, uu, dd, tt}, "db_tagy_domy"},
{"fanWI", args{fmtFancy, uu, dd, tt}, "tagy_domy.zone"},
{"fanWO", args{fmtFancy, uu, dd, ""}, "domy.zone"},
{"errP", args{fmtErrorPct, uu, dd, tt}, "literal%(format may not end in %)"},
{"errQ", args{fmtErrorOpt, uu, dd, tt}, "literal%(format may not end in %?)"},
{"errU", args{fmtErrorUnk, uu, dd, tt}, "literal%(unknown %verb %o)"},
{"literal", args{"literal", ff}, "literal"},
{"middle", args{"mid%Dle", ff}, "midrawle"},
{"D", args{"%D", ff}, "raw"},
{"I", args{"%I", ff}, "idn"},
{"N", args{"%N", ff}, "unicode"},
{"T", args{"%T", ff}, "tagb"},
{"x1", args{"XX%?xYY", ff}, "XXxYY"},
{"x2", args{"AA%?xBB", tagless}, "AABB"},
{"U", args{"%U", ff}, "unique!taga"},
{"percent", args{"%%", ff}, "%"},
//
{"default", args{fmtDefault, ff}, "unique!taga.zone"},
{"basic", args{fmtBasic, ff}, "unique!taga - tagb - raw"},
{"front", args{"%Daaa", ff}, "rawaaa"},
{"tail", args{"bbb%D", ff}, "bbbraw"},
{"bk1", args{fmtBk1, ff}, "db_unique!taga"},
{"bk2", args{fmtBk2, ff}, "db_tagb_raw"},
{"fanWI", args{fmtFancy, ff}, "tagb_raw.zone"},
{"fanWO", args{fmtFancy, tagless}, "raw.zone"},
{"errP", args{fmtErrorPct, ff}, "literal%(format may not end in %)"},
{"errQ", args{fmtErrorOpt, ff}, "literal%(format may not end in %?)"},
{"errU", args{fmtErrorUnk, ff}, "literal%(unknown %verb %o)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := makeFileName(tt.args.format, tt.args.uniquename, tt.args.domain, tt.args.tag); got != tt.want {
t.Errorf("makeFileName() = %v, want %v", got, tt.want)
if got := makeFileName(tt.args.format, tt.args.ff); got != tt.want {
t.Errorf("makeFileName(%q) = %q, want %q", tt.args.format, got, tt.want)
}
})
}

View file

@ -58,7 +58,7 @@ func (dsp *powerdnsProvider) getDiff2DomainCorrections(dc *models.DomainConfig,
}
}
domainVariant := dsp.zoneName(dc.Name, dc.Metadata[models.DomainTag])
domainVariant := dsp.zoneName(dc.Name, dc.Tag)
// only append a Correction if there are any, otherwise causes an error when sending an empty rrset
if len(rrDeleteSets) > 0 {

View file

@ -10,7 +10,7 @@ import (
// getDNSSECCorrections returns corrections that update a domain's DNSSEC state.
func (dsp *powerdnsProvider) getDNSSECCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
domainVariant := dsp.zoneName(dc.Name, dc.Metadata[models.DomainTag])
domainVariant := dsp.zoneName(dc.Name, dc.Tag)
zoneCryptokeys, getErr := dsp.client.Cryptokeys().ListCryptokeys(context.Background(), dsp.ServerName, domainVariant)
if getErr != nil {
if _, ok := getErr.(pdnshttp.ErrNotFound); ok {