diff --git a/documentation/provider/bind.md b/documentation/provider/bind.md index 5136e0795..54159098d 100644 --- a/documentation/provider/bind.md +++ b/documentation/provider/bind.md @@ -22,13 +22,18 @@ Example: { "bind": { "TYPE": "BIND", - "directory": "myzones", - "filenameformat": "%U.zone" + "directory": "myzones" } } ``` {% endcode %} +As of 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 +`directory` setting and the `filenameformat` setting. For security reasons, the +automatic creation of subdirectories is disabled if `dnscontrol` is running as +root. (Running DNSControl as root is not recommended in general.) + ## Meta configuration This provider accepts some optional metadata in the `NewDnsProvider()` call. @@ -85,43 +90,59 @@ DNSControl does not handle special serial number math such as "looping through z # filenameformat The `filenameformat` parameter specifies the file name to be used when -writing the zone file. The default (`%U.zone`) is acceptable in most cases: the +writing the zone file. The default (`%c.zone`) is acceptable in most cases: the file name is the name as specified in the `D()` function plus ".zone". The filenameformat is a string with a few printf-like `%` verbs: - * 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 `%?` +| Verb | Description | `EXAMple.com` | `EXAMple.com!MyTag` | `рф.com!myTag` | +| ------- | ------------------------------------------------- | ------------- | ------------------- | -------------------- | +| `%T` | the tag | "" (null) | `myTag` | `myTag` | +| `%c` | canonical name, globally unique and comparable | `example.com` | `example.com!myTag` | `xn--p1ai.com!myTag` | +| `%a` | ASCII domain (Punycode, downcased) | `example.com` | `example.com` | `xn--p1ai.com` | +| `%u` | Unicode domain (non-Unicode parts downcased) | `example.com` | `example.com` | `рф.com` | +| `%r` | Raw (unmodified) Domain from `D()` (risky!) | `EXAMple.com` | `EXAMple.com` | `рф.com` | +| `%f` | like `%c` but better for filenames (`%a%?_%T`) | `example.com` | `example.com_myTag` | `xn--p1ai.com_myTag` | +| `%F` | like `%f` but reversed order (`%T%?_%a`) | `example.com` | `myTag_example.com` | `myTag_xn--p1ai.com` | +| `%?x` | returns `x` if tag exists, otherwise "" | "" (null) | `x` | `x` | +| `%%` | a literal percent sign | `%` | `%` | `%` | +| `a-Z./` | other printable characters are copied exactly | `a-Z./` | `a-Z./` | `a-Z./` | +| `%U` | (deprecated, use `%c`) Same as `%D%?!%T` (risky!) | `example.com` | `example.com!myTag` | `рф.com!myTag` | +| `%D` | (deprecated, use `%r`) mangles Unicode (risky!) | `example.com` | `example.com` | `рф.com` | -Typical values: +* `%?x` is typically used to generate an optional `!` or `_` if there is a tag. +* `%r` is considered "risky" because it can produce a domain name that is not + canonical. For example, if you use `D("FOO.com")` and later change it to `D("foo.com")`, your file names will change. +* Format strings must not end with an incomplete `%` or `%?` +* Generating a filename without a tag is risky. For example, if the same + `dnsconfig.js` has `D("example.com!inside", DSP_BIND)` and + `D("example.com!outside", DSP_BIND)`, both will use the same filename. + DNSControl will write both zone files to the same file, flapping between the + two. No error or warning will be output. - * `%U.zone` (The default) - * `example.com.zone` or `example.com!tag.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` +Useful examples: -{% 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 %} +| Verb | Description | `EXAMple.com` | `EXAMple.com!MyTag` | `рф.com!myTag` | +| ------------ | ----------------------------------- | ------------------ | ------------------------ | ------------------------- | +| `%c.zone` | Default format (v4.28 and later) | `example.com.zone` | `example.com!myTag.zone` | `xn--p1ai.com!myTag.zone` | +| `%U.zone` | Default format (pre-v4.28) (risky!) | `example.com.zone` | `example.com!myTag.zone` | `рф.com!myTag.zone` | +| `db_%f` | Recommended in a popular DNS book | `db_example.com` | `db_example.com_myTag` | `db_xn--p1ai.com_myTag` | +| `db_%a%?_%T` | same as above but using `%?_` | `db_example.com` | `db_example.com_myTag` | `db_xn--p1ai.com_myTag` | -(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 -`directory` setting and the `filenameformat` setting. The automatic creation of -subdirectories is disabled if `dnscontrol` is running as root for security -reasons. +Compatibility notes: + +* `%D` should not be used. It downcases the string in a way that is probably + incompatible with Unicode characters. It is retained for compatibility with + pre-v4.28 releases. If your domain has capital Unicode characters, backwards + compatibility is not guaranteed. Use `%r` instead. +* `%U` relies on `%D` which is deprecated. Use `%c` instead. +* As of v4.28 the default format string changed from `%U.zone` to `%c.zone`. This + should only matter if your `D()` statements included non-ASCII (Unicode) + runes that were capitalized. +* If you are using pre-v4.28 releases the above table is slightly misleading + because uppercase ASCII letters do not always work. If you are using + pre-v4.28 releases, assume the above table lists `example.com` instead + of `EXAMpl.com`. # FYI: get-zones @@ -132,7 +153,8 @@ any files named `*.zone` and assumes they are zone files. dnscontrol get-zones --format=nameonly - BIND all ``` -If `filenameformat` is defined, `dnscontrol` makes a guess at which -filenames are zones but doesn't try to hard to get it right, which is -mathematically impossible in some cases. Feel free to file an issue if -your format string doesn't work. I love a challenge! +If `filenameformat` is defined, `dnscontrol` makes a guess at which filenames +are zones by reversing the logic of the format string. It doesn't try very hard +to get this right, as getting it right in all situations is mathematically +impossible. Feel free to file an issue if find a situation where it doesn't +work. I love a challenge! diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index 2211b8aaa..73e64a63f 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -2034,11 +2034,6 @@ func makeTests() []*TestGroup { ), ), - // This MUST be the last test. - testgroup("final", - tc("final", txt("final", `TestDNSProviders was successful!`)), - ), - testgroup("SMIMEA", requires(providers.CanUseSMIMEA), tc("SMIMEA record", smimea("_443._tcp", 3, 1, 1, sha256hash)), @@ -2061,6 +2056,12 @@ func makeTests() []*TestGroup { // every quarter. There may be library updates, API changes, // etc. + // This SHOULD be the last test. We do this so that we always + // leave zones with a single TXT record exclaming our success. + // Nothing depends on this record existing or should depend on it. + testgroup("final", + tc("final", txt("final", `TestDNSProviders was successful!`)), + ), } return tests diff --git a/models/domain.go b/models/domain.go index e45b1fbb8..8988da469 100644 --- a/models/domain.go +++ b/models/domain.go @@ -13,7 +13,7 @@ const ( DomainTag = "dnscontrol_tag" // A copy of DomainConfig.Tag DomainUniqueName = "dnscontrol_uniquename" // A copy of DomainConfig.UniqueName DomainNameRaw = "dnscontrol_nameraw" // A copy of DomainConfig.NameRaw - DomainNameIDN = "dnscontrol_nameidn" // A copy of DomainConfig.NameIDN + DomainNameASCII = "dnscontrol_nameascii" // A copy of DomainConfig.NameASCII DomainNameUnicode = "dnscontrol_nameunicode" // A copy of DomainConfig.NameUnicode ) @@ -74,7 +74,7 @@ func (dc *DomainConfig) PostProcess() { // 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 + dc.Tag, dc.NameRaw, dc.Name, dc.NameUnicode, dc.UniqueName = ff.Tag, ff.NameRaw, ff.NameASCII, ff.NameUnicode, ff.UniqueName // Store the FixForms is Metadata so we don't have to change the signature of every function that might need them. // This is a bit ugly but avoids a huge refactor. Please avoid using these to make the future refactor easier. @@ -82,7 +82,7 @@ func (dc *DomainConfig) PostProcess() { dc.Metadata[DomainTag] = dc.Tag } //dc.Metadata[DomainNameRaw] = dc.NameRaw - //dc.Metadata[DomainNameIDN] = dc.Name + //dc.Metadata[DomainNameASCII] = dc.Name //dc.Metadata[DomainNameUnicode] = dc.NameUnicode dc.Metadata[DomainUniqueName] = dc.UniqueName } diff --git a/pkg/domaintags/domaintags.go b/pkg/domaintags/domaintags.go index adf07d810..1d933bf9e 100644 --- a/pkg/domaintags/domaintags.go +++ b/pkg/domaintags/domaintags.go @@ -9,7 +9,7 @@ import ( // 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" + NameASCII string // "punycode.com" NameUnicode string // "unicode.com" (converted to downcase BEFORE unicode conversion) UniqueName string // "punycode.com!tag" @@ -25,7 +25,7 @@ type DomainFixedForms struct { // * .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 tag, nameRaw, nameASCII, nameUnicode, uniqueName string var hasBang bool // Split tag from name. @@ -38,42 +38,45 @@ func MakeDomainFixForms(n string) DomainFixedForms { hasBang = false } - nameRaw = strings.ToLower(p[0]) + nameRaw = p[0] if strings.HasPrefix(n, nameRaw) { // Avoid pointless duplication. nameRaw = n[0:len(nameRaw)] } - nameIDN, err = idna.ToASCII(nameRaw) + nameASCII, err = idna.ToASCII(nameRaw) if err != nil { - nameIDN = nameRaw // Fallback to raw name on error. + nameASCII = nameRaw // Fallback to raw name on error. } else { + nameASCII = strings.ToLower(nameASCII) // Avoid pointless duplication. - if nameIDN == nameRaw { - nameIDN = nameRaw + if strings.HasPrefix(n, nameASCII) { + // Avoid pointless duplication. + nameASCII = n[0:len(nameASCII)] } } - nameUnicode, err = idna.ToUnicode(nameRaw) + nameUnicode, err = idna.ToUnicode(nameASCII) // We use nameASCII since it is already lowercased. if err != nil { nameUnicode = nameRaw // Fallback to raw name on error. } else { // Avoid pointless duplication. - if nameUnicode == nameRaw { - nameUnicode = nameRaw + if strings.HasPrefix(n, nameUnicode) { + // Avoid pointless duplication. + nameUnicode = n[0:len(nameUnicode)] } } if hasBang { - uniqueName = nameIDN + "!" + tag + uniqueName = nameASCII + "!" + tag } else { - uniqueName = nameIDN + uniqueName = nameASCII } return DomainFixedForms{ Tag: tag, NameRaw: nameRaw, - NameIDN: nameIDN, + NameASCII: nameASCII, NameUnicode: nameUnicode, UniqueName: uniqueName, HasBang: hasBang, diff --git a/pkg/domaintags/domaintags_test.go b/pkg/domaintags/domaintags_test.go index f6c010c33..63f1bb04d 100644 --- a/pkg/domaintags/domaintags_test.go +++ b/pkg/domaintags/domaintags_test.go @@ -10,7 +10,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input string wantTag string wantNameRaw string - wantNameIDN string + wantNameASCII string wantNameUnicode string wantUniqueName string wantHasBang bool @@ -20,7 +20,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "example.com", wantTag: "", wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com", wantHasBang: false, @@ -30,7 +30,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "example.com!mytag", wantTag: "mytag", wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com!mytag", wantHasBang: true, @@ -40,7 +40,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "example.com!", wantTag: "", wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com!", wantHasBang: true, @@ -50,7 +50,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "उदाहरण.com", wantTag: "", wantNameRaw: "उदाहरण.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com", wantHasBang: false, @@ -60,7 +60,7 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "उदाहरण.com!mytag", wantTag: "mytag", wantNameRaw: "उदाहरण.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", wantHasBang: true, @@ -70,17 +70,29 @@ func Test_MakeDomainFixForms(t *testing.T) { input: "xn--p1b6ci4b4b3a.com", wantTag: "", wantNameRaw: "xn--p1b6ci4b4b3a.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com", wantHasBang: false, }, + { + // Unicode chars should be left alone (as far as case folding goes) + // Here are some Armenian characters https://tools.lgm.cl/lettercase.html + name: "mixed case unicode", + input: "fooԷէԸըԹ.com!myTag", + wantTag: "myTag", + wantNameRaw: "fooԷէԸըԹ.com", + wantNameASCII: "xn--foo-b7dfg43aja.com", + wantNameUnicode: "fooԷէԸըԹ.com", + wantUniqueName: "xn--foo-b7dfg43aja.com!myTag", + wantHasBang: true, + }, { name: "punycode domain with tag", input: "xn--p1b6ci4b4b3a.com!mytag", wantTag: "mytag", wantNameRaw: "xn--p1b6ci4b4b3a.com", - wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameASCII: "xn--p1b6ci4b4b3a.com", wantNameUnicode: "उदाहरण.com", wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", wantHasBang: true, @@ -89,8 +101,8 @@ func Test_MakeDomainFixForms(t *testing.T) { name: "mixed case domain", input: "Example.COM", wantTag: "", - wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameRaw: "Example.COM", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com", wantHasBang: false, @@ -99,12 +111,34 @@ func Test_MakeDomainFixForms(t *testing.T) { name: "mixed case domain with tag", input: "Example.COM!MyTag", wantTag: "MyTag", - wantNameRaw: "example.com", - wantNameIDN: "example.com", + wantNameRaw: "Example.COM", + wantNameASCII: "example.com", wantNameUnicode: "example.com", wantUniqueName: "example.com!MyTag", wantHasBang: true, }, + // This is used in the documentation for the BIND provider, thus we test + // it to make sure we got it right. + { + name: "BIND example 1", + input: "рф.com!myTag", + wantTag: "myTag", + wantNameRaw: "рф.com", + wantNameASCII: "xn--p1ai.com", + wantNameUnicode: "рф.com", + wantUniqueName: "xn--p1ai.com!myTag", + wantHasBang: true, + }, + { + name: "BIND example 2", + input: "рф.com", + wantTag: "", + wantNameRaw: "рф.com", + wantNameASCII: "xn--p1ai.com", + wantNameUnicode: "рф.com", + wantUniqueName: "xn--p1ai.com", + wantHasBang: false, + }, } for _, tt := range tests { @@ -116,8 +150,8 @@ func Test_MakeDomainFixForms(t *testing.T) { 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.NameASCII != tt.wantNameASCII { + t.Errorf("MakeDomainFixForms() gotNameASCII = %v, want %v", got.NameASCII, tt.wantNameASCII) } if got.NameUnicode != tt.wantNameUnicode { t.Errorf("MakeDomainFixForms() gotNameUnicode = %v, want %v", got.NameUnicode, tt.wantNameUnicode) diff --git a/pkg/domaintags/permitlist.go b/pkg/domaintags/permitlist.go index b9e8e904b..220a2cad3 100644 --- a/pkg/domaintags/permitlist.go +++ b/pkg/domaintags/permitlist.go @@ -24,8 +24,8 @@ func CompilePermitList(s string) PermitList { continue } ff := MakeDomainFixForms(l) - if ff.HasBang && ff.NameIDN == "" { // Treat empty name as wildcard. - ff.NameIDN = "*" + if ff.HasBang && ff.NameASCII == "" { // Treat empty name as wildcard. + ff.NameASCII = "*" } sl.items = append(sl.items, ff) } @@ -65,24 +65,24 @@ func (pl *PermitList) Permitted(domToCheck string) bool { // Now that we know the tag matches, we can focus on the name. // `*!tag` or `*` matches everything. - if filterItem.NameIDN == "*" { + if filterItem.NameASCII == "*" { return true } // If the name starts with "*." then match the suffix. - if strings.HasPrefix(filterItem.NameIDN, "*.") { + if strings.HasPrefix(filterItem.NameASCII, "*.") { // example.com matches *.example.com - if domToCheckFF.NameIDN == filterItem.NameIDN[2:] || domToCheckFF.NameUnicode == filterItem.NameUnicode[2:] { + if domToCheckFF.NameASCII == filterItem.NameASCII[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:]) { + if strings.HasSuffix(domToCheckFF.NameASCII, filterItem.NameASCII[1:]) || strings.HasSuffix(domToCheckFF.NameUnicode, filterItem.NameUnicode[1:]) { return true } } // No wildcards? Exact match. - if filterItem.NameIDN == domToCheckFF.NameIDN || filterItem.NameUnicode == domToCheckFF.NameUnicode { + if filterItem.NameASCII == domToCheckFF.NameASCII || filterItem.NameUnicode == domToCheckFF.NameUnicode { return true } } diff --git a/providers/bind/bindProvider.go b/providers/bind/bindProvider.go index 525e8c74f..a71cf25d6 100644 --- a/providers/bind/bindProvider.go +++ b/providers/bind/bindProvider.go @@ -69,7 +69,7 @@ func initBind(config map[string]string, providermeta json.RawMessage) (providers api.directory = "zones" } if api.filenameformat == "" { - api.filenameformat = "%U.zone" + api.filenameformat = "%c.zone" } if len(providermeta) != 0 { err := json.Unmarshal(providermeta, api) @@ -171,7 +171,7 @@ func (c *bindProvider) GetZoneRecords(domain string, meta map[string]string) (mo ff := domaintags.DomainFixedForms{ Tag: meta[models.DomainTag], NameRaw: meta[models.DomainNameRaw], - NameIDN: domain, + NameASCII: domain, NameUnicode: meta[models.DomainNameUnicode], UniqueName: meta[models.DomainUniqueName], } @@ -282,7 +282,7 @@ func (c *bindProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, foundR domaintags.DomainFixedForms{ Tag: dc.Tag, NameRaw: dc.NameRaw, - NameIDN: dc.Name, + NameASCII: dc.Name, NameUnicode: dc.NameUnicode, UniqueName: dc.UniqueName, }, diff --git a/providers/bind/fnames.go b/providers/bind/fnames.go index db7b22514..b333e390d 100644 --- a/providers/bind/fnames.go +++ b/providers/bind/fnames.go @@ -13,11 +13,6 @@ import ( // makeFileName uses format to generate a zone's filename. See the 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 == "" { panic("BUG: makeFileName called with null format") } @@ -40,16 +35,31 @@ func makeFileName(format string, ff domaintags.DomainFixedForms) string { pos++ tok = tokens[pos] switch tok { - case "D": - b.WriteString(nameRaw) - case "T": - b.WriteString(tag) - case "U": - b.WriteString(uniquename) - case "I": - b.WriteString(nameIDN) - case "N": - b.WriteString(nameUnicode) + + // v4.28 names + case "r": // NameRaw "originalinput.com" (i for input) + b.WriteString(ff.NameRaw) + case "a": // NameASCII "punycode.com" (a for ascii) + b.WriteString(ff.NameASCII) + case "u": // NameUnicode "unicode.com" (u for unicode) + b.WriteString(ff.NameUnicode) + case "c": // UniqueName "punycode.com!tag" or "punycode.com" if no tag (c for canonical) + b.WriteString(ff.UniqueName) + case "f": // + b.WriteString(ff.NameASCII) + if ff.Tag != "" { + b.WriteString("_") + b.WriteString(ff.Tag) + } + case "F": // + if ff.Tag != "" { + b.WriteString(ff.Tag) + b.WriteString("_") + } + b.WriteString(ff.NameASCII) + case "T": // Tag The tag portion of `example.com!tag` + b.WriteString(ff.Tag) + case "%": b.WriteString("%") case "?": @@ -59,9 +69,20 @@ func makeFileName(format string, ff domaintags.DomainFixedForms) string { } pos++ tok = tokens[pos] - if tag != "" { + if ff.Tag != "" { b.WriteString(tok) } + + // Legacy names kept for compatibility + case "U": // the domain name as specified in `D()` + b.WriteString(strings.ToLower(ff.NameRaw)) + if ff.Tag != "" { + b.WriteString("!") + b.WriteString(ff.Tag) + } + case "D": // domain (without tag) as specified in D() (no IDN conversion, but downcased) + b.WriteString(strings.ToLower(ff.NameRaw)) + default: fmt.Fprintf(&b, "%%(unknown %%verb %%%s)", tok) } @@ -156,14 +177,14 @@ func makeExtractor(format string) (string, error) { pos++ tok = tokens[pos] switch tok { - case "D": + case "D", "a", "u", "r": b.WriteString(`(.*)`) case "T": if pass == 0 { // On the second pass, nothing is generated. b.WriteString(`.*`) } - case "U": + case "U", "c": if pass == 0 { b.WriteString(`(.*)!.+`) } else { diff --git a/providers/bind/fnames_test.go b/providers/bind/fnames_test.go index 66a0bcb8a..b37d2a58c 100644 --- a/providers/bind/fnames_test.go +++ b/providers/bind/fnames_test.go @@ -8,29 +8,28 @@ import ( ) func Test_makeFileName(t *testing.T) { - 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 - fmtBk2 := "db_%T_%D" // Something I've seen in books on DNS fmtFancy := "%T%?_%D.zone" // Include the tag_ only if there is a tag fmtErrorPct := "literal%" fmtErrorOpt := "literal%?" fmtErrorUnk := "literal%o" // Unknown % verb + ff := domaintags.DomainFixedForms{ + NameRaw: "domy", + NameASCII: "idn", + NameUnicode: "uni", + UniqueName: "unique!taga", + Tag: "tagy", + } + tagless := domaintags.DomainFixedForms{ + NameRaw: "domy", + NameASCII: "idn", + NameUnicode: "uni", + UniqueName: "unique", + Tag: "", + } + type args struct { format string ff domaintags.DomainFixedForms @@ -40,25 +39,15 @@ func Test_makeFileName(t *testing.T) { args args want string }{ + // Test corner cases and common cases. {"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"}, + {"basic", args{fmtBasic, ff}, "domy!tagy - tagy - domy"}, + {"solo", args{"%D", ff}, "domy"}, + {"front", args{"%Daaa", ff}, "domyaaa"}, + {"tail", args{"bbb%D", ff}, "bbbdomy"}, + {"def", args{fmtDefault, ff}, "domy!tagy.zone"}, + {"fanWI", args{fmtFancy, ff}, "tagy_domy.zone"}, + {"fanWO", args{fmtFancy, tagless}, "domy.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)"}, @@ -73,6 +62,66 @@ func Test_makeFileName(t *testing.T) { } } +func Test_makeFileName_2(t *testing.T) { + ff1 := domaintags.MakeDomainFixForms(`EXAMple.com`) + ff2 := domaintags.MakeDomainFixForms(`EXAMple.com!myTag`) + ff3 := domaintags.MakeDomainFixForms(`рф.com!myTag`) + + tests := []struct { + name string + format string + want1 string + want2 string + want3 string + descr string // Not used in test, just for documentation generation + }{ + // NOTE: "Domain" in these descriptions means the domain name without any split horizon tag. Technically the "Zone". + {`T`, `%T`, ``, `myTag`, `myTag`, `the tag`}, + {`c`, `%c`, `example.com`, `example.com!myTag`, `xn--p1ai.com!myTag`, `canonical name, globally unique and comparable`}, + {`a`, `%a`, `example.com`, `example.com`, `xn--p1ai.com`, `ASCII domain (Punycode, downcased)`}, + {`u`, `%u`, `example.com`, `example.com`, `рф.com`, `Unicode domain (non-Unicode parts downcased)`}, + {`r`, `%r`, `EXAMple.com`, `EXAMple.com`, `рф.com`, "Raw (unmodified) Domain from `D()` (risky!)"}, + {`f`, `%f`, `example.com`, `example.com_myTag`, `xn--p1ai.com_myTag`, "like `%c` but better for filenames (`%a%?_%T`)"}, + {`F`, `%F`, `example.com`, `myTag_example.com`, `myTag_xn--p1ai.com`, "like `%f` but reversed order (`%T%?_%a`)"}, + {`%?x`, `%?x`, ``, `x`, `x`, "returns `x` if tag exists, otherwise \"\""}, + {`%`, `%%`, `%`, `%`, `%`, `a literal percent sign`}, + + // Pre-v4.28 names kept for compatibility (note: pre v4.28 did not permit mixed case domain names, we downcased them here for the tests) + {`U`, `%U`, `example.com`, `example.com!myTag`, `рф.com!myTag`, "(deprecated, use `%c`) Same as `%D%?!%T` (risky!)"}, + {`D`, `%D`, `example.com`, `example.com`, `рф.com`, "(deprecated, use `%r`) mangles Unicode (risky!)"}, + {`%T%?_%D.zone`, `%T%?_%D.zone`, `example.com.zone`, `myTag_example.com.zone`, `myTag_рф.com.zone`, `mentioned in the docs`}, + {`db_%T%?_%D`, `db_%T%?_%D`, `db_example.com`, `db_myTag_example.com`, `db_myTag_рф.com`, `mentioned in the docs`}, + {`db_%D`, `db_%D`, `db_example.com`, `db_example.com`, `db_рф.com`, `mentioned in the docs`}, + + // Examples used in the documentation for the BIND provider + {`%c.zone`, `%c.zone`, "example.com.zone", "example.com!myTag.zone", "xn--p1ai.com!myTag.zone", "Default format (v4.28 and later)"}, + {`%U.zone`, `%U.zone`, `example.com.zone`, `example.com!myTag.zone`, `рф.com!myTag.zone`, "Default format (pre-v4.28) (risky!)"}, + {`db_%f`, `db_%f`, `db_example.com`, `db_example.com_myTag`, `db_xn--p1ai.com_myTag`, "Recommended in a popular DNS book"}, + {`db_a%?_%T`, `db_%a%?_%T`, `db_example.com`, `db_example.com_myTag`, `db_xn--p1ai.com_myTag`, "same as above but using `%?_`"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got1 := makeFileName(tt.format, ff1) + if got1 != tt.want1 { + t.Errorf("makeFileName(%q) = ff1 %q, want %q", tt.format, got1, tt.want1) + } + got2 := makeFileName(tt.format, ff2) + if got2 != tt.want2 { + t.Errorf("makeFileName(%q) = ff2 %q, want %q", tt.format, got2, tt.want2) + } + got3 := makeFileName(tt.format, ff3) + if got3 != tt.want3 { + t.Errorf("makeFileName(%q) = ff3 %q, want %q", tt.format, got3, tt.want3) + } + //Uncomment to regenerate lines used in documentation/provider/bind.md 's table: + // fmt.Print(strings.ReplaceAll(fmt.Sprintf("| `%s` | %s | `%s` | `%s` | `%s` |\n", tt.format, tt.descr, got1, got2, got3), "``", "`\"\"` (null)")) + //Uncomment to regenerate the above test cases: + // fmt.Printf("{`%s`, `%s`, `%s`, `%s`, `%s`, %q},\n", tt.name, tt.format, got1, got2, got3, tt.descr) + }) + } +} + func Test_makeExtractor(t *testing.T) { type args struct { format string @@ -83,6 +132,10 @@ func Test_makeExtractor(t *testing.T) { want string wantErr bool }{ + {"c", args{"%c.zone"}, `(.*)!.+\.zone|(.*)\.zone`, false}, + {"a", args{"%a.zone"}, `(.*)\.zone`, false}, + {"u", args{"%u.zone"}, `(.*)\.zone`, false}, + {"r", args{"%r.zone"}, `(.*)\.zone`, false}, // TODO: Add test cases. {"u", args{"%U.zone"}, `(.*)!.+\.zone|(.*)\.zone`, false}, {"d", args{"%D.zone"}, `(.*)\.zone`, false},