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:
@ -112,10 +112,10 @@ func run(args PreviewArgs, push bool, interactive bool, out printer.CLI) error {
|
|||||||
totalCorrections := 0
|
totalCorrections := 0
|
||||||
DomainLoop:
|
DomainLoop:
|
||||||
for _, domain := range cfg.Domains {
|
for _, domain := range cfg.Domains {
|
||||||
if !args.shouldRunDomain(domain.Name) {
|
if !args.shouldRunDomain(domain.UniqueName) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.StartDomain(domain.Name)
|
out.StartDomain(domain.UniqueName)
|
||||||
nsList, err := nameservers.DetermineNameservers(domain)
|
nsList, err := nameservers.DetermineNameservers(domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -42,6 +42,56 @@ D("example.com", REGISTRAR, DnsProvider(r53),
|
|||||||
CNAME("test", "foo.example2.com."),
|
CNAME("test", "foo.example2.com."),
|
||||||
GOOGLE_APPS_DOMAIN_MX
|
GOOGLE_APPS_DOMAIN_MX
|
||||||
);
|
);
|
||||||
|
|
||||||
{%endhighlight%}
|
{%endhighlight%}
|
||||||
{% include endExample.html %}
|
{% include endExample.html %}
|
||||||
|
|
||||||
|
|
||||||
|
# Split Horizon DNS
|
||||||
|
|
||||||
|
DNSControl supports Split Horizon DNS. Simply
|
||||||
|
define the domain two or more times, each with
|
||||||
|
their own unique parameters.
|
||||||
|
|
||||||
|
To differentiate the different domains, specify the domains as
|
||||||
|
`domain.tld!tag`, such as `example.com!inside` and
|
||||||
|
`example.com!outside`.
|
||||||
|
|
||||||
|
{% include startExample.html %}
|
||||||
|
{% highlight js %}
|
||||||
|
var REG = NewRegistrar("Third-Party", "NONE");
|
||||||
|
var DNS_INSIDE = NewDnsProvider("Cloudflare", "CLOUDFLAREAPI");
|
||||||
|
var DNS_OUTSIDE = NewDnsProvider("bind", "BIND");
|
||||||
|
|
||||||
|
D("example.com!inside", REG, DnsProvider(DNS_INSIDE),
|
||||||
|
A("www", "10.10.10.10")
|
||||||
|
);
|
||||||
|
|
||||||
|
D("example.com!outside", REG, DnsProvider(DNS_OUTSIDE),
|
||||||
|
A("www", "20.20.20.20")
|
||||||
|
);
|
||||||
|
|
||||||
|
D_EXTEND("example.com!inside",
|
||||||
|
A("internal", "10.99.99.99")
|
||||||
|
);
|
||||||
|
{%endhighlight%}
|
||||||
|
{% include endExample.html %}
|
||||||
|
|
||||||
|
A domain name without a `!` is assigned a tag that is the empty
|
||||||
|
string. For example, `example.com` and `example.com!` are equivalent.
|
||||||
|
However, we strongly recommend against using the empty tag, as it
|
||||||
|
risks creating confusion. In other words, if you have `domain.tld`
|
||||||
|
and `domain.tld!external` you now require humans to remember that
|
||||||
|
`domain.tld` is the external one. I mean... the internal one. You
|
||||||
|
may have noticed this mistake, but will your coworkers? Will you in
|
||||||
|
six months? You get the idea.
|
||||||
|
|
||||||
|
DNSControl command line flag `--domains` is an exact match. If you
|
||||||
|
define domains `example.com!george` and `example.com!john` then:
|
||||||
|
|
||||||
|
* `--domains=example.com` will not match either domain.
|
||||||
|
* `--domains='example.com!george'` will match only match the first.
|
||||||
|
* `--domains='example.com!george',example.com!john` will match both.
|
||||||
|
|
||||||
|
NOTE: The quotes are required if your shell treats `!` as a special
|
||||||
|
character, which is probably does. If you see an error that mentions
|
||||||
|
`event not found` you probably forgot the quotes.
|
||||||
|
@ -18,11 +18,13 @@ you can specify a `directory` where the provider will look for and create zone f
|
|||||||
{% highlight json %}
|
{% highlight json %}
|
||||||
{
|
{
|
||||||
"bind": {
|
"bind": {
|
||||||
"directory": "myzones"
|
"directory": "myzones",
|
||||||
|
"filenameformat": "%U.zone" << The default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{% endhighlight %}
|
{% endhighlight %}
|
||||||
|
|
||||||
|
|
||||||
The BIND accepts some optional metadata via your DNS config when you create the provider:
|
The BIND accepts some optional metadata via your DNS config when you create the provider:
|
||||||
|
|
||||||
In this example we set the default SOA settings and NS records.
|
In this example we set the default SOA settings and NS records.
|
||||||
@ -46,15 +48,55 @@ var BIND = NewDnsProvider('bind', 'BIND', {
|
|||||||
})
|
})
|
||||||
{% endhighlight %}
|
{% endhighlight %}
|
||||||
|
|
||||||
|
# filenameformat
|
||||||
|
|
||||||
|
The `filenameformat` parameter specifies the file name to be used when
|
||||||
|
writing the zone file. The default is acceptable in most cases: the
|
||||||
|
name as specified in the `D()` function, plus ".zone".
|
||||||
|
|
||||||
|
The filenameformat is a string with a few printf-like `%` verbs:
|
||||||
|
|
||||||
|
* `%U` the domain name as specified in `D()`
|
||||||
|
* `%D` the domain name without any split horizon tag
|
||||||
|
* `%T` the split horizon tag, or "", see `D()`
|
||||||
|
* `%?x` this returns `x` if the split horizon tag is non-null, otherwise nothing. `x` can be any printable.
|
||||||
|
* `%%` `%`
|
||||||
|
* ordinary characters (not `%`) are copied unchanged to the output stream
|
||||||
|
* FYI: format strings must not end with an incomplete `%` or `%?`
|
||||||
|
* FYI: `/` or other filesystem separators result in undefined behavior
|
||||||
|
|
||||||
|
Typical values:
|
||||||
|
|
||||||
|
* `%U.zone` (The default)
|
||||||
|
* `example.com.zone` or `example.com!tag.zone`
|
||||||
|
* `%T%*U%D.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`
|
||||||
|
* `db_%D`
|
||||||
|
* `db_example.com`
|
||||||
|
|
||||||
|
The last example will generate the same name for both
|
||||||
|
`D("example.tld!inside")` and `D("example.tld!outside")`. This
|
||||||
|
assumes two BIND providers are configured in `creds.json`, eacch with
|
||||||
|
a different `directory` setting. Otherwise `dnscontrol` will write
|
||||||
|
both domains to the same file, flapping between the two back and
|
||||||
|
forth.
|
||||||
|
|
||||||
# FYI: get-zones
|
# FYI: get-zones
|
||||||
|
|
||||||
When used with "get-zones", specifying "all" scans the directory for
|
The dnscontrol `get-zones all` subcommand scans the directory for
|
||||||
any files named `*.zone` and assumes they are zone files.
|
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 an guess at which
|
||||||
|
filenames are zones but doesn't try to hard to get it right, which is
|
||||||
|
mathematically impossible in all cases. Feel free to file an issue if
|
||||||
|
your format string doesn't work. I love a challenge!
|
||||||
|
|
||||||
# FYI: SOA Records
|
# FYI: SOA Records
|
||||||
|
|
||||||
DNSControl assumes that SOA records are managed by the provider. Most
|
DNSControl assumes that SOA records are managed by the provider. Most
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/StackExchange/dnscontrol/v3/models"
|
"github.com/StackExchange/dnscontrol/v3/models"
|
||||||
"github.com/StackExchange/dnscontrol/v3/pkg/nameservers"
|
"github.com/StackExchange/dnscontrol/v3/pkg/nameservers"
|
||||||
|
"github.com/StackExchange/dnscontrol/v3/pkg/normalize"
|
||||||
"github.com/StackExchange/dnscontrol/v3/providers"
|
"github.com/StackExchange/dnscontrol/v3/providers"
|
||||||
_ "github.com/StackExchange/dnscontrol/v3/providers/_all"
|
_ "github.com/StackExchange/dnscontrol/v3/providers/_all"
|
||||||
"github.com/StackExchange/dnscontrol/v3/providers/config"
|
"github.com/StackExchange/dnscontrol/v3/providers/config"
|
||||||
@ -93,6 +94,8 @@ func getDomainConfigWithNameservers(t *testing.T, prv providers.DNSServiceProvid
|
|||||||
dc := &models.DomainConfig{
|
dc := &models.DomainConfig{
|
||||||
Name: domainName,
|
Name: domainName,
|
||||||
}
|
}
|
||||||
|
normalize.UpdateNameSplitHorizon(dc)
|
||||||
|
|
||||||
// fix up nameservers
|
// fix up nameservers
|
||||||
ns, err := prv.GetNameservers(domainName)
|
ns, err := prv.GetNameservers(domainName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -9,6 +9,8 @@ import (
|
|||||||
// DomainConfig describes a DNS domain (tecnically a DNS zone).
|
// DomainConfig describes a DNS domain (tecnically a DNS zone).
|
||||||
type DomainConfig struct {
|
type DomainConfig struct {
|
||||||
Name string `json:"name"` // NO trailing "."
|
Name string `json:"name"` // NO trailing "."
|
||||||
|
Tag string `json:"-"` // split horizon tag
|
||||||
|
UniqueName string `json:"-"` // .Name + "!" + .Tag
|
||||||
RegistrarName string `json:"registrar"`
|
RegistrarName string `json:"registrar"`
|
||||||
DNSProviderNames map[string]int `json:"dnsProviders"`
|
DNSProviderNames map[string]int `json:"dnsProviders"`
|
||||||
|
|
||||||
|
24
pkg/js/parse_tests/037-splithor.js
Normal file
24
pkg/js/parse_tests/037-splithor.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
var REG = NewRegistrar("Third-Party", "NONE");
|
||||||
|
var DNS_MAIN = NewDnsProvider("otherconfig", "CLOUDFLAREAPI");
|
||||||
|
var DNS_INSIDE = NewDnsProvider("Cloudflare", "CLOUDFLAREAPI");
|
||||||
|
var DNS_OUTSIDE = NewDnsProvider("bind", "BIND");
|
||||||
|
|
||||||
|
D("example.com", REG, DnsProvider(DNS_MAIN),
|
||||||
|
A("main", "3.3.3.3")
|
||||||
|
);
|
||||||
|
|
||||||
|
D("example.com!inside", REG, DnsProvider(DNS_INSIDE),
|
||||||
|
A("main", "1.1.1.1")
|
||||||
|
);
|
||||||
|
|
||||||
|
D("example.com!outside", REG, DnsProvider(DNS_OUTSIDE),
|
||||||
|
A("main", "8.8.8.8")
|
||||||
|
);
|
||||||
|
|
||||||
|
D_EXTEND("example.com",
|
||||||
|
A("www", "33.33.33.33")
|
||||||
|
);
|
||||||
|
|
||||||
|
D_EXTEND("example.com!inside",
|
||||||
|
A("main", "11.11.11.11")
|
||||||
|
);
|
76
pkg/js/parse_tests/037-splithor.json
Normal file
76
pkg/js/parse_tests/037-splithor.json
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"dns_providers": [
|
||||||
|
{
|
||||||
|
"name": "otherconfig",
|
||||||
|
"type": "CLOUDFLAREAPI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cloudflare",
|
||||||
|
"type": "CLOUDFLAREAPI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bind",
|
||||||
|
"type": "BIND"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
{
|
||||||
|
"dnsProviders": {
|
||||||
|
"otherconfig": -1
|
||||||
|
},
|
||||||
|
"name": "example.com",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"target": "3.3.3.3",
|
||||||
|
"type": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "www",
|
||||||
|
"target": "33.33.33.33",
|
||||||
|
"type": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registrar": "Third-Party"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dnsProviders": {
|
||||||
|
"Cloudflare": -1
|
||||||
|
},
|
||||||
|
"name": "example.com!inside",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"target": "1.1.1.1",
|
||||||
|
"type": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"target": "11.11.11.11",
|
||||||
|
"type": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registrar": "Third-Party"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dnsProviders": {
|
||||||
|
"bind": -1
|
||||||
|
},
|
||||||
|
"name": "example.com!outside",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"target": "8.8.8.8",
|
||||||
|
"type": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registrar": "Third-Party"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registrars": [
|
||||||
|
{
|
||||||
|
"name": "Third-Party",
|
||||||
|
"type": "NONE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -253,6 +253,11 @@ type Warning struct {
|
|||||||
|
|
||||||
// ValidateAndNormalizeConfig performs and normalization and/or validation of the IR.
|
// ValidateAndNormalizeConfig performs and normalization and/or validation of the IR.
|
||||||
func ValidateAndNormalizeConfig(config *models.DNSConfig) (errs []error) {
|
func ValidateAndNormalizeConfig(config *models.DNSConfig) (errs []error) {
|
||||||
|
err := processSplitHorizonDomains(config)
|
||||||
|
if err != nil {
|
||||||
|
return []error{err}
|
||||||
|
}
|
||||||
|
|
||||||
for _, domain := range config.Domains {
|
for _, domain := range config.Domains {
|
||||||
pTypes := []string{}
|
pTypes := []string{}
|
||||||
for _, provider := range domain.DNSProviderInstances {
|
for _, provider := range domain.DNSProviderInstances {
|
||||||
@ -457,6 +462,48 @@ func ValidateAndNormalizeConfig(config *models.DNSConfig) (errs []error) {
|
|||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateNameSplitHorizon fills in the split horizon fields.
|
||||||
|
func UpdateNameSplitHorizon(dc *models.DomainConfig) {
|
||||||
|
if dc.UniqueName == "" {
|
||||||
|
dc.UniqueName = dc.Name
|
||||||
|
}
|
||||||
|
if dc.Tag == "" {
|
||||||
|
l := strings.SplitN(dc.Name, "!", 2)
|
||||||
|
if len(l) == 2 {
|
||||||
|
dc.Name = l[0]
|
||||||
|
dc.Tag = l[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processSplitHorizonDomains finds "domain.tld!tag" domains and pre-processes them.
|
||||||
|
func processSplitHorizonDomains(config *models.DNSConfig) error {
|
||||||
|
// Parse out names and tags.
|
||||||
|
for _, d := range config.Domains {
|
||||||
|
UpdateNameSplitHorizon(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify uniquenames are unique
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, d := range config.Domains {
|
||||||
|
if seen[d.UniqueName] {
|
||||||
|
return fmt.Errorf("duplicate domain name: %q", d.UniqueName)
|
||||||
|
}
|
||||||
|
seen[d.UniqueName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDomainSpec parses "domain.tld!tag" into its component parts.
|
||||||
|
func parseDomainSpec(s string) (domain, tag string) {
|
||||||
|
l := strings.SplitN(s, "!", 2)
|
||||||
|
if len(l) == 2 {
|
||||||
|
return l[0], l[1]
|
||||||
|
}
|
||||||
|
return l[0], ""
|
||||||
|
}
|
||||||
|
|
||||||
func checkAutoDNSSEC(dc *models.DomainConfig) (errs []error) {
|
func checkAutoDNSSEC(dc *models.DomainConfig) (errs []error) {
|
||||||
if dc.AutoDNSSEC != "" && dc.AutoDNSSEC != "on" && dc.AutoDNSSEC != "off" {
|
if dc.AutoDNSSEC != "" && dc.AutoDNSSEC != "on" && dc.AutoDNSSEC != "off" {
|
||||||
errs = append(errs, fmt.Errorf("Domain %q AutoDNSSEC=%q is invalid (expecting \"\", \"off\", or \"on\")", dc.Name, dc.AutoDNSSEC))
|
errs = append(errs, fmt.Errorf("Domain %q AutoDNSSEC=%q is invalid (expecting \"\", \"off\", or \"on\")", dc.Name, dc.AutoDNSSEC))
|
||||||
|
@ -53,10 +53,14 @@ func initBind(config map[string]string, providermeta json.RawMessage) (providers
|
|||||||
// meta -- the json blob from NewReq('name', 'TYPE', meta)
|
// meta -- the json blob from NewReq('name', 'TYPE', meta)
|
||||||
api := &bindProvider{
|
api := &bindProvider{
|
||||||
directory: config["directory"],
|
directory: config["directory"],
|
||||||
|
filenameformat: config["filenameformat"],
|
||||||
}
|
}
|
||||||
if api.directory == "" {
|
if api.directory == "" {
|
||||||
api.directory = "zones"
|
api.directory = "zones"
|
||||||
}
|
}
|
||||||
|
if api.filenameformat == "" {
|
||||||
|
api.filenameformat = "%U.zone"
|
||||||
|
}
|
||||||
if len(providermeta) != 0 {
|
if len(providermeta) != 0 {
|
||||||
err := json.Unmarshal(providermeta, api)
|
err := json.Unmarshal(providermeta, api)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -98,6 +102,7 @@ type bindProvider struct {
|
|||||||
DefaultSoa SoaInfo `json:"default_soa"`
|
DefaultSoa SoaInfo `json:"default_soa"`
|
||||||
nameservers []*models.Nameserver
|
nameservers []*models.Nameserver
|
||||||
directory string
|
directory string
|
||||||
|
filenameformat string
|
||||||
zonefile string // Where the zone data is expected
|
zonefile string // Where the zone data is expected
|
||||||
zoneFileFound bool // Did the zonefile exist?
|
zoneFileFound bool // Did the zonefile exist?
|
||||||
}
|
}
|
||||||
@ -117,16 +122,19 @@ func (c *bindProvider) ListZones() ([]string, error) {
|
|||||||
return nil, fmt.Errorf("directory %q does not exist", c.directory)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return files, fmt.Errorf("bind ListZones open dir %q: %w",
|
||||||
|
c.directory, err)
|
||||||
}
|
}
|
||||||
var zones []string
|
filenames, err := f.Readdirnames(-1)
|
||||||
for _, n := range filenames {
|
if err != nil {
|
||||||
_, file := filepath.Split(n)
|
return files, fmt.Errorf("bind ListZones readdir %q: %w",
|
||||||
zones = append(zones, strings.TrimSuffix(file, ".zone"))
|
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.
|
// 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)
|
fmt.Printf("\nWARNING: BIND directory %q does not exist!\n", c.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.zonefile = filepath.Join(
|
if c.zonefile == "" {
|
||||||
c.directory,
|
// This layering violation is needed for tests only.
|
||||||
strings.Replace(strings.ToLower(domain), "/", "_", -1)+".zone")
|
// Otherwise, this is set already.
|
||||||
|
c.zonefile = filepath.Join(c.directory,
|
||||||
|
makeFileName(c.filenameformat, domain, domain, ""))
|
||||||
|
}
|
||||||
content, err := ioutil.ReadFile(c.zonefile)
|
content, err := ioutil.ReadFile(c.zonefile)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
// If the file doesn't exist, that's not an error. Just informational.
|
// If the file doesn't exist, that's not an error. Just informational.
|
||||||
c.zoneFileFound = false
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -185,6 +195,9 @@ func (c *bindProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.
|
|||||||
comments = append(comments, "Automatic DNSSEC signing requested")
|
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)
|
foundRecords, err := c.GetZoneRecords(dc.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
193
providers/bind/fnames.go
Normal file
193
providers/bind/fnames.go
Normal 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
|
||||||
|
}
|
138
providers/bind/fnames_test.go
Normal file
138
providers/bind/fnames_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user