FEATURE: Fixing IDN support for domains (#3879)

# Issue

The previous fix had backwards compatibility issues and treated
uppercase Unicode incorrectly.

# Resolution

* Don't call strings.ToUpper() on Unicode strings. Only call it on the
output of ToASCII.
* Fix BIND's "filenameformat" to be more compatible (only breaks if you
had uppercase unicode in a domain name... which you probably didn't)
* Change IDN to ASCII in most places (Thanks for the suggestion,
@KaiSchwarz-cnic!)
* Update BIND documentation
This commit is contained in:
Tom Limoncelli 2025-12-03 20:31:59 -05:00 committed by Thomas Limoncelli
parent 9192704cb2
commit 55260726c0
No known key found for this signature in database
9 changed files with 266 additions and 132 deletions

View file

@ -22,13 +22,18 @@ Example:
{ {
"bind": { "bind": {
"TYPE": "BIND", "TYPE": "BIND",
"directory": "myzones", "directory": "myzones"
"filenameformat": "%U.zone"
} }
} }
``` ```
{% endcode %} {% 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 ## Meta configuration
This provider accepts some optional metadata in the `NewDnsProvider()` call. 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 # filenameformat
The `filenameformat` parameter specifies the file name to be used when 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". 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 filenameformat is a string with a few printf-like `%` verbs:
* The domain name without tag (the `example.com` part of `example.com!tag`): | Verb | Description | `EXAMple.com` | `EXAMple.com!MyTag` | `рф.com!myTag` |
* `%D` as specified in `D()` (no IDN conversion, but downcased) | ------- | ------------------------------------------------- | ------------- | ------------------- | -------------------- |
* `%I` converted to IDN/Punycode (`xn--...`) and downcased. | `%T` | the tag | "" (null) | `myTag` | `myTag` |
* `%N` converted to Unicode (downcased first) | `%c` | canonical name, globally unique and comparable | `example.com` | `example.com!myTag` | `xn--p1ai.com!myTag` |
* `%T` the split horizon tag, or "" (the `tag` part of `example.com!tag`) | `%a` | ASCII domain (Punycode, downcased) | `example.com` | `example.com` | `xn--p1ai.com` |
* `%?x` this returns `x` if the split horizon tag is non-null, otherwise nothing. `x` can be any printable but is usually `!`. | `%u` | Unicode domain (non-Unicode parts downcased) | `example.com` | `example.com` | `рф.com` |
* `%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. | `%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` |
* ordinary characters (not `%`) are copied unchanged to the output stream | `%F` | like `%f` but reversed order (`%T%?_%a`) | `example.com` | `myTag_example.com` | `myTag_xn--p1ai.com` |
* FYI: format strings must not end with an incomplete `%` or `%?` | `%?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) Useful examples:
* `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`
{% hint style="warning" %} | Verb | Description | `EXAMple.com` | `EXAMple.com!MyTag` | `рф.com!myTag` |
**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 | `%c.zone` | Default format (v4.28 and later) | `example.com.zone` | `example.com!myTag.zone` | `xn--p1ai.com!myTag.zone` |
flapping back and forth between the two. The best way to prevent this is to | `%U.zone` | Default format (pre-v4.28) (risky!) | `example.com.zone` | `example.com!myTag.zone` | `рф.com!myTag.zone` |
always include the tag (`%T`) or use `%U` which includes the tag. | `db_%f` | Recommended in a popular DNS book | `db_example.com` | `db_example.com_myTag` | `db_xn--p1ai.com_myTag` |
{% endhint %} | `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 Compatibility notes:
the filename. This includes both the portion of the path created by the
`directory` setting and the `filenameformat` setting. The automatic creation of * `%D` should not be used. It downcases the string in a way that is probably
subdirectories is disabled if `dnscontrol` is running as root for security incompatible with Unicode characters. It is retained for compatibility with
reasons. 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 # FYI: get-zones
@ -132,7 +153,8 @@ any files named `*.zone` and assumes they are zone files.
dnscontrol get-zones --format=nameonly - BIND all dnscontrol get-zones --format=nameonly - BIND all
``` ```
If `filenameformat` is defined, `dnscontrol` makes a guess at which If `filenameformat` is defined, `dnscontrol` makes a guess at which filenames
filenames are zones but doesn't try to hard to get it right, which is are zones by reversing the logic of the format string. It doesn't try very hard
mathematically impossible in some cases. Feel free to file an issue if to get this right, as getting it right in all situations is mathematically
your format string doesn't work. I love a challenge! impossible. Feel free to file an issue if find a situation where it doesn't
work. I love a challenge!

View file

@ -2034,11 +2034,6 @@ func makeTests() []*TestGroup {
), ),
), ),
// This MUST be the last test.
testgroup("final",
tc("final", txt("final", `TestDNSProviders was successful!`)),
),
testgroup("SMIMEA", testgroup("SMIMEA",
requires(providers.CanUseSMIMEA), requires(providers.CanUseSMIMEA),
tc("SMIMEA record", smimea("_443._tcp", 3, 1, 1, sha256hash)), 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, // every quarter. There may be library updates, API changes,
// etc. // 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 return tests

View file

@ -13,7 +13,7 @@ const (
DomainTag = "dnscontrol_tag" // A copy of DomainConfig.Tag DomainTag = "dnscontrol_tag" // A copy of DomainConfig.Tag
DomainUniqueName = "dnscontrol_uniquename" // A copy of DomainConfig.UniqueName DomainUniqueName = "dnscontrol_uniquename" // A copy of DomainConfig.UniqueName
DomainNameRaw = "dnscontrol_nameraw" // A copy of DomainConfig.NameRaw 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 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. // Turn the user-supplied name into the fixed forms.
ff := domaintags.MakeDomainFixForms(dc.Name) 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. // 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. // 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[DomainTag] = dc.Tag
} }
//dc.Metadata[DomainNameRaw] = dc.NameRaw //dc.Metadata[DomainNameRaw] = dc.NameRaw
//dc.Metadata[DomainNameIDN] = dc.Name //dc.Metadata[DomainNameASCII] = dc.Name
//dc.Metadata[DomainNameUnicode] = dc.NameUnicode //dc.Metadata[DomainNameUnicode] = dc.NameUnicode
dc.Metadata[DomainUniqueName] = dc.UniqueName dc.Metadata[DomainUniqueName] = dc.UniqueName
} }

View file

@ -9,7 +9,7 @@ import (
// DomainFixedForms stores the various fixed forms of a domain name and tag. // DomainFixedForms stores the various fixed forms of a domain name and tag.
type DomainFixedForms struct { type DomainFixedForms struct {
NameRaw string // "originalinput.com" (name as input by the user, lowercased (no tag)) 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) NameUnicode string // "unicode.com" (converted to downcase BEFORE unicode conversion)
UniqueName string // "punycode.com!tag" UniqueName string // "punycode.com!tag"
@ -25,7 +25,7 @@ type DomainFixedForms struct {
// * .UniqueName: "example.com!tag" unique across the entire config. // * .UniqueName: "example.com!tag" unique across the entire config.
func MakeDomainFixForms(n string) DomainFixedForms { func MakeDomainFixForms(n string) DomainFixedForms {
var err error var err error
var tag, nameRaw, nameIDN, nameUnicode, uniqueName string var tag, nameRaw, nameASCII, nameUnicode, uniqueName string
var hasBang bool var hasBang bool
// Split tag from name. // Split tag from name.
@ -38,42 +38,45 @@ func MakeDomainFixForms(n string) DomainFixedForms {
hasBang = false hasBang = false
} }
nameRaw = strings.ToLower(p[0]) nameRaw = p[0]
if strings.HasPrefix(n, nameRaw) { if strings.HasPrefix(n, nameRaw) {
// Avoid pointless duplication. // Avoid pointless duplication.
nameRaw = n[0:len(nameRaw)] nameRaw = n[0:len(nameRaw)]
} }
nameIDN, err = idna.ToASCII(nameRaw) nameASCII, err = idna.ToASCII(nameRaw)
if err != nil { if err != nil {
nameIDN = nameRaw // Fallback to raw name on error. nameASCII = nameRaw // Fallback to raw name on error.
} else { } else {
nameASCII = strings.ToLower(nameASCII)
// Avoid pointless duplication. // Avoid pointless duplication.
if nameIDN == nameRaw { if strings.HasPrefix(n, nameASCII) {
nameIDN = nameRaw // 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 { if err != nil {
nameUnicode = nameRaw // Fallback to raw name on error. nameUnicode = nameRaw // Fallback to raw name on error.
} else { } else {
// Avoid pointless duplication. // Avoid pointless duplication.
if nameUnicode == nameRaw { if strings.HasPrefix(n, nameUnicode) {
nameUnicode = nameRaw // Avoid pointless duplication.
nameUnicode = n[0:len(nameUnicode)]
} }
} }
if hasBang { if hasBang {
uniqueName = nameIDN + "!" + tag uniqueName = nameASCII + "!" + tag
} else { } else {
uniqueName = nameIDN uniqueName = nameASCII
} }
return DomainFixedForms{ return DomainFixedForms{
Tag: tag, Tag: tag,
NameRaw: nameRaw, NameRaw: nameRaw,
NameIDN: nameIDN, NameASCII: nameASCII,
NameUnicode: nameUnicode, NameUnicode: nameUnicode,
UniqueName: uniqueName, UniqueName: uniqueName,
HasBang: hasBang, HasBang: hasBang,

View file

@ -10,7 +10,7 @@ func Test_MakeDomainFixForms(t *testing.T) {
input string input string
wantTag string wantTag string
wantNameRaw string wantNameRaw string
wantNameIDN string wantNameASCII string
wantNameUnicode string wantNameUnicode string
wantUniqueName string wantUniqueName string
wantHasBang bool wantHasBang bool
@ -20,7 +20,7 @@ func Test_MakeDomainFixForms(t *testing.T) {
input: "example.com", input: "example.com",
wantTag: "", wantTag: "",
wantNameRaw: "example.com", wantNameRaw: "example.com",
wantNameIDN: "example.com", wantNameASCII: "example.com",
wantNameUnicode: "example.com", wantNameUnicode: "example.com",
wantUniqueName: "example.com", wantUniqueName: "example.com",
wantHasBang: false, wantHasBang: false,
@ -30,7 +30,7 @@ func Test_MakeDomainFixForms(t *testing.T) {
input: "example.com!mytag", input: "example.com!mytag",
wantTag: "mytag", wantTag: "mytag",
wantNameRaw: "example.com", wantNameRaw: "example.com",
wantNameIDN: "example.com", wantNameASCII: "example.com",
wantNameUnicode: "example.com", wantNameUnicode: "example.com",
wantUniqueName: "example.com!mytag", wantUniqueName: "example.com!mytag",
wantHasBang: true, wantHasBang: true,
@ -40,7 +40,7 @@ func Test_MakeDomainFixForms(t *testing.T) {
input: "example.com!", input: "example.com!",
wantTag: "", wantTag: "",
wantNameRaw: "example.com", wantNameRaw: "example.com",
wantNameIDN: "example.com", wantNameASCII: "example.com",
wantNameUnicode: "example.com", wantNameUnicode: "example.com",
wantUniqueName: "example.com!", wantUniqueName: "example.com!",
wantHasBang: true, wantHasBang: true,
@ -50,7 +50,7 @@ func Test_MakeDomainFixForms(t *testing.T) {
input: "उदाहरण.com", input: "उदाहरण.com",
wantTag: "", wantTag: "",
wantNameRaw: "उदाहरण.com", wantNameRaw: "उदाहरण.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com", wantNameASCII: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com", wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com", wantUniqueName: "xn--p1b6ci4b4b3a.com",
wantHasBang: false, wantHasBang: false,
@ -60,7 +60,7 @@ func Test_MakeDomainFixForms(t *testing.T) {
input: "उदाहरण.com!mytag", input: "उदाहरण.com!mytag",
wantTag: "mytag", wantTag: "mytag",
wantNameRaw: "उदाहरण.com", wantNameRaw: "उदाहरण.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com", wantNameASCII: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com", wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag",
wantHasBang: true, wantHasBang: true,
@ -70,17 +70,29 @@ func Test_MakeDomainFixForms(t *testing.T) {
input: "xn--p1b6ci4b4b3a.com", input: "xn--p1b6ci4b4b3a.com",
wantTag: "", wantTag: "",
wantNameRaw: "xn--p1b6ci4b4b3a.com", wantNameRaw: "xn--p1b6ci4b4b3a.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com", wantNameASCII: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com", wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com", wantUniqueName: "xn--p1b6ci4b4b3a.com",
wantHasBang: false, 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", name: "punycode domain with tag",
input: "xn--p1b6ci4b4b3a.com!mytag", input: "xn--p1b6ci4b4b3a.com!mytag",
wantTag: "mytag", wantTag: "mytag",
wantNameRaw: "xn--p1b6ci4b4b3a.com", wantNameRaw: "xn--p1b6ci4b4b3a.com",
wantNameIDN: "xn--p1b6ci4b4b3a.com", wantNameASCII: "xn--p1b6ci4b4b3a.com",
wantNameUnicode: "उदाहरण.com", wantNameUnicode: "उदाहरण.com",
wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag",
wantHasBang: true, wantHasBang: true,
@ -89,8 +101,8 @@ func Test_MakeDomainFixForms(t *testing.T) {
name: "mixed case domain", name: "mixed case domain",
input: "Example.COM", input: "Example.COM",
wantTag: "", wantTag: "",
wantNameRaw: "example.com", wantNameRaw: "Example.COM",
wantNameIDN: "example.com", wantNameASCII: "example.com",
wantNameUnicode: "example.com", wantNameUnicode: "example.com",
wantUniqueName: "example.com", wantUniqueName: "example.com",
wantHasBang: false, wantHasBang: false,
@ -99,12 +111,34 @@ func Test_MakeDomainFixForms(t *testing.T) {
name: "mixed case domain with tag", name: "mixed case domain with tag",
input: "Example.COM!MyTag", input: "Example.COM!MyTag",
wantTag: "MyTag", wantTag: "MyTag",
wantNameRaw: "example.com", wantNameRaw: "Example.COM",
wantNameIDN: "example.com", wantNameASCII: "example.com",
wantNameUnicode: "example.com", wantNameUnicode: "example.com",
wantUniqueName: "example.com!MyTag", wantUniqueName: "example.com!MyTag",
wantHasBang: true, 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 { for _, tt := range tests {
@ -116,8 +150,8 @@ func Test_MakeDomainFixForms(t *testing.T) {
if got.NameRaw != tt.wantNameRaw { if got.NameRaw != tt.wantNameRaw {
t.Errorf("MakeDomainFixForms() gotNameRaw = %v, want %v", got.NameRaw, tt.wantNameRaw) t.Errorf("MakeDomainFixForms() gotNameRaw = %v, want %v", got.NameRaw, tt.wantNameRaw)
} }
if got.NameIDN != tt.wantNameIDN { if got.NameASCII != tt.wantNameASCII {
t.Errorf("MakeDomainFixForms() gotNameIDN = %v, want %v", got.NameIDN, tt.wantNameIDN) t.Errorf("MakeDomainFixForms() gotNameASCII = %v, want %v", got.NameASCII, tt.wantNameASCII)
} }
if got.NameUnicode != tt.wantNameUnicode { if got.NameUnicode != tt.wantNameUnicode {
t.Errorf("MakeDomainFixForms() gotNameUnicode = %v, want %v", got.NameUnicode, tt.wantNameUnicode) t.Errorf("MakeDomainFixForms() gotNameUnicode = %v, want %v", got.NameUnicode, tt.wantNameUnicode)

View file

@ -24,8 +24,8 @@ func CompilePermitList(s string) PermitList {
continue continue
} }
ff := MakeDomainFixForms(l) ff := MakeDomainFixForms(l)
if ff.HasBang && ff.NameIDN == "" { // Treat empty name as wildcard. if ff.HasBang && ff.NameASCII == "" { // Treat empty name as wildcard.
ff.NameIDN = "*" ff.NameASCII = "*"
} }
sl.items = append(sl.items, ff) 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. // Now that we know the tag matches, we can focus on the name.
// `*!tag` or `*` matches everything. // `*!tag` or `*` matches everything.
if filterItem.NameIDN == "*" { if filterItem.NameASCII == "*" {
return true return true
} }
// If the name starts with "*." then match the suffix. // If the name starts with "*." then match the suffix.
if strings.HasPrefix(filterItem.NameIDN, "*.") { if strings.HasPrefix(filterItem.NameASCII, "*.") {
// example.com matches *.example.com // 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 return true
} }
// foo.example.com matches *.example.com // 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 return true
} }
} }
// No wildcards? Exact match. // No wildcards? Exact match.
if filterItem.NameIDN == domToCheckFF.NameIDN || filterItem.NameUnicode == domToCheckFF.NameUnicode { if filterItem.NameASCII == domToCheckFF.NameASCII || filterItem.NameUnicode == domToCheckFF.NameUnicode {
return true return true
} }
} }

View file

@ -69,7 +69,7 @@ func initBind(config map[string]string, providermeta json.RawMessage) (providers
api.directory = "zones" api.directory = "zones"
} }
if api.filenameformat == "" { if api.filenameformat == "" {
api.filenameformat = "%U.zone" api.filenameformat = "%c.zone"
} }
if len(providermeta) != 0 { if len(providermeta) != 0 {
err := json.Unmarshal(providermeta, api) err := json.Unmarshal(providermeta, api)
@ -171,7 +171,7 @@ func (c *bindProvider) GetZoneRecords(domain string, meta map[string]string) (mo
ff := domaintags.DomainFixedForms{ ff := domaintags.DomainFixedForms{
Tag: meta[models.DomainTag], Tag: meta[models.DomainTag],
NameRaw: meta[models.DomainNameRaw], NameRaw: meta[models.DomainNameRaw],
NameIDN: domain, NameASCII: domain,
NameUnicode: meta[models.DomainNameUnicode], NameUnicode: meta[models.DomainNameUnicode],
UniqueName: meta[models.DomainUniqueName], UniqueName: meta[models.DomainUniqueName],
} }
@ -282,7 +282,7 @@ func (c *bindProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, foundR
domaintags.DomainFixedForms{ domaintags.DomainFixedForms{
Tag: dc.Tag, Tag: dc.Tag,
NameRaw: dc.NameRaw, NameRaw: dc.NameRaw,
NameIDN: dc.Name, NameASCII: dc.Name,
NameUnicode: dc.NameUnicode, NameUnicode: dc.NameUnicode,
UniqueName: dc.UniqueName, UniqueName: dc.UniqueName,
}, },

View file

@ -13,11 +13,6 @@ import (
// makeFileName uses format to generate a zone's filename. See the // makeFileName uses format to generate a zone's filename. See the
func makeFileName(format string, ff domaintags.DomainFixedForms) string { func makeFileName(format string, ff domaintags.DomainFixedForms) string {
//fmt.Printf("DEBUG: makeFileName(%q, %+v)\n", format, ff) //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 == "" { if format == "" {
panic("BUG: makeFileName called with null format") panic("BUG: makeFileName called with null format")
} }
@ -40,16 +35,31 @@ func makeFileName(format string, ff domaintags.DomainFixedForms) string {
pos++ pos++
tok = tokens[pos] tok = tokens[pos]
switch tok { switch tok {
case "D":
b.WriteString(nameRaw) // v4.28 names
case "T": case "r": // NameRaw "originalinput.com" (i for input)
b.WriteString(tag) b.WriteString(ff.NameRaw)
case "U": case "a": // NameASCII "punycode.com" (a for ascii)
b.WriteString(uniquename) b.WriteString(ff.NameASCII)
case "I": case "u": // NameUnicode "unicode.com" (u for unicode)
b.WriteString(nameIDN) b.WriteString(ff.NameUnicode)
case "N": case "c": // UniqueName "punycode.com!tag" or "punycode.com" if no tag (c for canonical)
b.WriteString(nameUnicode) 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 "%": case "%":
b.WriteString("%") b.WriteString("%")
case "?": case "?":
@ -59,9 +69,20 @@ func makeFileName(format string, ff domaintags.DomainFixedForms) string {
} }
pos++ pos++
tok = tokens[pos] tok = tokens[pos]
if tag != "" { if ff.Tag != "" {
b.WriteString(tok) 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: default:
fmt.Fprintf(&b, "%%(unknown %%verb %%%s)", tok) fmt.Fprintf(&b, "%%(unknown %%verb %%%s)", tok)
} }
@ -156,14 +177,14 @@ func makeExtractor(format string) (string, error) {
pos++ pos++
tok = tokens[pos] tok = tokens[pos]
switch tok { switch tok {
case "D": case "D", "a", "u", "r":
b.WriteString(`(.*)`) b.WriteString(`(.*)`)
case "T": case "T":
if pass == 0 { if pass == 0 {
// On the second pass, nothing is generated. // On the second pass, nothing is generated.
b.WriteString(`.*`) b.WriteString(`.*`)
} }
case "U": case "U", "c":
if pass == 0 { if pass == 0 {
b.WriteString(`(.*)!.+`) b.WriteString(`(.*)!.+`)
} else { } else {

View file

@ -8,29 +8,28 @@ import (
) )
func Test_makeFileName(t *testing.T) { 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" fmtDefault := "%U.zone"
fmtBasic := "%U - %T - %D" 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 fmtFancy := "%T%?_%D.zone" // Include the tag_ only if there is a tag
fmtErrorPct := "literal%" fmtErrorPct := "literal%"
fmtErrorOpt := "literal%?" fmtErrorOpt := "literal%?"
fmtErrorUnk := "literal%o" // Unknown % verb 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 { type args struct {
format string format string
ff domaintags.DomainFixedForms ff domaintags.DomainFixedForms
@ -40,25 +39,15 @@ func Test_makeFileName(t *testing.T) {
args args args args
want string want string
}{ }{
// Test corner cases and common cases.
{"literal", args{"literal", ff}, "literal"}, {"literal", args{"literal", ff}, "literal"},
{"middle", args{"mid%Dle", ff}, "midrawle"}, {"basic", args{fmtBasic, ff}, "domy!tagy - tagy - domy"},
{"D", args{"%D", ff}, "raw"}, {"solo", args{"%D", ff}, "domy"},
{"I", args{"%I", ff}, "idn"}, {"front", args{"%Daaa", ff}, "domyaaa"},
{"N", args{"%N", ff}, "unicode"}, {"tail", args{"bbb%D", ff}, "bbbdomy"},
{"T", args{"%T", ff}, "tagb"}, {"def", args{fmtDefault, ff}, "domy!tagy.zone"},
{"x1", args{"XX%?xYY", ff}, "XXxYY"}, {"fanWI", args{fmtFancy, ff}, "tagy_domy.zone"},
{"x2", args{"AA%?xBB", tagless}, "AABB"}, {"fanWO", args{fmtFancy, tagless}, "domy.zone"},
{"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 %)"}, {"errP", args{fmtErrorPct, ff}, "literal%(format may not end in %)"},
{"errQ", args{fmtErrorOpt, 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)"}, {"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) { func Test_makeExtractor(t *testing.T) {
type args struct { type args struct {
format string format string
@ -83,6 +132,10 @@ func Test_makeExtractor(t *testing.T) {
want string want string
wantErr bool 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. // TODO: Add test cases.
{"u", args{"%U.zone"}, `(.*)!.+\.zone|(.*)\.zone`, false}, {"u", args{"%U.zone"}, `(.*)!.+\.zone|(.*)\.zone`, false},
{"d", args{"%D.zone"}, `(.*)\.zone`, false}, {"d", args{"%D.zone"}, `(.*)\.zone`, false},