dnscontrol/providers/bind/fnames.go
Tom Limoncelli 1b2f5d4d34
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>
2025-11-29 12:17:44 -05:00

203 lines
5 KiB
Go

package bind
import (
"bytes"
"fmt"
"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 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")
}
var b bytes.Buffer
tokens := strings.Split(format, "")
lastpos := len(tokens) - 1
for pos := 0; pos < len(tokens); pos++ {
tok := tokens[pos]
if tok != "%" {
b.WriteString(tok)
continue
}
if pos == lastpos {
b.WriteString("%(format may not end in %)")
continue
}
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)
case "%":
b.WriteString("%")
case "?":
if pos == lastpos {
b.WriteString("%(format may not end in %?)")
continue
}
pos++
tok = tokens[pos]
if tag != "" {
b.WriteString(tok)
}
default:
fmt.Fprintf(&b, "%%(unknown %%verb %%%s)", tok)
}
}
// fmt.Printf("DEBUG: makeFileName returns= %q\n", b.String())
return b.String()
}
// extractZonesFromFilenames extracts the zone names from a list of filenames
// based on the format string used to create the files. It is mathematically
// impossible to do this correctly for all format strings, but typical format
// strings are supported.
func extractZonesFromFilenames(format string, names []string) []string {
var zones []string
// Generate a regex that will extract the zonename from a filename.
extractor, err := makeExtractor(format)
if err != nil {
// Give up. Return the list of filenames.
return names
}
re := regexp.MustCompile(extractor)
//
for _, n := range names {
_, file := filepath.Split(n)
l := re.FindStringSubmatch(file)
// l[1:] is a list of matches and null strings. Pick the first non-null string.
if len(l) > 1 {
for _, s := range l[1:] {
if s != "" {
zones = append(zones, s)
break
}
}
}
}
return zones
}
// makeExtractor generates a regex that extracts domain names from filenames.
// format specifies the format string used by makeFileName to generate such
// filenames. It is mathematically impossible to do this correctly for all
// format strings, but typical format strings are supported.
func makeExtractor(format string) (string, error) {
// The algorithm works as follows.
// We generate a regex that is A or A|B.
// A is the regex that works if tag is non-null.
// B is the regex that assumes tags are "".
// If no tag-related verbs are used, A is sufficient.
// If a tag-related verb is used, we append | and generate B, which does
// Each % verb is turned into an appropriate subexpression based on pass.
// NB: This is some rather fancy CS stuff just to make the
// "get-zones all" command work for BIND. That's a lot of work for
// a feature that isn't going to be used very often, if at all.
// Therefore if this ever becomes a maintenance bother, we can just
// replace this with something more simple. For example, the
// creds.json file could specify the regex and humans can specify
// the Extractor themselves. Or, just remove this feature from the
// BIND driver.
var b bytes.Buffer
tokens := strings.Split(format, "")
lastpos := len(tokens) - 1
generateB := false
for pass := range []int{0, 1} {
for pos := 0; pos < len(tokens); pos++ {
tok := tokens[pos]
if tok == "." {
// dots are escaped
b.WriteString(`\.`)
continue
}
if tok != "%" {
// ordinary runes are passed unmodified.
b.WriteString(tok)
continue
}
if pos == lastpos {
return ``, fmt.Errorf("format may not end in %%: %q", format)
}
// Process % verbs
// Move to the next token, which is the verb name: D, U, etc.
pos++
tok = tokens[pos]
switch tok {
case "D":
b.WriteString(`(.*)`)
case "T":
if pass == 0 {
// On the second pass, nothing is generated.
b.WriteString(`.*`)
}
case "U":
if pass == 0 {
b.WriteString(`(.*)!.+`)
} else {
b.WriteString(`(.*)`)
}
generateB = true
case "?":
if pos == lastpos {
return ``, fmt.Errorf("format may not end in %%?: %q", format)
}
// Move to the next token, the tag-only char.
pos++
tok = tokens[pos]
if pass == 0 {
// On the second pass, nothing is generated.
b.WriteString(tok)
}
generateB = true
default:
return ``, fmt.Errorf("unknown %%verb %%%s: %q", tok, format)
}
}
// At the end of the first pass determine if we need the second pass.
if pass == 0 {
if generateB {
// We had a %? token. Now repeat the process
// but generate an "or" that assumes no tags.
b.WriteString(`|`)
} else {
break
}
}
}
return b.String(), nil
}