1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

NEW FEATURE: Support Split Horizon DNS (#1034)

* Implement main feature
* BIND: Permit printf-like file name formats
* BIND: Make filenameformat work forwards and backwards.
* Fix extrator test cases
This commit is contained in:
Tom Limoncelli
2021-02-05 12:12:45 -05:00
committed by GitHub
parent 36289f7157
commit c547beacc0
11 changed files with 612 additions and 24 deletions

View File

@@ -52,11 +52,15 @@ func initBind(config map[string]string, providermeta json.RawMessage) (providers
// config -- the key/values from creds.json
// meta -- the json blob from NewReq('name', 'TYPE', meta)
api := &bindProvider{
directory: config["directory"],
directory: config["directory"],
filenameformat: config["filenameformat"],
}
if api.directory == "" {
api.directory = "zones"
}
if api.filenameformat == "" {
api.filenameformat = "%U.zone"
}
if len(providermeta) != 0 {
err := json.Unmarshal(providermeta, api)
if err != nil {
@@ -94,12 +98,13 @@ func (s SoaInfo) String() string {
// bindProvider is the provider handle for the bindProvider driver.
type bindProvider struct {
DefaultNS []string `json:"default_ns"`
DefaultSoa SoaInfo `json:"default_soa"`
nameservers []*models.Nameserver
directory string
zonefile string // Where the zone data is expected
zoneFileFound bool // Did the zonefile exist?
DefaultNS []string `json:"default_ns"`
DefaultSoa SoaInfo `json:"default_soa"`
nameservers []*models.Nameserver
directory string
filenameformat string
zonefile string // Where the zone data is expected
zoneFileFound bool // Did the zonefile exist?
}
// GetNameservers returns the nameservers for a domain.
@@ -117,16 +122,19 @@ func (c *bindProvider) ListZones() ([]string, error) {
return nil, fmt.Errorf("directory %q does not exist", c.directory)
}
filenames, err := filepath.Glob(filepath.Join(c.directory, "*.zone"))
var files []string
f, err := os.Open(c.directory)
if err != nil {
return nil, err
return files, fmt.Errorf("bind ListZones open dir %q: %w",
c.directory, err)
}
var zones []string
for _, n := range filenames {
_, file := filepath.Split(n)
zones = append(zones, strings.TrimSuffix(file, ".zone"))
filenames, err := f.Readdirnames(-1)
if err != nil {
return files, fmt.Errorf("bind ListZones readdir %q: %w",
c.directory, err)
}
return zones, nil
return extractZonesFromFilenames(c.filenameformat, filenames), nil
}
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
@@ -137,15 +145,17 @@ func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) {
fmt.Printf("\nWARNING: BIND directory %q does not exist!\n", c.directory)
}
c.zonefile = filepath.Join(
c.directory,
strings.Replace(strings.ToLower(domain), "/", "_", -1)+".zone")
if c.zonefile == "" {
// This layering violation is needed for tests only.
// Otherwise, this is set already.
c.zonefile = filepath.Join(c.directory,
makeFileName(c.filenameformat, domain, domain, ""))
}
content, err := ioutil.ReadFile(c.zonefile)
if os.IsNotExist(err) {
// If the file doesn't exist, that's not an error. Just informational.
c.zoneFileFound = false
fmt.Fprintf(os.Stderr, "File not found: '%v'\n", c.zonefile)
fmt.Fprintf(os.Stderr, "File does not yet exist: %q\n", c.zonefile)
return nil, nil
}
if err != nil {
@@ -185,6 +195,9 @@ func (c *bindProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.
comments = append(comments, "Automatic DNSSEC signing requested")
}
c.zonefile = filepath.Join(c.directory,
makeFileName(c.filenameformat, dc.UniqueName, dc.Name, dc.Tag))
foundRecords, err := c.GetZoneRecords(dc.Name)
if err != nil {
return nil, err

193
providers/bind/fnames.go Normal file
View File

@@ -0,0 +1,193 @@
package bind
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// makeFileName uses format to generate a zone's filename. See the
func makeFileName(format, uniquename, domain, tag string) string {
if format == "" {
fmt.Fprintf(os.Stderr, "BUG: makeFileName called with null format\n")
return uniquename
}
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(domain)
case "T":
b.WriteString(tag)
case "U":
b.WriteString(uniquename)
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)
}
}
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
}

View File

@@ -0,0 +1,138 @@
package bind
import (
"reflect"
"testing"
)
func Test_makeFileName(t *testing.T) {
uu := "uni"
dd := "domy"
tt := "tagy"
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
type args struct {
format string
uniquename string
domain string
tag string
}
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)"},
}
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)
}
})
}
}
func Test_makeExtractor(t *testing.T) {
type args struct {
format string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
// TODO: Add test cases.
{"u", args{"%U.zone"}, `(.*)!.+\.zone|(.*)\.zone`, false},
{"d", args{"%D.zone"}, `(.*)\.zone`, false},
{"basic", args{"%U - %T - %D"}, `(.*)!.+ - .* - (.*)|(.*) - - (.*)`, false},
{"bk1", args{"db_%U"}, `db_(.*)!.+|db_(.*)`, false},
{"bk2", args{"db_%T_%D"}, `db_.*_(.*)`, false},
{"fan", args{"%T%?_%D.zone"}, `.*_(.*)\.zone|(.*)\.zone`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := makeExtractor(tt.args.format)
if (err != nil) != tt.wantErr {
t.Errorf("makeExtractor() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("makeExtractor() = %v, want %v", got, tt.want)
}
})
}
}
func Test_extractZonesFromFilenames(t *testing.T) {
type args struct {
format string
names []string
}
// A list of filenames one might find in a directory.
filelist := []string{
"foo!one.zone", // u
"dom.tld!two.zone", // u
"foo.zone", // d
"dom.tld.zone", // d
"foo!one - one - foo", // basic
"dom.tld!two - two - dom.tld", // basic
"db_foo", // bk1
"db_dom.tld", // bk1
"db_dom.tld!tag", // bk1
"db_inside_foo", // bk2
"db_outside_dom.tld", // bk2
"db__example.com", // bk2
"dom.zone", // fan
"example.com.zone", // fan (no tag)
"mytag_example.com.zone", // fan (w/ tag)
"dom.zone", // fan (no tag)
"mytag_dom.zone", // fan (w/ tag)
}
tests := []struct {
name string
args args
want []string
}{
{"0", args{"%D.zone", []string{"foo.zone", "dom.tld.zone"}}, []string{"foo", "dom.tld"}},
{"1", args{"%U.zone", []string{"foo.zone", "dom.tld.zone"}}, []string{"foo", "dom.tld"}},
{"2", args{"%T%?_%D.zone", []string{"inside_ex.tld.zone", "foo.zone", "dom.tld.zone"}}, []string{"ex.tld", "foo", "dom.tld"}},
{"d", args{"%D.zone", filelist}, []string{"foo!one", "dom.tld!two", "foo", "dom.tld", "dom", "example.com", "mytag_example.com", "dom", "mytag_dom"}},
{"u", args{"%U.zone", filelist}, []string{"foo", "dom.tld", "foo", "dom.tld", "dom", "example.com", "mytag_example.com", "dom", "mytag_dom"}},
{"bk1", args{"db_%U", filelist}, []string{"foo", "dom.tld", "dom.tld", "inside_foo", "outside_dom.tld", "_example.com"}},
{"bk2", args{"db_%T_%D", filelist}, []string{"foo", "dom.tld", "example.com"}},
{"fan", args{"%T%?_%D.zone", filelist}, []string{"foo!one", "dom.tld!two", "foo", "dom.tld", "dom", "example.com", "example.com", "dom", "dom"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractZonesFromFilenames(tt.args.format, tt.args.names); !reflect.DeepEqual(got, tt.want) {
ext, _ := makeExtractor(tt.args.format)
t.Errorf("extractZonesFromFilenames() = %v, want %v Fm=%s Ex=%s", got, tt.want, tt.args.format, ext)
}
})
}
}