dnscontrol/providers/bind/fnames.go
Tom Limoncelli c11a523982
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
2025-12-03 20:31:59 -05:00

224 lines
5.8 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)
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 {
// 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 "?":
if pos == lastpos {
b.WriteString("%(format may not end in %?)")
continue
}
pos++
tok = tokens[pos]
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)
}
}
// 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", "a", "u", "r":
b.WriteString(`(.*)`)
case "T":
if pass == 0 {
// On the second pass, nothing is generated.
b.WriteString(`.*`)
}
case "U", "c":
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
}