From 87ad01d194ddc05950e79382c838d348e11d3db2 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Tue, 18 Feb 2020 08:59:18 -0500 Subject: [PATCH] Add "get-zone" command (#613) * Add GetZoneRecords to DNSProvider interface * dnscontrol now uses ufave/cli/v2 * NEW: get-zones.md * HasRecordTypeName should be a method on models.Records not models.DomainConfig * Implement BIND's GetZoneRecords * new WriteZoneFile implemented * go mod vendor * Update docs to use get-zone instead of convertzone * Add CanGetZone capability and update all providers. * Get all zones for a provider at once (#626) * implement GetZoneRecords for cloudflare * munge cloudflare ttls * Implement GetZoneRecords for cloudflare (#625) Co-authored-by: Craig Peterson <192540+captncraig@users.noreply.github.com> --- build/generate/featureMatrix.go | 2 + cmd/convertzone/README.md | 7 +- cmd/convertzone/main.go | 9 +- commands/getZones.go | 207 +++++++++++ docs/_includes/matrix.html | 67 ++++ docs/adding-new-rtypes.md | 2 +- docs/get-zones.md | 85 +++++ docs/getting-started.md | 7 +- docs/migrating.md | 54 ++- go.sum | 1 + models/dns_test.go | 22 +- models/dnsrr.go | 123 +++++++ models/domain.go | 10 - models/provider.go | 13 +- models/record.go | 10 + models/record_test.go | 18 + pkg/normalize/validate.go | 2 +- pkg/prettyzone/prettyzone.go | 142 ++++++++ .../prettyzone}/prettyzone_test.go | 148 +++++--- pkg/prettyzone/sorting.go | 194 +++++++++++ providers/activedir/activedirProvider.go | 1 + providers/activedir/domains.go | 8 + providers/azuredns/azureDnsProvider.go | 9 + providers/bind/bindProvider.go | 195 ++++------- providers/bind/prettyzone.go | 325 ------------------ providers/capabilities.go | 3 + providers/cloudflare/cloudflareProvider.go | 61 +++- providers/cloudns/cloudnsProvider.go | 11 +- .../digitalocean/digitaloceanProvider.go | 11 +- providers/dnsimple/dnsimpleProvider.go | 9 + providers/exoscale/exoscaleProvider.go | 9 + providers/gandi/gandiProvider.go | 11 +- providers/gandi/livedns.go | 8 + providers/gandi_v5/gandi_v5Provider.go | 1 + providers/gcloud/gcloudProvider.go | 9 + providers/hexonet/hexonetProvider.go | 1 + providers/hexonet/records.go | 8 + providers/linode/linodeProvider.go | 11 +- providers/namecheap/namecheapProvider.go | 9 + providers/namedotcom/namedotcomProvider.go | 1 + providers/namedotcom/records.go | 8 + providers/ns1/ns1provider.go | 8 + providers/octodns/octodnsProvider.go | 9 + providers/opensrs/opensrsProvider.go | 1 + providers/ovh/ovhProvider.go | 11 +- providers/providers.go | 15 + providers/route53/route53Provider.go | 43 ++- providers/softlayer/softlayerProvider.go | 11 +- providers/vultr/vultrProvider.go | 9 + 49 files changed, 1327 insertions(+), 612 deletions(-) create mode 100644 commands/getZones.go create mode 100644 docs/get-zones.md create mode 100644 models/dnsrr.go create mode 100644 pkg/prettyzone/prettyzone.go rename {providers/bind => pkg/prettyzone}/prettyzone_test.go (77%) create mode 100644 pkg/prettyzone/sorting.go delete mode 100644 providers/bind/prettyzone.go diff --git a/build/generate/featureMatrix.go b/build/generate/featureMatrix.go index 806195293..920ff2dda 100644 --- a/build/generate/featureMatrix.go +++ b/build/generate/featureMatrix.go @@ -42,6 +42,7 @@ func generateFeatureMatrix() error { {"dual host", "This provider is recommended for use in 'dual hosting' scenarios. Usually this means the provider allows full control over the apex NS records"}, {"create-domains", "This means the provider can automatically create domains that do not currently exist on your account. The 'dnscontrol create-domains' command will initialize any missing domains"}, {"no_purge", "indicates you can use NO_PURGE macro to prevent deleting records not managed by dnscontrol. A few providers that generate the entire zone from scratch have a problem implementing this."}, + {"get-zones", "indicates the dnscontrol get-zones subcommand is implemented."}, }, } for _, p := range providerTypes { @@ -81,6 +82,7 @@ func generateFeatureMatrix() error { setCap("SSHFP", providers.CanUseSSHFP) setCap("TLSA", providers.CanUseTLSA) setCap("TXTMulti", providers.CanUseTXTMulti) + setCap("get-zones", providers.CanGetZones) setDoc("dual host", providers.DocDualHost, false) setDoc("create-domains", providers.DocCreateDomains, true) diff --git a/cmd/convertzone/README.md b/cmd/convertzone/README.md index d6f5b3664..1a945f800 100644 --- a/cmd/convertzone/README.md +++ b/cmd/convertzone/README.md @@ -1,3 +1,8 @@ + +!!! NOTE: This command has been replaced by the "dnscontrol get-zones" +!!! subcommand. It can do everything convertzone does and more, with +!!! fewer bugs. This command will be removed from the distribution soon. + # convertzone -- Converts a standard DNS zonefile into tsv, pretty, or DSL This is a crude hack we put together to read a couple common zonefile @@ -98,4 +103,4 @@ Example: Generate statements for a dnsconfig.js file: Note: The conversion is not perfect. You'll need to manually clean it up and insert it into `dnsconfig.js`. More instructions in the -DNSControl [migration doc]({site.github.url}}/migration). \ No newline at end of file +DNSControl [migration doc]({site.github.url}}/migration). diff --git a/cmd/convertzone/main.go b/cmd/convertzone/main.go index d2c5984c5..965a1a966 100644 --- a/cmd/convertzone/main.go +++ b/cmd/convertzone/main.go @@ -46,7 +46,7 @@ import ( "github.com/miekg/dns" "github.com/miekg/dns/dnsutil" - "github.com/StackExchange/dnscontrol/v2/providers/bind" + "github.com/StackExchange/dnscontrol/v2/pkg/prettyzone" "github.com/StackExchange/dnscontrol/v2/providers/octodns/octoyaml" ) @@ -83,7 +83,7 @@ func main() { switch *flagOutfmt { case "pretty": - bind.WriteZoneFile(os.Stdout, recs, zonename) + prettyzone.WriteZoneFileRR(os.Stdout, recs, zonename, 0) case "dsl": fmt.Printf(`D("%s", %s, DnsProvider(%s)`, zonename, *flagRegText, *flagProviderText) rrFormat(zonename, filename, recs, defTTL, true) @@ -151,11 +151,6 @@ func readOctodns(zonename string, r io.Reader, filename string) []dns.RR { return l } -// pretty outputs the zonefile using the prettyprinter. -func writePretty(zonename string, recs []dns.RR, defaultTTL uint32) { - bind.WriteZoneFile(os.Stdout, recs, zonename) -} - // rrFormat outputs the zonefile in either DSL or TSV format. func rrFormat(zonename string, filename string, recs []dns.RR, defaultTTL uint32, dsl bool) { zonenamedot := zonename + "." diff --git a/commands/getZones.go b/commands/getZones.go new file mode 100644 index 000000000..37feb9764 --- /dev/null +++ b/commands/getZones.go @@ -0,0 +1,207 @@ +package commands + +import ( + "fmt" + "os" + "strings" + + "github.com/StackExchange/dnscontrol/v2/models" + "github.com/StackExchange/dnscontrol/v2/pkg/prettyzone" + "github.com/StackExchange/dnscontrol/v2/providers" + "github.com/StackExchange/dnscontrol/v2/providers/config" + "github.com/urfave/cli/v2" +) + +var _ = cmd(catUtils, func() *cli.Command { + var args GetZoneArgs + return &cli.Command{ + Name: "get-zones", + Aliases: []string{"get-zone"}, + Usage: "gets a zone from a provider (stand-alone)", + Action: func(ctx *cli.Context) error { + if ctx.NArg() < 3 { + return cli.NewExitError("Arguments should be: credskey providername zone(s) (Ex: r53 ROUTE53 example.com)", 1) + + } + args.CredName = ctx.Args().Get(0) + args.ProviderName = ctx.Args().Get(1) + args.ZoneNames = ctx.Args().Slice()[2:] + return exit(GetZone(args)) + }, + Flags: args.flags(), + UsageText: "dnscontrol get-zones [command options] credkey provider zone [...]", + Description: `Download a zone from a provider. This is a stand-alone utility. + +ARGUMENTS: + credkey: The name used in creds.json (first parameter to NewDnsProvider() in dnsconfig.js) + provider: The name of the provider (second parameter to NewDnsProvider() in dnsconfig.js) + zone: One or more zones (domains) to download; or "all". + +FORMATS: + --format=dsl dnsconfig.js format (a decent first draft) + --format=pretty BIND Zonefile format + --format=tsv TAB separated value (useful for AWK) + --format=tsvfqdn tsv with FQDNs (useful for multiple zones) + +EXAMPLES: + dnscontrol get-zones myr53 ROUTE53 example.com + dnscontrol get-zones gmain GANDI_V5 example.comn other.com + dnscontrol get-zones cfmain CLOUDFLAREAPI all + dnscontrol get-zones -format=tsv bind BIND example.com + dnscontrol get-zones -format=dsl -out=draft.js glcoud GCLOUD example.com`, + } +}()) + +// GetZoneArgs args required for the create-domain subcommand. +type GetZoneArgs struct { + GetCredentialsArgs // Args related to creds.json + CredName string // key in creds.json + ProviderName string // provider name: BIND, GANDI_V5, etc or "-" + ZoneNames []string // The zones to get + OutputFormat string // Output format + OutputFile string // Filename to send output ("" means stdout) + DefaultTTL int // default TTL for providers where it is unknown +} + +func (args *GetZoneArgs) flags() []cli.Flag { + flags := args.GetCredentialsArgs.flags() + flags = append(flags, &cli.StringFlag{ + Name: "format", + Destination: &args.OutputFormat, + Value: "pretty", + Usage: `Output format: dsl pretty tsv tsvfqdn`, + }) + flags = append(flags, &cli.StringFlag{ + Name: "out", + Destination: &args.OutputFile, + Usage: `Instead of stdout, write to this file`, + }) + flags = append(flags, &cli.IntFlag{ + Name: "ttl", + Destination: &args.DefaultTTL, + Usage: `Default TTL`, + Value: 300, + }) + return flags +} + +// GetZone contains all data/flags needed to run get-zones, independently of CLI. +func GetZone(args GetZoneArgs) error { + var providerConfigs map[string]map[string]string + var err error + + // Read it in: + providerConfigs, err = config.LoadProviderConfigs(args.CredsFile) + if err != nil { + return err + } + provider, err := providers.CreateDNSProvider(args.ProviderName, providerConfigs[args.CredName], nil) + if err != nil { + return err + } + + // decide which zones we need to convert + zones := args.ZoneNames + if len(args.ZoneNames) == 1 && args.ZoneNames[0] == "all" { + lister, ok := provider.(providers.ZoneLister) + if !ok { + return fmt.Errorf("provider type %s cannot list zones to use the 'all' feature", args.ProviderName) + } + zones, err = lister.ListZones() + if err != nil { + return err + } + } + + // actually fetch all of the records + zoneRecs := make([]models.Records, len(zones)) + for i, zone := range zones { + recs, err := provider.GetZoneRecords(zone) + if err != nil { + return err + } + zoneRecs[i] = recs + } + + // Write it out: + + // first open output stream and print initial header (if applicable) + w := os.Stdout + if args.OutputFile != "" { + w, err = os.Create(args.OutputFile) + } + if err != nil { + return err + } + defer w.Close() + if args.OutputFormat == "dsl" { + fmt.Fprintf(w, `var %s = NewDnsProvider("%s", "%s");`+"\n", + args.CredName, args.CredName, args.ProviderName) + } + + for i, recs := range zoneRecs { + zoneName := zones[i] + // now print all zones + z := prettyzone.PrettySort(recs, zoneName, 0) + switch args.OutputFormat { + case "pretty": + fmt.Fprintf(w, "$ORIGIN %s.\n", zoneName) + prettyzone.WriteZoneFileRC(w, z.Records, zoneName) + fmt.Fprintln(w) + case "dsl": + + fmt.Fprintf(w, `D("%s", REG_CHANGEME,`+"\n", zoneName) + fmt.Fprintf(w, "\tDnsProvider(%s)", args.CredName) + for _, rec := range recs { + fmt.Fprint(w, formatDsl(zoneName, rec, uint32(args.DefaultTTL))) + } + fmt.Fprint(w, "\n)\n") + case "tsv": + for _, rec := range recs { + fmt.Fprintf(w, + fmt.Sprintf("%s\t%d\tIN\t%s\t%s\n", + rec.Name, rec.TTL, rec.Type, rec.GetTargetCombined())) + } + case "tsvfqdn": + for _, rec := range recs { + fmt.Fprintf(w, + fmt.Sprintf("%s\t%d\tIN\t%s\t%s\n", + rec.NameFQDN, rec.TTL, rec.Type, rec.GetTargetCombined())) + } + default: + return fmt.Errorf("format %q unknown", args.OutputFile) + } + } + return nil +} + +func formatDsl(zonename string, rec *models.RecordConfig, defaultTTL uint32) string { + + target := rec.GetTargetCombined() + + ttlop := "" + if rec.TTL != defaultTTL && rec.TTL != 0 { + ttlop = fmt.Sprintf(", TTL(%d)", rec.TTL) + } + + switch rec.Type { // #rtype_variations + case "MX": + target = fmt.Sprintf("%d, '%s'", rec.MxPreference, rec.GetTargetField()) + case "SOA": + case "TXT": + if len(rec.TxtStrings) == 1 { + target = `'` + rec.TxtStrings[0] + `'` + } else { + target = `['` + strings.Join(rec.TxtStrings, `', '`) + `']` + } + case "NS": + // NS records at the apex should be NAMESERVER() records. + if rec.Name == "@" { + return fmt.Sprintf(",\n\tNAMESERVER('%s'%s)", target, ttlop) + } + default: + target = "'" + target + "'" + } + + return fmt.Sprintf(",\n\t%s('%s', %s%s)", rec.Type, rec.Name, target, ttlop) +} diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 7a49a6502..4e0f78bc5 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -902,5 +902,72 @@ + + get-zones + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/adding-new-rtypes.md b/docs/adding-new-rtypes.md index 559dc09e4..0b6a7f102 100644 --- a/docs/adding-new-rtypes.md +++ b/docs/adding-new-rtypes.md @@ -53,7 +53,7 @@ a minimum. * Add the capability to the file `dnscontrol/providers/capabilities.go` (look for `CanUseAlias` and add it to the end of the list.) * Add this feature to the feature matrix in `dnscontrol/build/generate/featureMatrix.go` (Add it to the variable `matrix` then add it later in the file with a `setCap()` statement. -* Mark the `bind` provider as supporting this record type by updating `dnscontrol/providers/bind/bindProvider.go` (look for `providers.CanUs` and you'll see what to do). +* Mark the `bind` provider as supporting this record type by updating `dnscontrol/providers/bind/bindProvider.go` (look for `providers.CanUse` and you'll see what to do). ## Step 3: Add a helper function diff --git a/docs/get-zones.md b/docs/get-zones.md new file mode 100644 index 000000000..c7b4825e9 --- /dev/null +++ b/docs/get-zones.md @@ -0,0 +1,85 @@ +--- +layout: default +title: Get-Zones subcommand +--- + +# get-zones (was "convertzone") + +DNSControl has a stand-alone utility that will contact a provider, +download the records of one or more zones, and output them to a file +in a variety of formats. + +The original purpose of this command is to help convert legacy domains +to DNScontrol (bootstrapping). Since bootstrapping can not depend on +`dnsconfig.js`, `get-zones` relies on command line parameters and +`creds.json` exclusively. + +Syntax: + + dnscontrol get-zones [command options] credkey provider zone [...] + + --creds value Provider credentials JSON file (default: "creds.json") + --format value Output format: dsl tsv pretty (default: "pretty") + --out value Instead of stdout, write to this file + +ARGUMENTS: + credkey: The name used in creds.json (first parameter to NewDnsProvider() in dnsconfig.js) + provider: The name of the provider (second parameter to NewDnsProvider() in dnsconfig.js) + zone: One or more zones (domains) to download; or "all". + +EXAMPLES: + dnscontrol get-zones myr53 ROUTE53 example.com + dnscontrol get-zones gmain GANDI_V5 example.comn other.com + dnscontrol get-zones cfmain CLOUDFLAREAPI all + dnscontrol get-zones -format=tsv bind BIND example.com + dnscontrol get-zones -format=dsl -out=draft.js glcoud GCLOUD example.com`, + + +# Example commands + +dnscontrol get-zone + +# Developer Note + +This command is not implemented for all providers. + +To add this to a provider: + +1. Document the feature + +In the `*Provider.go` file, change the setting to implemented. + +* OLD: ` providers.CanGetZones: providers.Unimplemented(),` +* NEW: ` providers.CanGetZones: providers.Can(),` + +2. Update the docs + +``` +go generate +``` + +3. Implement the `GetZoneRecords` function + +Find the `GetZoneRecords` function in the `*Provider.go` file. + +If currently returns `fmt.Errorf("not implemented")`. + +Instead, it should gather the records from the provider +and return them as a list of RecordConfig structs. + +The code to do that already exists in `GetDomainCorrections`. +You should extract it into its own function (`GetZoneRecords`), rather +than having it be burried in the middle of `GetDomainCorrections`. +`GetDomainCorrections` should call `GetZoneRecords`. + +Once that is done the `get-zone` subcommand should work. + +4. Optionally implemement the `ListZones` function + +If the `ListZones` function is implemented, the command will activate +the ability to specify `all` as the zone, at which point all zones +will be downloaded. + +(Technically what is happening is by implementing the `ListZones` +function, you are completing the `ZoneLister` interface for that +provider.) diff --git a/docs/getting-started.md b/docs/getting-started.md index 475215f32..558582005 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -244,9 +244,10 @@ The [Migrating]({{site.github.url}}/migrating) doc has advice about converting from other systems. You can manually create the `D()` statements, or you can generate them automatically using the -[convertzone](https://github.com/StackExchange/dnscontrol/blob/master/cmd/convertzone/README.md) -utility that is included in the DNSControl repo (it converts -BIND-style zone files and OctoDNS-style YAML files to DNSControl's language). +[dnscontrol get-zones]({{site.github.url}}/get-zones) +command to import the zone from (most) providers and output it as code +that can be added to `dnsconfig.js` and used with very little +modification. Now you can make change to the domain(s) and run `dnscontrol preview` diff --git a/docs/migrating.md b/docs/migrating.md index 934fde2cb..3ead689e6 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -42,34 +42,51 @@ For a small domain you can probably create the `D()` statements by hand, possibly with your text editor's search and replace functions. However, where's the fun in that? -The `convertzone` tool can automate 90% of the conversion for you. It -reads a BIND-style zone file or an OctoDNS-style YAML file and outputs a `D()` statement -that is usually fairly complete. You may need to touch it up a bit. +The `dnscontrol get-zones` subcommand +[documented here]({{site.github.url}}/get-zones) +can automate 90% of the conversion for you. It +reads BIND-style zonefiles, or will use a providers API to gather the DNS records. It will then output the records in a variety of formats, including +the as a `D()` statement +that is usually fairly complete. You may need to touch it up a bit, +especially if you use pseudo record types in one provider that are +not supported by another. -The convertzone command is in the `cmd/convertzone` subdirectory. -Build instructions are -[here](https://github.com/StackExchange/dnscontrol/blob/master/cmd/convertzone/README.md). +Example 1: Read a BIND zonefile -If you do not use BIND already, most DNS providers will export your -existing zone data to a file called the BIND zone file format. +Most DNS Service Providers have an 'export to zonefile' feature. -For example, suppose you owned the `foo.com` domain and the zone file -was in a file called `old/zone.foo.com`. This command will convert the file: +``` +dnscontrol get-zones -format=dsl bind BIND example.com +dnscontrol get-zones -format=dsl -out=draft.js bind BIND example.com +``` - convertzone -out=dsl foo.com first-draft.js +This will read the file `zones/example.com.zone`. The system is a bit +inflexible and that must be the filename. You can copy the file to +that name, or use a symlink. -If you are converting an OctoDNS file, add the flag `-in=octodns`: +Add the contents of `draft.js` to `dnsconfig.js` and edit it as needed. - convertzone -in=octodns -out=dsl foo.com first-draft.js +Example 2: Read from a provider -Add the contents of `first-draft.js` to `dnsconfig.js` +This requires creating a `creds.json` file as described in +[Getting Started]({{site.github.url}}/getting-started). + +Suppose your `creds.json` file has the name `global_aws` +for the provider `ROUTE53`. Your command would look like this: + +``` +dnscontrol get-zones -format=dsl global_aws ROUTE53 example.com +dnscontrol get-zones -format=dsl -out=draft.js global_aws ROUTE53 example.com +``` + +Add the contents of `draft.js` to `dnsconfig.js` and edit it as needed. Run `dnscontrol preview` and see if it finds any differences. Edit dnsconfig.js until `dnscontrol preview` shows no errors and no changes to be made. This means the conversion of your old DNS data is correct. -convertzone makes a guess at what to do with NS records. +`dnscontrol get-zones` makes a guess at what to do with NS records. An NS record at the apex is turned into a NAMESERVER() call, the rest are left as NS(). You probably want to check each of them for correctness. @@ -81,7 +98,7 @@ Of course, once `dnscontrol preview` runs cleanly, you can do any kind of cleanups you want. In fact, they should be easier to do now that you are using DNSControl! -If convertzone could have done a better job, please +If `dnscontrol get-zones` could have done a better job, please [let us know](https://github.com/StackExchange/dnscontrol/issues)! ## Example workflow @@ -91,7 +108,8 @@ to convert a zone. Lines that start with `#` are comments. # Note this command uses ">>" to append to dnsconfig.js. Do # not use ">" as that will erase the existing file. - convertzone -out=dsl foo.com >dnsconfig.js + dnscontrol get-zones -format=dsl -out=draft.js bind BIND foo.com + cat >>dnsconfig.js draft.js # Append to dnsconfig.js # dnscontrol preview vim dnsconfig.js @@ -105,4 +123,4 @@ to convert a zone. Lines that start with `#` are comments. vim dnsconfig.js dnscontrol preview # (repeat until all warnings/errors are resolved) - dnscontrol push \ No newline at end of file + dnscontrol push diff --git a/go.sum b/go.sum index 5313032e5..81ebf5819 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,7 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55 h1:jbGlDKdzAZ92NzK65hUP98ri0/r50vVVvmZsFP/nIqo= github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55/go.mod h1:GCzqZQHydohgVLSIqRKZeTt8IGb1Y4NaFfim3H40uUI= +github.com/StackExchange/dnscontrol v0.2.8 h1:7jviqDH9cIqRSRpH0UxgmpT7a8CwEhs9mLHBhoYhXo8= github.com/TomOnTime/utfutil v0.0.0-20180511104225-09c41003ee1d h1:WtAMR0fPCOfK7TPGZ8ZpLLY18HRvL7XJ3xcs0wnREgo= github.com/TomOnTime/utfutil v0.0.0-20180511104225-09c41003ee1d/go.mod h1:WML6KOYjeU8N6YyusMjj2qRvaPNUEvrQvaxuFcMRFJY= github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= diff --git a/models/dns_test.go b/models/dns_test.go index 40cfb3d7c..b70c14a13 100644 --- a/models/dns_test.go +++ b/models/dns_test.go @@ -4,24 +4,6 @@ import ( "testing" ) -func TestHasRecordTypeName(t *testing.T) { - x := &RecordConfig{ - Type: "A", - Name: "@", - } - dc := DomainConfig{} - if dc.HasRecordTypeName("A", "@") { - t.Errorf("%v: expected (%v) got (%v)\n", dc.Records, false, true) - } - dc.Records = append(dc.Records, x) - if !dc.HasRecordTypeName("A", "@") { - t.Errorf("%v: expected (%v) got (%v)\n", dc.Records, true, false) - } - if dc.HasRecordTypeName("AAAA", "@") { - t.Errorf("%v: expected (%v) got (%v)\n", dc.Records, false, true) - } -} - func TestRR(t *testing.T) { experiment := RecordConfig{ Type: "A", @@ -75,10 +57,10 @@ func TestDowncase(t *testing.T) { &RecordConfig{Type: "MX", Name: "UPPER", Target: "TARGETMX"}, }} downcase(dc.Records) - if !dc.HasRecordTypeName("MX", "lower") { + if !dc.Records.HasRecordTypeName("MX", "lower") { t.Errorf("%v: expected (%v) got (%v)\n", dc.Records, false, true) } - if !dc.HasRecordTypeName("MX", "upper") { + if !dc.Records.HasRecordTypeName("MX", "upper") { t.Errorf("%v: expected (%v) got (%v)\n", dc.Records, false, true) } if dc.Records[0].GetTargetField() != "targetmx" { diff --git a/models/dnsrr.go b/models/dnsrr.go new file mode 100644 index 000000000..b9a9e625a --- /dev/null +++ b/models/dnsrr.go @@ -0,0 +1,123 @@ +package models + +// methods that make RecordConfig meet the dns.RR interface. + +import ( + "fmt" + "log" + "strings" + + "github.com/miekg/dns" +) + +//// Header Header returns the header of an resource record. +//func (rc *RecordConfig) Header() *dns.RR_Header { +// log.Fatal("Header not implemented") +// return nil +//} + +// String returns the text representation of the resource record. +func (rc *RecordConfig) String() string { + return rc.GetTargetCombined() +} + +//// copy returns a copy of the RR +//func (rc *RecordConfig) copy() dns.RR { +// log.Fatal("Copy not implemented") +// return dns.TypeToRR[dns.TypeA]() +//} +// +//// len returns the length (in octets) of the uncompressed RR in wire format. +//func (rc *RecordConfig) len() int { +// log.Fatal("len not implemented") +// return 0 +//} +// +//// pack packs an RR into wire format. +//func (rc *RecordConfig) pack([]byte, int, map[string]int, bool) (int, error) { +// log.Fatal("pack not implemented") +// return 0, nil +//} + +// Conversions + +// RRstoRCs converts []dns.RR to []RecordConfigs. +func RRstoRCs(rrs []dns.RR, origin string, replaceSerial uint32) Records { + rcs := make(Records, 0, len(rrs)) + var x uint32 + for _, r := range rrs { + var rc RecordConfig + //fmt.Printf("CONVERT: %+v\n", r) + rc, x = RRtoRC(r, origin, replaceSerial) + replaceSerial = x + rcs = append(rcs, &rc) + } + return rcs +} + +// RRtoRC converts dns.RR to RecordConfig +func RRtoRC(rr dns.RR, origin string, replaceSerial uint32) (RecordConfig, uint32) { + // Convert's dns.RR into our native data type (RecordConfig). + // Records are translated directly with no changes. + // If it is an SOA for the apex domain and + // replaceSerial != 0, change the serial to replaceSerial. + // WARNING(tlim): This assumes SOAs do not have serial=0. + // If one is found, we replace it with serial=1. + var oldSerial, newSerial uint32 + header := rr.Header() + rc := new(RecordConfig) + rc.Type = dns.TypeToString[header.Rrtype] + rc.TTL = header.Ttl + rc.Original = rr + rc.SetLabelFromFQDN(strings.TrimSuffix(header.Name, "."), origin) + switch v := rr.(type) { // #rtype_variations + case *dns.A: + panicInvalid(rc.SetTarget(v.A.String())) + case *dns.AAAA: + panicInvalid(rc.SetTarget(v.AAAA.String())) + case *dns.CAA: + panicInvalid(rc.SetTargetCAA(v.Flag, v.Tag, v.Value)) + case *dns.CNAME: + panicInvalid(rc.SetTarget(v.Target)) + case *dns.MX: + panicInvalid(rc.SetTargetMX(v.Preference, v.Mx)) + case *dns.NS: + panicInvalid(rc.SetTarget(v.Ns)) + case *dns.PTR: + panicInvalid(rc.SetTarget(v.Ptr)) + case *dns.NAPTR: + panicInvalid(rc.SetTargetNAPTR(v.Order, v.Preference, v.Flags, v.Service, v.Regexp, v.Replacement)) + case *dns.SOA: + oldSerial = v.Serial + if oldSerial == 0 { + // For SOA records, we never return a 0 serial number. + oldSerial = 1 + } + newSerial = v.Serial + if rc.GetLabel() == "@" && replaceSerial != 0 { + newSerial = replaceSerial + } + panicInvalid(rc.SetTarget( + fmt.Sprintf("%v %v %v %v %v %v %v", + v.Ns, v.Mbox, newSerial, v.Refresh, v.Retry, v.Expire, v.Minttl), + )) + // FIXME(tlim): SOA should be handled by splitting out the fields. + case *dns.SRV: + panicInvalid(rc.SetTargetSRV(v.Priority, v.Weight, v.Port, v.Target)) + case *dns.SSHFP: + panicInvalid(rc.SetTargetSSHFP(v.Algorithm, v.Type, v.FingerPrint)) + case *dns.TLSA: + panicInvalid(rc.SetTargetTLSA(v.Usage, v.Selector, v.MatchingType, v.Certificate)) + case *dns.TXT: + panicInvalid(rc.SetTargetTXTs(v.Txt)) + default: + log.Fatalf("rrToRecord: Unimplemented zone record type=%s (%v)\n", rc.Type, rr) + } + return *rc, oldSerial +} + +func panicInvalid(err error) { + if err != nil { + panic(fmt.Errorf("unparsable record received from BIND: %w", err)) + } +} diff --git a/models/domain.go b/models/domain.go index 633e7e47e..d25a93706 100644 --- a/models/domain.go +++ b/models/domain.go @@ -47,16 +47,6 @@ func (dc *DomainConfig) Copy() (*DomainConfig, error) { return newDc, err } -// HasRecordTypeName returns True if there is a record with this rtype and name. -func (dc *DomainConfig) HasRecordTypeName(rtype, name string) bool { - for _, r := range dc.Records { - if r.Type == rtype && r.GetLabel() == name { - return true - } - } - return false -} - // Filter removes all records that don't match the filter f. func (dc *DomainConfig) Filter(f func(r *RecordConfig) bool) { recs := []*RecordConfig{} diff --git a/models/provider.go b/models/provider.go index 3a93a23c6..54c07f28e 100644 --- a/models/provider.go +++ b/models/provider.go @@ -4,20 +4,9 @@ package models type DNSProvider interface { GetNameservers(domain string) ([]*Nameserver, error) GetDomainCorrections(dc *DomainConfig) ([]*Correction, error) + GetZoneRecords(domain string) (Records, error) } -// DNSProvider3 will replace DNSProvider in 3.0. -// If you want to future-proof your code, implement these -// functions and implement GetDomainCorrections() as in -// providers/gandi_v5/gandi_v5Provider.go -//type DNSProvider3 interface { -// GetNameservers(domain string) ([]*Nameserver, error) -// GetZoneRecords(domain string) (Records, error) -// PrepFoundRecords(recs Records) Records -// PrepDesiredRecords(dc *DomainConfig) -// GenerateDomainCorrections(dc *DomainConfig, existing Records) ([]*Correction, error) -//} - // Registrar is an interface for Registrar plug-ins. type Registrar interface { GetRegistrarCorrections(dc *DomainConfig) ([]*Correction, error) diff --git a/models/record.go b/models/record.go index a51e75d93..4b5c8c5f4 100644 --- a/models/record.go +++ b/models/record.go @@ -305,6 +305,16 @@ func (rc *RecordConfig) Key() RecordKey { // Records is a list of *RecordConfig. type Records []*RecordConfig +// HasRecordTypeName returns True if there is a record with this rtype and name. +func (recs Records) HasRecordTypeName(rtype, name string) bool { + for _, r := range recs { + if r.Type == rtype && r.Name == name { + return true + } + } + return false +} + // FQDNMap returns a map of all LabelFQDNs. Useful for making a // truthtable of labels that exist in Records. func (r Records) FQDNMap() (m map[string]bool) { diff --git a/models/record_test.go b/models/record_test.go index be361e577..1d0fe28af 100644 --- a/models/record_test.go +++ b/models/record_test.go @@ -2,6 +2,24 @@ package models import "testing" +func TestHasRecordTypeName(t *testing.T) { + x := &RecordConfig{ + Type: "A", + Name: "@", + } + recs := Records{} + if recs.HasRecordTypeName("A", "@") { + t.Errorf("%v: expected (%v) got (%v)\n", recs, false, true) + } + recs = append(recs, x) + if !recs.HasRecordTypeName("A", "@") { + t.Errorf("%v: expected (%v) got (%v)\n", recs, true, false) + } + if recs.HasRecordTypeName("AAAA", "@") { + t.Errorf("%v: expected (%v) got (%v)\n", recs, false, true) + } +} + func TestKey(t *testing.T) { var tests = []struct { rc RecordConfig diff --git a/pkg/normalize/validate.go b/pkg/normalize/validate.go index 60a921c86..977f9a391 100644 --- a/pkg/normalize/validate.go +++ b/pkg/normalize/validate.go @@ -207,7 +207,7 @@ func importTransform(srcDomain, dstDomain *models.DomainConfig, transforms []tra // 4. For As, change the target as described the transforms. for _, rec := range srcDomain.Records { - if dstDomain.HasRecordTypeName(rec.Type, rec.GetLabelFQDN()) { + if dstDomain.Records.HasRecordTypeName(rec.Type, rec.GetLabelFQDN()) { continue } newRec := func() *models.RecordConfig { diff --git a/pkg/prettyzone/prettyzone.go b/pkg/prettyzone/prettyzone.go new file mode 100644 index 000000000..ba62b8093 --- /dev/null +++ b/pkg/prettyzone/prettyzone.go @@ -0,0 +1,142 @@ +package prettyzone + +// Generate zonefiles. +// This generates a zonefile that prioritizes beauty over efficiency. + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/StackExchange/dnscontrol/v2/models" + "github.com/miekg/dns" +) + +// mostCommonTTL returns the most common TTL in a set of records. If there is +// a tie, the highest TTL is selected. This makes the results consistent. +// NS records are not included in the analysis because Tom said so. +func mostCommonTTL(records models.Records) uint32 { + // Index the TTLs in use: + d := make(map[uint32]int) + for _, r := range records { + if r.Type != "NS" { + d[r.TTL]++ + } + } + // Find the largest count: + var mc int + for _, value := range d { + if value > mc { + mc = value + } + } + // Find the largest key with that count: + var mk uint32 + for key, value := range d { + if value == mc { + if key > mk { + mk = key + } + } + } + return mk +} + +// WriteZoneFileRR is a helper for when you have []dns.RR instead of models.Records +func WriteZoneFileRR(w io.Writer, records []dns.RR, origin string, serial uint32) error { + return WriteZoneFileRC(w, models.RRstoRCs(records, origin, serial), origin) +} + +// WriteZoneFileRC writes a beautifully formatted zone file. +func WriteZoneFileRC(w io.Writer, records models.Records, origin string) error { + // This function prioritizes beauty over output size. + // * The zone records are sorted by label, grouped by subzones to + // be easy to read and pleasant to the eye. + // * Within a label, SOA and NS records are listed first. + // * MX records are sorted numericly by preference value. + // * SRV records are sorted numericly by port, then priority, then weight. + // * A records are sorted by IP address, not lexicographically. + // * Repeated labels are removed. + // * $TTL is used to eliminate clutter. The most common TTL value is used. + // * "@" is used instead of the apex domain name. + + z := PrettySort(records, origin, mostCommonTTL(records)) + + return z.generateZoneFileHelper(w) +} + +func PrettySort(records models.Records, origin string, defaultTTL uint32) *zoneGenData { + if defaultTTL == 0 { + defaultTTL = mostCommonTTL(records) + } + z := &zoneGenData{ + Origin: origin + ".", + DefaultTTL: defaultTTL, + } + z.Records = nil + for _, r := range records { + z.Records = append(z.Records, r) + } + return z +} + +// generateZoneFileHelper creates a pretty zonefile. +func (z *zoneGenData) generateZoneFileHelper(w io.Writer) error { + + nameShortPrevious := "" + + sort.Sort(z) + fmt.Fprintln(w, "$TTL", z.DefaultTTL) + for i, rr := range z.Records { + + // Fake types are commented out. + prefix := "" + _, ok := dns.StringToType[rr.Type] + if !ok { + prefix = ";" + } + + // name + nameShort := rr.Name + name := nameShort + if (prefix == "") && (i > 0 && nameShort == nameShortPrevious) { + name = "" + } else { + name = nameShort + } + nameShortPrevious = nameShort + + // ttl + ttl := "" + if rr.TTL != z.DefaultTTL && rr.TTL != 0 { + ttl = fmt.Sprint(rr.TTL) + } + + // type + typeStr := rr.Type + + // the remaining line + target := rr.GetTargetCombined() + + fmt.Fprintf(w, "%s%s\n", + prefix, formatLine([]int{10, 5, 2, 5, 0}, []string{name, ttl, "IN", typeStr, target})) + } + return nil +} + +func formatLine(lengths []int, fields []string) string { + c := 0 + result := "" + for i, length := range lengths { + item := fields[i] + for len(result) < c { + result += " " + } + if item != "" { + result += item + " " + } + c += length + 1 + } + return strings.TrimRight(result, " ") +} diff --git a/providers/bind/prettyzone_test.go b/pkg/prettyzone/prettyzone_test.go similarity index 77% rename from providers/bind/prettyzone_test.go rename to pkg/prettyzone/prettyzone_test.go index fc7f1ec7c..1e576d685 100644 --- a/providers/bind/prettyzone_test.go +++ b/pkg/prettyzone/prettyzone_test.go @@ -1,4 +1,4 @@ -package bind +package prettyzone import ( "bytes" @@ -7,6 +7,7 @@ import ( "math/rand" "testing" + "github.com/StackExchange/dnscontrol/v2/models" "github.com/miekg/dns" "github.com/miekg/dns/dnsutil" ) @@ -27,7 +28,7 @@ func parseAndRegen(t *testing.T, buf *bytes.Buffer, expected string) { } // Generate it back: buf2 := &bytes.Buffer{} - WriteZoneFile(buf2, parsed, "bosun.org.") + WriteZoneFileRR(buf2, parsed, "bosun.org", 99) // Compare: if buf2.String() != expected { @@ -47,7 +48,8 @@ func TestMostCommonTtl(t *testing.T) { // All records are TTL=100 records = nil records, e = append(records, r1, r1, r1), 100 - g = mostCommonTTL(records) + x := models.RRstoRCs(records, "bosun.org", 99) + g = mostCommonTTL(x) if e != g { t.Fatalf("expected %d; got %d\n", e, g) } @@ -55,7 +57,7 @@ func TestMostCommonTtl(t *testing.T) { // Mixture of TTLs with an obvious winner. records = nil records, e = append(records, r1, r2, r2), 200 - g = mostCommonTTL(records) + g = mostCommonTTL(models.RRstoRCs(records, "bosun.org", 99)) if e != g { t.Fatalf("expected %d; got %d\n", e, g) } @@ -63,7 +65,7 @@ func TestMostCommonTtl(t *testing.T) { // 3-way tie. Largest TTL should be used. records = nil records, e = append(records, r1, r2, r3), 300 - g = mostCommonTTL(records) + g = mostCommonTTL(models.RRstoRCs(records, "bosun.org", 99)) if e != g { t.Fatalf("expected %d; got %d\n", e, g) } @@ -71,7 +73,7 @@ func TestMostCommonTtl(t *testing.T) { // NS records are ignored. records = nil records, e = append(records, r1, r4, r5), 100 - g = mostCommonTTL(records) + g = mostCommonTTL(models.RRstoRCs(records, "bosun.org", 99)) if e != g { t.Fatalf("expected %d; got %d\n", e, g) } @@ -85,7 +87,7 @@ func TestWriteZoneFileSimple(t *testing.T) { r2, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.154") r3, _ := dns.NewRR("www.bosun.org. 300 IN CNAME bosun.org.") buf := &bytes.Buffer{} - WriteZoneFile(buf, []dns.RR{r1, r2, r3}, "bosun.org.") + WriteZoneFileRR(buf, []dns.RR{r1, r2, r3}, "bosun.org", 99) expected := `$TTL 300 @ IN A 192.30.252.153 IN A 192.30.252.154 @@ -106,7 +108,7 @@ func TestWriteZoneFileSimpleTtl(t *testing.T) { r3, _ := dns.NewRR("bosun.org. 100 IN A 192.30.252.155") r4, _ := dns.NewRR("www.bosun.org. 300 IN CNAME bosun.org.") buf := &bytes.Buffer{} - WriteZoneFile(buf, []dns.RR{r1, r2, r3, r4}, "bosun.org.") + WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4}, "bosun.org", 99) expected := `$TTL 100 @ IN A 192.30.252.153 IN A 192.30.252.154 @@ -116,26 +118,27 @@ www 300 IN CNAME bosun.org. if buf.String() != expected { t.Log(buf.String()) t.Log(expected) - t.Fatalf("Zone file does not match.") + t.Fatalf("Zone file does not match") } parseAndRegen(t, buf, expected) } func TestWriteZoneFileMx(t *testing.T) { - // exhibits explicit ttls and long name - r1, _ := dns.NewRR(`bosun.org. 300 IN TXT "aaa"`) - r2, _ := dns.NewRR(`bosun.org. 300 IN TXT "bbb"`) - r2.(*dns.TXT).Txt[0] = `b"bb` - r3, _ := dns.NewRR("bosun.org. 300 IN MX 1 ASPMX.L.GOOGLE.COM.") - r4, _ := dns.NewRR("bosun.org. 300 IN MX 5 ALT1.ASPMX.L.GOOGLE.COM.") - r5, _ := dns.NewRR("bosun.org. 300 IN MX 10 ASPMX3.GOOGLEMAIL.COM.") - r6, _ := dns.NewRR("bosun.org. 300 IN A 198.252.206.16") - r7, _ := dns.NewRR("*.bosun.org. 600 IN A 198.252.206.16") - r8, _ := dns.NewRR(`_domainkey.bosun.org. 300 IN TXT "vvvv"`) - r9, _ := dns.NewRR(`google._domainkey.bosun.org. 300 IN TXT "\"foo\""`) + // sort by priority + r1, _ := dns.NewRR("aaa.bosun.org. IN MX 1 aaa.example.com.") + r2, _ := dns.NewRR("aaa.bosun.org. IN MX 5 aaa.example.com.") + r3, _ := dns.NewRR("aaa.bosun.org. IN MX 10 aaa.example.com.") + // same priority? sort by name + r4, _ := dns.NewRR("bbb.bosun.org. IN MX 10 ccc.example.com.") + r5, _ := dns.NewRR("bbb.bosun.org. IN MX 10 bbb.example.com.") + r6, _ := dns.NewRR("bbb.bosun.org. IN MX 10 aaa.example.com.") + // a mix + r7, _ := dns.NewRR("ccc.bosun.org. IN MX 40 zzz.example.com.") + r8, _ := dns.NewRR("ccc.bosun.org. IN MX 40 aaa.example.com.") + r9, _ := dns.NewRR("ccc.bosun.org. IN MX 1 ttt.example.com.") buf := &bytes.Buffer{} - WriteZoneFile(buf, []dns.RR{r1, r2, r3, r4, r5, r6, r7, r8, r9}, "bosun.org") + WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4, r5, r6, r7, r8, r9}, "bosun.org", 99) if buf.String() != testdataZFMX { t.Log(buf.String()) t.Log(testdataZFMX) @@ -144,16 +147,16 @@ func TestWriteZoneFileMx(t *testing.T) { parseAndRegen(t, buf, testdataZFMX) } -var testdataZFMX = `$TTL 300 -@ IN A 198.252.206.16 - IN MX 1 ASPMX.L.GOOGLE.COM. - IN MX 5 ALT1.ASPMX.L.GOOGLE.COM. - IN MX 10 ASPMX3.GOOGLEMAIL.COM. - IN TXT "aaa" - IN TXT "b\"bb" -* 600 IN A 198.252.206.16 -_domainkey IN TXT "vvvv" -google._domainkey IN TXT "\"foo\"" +var testdataZFMX = `$TTL 3600 +aaa IN MX 1 aaa.example.com. + IN MX 5 aaa.example.com. + IN MX 10 aaa.example.com. +bbb IN MX 10 aaa.example.com. + IN MX 10 bbb.example.com. + IN MX 10 ccc.example.com. +ccc IN MX 1 ttt.example.com. + IN MX 40 aaa.example.com. + IN MX 40 zzz.example.com. ` func TestWriteZoneFileSrv(t *testing.T) { @@ -164,7 +167,7 @@ func TestWriteZoneFileSrv(t *testing.T) { r4, _ := dns.NewRR(`bosun.org. 300 IN SRV 20 10 5050 foo.com.`) r5, _ := dns.NewRR(`bosun.org. 300 IN SRV 10 10 5050 foo.com.`) buf := &bytes.Buffer{} - WriteZoneFile(buf, []dns.RR{r1, r2, r3, r4, r5}, "bosun.org") + WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4, r5}, "bosun.org", 99) if buf.String() != testdataZFSRV { t.Log(buf.String()) t.Log(testdataZFSRV) @@ -187,7 +190,7 @@ func TestWriteZoneFilePtr(t *testing.T) { r2, _ := dns.NewRR(`bosun.org. 300 IN PTR barney.bosun.org.`) r3, _ := dns.NewRR(`bosun.org. 300 IN PTR alex.bosun.org.`) buf := &bytes.Buffer{} - WriteZoneFile(buf, []dns.RR{r1, r2, r3}, "bosun.org") + WriteZoneFileRR(buf, []dns.RR{r1, r2, r3}, "bosun.org", 99) if buf.String() != testdataZFPTR { t.Log(buf.String()) t.Log(testdataZFPTR) @@ -211,7 +214,7 @@ func TestWriteZoneFileCaa(t *testing.T) { r5, _ := dns.NewRR(`bosun.org. 300 IN CAA 0 iodef "https://example.net"`) r6, _ := dns.NewRR(`bosun.org. 300 IN CAA 1 iodef "mailto:example.com"`) buf := &bytes.Buffer{} - WriteZoneFile(buf, []dns.RR{r1, r2, r3, r4, r5, r6}, "bosun.org") + WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4, r5, r6}, "bosun.org", 99) if buf.String() != testdataZFCAA { t.Log(buf.String()) t.Log(testdataZFCAA) @@ -255,7 +258,7 @@ func TestWriteZoneFileEach(t *testing.T) { d = append(d, mustNewRR(`sub.bosun.org. 300 IN NS bosun.org.`)) // Must be a label with no other records. d = append(d, mustNewRR(`x.bosun.org. 300 IN CNAME bosun.org.`)) // Must be a label with no other records. buf := &bytes.Buffer{} - WriteZoneFile(buf, d, "bosun.org") + WriteZoneFileRR(buf, d, "bosun.org", 99) if buf.String() != testdataZFEach { t.Log(buf.String()) t.Log(testdataZFEach) @@ -265,18 +268,49 @@ func TestWriteZoneFileEach(t *testing.T) { } var testdataZFEach = `$TTL 300 -4.5. IN PTR y.bosun.org. @ IN A 1.2.3.4 - IN MX 1 bosun.org. - IN TXT "my text" IN AAAA 4500:fe::1 + IN MX 1 bosun.org. IN SRV 10 10 9999 foo.com. + IN TXT "my text" IN CAA 0 issue "letsencrypt.org" +4.5 IN PTR y.bosun.org. _443._tcp IN TLSA 3 1 1 abcdef0 sub IN NS bosun.org. x IN CNAME bosun.org. ` +func TestWriteZoneFileSynth(t *testing.T) { + r1, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.153") + r2, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.154") + r3, _ := dns.NewRR("www.bosun.org. 300 IN CNAME bosun.org.") + rsynm := &models.RecordConfig{Type: "R53_ALIAS", TTL: 300} + rsynm.SetLabel("myalias", "bosun.org") + rsynz := &models.RecordConfig{Type: "R53_ALIAS", TTL: 300} + rsynz.SetLabel("zalias", "bosun.org") + + recs := models.RRstoRCs([]dns.RR{r1, r2, r3}, "bosun.org", 99) + recs = append(recs, rsynm) + recs = append(recs, rsynm) + recs = append(recs, rsynz) + + buf := &bytes.Buffer{} + WriteZoneFileRC(buf, recs, "bosun.org") + expected := `$TTL 300 +@ IN A 192.30.252.153 + IN A 192.30.252.154 +;myalias IN R53_ALIAS type= zone_id= +;myalias IN R53_ALIAS type= zone_id= +www IN CNAME bosun.org. +;zalias IN R53_ALIAS type= zone_id= +` + if buf.String() != expected { + t.Log(buf.String()) + t.Log(expected) + t.Fatalf("Zone file does not match.") + } +} + // Test sorting func TestWriteZoneFileOrder(t *testing.T) { @@ -305,7 +339,7 @@ func TestWriteZoneFileOrder(t *testing.T) { } buf := &bytes.Buffer{} - WriteZoneFile(buf, records, "stackoverflow.com.") + WriteZoneFileRR(buf, records, "stackoverflow.com", 99) // Compare if buf.String() != testdataOrder { t.Log("Found:") @@ -325,7 +359,7 @@ func TestWriteZoneFileOrder(t *testing.T) { } // Generate buf := &bytes.Buffer{} - WriteZoneFile(buf, records, "stackoverflow.com.") + WriteZoneFileRR(buf, records, "stackoverflow.com", 99) // Compare if buf.String() != testdataOrder { t.Log(buf.String()) @@ -452,25 +486,25 @@ func TestZoneRrtypeLess(t *testing.T) { */ var tests = []struct { - e1, e2 uint16 + e1, e2 string expected bool }{ - {dns.TypeSOA, dns.TypeSOA, false}, - {dns.TypeSOA, dns.TypeA, true}, - {dns.TypeSOA, dns.TypeTXT, true}, - {dns.TypeSOA, dns.TypeNS, true}, - {dns.TypeNS, dns.TypeSOA, false}, - {dns.TypeNS, dns.TypeA, true}, - {dns.TypeNS, dns.TypeTXT, true}, - {dns.TypeNS, dns.TypeNS, false}, - {dns.TypeA, dns.TypeSOA, false}, - {dns.TypeA, dns.TypeA, false}, - {dns.TypeA, dns.TypeTXT, true}, - {dns.TypeA, dns.TypeNS, false}, - {dns.TypeMX, dns.TypeSOA, false}, - {dns.TypeMX, dns.TypeA, false}, - {dns.TypeMX, dns.TypeTXT, true}, - {dns.TypeMX, dns.TypeNS, false}, + {"SOA", "SOA", false}, + {"SOA", "A", true}, + {"SOA", "TXT", true}, + {"SOA", "NS", true}, + {"NS", "SOA", false}, + {"NS", "A", true}, + {"NS", "TXT", true}, + {"NS", "NS", false}, + {"A", "SOA", false}, + {"A", "A", false}, + {"A", "TXT", true}, + {"A", "NS", false}, + {"MX", "SOA", false}, + {"MX", "A", false}, + {"MX", "TXT", true}, + {"MX", "NS", false}, } for _, test := range tests { diff --git a/pkg/prettyzone/sorting.go b/pkg/prettyzone/sorting.go new file mode 100644 index 000000000..03a78e420 --- /dev/null +++ b/pkg/prettyzone/sorting.go @@ -0,0 +1,194 @@ +package prettyzone + +// Generate zonefiles. +// This generates a zonefile that prioritizes beauty over efficiency. + +import ( + "bytes" + "log" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v2/models" +) + +type zoneGenData struct { + Origin string + DefaultTTL uint32 + Records models.Records +} + +func (z *zoneGenData) Len() int { return len(z.Records) } +func (z *zoneGenData) Swap(i, j int) { z.Records[i], z.Records[j] = z.Records[j], z.Records[i] } +func (z *zoneGenData) Less(i, j int) bool { + a, b := z.Records[i], z.Records[j] + + // Sort by name. + compA, compB := a.NameFQDN, b.NameFQDN + if compA != compB { + if a.Name == "@" { + compA = "@" + } + if b.Name == "@" { + compB = "@" + } + return zoneLabelLess(compA, compB) + } + + // sub-sort by type + if a.Type != b.Type { + return zoneRrtypeLess(a.Type, b.Type) + } + + // sub-sort within type: + switch a.Type { // #rtype_variations + case "A": + ta2, tb2 := a.GetTargetIP(), b.GetTargetIP() + ipa, ipb := ta2.To4(), tb2.To4() + if ipa == nil || ipb == nil { + log.Fatalf("should not happen: IPs are not 4 bytes: %#v %#v", ta2, tb2) + } + return bytes.Compare(ipa, ipb) == -1 + case "AAAA": + ta2, tb2 := a.GetTargetIP(), b.GetTargetIP() + ipa, ipb := ta2.To16(), tb2.To16() + if ipa == nil || ipb == nil { + log.Fatalf("should not happen: IPs are not 16 bytes: %#v %#v", ta2, tb2) + } + return bytes.Compare(ipa, ipb) == -1 + case "MX": + // sort by priority. If they are equal, sort by Mx. + if a.MxPreference == b.MxPreference { + return a.GetTargetField() < b.GetTargetField() + } + return a.MxPreference < b.MxPreference + case "SRV": + //ta2, tb2 := a.(*dns.SRV), b.(*dns.SRV) + pa, pb := a.SrvPort, b.SrvPort + if pa != pb { + return pa < pb + } + pa, pb = a.SrvPriority, b.SrvPriority + if pa != pb { + return pa < pb + } + pa, pb = a.SrvWeight, b.SrvWeight + if pa != pb { + return pa < pb + } + case "PTR": + //ta2, tb2 := a.(*dns.PTR), b.(*dns.PTR) + pa, pb := a.GetTargetField(), b.GetTargetField() + if pa != pb { + return pa < pb + } + case "CAA": + //ta2, tb2 := a.(*dns.CAA), b.(*dns.CAA) + // sort by tag + pa, pb := a.CaaTag, b.CaaTag + if pa != pb { + return pa < pb + } + // then flag + fa, fb := a.CaaFlag, b.CaaFlag + if fa != fb { + // flag set goes before ones without flag set + return fa > fb + } + default: + // pass through. String comparison is sufficient. + } + return a.String() < b.String() +} + +func zoneLabelLess(a, b string) bool { + // Compare two zone labels for the purpose of sorting the RRs in a Zone. + + // If they are equal, we are done. All other code is simplified + // because we can assume a!=b. + if a == b { + return false + } + + // Sort @ at the top, then *, then everything else lexigraphically. + // i.e. @ always is less. * is is less than everything but @. + if a == "@" { + return true + } + if b == "@" { + return false + } + if a == "*" { + return true + } + if b == "*" { + return false + } + + // Split into elements and match up last elements to first. Compare the + // first non-equal elements. + + as := strings.Split(a, ".") + bs := strings.Split(b, ".") + ia := len(as) - 1 + ib := len(bs) - 1 + + var min int + if ia < ib { + min = len(as) - 1 + } else { + min = len(bs) - 1 + } + + // Skip the matching highest elements, then compare the next item. + for i, j := ia, ib; min >= 0; i, j, min = i-1, j-1, min-1 { + // Compare as[i] < bs[j] + // Sort @ at the top, then *, then everything else. + // i.e. @ always is less. * is is less than everything but @. + // If both are numeric, compare as integers, otherwise as strings. + + if as[i] != bs[j] { + + // If the first element is *, it is always less. + if i == 0 && as[i] == "*" { + return true + } + if j == 0 && bs[j] == "*" { + return false + } + + // If the elements are both numeric, compare as integers: + au, aerr := strconv.ParseUint(as[i], 10, 64) + bu, berr := strconv.ParseUint(bs[j], 10, 64) + if aerr == nil && berr == nil { + return au < bu + } + // otherwise, compare as strings: + return as[i] < bs[j] + } + } + // The min top elements were equal, so the shorter name is less. + return ia < ib +} + +func zoneRrtypeLess(a, b string) bool { + // Compare two RR types for the purpose of sorting the RRs in a Zone. + + if a == b { + return false + } + + // List SOAs, NSs, etc. then all others alphabetically. + + for _, t := range []string{"SOA", "NS", "CNAME", + "A", "AAAA", "MX", "SRV", "TXT", + } { + if a == t { + return true + } + if b == t { + return false + } + } + return a < b +} diff --git a/providers/activedir/activedirProvider.go b/providers/activedir/activedirProvider.go index a9712bc09..0e501d0ac 100644 --- a/providers/activedir/activedirProvider.go +++ b/providers/activedir/activedirProvider.go @@ -24,6 +24,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Cannot("AD depends on the zone already existing on the dns server"), providers.DocDualHost: providers.Cannot("This driver does not manage NS records, so should not be used for dual-host scenarios"), providers.DocOfficiallySupported: providers.Can(), + providers.CanGetZones: providers.Unimplemented(), } // Register with the dnscontrol system. diff --git a/providers/activedir/domains.go b/providers/activedir/domains.go index a865d1d4e..235795492 100644 --- a/providers/activedir/domains.go +++ b/providers/activedir/domains.go @@ -37,6 +37,14 @@ var supportedTypes = map[string]bool{ "NS": true, } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *adProvider) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections gets existing records, diffs them against existing, and returns corrections. func (c *adProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { diff --git a/providers/azuredns/azureDnsProvider.go b/providers/azuredns/azureDnsProvider.go index 57b8052cf..2a55d4a5e 100644 --- a/providers/azuredns/azureDnsProvider.go +++ b/providers/azuredns/azureDnsProvider.go @@ -62,6 +62,7 @@ var features = providers.DocumentationNotes{ providers.CanUseNAPTR: providers.Cannot(), providers.CanUseSSHFP: providers.Cannot(), providers.CanUseTLSA: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -110,6 +111,14 @@ func (a *azureDnsProvider) GetNameservers(domain string) ([]*models.Nameserver, return ns, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *azureDnsProvider) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + func (a *azureDnsProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { err := dc.Punycode() diff --git a/providers/bind/bindProvider.go b/providers/bind/bindProvider.go index 77d8f44be..4a56d079a 100644 --- a/providers/bind/bindProvider.go +++ b/providers/bind/bindProvider.go @@ -25,6 +25,7 @@ import ( "github.com/miekg/dns" "github.com/StackExchange/dnscontrol/v2/models" + "github.com/StackExchange/dnscontrol/v2/pkg/prettyzone" "github.com/StackExchange/dnscontrol/v2/providers" "github.com/StackExchange/dnscontrol/v2/providers/diff" ) @@ -41,6 +42,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Can("Driver just maintains list of zone files. It should automatically add missing ones."), providers.DocDualHost: providers.Can(), providers.DocOfficiallySupported: providers.Can(), + providers.CanGetZones: providers.Can(), } func initBind(config map[string]string, providermeta json.RawMessage) (providers.DNSServiceProvider, error) { @@ -83,79 +85,12 @@ func (s SoaInfo) String() string { // Bind is the provider handle for the Bind driver. type Bind struct { - DefaultNS []string `json:"default_ns"` - DefaultSoa SoaInfo `json:"default_soa"` - nameservers []*models.Nameserver - directory string -} - -// var bindSkeletin = flag.String("bind_skeletin", "skeletin/master/var/named/chroot/var/named/master", "") - -func rrToRecord(rr dns.RR, origin string, replaceSerial uint32) (models.RecordConfig, uint32) { - // Convert's dns.RR into our native data type (models.RecordConfig). - // Records are translated directly with no changes. - // If it is an SOA for the apex domain and - // replaceSerial != 0, change the serial to replaceSerial. - // WARNING(tlim): This assumes SOAs do not have serial=0. - // If one is found, we replace it with serial=1. - var oldSerial, newSerial uint32 - header := rr.Header() - rc := models.RecordConfig{ - Type: dns.TypeToString[header.Rrtype], - TTL: header.Ttl, - } - rc.SetLabelFromFQDN(strings.TrimSuffix(header.Name, "."), origin) - switch v := rr.(type) { // #rtype_variations - case *dns.A: - panicInvalid(rc.SetTarget(v.A.String())) - case *dns.AAAA: - panicInvalid(rc.SetTarget(v.AAAA.String())) - case *dns.CAA: - panicInvalid(rc.SetTargetCAA(v.Flag, v.Tag, v.Value)) - case *dns.CNAME: - panicInvalid(rc.SetTarget(v.Target)) - case *dns.MX: - panicInvalid(rc.SetTargetMX(v.Preference, v.Mx)) - case *dns.NS: - panicInvalid(rc.SetTarget(v.Ns)) - case *dns.PTR: - panicInvalid(rc.SetTarget(v.Ptr)) - case *dns.NAPTR: - panicInvalid(rc.SetTargetNAPTR(v.Order, v.Preference, v.Flags, v.Service, v.Regexp, v.Replacement)) - case *dns.SOA: - oldSerial = v.Serial - if oldSerial == 0 { - // For SOA records, we never return a 0 serial number. - oldSerial = 1 - } - newSerial = v.Serial - //if (dnsutil.TrimDomainName(rc.Name, origin+".") == "@") && replaceSerial != 0 { - if rc.GetLabel() == "@" && replaceSerial != 0 { - newSerial = replaceSerial - } - panicInvalid(rc.SetTarget( - fmt.Sprintf("%v %v %v %v %v %v %v", - v.Ns, v.Mbox, newSerial, v.Refresh, v.Retry, v.Expire, v.Minttl), - )) - // FIXME(tlim): SOA should be handled by splitting out the fields. - case *dns.SRV: - panicInvalid(rc.SetTargetSRV(v.Priority, v.Weight, v.Port, v.Target)) - case *dns.SSHFP: - panicInvalid(rc.SetTargetSSHFP(v.Algorithm, v.Type, v.FingerPrint)) - case *dns.TLSA: - panicInvalid(rc.SetTargetTLSA(v.Usage, v.Selector, v.MatchingType, v.Certificate)) - case *dns.TXT: - panicInvalid(rc.SetTargetTXTs(v.Txt)) - default: - log.Fatalf("rrToRecord: Unimplemented zone record type=%s (%v)\n", rc.Type, rr) - } - return rc, oldSerial -} - -func panicInvalid(err error) { - if err != nil { - panic(fmt.Errorf("unparsable record received from BIND: %w", err)) - } + 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? } func makeDefaultSOA(info SoaInfo, origin string) *models.RecordConfig { @@ -195,6 +130,56 @@ func (c *Bind) GetNameservers(string) ([]*models.Nameserver, error) { return c.nameservers, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (c *Bind) GetZoneRecords(domain string) (models.Records, error) { + // Default SOA record. If we see one in the zone, this will be replaced. + + soaRec := makeDefaultSOA(c.DefaultSoa, domain) + foundRecords := models.Records{} + var oldSerial, newSerial uint32 + + if _, err := os.Stat(c.directory); os.IsNotExist(err) { + fmt.Printf("\nWARNING: BIND directory %q does not exist!\n", c.directory) + } + + zonefile := filepath.Join(c.directory, strings.Replace(strings.ToLower(domain), "/", "_", -1)+".zone") + c.zonefile = zonefile + foundFH, err := os.Open(zonefile) + c.zoneFileFound = err == nil + if err != nil && !os.IsNotExist(os.ErrNotExist) { + // Don't whine if the file doesn't exist. However all other + // errors will be reported. + fmt.Printf("Could not read zonefile: %v\n", err) + } else { + for x := range dns.ParseZone(foundFH, domain, zonefile) { + if x.Error != nil { + log.Println("Error in zonefile:", x.Error) + } else { + rec, serial := models.RRtoRC(x.RR, domain, oldSerial) + if serial != 0 && oldSerial != 0 { + log.Fatalf("Multiple SOA records in zonefile: %v\n", zonefile) + } + if serial != 0 { + // This was an SOA record. Update the serial. + oldSerial = serial + newSerial = generateSerial(oldSerial) + // Regenerate with new serial: + *soaRec, _ = models.RRtoRC(x.RR, domain, newSerial) + rec = *soaRec + } + foundRecords = append(foundRecords, &rec) + } + } + } + + // Add SOA record to expected set: + if !foundRecords.HasRecordTypeName("SOA", "@") { + //foundRecords = append(foundRecords, soaRec) + } + + return foundRecords, nil +} + // GetDomainCorrections returns a list of corrections to update a domain. func (c *Bind) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() @@ -211,49 +196,9 @@ func (c *Bind) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correcti // foundDiffRecords < foundRecords // diff.Inc...(foundDiffRecords, expectedDiffRecords ) - // Default SOA record. If we see one in the zone, this will be replaced. - soaRec := makeDefaultSOA(c.DefaultSoa, dc.Name) - - // Read foundRecords: - foundRecords := make([]*models.RecordConfig, 0) - var oldSerial, newSerial uint32 - - if _, err := os.Stat(c.directory); os.IsNotExist(err) { - fmt.Printf("\nWARNING: BIND directory %q does not exist!\n", c.directory) - } - - zonefile := filepath.Join(c.directory, strings.Replace(strings.ToLower(dc.Name), "/", "_", -1)+".zone") - foundFH, err := os.Open(zonefile) - zoneFileFound := err == nil - if err != nil && !os.IsNotExist(os.ErrNotExist) { - // Don't whine if the file doesn't exist. However all other - // errors will be reported. - fmt.Printf("Could not read zonefile: %v\n", err) - } else { - for x := range dns.ParseZone(foundFH, dc.Name, zonefile) { - if x.Error != nil { - log.Println("Error in zonefile:", x.Error) - } else { - rec, serial := rrToRecord(x.RR, dc.Name, oldSerial) - if serial != 0 && oldSerial != 0 { - log.Fatalf("Multiple SOA records in zonefile: %v\n", zonefile) - } - if serial != 0 { - // This was an SOA record. Update the serial. - oldSerial = serial - newSerial = generateSerial(oldSerial) - // Regenerate with new serial: - *soaRec, _ = rrToRecord(x.RR, dc.Name, newSerial) - rec = *soaRec - } - foundRecords = append(foundRecords, &rec) - } - } - } - - // Add SOA record to expected set: - if !dc.HasRecordTypeName("SOA", "@") { - dc.Records = append(dc.Records, soaRec) + foundRecords, err := c.GetZoneRecords(dc.Name) + if err != nil { + return nil, err } // Normalize @@ -267,24 +212,24 @@ func (c *Bind) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correcti changes := false for _, i := range create { changes = true - if zoneFileFound { + if c.zoneFileFound { fmt.Fprintln(buf, i) } } for _, i := range del { changes = true - if zoneFileFound { + if c.zoneFileFound { fmt.Fprintln(buf, i) } } for _, i := range mod { changes = true - if zoneFileFound { + if c.zoneFileFound { fmt.Fprintln(buf, i) } } msg := fmt.Sprintf("GENERATE_ZONEFILE: %s\n", dc.Name) - if !zoneFileFound { + if !c.zoneFileFound { msg = msg + fmt.Sprintf(" (%d records)\n", len(create)) } msg += buf.String() @@ -294,16 +239,12 @@ func (c *Bind) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correcti &models.Correction{ Msg: msg, F: func() error { - fmt.Printf("CREATING ZONEFILE: %v\n", zonefile) - zf, err := os.Create(zonefile) + fmt.Printf("CREATING ZONEFILE: %v\n", c.zonefile) + zf, err := os.Create(c.zonefile) if err != nil { log.Fatalf("Could not create zonefile: %v", err) } - zonefilerecords := make([]dns.RR, 0, len(dc.Records)) - for _, r := range dc.Records { - zonefilerecords = append(zonefilerecords, r.ToRR()) - } - err = WriteZoneFile(zf, zonefilerecords, dc.Name) + err = prettyzone.WriteZoneFileRC(zf, dc.Records, dc.Name) if err != nil { log.Fatalf("WriteZoneFile error: %v\n", err) diff --git a/providers/bind/prettyzone.go b/providers/bind/prettyzone.go deleted file mode 100644 index 032220208..000000000 --- a/providers/bind/prettyzone.go +++ /dev/null @@ -1,325 +0,0 @@ -package bind - -// Generate zonefiles. -// This generates a zonefile that prioritizes beauty over efficiency. - -import ( - "bytes" - "fmt" - "io" - "log" - "sort" - "strconv" - "strings" - - "github.com/miekg/dns" - "github.com/miekg/dns/dnsutil" -) - -type zoneGenData struct { - Origin string - DefaultTTL uint32 - Records []dns.RR -} - -func (z *zoneGenData) Len() int { return len(z.Records) } -func (z *zoneGenData) Swap(i, j int) { z.Records[i], z.Records[j] = z.Records[j], z.Records[i] } -func (z *zoneGenData) Less(i, j int) bool { - a, b := z.Records[i], z.Records[j] - compA, compB := dnsutil.AddOrigin(a.Header().Name, z.Origin+"."), dnsutil.AddOrigin(b.Header().Name, z.Origin+".") - if compA != compB { - if compA == z.Origin+"." { - compA = "@" - } - if compB == z.Origin+"." { - compB = "@" - } - return zoneLabelLess(compA, compB) - } - rrtypeA, rrtypeB := a.Header().Rrtype, b.Header().Rrtype - if rrtypeA != rrtypeB { - return zoneRrtypeLess(rrtypeA, rrtypeB) - } - switch rrtypeA { // #rtype_variations - case dns.TypeA: - ta2, tb2 := a.(*dns.A), b.(*dns.A) - ipa, ipb := ta2.A.To4(), tb2.A.To4() - if ipa == nil || ipb == nil { - log.Fatalf("should not happen: IPs are not 4 bytes: %#v %#v", ta2, tb2) - } - return bytes.Compare(ipa, ipb) == -1 - case dns.TypeAAAA: - ta2, tb2 := a.(*dns.AAAA), b.(*dns.AAAA) - ipa, ipb := ta2.AAAA.To16(), tb2.AAAA.To16() - return bytes.Compare(ipa, ipb) == -1 - case dns.TypeMX: - ta2, tb2 := a.(*dns.MX), b.(*dns.MX) - pa, pb := ta2.Preference, tb2.Preference - // sort by priority. If they are equal, sort by Mx. - if pa != pb { - return pa < pb - } - return ta2.Mx < tb2.Mx - case dns.TypeSRV: - ta2, tb2 := a.(*dns.SRV), b.(*dns.SRV) - pa, pb := ta2.Port, tb2.Port - if pa != pb { - return pa < pb - } - pa, pb = ta2.Priority, tb2.Priority - if pa != pb { - return pa < pb - } - pa, pb = ta2.Weight, tb2.Weight - if pa != pb { - return pa < pb - } - case dns.TypePTR: - ta2, tb2 := a.(*dns.PTR), b.(*dns.PTR) - pa, pb := ta2.Ptr, tb2.Ptr - if pa != pb { - return pa < pb - } - case dns.TypeCAA: - ta2, tb2 := a.(*dns.CAA), b.(*dns.CAA) - // sort by tag - pa, pb := ta2.Tag, tb2.Tag - if pa != pb { - return pa < pb - } - // then flag - fa, fb := ta2.Flag, tb2.Flag - if fa != fb { - // flag set goes before ones without flag set - return fa > fb - } - default: - // pass through. String comparison is sufficient. - } - return a.String() < b.String() -} - -// mostCommonTTL returns the most common TTL in a set of records. If there is -// a tie, the highest TTL is selected. This makes the results consistent. -// NS records are not included in the analysis because Tom said so. -func mostCommonTTL(records []dns.RR) uint32 { - // Index the TTLs in use: - d := make(map[uint32]int) - for _, r := range records { - if r.Header().Rrtype != dns.TypeNS { - d[r.Header().Ttl]++ - } - } - // Find the largest count: - var mc int - for _, value := range d { - if value > mc { - mc = value - } - } - // Find the largest key with that count: - var mk uint32 - for key, value := range d { - if value == mc { - if key > mk { - mk = key - } - } - } - return mk -} - -// WriteZoneFile writes a beautifully formatted zone file. -func WriteZoneFile(w io.Writer, records []dns.RR, origin string) error { - // This function prioritizes beauty over efficiency. - // * The zone records are sorted by label, grouped by subzones to - // be easy to read and pleasant to the eye. - // * Within a label, SOA and NS records are listed first. - // * MX records are sorted numericly by preference value. - // * SRV records are sorted numericly by port, then priority, then weight. - // * A records are sorted by IP address, not lexicographically. - // * Repeated labels are removed. - // * $TTL is used to eliminate clutter. The most common TTL value is used. - // * "@" is used instead of the apex domain name. - - defaultTTL := mostCommonTTL(records) - - z := &zoneGenData{ - Origin: dnsutil.AddOrigin(origin, "."), - DefaultTTL: defaultTTL, - } - z.Records = nil - for _, r := range records { - z.Records = append(z.Records, r) - } - return z.generateZoneFileHelper(w) -} - -// generateZoneFileHelper creates a pretty zonefile. -func (z *zoneGenData) generateZoneFileHelper(w io.Writer) error { - - nameShortPrevious := "" - - sort.Sort(z) - fmt.Fprintln(w, "$TTL", z.DefaultTTL) - for i, rr := range z.Records { - line := rr.String() - if line[0] == ';' { - continue - } - hdr := rr.Header() - - items := strings.SplitN(line, "\t", 5) - if len(items) < 5 { - log.Fatalf("Too few items in: %v", line) - } - - // items[0]: name - nameFqdn := hdr.Name - nameShort := dnsutil.TrimDomainName(nameFqdn, z.Origin) - name := nameShort - if i > 0 && nameShort == nameShortPrevious { - name = "" - } else { - name = nameShort - } - nameShortPrevious = nameShort - - // items[1]: ttl - ttl := "" - if hdr.Ttl != z.DefaultTTL && hdr.Ttl != 0 { - ttl = items[1] - } - - // items[2]: class - if hdr.Class != dns.ClassINET { - log.Fatalf("generateZoneFileHelper: Unimplemented class=%v", items[2]) - } - - // items[3]: type - typeStr := dns.TypeToString[hdr.Rrtype] - - // items[4]: the remaining line - target := items[4] - - fmt.Fprintln(w, formatLine([]int{10, 5, 2, 5, 0}, []string{name, ttl, "IN", typeStr, target})) - } - return nil -} - -func formatLine(lengths []int, fields []string) string { - c := 0 - result := "" - for i, length := range lengths { - item := fields[i] - for len(result) < c { - result += " " - } - if item != "" { - result += item + " " - } - c += length + 1 - } - return strings.TrimRight(result, " ") -} - -func isNumeric(s string) bool { - _, err := strconv.ParseFloat(s, 64) - return err == nil -} - -func zoneLabelLess(a, b string) bool { - // Compare two zone labels for the purpose of sorting the RRs in a Zone. - - // If they are equal, we are done. All other code is simplified - // because we can assume a!=b. - if a == b { - return false - } - - // Sort @ at the top, then *, then everything else lexigraphically. - // i.e. @ always is less. * is is less than everything but @. - if a == "@" { - return true - } - if b == "@" { - return false - } - if a == "*" { - return true - } - if b == "*" { - return false - } - - // Split into elements and match up last elements to first. Compare the - // first non-equal elements. - - as := strings.Split(a, ".") - bs := strings.Split(b, ".") - ia := len(as) - 1 - ib := len(bs) - 1 - - var min int - if ia < ib { - min = len(as) - 1 - } else { - min = len(bs) - 1 - } - - // Skip the matching highest elements, then compare the next item. - for i, j := ia, ib; min >= 0; i, j, min = i-1, j-1, min-1 { - // Compare as[i] < bs[j] - // Sort @ at the top, then *, then everything else. - // i.e. @ always is less. * is is less than everything but @. - // If both are numeric, compare as integers, otherwise as strings. - - if as[i] != bs[j] { - - // If the first element is *, it is always less. - if i == 0 && as[i] == "*" { - return true - } - if j == 0 && bs[j] == "*" { - return false - } - - // If the elements are both numeric, compare as integers: - au, aerr := strconv.ParseUint(as[i], 10, 64) - bu, berr := strconv.ParseUint(bs[j], 10, 64) - if aerr == nil && berr == nil { - return au < bu - } - // otherwise, compare as strings: - return as[i] < bs[j] - } - } - // The min top elements were equal, so the shorter name is less. - return ia < ib -} - -func zoneRrtypeLess(a, b uint16) bool { - // Compare two RR types for the purpose of sorting the RRs in a Zone. - - // If they are equal, we are done. All other code is simplified - // because we can assume a!=b. - if a == b { - return false - } - - // List SOAs, then NSs, then all others. - // i.e. SOA is always less. NS is less than everything but SOA. - if a == dns.TypeSOA { - return true - } - if b == dns.TypeSOA { - return false - } - if a == dns.TypeNS { - return true - } - if b == dns.TypeNS { - return false - } - return a < b -} diff --git a/providers/capabilities.go b/providers/capabilities.go index dcc5699df..14b325d5b 100644 --- a/providers/capabilities.go +++ b/providers/capabilities.go @@ -49,6 +49,9 @@ const ( // CanUseRoute53Alias indicates the provider support the specific R53_ALIAS records that only the Route53 provider supports CanUseRoute53Alias + + // CanGetZoe indicates the provider supports the get-zones subcommand. + CanGetZones ) var providerCapabilities = map[string]map[Capability]bool{} diff --git a/providers/cloudflare/cloudflareProvider.go b/providers/cloudflare/cloudflareProvider.go index 29d5de8fb..9bf6b3632 100644 --- a/providers/cloudflare/cloudflareProvider.go +++ b/providers/cloudflare/cloudflareProvider.go @@ -47,6 +47,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Can(), providers.DocDualHost: providers.Cannot("Cloudflare will not work well in situations where it is not the only DNS server"), providers.DocOfficiallySupported: providers.Can(), + providers.CanGetZones: providers.Can(), } func init() { @@ -93,24 +94,60 @@ func (c *CloudflareApi) GetNameservers(domain string) ([]*models.Nameserver, err return models.StringsToNameservers(ns), nil } -// GetDomainCorrections returns a list of corrections to update a domain. -func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { - if c.domainIndex == nil { - if err := c.fetchDomainList(); err != nil { - return nil, err +func (c *CloudflareApi) ListZones() ([]string, error) { + if err := c.fetchDomainList(); err != nil { + return nil, err + } + zones := make([]string, 0, len(c.domainIndex)) + for d := range c.domainIndex { + zones = append(zones, d) + } + return zones, nil +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (c *CloudflareApi) GetZoneRecords(domain string) (models.Records, error) { + id, err := c.getDomainID(domain) + if err != nil { + return nil, err + } + records, err := c.getRecordsForDomain(id, domain) + if err != nil { + return nil, err + } + for _, rec := range records { + if rec.TTL == 1 { + rec.TTL = 0 } } - id, ok := c.domainIndex[dc.Name] - if !ok { - return nil, fmt.Errorf("%s not listed in zones for cloudflare account", dc.Name) - } + return records, nil +} - if err := c.preprocessConfig(dc); err != nil { +func (c *CloudflareApi) getDomainID(name string) (string, error) { + if c.domainIndex == nil { + if err := c.fetchDomainList(); err != nil { + return "", err + } + } + id, ok := c.domainIndex[name] + if !ok { + return "", fmt.Errorf("'%s' not a zone in cloudflare account", name) + } + return id, nil +} + +// GetDomainCorrections returns a list of corrections to update a domain. +func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + id, err := c.getDomainID(dc.Name) + if err != nil { + return nil, err + } + records, err := c.getRecordsForDomain(id, dc.Name) + if err != nil { return nil, err } - records, err := c.getRecordsForDomain(id, dc.Name) - if err != nil { + if err := c.preprocessConfig(dc); err != nil { return nil, err } for i := len(records) - 1; i >= 0; i-- { diff --git a/providers/cloudns/cloudnsProvider.go b/providers/cloudns/cloudnsProvider.go index 3086ce93d..aab892b2b 100644 --- a/providers/cloudns/cloudnsProvider.go +++ b/providers/cloudns/cloudnsProvider.go @@ -49,6 +49,7 @@ var features = providers.DocumentationNotes{ providers.CanUseCAA: providers.Can(), providers.CanUseTLSA: providers.Can(), providers.CanUsePTR: providers.Unimplemented(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -79,7 +80,7 @@ func (c *api) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correctio } domainID, ok := c.domainIndex[dc.Name] if !ok { - return nil, fmt.Errorf("%s not listed in domains for ClouDNS account", dc.Name) + return nil, fmt.Errorf("'%s' not a zone in ClouDNS account", dc.Name) } records, err := c.getRecords(domainID) @@ -150,6 +151,14 @@ func (c *api) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correctio return corrections, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *api) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // EnsureDomainExists returns an error if domain doesn't exist. func (c *api) EnsureDomainExists(domain string) error { if err := c.fetchDomainList(); err != nil { diff --git a/providers/digitalocean/digitaloceanProvider.go b/providers/digitalocean/digitaloceanProvider.go index bb8efb774..eafed6889 100644 --- a/providers/digitalocean/digitaloceanProvider.go +++ b/providers/digitalocean/digitaloceanProvider.go @@ -69,7 +69,8 @@ var features = providers.DocumentationNotes{ // Digitalocean support CAA records, except // ";" value with issue/issuewild records: // https://www.digitalocean.com/docs/networking/dns/how-to/create-caa-records/ - providers.CanUseCAA: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -95,6 +96,14 @@ func (api *DoApi) GetNameservers(domain string) ([]*models.Nameserver, error) { return models.StringsToNameservers(defaultNameServerNames), nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *DoApi) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns a list of corretions for the domain. func (api *DoApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { ctx := context.Background() diff --git a/providers/dnsimple/dnsimpleProvider.go b/providers/dnsimple/dnsimpleProvider.go index 526cdb3f0..272e0536f 100644 --- a/providers/dnsimple/dnsimpleProvider.go +++ b/providers/dnsimple/dnsimpleProvider.go @@ -25,6 +25,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Cannot(), providers.DocDualHost: providers.Cannot("DNSimple does not allow sufficient control over the apex NS records"), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -53,6 +54,14 @@ func (c *DnsimpleApi) GetNameservers(domainName string) ([]*models.Nameserver, e return models.StringsToNameservers(defaultNameServerNames), nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *DnsimpleApi) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns corrections that update a domain. func (c *DnsimpleApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { corrections := []*models.Correction{} diff --git a/providers/exoscale/exoscaleProvider.go b/providers/exoscale/exoscaleProvider.go index db2afcdc0..a02927803 100644 --- a/providers/exoscale/exoscaleProvider.go +++ b/providers/exoscale/exoscaleProvider.go @@ -32,6 +32,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Cannot(), providers.DocDualHost: providers.Cannot("Exoscale does not allow sufficient control over the apex NS records"), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -55,6 +56,14 @@ func (c *exoscaleProvider) GetNameservers(domain string) ([]*models.Nameserver, return nil, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *exoscaleProvider) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns a list of corretions for the domain. func (c *exoscaleProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() diff --git a/providers/gandi/gandiProvider.go b/providers/gandi/gandiProvider.go index cd6265cc4..856a88edf 100644 --- a/providers/gandi/gandiProvider.go +++ b/providers/gandi/gandiProvider.go @@ -33,6 +33,7 @@ var features = providers.DocumentationNotes{ providers.CantUseNOPURGE: providers.Cannot(), providers.DocCreateDomains: providers.Cannot("Can only manage domains registered through their service"), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -58,7 +59,7 @@ func (c *GandiApi) getDomainInfo(domain string) (*gandidomain.DomainInfo, error) } _, ok := c.domainIndex[domain] if !ok { - return nil, fmt.Errorf("%s not listed in zones for gandi account", domain) + return nil, fmt.Errorf("'%s' not a zone in gandi account", domain) } return c.fetchDomainInfo(domain) } @@ -76,6 +77,14 @@ func (c *GandiApi) GetNameservers(domain string) ([]*models.Nameserver, error) { return ns, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *GandiApi) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns a list of corrections recommended for this domain. func (c *GandiApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() diff --git a/providers/gandi/livedns.go b/providers/gandi/livedns.go index f984d0388..4b05808cd 100644 --- a/providers/gandi/livedns.go +++ b/providers/gandi/livedns.go @@ -85,6 +85,14 @@ func (c *liveClient) GetNameservers(domain string) ([]*models.Nameserver, error) return ns, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *liveClient) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns a list of corrections recommended for this domain. func (c *liveClient) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() diff --git a/providers/gandi_v5/gandi_v5Provider.go b/providers/gandi_v5/gandi_v5Provider.go index e3993c92b..ad626eb98 100644 --- a/providers/gandi_v5/gandi_v5Provider.go +++ b/providers/gandi_v5/gandi_v5Provider.go @@ -46,6 +46,7 @@ var features = providers.DocumentationNotes{ providers.CantUseNOPURGE: providers.Cannot(), providers.DocCreateDomains: providers.Cannot("Can only manage domains registered through their service"), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Can(), } // Section 2: Define the API client. diff --git a/providers/gcloud/gcloudProvider.go b/providers/gcloud/gcloudProvider.go index 03770ed58..b3184e607 100644 --- a/providers/gcloud/gcloudProvider.go +++ b/providers/gcloud/gcloudProvider.go @@ -22,6 +22,7 @@ var features = providers.DocumentationNotes{ providers.CanUseSRV: providers.Can(), providers.CanUseCAA: providers.Can(), providers.CanUseTXTMulti: providers.Can(), + providers.CanGetZones: providers.Unimplemented(), } func sPtr(s string) *string { @@ -124,6 +125,14 @@ func keyForRec(r *models.RecordConfig) key { return key{Type: r.Type, Name: r.GetLabelFQDN() + "."} } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *gcloud) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + func (g *gcloud) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { if err := dc.Punycode(); err != nil { return nil, err diff --git a/providers/hexonet/hexonetProvider.go b/providers/hexonet/hexonetProvider.go index 894135aea..3cb530f5d 100644 --- a/providers/hexonet/hexonetProvider.go +++ b/providers/hexonet/hexonetProvider.go @@ -29,6 +29,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Can(), providers.DocDualHost: providers.Can(), providers.DocOfficiallySupported: providers.Cannot("Actively maintained provider module."), + providers.CanGetZones: providers.Unimplemented(), } func newProvider(conf map[string]string) (*HXClient, error) { diff --git a/providers/hexonet/records.go b/providers/hexonet/records.go index e318989ae..22f9e6045 100644 --- a/providers/hexonet/records.go +++ b/providers/hexonet/records.go @@ -35,6 +35,14 @@ type HXRecord struct { Priority uint32 } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *HXClient) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections gathers correctios that would bring n to match dc. func (n *HXClient) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() diff --git a/providers/linode/linodeProvider.go b/providers/linode/linodeProvider.go index b3780d39d..a95bfac14 100644 --- a/providers/linode/linodeProvider.go +++ b/providers/linode/linodeProvider.go @@ -88,6 +88,7 @@ func NewLinode(m map[string]string, metadata json.RawMessage) (providers.DNSServ var features = providers.DocumentationNotes{ providers.DocDualHost: providers.Cannot(), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -100,6 +101,14 @@ func (api *LinodeApi) GetNameservers(domain string) ([]*models.Nameserver, error return models.StringsToNameservers(defaultNameServerNames), nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *LinodeApi) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns the corrections for a domain. func (api *LinodeApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc, err := dc.Copy() @@ -116,7 +125,7 @@ func (api *LinodeApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.C } domainID, ok := api.domainIndex[dc.Name] if !ok { - return nil, fmt.Errorf("%s not listed in domains for Linode account", dc.Name) + return nil, fmt.Errorf("'%s' not a zone in Linode account", dc.Name) } records, err := api.getRecords(domainID) diff --git a/providers/namecheap/namecheapProvider.go b/providers/namecheap/namecheapProvider.go index 1648ab6f3..b6020d2c2 100644 --- a/providers/namecheap/namecheapProvider.go +++ b/providers/namecheap/namecheapProvider.go @@ -36,6 +36,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Cannot("Requires domain registered through their service"), providers.DocDualHost: providers.Cannot("Doesn't allow control of apex NS records"), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -105,6 +106,14 @@ func doWithRetry(f func() error) { } } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *Namecheap) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns the corrections for the domain. func (n *Namecheap) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() diff --git a/providers/namedotcom/namedotcomProvider.go b/providers/namedotcom/namedotcomProvider.go index 83f36913d..93ff3efad 100644 --- a/providers/namedotcom/namedotcomProvider.go +++ b/providers/namedotcom/namedotcomProvider.go @@ -28,6 +28,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Cannot("New domains require registration"), providers.DocDualHost: providers.Cannot("Apex NS records not editable"), providers.DocOfficiallySupported: providers.Can(), + providers.CanGetZones: providers.Unimplemented(), } func newReg(conf map[string]string) (providers.Registrar, error) { diff --git a/providers/namedotcom/records.go b/providers/namedotcom/records.go index 99237e4d6..ac3528b98 100644 --- a/providers/namedotcom/records.go +++ b/providers/namedotcom/records.go @@ -19,6 +19,14 @@ var defaultNameservers = []*models.Nameserver{ {Name: "ns4.name.com"}, } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *NameCom) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections gathers correctios that would bring n to match dc. func (n *NameCom) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() diff --git a/providers/ns1/ns1provider.go b/providers/ns1/ns1provider.go index 25c0a75d0..ae5650909 100644 --- a/providers/ns1/ns1provider.go +++ b/providers/ns1/ns1provider.go @@ -43,6 +43,14 @@ func (n *nsone) GetNameservers(domain string) ([]*models.Nameserver, error) { return models.StringsToNameservers(z.DNSServers), nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *nsone) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + func (n *nsone) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() //dc.CombineMXs() diff --git a/providers/octodns/octodnsProvider.go b/providers/octodns/octodnsProvider.go index 1c6354d70..91a52075e 100644 --- a/providers/octodns/octodnsProvider.go +++ b/providers/octodns/octodnsProvider.go @@ -40,6 +40,7 @@ var features = providers.DocumentationNotes{ //providers.CanUseTXTMulti: providers.Can(), providers.DocCreateDomains: providers.Cannot("Driver just maintains list of OctoDNS config files. You must manually create the master config files that refer these."), providers.DocDualHost: providers.Cannot("Research is needed."), + providers.CanGetZones: providers.Unimplemented(), } func initProvider(config map[string]string, providermeta json.RawMessage) (providers.DNSServiceProvider, error) { @@ -78,6 +79,14 @@ func (c *Provider) GetNameservers(string) ([]*models.Nameserver, error) { return nil, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *Provider) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns a list of corrections to update a domain. func (c *Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() diff --git a/providers/opensrs/opensrsProvider.go b/providers/opensrs/opensrsProvider.go index e16a00cfe..5956421e2 100644 --- a/providers/opensrs/opensrsProvider.go +++ b/providers/opensrs/opensrsProvider.go @@ -17,6 +17,7 @@ var docNotes = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Cannot(), providers.DocOfficiallySupported: providers.Cannot(), providers.CanUseTLSA: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { diff --git a/providers/ovh/ovhProvider.go b/providers/ovh/ovhProvider.go index 9e544b94c..17483a388 100644 --- a/providers/ovh/ovhProvider.go +++ b/providers/ovh/ovhProvider.go @@ -27,6 +27,7 @@ var features = providers.DocumentationNotes{ providers.DocCreateDomains: providers.Cannot("New domains require registration"), providers.DocDualHost: providers.Can(), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func newOVH(m map[string]string, metadata json.RawMessage) (*ovhProvider, error) { @@ -60,7 +61,7 @@ func init() { func (c *ovhProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { _, ok := c.zones[domain] if !ok { - return nil, fmt.Errorf("%s not listed in zones for ovh account", domain) + return nil, fmt.Errorf("'%s' not a zone in ovh account", domain) } ns, err := c.fetchRegistrarNS(domain) @@ -79,6 +80,14 @@ func (e errNoExist) Error() string { return fmt.Sprintf("Domain %s not found in your ovh account", e.domain) } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *ovhProvider) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + func (c *ovhProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() //dc.CombineMXs() diff --git a/providers/providers.go b/providers/providers.go index 3a35aaa65..7b83a9a6a 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -24,6 +24,13 @@ type DomainCreator interface { EnsureDomainExists(domain string) error } +// DomainLister should be implemented by providers that have the +// ability to list the zones they manage. This facilitates using the +// "get-zones" command for "all" zones. +type ZoneLister interface { + ListZones() ([]string, error) +} + // RegistrarInitializer is a function to create a registrar. Function will be passed the unprocessed json payload from the configuration file for the given provider. type RegistrarInitializer func(map[string]string) (Registrar, error) @@ -85,6 +92,14 @@ func (n None) GetNameservers(string) ([]*models.Nameserver, error) { return nil, nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client None) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns corrections to update a domain. func (n None) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { return nil, nil diff --git a/providers/route53/route53Provider.go b/providers/route53/route53Provider.go index f0a60355c..66ace81b7 100644 --- a/providers/route53/route53Provider.go +++ b/providers/route53/route53Provider.go @@ -20,10 +20,11 @@ import ( ) type route53Provider struct { - client *r53.Route53 - registrar *r53d.Route53Domains - delegationSet *string - zones map[string]*r53.HostedZone + client *r53.Route53 + registrar *r53d.Route53Domains + delegationSet *string + zones map[string]*r53.HostedZone + originalRecords []*r53.ResourceRecordSet } func newRoute53Reg(conf map[string]string) (providers.Registrar, error) { @@ -73,6 +74,7 @@ var features = providers.DocumentationNotes{ providers.CanUseTXTMulti: providers.Can(), providers.CanUseCAA: providers.Can(), providers.CanUseRoute53Alias: providers.Can(), + providers.CanGetZones: providers.Can(), } func init() { @@ -169,25 +171,42 @@ func (r *route53Provider) GetNameservers(domain string) ([]*models.Nameserver, e return ns, nil } -func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { - dc.Punycode() +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (r *route53Provider) GetZoneRecords(domain string) (models.Records, error) { - var corrections = []*models.Correction{} - zone, ok := r.zones[dc.Name] - // add zone if it doesn't exist + zone, ok := r.zones[domain] if !ok { - return nil, errNoExist{dc.Name} + return nil, errNoExist{domain} } records, err := r.fetchRecordSets(zone.Id) if err != nil { return nil, err } + r.originalRecords = records var existingRecords = []*models.RecordConfig{} for _, set := range records { - existingRecords = append(existingRecords, nativeToRecords(set, dc.Name)...) + existingRecords = append(existingRecords, nativeToRecords(set, domain)...) } + return existingRecords, nil +} + +func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc.Punycode() + + var corrections = []*models.Correction{} + + existingRecords, err := r.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + + zone, ok := r.zones[dc.Name] + if !ok { + return nil, errNoExist{dc.Name} + } + for _, want := range dc.Records { // update zone_id to current zone.id if not specified by the user if want.Type == "R53_ALIAS" && want.R53Alias["zone_id"] == "" { @@ -235,7 +254,7 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode chg.Action = sPtr("DELETE") delDesc = append(delDesc, strings.Join(namesToUpdate[k], "\n")) // on delete just submit the original resource set we got from r53. - for _, r := range records { + for _, r := range r.originalRecords { if unescape(r.Name) == k.NameFQDN && (*r.Type == k.Type || k.Type == "R53_ALIAS_"+*r.Type) { rrset = r break diff --git a/providers/softlayer/softlayerProvider.go b/providers/softlayer/softlayerProvider.go index 37eda34d1..24a1b02d3 100644 --- a/providers/softlayer/softlayerProvider.go +++ b/providers/softlayer/softlayerProvider.go @@ -22,7 +22,8 @@ type SoftLayer struct { } var features = providers.DocumentationNotes{ - providers.CanUseSRV: providers.Can(), + providers.CanUseSRV: providers.Can(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -52,6 +53,14 @@ func (s *SoftLayer) GetNameservers(domain string) ([]*models.Nameserver, error) return models.StringsToNameservers(nservers), nil } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *SoftLayer) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections returns corrections to update a domain. func (s *SoftLayer) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { corrections := []*models.Correction{} diff --git a/providers/vultr/vultrProvider.go b/providers/vultr/vultrProvider.go index ccea43c5f..0a7a4ab83 100644 --- a/providers/vultr/vultrProvider.go +++ b/providers/vultr/vultrProvider.go @@ -34,6 +34,7 @@ var features = providers.DocumentationNotes{ providers.CanUseSSHFP: providers.Can(), providers.DocCreateDomains: providers.Can(), providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Unimplemented(), } func init() { @@ -66,6 +67,14 @@ func NewProvider(m map[string]string, metadata json.RawMessage) (providers.DNSSe return &Provider{client, token}, err } +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *Provider) GetZoneRecords(domain string) (models.Records, error) { + return nil, fmt.Errorf("not implemented") + // This enables the get-zones subcommand. + // Implement this by extracting the code from GetDomainCorrections into + // a single function. For most providers this should be relatively easy. +} + // GetDomainCorrections gets the corrections for a DomainConfig. func (api *Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode()