diff options
Diffstat (limited to 'plugin')
308 files changed, 29222 insertions, 0 deletions
diff --git a/plugin/auto/README.md b/plugin/auto/README.md new file mode 100644 index 000000000..7cbc4fced --- /dev/null +++ b/plugin/auto/README.md @@ -0,0 +1,68 @@ +# auto + +*auto* enables serving zone data from an RFC 1035-style master file which is automatically picked +up from disk. + +The *auto* plugin is used for an "old-style" DNS server. It serves from a preloaded file that exists +on disk. If the zone file contains signatures (i.e. is signed, i.e. DNSSEC) correct DNSSEC answers +are returned. Only NSEC is supported! If you use this setup *you* are responsible for resigning the +zonefile. New zones or changed zone are automatically picked up from disk. + +## Syntax + +~~~ +auto [ZONES...] { + directory DIR [REGEXP ORIGIN_TEMPLATE [TIMEOUT]] + no_reload + upstream ADDRESS... +} +~~~ + +**ZONES** zones it should be authoritative for. If empty, the zones from the configuration block +are used. + +* `directory` loads zones from the speficied **DIR**. If a file name matches **REGEXP** it will be + used to extract the origin. **ORIGIN_TEMPLATE** will be used as a template for the origin. Strings + like `{<number>}` are replaced with the respective matches in the file name, i.e. `{1}` is the + first match, `{2}` is the second, etc.. The default is: `db\.(.*) {1}` e.g. from a file with the + name `db.example.com`, the extracted origin will be `example.com`. **TIMEOUT** specifies how often + CoreDNS should scan the directory, the default is every 60 seconds. This value is in seconds. + The minimum value is 1 second. +* `no_reload` by default CoreDNS will reload a zone from disk whenever it detects a change to the + file. This option disables that behavior. +* `upstream` defines upstream resolvers to be used resolve external names found (think CNAMEs) + pointing to external names. **ADDRESS** can be an IP address, and IP:port or a string pointing to + a file that is structured as /etc/resolv.conf. + +All directives from the *file* plugin are supported. Note that *auto* will load all zones found, +even though the directive might only receive queries for a specific zone. I.e: + +~~~ +auto example.org { + directory /etc/coredns/zones +} +~~~ +Will happily pick up a zone for `example.COM`, except it will never be queried, because the *auto* +directive only is authoritative for `example.ORG`. + +## Examples + +Load `org` domains from `/etc/coredns/zones/org` and allow transfers to the internet, but send +notifies to 10.240.1.1 + +~~~ +auto org { + directory /etc/coredns/zones/org + transfer to * + transfer to 10.240.1.1 +} +~~~ + +Load `org` domains from `/etc/coredns/zones/org` and looks for file names as `www.db.example.org`, +where `example.org` is the origin. Scan every 45 seconds. + +~~~ +auto org { + directory /etc/coredns/zones/org www\.db\.(.*) {1} 45 +} +~~~ diff --git a/plugin/auto/auto.go b/plugin/auto/auto.go new file mode 100644 index 000000000..e9cab1950 --- /dev/null +++ b/plugin/auto/auto.go @@ -0,0 +1,96 @@ +// Package auto implements an on-the-fly loading file backend. +package auto + +import ( + "regexp" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/metrics" + "github.com/coredns/coredns/plugin/proxy" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +type ( + // Auto holds the zones and the loader configuration for automatically loading zones. + Auto struct { + Next plugin.Handler + *Zones + + metrics *metrics.Metrics + loader + } + + loader struct { + directory string + template string + re *regexp.Regexp + + // In the future this should be something like ZoneMeta that contains all this stuff. + transferTo []string + noReload bool + proxy proxy.Proxy // Proxy for looking up names during the resolution process + + duration time.Duration + } +) + +// ServeDNS implements the plugin.Handle interface. +func (a Auto) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + qname := state.Name() + + // TODO(miek): match the qname better in the map + + // Precheck with the origins, i.e. are we allowed to looks here. + zone := plugin.Zones(a.Zones.Origins()).Matches(qname) + if zone == "" { + return plugin.NextOrFailure(a.Name(), a.Next, ctx, w, r) + } + + // Now the real zone. + zone = plugin.Zones(a.Zones.Names()).Matches(qname) + + a.Zones.RLock() + z, ok := a.Zones.Z[zone] + a.Zones.RUnlock() + + if !ok || z == nil { + return dns.RcodeServerFailure, nil + } + + if state.QType() == dns.TypeAXFR || state.QType() == dns.TypeIXFR { + xfr := file.Xfr{Zone: z} + return xfr.ServeDNS(ctx, w, r) + } + + answer, ns, extra, result := z.Lookup(state, qname) + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + m.Answer, m.Ns, m.Extra = answer, ns, extra + + switch result { + case file.Success: + case file.NoData: + case file.NameError: + m.Rcode = dns.RcodeNameError + case file.Delegation: + m.Authoritative = false + case file.ServerFailure: + return dns.RcodeServerFailure, nil + } + + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +// Name implements the Handler interface. +func (a Auto) Name() string { return "auto" } diff --git a/plugin/auto/regexp.go b/plugin/auto/regexp.go new file mode 100644 index 000000000..fa424ec7e --- /dev/null +++ b/plugin/auto/regexp.go @@ -0,0 +1,20 @@ +package auto + +// rewriteToExpand rewrites our template string to one that we can give to regexp.ExpandString. This basically +// involves prefixing any '{' with a '$'. +func rewriteToExpand(s string) string { + // Pretty dumb at the moment, every { will get a $ prefixed. + // Also wasteful as we build the string with +=. This is OKish + // as we do this during config parsing. + + copy := "" + + for _, c := range s { + if c == '{' { + copy += "$" + } + copy += string(c) + } + + return copy +} diff --git a/plugin/auto/regexp_test.go b/plugin/auto/regexp_test.go new file mode 100644 index 000000000..17c35eb90 --- /dev/null +++ b/plugin/auto/regexp_test.go @@ -0,0 +1,20 @@ +package auto + +import "testing" + +func TestRewriteToExpand(t *testing.T) { + tests := []struct { + in string + expected string + }{ + {in: "", expected: ""}, + {in: "{1}", expected: "${1}"}, + {in: "{1", expected: "${1"}, + } + for i, tc := range tests { + got := rewriteToExpand(tc.in) + if got != tc.expected { + t.Errorf("Test %d: Expected error %v, but got %v", i, tc.expected, got) + } + } +} diff --git a/plugin/auto/setup.go b/plugin/auto/setup.go new file mode 100644 index 000000000..75966f8a0 --- /dev/null +++ b/plugin/auto/setup.go @@ -0,0 +1,172 @@ +package auto + +import ( + "log" + "os" + "path" + "regexp" + "strconv" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/metrics" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/proxy" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("auto", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + a, err := autoParse(c) + if err != nil { + return plugin.Error("auto", err) + } + + c.OnStartup(func() error { + m := dnsserver.GetConfig(c).Handler("prometheus") + if m == nil { + return nil + } + (&a).metrics = m.(*metrics.Metrics) + return nil + }) + + walkChan := make(chan bool) + + c.OnStartup(func() error { + err := a.Walk() + if err != nil { + return err + } + + go func() { + ticker := time.NewTicker(a.loader.duration) + for { + select { + case <-walkChan: + return + case <-ticker.C: + a.Walk() + } + } + }() + return nil + }) + + c.OnShutdown(func() error { + close(walkChan) + return nil + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + a.Next = next + return a + }) + + return nil +} + +func autoParse(c *caddy.Controller) (Auto, error) { + var a = Auto{ + loader: loader{template: "${1}", re: regexp.MustCompile(`db\.(.*)`), duration: 60 * time.Second}, + Zones: &Zones{}, + } + + config := dnsserver.GetConfig(c) + + for c.Next() { + // auto [ZONES...] + a.Zones.origins = make([]string, len(c.ServerBlockKeys)) + copy(a.Zones.origins, c.ServerBlockKeys) + + args := c.RemainingArgs() + if len(args) > 0 { + a.Zones.origins = args + } + for i := range a.Zones.origins { + a.Zones.origins[i] = plugin.Host(a.Zones.origins[i]).Normalize() + } + + for c.NextBlock() { + switch c.Val() { + case "directory": // directory DIR [REGEXP [TEMPLATE] [DURATION]] + if !c.NextArg() { + return a, c.ArgErr() + } + a.loader.directory = c.Val() + if !path.IsAbs(a.loader.directory) && config.Root != "" { + a.loader.directory = path.Join(config.Root, a.loader.directory) + } + _, err := os.Stat(a.loader.directory) + if err != nil { + if os.IsNotExist(err) { + log.Printf("[WARNING] Directory does not exist: %s", a.loader.directory) + } else { + return a, c.Errf("Unable to access root path '%s': %v", a.loader.directory, err) + } + } + + // regexp + if c.NextArg() { + a.loader.re, err = regexp.Compile(c.Val()) + if err != nil { + return a, err + } + if a.loader.re.NumSubexp() == 0 { + return a, c.Errf("Need at least one sub expression") + } + } + + // template + if c.NextArg() { + a.loader.template = rewriteToExpand(c.Val()) + } + + // duration + if c.NextArg() { + i, err := strconv.Atoi(c.Val()) + if err != nil { + return a, err + } + if i < 1 { + i = 1 + } + a.loader.duration = time.Duration(i) * time.Second + } + + case "no_reload": + a.loader.noReload = true + + case "upstream": + args := c.RemainingArgs() + if len(args) == 0 { + return a, c.ArgErr() + } + ups, err := dnsutil.ParseHostPortOrFile(args...) + if err != nil { + return a, err + } + a.loader.proxy = proxy.NewLookup(ups) + + default: + t, _, e := file.TransferParse(c, false) + if e != nil { + return a, e + } + if t != nil { + a.loader.transferTo = append(a.loader.transferTo, t...) + } + } + } + } + return a, nil +} diff --git a/plugin/auto/setup_test.go b/plugin/auto/setup_test.go new file mode 100644 index 000000000..9754551d2 --- /dev/null +++ b/plugin/auto/setup_test.go @@ -0,0 +1,125 @@ +package auto + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestAutoParse(t *testing.T) { + tests := []struct { + inputFileRules string + shouldErr bool + expectedDirectory string + expectedTempl string + expectedRe string + expectedTo []string + }{ + { + `auto example.org { + directory /tmp + transfer to 127.0.0.1 + }`, + false, "/tmp", "${1}", `db\.(.*)`, []string{"127.0.0.1:53"}, + }, + { + `auto 10.0.0.0/24 { + directory /tmp + }`, + false, "/tmp", "${1}", `db\.(.*)`, nil, + }, + { + `auto { + directory /tmp + no_reload + }`, + false, "/tmp", "${1}", `db\.(.*)`, nil, + }, + { + `auto { + directory /tmp (.*) bliep + }`, + false, "/tmp", "bliep", `(.*)`, nil, + }, + { + `auto { + directory /tmp (.*) bliep 10 + }`, + false, "/tmp", "bliep", `(.*)`, nil, + }, + { + `auto { + directory /tmp (.*) bliep + transfer to 127.0.0.1 + transfer to 127.0.0.2 + upstream 8.8.8.8 + }`, + false, "/tmp", "bliep", `(.*)`, []string{"127.0.0.1:53", "127.0.0.2:53"}, + }, + // errors + { + `auto example.org { + directory + }`, + true, "", "${1}", `db\.(.*)`, nil, + }, + { + `auto example.org { + directory /tmp * {1} + }`, + true, "", "${1}", ``, nil, + }, + { + `auto example.org { + directory /tmp * {1} aa + }`, + true, "", "${1}", ``, nil, + }, + { + `auto example.org { + directory /tmp .* {1} + }`, + true, "", "${1}", ``, nil, + }, + { + `auto example.org { + directory /tmp .* {1} + }`, + true, "", "${1}", ``, nil, + }, + { + `auto example.org { + directory /tmp .* {1} + }`, + true, "", "${1}", ``, nil, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + a, err := autoParse(c) + + if err == nil && test.shouldErr { + t.Fatalf("Test %d expected errors, but got no error", i) + } else if err != nil && !test.shouldErr { + t.Fatalf("Test %d expected no errors, but got '%v'", i, err) + } else if !test.shouldErr { + if a.loader.directory != test.expectedDirectory { + t.Fatalf("Test %d expected %v, got %v", i, test.expectedDirectory, a.loader.directory) + } + if a.loader.template != test.expectedTempl { + t.Fatalf("Test %d expected %v, got %v", i, test.expectedTempl, a.loader.template) + } + if a.loader.re.String() != test.expectedRe { + t.Fatalf("Test %d expected %v, got %v", i, test.expectedRe, a.loader.re) + } + if test.expectedTo != nil { + for j, got := range a.loader.transferTo { + if got != test.expectedTo[j] { + t.Fatalf("Test %d expected %v, got %v", i, test.expectedTo[j], got) + } + } + } + } + } +} diff --git a/plugin/auto/walk.go b/plugin/auto/walk.go new file mode 100644 index 000000000..a98f2318e --- /dev/null +++ b/plugin/auto/walk.go @@ -0,0 +1,109 @@ +package auto + +import ( + "log" + "os" + "path" + "path/filepath" + "regexp" + + "github.com/coredns/coredns/plugin/file" + + "github.com/miekg/dns" +) + +// Walk will recursively walk of the file under l.directory and adds the one that match l.re. +func (a Auto) Walk() error { + + // TODO(miek): should add something so that we don't stomp on each other. + + toDelete := make(map[string]bool) + for _, n := range a.Zones.Names() { + toDelete[n] = true + } + + filepath.Walk(a.loader.directory, func(path string, info os.FileInfo, err error) error { + if info == nil || info.IsDir() { + return nil + } + + match, origin := matches(a.loader.re, info.Name(), a.loader.template) + if !match { + return nil + } + + if _, ok := a.Zones.Z[origin]; ok { + // we already have this zone + toDelete[origin] = false + return nil + } + + reader, err := os.Open(path) + if err != nil { + log.Printf("[WARNING] Opening %s failed: %s", path, err) + return nil + } + defer reader.Close() + + // Serial for loading a zone is 0, because it is a new zone. + zo, err := file.Parse(reader, origin, path, 0) + if err != nil { + log.Printf("[WARNING] Parse zone `%s': %v", origin, err) + return nil + } + + zo.NoReload = a.loader.noReload + zo.Proxy = a.loader.proxy + zo.TransferTo = a.loader.transferTo + + a.Zones.Add(zo, origin) + + if a.metrics != nil { + a.metrics.AddZone(origin) + } + + zo.Notify() + + log.Printf("[INFO] Inserting zone `%s' from: %s", origin, path) + + toDelete[origin] = false + + return nil + }) + + for origin, ok := range toDelete { + if !ok { + continue + } + + if a.metrics != nil { + a.metrics.RemoveZone(origin) + } + + a.Zones.Remove(origin) + + log.Printf("[INFO] Deleting zone `%s'", origin) + } + + return nil +} + +// matches matches re to filename, if is is a match, the subexpression will be used to expand +// template to an origin. When match is true that origin is returned. Origin is fully qualified. +func matches(re *regexp.Regexp, filename, template string) (match bool, origin string) { + base := path.Base(filename) + + matches := re.FindStringSubmatchIndex(base) + if matches == nil { + return false, "" + } + + by := re.ExpandString(nil, template, base, matches) + if by == nil { + return false, "" + } + + origin = dns.Fqdn(string(by)) + + return true, origin +} diff --git a/plugin/auto/walk_test.go b/plugin/auto/walk_test.go new file mode 100644 index 000000000..29b9dbb55 --- /dev/null +++ b/plugin/auto/walk_test.go @@ -0,0 +1,94 @@ +package auto + +import ( + "io/ioutil" + "log" + "os" + "path" + "regexp" + "testing" +) + +var dbFiles = []string{"db.example.org", "aa.example.org"} + +const zoneContent = `; testzone +@ IN SOA sns.dns.icann.org. noc.dns.icann.org. 2016082534 7200 3600 1209600 3600 + NS a.iana-servers.net. + NS b.iana-servers.net. + +www IN A 127.0.0.1 +` + +func TestWalk(t *testing.T) { + log.SetOutput(ioutil.Discard) + + tempdir, err := createFiles() + if err != nil { + if tempdir != "" { + os.RemoveAll(tempdir) + } + t.Fatal(err) + } + defer os.RemoveAll(tempdir) + + ldr := loader{ + directory: tempdir, + re: regexp.MustCompile(`db\.(.*)`), + template: `${1}`, + } + + a := Auto{ + loader: ldr, + Zones: &Zones{}, + } + + a.Walk() + + // db.example.org and db.example.com should be here (created in createFiles) + for _, name := range []string{"example.com.", "example.org."} { + if _, ok := a.Zones.Z[name]; !ok { + t.Errorf("%s should have been added", name) + } + } +} + +func TestWalkNonExistent(t *testing.T) { + log.SetOutput(ioutil.Discard) + + nonExistingDir := "highly_unlikely_to_exist_dir" + + ldr := loader{ + directory: nonExistingDir, + re: regexp.MustCompile(`db\.(.*)`), + template: `${1}`, + } + + a := Auto{ + loader: ldr, + Zones: &Zones{}, + } + + a.Walk() +} + +func createFiles() (string, error) { + dir, err := ioutil.TempDir(os.TempDir(), "coredns") + if err != nil { + return dir, err + } + + for _, name := range dbFiles { + if err := ioutil.WriteFile(path.Join(dir, name), []byte(zoneContent), 0644); err != nil { + return dir, err + } + } + // symlinks + if err = os.Symlink(path.Join(dir, "db.example.org"), path.Join(dir, "db.example.com")); err != nil { + return dir, err + } + if err = os.Symlink(path.Join(dir, "db.example.org"), path.Join(dir, "aa.example.com")); err != nil { + return dir, err + } + + return dir, nil +} diff --git a/plugin/auto/watcher_test.go b/plugin/auto/watcher_test.go new file mode 100644 index 000000000..329d8dc85 --- /dev/null +++ b/plugin/auto/watcher_test.go @@ -0,0 +1,58 @@ +package auto + +import ( + "io/ioutil" + "log" + "os" + "path" + "regexp" + "testing" +) + +func TestWatcher(t *testing.T) { + log.SetOutput(ioutil.Discard) + + tempdir, err := createFiles() + if err != nil { + if tempdir != "" { + os.RemoveAll(tempdir) + } + t.Fatal(err) + } + defer os.RemoveAll(tempdir) + + ldr := loader{ + directory: tempdir, + re: regexp.MustCompile(`db\.(.*)`), + template: `${1}`, + } + + a := Auto{ + loader: ldr, + Zones: &Zones{}, + } + + a.Walk() + + // example.org and example.com should exist + if x := len(a.Zones.Z["example.org."].All()); x != 4 { + t.Fatalf("Expected 4 RRs, got %d", x) + } + if x := len(a.Zones.Z["example.com."].All()); x != 4 { + t.Fatalf("Expected 4 RRs, got %d", x) + } + + // Now remove one file, rescan and see if it's gone. + if err := os.Remove(path.Join(tempdir, "db.example.com")); err != nil { + t.Fatal(err) + } + + a.Walk() + + if _, ok := a.Zones.Z["example.com."]; ok { + t.Errorf("Expected %q to be gone.", "example.com.") + } + if _, ok := a.Zones.Z["example.org."]; !ok { + t.Errorf("Expected %q to still be there.", "example.org.") + } +} diff --git a/plugin/auto/zone.go b/plugin/auto/zone.go new file mode 100644 index 000000000..e46f04e33 --- /dev/null +++ b/plugin/auto/zone.go @@ -0,0 +1,76 @@ +// Package auto implements a on-the-fly loading file backend. +package auto + +import ( + "sync" + + "github.com/coredns/coredns/plugin/file" +) + +// Zones maps zone names to a *Zone. This keep track of what we zones we have loaded at +// any one time. +type Zones struct { + Z map[string]*file.Zone // A map mapping zone (origin) to the Zone's data. + names []string // All the keys from the map Z as a string slice. + + origins []string // Any origins from the server block. + + sync.RWMutex +} + +// Names returns the names from z. +func (z *Zones) Names() []string { + z.RLock() + n := z.names + z.RUnlock() + return n +} + +// Origins returns the origins from z. +func (z *Zones) Origins() []string { + // doesn't need locking, because there aren't multiple Go routines accessing it. + return z.origins +} + +// Zones returns a zone with origin name from z, nil when not found. +func (z *Zones) Zones(name string) *file.Zone { + z.RLock() + zo := z.Z[name] + z.RUnlock() + return zo +} + +// Add adds a new zone into z. If zo.NoReload is false, the +// reload goroutine is started. +func (z *Zones) Add(zo *file.Zone, name string) { + z.Lock() + + if z.Z == nil { + z.Z = make(map[string]*file.Zone) + } + + z.Z[name] = zo + z.names = append(z.names, name) + zo.Reload() + + z.Unlock() +} + +// Remove removes the zone named name from z. It also stop the the zone's reload goroutine. +func (z *Zones) Remove(name string) { + z.Lock() + + if zo, ok := z.Z[name]; ok && !zo.NoReload { + zo.ReloadShutdown <- true + } + + delete(z.Z, name) + + // TODO(miek): just regenerate Names (might be bad if you have a lot of zones...) + z.names = []string{} + for n := range z.Z { + z.names = append(z.names, n) + } + + z.Unlock() +} diff --git a/plugin/autopath/README.md b/plugin/autopath/README.md new file mode 100644 index 000000000..02b4390fc --- /dev/null +++ b/plugin/autopath/README.md @@ -0,0 +1,45 @@ +# autopath + +The *autopath* plugin allows CoreDNS to perform server side search path completion. +If it sees a query that matches the first element of the configured search path, *autopath* will +follow the chain of search path elements and returns the first reply that is not NXDOMAIN. +On any failures the original reply is returned. + +Because *autopath* returns a reply for a name that wasn't the original question it will add a CNAME +that points from the original name (with the search path element in it) to the name of this answer. + +## Syntax + +~~~ +autopath [ZONE..] RESOLV-CONF +~~~ + +* **ZONES** zones *autopath* should be authoritative for. +* **RESOLV-CONF** points to a `resolv.conf` like file or uses a special syntax to point to another + plugin. For instance `@kubernetes`, will call out to the kubernetes plugin (for each + query) to retrieve the search list it should use. + +Currently the following set of plugin has implemented *autopath*: + +* *kubernetes* +* *erratic* + +## Examples + +~~~ +autopath my-resolv.conf +~~~ + +Use `my-resolv.conf` as the file to get the search path from. This file only needs so have one line: +`search domain1 domain2 ...` + +~~~ +autopath @kubernetes +~~~ + +Use the search path dynamically retrieved from the kubernetes plugin. + +## Bugs + +When the *cache* plugin is enabled it is possible for pods in different namespaces to get the +same answer. diff --git a/plugin/autopath/autopath.go b/plugin/autopath/autopath.go new file mode 100644 index 000000000..5c804a040 --- /dev/null +++ b/plugin/autopath/autopath.go @@ -0,0 +1,152 @@ +/* +Package autopath implements autopathing. This is a hack; it shortcuts the +client's search path resolution by performing these lookups on the server... + +The server has a copy (via AutoPathFunc) of the client's search path and on +receiving a query it first establish if the suffix matches the FIRST configured +element. If no match can be found the query will be forwarded up the plugin +chain without interference (iff 'fallthrough' has been set). + +If the query is deemed to fall in the search path the server will perform the +queries with each element of the search path appended in sequence until a +non-NXDOMAIN answer has been found. That reply will then be returned to the +client - with some CNAME hackery to let the client accept the reply. + +If all queries return NXDOMAIN we return the original as-is and let the client +continue searching. The client will go to the next element in the search path, +but we won’t do any more autopathing. It means that in the failure case, you do +more work, since the server looks it up, then the client still needs to go +through the search path. + +It is assume the search path ordering is identical between server and client. + +Midldeware implementing autopath, must have a function called `AutoPath` of type +autopath.Func. Note the searchpath must be ending with the empty string. + +I.e: + +func (m Middleware ) AutoPath(state request.Request) []string { + return []string{"first", "second", "last", ""} +} +*/ +package autopath + +import ( + "log" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/pkg/nonwriter" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Func defines the function plugin should implement to return a search +// path to the autopath plugin. The last element of the slice must be the empty string. +// If Func returns a nil slice, no autopathing will be done. +type Func func(request.Request) []string + +// AutoPath perform autopath: service side search path completion. +type AutoPath struct { + Next plugin.Handler + Zones []string + + // Search always includes "" as the last element, so we try the base query with out any search paths added as well. + search []string + searchFunc Func +} + +// ServeDNS implements the plugin.Handle interface. +func (a *AutoPath) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + zone := plugin.Zones(a.Zones).Matches(state.Name()) + if zone == "" { + return plugin.NextOrFailure(a.Name(), a.Next, ctx, w, r) + } + + // Check if autopath should be done, searchFunc takes precedence over the local configured search path. + var err error + searchpath := a.search + + if a.searchFunc != nil { + searchpath = a.searchFunc(state) + } + + if len(searchpath) == 0 { + log.Printf("[WARNING] No search path available for autopath") + return plugin.NextOrFailure(a.Name(), a.Next, ctx, w, r) + } + + if !firstInSearchPath(state.Name(), searchpath) { + return plugin.NextOrFailure(a.Name(), a.Next, ctx, w, r) + } + + origQName := state.QName() + + // Establish base name of the query. I.e what was originally asked. + base, err := dnsutil.TrimZone(state.QName(), searchpath[0]) // TODO(miek): we loose the original case of the query here. + if err != nil { + return dns.RcodeServerFailure, err + } + + firstReply := new(dns.Msg) + firstRcode := 0 + var firstErr error + + ar := r.Copy() + // Walk the search path and see if we can get a non-nxdomain - if they all fail we return the first + // query we've done and return that as-is. This means the client will do the search path walk again... + for i, s := range searchpath { + newQName := base + "." + s + ar.Question[0].Name = newQName + nw := nonwriter.New(w) + + rcode, err := plugin.NextOrFailure(a.Name(), a.Next, ctx, nw, ar) + if err != nil { + // Return now - not sure if this is the best. We should also check if the write has happened. + return rcode, err + } + if i == 0 { + firstReply = nw.Msg + firstRcode = rcode + firstErr = err + } + + if !plugin.ClientWrite(rcode) { + continue + } + + if nw.Msg.Rcode == dns.RcodeNameError { + continue + } + + msg := nw.Msg + cnamer(msg, origQName) + + // Write whatever non-nxdomain answer we've found. + w.WriteMsg(msg) + return rcode, err + + } + if plugin.ClientWrite(firstRcode) { + w.WriteMsg(firstReply) + } + return firstRcode, firstErr +} + +// Name implements the Handler interface. +func (a *AutoPath) Name() string { return "autopath" } + +// firstInSearchPath checks if name is equal to are a sibling of the first element in the search path. +func firstInSearchPath(name string, searchpath []string) bool { + if name == searchpath[0] { + return true + } + if dns.IsSubDomain(searchpath[0], name) { + return true + } + return false +} diff --git a/plugin/autopath/autopath_test.go b/plugin/autopath/autopath_test.go new file mode 100644 index 000000000..a00bbf0a6 --- /dev/null +++ b/plugin/autopath/autopath_test.go @@ -0,0 +1,166 @@ +package autopath + +import ( + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var autopathTestCases = []test.Case{ + { + // search path expansion. + Qname: "b.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.CNAME("b.example.org. 3600 IN CNAME b.com."), + test.A("b.com." + defaultA), + }, + }, + { + // No search path expansion + Qname: "a.example.com.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("a.example.com." + defaultA), + }, + }, +} + +func newTestAutoPath() *AutoPath { + ap := new(AutoPath) + ap.Zones = []string{"."} + ap.Next = nextHandler(map[string]int{ + "b.example.org.": dns.RcodeNameError, + "b.com.": dns.RcodeSuccess, + "a.example.com.": dns.RcodeSuccess, + }) + + ap.search = []string{"example.org.", "example.com.", "com.", ""} + return ap +} + +func TestAutoPath(t *testing.T) { + ap := newTestAutoPath() + ctx := context.TODO() + + for _, tc := range autopathTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := ap.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + continue + } + + // No sorting here as we want to check if the CNAME sits *before* the + // test of the answer. + resp := rec.Msg + + if !test.Header(t, tc, resp) { + t.Logf("%v\n", resp) + continue + } + if !test.Section(t, tc, test.Answer, resp.Answer) { + t.Logf("%v\n", resp) + } + if !test.Section(t, tc, test.Ns, resp.Ns) { + t.Logf("%v\n", resp) + } + if !test.Section(t, tc, test.Extra, resp.Extra) { + t.Logf("%v\n", resp) + } + } +} + +var autopathNoAnswerTestCases = []test.Case{ + { + // search path expansion, no answer + Qname: "c.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.CNAME("b.example.org. 3600 IN CNAME b.com."), + test.A("b.com." + defaultA), + }, + }, +} + +func TestAutoPathNoAnswer(t *testing.T) { + ap := newTestAutoPath() + ctx := context.TODO() + + for _, tc := range autopathNoAnswerTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + rcode, err := ap.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + continue + } + if plugin.ClientWrite(rcode) { + t.Fatalf("expected no client write, got one for rcode %d", rcode) + } + } +} + +// nextHandler returns a Handler that returns an answer for the question in the +// request per the domain->answer map. On success an RR will be returned: "qname 3600 IN A 127.0.0.53" +func nextHandler(mm map[string]int) test.Handler { + return test.HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + rcode, ok := mm[r.Question[0].Name] + if !ok { + return dns.RcodeServerFailure, nil + } + + m := new(dns.Msg) + m.SetReply(r) + + switch rcode { + case dns.RcodeNameError: + m.Rcode = rcode + m.Ns = []dns.RR{soa} + w.WriteMsg(m) + return m.Rcode, nil + + case dns.RcodeSuccess: + m.Rcode = rcode + a, _ := dns.NewRR(r.Question[0].Name + defaultA) + m.Answer = []dns.RR{a} + + w.WriteMsg(m) + return m.Rcode, nil + default: + panic("nextHandler: unhandled rcode") + } + }) +} + +const defaultA = " 3600 IN A 127.0.0.53" + +var soa = func() dns.RR { + s, _ := dns.NewRR("example.org. 1800 IN SOA example.org. example.org. 1502165581 14400 3600 604800 14400") + return s +}() + +func TestInSearchPath(t *testing.T) { + a := AutoPath{search: []string{"default.svc.cluster.local.", "svc.cluster.local.", "cluster.local."}} + + tests := []struct { + qname string + b bool + }{ + {"google.com", false}, + {"default.svc.cluster.local.", true}, + {"a.default.svc.cluster.local.", true}, + {"a.b.svc.cluster.local.", false}, + } + for i, tc := range tests { + got := firstInSearchPath(tc.qname, a.search) + if got != tc.b { + t.Errorf("Test %d, got %v, expected %v", i, got, tc.b) + } + } +} diff --git a/plugin/autopath/cname.go b/plugin/autopath/cname.go new file mode 100644 index 000000000..3b2c60f4e --- /dev/null +++ b/plugin/autopath/cname.go @@ -0,0 +1,25 @@ +package autopath + +import ( + "strings" + + "github.com/miekg/dns" +) + +// cnamer will prefix the answer section with a cname that points from original qname to the +// name of the first RR. It will also update the question section and put original in there. +func cnamer(m *dns.Msg, original string) { + for _, a := range m.Answer { + if strings.EqualFold(original, a.Header().Name) { + continue + } + m.Answer = append(m.Answer, nil) + copy(m.Answer[1:], m.Answer) + m.Answer[0] = &dns.CNAME{ + Hdr: dns.RR_Header{Name: original, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: a.Header().Ttl}, + Target: a.Header().Name, + } + break + } + m.Question[0].Name = original +} diff --git a/plugin/autopath/setup.go b/plugin/autopath/setup.go new file mode 100644 index 000000000..c83912a63 --- /dev/null +++ b/plugin/autopath/setup.go @@ -0,0 +1,93 @@ +package autopath + +import ( + "fmt" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/erratic" + "github.com/coredns/coredns/plugin/kubernetes" + + "github.com/mholt/caddy" + "github.com/miekg/dns" +) + +func init() { + caddy.RegisterPlugin("autopath", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) + +} + +func setup(c *caddy.Controller) error { + ap, mw, err := autoPathParse(c) + if err != nil { + return plugin.Error("autopath", err) + } + + // Do this in OnStartup, so all plugin has been initialized. + c.OnStartup(func() error { + m := dnsserver.GetConfig(c).Handler(mw) + if m == nil { + return nil + } + if x, ok := m.(*kubernetes.Kubernetes); ok { + ap.searchFunc = x.AutoPath + } + if x, ok := m.(*erratic.Erratic); ok { + ap.searchFunc = x.AutoPath + } + return nil + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + ap.Next = next + return ap + }) + + return nil +} + +// allowedMiddleware has a list of plugin that can be used by autopath. +var allowedMiddleware = map[string]bool{ + "@kubernetes": true, + "@erratic": true, +} + +func autoPathParse(c *caddy.Controller) (*AutoPath, string, error) { + ap := &AutoPath{} + mw := "" + + for c.Next() { + zoneAndresolv := c.RemainingArgs() + if len(zoneAndresolv) < 1 { + return ap, "", fmt.Errorf("no resolv-conf specified") + } + resolv := zoneAndresolv[len(zoneAndresolv)-1] + if resolv[0] == '@' { + _, ok := allowedMiddleware[resolv] + if ok { + mw = resolv[1:] + } + } else { + // assume file on disk + rc, err := dns.ClientConfigFromFile(resolv) + if err != nil { + return ap, "", fmt.Errorf("failed to parse %q: %v", resolv, err) + } + ap.search = rc.Search + plugin.Zones(ap.search).Normalize() + ap.search = append(ap.search, "") // sentinal value as demanded. + } + ap.Zones = zoneAndresolv[:len(zoneAndresolv)-1] + if len(ap.Zones) == 0 { + ap.Zones = make([]string, len(c.ServerBlockKeys)) + copy(ap.Zones, c.ServerBlockKeys) + } + for i, str := range ap.Zones { + ap.Zones[i] = plugin.Host(str).Normalize() + } + } + return ap, mw, nil +} diff --git a/plugin/autopath/setup_test.go b/plugin/autopath/setup_test.go new file mode 100644 index 000000000..3e13aa74f --- /dev/null +++ b/plugin/autopath/setup_test.go @@ -0,0 +1,77 @@ +package autopath + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/coredns/coredns/plugin/test" + + "github.com/mholt/caddy" +) + +func TestSetupAutoPath(t *testing.T) { + resolv, rm, err := test.TempFile(os.TempDir(), resolvConf) + if err != nil { + t.Fatalf("Could not create resolv.conf test file %s: %s", resolvConf, err) + } + defer rm() + + tests := []struct { + input string + shouldErr bool + expectedZone string + expectedMw string // expected plugin. + expectedSearch []string // expected search path + expectedErrContent string // substring from the expected error. Empty for positive cases. + }{ + // positive + {`autopath @kubernetes`, false, "", "kubernetes", nil, ""}, + {`autopath example.org @kubernetes`, false, "example.org.", "kubernetes", nil, ""}, + {`autopath 10.0.0.0/8 @kubernetes`, false, "10.in-addr.arpa.", "kubernetes", nil, ""}, + {`autopath ` + resolv, false, "", "", []string{"bar.com.", "baz.com.", ""}, ""}, + // negative + {`autopath kubernetes`, true, "", "", nil, "open kubernetes: no such file or directory"}, + {`autopath`, true, "", "", nil, "no resolv-conf"}, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + ap, mw, err := autoPathParse(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + } + } + + if !test.shouldErr && mw != test.expectedMw { + t.Errorf("Test %d, Middleware not correctly set for input %s. Expected: %s, actual: %s", i, test.input, test.expectedMw, mw) + } + if !test.shouldErr && ap.search != nil { + if !reflect.DeepEqual(test.expectedSearch, ap.search) { + t.Errorf("Test %d, wrong searchpath for input %s. Expected: '%v', actual: '%v'", i, test.input, test.expectedSearch, ap.search) + } + } + if !test.shouldErr && test.expectedZone != "" { + if test.expectedZone != ap.Zones[0] { + t.Errorf("Test %d, expected zone %q for input %s, got: %q", i, test.expectedZone, test.input, ap.Zones[0]) + } + } + } +} + +const resolvConf = `nameserver 1.2.3.4 +domain foo.com +search bar.com baz.com +options ndots:5 +` diff --git a/plugin/backend.go b/plugin/backend.go new file mode 100644 index 000000000..b520ce390 --- /dev/null +++ b/plugin/backend.go @@ -0,0 +1,32 @@ +package plugin + +import ( + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// ServiceBackend defines a (dynamic) backend that returns a slice of service definitions. +type ServiceBackend interface { + // Services communicates with the backend to retrieve the service definition. Exact indicates + // on exact much are that we are allowed to recurs. + Services(state request.Request, exact bool, opt Options) ([]msg.Service, error) + + // Reverse communicates with the backend to retrieve service definition based on a IP address + // instead of a name. I.e. a reverse DNS lookup. + Reverse(state request.Request, exact bool, opt Options) ([]msg.Service, error) + + // Lookup is used to find records else where. + Lookup(state request.Request, name string, typ uint16) (*dns.Msg, error) + + // Returns _all_ services that matches a certain name. + // Note: it does not implement a specific service. + Records(state request.Request, exact bool) ([]msg.Service, error) + + // IsNameError return true if err indicated a record not found condition + IsNameError(err error) bool +} + +// Options are extra options that can be specified for a lookup. +type Options struct{} diff --git a/plugin/backend_lookup.go b/plugin/backend_lookup.go new file mode 100644 index 000000000..f04b397b2 --- /dev/null +++ b/plugin/backend_lookup.go @@ -0,0 +1,410 @@ +package plugin + +import ( + "fmt" + "math" + "net" + "time" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// A returns A records from Backend or an error. +func A(b ServiceBackend, zone string, state request.Request, previousRecords []dns.RR, opt Options) (records []dns.RR, err error) { + services, err := b.Services(state, false, opt) + if err != nil { + return nil, err + } + + for _, serv := range services { + + what, ip := serv.HostType() + + switch what { + case dns.TypeCNAME: + if Name(state.Name()).Matches(dns.Fqdn(serv.Host)) { + // x CNAME x is a direct loop, don't add those + continue + } + + newRecord := serv.NewCNAME(state.QName(), serv.Host) + if len(previousRecords) > 7 { + // don't add it, and just continue + continue + } + if dnsutil.DuplicateCNAME(newRecord, previousRecords) { + continue + } + + state1 := state.NewWithQuestion(serv.Host, state.QType()) + nextRecords, err := A(b, zone, state1, append(previousRecords, newRecord), opt) + + if err == nil { + // Not only have we found something we should add the CNAME and the IP addresses. + if len(nextRecords) > 0 { + records = append(records, newRecord) + records = append(records, nextRecords...) + } + continue + } + // This means we can not complete the CNAME, try to look else where. + target := newRecord.Target + if dns.IsSubDomain(zone, target) { + // We should already have found it + continue + } + // Lookup + m1, e1 := b.Lookup(state, target, state.QType()) + if e1 != nil { + continue + } + // Len(m1.Answer) > 0 here is well? + records = append(records, newRecord) + records = append(records, m1.Answer...) + continue + + case dns.TypeA: + records = append(records, serv.NewA(state.QName(), ip)) + + case dns.TypeAAAA: + // nodata? + } + } + return records, nil +} + +// AAAA returns AAAA records from Backend or an error. +func AAAA(b ServiceBackend, zone string, state request.Request, previousRecords []dns.RR, opt Options) (records []dns.RR, err error) { + services, err := b.Services(state, false, opt) + if err != nil { + return nil, err + } + + for _, serv := range services { + + what, ip := serv.HostType() + + switch what { + case dns.TypeCNAME: + // Try to resolve as CNAME if it's not an IP, but only if we don't create loops. + if Name(state.Name()).Matches(dns.Fqdn(serv.Host)) { + // x CNAME x is a direct loop, don't add those + continue + } + + newRecord := serv.NewCNAME(state.QName(), serv.Host) + if len(previousRecords) > 7 { + // don't add it, and just continue + continue + } + if dnsutil.DuplicateCNAME(newRecord, previousRecords) { + continue + } + + state1 := state.NewWithQuestion(serv.Host, state.QType()) + nextRecords, err := AAAA(b, zone, state1, append(previousRecords, newRecord), opt) + + if err == nil { + // Not only have we found something we should add the CNAME and the IP addresses. + if len(nextRecords) > 0 { + records = append(records, newRecord) + records = append(records, nextRecords...) + } + continue + } + // This means we can not complete the CNAME, try to look else where. + target := newRecord.Target + if dns.IsSubDomain(zone, target) { + // We should already have found it + continue + } + m1, e1 := b.Lookup(state, target, state.QType()) + if e1 != nil { + continue + } + // Len(m1.Answer) > 0 here is well? + records = append(records, newRecord) + records = append(records, m1.Answer...) + continue + // both here again + + case dns.TypeA: + // nada? + + case dns.TypeAAAA: + records = append(records, serv.NewAAAA(state.QName(), ip)) + } + } + return records, nil +} + +// SRV returns SRV records from the Backend. +// If the Target is not a name but an IP address, a name is created on the fly. +func SRV(b ServiceBackend, zone string, state request.Request, opt Options) (records, extra []dns.RR, err error) { + services, err := b.Services(state, false, opt) + if err != nil { + return nil, nil, err + } + + // Looping twice to get the right weight vs priority + w := make(map[int]int) + for _, serv := range services { + weight := 100 + if serv.Weight != 0 { + weight = serv.Weight + } + if _, ok := w[serv.Priority]; !ok { + w[serv.Priority] = weight + continue + } + w[serv.Priority] += weight + } + lookup := make(map[string]bool) + for _, serv := range services { + w1 := 100.0 / float64(w[serv.Priority]) + if serv.Weight == 0 { + w1 *= 100 + } else { + w1 *= float64(serv.Weight) + } + weight := uint16(math.Floor(w1)) + + what, ip := serv.HostType() + + switch what { + case dns.TypeCNAME: + srv := serv.NewSRV(state.QName(), weight) + records = append(records, srv) + + if _, ok := lookup[srv.Target]; ok { + break + } + + lookup[srv.Target] = true + + if !dns.IsSubDomain(zone, srv.Target) { + m1, e1 := b.Lookup(state, srv.Target, dns.TypeA) + if e1 == nil { + extra = append(extra, m1.Answer...) + } + + m1, e1 = b.Lookup(state, srv.Target, dns.TypeAAAA) + if e1 == nil { + // If we have seen CNAME's we *assume* that they are already added. + for _, a := range m1.Answer { + if _, ok := a.(*dns.CNAME); !ok { + extra = append(extra, a) + } + } + } + break + } + // Internal name, we should have some info on them, either v4 or v6 + // Clients expect a complete answer, because we are a recursor in their view. + state1 := state.NewWithQuestion(srv.Target, dns.TypeA) + addr, e1 := A(b, zone, state1, nil, opt) + if e1 == nil { + extra = append(extra, addr...) + } + // IPv6 lookups here as well? AAAA(zone, state1, nil). + + case dns.TypeA, dns.TypeAAAA: + serv.Host = msg.Domain(serv.Key) + srv := serv.NewSRV(state.QName(), weight) + + records = append(records, srv) + extra = append(extra, newAddress(serv, srv.Target, ip, what)) + } + } + return records, extra, nil +} + +// MX returns MX records from the Backend. If the Target is not a name but an IP address, a name is created on the fly. +func MX(b ServiceBackend, zone string, state request.Request, opt Options) (records, extra []dns.RR, err error) { + services, err := b.Services(state, false, opt) + if err != nil { + return nil, nil, err + } + + lookup := make(map[string]bool) + for _, serv := range services { + if !serv.Mail { + continue + } + what, ip := serv.HostType() + switch what { + case dns.TypeCNAME: + mx := serv.NewMX(state.QName()) + records = append(records, mx) + if _, ok := lookup[mx.Mx]; ok { + break + } + + lookup[mx.Mx] = true + + if !dns.IsSubDomain(zone, mx.Mx) { + m1, e1 := b.Lookup(state, mx.Mx, dns.TypeA) + if e1 == nil { + extra = append(extra, m1.Answer...) + } + + m1, e1 = b.Lookup(state, mx.Mx, dns.TypeAAAA) + if e1 == nil { + // If we have seen CNAME's we *assume* that they are already added. + for _, a := range m1.Answer { + if _, ok := a.(*dns.CNAME); !ok { + extra = append(extra, a) + } + } + } + break + } + // Internal name + state1 := state.NewWithQuestion(mx.Mx, dns.TypeA) + addr, e1 := A(b, zone, state1, nil, opt) + if e1 == nil { + extra = append(extra, addr...) + } + // e.AAAA as well + + case dns.TypeA, dns.TypeAAAA: + serv.Host = msg.Domain(serv.Key) + records = append(records, serv.NewMX(state.QName())) + extra = append(extra, newAddress(serv, serv.Host, ip, what)) + } + } + return records, extra, nil +} + +// CNAME returns CNAME records from the backend or an error. +func CNAME(b ServiceBackend, zone string, state request.Request, opt Options) (records []dns.RR, err error) { + services, err := b.Services(state, true, opt) + if err != nil { + return nil, err + } + + if len(services) > 0 { + serv := services[0] + if ip := net.ParseIP(serv.Host); ip == nil { + records = append(records, serv.NewCNAME(state.QName(), serv.Host)) + } + } + return records, nil +} + +// TXT returns TXT records from Backend or an error. +func TXT(b ServiceBackend, zone string, state request.Request, opt Options) (records []dns.RR, err error) { + services, err := b.Services(state, false, opt) + if err != nil { + return nil, err + } + + for _, serv := range services { + if serv.Text == "" { + continue + } + records = append(records, serv.NewTXT(state.QName())) + } + return records, nil +} + +// PTR returns the PTR records from the backend, only services that have a domain name as host are included. +func PTR(b ServiceBackend, zone string, state request.Request, opt Options) (records []dns.RR, err error) { + services, err := b.Reverse(state, true, opt) + if err != nil { + return nil, err + } + + for _, serv := range services { + if ip := net.ParseIP(serv.Host); ip == nil { + records = append(records, serv.NewPTR(state.QName(), serv.Host)) + } + } + return records, nil +} + +// NS returns NS records from the backend +func NS(b ServiceBackend, zone string, state request.Request, opt Options) (records, extra []dns.RR, err error) { + // NS record for this zone live in a special place, ns.dns.<zone>. Fake our lookup. + // only a tad bit fishy... + old := state.QName() + + state.Clear() + state.Req.Question[0].Name = "ns.dns." + zone + services, err := b.Services(state, false, opt) + if err != nil { + return nil, nil, err + } + // ... and reset + state.Req.Question[0].Name = old + + for _, serv := range services { + what, ip := serv.HostType() + switch what { + case dns.TypeCNAME: + return nil, nil, fmt.Errorf("NS record must be an IP address: %s", serv.Host) + + case dns.TypeA, dns.TypeAAAA: + serv.Host = msg.Domain(serv.Key) + records = append(records, serv.NewNS(state.QName())) + extra = append(extra, newAddress(serv, serv.Host, ip, what)) + } + } + return records, extra, nil +} + +// SOA returns a SOA record from the backend. +func SOA(b ServiceBackend, zone string, state request.Request, opt Options) ([]dns.RR, error) { + header := dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Ttl: 300, Class: dns.ClassINET} + + Mbox := hostmaster + "." + Ns := "ns.dns." + if zone[0] != '.' { + Mbox += zone + Ns += zone + } + + soa := &dns.SOA{Hdr: header, + Mbox: Mbox, + Ns: Ns, + Serial: uint32(time.Now().Unix()), + Refresh: 7200, + Retry: 1800, + Expire: 86400, + Minttl: minTTL, + } + return []dns.RR{soa}, nil +} + +// BackendError writes an error response to the client. +func BackendError(b ServiceBackend, zone string, rcode int, state request.Request, err error, opt Options) (int, error) { + m := new(dns.Msg) + m.SetRcode(state.Req, rcode) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + m.Ns, _ = SOA(b, zone, state, opt) + + state.SizeAndDo(m) + state.W.WriteMsg(m) + // Return success as the rcode to signal we have written to the client. + return dns.RcodeSuccess, err +} + +func newAddress(s msg.Service, name string, ip net.IP, what uint16) dns.RR { + + hdr := dns.RR_Header{Name: name, Rrtype: what, Class: dns.ClassINET, Ttl: s.TTL} + + if what == dns.TypeA { + return &dns.A{Hdr: hdr, A: ip} + } + // Should always be dns.TypeAAAA + return &dns.AAAA{Hdr: hdr, AAAA: ip} +} + +const ( + minTTL = 60 + hostmaster = "hostmaster" +) diff --git a/plugin/bind/README.md b/plugin/bind/README.md new file mode 100644 index 000000000..57b3c1e18 --- /dev/null +++ b/plugin/bind/README.md @@ -0,0 +1,22 @@ +# bind + +*bind* overrides the host to which the server should bind. + +Normally, the listener binds to the wildcard host. However, you may force the listener to bind to +another IP instead. This directive accepts only an address, not a port. + +## Syntax + +~~~ txt +bind ADDRESS +~~~ + +**ADDRESS** is the IP address to bind to. + +## Examples + +To make your socket accessible only to that machine, bind to IP 127.0.0.1 (localhost): + +~~~ txt +bind 127.0.0.1 +~~~ diff --git a/plugin/bind/bind.go b/plugin/bind/bind.go new file mode 100644 index 000000000..bd3c32b51 --- /dev/null +++ b/plugin/bind/bind.go @@ -0,0 +1,11 @@ +// Package bind allows binding to a specific interface instead of bind to all of them. +package bind + +import "github.com/mholt/caddy" + +func init() { + caddy.RegisterPlugin("bind", caddy.Plugin{ + ServerType: "dns", + Action: setupBind, + }) +} diff --git a/plugin/bind/bind_test.go b/plugin/bind/bind_test.go new file mode 100644 index 000000000..11556f0bd --- /dev/null +++ b/plugin/bind/bind_test.go @@ -0,0 +1,30 @@ +package bind + +import ( + "testing" + + "github.com/coredns/coredns/core/dnsserver" + + "github.com/mholt/caddy" +) + +func TestSetupBind(t *testing.T) { + c := caddy.NewTestController("dns", `bind 1.2.3.4`) + err := setupBind(c) + if err != nil { + t.Fatalf("Expected no errors, but got: %v", err) + } + + cfg := dnsserver.GetConfig(c) + if got, want := cfg.ListenHost, "1.2.3.4"; got != want { + t.Errorf("Expected the config's ListenHost to be %s, was %s", want, got) + } +} + +func TestBindAddress(t *testing.T) { + c := caddy.NewTestController("dns", `bind 1.2.3.bla`) + err := setupBind(c) + if err == nil { + t.Fatalf("Expected errors, but got none") + } +} diff --git a/plugin/bind/setup.go b/plugin/bind/setup.go new file mode 100644 index 000000000..796377841 --- /dev/null +++ b/plugin/bind/setup.go @@ -0,0 +1,24 @@ +package bind + +import ( + "fmt" + "net" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func setupBind(c *caddy.Controller) error { + config := dnsserver.GetConfig(c) + for c.Next() { + if !c.Args(&config.ListenHost) { + return plugin.Error("bind", c.ArgErr()) + } + } + if net.ParseIP(config.ListenHost) == nil { + return plugin.Error("bind", fmt.Errorf("not a valid IP address: %s", config.ListenHost)) + } + return nil +} diff --git a/plugin/cache/README.md b/plugin/cache/README.md new file mode 100644 index 000000000..6477fe891 --- /dev/null +++ b/plugin/cache/README.md @@ -0,0 +1,68 @@ +# cache + +*cache* enables a frontend cache. It will cache all records except zone transfers and metadata records. + +## Syntax + +~~~ txt +cache [TTL] [ZONES...] +~~~ + +* **TTL** max TTL in seconds. If not specified, the maximum TTL will be used which is 3600 for + noerror responses and 1800 for denial of existence ones. + Setting a TTL of 300 *cache 300* would cache the record up to 300 seconds. +* **ZONES** zones it should cache for. If empty, the zones from the configuration block are used. + +Each element in the cache is cached according to its TTL (with **TTL** as the max). +For the negative cache, the SOA's MinTTL value is used. A cache can contain up to 10,000 items by +default. A TTL of zero is not allowed. + +If you want more control: + +~~~ txt +cache [TTL] [ZONES...] { + success CAPACITY [TTL] + denial CAPACITY [TTL] + prefetch AMOUNT [[DURATION] [PERCENTAGE%]] +} +~~~ + +* **TTL** and **ZONES** as above. +* `success`, override the settings for caching successful responses, **CAPACITY** indicates the maximum + number of packets we cache before we start evicting (*randomly*). **TTL** overrides the cache maximum TTL. +* `denial`, override the settings for caching denial of existence responses, **CAPACITY** indicates the maximum + number of packets we cache before we start evicting (LRU). **TTL** overrides the cache maximum TTL. + There is a third category (`error`) but those responses are never cached. +* `prefetch`, will prefetch popular items when they are about to be expunged from the cache. + Popular means **AMOUNT** queries have been seen no gaps of **DURATION** or more between them. + **DURATION** defaults to 1m. Prefetching will happen when the TTL drops below **PERCENTAGE**, + which defaults to `10%`. Values should be in the range `[10%, 90%]`. Note the percent sign is + mandatory. **PERCENTAGE** is treated as an `int`. + +The minimum TTL allowed on resource records is 5 seconds. + +## Metrics + +If monitoring is enabled (via the *prometheus* directive) then the following metrics are exported: + +* coredns_cache_size{type} - Total elements in the cache by cache type. +* coredns_cache_capacity{type} - Total capacity of the cache by cache type. +* coredns_cache_hits_total{type} - Counter of cache hits by cache type. +* coredns_cache_misses_total - Counter of cache misses. + +Cache types are either "denial" or "success". + +## Examples + +Enable caching for all zones, but cap everything to a TTL of 10 seconds: + +~~~ +cache 10 +~~~ + +Proxy to Google Public DNS and only cache responses for example.org (or below). + +~~~ +proxy . 8.8.8.8:53 +cache example.org +~~~ diff --git a/plugin/cache/cache.go b/plugin/cache/cache.go new file mode 100644 index 000000000..b37e527cf --- /dev/null +++ b/plugin/cache/cache.go @@ -0,0 +1,167 @@ +// Package cache implements a cache. +package cache + +import ( + "encoding/binary" + "hash/fnv" + "log" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/pkg/response" + + "github.com/miekg/dns" +) + +// Cache is plugin that looks up responses in a cache and caches replies. +// It has a success and a denial of existence cache. +type Cache struct { + Next plugin.Handler + Zones []string + + ncache *cache.Cache + ncap int + nttl time.Duration + + pcache *cache.Cache + pcap int + pttl time.Duration + + // Prefetch. + prefetch int + duration time.Duration + percentage int +} + +// Return key under which we store the item, -1 will be returned if we don't store the +// message. +// Currently we do not cache Truncated, errors zone transfers or dynamic update messages. +func key(m *dns.Msg, t response.Type, do bool) int { + // We don't store truncated responses. + if m.Truncated { + return -1 + } + // Nor errors or Meta or Update + if t == response.OtherError || t == response.Meta || t == response.Update { + return -1 + } + + return int(hash(m.Question[0].Name, m.Question[0].Qtype, do)) +} + +var one = []byte("1") +var zero = []byte("0") + +func hash(qname string, qtype uint16, do bool) uint32 { + h := fnv.New32() + + if do { + h.Write(one) + } else { + h.Write(zero) + } + + b := make([]byte, 2) + binary.BigEndian.PutUint16(b, qtype) + h.Write(b) + + for i := range qname { + c := qname[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + h.Write([]byte{c}) + } + + return h.Sum32() +} + +// ResponseWriter is a response writer that caches the reply message. +type ResponseWriter struct { + dns.ResponseWriter + *Cache + + prefetch bool // When true write nothing back to the client. +} + +// WriteMsg implements the dns.ResponseWriter interface. +func (w *ResponseWriter) WriteMsg(res *dns.Msg) error { + do := false + mt, opt := response.Typify(res, time.Now().UTC()) + if opt != nil { + do = opt.Do() + } + + // key returns empty string for anything we don't want to cache. + key := key(res, mt, do) + + duration := w.pttl + if mt == response.NameError || mt == response.NoData { + duration = w.nttl + } + + msgTTL := minMsgTTL(res, mt) + if msgTTL < duration { + duration = msgTTL + } + + if key != -1 { + w.set(res, key, mt, duration) + + cacheSize.WithLabelValues(Success).Set(float64(w.pcache.Len())) + cacheSize.WithLabelValues(Denial).Set(float64(w.ncache.Len())) + } + + if w.prefetch { + return nil + } + + return w.ResponseWriter.WriteMsg(res) +} + +func (w *ResponseWriter) set(m *dns.Msg, key int, mt response.Type, duration time.Duration) { + if key == -1 { + log.Printf("[ERROR] Caching called with empty cache key") + return + } + + switch mt { + case response.NoError, response.Delegation: + i := newItem(m, duration) + w.pcache.Add(uint32(key), i) + + case response.NameError, response.NoData: + i := newItem(m, duration) + w.ncache.Add(uint32(key), i) + + case response.OtherError: + // don't cache these + default: + log.Printf("[WARNING] Caching called with unknown classification: %d", mt) + } +} + +// Write implements the dns.ResponseWriter interface. +func (w *ResponseWriter) Write(buf []byte) (int, error) { + log.Printf("[WARNING] Caching called with Write: not caching reply") + if w.prefetch { + return 0, nil + } + n, err := w.ResponseWriter.Write(buf) + return n, err +} + +const ( + maxTTL = 1 * time.Hour + maxNTTL = 30 * time.Minute + + minTTL = 5 // seconds + + defaultCap = 10000 // default capacity of the cache. + + // Success is the class for caching positive caching. + Success = "success" + // Denial is the class defined for negative caching. + Denial = "denial" +) diff --git a/plugin/cache/cache_test.go b/plugin/cache/cache_test.go new file mode 100644 index 000000000..ad23f4d5a --- /dev/null +++ b/plugin/cache/cache_test.go @@ -0,0 +1,251 @@ +package cache + +import ( + "io/ioutil" + "log" + "testing" + "time" + + "golang.org/x/net/context" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/pkg/response" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +type cacheTestCase struct { + test.Case + in test.Case + AuthenticatedData bool + Authoritative bool + RecursionAvailable bool + Truncated bool + shouldCache bool +} + +var cacheTestCases = []cacheTestCase{ + { + RecursionAvailable: true, AuthenticatedData: true, Authoritative: true, + Case: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("miek.nl. 3600 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 3600 IN MX 10 aspmx2.googlemail.com."), + }, + }, + in: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("miek.nl. 3601 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 3601 IN MX 10 aspmx2.googlemail.com."), + }, + }, + shouldCache: true, + }, + { + RecursionAvailable: true, AuthenticatedData: true, Authoritative: true, + Case: test.Case{ + Qname: "mIEK.nL.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("mIEK.nL. 3600 IN MX 1 aspmx.l.google.com."), + test.MX("mIEK.nL. 3600 IN MX 10 aspmx2.googlemail.com."), + }, + }, + in: test.Case{ + Qname: "mIEK.nL.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("mIEK.nL. 3601 IN MX 1 aspmx.l.google.com."), + test.MX("mIEK.nL. 3601 IN MX 10 aspmx2.googlemail.com."), + }, + }, + shouldCache: true, + }, + { + Truncated: true, + Case: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com.")}, + }, + in: test.Case{}, + shouldCache: false, + }, + { + RecursionAvailable: true, Authoritative: true, + Case: test.Case{ + Rcode: dns.RcodeNameError, + Qname: "example.org.", Qtype: dns.TypeA, + Ns: []dns.RR{ + test.SOA("example.org. 3600 IN SOA sns.dns.icann.org. noc.dns.icann.org. 2016082540 7200 3600 1209600 3600"), + }, + }, + in: test.Case{ + Rcode: dns.RcodeNameError, + Qname: "example.org.", Qtype: dns.TypeA, + Ns: []dns.RR{ + test.SOA("example.org. 3600 IN SOA sns.dns.icann.org. noc.dns.icann.org. 2016082540 7200 3600 1209600 3600"), + }, + }, + shouldCache: true, + }, + { + RecursionAvailable: true, Authoritative: true, + Case: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Do: true, + Answer: []dns.RR{ + test.MX("miek.nl. 3600 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 3600 IN MX 10 aspmx2.googlemail.com."), + test.RRSIG("miek.nl. 3600 IN RRSIG MX 8 2 1800 20160521031301 20160421031301 12051 miek.nl. lAaEzB5teQLLKyDenatmyhca7blLRg9DoGNrhe3NReBZN5C5/pMQk8Jc u25hv2fW23/SLm5IC2zaDpp2Fzgm6Jf7e90/yLcwQPuE7JjS55WMF+HE LEh7Z6AEb+Iq4BWmNhUz6gPxD4d9eRMs7EAzk13o1NYi5/JhfL6IlaYy qkc="), + }, + }, + in: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Do: true, + Answer: []dns.RR{ + test.MX("miek.nl. 3600 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 3600 IN MX 10 aspmx2.googlemail.com."), + test.RRSIG("miek.nl. 1800 IN RRSIG MX 8 2 1800 20160521031301 20160421031301 12051 miek.nl. lAaEzB5teQLLKyDenatmyhca7blLRg9DoGNrhe3NReBZN5C5/pMQk8Jc u25hv2fW23/SLm5IC2zaDpp2Fzgm6Jf7e90/yLcwQPuE7JjS55WMF+HE LEh7Z6AEb+Iq4BWmNhUz6gPxD4d9eRMs7EAzk13o1NYi5/JhfL6IlaYy qkc="), + }, + }, + shouldCache: false, + }, + { + RecursionAvailable: true, Authoritative: true, + Case: test.Case{ + Qname: "example.org.", Qtype: dns.TypeMX, + Do: true, + Answer: []dns.RR{ + test.MX("example.org. 3600 IN MX 1 aspmx.l.google.com."), + test.MX("example.org. 3600 IN MX 10 aspmx2.googlemail.com."), + test.RRSIG("example.org. 3600 IN RRSIG MX 8 2 1800 20170521031301 20170421031301 12051 miek.nl. lAaEzB5teQLLKyDenatmyhca7blLRg9DoGNrhe3NReBZN5C5/pMQk8Jc u25hv2fW23/SLm5IC2zaDpp2Fzgm6Jf7e90/yLcwQPuE7JjS55WMF+HE LEh7Z6AEb+Iq4BWmNhUz6gPxD4d9eRMs7EAzk13o1NYi5/JhfL6IlaYy qkc="), + }, + }, + in: test.Case{ + Qname: "example.org.", Qtype: dns.TypeMX, + Do: true, + Answer: []dns.RR{ + test.MX("example.org. 3600 IN MX 1 aspmx.l.google.com."), + test.MX("example.org. 3600 IN MX 10 aspmx2.googlemail.com."), + test.RRSIG("example.org. 1800 IN RRSIG MX 8 2 1800 20170521031301 20170421031301 12051 miek.nl. lAaEzB5teQLLKyDenatmyhca7blLRg9DoGNrhe3NReBZN5C5/pMQk8Jc u25hv2fW23/SLm5IC2zaDpp2Fzgm6Jf7e90/yLcwQPuE7JjS55WMF+HE LEh7Z6AEb+Iq4BWmNhUz6gPxD4d9eRMs7EAzk13o1NYi5/JhfL6IlaYy qkc="), + }, + }, + shouldCache: true, + }, +} + +func cacheMsg(m *dns.Msg, tc cacheTestCase) *dns.Msg { + m.RecursionAvailable = tc.RecursionAvailable + m.AuthenticatedData = tc.AuthenticatedData + m.Authoritative = tc.Authoritative + m.Rcode = tc.Rcode + m.Truncated = tc.Truncated + m.Answer = tc.in.Answer + m.Ns = tc.in.Ns + // m.Extra = tc.in.Extra don't copy Extra, because we don't care and fake EDNS0 DO with tc.Do. + return m +} + +func newTestCache(ttl time.Duration) (*Cache, *ResponseWriter) { + c := &Cache{Zones: []string{"."}, pcap: defaultCap, ncap: defaultCap, pttl: ttl, nttl: ttl} + c.pcache = cache.New(c.pcap) + c.ncache = cache.New(c.ncap) + + crr := &ResponseWriter{ResponseWriter: nil, Cache: c} + return c, crr +} + +func TestCache(t *testing.T) { + now, _ := time.Parse(time.UnixDate, "Fri Apr 21 10:51:21 BST 2017") + utc := now.UTC() + + c, crr := newTestCache(maxTTL) + + log.SetOutput(ioutil.Discard) + + for _, tc := range cacheTestCases { + m := tc.in.Msg() + m = cacheMsg(m, tc) + do := tc.in.Do + + mt, _ := response.Typify(m, utc) + k := key(m, mt, do) + + crr.set(m, k, mt, c.pttl) + + name := plugin.Name(m.Question[0].Name).Normalize() + qtype := m.Question[0].Qtype + + i, _ := c.get(time.Now().UTC(), name, qtype, do) + ok := i != nil + + if ok != tc.shouldCache { + t.Errorf("cached message that should not have been cached: %s", name) + continue + } + + if ok { + resp := i.toMsg(m) + + if !test.Header(t, tc.Case, resp) { + t.Logf("%v\n", resp) + continue + } + + if !test.Section(t, tc.Case, test.Answer, resp.Answer) { + t.Logf("%v\n", resp) + } + if !test.Section(t, tc.Case, test.Ns, resp.Ns) { + t.Logf("%v\n", resp) + + } + if !test.Section(t, tc.Case, test.Extra, resp.Extra) { + t.Logf("%v\n", resp) + } + } + } +} + +func BenchmarkCacheResponse(b *testing.B) { + c := &Cache{Zones: []string{"."}, pcap: defaultCap, ncap: defaultCap, pttl: maxTTL, nttl: maxTTL} + c.pcache = cache.New(c.pcap) + c.ncache = cache.New(c.ncap) + c.prefetch = 1 + c.duration = 1 * time.Second + c.Next = BackendHandler() + + ctx := context.TODO() + + reqs := make([]*dns.Msg, 5) + for i, q := range []string{"example1", "example2", "a", "b", "ddd"} { + reqs[i] = new(dns.Msg) + reqs[i].SetQuestion(q+".example.org.", dns.TypeA) + } + + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + req := reqs[i] + c.ServeDNS(ctx, &test.ResponseWriter{}, req) + i++ + i = i % 5 + } + }) +} + +func BackendHandler() plugin.Handler { + return plugin.HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + m := new(dns.Msg) + m.SetReply(r) + m.Response = true + m.RecursionAvailable = true + + owner := m.Question[0].Name + m.Answer = []dns.RR{test.A(owner + " 303 IN A 127.0.0.53")} + + w.WriteMsg(m) + return dns.RcodeSuccess, nil + }) +} diff --git a/plugin/cache/freq/freq.go b/plugin/cache/freq/freq.go new file mode 100644 index 000000000..f545f222e --- /dev/null +++ b/plugin/cache/freq/freq.go @@ -0,0 +1,55 @@ +// Package freq keeps track of last X seen events. The events themselves are not stored +// here. So the Freq type should be added next to the thing it is tracking. +package freq + +import ( + "sync" + "time" +) + +// Freq tracks the frequencies of things. +type Freq struct { + // Last time we saw a query for this element. + last time.Time + // Number of this in the last time slice. + hits int + + sync.RWMutex +} + +// New returns a new initialized Freq. +func New(t time.Time) *Freq { + return &Freq{last: t, hits: 0} +} + +// Update updates the number of hits. Last time seen will be set to now. +// If the last time we've seen this entity is within now - d, we increment hits, otherwise +// we reset hits to 1. It returns the number of hits. +func (f *Freq) Update(d time.Duration, now time.Time) int { + earliest := now.Add(-1 * d) + f.Lock() + defer f.Unlock() + if f.last.Before(earliest) { + f.last = now + f.hits = 1 + return f.hits + } + f.last = now + f.hits++ + return f.hits +} + +// Hits returns the number of hits that we have seen, according to the updates we have done to f. +func (f *Freq) Hits() int { + f.RLock() + defer f.RUnlock() + return f.hits +} + +// Reset resets f to time t and hits to hits. +func (f *Freq) Reset(t time.Time, hits int) { + f.Lock() + defer f.Unlock() + f.last = t + f.hits = hits +} diff --git a/plugin/cache/freq/freq_test.go b/plugin/cache/freq/freq_test.go new file mode 100644 index 000000000..740194c86 --- /dev/null +++ b/plugin/cache/freq/freq_test.go @@ -0,0 +1,36 @@ +package freq + +import ( + "testing" + "time" +) + +func TestFreqUpdate(t *testing.T) { + now := time.Now().UTC() + f := New(now) + window := 1 * time.Minute + + f.Update(window, time.Now().UTC()) + f.Update(window, time.Now().UTC()) + f.Update(window, time.Now().UTC()) + hitsCheck(t, f, 3) + + f.Reset(now, 0) + history := time.Now().UTC().Add(-3 * time.Minute) + f.Update(window, history) + hitsCheck(t, f, 1) +} + +func TestReset(t *testing.T) { + f := New(time.Now().UTC()) + f.Update(1*time.Minute, time.Now().UTC()) + hitsCheck(t, f, 1) + f.Reset(time.Now().UTC(), 0) + hitsCheck(t, f, 0) +} + +func hitsCheck(t *testing.T, f *Freq, expected int) { + if x := f.Hits(); x != expected { + t.Fatalf("Expected hits to be %d, got %d", expected, x) + } +} diff --git a/plugin/cache/handler.go b/plugin/cache/handler.go new file mode 100644 index 000000000..ebd87d659 --- /dev/null +++ b/plugin/cache/handler.go @@ -0,0 +1,119 @@ +package cache + +import ( + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/net/context" +) + +// ServeDNS implements the plugin.Handler interface. +func (c *Cache) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + qname := state.Name() + qtype := state.QType() + zone := plugin.Zones(c.Zones).Matches(qname) + if zone == "" { + return c.Next.ServeDNS(ctx, w, r) + } + + do := state.Do() // TODO(): might need more from OPT record? Like the actual bufsize? + + now := time.Now().UTC() + + i, ttl := c.get(now, qname, qtype, do) + if i != nil && ttl > 0 { + resp := i.toMsg(r) + + state.SizeAndDo(resp) + resp, _ = state.Scrub(resp) + w.WriteMsg(resp) + + i.Freq.Update(c.duration, now) + + pct := 100 + if i.origTTL != 0 { // you'll never know + pct = int(float64(ttl) / float64(i.origTTL) * 100) + } + + if c.prefetch > 0 && i.Freq.Hits() > c.prefetch && pct < c.percentage { + // When prefetching we loose the item i, and with it the frequency + // that we've gathered sofar. See we copy the frequencies info back + // into the new item that was stored in the cache. + prr := &ResponseWriter{ResponseWriter: w, Cache: c, prefetch: true} + plugin.NextOrFailure(c.Name(), c.Next, ctx, prr, r) + + if i1, _ := c.get(now, qname, qtype, do); i1 != nil { + i1.Freq.Reset(now, i.Freq.Hits()) + } + } + + return dns.RcodeSuccess, nil + } + + crr := &ResponseWriter{ResponseWriter: w, Cache: c} + return plugin.NextOrFailure(c.Name(), c.Next, ctx, crr, r) +} + +// Name implements the Handler interface. +func (c *Cache) Name() string { return "cache" } + +func (c *Cache) get(now time.Time, qname string, qtype uint16, do bool) (*item, int) { + k := hash(qname, qtype, do) + + if i, ok := c.ncache.Get(k); ok { + cacheHits.WithLabelValues(Denial).Inc() + return i.(*item), i.(*item).ttl(now) + } + + if i, ok := c.pcache.Get(k); ok { + cacheHits.WithLabelValues(Success).Inc() + return i.(*item), i.(*item).ttl(now) + } + cacheMisses.Inc() + return nil, 0 +} + +var ( + cacheSize = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "size", + Help: "The number of elements in the cache.", + }, []string{"type"}) + + cacheCapacity = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "capacity", + Help: "The cache's capacity.", + }, []string{"type"}) + + cacheHits = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "hits_total", + Help: "The count of cache hits.", + }, []string{"type"}) + + cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "misses_total", + Help: "The count of cache misses.", + }) +) + +const subsystem = "cache" + +func init() { + prometheus.MustRegister(cacheSize) + prometheus.MustRegister(cacheCapacity) + prometheus.MustRegister(cacheHits) + prometheus.MustRegister(cacheMisses) +} diff --git a/plugin/cache/item.go b/plugin/cache/item.go new file mode 100644 index 000000000..2c215617b --- /dev/null +++ b/plugin/cache/item.go @@ -0,0 +1,116 @@ +package cache + +import ( + "time" + + "github.com/coredns/coredns/plugin/cache/freq" + "github.com/coredns/coredns/plugin/pkg/response" + "github.com/miekg/dns" +) + +type item struct { + Rcode int + Authoritative bool + AuthenticatedData bool + RecursionAvailable bool + Answer []dns.RR + Ns []dns.RR + Extra []dns.RR + + origTTL uint32 + stored time.Time + + *freq.Freq +} + +func newItem(m *dns.Msg, d time.Duration) *item { + i := new(item) + i.Rcode = m.Rcode + i.Authoritative = m.Authoritative + i.AuthenticatedData = m.AuthenticatedData + i.RecursionAvailable = m.RecursionAvailable + i.Answer = m.Answer + i.Ns = m.Ns + i.Extra = make([]dns.RR, len(m.Extra)) + // Don't copy OPT record as these are hop-by-hop. + j := 0 + for _, e := range m.Extra { + if e.Header().Rrtype == dns.TypeOPT { + continue + } + i.Extra[j] = e + j++ + } + i.Extra = i.Extra[:j] + + i.origTTL = uint32(d.Seconds()) + i.stored = time.Now().UTC() + + i.Freq = new(freq.Freq) + + return i +} + +// toMsg turns i into a message, it tailors the reply to m. +// The Authoritative bit is always set to 0, because the answer is from the cache. +func (i *item) toMsg(m *dns.Msg) *dns.Msg { + m1 := new(dns.Msg) + m1.SetReply(m) + + m1.Authoritative = false + m1.AuthenticatedData = i.AuthenticatedData + m1.RecursionAvailable = i.RecursionAvailable + m1.Rcode = i.Rcode + m1.Compress = true + + m1.Answer = make([]dns.RR, len(i.Answer)) + m1.Ns = make([]dns.RR, len(i.Ns)) + m1.Extra = make([]dns.RR, len(i.Extra)) + + ttl := uint32(i.ttl(time.Now())) + if ttl < minTTL { + ttl = minTTL + } + + for j, r := range i.Answer { + m1.Answer[j] = dns.Copy(r) + m1.Answer[j].Header().Ttl = ttl + } + for j, r := range i.Ns { + m1.Ns[j] = dns.Copy(r) + m1.Ns[j].Header().Ttl = ttl + } + for j, r := range i.Extra { + m1.Extra[j] = dns.Copy(r) + if m1.Extra[j].Header().Rrtype != dns.TypeOPT { + m1.Extra[j].Header().Ttl = ttl + } + } + return m1 +} + +func (i *item) ttl(now time.Time) int { + ttl := int(i.origTTL) - int(now.UTC().Sub(i.stored).Seconds()) + return ttl +} + +func minMsgTTL(m *dns.Msg, mt response.Type) time.Duration { + if mt != response.NoError && mt != response.NameError && mt != response.NoData { + return 0 + } + + minTTL := maxTTL + for _, r := range append(m.Answer, m.Ns...) { + switch mt { + case response.NameError, response.NoData: + if r.Header().Rrtype == dns.TypeSOA { + return time.Duration(r.(*dns.SOA).Minttl) * time.Second + } + case response.NoError, response.Delegation: + if r.Header().Ttl < uint32(minTTL.Seconds()) { + minTTL = time.Duration(r.Header().Ttl) * time.Second + } + } + } + return minTTL +} diff --git a/plugin/cache/prefech_test.go b/plugin/cache/prefech_test.go new file mode 100644 index 000000000..0e9d84da2 --- /dev/null +++ b/plugin/cache/prefech_test.go @@ -0,0 +1,54 @@ +package cache + +import ( + "fmt" + "testing" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + + "github.com/coredns/coredns/plugin/test" + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var p = false + +func TestPrefetch(t *testing.T) { + c := &Cache{Zones: []string{"."}, pcap: defaultCap, ncap: defaultCap, pttl: maxTTL, nttl: maxTTL} + c.pcache = cache.New(c.pcap) + c.ncache = cache.New(c.ncap) + c.prefetch = 1 + c.duration = 1 * time.Second + c.Next = PrefetchHandler(t, dns.RcodeSuccess, nil) + + ctx := context.TODO() + + req := new(dns.Msg) + req.SetQuestion("lowttl.example.org.", dns.TypeA) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + + c.ServeDNS(ctx, rec, req) + p = true // prefetch should be true for the 2nd fetch + c.ServeDNS(ctx, rec, req) +} + +func PrefetchHandler(t *testing.T, rcode int, err error) plugin.Handler { + return plugin.HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + m := new(dns.Msg) + m.SetQuestion("lowttl.example.org.", dns.TypeA) + m.Response = true + m.RecursionAvailable = true + m.Answer = append(m.Answer, test.A("lowttl.example.org. 80 IN A 127.0.0.53")) + if p != w.(*ResponseWriter).prefetch { + err = fmt.Errorf("cache prefetch not equal to p: got %t, want %t", p, w.(*ResponseWriter).prefetch) + t.Fatal(err) + } + + w.WriteMsg(m) + return rcode, err + }) +} diff --git a/plugin/cache/setup.go b/plugin/cache/setup.go new file mode 100644 index 000000000..d8ef9a8d7 --- /dev/null +++ b/plugin/cache/setup.go @@ -0,0 +1,170 @@ +package cache + +import ( + "fmt" + "strconv" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/cache" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("cache", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + ca, err := cacheParse(c) + if err != nil { + return plugin.Error("cache", err) + } + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + ca.Next = next + return ca + }) + + // Export the capacity for the metrics. This only happens once, because this is a re-load change only. + cacheCapacity.WithLabelValues(Success).Set(float64(ca.pcap)) + cacheCapacity.WithLabelValues(Denial).Set(float64(ca.ncap)) + + return nil +} + +func cacheParse(c *caddy.Controller) (*Cache, error) { + + ca := &Cache{pcap: defaultCap, ncap: defaultCap, pttl: maxTTL, nttl: maxNTTL, prefetch: 0, duration: 1 * time.Minute} + + for c.Next() { + // cache [ttl] [zones..] + origins := make([]string, len(c.ServerBlockKeys)) + copy(origins, c.ServerBlockKeys) + args := c.RemainingArgs() + + if len(args) > 0 { + // first args may be just a number, then it is the ttl, if not it is a zone + ttl, err := strconv.Atoi(args[0]) + if err == nil { + // Reserve 0 (and smaller for future things) + if ttl <= 0 { + return nil, fmt.Errorf("cache TTL can not be zero or negative: %d", ttl) + } + ca.pttl = time.Duration(ttl) * time.Second + ca.nttl = time.Duration(ttl) * time.Second + args = args[1:] + } + if len(args) > 0 { + copy(origins, args) + } + } + + // Refinements? In an extra block. + for c.NextBlock() { + switch c.Val() { + // first number is cap, second is an new ttl + case Success: + args := c.RemainingArgs() + if len(args) == 0 { + return nil, c.ArgErr() + } + pcap, err := strconv.Atoi(args[0]) + if err != nil { + return nil, err + } + ca.pcap = pcap + if len(args) > 1 { + pttl, err := strconv.Atoi(args[1]) + if err != nil { + return nil, err + } + // Reserve 0 (and smaller for future things) + if pttl <= 0 { + return nil, fmt.Errorf("cache TTL can not be zero or negative: %d", pttl) + } + ca.pttl = time.Duration(pttl) * time.Second + } + case Denial: + args := c.RemainingArgs() + if len(args) == 0 { + return nil, c.ArgErr() + } + ncap, err := strconv.Atoi(args[0]) + if err != nil { + return nil, err + } + ca.ncap = ncap + if len(args) > 1 { + nttl, err := strconv.Atoi(args[1]) + if err != nil { + return nil, err + } + // Reserve 0 (and smaller for future things) + if nttl <= 0 { + return nil, fmt.Errorf("cache TTL can not be zero or negative: %d", nttl) + } + ca.nttl = time.Duration(nttl) * time.Second + } + case "prefetch": + args := c.RemainingArgs() + if len(args) == 0 || len(args) > 3 { + return nil, c.ArgErr() + } + amount, err := strconv.Atoi(args[0]) + if err != nil { + return nil, err + } + if amount < 0 { + return nil, fmt.Errorf("prefetch amount should be positive: %d", amount) + } + ca.prefetch = amount + + ca.duration = 1 * time.Minute + ca.percentage = 10 + if len(args) > 1 { + dur, err := time.ParseDuration(args[1]) + if err != nil { + return nil, err + } + ca.duration = dur + } + if len(args) > 2 { + pct := args[2] + if x := pct[len(pct)-1]; x != '%' { + return nil, fmt.Errorf("last character of percentage should be `%%`, but is: %q", x) + } + pct = pct[:len(pct)-1] + + num, err := strconv.Atoi(pct) + if err != nil { + return nil, err + } + if num < 10 || num > 90 { + return nil, fmt.Errorf("percentage should fall in range [10, 90]: %d", num) + } + ca.percentage = num + } + + default: + return nil, c.ArgErr() + } + } + + for i := range origins { + origins[i] = plugin.Host(origins[i]).Normalize() + } + + ca.Zones = origins + + ca.pcache = cache.New(ca.pcap) + ca.ncache = cache.New(ca.ncap) + + return ca, nil + } + + return nil, nil +} diff --git a/plugin/cache/setup_test.go b/plugin/cache/setup_test.go new file mode 100644 index 000000000..afc2ecc13 --- /dev/null +++ b/plugin/cache/setup_test.go @@ -0,0 +1,94 @@ +package cache + +import ( + "testing" + "time" + + "github.com/mholt/caddy" +) + +func TestSetup(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedNcap int + expectedPcap int + expectedNttl time.Duration + expectedPttl time.Duration + expectedPrefetch int + }{ + {`cache`, false, defaultCap, defaultCap, maxNTTL, maxTTL, 0}, + {`cache {}`, false, defaultCap, defaultCap, maxNTTL, maxTTL, 0}, + {`cache example.nl { + success 10 + }`, false, defaultCap, 10, maxNTTL, maxTTL, 0}, + {`cache example.nl { + success 10 + denial 10 15 + }`, false, 10, 10, 15 * time.Second, maxTTL, 0}, + {`cache 25 example.nl { + success 10 + denial 10 15 + }`, false, 10, 10, 15 * time.Second, 25 * time.Second, 0}, + {`cache aaa example.nl`, false, defaultCap, defaultCap, maxNTTL, maxTTL, 0}, + {`cache { + prefetch 10 + }`, false, defaultCap, defaultCap, maxNTTL, maxTTL, 10}, + + // fails + {`cache example.nl { + success + denial 10 15 + }`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + {`cache example.nl { + success 15 + denial aaa + }`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + {`cache example.nl { + positive 15 + negative aaa + }`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + {`cache 0 example.nl`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + {`cache -1 example.nl`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + {`cache 1 example.nl { + positive 0 + }`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + {`cache 1 example.nl { + positive 0 + prefetch -1 + }`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + {`cache 1 example.nl { + prefetch 0 blurp + }`, true, defaultCap, defaultCap, maxTTL, maxTTL, 0}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + ca, err := cacheParse(c) + if test.shouldErr && err == nil { + t.Errorf("Test %v: Expected error but found nil", i) + continue + } else if !test.shouldErr && err != nil { + t.Errorf("Test %v: Expected no error but found error: %v", i, err) + continue + } + if test.shouldErr && err != nil { + continue + } + + if ca.ncap != test.expectedNcap { + t.Errorf("Test %v: Expected ncap %v but found: %v", i, test.expectedNcap, ca.ncap) + } + if ca.pcap != test.expectedPcap { + t.Errorf("Test %v: Expected pcap %v but found: %v", i, test.expectedPcap, ca.pcap) + } + if ca.nttl != test.expectedNttl { + t.Errorf("Test %v: Expected nttl %v but found: %v", i, test.expectedNttl, ca.nttl) + } + if ca.pttl != test.expectedPttl { + t.Errorf("Test %v: Expected pttl %v but found: %v", i, test.expectedPttl, ca.pttl) + } + if ca.prefetch != test.expectedPrefetch { + t.Errorf("Test %v: Expected prefetch %v but found: %v", i, test.expectedPrefetch, ca.prefetch) + } + } +} diff --git a/plugin/chaos/README.md b/plugin/chaos/README.md new file mode 100644 index 000000000..4c43590e5 --- /dev/null +++ b/plugin/chaos/README.md @@ -0,0 +1,46 @@ +# chaos + +The *chaos* plugin allows CoreDNS to respond to TXT queries in the CH class. + +This is useful for retrieving version or author information from the server. + +## Syntax + +~~~ +chaos [VERSION] [AUTHORS...] +~~~ + +* **VERSION** is the version to return. Defaults to `CoreDNS-<version>`, if not set. +* **AUTHORS** is what authors to return. No default. + +Note that you have to make sure that this plugin will get actual queries for the +following zones: `version.bind`, `version.server`, `authors.bind`, `hostname.bind` and +`id.server`. + +## Examples + +Specify all the zones in full. + +~~~ corefile +version.bind version.server authors.bind hostname.bind id.server { + chaos CoreDNS-001 info@coredns.io +} +~~~ + +Or just default to `.`: + +~~~ corefile +. { + chaos CoreDNS-001 info@coredns.io +} +~~~ + +And test with `dig`: + +~~~ txt +% dig @localhost CH TXT version.bind +... +;; ANSWER SECTION: +version.bind. 0 CH TXT "CoreDNS-001" +... +~~~ diff --git a/plugin/chaos/chaos.go b/plugin/chaos/chaos.go new file mode 100644 index 000000000..c9811fbd0 --- /dev/null +++ b/plugin/chaos/chaos.go @@ -0,0 +1,62 @@ +// Package chaos implements a plugin that answer to 'CH version.bind TXT' type queries. +package chaos + +import ( + "os" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Chaos allows CoreDNS to reply to CH TXT queries and return author or +// version information. +type Chaos struct { + Next plugin.Handler + Version string + Authors map[string]bool +} + +// ServeDNS implements the plugin.Handler interface. +func (c Chaos) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + if state.QClass() != dns.ClassCHAOS || state.QType() != dns.TypeTXT { + return plugin.NextOrFailure(c.Name(), c.Next, ctx, w, r) + } + + m := new(dns.Msg) + m.SetReply(r) + + hdr := dns.RR_Header{Name: state.QName(), Rrtype: dns.TypeTXT, Class: dns.ClassCHAOS, Ttl: 0} + switch state.Name() { + default: + return c.Next.ServeDNS(ctx, w, r) + case "authors.bind.": + for a := range c.Authors { + m.Answer = append(m.Answer, &dns.TXT{Hdr: hdr, Txt: []string{trim(a)}}) + } + case "version.bind.", "version.server.": + m.Answer = []dns.RR{&dns.TXT{Hdr: hdr, Txt: []string{trim(c.Version)}}} + case "hostname.bind.", "id.server.": + hostname, err := os.Hostname() + if err != nil { + hostname = "localhost" + } + m.Answer = []dns.RR{&dns.TXT{Hdr: hdr, Txt: []string{trim(hostname)}}} + } + state.SizeAndDo(m) + w.WriteMsg(m) + return 0, nil +} + +// Name implements the Handler interface. +func (c Chaos) Name() string { return "chaos" } + +func trim(s string) string { + if len(s) < 256 { + return s + } + return s[:255] +} diff --git a/plugin/chaos/chaos_test.go b/plugin/chaos/chaos_test.go new file mode 100644 index 000000000..332d90381 --- /dev/null +++ b/plugin/chaos/chaos_test.go @@ -0,0 +1,80 @@ +package chaos + +import ( + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestChaos(t *testing.T) { + em := Chaos{ + Version: version, + Authors: map[string]bool{"Miek Gieben": true}, + } + + tests := []struct { + next plugin.Handler + qname string + qtype uint16 + expectedCode int + expectedReply string + expectedErr error + }{ + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "version.bind", + expectedCode: dns.RcodeSuccess, + expectedReply: version, + expectedErr: nil, + }, + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "authors.bind", + expectedCode: dns.RcodeSuccess, + expectedReply: "Miek Gieben", + expectedErr: nil, + }, + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "authors.bind", + qtype: dns.TypeSRV, + expectedCode: dns.RcodeSuccess, + expectedErr: nil, + }, + } + + ctx := context.TODO() + + for i, tc := range tests { + req := new(dns.Msg) + if tc.qtype == 0 { + tc.qtype = dns.TypeTXT + } + req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype) + req.Question[0].Qclass = dns.ClassCHAOS + em.Next = tc.next + + rec := dnsrecorder.New(&test.ResponseWriter{}) + code, err := em.ServeDNS(ctx, rec, req) + + if err != tc.expectedErr { + t.Errorf("Test %d: Expected error %v, but got %v", i, tc.expectedErr, err) + } + if code != int(tc.expectedCode) { + t.Errorf("Test %d: Expected status code %d, but got %d", i, tc.expectedCode, code) + } + if tc.expectedReply != "" { + answer := rec.Msg.Answer[0].(*dns.TXT).Txt[0] + if answer != tc.expectedReply { + t.Errorf("Test %d: Expected answer %s, but got %s", i, tc.expectedReply, answer) + } + } + } +} + +const version = "CoreDNS-001" diff --git a/plugin/chaos/setup.go b/plugin/chaos/setup.go new file mode 100644 index 000000000..2064f4eae --- /dev/null +++ b/plugin/chaos/setup.go @@ -0,0 +1,55 @@ +package chaos + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("chaos", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) + +} + +func setup(c *caddy.Controller) error { + version, authors, err := chaosParse(c) + if err != nil { + return plugin.Error("chaos", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return Chaos{Next: next, Version: version, Authors: authors} + }) + + return nil +} + +func chaosParse(c *caddy.Controller) (string, map[string]bool, error) { + // Set here so we pick up AppName and AppVersion that get set in coremain's init(). + chaosVersion = caddy.AppName + "-" + caddy.AppVersion + + version := "" + authors := make(map[string]bool) + + for c.Next() { + args := c.RemainingArgs() + if len(args) == 0 { + return chaosVersion, nil, nil + } + if len(args) == 1 { + return args[0], nil, nil + } + version = args[0] + for _, a := range args[1:] { + authors[a] = true + } + return version, authors, nil + } + return version, authors, nil +} + +var chaosVersion string diff --git a/plugin/chaos/setup_test.go b/plugin/chaos/setup_test.go new file mode 100644 index 000000000..6f3c13fb3 --- /dev/null +++ b/plugin/chaos/setup_test.go @@ -0,0 +1,54 @@ +package chaos + +import ( + "strings" + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupChaos(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedVersion string // expected version. + expectedAuthor string // expected author (string, although we get a map). + expectedErrContent string // substring from the expected error. Empty for positive cases. + }{ + // positive + { + `chaos v2`, false, "v2", "", "", + }, + { + `chaos v3 "Miek Gieben"`, false, "v3", "Miek Gieben", "", + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + version, authors, err := chaosParse(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + } + } + + if !test.shouldErr && version != test.expectedVersion { + t.Errorf("Chaos not correctly set for input %s. Expected: %s, actual: %s", test.input, test.expectedVersion, version) + } + if !test.shouldErr && authors != nil { + if _, ok := authors[test.expectedAuthor]; !ok { + t.Errorf("Chaos not correctly set for input %s. Expected: '%s', actual: '%s'", test.input, test.expectedAuthor, "Miek Gieben") + } + } + } +} diff --git a/plugin/debug/README.md b/plugin/debug/README.md new file mode 100644 index 000000000..2598a1900 --- /dev/null +++ b/plugin/debug/README.md @@ -0,0 +1,20 @@ +# debug + +*debug* disables the automatic recovery upon a CoreDNS crash so that you'll get a nice stack trace. + +Note that the *errors* plugin (if loaded) will also set a `recover` negating this setting. +The main use of *debug* is to help testing. + +## Syntax + +~~~ txt +debug +~~~ + +## Examples + +Disable CoreDNS' ability to recover from crashes: + +~~~ txt +debug +~~~ diff --git a/plugin/debug/debug.go b/plugin/debug/debug.go new file mode 100644 index 000000000..d69ce0e55 --- /dev/null +++ b/plugin/debug/debug.go @@ -0,0 +1,28 @@ +package debug + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("debug", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + config := dnsserver.GetConfig(c) + + for c.Next() { + if c.NextArg() { + return plugin.Error("debug", c.ArgErr()) + } + config.Debug = true + } + + return nil +} diff --git a/plugin/debug/debug_test.go b/plugin/debug/debug_test.go new file mode 100644 index 000000000..a4802fee5 --- /dev/null +++ b/plugin/debug/debug_test.go @@ -0,0 +1,49 @@ +package debug + +import ( + "io/ioutil" + "log" + "testing" + + "github.com/coredns/coredns/core/dnsserver" + + "github.com/mholt/caddy" +) + +func TestDebug(t *testing.T) { + log.SetOutput(ioutil.Discard) + + tests := []struct { + input string + shouldErr bool + expectedDebug bool + }{ + // positive + { + `debug`, false, true, + }, + // negative + { + `debug off`, true, false, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + err := setup(c) + cfg := dnsserver.GetConfig(c) + + if test.shouldErr && err == nil { + t.Fatalf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Fatalf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + } + if cfg.Debug != test.expectedDebug { + t.Fatalf("Test %d: Expected debug to be: %t, but got: %t, input: %s", i, test.expectedDebug, cfg.Debug, test.input) + } + } +} diff --git a/plugin/dnssec/README.md b/plugin/dnssec/README.md new file mode 100644 index 000000000..e087f6c9a --- /dev/null +++ b/plugin/dnssec/README.md @@ -0,0 +1,88 @@ +# dnssec + +*dnssec* enables on-the-fly DNSSEC signing of served data. + +## Syntax + +~~~ +dnssec [ZONES... ] { + key file KEY... + cache_capacity CAPACITY +} +~~~ + +The specified key is used for all signing operations. The DNSSEC signing will treat this key a +CSK (common signing key), forgoing the ZSK/KSK split. All signing operations are done online. +Authenticated denial of existence is implemented with NSEC black lies. Using ECDSA as an algorithm +is preferred as this leads to smaller signatures (compared to RSA). NSEC3 is *not* supported. + +If multiple *dnssec* plugins are specified in the same zone, the last one specified will be +used ( see [bugs](#bugs) ). + +* `ZONES` zones that should be signed. If empty, the zones from the configuration block + are used. + +* `key file` indicates that key file(s) should be read from disk. When multiple keys are specified, RRsets + will be signed with all keys. Generating a key can be done with `dnssec-keygen`: `dnssec-keygen -a + ECDSAP256SHA256 <zonename>`. A key created for zone *A* can be safely used for zone *B*. The name of the + key file can be specified as one of the following formats + + * basename of the generated key `Kexample.org+013+45330` + + * generated public key `Kexample.org+013+45330.key` + + * generated private key `Kexample.org+013+45330.private` + +* `cache_capacity` indicates the capacity of the cache. The dnssec plugin uses a cache to store + RRSIGs. The default capacity is 10000. + +## Metrics + +If monitoring is enabled (via the *prometheus* directive) then the following metrics are exported: + +* coredns_dnssec_cache_size{type} - total elements in the cache, type is "signature". +* coredns_dnssec_cache_capacity{type} - total capacity of the cache, type is "signature". +* coredns_dnssec_cache_hits_total - Counter of cache hits. +* coredns_dnssec_cache_misses_total - Counter of cache misses. + +## Examples + +Sign responses for `example.org` with the key "Kexample.org.+013+45330.key". + +~~~ +example.org:53 { + dnssec { + key file /etc/coredns/Kexample.org.+013+45330 + } + whoami +} +~~~ + +Sign responses for a kubernetes zone with the key "Kcluster.local+013+45129.key". + +~~~ +cluster.local:53 { + kubernetes cluster.local + dnssec cluster.local { + key file /etc/coredns/Kcluster.local+013+45129 + } +} +~~~ + +## Bugs + +Multiple *dnssec* plugins inside one server stanza will silently overwrite earlier ones, here +`example.local` will overwrite the one for `cluster.local`. + +~~~ +.:53 { + kubernetes cluster.local + dnssec cluster.local { + key file /etc/coredns/cluster.local + } + dnssec example.local { + key file /etc/coredns/example.local + } + whoami +} +~~~ diff --git a/plugin/dnssec/black_lies.go b/plugin/dnssec/black_lies.go new file mode 100644 index 000000000..527b2fc3e --- /dev/null +++ b/plugin/dnssec/black_lies.go @@ -0,0 +1,24 @@ +package dnssec + +import "github.com/miekg/dns" + +// nsec returns an NSEC useful for NXDOMAIN respsones. +// See https://tools.ietf.org/html/draft-valsorda-dnsop-black-lies-00 +// For example, a request for the non-existing name a.example.com would +// cause the following NSEC record to be generated: +// a.example.com. 3600 IN NSEC \000.a.example.com. ( RRSIG NSEC ) +// This inturn makes every NXDOMAIN answer a NODATA one, don't forget to flip +// the header rcode to NOERROR. +func (d Dnssec) nsec(name, zone string, ttl, incep, expir uint32) ([]dns.RR, error) { + nsec := &dns.NSEC{} + nsec.Hdr = dns.RR_Header{Name: name, Ttl: ttl, Class: dns.ClassINET, Rrtype: dns.TypeNSEC} + nsec.NextDomain = "\\000." + name + nsec.TypeBitMap = []uint16{dns.TypeRRSIG, dns.TypeNSEC} + + sigs, err := d.sign([]dns.RR{nsec}, zone, ttl, incep, expir) + if err != nil { + return nil, err + } + + return append(sigs, nsec), nil +} diff --git a/plugin/dnssec/black_lies_test.go b/plugin/dnssec/black_lies_test.go new file mode 100644 index 000000000..80c2ce484 --- /dev/null +++ b/plugin/dnssec/black_lies_test.go @@ -0,0 +1,49 @@ +package dnssec + +import ( + "testing" + "time" + + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +func TestZoneSigningBlackLies(t *testing.T) { + d, rm1, rm2 := newDnssec(t, []string{"miek.nl."}) + defer rm1() + defer rm2() + + m := testNxdomainMsg() + state := request.Request{Req: m} + m = d.Sign(state, "miek.nl.", time.Now().UTC()) + if !section(m.Ns, 2) { + t.Errorf("authority section should have 2 sig") + } + var nsec *dns.NSEC + for _, r := range m.Ns { + if r.Header().Rrtype == dns.TypeNSEC { + nsec = r.(*dns.NSEC) + } + } + if m.Rcode != dns.RcodeSuccess { + t.Errorf("expected rcode %d, got %d", dns.RcodeSuccess, m.Rcode) + } + if nsec == nil { + t.Fatalf("expected NSEC, got none") + } + if nsec.Hdr.Name != "ww.miek.nl." { + t.Errorf("expected %s, got %s", "ww.miek.nl.", nsec.Hdr.Name) + } + if nsec.NextDomain != "\\000.ww.miek.nl." { + t.Errorf("expected %s, got %s", "\\000.ww.miek.nl.", nsec.NextDomain) + } +} + +func testNxdomainMsg() *dns.Msg { + return &dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeNameError}, + Question: []dns.Question{{Name: "ww.miek.nl.", Qclass: dns.ClassINET, Qtype: dns.TypeTXT}}, + Ns: []dns.RR{test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1461471181 14400 3600 604800 14400")}, + } +} diff --git a/plugin/dnssec/cache.go b/plugin/dnssec/cache.go new file mode 100644 index 000000000..ea95b73b4 --- /dev/null +++ b/plugin/dnssec/cache.go @@ -0,0 +1,22 @@ +package dnssec + +import ( + "hash/fnv" + + "github.com/miekg/dns" +) + +// hash serializes the RRset and return a signature cache key. +func hash(rrs []dns.RR) uint32 { + h := fnv.New32() + buf := make([]byte, 256) + for _, r := range rrs { + off, err := dns.PackRR(r, buf, 0, nil, false) + if err == nil { + h.Write(buf[:off]) + } + } + + i := h.Sum32() + return i +} diff --git a/plugin/dnssec/cache_test.go b/plugin/dnssec/cache_test.go new file mode 100644 index 000000000..b978df244 --- /dev/null +++ b/plugin/dnssec/cache_test.go @@ -0,0 +1,34 @@ +package dnssec + +import ( + "testing" + "time" + + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" +) + +func TestCacheSet(t *testing.T) { + fPriv, rmPriv, _ := test.TempFile(".", privKey) + fPub, rmPub, _ := test.TempFile(".", pubKey) + defer rmPriv() + defer rmPub() + + dnskey, err := ParseKeyFile(fPub, fPriv) + if err != nil { + t.Fatalf("failed to parse key: %v\n", err) + } + + c := cache.New(defaultCap) + m := testMsg() + state := request.Request{Req: m} + k := hash(m.Answer) // calculate *before* we add the sig + d := New([]string{"miek.nl."}, []*DNSKEY{dnskey}, nil, c) + d.Sign(state, "miek.nl.", time.Now().UTC()) + + _, ok := d.get(k) + if !ok { + t.Errorf("signature was not added to the cache") + } +} diff --git a/plugin/dnssec/dnskey.go b/plugin/dnssec/dnskey.go new file mode 100644 index 000000000..ce787ab54 --- /dev/null +++ b/plugin/dnssec/dnskey.go @@ -0,0 +1,72 @@ +package dnssec + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "errors" + "os" + "time" + + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// DNSKEY holds a DNSSEC public and private key used for on-the-fly signing. +type DNSKEY struct { + K *dns.DNSKEY + s crypto.Signer + keytag uint16 +} + +// ParseKeyFile read a DNSSEC keyfile as generated by dnssec-keygen or other +// utilities. It adds ".key" for the public key and ".private" for the private key. +func ParseKeyFile(pubFile, privFile string) (*DNSKEY, error) { + f, e := os.Open(pubFile) + if e != nil { + return nil, e + } + k, e := dns.ReadRR(f, pubFile) + if e != nil { + return nil, e + } + + f, e = os.Open(privFile) + if e != nil { + return nil, e + } + p, e := k.(*dns.DNSKEY).ReadPrivateKey(f, privFile) + if e != nil { + return nil, e + } + + if v, ok := p.(*rsa.PrivateKey); ok { + return &DNSKEY{k.(*dns.DNSKEY), v, k.(*dns.DNSKEY).KeyTag()}, nil + } + if v, ok := p.(*ecdsa.PrivateKey); ok { + return &DNSKEY{k.(*dns.DNSKEY), v, k.(*dns.DNSKEY).KeyTag()}, nil + } + return &DNSKEY{k.(*dns.DNSKEY), nil, 0}, errors.New("no known? private key found") +} + +// getDNSKEY returns the correct DNSKEY to the client. Signatures are added when do is true. +func (d Dnssec) getDNSKEY(state request.Request, zone string, do bool) *dns.Msg { + keys := make([]dns.RR, len(d.keys)) + for i, k := range d.keys { + keys[i] = dns.Copy(k.K) + keys[i].Header().Name = zone + } + m := new(dns.Msg) + m.SetReply(state.Req) + m.Answer = keys + if !do { + return m + } + + incep, expir := incepExpir(time.Now().UTC()) + if sigs, err := d.sign(keys, zone, 3600, incep, expir); err == nil { + m.Answer = append(m.Answer, sigs...) + } + return m +} diff --git a/plugin/dnssec/dnssec.go b/plugin/dnssec/dnssec.go new file mode 100644 index 000000000..84de05c86 --- /dev/null +++ b/plugin/dnssec/dnssec.go @@ -0,0 +1,135 @@ +// Package dnssec implements a plugin that signs responses on-the-fly using +// NSEC black lies. +package dnssec + +import ( + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/pkg/response" + "github.com/coredns/coredns/plugin/pkg/singleflight" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// Dnssec signs the reply on-the-fly. +type Dnssec struct { + Next plugin.Handler + + zones []string + keys []*DNSKEY + inflight *singleflight.Group + cache *cache.Cache +} + +// New returns a new Dnssec. +func New(zones []string, keys []*DNSKEY, next plugin.Handler, c *cache.Cache) Dnssec { + return Dnssec{Next: next, + zones: zones, + keys: keys, + cache: c, + inflight: new(singleflight.Group), + } +} + +// Sign signs the message in state. it takes care of negative or nodata responses. It +// uses NSEC black lies for authenticated denial of existence. Signatures +// creates will be cached for a short while. By default we sign for 8 days, +// starting 3 hours ago. +func (d Dnssec) Sign(state request.Request, zone string, now time.Time) *dns.Msg { + req := state.Req + + mt, _ := response.Typify(req, time.Now().UTC()) // TODO(miek): need opt record here? + if mt == response.Delegation { + // TODO(miek): uh, signing DS record?!?! + return req + } + + incep, expir := incepExpir(now) + + if mt == response.NameError { + if req.Ns[0].Header().Rrtype != dns.TypeSOA || len(req.Ns) > 1 { + return req + } + + ttl := req.Ns[0].Header().Ttl + + if sigs, err := d.sign(req.Ns, zone, ttl, incep, expir); err == nil { + req.Ns = append(req.Ns, sigs...) + } + if sigs, err := d.nsec(state.Name(), zone, ttl, incep, expir); err == nil { + req.Ns = append(req.Ns, sigs...) + } + if len(req.Ns) > 1 { // actually added nsec and sigs, reset the rcode + req.Rcode = dns.RcodeSuccess + } + return req + } + + for _, r := range rrSets(req.Answer) { + ttl := r[0].Header().Ttl + if sigs, err := d.sign(r, zone, ttl, incep, expir); err == nil { + req.Answer = append(req.Answer, sigs...) + } + } + for _, r := range rrSets(req.Ns) { + ttl := r[0].Header().Ttl + if sigs, err := d.sign(r, zone, ttl, incep, expir); err == nil { + req.Ns = append(req.Ns, sigs...) + } + } + for _, r := range rrSets(req.Extra) { + ttl := r[0].Header().Ttl + if sigs, err := d.sign(r, zone, ttl, incep, expir); err == nil { + req.Extra = append(sigs, req.Extra...) // prepend to leave OPT alone + } + } + return req +} + +func (d Dnssec) sign(rrs []dns.RR, signerName string, ttl, incep, expir uint32) ([]dns.RR, error) { + k := hash(rrs) + sgs, ok := d.get(k) + if ok { + return sgs, nil + } + + sigs, err := d.inflight.Do(k, func() (interface{}, error) { + sigs := make([]dns.RR, len(d.keys)) + var e error + for i, k := range d.keys { + sig := k.newRRSIG(signerName, ttl, incep, expir) + e = sig.Sign(k.s, rrs) + sigs[i] = sig + } + d.set(k, sigs) + return sigs, e + }) + return sigs.([]dns.RR), err +} + +func (d Dnssec) set(key uint32, sigs []dns.RR) { + d.cache.Add(key, sigs) +} + +func (d Dnssec) get(key uint32) ([]dns.RR, bool) { + if s, ok := d.cache.Get(key); ok { + cacheHits.Inc() + return s.([]dns.RR), true + } + cacheMisses.Inc() + return nil, false +} + +func incepExpir(now time.Time) (uint32, uint32) { + incep := uint32(now.Add(-3 * time.Hour).Unix()) // -(2+1) hours, be sure to catch daylight saving time and such + expir := uint32(now.Add(eightDays).Unix()) // sign for 8 days + return incep, expir +} + +const ( + eightDays = 8 * 24 * time.Hour + defaultCap = 10000 // default capacity of the cache. +) diff --git a/plugin/dnssec/dnssec_test.go b/plugin/dnssec/dnssec_test.go new file mode 100644 index 000000000..83ce70beb --- /dev/null +++ b/plugin/dnssec/dnssec_test.go @@ -0,0 +1,219 @@ +package dnssec + +import ( + "testing" + "time" + + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +func TestZoneSigning(t *testing.T) { + d, rm1, rm2 := newDnssec(t, []string{"miek.nl."}) + defer rm1() + defer rm2() + + m := testMsg() + state := request.Request{Req: m} + + m = d.Sign(state, "miek.nl.", time.Now().UTC()) + if !section(m.Answer, 1) { + t.Errorf("answer section should have 1 sig") + } + if !section(m.Ns, 1) { + t.Errorf("authority section should have 1 sig") + } +} + +func TestZoneSigningDouble(t *testing.T) { + d, rm1, rm2 := newDnssec(t, []string{"miek.nl."}) + defer rm1() + defer rm2() + + fPriv1, rmPriv1, _ := test.TempFile(".", privKey1) + fPub1, rmPub1, _ := test.TempFile(".", pubKey1) + defer rmPriv1() + defer rmPub1() + + key1, err := ParseKeyFile(fPub1, fPriv1) + if err != nil { + t.Fatalf("failed to parse key: %v\n", err) + } + d.keys = append(d.keys, key1) + + m := testMsg() + state := request.Request{Req: m} + m = d.Sign(state, "miek.nl.", time.Now().UTC()) + if !section(m.Answer, 2) { + t.Errorf("answer section should have 1 sig") + } + if !section(m.Ns, 2) { + t.Errorf("authority section should have 1 sig") + } +} + +// TestSigningDifferentZone tests if a key for miek.nl and be used for example.org. +func TestSigningDifferentZone(t *testing.T) { + fPriv, rmPriv, _ := test.TempFile(".", privKey) + fPub, rmPub, _ := test.TempFile(".", pubKey) + defer rmPriv() + defer rmPub() + + key, err := ParseKeyFile(fPub, fPriv) + if err != nil { + t.Fatalf("failed to parse key: %v\n", err) + } + + m := testMsgEx() + state := request.Request{Req: m} + c := cache.New(defaultCap) + d := New([]string{"example.org."}, []*DNSKEY{key}, nil, c) + m = d.Sign(state, "example.org.", time.Now().UTC()) + if !section(m.Answer, 1) { + t.Errorf("answer section should have 1 sig") + t.Logf("%+v\n", m) + } + if !section(m.Ns, 1) { + t.Errorf("authority section should have 1 sig") + t.Logf("%+v\n", m) + } +} + +func TestSigningCname(t *testing.T) { + d, rm1, rm2 := newDnssec(t, []string{"miek.nl."}) + defer rm1() + defer rm2() + + m := testMsgCname() + state := request.Request{Req: m} + m = d.Sign(state, "miek.nl.", time.Now().UTC()) + if !section(m.Answer, 1) { + t.Errorf("answer section should have 1 sig") + } +} + +func TestZoneSigningDelegation(t *testing.T) { + d, rm1, rm2 := newDnssec(t, []string{"miek.nl."}) + defer rm1() + defer rm2() + + m := testDelegationMsg() + state := request.Request{Req: m} + m = d.Sign(state, "miek.nl.", time.Now().UTC()) + if !section(m.Ns, 0) { + t.Errorf("authority section should have 0 sig") + t.Logf("%v\n", m) + } + if !section(m.Extra, 0) { + t.Errorf("answer section should have 0 sig") + t.Logf("%v\n", m) + } +} + +func TestSigningDname(t *testing.T) { + d, rm1, rm2 := newDnssec(t, []string{"miek.nl."}) + defer rm1() + defer rm2() + + m := testMsgDname() + state := request.Request{Req: m} + // We sign *everything* we see, also the synthesized CNAME. + m = d.Sign(state, "miek.nl.", time.Now().UTC()) + if !section(m.Answer, 3) { + t.Errorf("answer section should have 3 sig") + } +} + +func section(rss []dns.RR, nrSigs int) bool { + i := 0 + for _, r := range rss { + if r.Header().Rrtype == dns.TypeRRSIG { + i++ + } + } + return nrSigs == i +} + +func testMsg() *dns.Msg { + // don't care about the message header + return &dns.Msg{ + Answer: []dns.RR{test.MX("miek.nl. 1703 IN MX 1 aspmx.l.google.com.")}, + Ns: []dns.RR{test.NS("miek.nl. 1703 IN NS omval.tednet.nl.")}, + } +} +func testMsgEx() *dns.Msg { + return &dns.Msg{ + Answer: []dns.RR{test.MX("example.org. 1703 IN MX 1 aspmx.l.google.com.")}, + Ns: []dns.RR{test.NS("example.org. 1703 IN NS omval.tednet.nl.")}, + } +} + +func testMsgCname() *dns.Msg { + return &dns.Msg{ + Answer: []dns.RR{test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl.")}, + } +} + +func testDelegationMsg() *dns.Msg { + return &dns.Msg{ + Ns: []dns.RR{ + test.NS("miek.nl. 3600 IN NS linode.atoom.net."), + test.NS("miek.nl. 3600 IN NS ns-ext.nlnetlabs.nl."), + test.NS("miek.nl. 3600 IN NS omval.tednet.nl."), + }, + Extra: []dns.RR{ + test.A("omval.tednet.nl. 3600 IN A 185.49.141.42"), + test.AAAA("omval.tednet.nl. 3600 IN AAAA 2a04:b900:0:100::42"), + }, + } +} + +func testMsgDname() *dns.Msg { + return &dns.Msg{ + Answer: []dns.RR{ + test.CNAME("a.dname.miek.nl. 1800 IN CNAME a.test.miek.nl."), + test.A("a.test.miek.nl. 1800 IN A 139.162.196.78"), + test.DNAME("dname.miek.nl. 1800 IN DNAME test.miek.nl."), + }, + } +} + +func newDnssec(t *testing.T, zones []string) (Dnssec, func(), func()) { + k, rm1, rm2 := newKey(t) + c := cache.New(defaultCap) + d := New(zones, []*DNSKEY{k}, nil, c) + return d, rm1, rm2 +} + +func newKey(t *testing.T) (*DNSKEY, func(), func()) { + fPriv, rmPriv, _ := test.TempFile(".", privKey) + fPub, rmPub, _ := test.TempFile(".", pubKey) + + key, err := ParseKeyFile(fPub, fPriv) + if err != nil { + t.Fatalf("failed to parse key: %v\n", err) + } + return key, rmPriv, rmPub +} + +const ( + pubKey = `miek.nl. IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4BXPP3gwhetiOUMnGA+x09nqzgF5IY OyjWB7N3rXqQbnOSILhH1hnuyh7mmA==` + privKey = `Private-key-format: v1.3 +Algorithm: 13 (ECDSAP256SHA256) +PrivateKey: /4BZk8AFvyW5hL3cOLSVxIp1RTqHSAEloWUxj86p3gs= +Created: 20160423195532 +Publish: 20160423195532 +Activate: 20160423195532 +` + pubKey1 = `example.org. IN DNSKEY 257 3 13 tVRWNSGpHZbCi7Pr7OmbADVUO3MxJ0Lb8Lk3o/HBHqCxf5K/J50lFqRa 98lkdAIiFOVRy8LyMvjwmxZKwB5MNw==` + privKey1 = `Private-key-format: v1.3 +Algorithm: 13 (ECDSAP256SHA256) +PrivateKey: i8j4OfDGT8CQt24SDwLz2hg9yx4qKOEOh1LvbAuSp1c= +Created: 20160423211746 +Publish: 20160423211746 +Activate: 20160423211746 +` +) diff --git a/plugin/dnssec/handler.go b/plugin/dnssec/handler.go new file mode 100644 index 000000000..6fa2dd042 --- /dev/null +++ b/plugin/dnssec/handler.go @@ -0,0 +1,82 @@ +package dnssec + +import ( + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/net/context" +) + +// ServeDNS implements the plugin.Handler interface. +func (d Dnssec) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + do := state.Do() + qname := state.Name() + qtype := state.QType() + zone := plugin.Zones(d.zones).Matches(qname) + if zone == "" { + return plugin.NextOrFailure(d.Name(), d.Next, ctx, w, r) + } + + // Intercept queries for DNSKEY, but only if one of the zones matches the qname, otherwise we let + // the query through. + if qtype == dns.TypeDNSKEY { + for _, z := range d.zones { + if qname == z { + resp := d.getDNSKEY(state, z, do) + resp.Authoritative = true + state.SizeAndDo(resp) + w.WriteMsg(resp) + return dns.RcodeSuccess, nil + } + } + } + + drr := &ResponseWriter{w, d} + return plugin.NextOrFailure(d.Name(), d.Next, ctx, drr, r) +} + +var ( + cacheSize = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "cache_size", + Help: "The number of elements in the dnssec cache.", + }, []string{"type"}) + + cacheCapacity = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "cache_capacity", + Help: "The dnssec cache's capacity.", + }, []string{"type"}) + + cacheHits = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "cache_hits_total", + Help: "The count of cache hits.", + }) + + cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "cache_misses_total", + Help: "The count of cache misses.", + }) +) + +// Name implements the Handler interface. +func (d Dnssec) Name() string { return "dnssec" } + +const subsystem = "dnssec" + +func init() { + prometheus.MustRegister(cacheSize) + prometheus.MustRegister(cacheCapacity) + prometheus.MustRegister(cacheHits) + prometheus.MustRegister(cacheMisses) +} diff --git a/plugin/dnssec/handler_test.go b/plugin/dnssec/handler_test.go new file mode 100644 index 000000000..2202a9ffe --- /dev/null +++ b/plugin/dnssec/handler_test.go @@ -0,0 +1,155 @@ +package dnssec + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var dnssecTestCases = []test.Case{ + { + Qname: "miek.nl.", Qtype: dns.TypeDNSKEY, + Answer: []dns.RR{ + test.DNSKEY("miek.nl. 3600 IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4"), + }, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeDNSKEY, Do: true, + Answer: []dns.RR{ + test.DNSKEY("miek.nl. 3600 IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4"), + test.RRSIG("miek.nl. 3600 IN RRSIG DNSKEY 13 2 3600 20160503150844 20160425120844 18512 miek.nl. Iw/kNOyM"), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, +} + +var dnsTestCases = []test.Case{ + { + Qname: "miek.nl.", Qtype: dns.TypeDNSKEY, + Answer: []dns.RR{ + test.DNSKEY("miek.nl. 3600 IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4"), + }, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."), + }, + Ns: []dns.RR{ + test.NS("miek.nl. 1800 IN NS linode.atoom.net."), + }, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeMX, Do: true, + Answer: []dns.RR{ + test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."), + test.RRSIG("miek.nl. 1800 IN RRSIG MX 13 2 3600 20160503192428 20160425162428 18512 miek.nl. 4nxuGKitXjPVA9zP1JIUvA09"), + }, + Ns: []dns.RR{ + test.NS("miek.nl. 1800 IN NS linode.atoom.net."), + test.RRSIG("miek.nl. 1800 IN RRSIG NS 13 2 3600 20161217114912 20161209084912 18512 miek.nl. ad9gA8VWgF1H8ze9/0Rk2Q=="), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "www.miek.nl.", Qtype: dns.TypeAAAA, Do: true, + Answer: []dns.RR{ + test.AAAA("a.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + test.RRSIG("a.miek.nl. 1800 IN RRSIG AAAA 13 3 3600 20160503193047 20160425163047 18512 miek.nl. UAyMG+gcnoXW3"), + test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl."), + test.RRSIG("www.miek.nl. 1800 IN RRSIG CNAME 13 3 3600 20160503193047 20160425163047 18512 miek.nl. E3qGZn"), + }, + Ns: []dns.RR{ + test.NS("miek.nl. 1800 IN NS linode.atoom.net."), + test.RRSIG("miek.nl. 1800 IN RRSIG NS 13 2 3600 20161217114912 20161209084912 18512 miek.nl. ad9gA8VWgF1H8ze9/0Rk2Q=="), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "www.example.org.", Qtype: dns.TypeAAAA, Do: true, + Rcode: dns.RcodeServerFailure, + // Extra: []dns.RR{test.OPT(4096, true)}, // test.ErrorHandler is a simple handler that does not do EDNS. + }, +} + +func TestLookupZone(t *testing.T) { + zone, err := file.Parse(strings.NewReader(dbMiekNL), "miek.nl.", "stdin", 0) + if err != nil { + return + } + fm := file.File{Next: test.ErrorHandler(), Zones: file.Zones{Z: map[string]*file.Zone{"miek.nl.": zone}, Names: []string{"miek.nl."}}} + dnskey, rm1, rm2 := newKey(t) + defer rm1() + defer rm2() + c := cache.New(defaultCap) + dh := New([]string{"miek.nl."}, []*DNSKEY{dnskey}, fm, c) + ctx := context.TODO() + + for _, tc := range dnsTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := dh.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +func TestLookupDNSKEY(t *testing.T) { + dnskey, rm1, rm2 := newKey(t) + defer rm1() + defer rm2() + c := cache.New(defaultCap) + dh := New([]string{"miek.nl."}, []*DNSKEY{dnskey}, test.ErrorHandler(), c) + ctx := context.TODO() + + for _, tc := range dnssecTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := dh.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + return + } + + resp := rec.Msg + if !resp.Authoritative { + t.Errorf("Authoritative Answer should be true, got false") + } + + test.SortAndCheck(t, resp, tc) + } +} + +const dbMiekNL = ` +$TTL 30M +$ORIGIN miek.nl. +@ IN SOA linode.atoom.net. miek.miek.nl. ( + 1282630057 ; Serial + 4H ; Refresh + 1H ; Retry + 7D ; Expire + 4H ) ; Negative Cache TTL + IN NS linode.atoom.net. + + IN MX 1 aspmx.l.google.com. + + IN A 139.162.196.78 + IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 + +a IN A 139.162.196.78 + IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 +www IN CNAME a` diff --git a/plugin/dnssec/responsewriter.go b/plugin/dnssec/responsewriter.go new file mode 100644 index 000000000..793cbcdd0 --- /dev/null +++ b/plugin/dnssec/responsewriter.go @@ -0,0 +1,49 @@ +package dnssec + +import ( + "log" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// ResponseWriter sign the response on the fly. +type ResponseWriter struct { + dns.ResponseWriter + d Dnssec +} + +// WriteMsg implements the dns.ResponseWriter interface. +func (d *ResponseWriter) WriteMsg(res *dns.Msg) error { + // By definition we should sign anything that comes back, we should still figure out for + // which zone it should be. + state := request.Request{W: d.ResponseWriter, Req: res} + + qname := state.Name() + zone := plugin.Zones(d.d.zones).Matches(qname) + if zone == "" { + return d.ResponseWriter.WriteMsg(res) + } + + if state.Do() { + res = d.d.Sign(state, zone, time.Now().UTC()) + + cacheSize.WithLabelValues("signature").Set(float64(d.d.cache.Len())) + } + state.SizeAndDo(res) + + return d.ResponseWriter.WriteMsg(res) +} + +// Write implements the dns.ResponseWriter interface. +func (d *ResponseWriter) Write(buf []byte) (int, error) { + log.Printf("[WARNING] Dnssec called with Write: not signing reply") + n, err := d.ResponseWriter.Write(buf) + return n, err +} + +// Hijack implements the dns.ResponseWriter interface. +func (d *ResponseWriter) Hijack() { d.ResponseWriter.Hijack() } diff --git a/plugin/dnssec/rrsig.go b/plugin/dnssec/rrsig.go new file mode 100644 index 000000000..c68413622 --- /dev/null +++ b/plugin/dnssec/rrsig.go @@ -0,0 +1,53 @@ +package dnssec + +import "github.com/miekg/dns" + +// newRRSIG return a new RRSIG, with all fields filled out, except the signed data. +func (k *DNSKEY) newRRSIG(signerName string, ttl, incep, expir uint32) *dns.RRSIG { + sig := new(dns.RRSIG) + + sig.Hdr.Rrtype = dns.TypeRRSIG + sig.Algorithm = k.K.Algorithm + sig.KeyTag = k.keytag + sig.SignerName = signerName + sig.Hdr.Ttl = ttl + sig.OrigTtl = origTTL + + sig.Inception = incep + sig.Expiration = expir + + return sig +} + +type rrset struct { + qname string + qtype uint16 +} + +// rrSets returns rrs as a map of RRsets. It skips RRSIG and OPT records as those don't need to be signed. +func rrSets(rrs []dns.RR) map[rrset][]dns.RR { + m := make(map[rrset][]dns.RR) + + for _, r := range rrs { + if r.Header().Rrtype == dns.TypeRRSIG || r.Header().Rrtype == dns.TypeOPT { + continue + } + + if s, ok := m[rrset{r.Header().Name, r.Header().Rrtype}]; ok { + s = append(s, r) + m[rrset{r.Header().Name, r.Header().Rrtype}] = s + continue + } + + s := make([]dns.RR, 1, 3) + s[0] = r + m[rrset{r.Header().Name, r.Header().Rrtype}] = s + } + + if len(m) > 0 { + return m + } + return nil +} + +const origTTL = 3600 diff --git a/plugin/dnssec/setup.go b/plugin/dnssec/setup.go new file mode 100644 index 000000000..2f5c21d97 --- /dev/null +++ b/plugin/dnssec/setup.go @@ -0,0 +1,128 @@ +package dnssec + +import ( + "fmt" + "strconv" + "strings" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/cache" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("dnssec", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + zones, keys, capacity, err := dnssecParse(c) + if err != nil { + return plugin.Error("dnssec", err) + } + + ca := cache.New(capacity) + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return New(zones, keys, next, ca) + }) + + // Export the capacity for the metrics. This only happens once, because this is a re-load change only. + cacheCapacity.WithLabelValues("signature").Set(float64(capacity)) + + return nil +} + +func dnssecParse(c *caddy.Controller) ([]string, []*DNSKEY, int, error) { + zones := []string{} + + keys := []*DNSKEY{} + + capacity := defaultCap + for c.Next() { + // dnssec [zones...] + zones = make([]string, len(c.ServerBlockKeys)) + copy(zones, c.ServerBlockKeys) + args := c.RemainingArgs() + if len(args) > 0 { + zones = args + } + + for c.NextBlock() { + switch c.Val() { + case "key": + k, e := keyParse(c) + if e != nil { + return nil, nil, 0, e + } + keys = append(keys, k...) + case "cache_capacity": + if !c.NextArg() { + return nil, nil, 0, c.ArgErr() + } + value := c.Val() + cacheCap, err := strconv.Atoi(value) + if err != nil { + return nil, nil, 0, err + } + capacity = cacheCap + } + + } + } + for i := range zones { + zones[i] = plugin.Host(zones[i]).Normalize() + } + + // Check if each keys owner name can actually sign the zones we want them to sign + for _, k := range keys { + kname := plugin.Name(k.K.Header().Name) + ok := false + for i := range zones { + if kname.Matches(zones[i]) { + ok = true + break + } + } + if !ok { + return zones, keys, capacity, fmt.Errorf("key %s (keyid: %d) can not sign any of the zones", string(kname), k.keytag) + } + } + + return zones, keys, capacity, nil +} + +func keyParse(c *caddy.Controller) ([]*DNSKEY, error) { + keys := []*DNSKEY{} + + if !c.NextArg() { + return nil, c.ArgErr() + } + value := c.Val() + if value == "file" { + ks := c.RemainingArgs() + if len(ks) == 0 { + return nil, c.ArgErr() + } + + for _, k := range ks { + base := k + // Kmiek.nl.+013+26205.key, handle .private or without extension: Kmiek.nl.+013+26205 + if strings.HasSuffix(k, ".key") { + base = k[:len(k)-4] + } + if strings.HasSuffix(k, ".private") { + base = k[:len(k)-8] + } + k, err := ParseKeyFile(base+".key", base+".private") + if err != nil { + return nil, err + } + keys = append(keys, k) + } + } + return keys, nil +} diff --git a/plugin/dnssec/setup_test.go b/plugin/dnssec/setup_test.go new file mode 100644 index 000000000..99a71279d --- /dev/null +++ b/plugin/dnssec/setup_test.go @@ -0,0 +1,120 @@ +package dnssec + +import ( + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupDnssec(t *testing.T) { + if err := ioutil.WriteFile("Kcluster.local.key", []byte(keypub), 0644); err != nil { + t.Fatalf("Failed to write pub key file: %s", err) + } + defer func() { os.Remove("Kcluster.local.key") }() + if err := ioutil.WriteFile("Kcluster.local.private", []byte(keypriv), 0644); err != nil { + t.Fatalf("Failed to write private key file: %s", err) + } + defer func() { os.Remove("Kcluster.local.private") }() + + tests := []struct { + input string + shouldErr bool + expectedZones []string + expectedKeys []string + expectedCapacity int + expectedErrContent string + }{ + {`dnssec`, false, nil, nil, defaultCap, ""}, + {`dnssec example.org`, false, []string{"example.org."}, nil, defaultCap, ""}, + {`dnssec 10.0.0.0/8`, false, []string{"10.in-addr.arpa."}, nil, defaultCap, ""}, + { + `dnssec example.org { + cache_capacity 100 + }`, false, []string{"example.org."}, nil, 100, "", + }, + { + `dnssec cluster.local { + key file Kcluster.local + }`, false, []string{"cluster.local."}, nil, defaultCap, "", + }, + { + `dnssec example.org cluster.local { + key file Kcluster.local + }`, false, []string{"example.org.", "cluster.local."}, nil, defaultCap, "", + }, + // fails + { + `dnssec example.org { + key file Kcluster.local + }`, true, []string{"example.org."}, nil, defaultCap, "can not sign any", + }, + { + `dnssec example.org { + key + }`, true, []string{"example.org."}, nil, defaultCap, "argument count", + }, + { + `dnssec example.org { + key file + }`, true, []string{"example.org."}, nil, defaultCap, "argument count", + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + zones, keys, capacity, err := dnssecParse(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + } + } + if !test.shouldErr { + for i, z := range test.expectedZones { + if zones[i] != z { + t.Errorf("Dnssec not correctly set for input %s. Expected: %s, actual: %s", test.input, z, zones[i]) + } + } + for i, k := range test.expectedKeys { + if k != keys[i].K.Header().Name { + t.Errorf("Dnssec not correctly set for input %s. Expected: '%s', actual: '%s'", test.input, k, keys[i].K.Header().Name) + } + } + if capacity != test.expectedCapacity { + t.Errorf("Dnssec not correctly set capacity for input '%s' Expected: '%d', actual: '%d'", test.input, capacity, test.expectedCapacity) + } + } + } +} + +const keypub = `; This is a zone-signing key, keyid 45330, for cluster.local. +; Created: 20170901060531 (Fri Sep 1 08:05:31 2017) +; Publish: 20170901060531 (Fri Sep 1 08:05:31 2017) +; Activate: 20170901060531 (Fri Sep 1 08:05:31 2017) +cluster.local. IN DNSKEY 256 3 5 AwEAAcFpDv+Cb23kFJowu+VU++b2N1uEHi6Ll9H0BzLasFOdJjEEclCO q/KlD4682vOMXxJNN8ZwOyiCa7Y0TEYqSwWvhHyn3bHCwuy4I6fss4Wd 7Y9dU+6QTgJ8LimGG40Iizjc9zqoU8Q+q81vIukpYWOHioHoY7hsWBvS RSlzDJk3` + +const keypriv = `Private-key-format: v1.3 +Algorithm: 5 (RSASHA1) +Modulus: wWkO/4JvbeQUmjC75VT75vY3W4QeLouX0fQHMtqwU50mMQRyUI6r8qUPjrza84xfEk03xnA7KIJrtjRMRipLBa+EfKfdscLC7Lgjp+yzhZ3tj11T7pBOAnwuKYYbjQiLONz3OqhTxD6rzW8i6SlhY4eKgehjuGxYG9JFKXMMmTc= +PublicExponent: AQAB +PrivateExponent: K5XyZFBPrjMVFX5gCZlyPyVDamNGrfSVXSIiMSqpS96BSdCXtmHAjCj4bZFPwkzi6+vs4tJN8p4ZifEVM0a6qwPZyENBrc2qbsweOXE6l8BaPVWFX30xvVRzGXuNtXxlBXE17zoHty5r5mRyRou1bc2HUS5otdkEjE30RiocQVk= +Prime1: 7RRFUxaZkVNVH1DaT/SV5Sb8kABB389qLwU++argeDCVf+Wm9BBlTrsz2U6bKlfpaUmYZKtCCd+CVxqzMyuu0w== +Prime2: 0NiY3d7Fa08IGY9L4TaFc02A721YcDNBBf95BP31qGvwnYsLFM/1xZwaEsIjohg8g+m/GpyIlvNMbK6pywIVjQ== +Exponent1: XjXO8pype9mMmvwrNNix9DTQ6nxfsQugW30PMHGZ78kGr6NX++bEC0xS50jYWjRDGcbYGzD+9iNujSScD3qNZw== +Exponent2: wkoOhLIfhUIj7etikyUup2Ld5WAbW15DSrotstg0NrgcQ+Q7reP96BXeJ79WeREFE09cyvv/EjdLzPv81/CbbQ== +Coefficient: ah4LL0KLTO8kSKHK+X9Ud8grYi94QSNdbX11ge/eFcS/41QhDuZRTAFv4y0+IG+VWd+XzojLsQs+jzLe5GzINg== +Created: 20170901060531 +Publish: 20170901060531 +Activate: 20170901060531 +` diff --git a/plugin/dnstap/README.md b/plugin/dnstap/README.md new file mode 100644 index 000000000..d91c9422c --- /dev/null +++ b/plugin/dnstap/README.md @@ -0,0 +1,61 @@ +# dnstap + +*dnstap* enables logging to dnstap, a flexible, structured binary log format for DNS software: http://dnstap.info. + +There is a buffer, expect at least 13 requests before the server sends its dnstap messages to the socket. + +## Syntax + +~~~ txt +dnstap SOCKET [full] +~~~ + +* **SOCKET** is the socket path supplied to the dnstap command line tool. +* `full` to include the wire-format DNS message. + +## Examples + +Log information about client requests and responses to */tmp/dnstap.sock*. + +~~~ txt +dnstap /tmp/dnstap.sock +~~~ + +Log information including the wire-format DNS message about client requests and responses to */tmp/dnstap.sock*. + +~~~ txt +dnstap unix:///tmp/dnstap.sock full +~~~ + +Log to a remote endpoint. + +~~~ txt +dnstap tcp://127.0.0.1:6000 full +~~~ + +## Dnstap command line tool + +~~~ sh +go get github.com/dnstap/golang-dnstap +cd $GOPATH/src/github.com/dnstap/golang-dnstap/dnstap +go build +./dnstap +~~~ + +The following command listens on the given socket and decodes messages to stdout. + +~~~ sh +dnstap -u /tmp/dnstap.sock +~~~ + +The following command listens on the given socket and saves message payloads to a binary dnstap-format log file. + +~~~ sh +dnstap -u /tmp/dnstap.sock -w /tmp/test.dnstap +~~~ + +Listen for dnstap messages on port 6000. + +~~~ sh +dnstap -l 127.0.0.1:6000 +~~~ diff --git a/plugin/dnstap/handler.go b/plugin/dnstap/handler.go new file mode 100644 index 000000000..b20bb2ad9 --- /dev/null +++ b/plugin/dnstap/handler.go @@ -0,0 +1,79 @@ +package dnstap + +import ( + "fmt" + "io" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/dnstap/msg" + "github.com/coredns/coredns/plugin/dnstap/taprw" + + tap "github.com/dnstap/golang-dnstap" + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Dnstap is the dnstap handler. +type Dnstap struct { + Next plugin.Handler + Out io.Writer + Pack bool +} + +type ( + // Tapper is implemented by the Context passed by the dnstap handler. + Tapper interface { + TapMessage(*tap.Message) error + TapBuilder() msg.Builder + } + tapContext struct { + context.Context + Dnstap + } +) + +// TapperFromContext will return a Tapper if the dnstap plugin is enabled. +func TapperFromContext(ctx context.Context) (t Tapper) { + t, _ = ctx.(Tapper) + return +} + +func tapMessageTo(w io.Writer, m *tap.Message) error { + frame, err := msg.Marshal(m) + if err != nil { + return fmt.Errorf("marshal: %s", err) + } + _, err = w.Write(frame) + return err +} + +// TapMessage implements Tapper. +func (h Dnstap) TapMessage(m *tap.Message) error { + return tapMessageTo(h.Out, m) +} + +// TapBuilder implements Tapper. +func (h Dnstap) TapBuilder() msg.Builder { + return msg.Builder{Full: h.Pack} +} + +// ServeDNS logs the client query and response to dnstap and passes the dnstap Context. +func (h Dnstap) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + rw := &taprw.ResponseWriter{ResponseWriter: w, Tapper: &h, Query: r} + rw.QueryEpoch() + + code, err := plugin.NextOrFailure(h.Name(), h.Next, tapContext{ctx, h}, rw, r) + if err != nil { + // ignore dnstap errors + return code, err + } + + if err := rw.DnstapError(); err != nil { + return code, plugin.Error("dnstap", err) + } + + return code, nil +} + +// Name returns dnstap. +func (h Dnstap) Name() string { return "dnstap" } diff --git a/plugin/dnstap/handler_test.go b/plugin/dnstap/handler_test.go new file mode 100644 index 000000000..54509de82 --- /dev/null +++ b/plugin/dnstap/handler_test.go @@ -0,0 +1,65 @@ +package dnstap + +import ( + "errors" + "fmt" + "testing" + + "github.com/coredns/coredns/plugin/dnstap/test" + mwtest "github.com/coredns/coredns/plugin/test" + + tap "github.com/dnstap/golang-dnstap" + "github.com/golang/protobuf/proto" + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func testCase(t *testing.T, tapq, tapr *tap.Message, q, r *dns.Msg) { + w := writer{} + w.queue = append(w.queue, tapq, tapr) + h := Dnstap{ + Next: mwtest.HandlerFunc(func(_ context.Context, + w dns.ResponseWriter, _ *dns.Msg) (int, error) { + + return 0, w.WriteMsg(r) + }), + Out: &w, + Pack: false, + } + _, err := h.ServeDNS(context.TODO(), &mwtest.ResponseWriter{}, q) + if err != nil { + t.Fatal(err) + } +} + +type writer struct { + queue []*tap.Message +} + +func (w *writer) Write(b []byte) (int, error) { + e := tap.Dnstap{} + if err := proto.Unmarshal(b, &e); err != nil { + return 0, err + } + if len(w.queue) == 0 { + return 0, errors.New("message not expected") + } + if !test.MsgEqual(w.queue[0], e.Message) { + return 0, fmt.Errorf("want: %v, have: %v", w.queue[0], e.Message) + } + w.queue = w.queue[1:] + return len(b), nil +} + +func TestDnstap(t *testing.T) { + q := mwtest.Case{Qname: "example.org", Qtype: dns.TypeA}.Msg() + r := mwtest.Case{ + Qname: "example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + mwtest.A("example.org. 3600 IN A 10.0.0.1"), + }, + }.Msg() + tapq := test.TestingData().ToClientQuery() + tapr := test.TestingData().ToClientResponse() + testCase(t, tapq, tapr, q, r) +} diff --git a/plugin/dnstap/msg/msg.go b/plugin/dnstap/msg/msg.go new file mode 100644 index 000000000..07930929e --- /dev/null +++ b/plugin/dnstap/msg/msg.go @@ -0,0 +1,168 @@ +// Package msg helps to build a dnstap Message. +package msg + +import ( + "errors" + "net" + "strconv" + "time" + + tap "github.com/dnstap/golang-dnstap" + "github.com/miekg/dns" +) + +// Builder helps to build Data by being aware of the dnstap plugin configuration. +type Builder struct { + Full bool + Data +} + +// AddrMsg parses the info of net.Addr and dns.Msg. +func (b *Builder) AddrMsg(a net.Addr, m *dns.Msg) (err error) { + err = b.RemoteAddr(a) + if err != nil { + return + } + return b.Msg(m) +} + +// Msg parses the info of dns.Msg. +func (b *Builder) Msg(m *dns.Msg) (err error) { + if b.Full { + err = b.Pack(m) + } + return +} + +// Data helps to build a dnstap Message. +// It can be transformed into the actual Message using this package. +type Data struct { + Packed []byte + SocketProto tap.SocketProtocol + SocketFam tap.SocketFamily + Address []byte + Port uint32 + TimeSec uint64 +} + +// HostPort decodes into Data any string returned by dnsutil.ParseHostPortOrFile. +func (d *Data) HostPort(addr string) error { + ip, port, err := net.SplitHostPort(addr) + if err != nil { + return err + } + p, err := strconv.ParseUint(port, 10, 32) + if err != nil { + return err + } + d.Port = uint32(p) + + if ip := net.ParseIP(ip); ip != nil { + d.Address = []byte(ip) + if ip := ip.To4(); ip != nil { + d.SocketFam = tap.SocketFamily_INET + } else { + d.SocketFam = tap.SocketFamily_INET6 + } + return nil + } + return errors.New("not an ip address") +} + +// RemoteAddr parses the information about the remote address into Data. +func (d *Data) RemoteAddr(remote net.Addr) error { + switch addr := remote.(type) { + case *net.TCPAddr: + d.Address = addr.IP + d.Port = uint32(addr.Port) + d.SocketProto = tap.SocketProtocol_TCP + case *net.UDPAddr: + d.Address = addr.IP + d.Port = uint32(addr.Port) + d.SocketProto = tap.SocketProtocol_UDP + default: + return errors.New("unknown remote address type") + } + + if a := net.IP(d.Address); a.To4() != nil { + d.SocketFam = tap.SocketFamily_INET + } else { + d.SocketFam = tap.SocketFamily_INET6 + } + + return nil +} + +// Pack encodes the DNS message into Data. +func (d *Data) Pack(m *dns.Msg) error { + packed, err := m.Pack() + if err != nil { + return err + } + d.Packed = packed + return nil +} + +// Epoch returns the epoch time in seconds. +func Epoch() uint64 { + return uint64(time.Now().Unix()) +} + +// Epoch sets the dnstap message epoch. +func (d *Data) Epoch() { + d.TimeSec = Epoch() +} + +// ToClientResponse transforms Data into a client response message. +func (d *Data) ToClientResponse() *tap.Message { + t := tap.Message_CLIENT_RESPONSE + return &tap.Message{ + Type: &t, + SocketFamily: &d.SocketFam, + SocketProtocol: &d.SocketProto, + ResponseTimeSec: &d.TimeSec, + ResponseMessage: d.Packed, + QueryAddress: d.Address, + QueryPort: &d.Port, + } +} + +// ToClientQuery transforms Data into a client query message. +func (d *Data) ToClientQuery() *tap.Message { + t := tap.Message_CLIENT_QUERY + return &tap.Message{ + Type: &t, + SocketFamily: &d.SocketFam, + SocketProtocol: &d.SocketProto, + QueryTimeSec: &d.TimeSec, + QueryMessage: d.Packed, + QueryAddress: d.Address, + QueryPort: &d.Port, + } +} + +// ToOutsideQuery transforms the data into a forwarder or resolver query message. +func (d *Data) ToOutsideQuery(t tap.Message_Type) *tap.Message { + return &tap.Message{ + Type: &t, + SocketFamily: &d.SocketFam, + SocketProtocol: &d.SocketProto, + QueryTimeSec: &d.TimeSec, + QueryMessage: d.Packed, + ResponseAddress: d.Address, + ResponsePort: &d.Port, + } +} + +// ToOutsideResponse transforms the data into a forwarder or resolver response message. +func (d *Data) ToOutsideResponse(t tap.Message_Type) *tap.Message { + return &tap.Message{ + Type: &t, + SocketFamily: &d.SocketFam, + SocketProtocol: &d.SocketProto, + ResponseTimeSec: &d.TimeSec, + ResponseMessage: d.Packed, + ResponseAddress: d.Address, + ResponsePort: &d.Port, + } +} diff --git a/plugin/dnstap/msg/msg_test.go b/plugin/dnstap/msg/msg_test.go new file mode 100644 index 000000000..649659a80 --- /dev/null +++ b/plugin/dnstap/msg/msg_test.go @@ -0,0 +1,42 @@ +package msg + +import ( + "net" + "reflect" + "testing" + + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + tap "github.com/dnstap/golang-dnstap" + "github.com/miekg/dns" +) + +func testRequest(t *testing.T, expected Data, r request.Request) { + d := Data{} + if err := d.RemoteAddr(r.W.RemoteAddr()); err != nil { + t.Fail() + return + } + if d.SocketProto != expected.SocketProto || + d.SocketFam != expected.SocketFam || + !reflect.DeepEqual(d.Address, expected.Address) || + d.Port != expected.Port { + t.Fatalf("expected: %v, have: %v", expected, d) + return + } +} +func TestRequest(t *testing.T) { + testRequest(t, Data{ + SocketProto: tap.SocketProtocol_UDP, + SocketFam: tap.SocketFamily_INET, + Address: net.ParseIP("10.240.0.1"), + Port: 40212, + }, testingRequest()) +} +func testingRequest() request.Request { + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + m.SetEdns0(4097, true) + return request.Request{W: &test.ResponseWriter{}, Req: m} +} diff --git a/plugin/dnstap/msg/wrapper.go b/plugin/dnstap/msg/wrapper.go new file mode 100644 index 000000000..a74c604d8 --- /dev/null +++ b/plugin/dnstap/msg/wrapper.go @@ -0,0 +1,26 @@ +package msg + +import ( + "fmt" + + lib "github.com/dnstap/golang-dnstap" + "github.com/golang/protobuf/proto" +) + +func wrap(m *lib.Message) *lib.Dnstap { + t := lib.Dnstap_MESSAGE + return &lib.Dnstap{ + Type: &t, + Message: m, + } +} + +// Marshal encodes the message to a binary dnstap payload. +func Marshal(m *lib.Message) (data []byte, err error) { + data, err = proto.Marshal(wrap(m)) + if err != nil { + err = fmt.Errorf("proto: %s", err) + return + } + return +} diff --git a/plugin/dnstap/out/socket.go b/plugin/dnstap/out/socket.go new file mode 100644 index 000000000..520dcf1d8 --- /dev/null +++ b/plugin/dnstap/out/socket.go @@ -0,0 +1,86 @@ +package out + +import ( + "fmt" + "net" + + fs "github.com/farsightsec/golang-framestream" +) + +// Socket is a Frame Streams encoder over a UNIX socket. +type Socket struct { + path string + enc *fs.Encoder + conn net.Conn + err error +} + +func openSocket(s *Socket) error { + conn, err := net.Dial("unix", s.path) + if err != nil { + return err + } + s.conn = conn + + enc, err := fs.NewEncoder(conn, &fs.EncoderOptions{ + ContentType: []byte("protobuf:dnstap.Dnstap"), + Bidirectional: true, + }) + if err != nil { + return err + } + s.enc = enc + + s.err = nil + return nil +} + +// NewSocket will always return a new Socket. +// err if nothing is listening to it, it will attempt to reconnect on the next Write. +func NewSocket(path string) (s *Socket, err error) { + s = &Socket{path: path} + if err = openSocket(s); err != nil { + err = fmt.Errorf("open socket: %s", err) + s.err = err + return + } + return +} + +// Write a single Frame Streams frame. +func (s *Socket) Write(frame []byte) (int, error) { + if s.err != nil { + // is the dnstap tool listening? + if err := openSocket(s); err != nil { + return 0, fmt.Errorf("open socket: %s", err) + } + } + n, err := s.enc.Write(frame) + if err != nil { + // the dnstap command line tool is down + s.conn.Close() + s.err = err + return 0, err + } + return n, nil + +} + +// Close the socket and flush the remaining frames. +func (s *Socket) Close() error { + if s.err != nil { + // nothing to close + return nil + } + + defer s.conn.Close() + + if err := s.enc.Flush(); err != nil { + return fmt.Errorf("flush: %s", err) + } + if err := s.enc.Close(); err != nil { + return err + } + + return nil +} diff --git a/plugin/dnstap/out/socket_test.go b/plugin/dnstap/out/socket_test.go new file mode 100644 index 000000000..050a38d36 --- /dev/null +++ b/plugin/dnstap/out/socket_test.go @@ -0,0 +1,94 @@ +package out + +import ( + "net" + "testing" + + fs "github.com/farsightsec/golang-framestream" +) + +func acceptOne(t *testing.T, l net.Listener) { + server, err := l.Accept() + if err != nil { + t.Fatalf("server accept: %s", err) + return + } + + dec, err := fs.NewDecoder(server, &fs.DecoderOptions{ + ContentType: []byte("protobuf:dnstap.Dnstap"), + Bidirectional: true, + }) + if err != nil { + t.Fatalf("server decoder: %s", err) + return + } + + if _, err := dec.Decode(); err != nil { + t.Errorf("server decode: %s", err) + } + + if err := server.Close(); err != nil { + t.Error(err) + } +} +func sendOne(socket *Socket) error { + if _, err := socket.Write([]byte("frame")); err != nil { + return err + } + if err := socket.enc.Flush(); err != nil { + // Would happen during Write in real life. + socket.conn.Close() + socket.err = err + return err + } + return nil +} +func TestSocket(t *testing.T) { + socket, err := NewSocket("dnstap.sock") + if err == nil { + t.Fatal("new socket: not listening but no error") + return + } + + if err := sendOne(socket); err == nil { + t.Fatal("not listening but no error") + return + } + + l, err := net.Listen("unix", "dnstap.sock") + if err != nil { + t.Fatal(err) + return + } + + wait := make(chan bool) + go func() { + acceptOne(t, l) + wait <- true + }() + + if err := sendOne(socket); err != nil { + t.Fatalf("send one: %s", err) + return + } + + <-wait + if err := sendOne(socket); err == nil { + panic("must fail") + } + + go func() { + acceptOne(t, l) + wait <- true + }() + + if err := sendOne(socket); err != nil { + t.Fatalf("send one: %s", err) + return + } + + <-wait + if err := l.Close(); err != nil { + t.Error(err) + } +} diff --git a/plugin/dnstap/out/tcp.go b/plugin/dnstap/out/tcp.go new file mode 100644 index 000000000..8d2c25270 --- /dev/null +++ b/plugin/dnstap/out/tcp.go @@ -0,0 +1,59 @@ +package out + +import ( + "net" + "time" + + fs "github.com/farsightsec/golang-framestream" +) + +// TCP is a Frame Streams encoder over TCP. +type TCP struct { + address string + frames [][]byte +} + +// NewTCP returns a TCP writer. +func NewTCP(address string) *TCP { + s := &TCP{address: address} + s.frames = make([][]byte, 0, 13) // 13 messages buffer + return s +} + +// Write a single Frame Streams frame. +func (s *TCP) Write(frame []byte) (n int, err error) { + s.frames = append(s.frames, frame) + if len(s.frames) == cap(s.frames) { + return len(frame), s.Flush() + } + return len(frame), nil +} + +// Flush the remaining frames. +func (s *TCP) Flush() error { + defer func() { + s.frames = s.frames[0:] + }() + c, err := net.DialTimeout("tcp", s.address, time.Second) + if err != nil { + return err + } + enc, err := fs.NewEncoder(c, &fs.EncoderOptions{ + ContentType: []byte("protobuf:dnstap.Dnstap"), + Bidirectional: true, + }) + if err != nil { + return err + } + for _, frame := range s.frames { + if _, err = enc.Write(frame); err != nil { + return err + } + } + return enc.Flush() +} + +// Close is an alias to Flush to satisfy io.WriteCloser similarly to type Socket. +func (s *TCP) Close() error { + return s.Flush() +} diff --git a/plugin/dnstap/out/tcp_test.go b/plugin/dnstap/out/tcp_test.go new file mode 100644 index 000000000..113603cd4 --- /dev/null +++ b/plugin/dnstap/out/tcp_test.go @@ -0,0 +1,66 @@ +package out + +import ( + "net" + "testing" +) + +func sendOneTCP(tcp *TCP) error { + if _, err := tcp.Write([]byte("frame")); err != nil { + return err + } + if err := tcp.Flush(); err != nil { + return err + } + return nil +} +func TestTCP(t *testing.T) { + tcp := NewTCP("localhost:14000") + + if err := sendOneTCP(tcp); err == nil { + t.Fatal("Not listening but no error.") + return + } + + l, err := net.Listen("tcp", "localhost:14000") + if err != nil { + t.Fatal(err) + return + } + + wait := make(chan bool) + go func() { + acceptOne(t, l) + wait <- true + }() + + if err := sendOneTCP(tcp); err != nil { + t.Fatalf("send one: %s", err) + return + } + + <-wait + + // TODO: When the server isn't responding according to the framestream protocol + // the thread is blocked. + /* + if err := sendOneTCP(tcp); err == nil { + panic("must fail") + } + */ + + go func() { + acceptOne(t, l) + wait <- true + }() + + if err := sendOneTCP(tcp); err != nil { + t.Fatalf("send one: %s", err) + return + } + + <-wait + if err := l.Close(); err != nil { + t.Error(err) + } +} diff --git a/plugin/dnstap/setup.go b/plugin/dnstap/setup.go new file mode 100644 index 000000000..a57873470 --- /dev/null +++ b/plugin/dnstap/setup.go @@ -0,0 +1,98 @@ +package dnstap + +import ( + "fmt" + "io" + "log" + "strings" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/dnstap/out" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + + "github.com/mholt/caddy" + "github.com/mholt/caddy/caddyfile" +) + +func init() { + caddy.RegisterPlugin("dnstap", caddy.Plugin{ + ServerType: "dns", + Action: wrapSetup, + }) +} + +func wrapSetup(c *caddy.Controller) error { + if err := setup(c); err != nil { + return plugin.Error("dnstap", err) + } + return nil +} + +type config struct { + target string + socket bool + full bool +} + +func parseConfig(d *caddyfile.Dispenser) (c config, err error) { + d.Next() // directive name + + if !d.Args(&c.target) { + return c, d.ArgErr() + } + + if strings.HasPrefix(c.target, "tcp://") { + // remote IP endpoint + servers, err := dnsutil.ParseHostPortOrFile(c.target[6:]) + if err != nil { + return c, d.ArgErr() + } + c.target = servers[0] + } else { + // default to UNIX socket + if strings.HasPrefix(c.target, "unix://") { + c.target = c.target[7:] + } + c.socket = true + } + + c.full = d.NextArg() && d.Val() == "full" + + return +} + +func setup(c *caddy.Controller) error { + conf, err := parseConfig(&c.Dispenser) + if err != nil { + return err + } + + dnstap := Dnstap{Pack: conf.full} + + var o io.WriteCloser + if conf.socket { + o, err = out.NewSocket(conf.target) + if err != nil { + log.Printf("[WARN] Can't connect to %s at the moment: %s", conf.target, err) + } + } else { + o = out.NewTCP(conf.target) + } + dnstap.Out = o + + c.OnShutdown(func() error { + if err := o.Close(); err != nil { + return fmt.Errorf("output: %s", err) + } + return nil + }) + + dnsserver.GetConfig(c).AddPlugin( + func(next plugin.Handler) plugin.Handler { + dnstap.Next = next + return dnstap + }) + + return nil +} diff --git a/plugin/dnstap/setup_test.go b/plugin/dnstap/setup_test.go new file mode 100644 index 000000000..fc1dc98e0 --- /dev/null +++ b/plugin/dnstap/setup_test.go @@ -0,0 +1,34 @@ +package dnstap + +import ( + "github.com/mholt/caddy" + "testing" +) + +func TestConfig(t *testing.T) { + tests := []struct { + file string + path string + full bool + socket bool + fail bool + }{ + {"dnstap dnstap.sock full", "dnstap.sock", true, true, false}, + {"dnstap unix://dnstap.sock", "dnstap.sock", false, true, false}, + {"dnstap tcp://127.0.0.1:6000", "127.0.0.1:6000", false, false, false}, + {"dnstap", "fail", false, true, true}, + } + for _, c := range tests { + cad := caddy.NewTestController("dns", c.file) + conf, err := parseConfig(&cad.Dispenser) + if c.fail { + if err == nil { + t.Errorf("%s: %s", c.file, err) + } + } else if err != nil || conf.target != c.path || + conf.full != c.full || conf.socket != c.socket { + + t.Errorf("expected: %+v\nhave: %+v\nerror: %s\n", c, conf, err) + } + } +} diff --git a/plugin/dnstap/taprw/writer.go b/plugin/dnstap/taprw/writer.go new file mode 100644 index 000000000..ae9965411 --- /dev/null +++ b/plugin/dnstap/taprw/writer.go @@ -0,0 +1,73 @@ +// Package taprw takes a query and intercepts the response. +// It will log both after the response is written. +package taprw + +import ( + "fmt" + + "github.com/coredns/coredns/plugin/dnstap/msg" + + tap "github.com/dnstap/golang-dnstap" + "github.com/miekg/dns" +) + +// Tapper is what ResponseWriter needs to log to dnstap. +type Tapper interface { + TapMessage(m *tap.Message) error + TapBuilder() msg.Builder +} + +// ResponseWriter captures the client response and logs the query to dnstap. +// Single request use. +type ResponseWriter struct { + queryEpoch uint64 + Query *dns.Msg + dns.ResponseWriter + Tapper + err error +} + +// DnstapError check if a dnstap error occurred during Write and returns it. +func (w ResponseWriter) DnstapError() error { + return w.err +} + +// QueryEpoch sets the query epoch as reported by dnstap. +func (w *ResponseWriter) QueryEpoch() { + w.queryEpoch = msg.Epoch() +} + +// WriteMsg writes back the response to the client and THEN works on logging the request +// and response to dnstap. +// Dnstap errors are to be checked by DnstapError. +func (w *ResponseWriter) WriteMsg(resp *dns.Msg) (writeErr error) { + writeErr = w.ResponseWriter.WriteMsg(resp) + writeEpoch := msg.Epoch() + + b := w.TapBuilder() + b.TimeSec = w.queryEpoch + if err := func() (err error) { + err = b.AddrMsg(w.ResponseWriter.RemoteAddr(), w.Query) + if err != nil { + return + } + return w.TapMessage(b.ToClientQuery()) + }(); err != nil { + w.err = fmt.Errorf("client query: %s", err) + // don't forget to call DnstapError later + } + + if writeErr == nil { + if err := func() (err error) { + b.TimeSec = writeEpoch + if err = b.Msg(resp); err != nil { + return + } + return w.TapMessage(b.ToClientResponse()) + }(); err != nil { + w.err = fmt.Errorf("client response: %s", err) + } + } + + return +} diff --git a/plugin/dnstap/taprw/writer_test.go b/plugin/dnstap/taprw/writer_test.go new file mode 100644 index 000000000..6969dc515 --- /dev/null +++ b/plugin/dnstap/taprw/writer_test.go @@ -0,0 +1,82 @@ +package taprw + +import ( + "errors" + "testing" + + "github.com/coredns/coredns/plugin/dnstap/msg" + "github.com/coredns/coredns/plugin/dnstap/test" + mwtest "github.com/coredns/coredns/plugin/test" + + tap "github.com/dnstap/golang-dnstap" + "github.com/miekg/dns" +) + +type TapFailer struct { +} + +func (TapFailer) TapMessage(*tap.Message) error { + return errors.New("failed") +} +func (TapFailer) TapBuilder() msg.Builder { + return msg.Builder{Full: true} +} + +func TestDnstapError(t *testing.T) { + rw := ResponseWriter{ + Query: new(dns.Msg), + ResponseWriter: &mwtest.ResponseWriter{}, + Tapper: TapFailer{}, + } + if err := rw.WriteMsg(new(dns.Msg)); err != nil { + t.Errorf("dnstap error during Write: %s", err) + } + if rw.DnstapError() == nil { + t.Fatal("no dnstap error") + } +} + +func testingMsg() (m *dns.Msg) { + m = new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + m.SetEdns0(4097, true) + return +} + +func TestClientQueryResponse(t *testing.T) { + trapper := test.TrapTapper{Full: true} + m := testingMsg() + rw := ResponseWriter{ + Query: m, + Tapper: &trapper, + ResponseWriter: &mwtest.ResponseWriter{}, + } + d := test.TestingData() + + // will the wire-format msg be reported? + bin, err := m.Pack() + if err != nil { + t.Fatal(err) + return + } + d.Packed = bin + + if err := rw.WriteMsg(m); err != nil { + t.Fatal(err) + return + } + if l := len(trapper.Trap); l != 2 { + t.Fatalf("%d msg trapped", l) + return + } + want := d.ToClientQuery() + have := trapper.Trap[0] + if !test.MsgEqual(want, have) { + t.Fatalf("query: want: %v\nhave: %v", want, have) + } + want = d.ToClientResponse() + have = trapper.Trap[1] + if !test.MsgEqual(want, have) { + t.Fatalf("response: want: %v\nhave: %v", want, have) + } +} diff --git a/plugin/dnstap/test/helpers.go b/plugin/dnstap/test/helpers.go new file mode 100644 index 000000000..8c5809725 --- /dev/null +++ b/plugin/dnstap/test/helpers.go @@ -0,0 +1,80 @@ +package test + +import ( + "net" + "reflect" + + "github.com/coredns/coredns/plugin/dnstap/msg" + + tap "github.com/dnstap/golang-dnstap" + "golang.org/x/net/context" +) + +// Context is a message trap. +type Context struct { + context.Context + TrapTapper +} + +// TestingData returns the Data matching coredns/test.ResponseWriter. +func TestingData() (d *msg.Data) { + d = &msg.Data{ + SocketFam: tap.SocketFamily_INET, + SocketProto: tap.SocketProtocol_UDP, + Address: net.ParseIP("10.240.0.1"), + Port: 40212, + } + return +} + +type comp struct { + Type *tap.Message_Type + SF *tap.SocketFamily + SP *tap.SocketProtocol + QA []byte + RA []byte + QP *uint32 + RP *uint32 + QTSec bool + RTSec bool + RM []byte + QM []byte +} + +func toComp(m *tap.Message) comp { + return comp{ + Type: m.Type, + SF: m.SocketFamily, + SP: m.SocketProtocol, + QA: m.QueryAddress, + RA: m.ResponseAddress, + QP: m.QueryPort, + RP: m.ResponsePort, + QTSec: m.QueryTimeSec != nil, + RTSec: m.ResponseTimeSec != nil, + RM: m.ResponseMessage, + QM: m.QueryMessage, + } +} + +// MsgEqual compares two dnstap messages ignoring timestamps. +func MsgEqual(a, b *tap.Message) bool { + return reflect.DeepEqual(toComp(a), toComp(b)) +} + +// TrapTapper traps messages. +type TrapTapper struct { + Trap []*tap.Message + Full bool +} + +// TapMessage adds the message to the trap. +func (t *TrapTapper) TapMessage(m *tap.Message) error { + t.Trap = append(t.Trap, m) + return nil +} + +// TapBuilder returns a test msg.Builder. +func (t *TrapTapper) TapBuilder() msg.Builder { + return msg.Builder{Full: t.Full} +} diff --git a/plugin/erratic/README.md b/plugin/erratic/README.md new file mode 100644 index 000000000..a41faaca9 --- /dev/null +++ b/plugin/erratic/README.md @@ -0,0 +1,76 @@ +# erratic + +*erratic* is a plugin useful for testing client behavior. It returns a static response to all +queries, but the responses can be delayed, dropped or truncated. + +The *erratic* plugin will respond to every A or AAAA query. For any other type it will return +a SERVFAIL response. The reply for A will return 192.0.2.53 (see RFC 5737), for AAAA it returns +2001:DB8::53 (see RFC 3849). + +*erratic* can also be used in conjunction with the *autopath* plugin. This is mostly to aid in + testing. + +## Syntax + +~~~ txt +erratic { + drop [AMOUNT] + truncate [AMOUNT] + delay [AMOUNT [DURATION]] +} +~~~ + +* `drop`: drop 1 per **AMOUNT** of queries, the default is 2. +* `truncate`: truncate 1 per **AMOUNT** of queries, the default is 2. +* `delay`: delay 1 per **AMOUNT** of queries for **DURATION**, the default for **AMOUNT** is 2 and + the default for **DURATION** is 100ms. + +## Examples + +~~~ txt +.:53 { + erratic { + drop 3 + } +} +~~~ + +Or even shorter if the defaults suits you. Note this only drops queries, it does not delay them. + +~~~ txt +. { + erratic +} +~~~ + +Delay 1 in 3 queries for 50ms + +~~~ txt +. { + erratic { + delay 3 50ms + } +} +~~~ + +Delay 1 in 3 and truncate 1 in 5. + +~~~ txt +. { + erratic { + delay 3 5ms + truncate 5 + } +} +~~~ + +Drop every second query. + +~~~ txt +. { + erratic { + drop 2 + truncate 2 + } +} +~~~ diff --git a/plugin/erratic/autopath.go b/plugin/erratic/autopath.go new file mode 100644 index 000000000..0e29fffe5 --- /dev/null +++ b/plugin/erratic/autopath.go @@ -0,0 +1,8 @@ +package erratic + +import "github.com/coredns/coredns/request" + +// AutoPath implements the AutoPathFunc call from the autopath plugin. +func (e *Erratic) AutoPath(state request.Request) []string { + return []string{"a.example.org.", "b.example.org.", ""} +} diff --git a/plugin/erratic/erratic.go b/plugin/erratic/erratic.go new file mode 100644 index 000000000..5b8cd30c9 --- /dev/null +++ b/plugin/erratic/erratic.go @@ -0,0 +1,95 @@ +// Package erratic implements a plugin that returns erratic answers (delayed, dropped). +package erratic + +import ( + "sync/atomic" + "time" + + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Erratic is a plugin that returns erratic repsonses to each client. +type Erratic struct { + drop uint64 + + delay uint64 + duration time.Duration + + truncate uint64 + + q uint64 // counter of queries +} + +// ServeDNS implements the plugin.Handler interface. +func (e *Erratic) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + drop := false + delay := false + trunc := false + + queryNr := atomic.LoadUint64(&e.q) + atomic.AddUint64(&e.q, 1) + + if e.drop > 0 && queryNr%e.drop == 0 { + drop = true + } + if e.delay > 0 && queryNr%e.delay == 0 { + delay = true + } + if e.truncate > 0 && queryNr&e.truncate == 0 { + trunc = true + } + + m := new(dns.Msg) + m.SetReply(r) + m.Compress = true + m.Authoritative = true + if trunc { + m.Truncated = true + } + + // small dance to copy rrA or rrAAAA into a non-pointer var that allows us to overwrite the ownername + // in a non-racy way. + switch state.QType() { + case dns.TypeA: + rr := *(rrA.(*dns.A)) + rr.Header().Name = state.QName() + m.Answer = append(m.Answer, &rr) + case dns.TypeAAAA: + rr := *(rrAAAA.(*dns.AAAA)) + rr.Header().Name = state.QName() + m.Answer = append(m.Answer, &rr) + default: + if !drop { + if delay { + time.Sleep(e.duration) + } + // coredns will return error. + return dns.RcodeServerFailure, nil + } + } + + if drop { + return 0, nil + } + + if delay { + time.Sleep(e.duration) + } + + state.SizeAndDo(m) + w.WriteMsg(m) + + return 0, nil +} + +// Name implements the Handler interface. +func (e *Erratic) Name() string { return "erratic" } + +var ( + rrA, _ = dns.NewRR(". IN 0 A 192.0.2.53") + rrAAAA, _ = dns.NewRR(". IN 0 AAAA 2001:DB8::53") +) diff --git a/plugin/erratic/erratic_test.go b/plugin/erratic/erratic_test.go new file mode 100644 index 000000000..7a1a420da --- /dev/null +++ b/plugin/erratic/erratic_test.go @@ -0,0 +1,79 @@ +package erratic + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestErraticDrop(t *testing.T) { + e := &Erratic{drop: 2} // 50% drops + + tests := []struct { + expectedCode int + expectedErr error + drop bool + }{ + {expectedCode: dns.RcodeSuccess, expectedErr: nil, drop: true}, + {expectedCode: dns.RcodeSuccess, expectedErr: nil, drop: false}, + } + + ctx := context.TODO() + + for i, tc := range tests { + req := new(dns.Msg) + req.SetQuestion("example.org.", dns.TypeA) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + code, err := e.ServeDNS(ctx, rec, req) + + if err != tc.expectedErr { + t.Errorf("Test %d: Expected error %q, but got %q", i, tc.expectedErr, err) + } + if code != int(tc.expectedCode) { + t.Errorf("Test %d: Expected status code %d, but got %d", i, tc.expectedCode, code) + } + + if tc.drop && rec.Msg != nil { + t.Errorf("Test %d: Expected dropped message, but got %q", i, rec.Msg.Question[0].Name) + } + } +} + +func TestErraticTruncate(t *testing.T) { + e := &Erratic{truncate: 2} // 50% drops + + tests := []struct { + expectedCode int + expectedErr error + truncate bool + }{ + {expectedCode: dns.RcodeSuccess, expectedErr: nil, truncate: true}, + {expectedCode: dns.RcodeSuccess, expectedErr: nil, truncate: false}, + } + + ctx := context.TODO() + + for i, tc := range tests { + req := new(dns.Msg) + req.SetQuestion("example.org.", dns.TypeA) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + code, err := e.ServeDNS(ctx, rec, req) + + if err != tc.expectedErr { + t.Errorf("Test %d: Expected error %q, but got %q", i, tc.expectedErr, err) + } + if code != int(tc.expectedCode) { + t.Errorf("Test %d: Expected status code %d, but got %d", i, tc.expectedCode, code) + } + + if tc.truncate && !rec.Msg.Truncated { + t.Errorf("Test %d: Expected truncated message, but got %q", i, rec.Msg.Question[0].Name) + } + } +} diff --git a/plugin/erratic/setup.go b/plugin/erratic/setup.go new file mode 100644 index 000000000..b0a56927d --- /dev/null +++ b/plugin/erratic/setup.go @@ -0,0 +1,117 @@ +package erratic + +import ( + "fmt" + "strconv" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("erratic", caddy.Plugin{ + ServerType: "dns", + Action: setupErratic, + }) +} + +func setupErratic(c *caddy.Controller) error { + e, err := parseErratic(c) + if err != nil { + return plugin.Error("erratic", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return e + }) + + return nil +} + +func parseErratic(c *caddy.Controller) (*Erratic, error) { + e := &Erratic{drop: 2} + drop := false // true if we've seen the drop keyword + + for c.Next() { // 'erratic' + for c.NextBlock() { + switch c.Val() { + case "drop": + args := c.RemainingArgs() + if len(args) > 1 { + return nil, c.ArgErr() + } + + if len(args) == 0 { + continue + } + + amount, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return nil, err + } + if amount < 0 { + return nil, fmt.Errorf("illegal amount value given %q", args[0]) + } + e.drop = uint64(amount) + drop = true + case "delay": + args := c.RemainingArgs() + if len(args) > 2 { + return nil, c.ArgErr() + } + + // Defaults. + e.delay = 2 + e.duration = 100 * time.Millisecond + if len(args) == 0 { + continue + } + + amount, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return nil, err + } + if amount < 0 { + return nil, fmt.Errorf("illegal amount value given %q", args[0]) + } + e.delay = uint64(amount) + + if len(args) > 1 { + duration, err := time.ParseDuration(args[1]) + if err != nil { + return nil, err + } + e.duration = duration + } + case "truncate": + args := c.RemainingArgs() + if len(args) > 1 { + return nil, c.ArgErr() + } + + if len(args) == 0 { + continue + } + + amount, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return nil, err + } + if amount < 0 { + return nil, fmt.Errorf("illegal amount value given %q", args[0]) + } + e.truncate = uint64(amount) + default: + return nil, c.Errf("unknown property '%s'", c.Val()) + } + } + } + if (e.delay > 0 || e.truncate > 0) && !drop { // delay is set, but we've haven't seen a drop keyword, remove default drop stuff + e.drop = 0 + } + + return e, nil +} diff --git a/plugin/erratic/setup_test.go b/plugin/erratic/setup_test.go new file mode 100644 index 000000000..759845f7a --- /dev/null +++ b/plugin/erratic/setup_test.go @@ -0,0 +1,103 @@ +package erratic + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupErratic(t *testing.T) { + c := caddy.NewTestController("dns", `erratic { + drop + }`) + if err := setupErratic(c); err != nil { + t.Fatalf("Test 1, expected no errors, but got: %q", err) + } + + c = caddy.NewTestController("dns", `erratic`) + if err := setupErratic(c); err != nil { + t.Fatalf("Test 2, expected no errors, but got: %q", err) + } + + c = caddy.NewTestController("dns", `erratic { + drop -1 + }`) + if err := setupErratic(c); err == nil { + t.Fatalf("Test 4, expected errors, but got: %q", err) + } +} + +func TestParseErratic(t *testing.T) { + tests := []struct { + input string + shouldErr bool + drop uint64 + delay uint64 + truncate uint64 + }{ + // oks + {`erratic`, false, 2, 0, 0}, + {`erratic { + drop 2 + delay 3 1ms + + }`, false, 2, 3, 0}, + {`erratic { + truncate 2 + delay 3 1ms + + }`, false, 0, 3, 2}, + {`erraric { + drop 3 + delay + }`, false, 3, 2, 0}, + // fails + {`erratic { + drop -1 + }`, true, 0, 0, 0}, + {`erratic { + delay -1 + }`, true, 0, 0, 0}, + {`erratic { + delay 1 2 4 + }`, true, 0, 0, 0}, + {`erratic { + delay 15.a + }`, true, 0, 0, 0}, + {`erraric { + drop 3 + delay 3 bla + }`, true, 0, 0, 0}, + {`erraric { + truncate 15.a + }`, true, 0, 0, 0}, + {`erraric { + something-else + }`, true, 0, 0, 0}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + e, err := parseErratic(c) + if test.shouldErr && err == nil { + t.Errorf("Test %v: Expected error but found nil", i) + continue + } else if !test.shouldErr && err != nil { + t.Errorf("Test %v: Expected no error but found error: %v", i, err) + continue + } + + if test.shouldErr { + continue + } + + if test.delay != e.delay { + t.Errorf("Test %v: Expected delay %d but found: %d", i, test.delay, e.delay) + } + if test.drop != e.drop { + t.Errorf("Test %v: Expected drop %d but found: %d", i, test.drop, e.drop) + } + if test.truncate != e.truncate { + t.Errorf("Test %v: Expected truncate %d but found: %d", i, test.truncate, e.truncate) + } + } +} diff --git a/plugin/errors/README.md b/plugin/errors/README.md new file mode 100644 index 000000000..21b8f4848 --- /dev/null +++ b/plugin/errors/README.md @@ -0,0 +1,22 @@ +# errors + +*errors* enables error logging. + +Any errors encountered during the query processing will be printed to standard output. + +## Syntax + +~~~ +errors +~~~ + +## Examples + +Use the *whoami* to respond to queries and Log errors to standard output. + +~~~ corefile +. { + whoami + errors +} +~~~ diff --git a/plugin/errors/errors.go b/plugin/errors/errors.go new file mode 100644 index 000000000..a313f2e0d --- /dev/null +++ b/plugin/errors/errors.go @@ -0,0 +1,79 @@ +// Package errors implements an HTTP error handling plugin. +package errors + +import ( + "fmt" + "log" + "runtime" + "strings" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// errorHandler handles DNS errors (and errors from other plugin). +type errorHandler struct { + Next plugin.Handler + LogFile string + Log *log.Logger +} + +// ServeDNS implements the plugin.Handler interface. +func (h errorHandler) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + defer h.recovery(ctx, w, r) + + rcode, err := plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + + if err != nil { + state := request.Request{W: w, Req: r} + errMsg := fmt.Sprintf("%s [ERROR %d %s %s] %v", time.Now().Format(timeFormat), rcode, state.Name(), state.Type(), err) + + h.Log.Println(errMsg) + } + + return rcode, err +} + +func (h errorHandler) Name() string { return "errors" } + +func (h errorHandler) recovery(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) { + rec := recover() + if rec == nil { + return + } + + // Obtain source of panic + // From: https://gist.github.com/swdunlop/9629168 + var name, file string // function name, file name + var line int + var pc [16]uintptr + n := runtime.Callers(3, pc[:]) + for _, pc := range pc[:n] { + fn := runtime.FuncForPC(pc) + if fn == nil { + continue + } + file, line = fn.FileLine(pc) + name = fn.Name() + if !strings.HasPrefix(name, "runtime.") { + break + } + } + + // Trim file path + delim := "/coredns/" + pkgPathPos := strings.Index(file, delim) + if pkgPathPos > -1 && len(file) > pkgPathPos+len(delim) { + file = file[pkgPathPos+len(delim):] + } + + panicMsg := fmt.Sprintf("%s [PANIC %s %s] %s:%d - %v", time.Now().Format(timeFormat), r.Question[0].Name, dns.Type(r.Question[0].Qtype), file, line, rec) + // Currently we don't use the function name, since file:line is more conventional + h.Log.Printf(panicMsg) +} + +const timeFormat = "02/Jan/2006:15:04:05 -0700" diff --git a/plugin/errors/errors_test.go b/plugin/errors/errors_test.go new file mode 100644 index 000000000..039562a56 --- /dev/null +++ b/plugin/errors/errors_test.go @@ -0,0 +1,73 @@ +package errors + +import ( + "bytes" + "errors" + "fmt" + "log" + "strings" + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestErrors(t *testing.T) { + buf := bytes.Buffer{} + em := errorHandler{Log: log.New(&buf, "", 0)} + + testErr := errors.New("test error") + tests := []struct { + next plugin.Handler + expectedCode int + expectedLog string + expectedErr error + }{ + { + next: genErrorHandler(dns.RcodeSuccess, nil), + expectedCode: dns.RcodeSuccess, + expectedLog: "", + expectedErr: nil, + }, + { + next: genErrorHandler(dns.RcodeNotAuth, testErr), + expectedCode: dns.RcodeNotAuth, + expectedLog: fmt.Sprintf("[ERROR %d %s] %v\n", dns.RcodeNotAuth, "example.org. A", testErr), + expectedErr: testErr, + }, + } + + ctx := context.TODO() + req := new(dns.Msg) + req.SetQuestion("example.org.", dns.TypeA) + + for i, tc := range tests { + em.Next = tc.next + buf.Reset() + rec := dnsrecorder.New(&test.ResponseWriter{}) + code, err := em.ServeDNS(ctx, rec, req) + + if err != tc.expectedErr { + t.Errorf("Test %d: Expected error %v, but got %v", + i, tc.expectedErr, err) + } + if code != tc.expectedCode { + t.Errorf("Test %d: Expected status code %d, but got %d", + i, tc.expectedCode, code) + } + if log := buf.String(); !strings.Contains(log, tc.expectedLog) { + t.Errorf("Test %d: Expected log %q, but got %q", + i, tc.expectedLog, log) + } + } +} + +func genErrorHandler(rcode int, err error) plugin.Handler { + return plugin.HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + return rcode, err + }) +} diff --git a/plugin/errors/setup.go b/plugin/errors/setup.go new file mode 100644 index 000000000..19bdcdb80 --- /dev/null +++ b/plugin/errors/setup.go @@ -0,0 +1,55 @@ +package errors + +import ( + "fmt" + "log" + "os" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("errors", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + handler, err := errorsParse(c) + if err != nil { + return plugin.Error("errors", err) + } + + handler.Log = log.New(os.Stdout, "", 0) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + handler.Next = next + return handler + }) + + return nil +} + +func errorsParse(c *caddy.Controller) (errorHandler, error) { + handler := errorHandler{} + + for c.Next() { + args := c.RemainingArgs() + switch len(args) { + case 0: + handler.LogFile = "stdout" + case 1: + if args[0] != "stdout" { + return handler, fmt.Errorf("invalid log file: %s", args[0]) + } + handler.LogFile = args[0] + default: + return handler, c.ArgErr() + } + } + return handler, nil +} diff --git a/plugin/errors/setup_test.go b/plugin/errors/setup_test.go new file mode 100644 index 000000000..bae85da32 --- /dev/null +++ b/plugin/errors/setup_test.go @@ -0,0 +1,45 @@ +package errors + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestErrorsParse(t *testing.T) { + tests := []struct { + inputErrorsRules string + shouldErr bool + expectedErrorHandler errorHandler + }{ + {`errors`, false, errorHandler{ + LogFile: "stdout", + }}, + {`errors stdout`, false, errorHandler{ + LogFile: "stdout", + }}, + {`errors errors.txt`, true, errorHandler{ + LogFile: "", + }}, + {`errors visible`, true, errorHandler{ + LogFile: "", + }}, + {`errors { log visible }`, true, errorHandler{ + LogFile: "stdout", + }}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputErrorsRules) + actualErrorsRule, err := errorsParse(c) + + if err == nil && test.shouldErr { + t.Errorf("Test %d didn't error, but it should have", i) + } else if err != nil && !test.shouldErr { + t.Errorf("Test %d errored, but it shouldn't have; got '%v'", i, err) + } + if actualErrorsRule.LogFile != test.expectedErrorHandler.LogFile { + t.Errorf("Test %d expected LogFile to be %s, but got %s", + i, test.expectedErrorHandler.LogFile, actualErrorsRule.LogFile) + } + } +} diff --git a/plugin/etcd/README.md b/plugin/etcd/README.md new file mode 100644 index 000000000..f65c193f1 --- /dev/null +++ b/plugin/etcd/README.md @@ -0,0 +1,109 @@ +# etcd + +*etcd* enables reading zone data from an etcd instance. The data in etcd has to be encoded as +a [message](https://github.com/skynetservices/skydns/blob/2fcff74cdc9f9a7dd64189a447ef27ac354b725f/msg/service.go#L26) +like [SkyDNS](https://github.com/skynetservices/skydns). It should also work just like SkyDNS. + +The etcd plugin makes extensive use of the proxy plugin to forward and query other servers +in the network. + +## Syntax + +~~~ +etcd [ZONES...] +~~~ + +* **ZONES** zones etcd should be authoritative for. + +The path will default to `/skydns` the local etcd proxy (http://localhost:2379). +If no zones are specified the block's zone will be used as the zone. + +If you want to `round robin` A and AAAA responses look at the `loadbalance` plugin. + +~~~ +etcd [ZONES...] { + stubzones + fallthrough + path PATH + endpoint ENDPOINT... + upstream ADDRESS... + tls CERT KEY CACERT +} +~~~ + +* `stubzones` enables the stub zones feature. The stubzone is *only* done in the etcd tree located + under the *first* zone specified. +* `fallthrough` If zone matches but no record can be generated, pass request to the next plugin. +* **PATH** the path inside etcd. Defaults to "/skydns". +* **ENDPOINT** the etcd endpoints. Defaults to "http://localhost:2397". +* `upstream` upstream resolvers to be used resolve external names found in etcd (think CNAMEs) + pointing to external names. If you want CoreDNS to act as a proxy for clients, you'll need to add + the proxy plugin. **ADDRESS** can be an IP address, and IP:port or a string pointing to a file + that is structured as /etc/resolv.conf. +* `tls` followed by: + * no arguments, if the server certificate is signed by a system-installed CA and no client cert is needed + * a single argument that is the CA PEM file, if the server cert is not signed by a system CA and no client cert is needed + * two arguments - path to cert PEM file, the path to private key PEM file - if the server certificate is signed by a system-installed CA and a client certificate is needed + * three arguments - path to cert PEM file, path to client private key PEM file, path to CA PEM file - if the server certificate is not signed by a system-installed CA and client certificate is needed + +## Examples + +This is the default SkyDNS setup, with everying specified in full: + +~~~ +.:53 { + etcd skydns.local { + stubzones + path /skydns + endpoint http://localhost:2379 + upstream 8.8.8.8:53 8.8.4.4:53 + } + prometheus + cache 160 skydns.local + loadbalance + proxy . 8.8.8.8:53 8.8.4.4:53 +} +~~~ + +Or a setup where we use `/etc/resolv.conf` as the basis for the proxy and the upstream +when resolving external pointing CNAMEs. + +~~~ +.:53 { + etcd skydns.local { + path /skydns + upstream /etc/resolv.conf + } + cache 160 skydns.local + proxy . /etc/resolv.conf +} +~~~ + + +### Reverse zones + +Reverse zones are supported. You need to make CoreDNS aware of the fact that you are also +authoritative for the reverse. For instance if you want to add the reverse for 10.0.0.0/24, you'll +need to add the zone `0.0.10.in-addr.arpa` to the list of zones. (The fun starts with IPv6 reverse zones +in the ip6.arpa domain.) Showing a snippet of a Corefile: + +~~~ + etcd skydns.local 0.0.10.in-addr.arpa { + stubzones + ... +~~~ + +Next you'll need to populate the zone with reverse records, here we add a reverse for +10.0.0.127 pointing to reverse.skydns.local. + +~~~ +% curl -XPUT http://127.0.0.1:4001/v2/keys/skydns/arpa/in-addr/10/0/0/127 \ + -d value='{"host":"reverse.skydns.local."}' +~~~ + +Querying with dig: + +~~~ +% dig @localhost -x 10.0.0.127 +short +reverse.atoom.net. +~~~ diff --git a/plugin/etcd/cname_test.go b/plugin/etcd/cname_test.go new file mode 100644 index 000000000..33291094b --- /dev/null +++ b/plugin/etcd/cname_test.go @@ -0,0 +1,79 @@ +// +build etcd + +package etcd + +// etcd needs to be running on http://localhost:2379 + +import ( + "testing" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +// Check the ordering of returned cname. +func TestCnameLookup(t *testing.T) { + etc := newEtcdMiddleware() + + for _, serv := range servicesCname { + set(t, etc, serv.Key, 0, serv) + defer delete(t, etc, serv.Key) + } + for _, tc := range dnsTestCasesCname { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := etc.ServeDNS(ctxt, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + return + } + + resp := rec.Msg + if !test.Header(t, tc, resp) { + t.Logf("%v\n", resp) + continue + } + if !test.Section(t, tc, test.Answer, resp.Answer) { + t.Logf("%v\n", resp) + } + if !test.Section(t, tc, test.Ns, resp.Ns) { + t.Logf("%v\n", resp) + } + if !test.Section(t, tc, test.Extra, resp.Extra) { + t.Logf("%v\n", resp) + } + } +} + +var servicesCname = []*msg.Service{ + {Host: "cname1.region2.skydns.test", Key: "a.server1.dev.region1.skydns.test."}, + {Host: "cname2.region2.skydns.test", Key: "cname1.region2.skydns.test."}, + {Host: "cname3.region2.skydns.test", Key: "cname2.region2.skydns.test."}, + {Host: "cname4.region2.skydns.test", Key: "cname3.region2.skydns.test."}, + {Host: "cname5.region2.skydns.test", Key: "cname4.region2.skydns.test."}, + {Host: "cname6.region2.skydns.test", Key: "cname5.region2.skydns.test."}, + {Host: "endpoint.region2.skydns.test", Key: "cname6.region2.skydns.test."}, + {Host: "10.240.0.1", Key: "endpoint.region2.skydns.test."}, +} + +var dnsTestCasesCname = []test.Case{ + { + Qname: "a.server1.dev.region1.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + test.SRV("a.server1.dev.region1.skydns.test. 300 IN SRV 10 100 0 cname1.region2.skydns.test."), + }, + Extra: []dns.RR{ + test.CNAME("cname1.region2.skydns.test. 300 IN CNAME cname2.region2.skydns.test."), + test.CNAME("cname2.region2.skydns.test. 300 IN CNAME cname3.region2.skydns.test."), + test.CNAME("cname3.region2.skydns.test. 300 IN CNAME cname4.region2.skydns.test."), + test.CNAME("cname4.region2.skydns.test. 300 IN CNAME cname5.region2.skydns.test."), + test.CNAME("cname5.region2.skydns.test. 300 IN CNAME cname6.region2.skydns.test."), + test.CNAME("cname6.region2.skydns.test. 300 IN CNAME endpoint.region2.skydns.test."), + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.1"), + }, + }, +} diff --git a/plugin/etcd/etcd.go b/plugin/etcd/etcd.go new file mode 100644 index 000000000..862be065b --- /dev/null +++ b/plugin/etcd/etcd.go @@ -0,0 +1,188 @@ +// Package etcd provides the etcd backend plugin. +package etcd + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/cache" + "github.com/coredns/coredns/plugin/pkg/singleflight" + "github.com/coredns/coredns/plugin/proxy" + "github.com/coredns/coredns/request" + + etcdc "github.com/coreos/etcd/client" + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Etcd is a plugin talks to an etcd cluster. +type Etcd struct { + Next plugin.Handler + Fallthrough bool + Zones []string + PathPrefix string + Proxy proxy.Proxy // Proxy for looking up names during the resolution process + Client etcdc.KeysAPI + Ctx context.Context + Inflight *singleflight.Group + Stubmap *map[string]proxy.Proxy // list of proxies for stub resolving. + + endpoints []string // Stored here as well, to aid in testing. +} + +// Services implements the ServiceBackend interface. +func (e *Etcd) Services(state request.Request, exact bool, opt plugin.Options) (services []msg.Service, err error) { + services, err = e.Records(state, exact) + if err != nil { + return + } + + services = msg.Group(services) + return +} + +// Reverse implements the ServiceBackend interface. +func (e *Etcd) Reverse(state request.Request, exact bool, opt plugin.Options) (services []msg.Service, err error) { + return e.Services(state, exact, opt) +} + +// Lookup implements the ServiceBackend interface. +func (e *Etcd) Lookup(state request.Request, name string, typ uint16) (*dns.Msg, error) { + return e.Proxy.Lookup(state, name, typ) +} + +// IsNameError implements the ServiceBackend interface. +func (e *Etcd) IsNameError(err error) bool { + if ee, ok := err.(etcdc.Error); ok && ee.Code == etcdc.ErrorCodeKeyNotFound { + return true + } + return false +} + +// Records looks up records in etcd. If exact is true, it will lookup just this +// name. This is used when find matches when completing SRV lookups for instance. +func (e *Etcd) Records(state request.Request, exact bool) ([]msg.Service, error) { + name := state.Name() + + path, star := msg.PathWithWildcard(name, e.PathPrefix) + r, err := e.get(path, true) + if err != nil { + return nil, err + } + segments := strings.Split(msg.Path(name, e.PathPrefix), "/") + switch { + case exact && r.Node.Dir: + return nil, nil + case r.Node.Dir: + return e.loopNodes(r.Node.Nodes, segments, star, nil) + default: + return e.loopNodes([]*etcdc.Node{r.Node}, segments, false, nil) + } +} + +// get is a wrapper for client.Get that uses SingleInflight to suppress multiple outstanding queries. +func (e *Etcd) get(path string, recursive bool) (*etcdc.Response, error) { + + hash := cache.Hash([]byte(path)) + + resp, err := e.Inflight.Do(hash, func() (interface{}, error) { + ctx, cancel := context.WithTimeout(e.Ctx, etcdTimeout) + defer cancel() + r, e := e.Client.Get(ctx, path, &etcdc.GetOptions{Sort: false, Recursive: recursive}) + if e != nil { + return nil, e + } + return r, e + }) + if err != nil { + return nil, err + } + return resp.(*etcdc.Response), err +} + +// skydns/local/skydns/east/staging/web +// skydns/local/skydns/west/production/web +// +// skydns/local/skydns/*/*/web +// skydns/local/skydns/*/web + +// loopNodes recursively loops through the nodes and returns all the values. The nodes' keyname +// will be match against any wildcards when star is true. +func (e *Etcd) loopNodes(ns []*etcdc.Node, nameParts []string, star bool, bx map[msg.Service]bool) (sx []msg.Service, err error) { + if bx == nil { + bx = make(map[msg.Service]bool) + } +Nodes: + for _, n := range ns { + if n.Dir { + nodes, err := e.loopNodes(n.Nodes, nameParts, star, bx) + if err != nil { + return nil, err + } + sx = append(sx, nodes...) + continue + } + if star { + keyParts := strings.Split(n.Key, "/") + for i, n := range nameParts { + if i > len(keyParts)-1 { + // name is longer than key + continue Nodes + } + if n == "*" || n == "any" { + continue + } + if keyParts[i] != n { + continue Nodes + } + } + } + serv := new(msg.Service) + if err := json.Unmarshal([]byte(n.Value), serv); err != nil { + return nil, fmt.Errorf("%s: %s", n.Key, err.Error()) + } + b := msg.Service{Host: serv.Host, Port: serv.Port, Priority: serv.Priority, Weight: serv.Weight, Text: serv.Text, Key: n.Key} + if _, ok := bx[b]; ok { + continue + } + bx[b] = true + + serv.Key = n.Key + serv.TTL = e.TTL(n, serv) + if serv.Priority == 0 { + serv.Priority = priority + } + sx = append(sx, *serv) + } + return sx, nil +} + +// TTL returns the smaller of the etcd TTL and the service's +// TTL. If neither of these are set (have a zero value), a default is used. +func (e *Etcd) TTL(node *etcdc.Node, serv *msg.Service) uint32 { + etcdTTL := uint32(node.TTL) + + if etcdTTL == 0 && serv.TTL == 0 { + return ttl + } + if etcdTTL == 0 { + return serv.TTL + } + if serv.TTL == 0 { + return etcdTTL + } + if etcdTTL < serv.TTL { + return etcdTTL + } + return serv.TTL +} + +const ( + priority = 10 // default priority when nothing is set + ttl = 300 // default ttl when nothing is set + etcdTimeout = 5 * time.Second +) diff --git a/plugin/etcd/group_test.go b/plugin/etcd/group_test.go new file mode 100644 index 000000000..d7f6172c7 --- /dev/null +++ b/plugin/etcd/group_test.go @@ -0,0 +1,74 @@ +// +build etcd + +package etcd + +import ( + "testing" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestGroupLookup(t *testing.T) { + etc := newEtcdMiddleware() + + for _, serv := range servicesGroup { + set(t, etc, serv.Key, 0, serv) + defer delete(t, etc, serv.Key) + } + for _, tc := range dnsTestCasesGroup { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := etc.ServeDNS(ctxt, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + continue + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +// Note the key is encoded as DNS name, while in "reality" it is a etcd path. +var servicesGroup = []*msg.Service{ + {Host: "127.0.0.1", Key: "a.dom.skydns.test.", Group: "g1"}, + {Host: "127.0.0.2", Key: "b.sub.dom.skydns.test.", Group: "g1"}, + + {Host: "127.0.0.1", Key: "a.dom2.skydns.test.", Group: "g1"}, + {Host: "127.0.0.2", Key: "b.sub.dom2.skydns.test.", Group: ""}, + + {Host: "127.0.0.1", Key: "a.dom1.skydns.test.", Group: "g1"}, + {Host: "127.0.0.2", Key: "b.sub.dom1.skydns.test.", Group: "g2"}, +} + +var dnsTestCasesGroup = []test.Case{ + // Groups + { + // hits the group 'g1' and only includes those records + Qname: "dom.skydns.test.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("dom.skydns.test. 300 IN A 127.0.0.1"), + test.A("dom.skydns.test. 300 IN A 127.0.0.2"), + }, + }, + { + // One has group, the other has not... Include the non-group always. + Qname: "dom2.skydns.test.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("dom2.skydns.test. 300 IN A 127.0.0.1"), + test.A("dom2.skydns.test. 300 IN A 127.0.0.2"), + }, + }, + { + // The groups differ. + Qname: "dom1.skydns.test.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("dom1.skydns.test. 300 IN A 127.0.0.1"), + }, + }, +} diff --git a/plugin/etcd/handler.go b/plugin/etcd/handler.go new file mode 100644 index 000000000..49f15343d --- /dev/null +++ b/plugin/etcd/handler.go @@ -0,0 +1,97 @@ +package etcd + +import ( + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// ServeDNS implements the plugin.Handler interface. +func (e *Etcd) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + opt := plugin.Options{} + state := request.Request{W: w, Req: r} + + name := state.Name() + + // We need to check stubzones first, because we may get a request for a zone we + // are not auth. for *but* do have a stubzone forward for. If we do the stubzone + // handler will handle the request. + if e.Stubmap != nil && len(*e.Stubmap) > 0 { + for zone := range *e.Stubmap { + if plugin.Name(zone).Matches(name) { + stub := Stub{Etcd: e, Zone: zone} + return stub.ServeDNS(ctx, w, r) + } + } + } + + zone := plugin.Zones(e.Zones).Matches(state.Name()) + if zone == "" { + return plugin.NextOrFailure(e.Name(), e.Next, ctx, w, r) + } + + var ( + records, extra []dns.RR + err error + ) + switch state.Type() { + case "A": + records, err = plugin.A(e, zone, state, nil, opt) + case "AAAA": + records, err = plugin.AAAA(e, zone, state, nil, opt) + case "TXT": + records, err = plugin.TXT(e, zone, state, opt) + case "CNAME": + records, err = plugin.CNAME(e, zone, state, opt) + case "PTR": + records, err = plugin.PTR(e, zone, state, opt) + case "MX": + records, extra, err = plugin.MX(e, zone, state, opt) + case "SRV": + records, extra, err = plugin.SRV(e, zone, state, opt) + case "SOA": + records, err = plugin.SOA(e, zone, state, opt) + case "NS": + if state.Name() == zone { + records, extra, err = plugin.NS(e, zone, state, opt) + break + } + fallthrough + default: + // Do a fake A lookup, so we can distinguish between NODATA and NXDOMAIN + _, err = plugin.A(e, zone, state, nil, opt) + } + + if e.IsNameError(err) { + if e.Fallthrough { + return plugin.NextOrFailure(e.Name(), e.Next, ctx, w, r) + } + // Make err nil when returning here, so we don't log spam for NXDOMAIN. + return plugin.BackendError(e, zone, dns.RcodeNameError, state, nil /* err */, opt) + } + if err != nil { + return plugin.BackendError(e, zone, dns.RcodeServerFailure, state, err, opt) + } + + if len(records) == 0 { + return plugin.BackendError(e, zone, dns.RcodeSuccess, state, err, opt) + } + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + m.Answer = append(m.Answer, records...) + m.Extra = append(m.Extra, extra...) + + m = dnsutil.Dedup(m) + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +// Name implements the Handler interface. +func (e *Etcd) Name() string { return "etcd" } diff --git a/plugin/etcd/lookup_test.go b/plugin/etcd/lookup_test.go new file mode 100644 index 000000000..1f52d5993 --- /dev/null +++ b/plugin/etcd/lookup_test.go @@ -0,0 +1,273 @@ +// +build etcd + +package etcd + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/pkg/singleflight" + "github.com/coredns/coredns/plugin/pkg/tls" + "github.com/coredns/coredns/plugin/proxy" + "github.com/coredns/coredns/plugin/test" + + etcdc "github.com/coreos/etcd/client" + "github.com/miekg/dns" +) + +func init() { + ctxt = context.TODO() +} + +// Note the key is encoded as DNS name, while in "reality" it is a etcd path. +var services = []*msg.Service{ + {Host: "dev.server1", Port: 8080, Key: "a.server1.dev.region1.skydns.test."}, + {Host: "10.0.0.1", Port: 8080, Key: "a.server1.prod.region1.skydns.test."}, + {Host: "10.0.0.2", Port: 8080, Key: "b.server1.prod.region1.skydns.test."}, + {Host: "::1", Port: 8080, Key: "b.server6.prod.region1.skydns.test."}, + // Unresolvable internal name. + {Host: "unresolvable.skydns.test", Key: "cname.prod.region1.skydns.test."}, + // Priority. + {Host: "priority.server1", Priority: 333, Port: 8080, Key: "priority.skydns.test."}, + // Subdomain. + {Host: "sub.server1", Port: 0, Key: "a.sub.region1.skydns.test."}, + {Host: "sub.server2", Port: 80, Key: "b.sub.region1.skydns.test."}, + {Host: "10.0.0.1", Port: 8080, Key: "c.sub.region1.skydns.test."}, + // Cname loop. + {Host: "a.cname.skydns.test", Key: "b.cname.skydns.test."}, + {Host: "b.cname.skydns.test", Key: "a.cname.skydns.test."}, + // Nameservers. + {Host: "10.0.0.2", Key: "a.ns.dns.skydns.test."}, + {Host: "10.0.0.3", Key: "b.ns.dns.skydns.test."}, + // Reverse. + {Host: "reverse.example.com", Key: "1.0.0.10.in-addr.arpa."}, // 10.0.0.1 +} + +var dnsTestCases = []test.Case{ + // SRV Test + { + Qname: "a.server1.dev.region1.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{test.SRV("a.server1.dev.region1.skydns.test. 300 SRV 10 100 8080 dev.server1.")}, + }, + // SRV Test (case test) + { + Qname: "a.SERVer1.dEv.region1.skydns.tEst.", Qtype: dns.TypeSRV, + Answer: []dns.RR{test.SRV("a.SERVer1.dEv.region1.skydns.tEst. 300 SRV 10 100 8080 dev.server1.")}, + }, + // NXDOMAIN Test + { + Qname: "doesnotexist.skydns.test.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("skydns.test. 300 SOA ns.dns.skydns.test. hostmaster.skydns.test. 0 0 0 0 0"), + }, + }, + // A Test + { + Qname: "a.server1.prod.region1.skydns.test.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A("a.server1.prod.region1.skydns.test. 300 A 10.0.0.1")}, + }, + // SRV Test where target is IP address + { + Qname: "a.server1.prod.region1.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{test.SRV("a.server1.prod.region1.skydns.test. 300 SRV 10 100 8080 a.server1.prod.region1.skydns.test.")}, + Extra: []dns.RR{test.A("a.server1.prod.region1.skydns.test. 300 A 10.0.0.1")}, + }, + // AAAA Test + { + Qname: "b.server6.prod.region1.skydns.test.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{test.AAAA("b.server6.prod.region1.skydns.test. 300 AAAA ::1")}, + }, + // Multiple A Record Test + { + Qname: "server1.prod.region1.skydns.test.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("server1.prod.region1.skydns.test. 300 A 10.0.0.1"), + test.A("server1.prod.region1.skydns.test. 300 A 10.0.0.2"), + }, + }, + // Priority Test + { + Qname: "priority.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{test.SRV("priority.skydns.test. 300 SRV 333 100 8080 priority.server1.")}, + }, + // Subdomain Test + { + Qname: "sub.region1.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + test.SRV("sub.region1.skydns.test. 300 IN SRV 10 33 0 sub.server1."), + test.SRV("sub.region1.skydns.test. 300 IN SRV 10 33 80 sub.server2."), + test.SRV("sub.region1.skydns.test. 300 IN SRV 10 33 8080 c.sub.region1.skydns.test."), + }, + Extra: []dns.RR{test.A("c.sub.region1.skydns.test. 300 IN A 10.0.0.1")}, + }, + // CNAME (unresolvable internal name) + { + Qname: "cname.prod.region1.skydns.test.", Qtype: dns.TypeA, + Ns: []dns.RR{test.SOA("skydns.test. 300 SOA ns.dns.skydns.test. hostmaster.skydns.test. 0 0 0 0 0")}, + }, + // Wildcard Test + { + Qname: "*.region1.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 0 sub.server1."), + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 0 unresolvable.skydns.test."), + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 80 sub.server2."), + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 8080 a.server1.prod.region1.skydns.test."), + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 8080 b.server1.prod.region1.skydns.test."), + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 8080 b.server6.prod.region1.skydns.test."), + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 8080 c.sub.region1.skydns.test."), + test.SRV("*.region1.skydns.test. 300 IN SRV 10 12 8080 dev.server1."), + }, + Extra: []dns.RR{ + test.A("a.server1.prod.region1.skydns.test. 300 IN A 10.0.0.1"), + test.A("b.server1.prod.region1.skydns.test. 300 IN A 10.0.0.2"), + test.AAAA("b.server6.prod.region1.skydns.test. 300 IN AAAA ::1"), + test.A("c.sub.region1.skydns.test. 300 IN A 10.0.0.1"), + }, + }, + // Wildcard Test + { + Qname: "prod.*.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + + test.SRV("prod.*.skydns.test. 300 IN SRV 10 25 0 unresolvable.skydns.test."), + test.SRV("prod.*.skydns.test. 300 IN SRV 10 25 8080 a.server1.prod.region1.skydns.test."), + test.SRV("prod.*.skydns.test. 300 IN SRV 10 25 8080 b.server1.prod.region1.skydns.test."), + test.SRV("prod.*.skydns.test. 300 IN SRV 10 25 8080 b.server6.prod.region1.skydns.test."), + }, + Extra: []dns.RR{ + test.A("a.server1.prod.region1.skydns.test. 300 IN A 10.0.0.1"), + test.A("b.server1.prod.region1.skydns.test. 300 IN A 10.0.0.2"), + test.AAAA("b.server6.prod.region1.skydns.test. 300 IN AAAA ::1"), + }, + }, + // Wildcard Test + { + Qname: "prod.any.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + test.SRV("prod.any.skydns.test. 300 IN SRV 10 25 0 unresolvable.skydns.test."), + test.SRV("prod.any.skydns.test. 300 IN SRV 10 25 8080 a.server1.prod.region1.skydns.test."), + test.SRV("prod.any.skydns.test. 300 IN SRV 10 25 8080 b.server1.prod.region1.skydns.test."), + test.SRV("prod.any.skydns.test. 300 IN SRV 10 25 8080 b.server6.prod.region1.skydns.test."), + }, + Extra: []dns.RR{ + test.A("a.server1.prod.region1.skydns.test. 300 IN A 10.0.0.1"), + test.A("b.server1.prod.region1.skydns.test. 300 IN A 10.0.0.2"), + test.AAAA("b.server6.prod.region1.skydns.test. 300 IN AAAA ::1"), + }, + }, + // CNAME loop detection + { + Qname: "a.cname.skydns.test.", Qtype: dns.TypeA, + Ns: []dns.RR{test.SOA("skydns.test. 300 SOA ns.dns.skydns.test. hostmaster.skydns.test. 1407441600 28800 7200 604800 60")}, + }, + // NODATA Test + { + Qname: "a.server1.dev.region1.skydns.test.", Qtype: dns.TypeTXT, + Ns: []dns.RR{test.SOA("skydns.test. 300 SOA ns.dns.skydns.test. hostmaster.skydns.test. 0 0 0 0 0")}, + }, + // NODATA Test + { + Qname: "a.server1.dev.region1.skydns.test.", Qtype: dns.TypeHINFO, + Ns: []dns.RR{test.SOA("skydns.test. 300 SOA ns.dns.skydns.test. hostmaster.skydns.test. 0 0 0 0 0")}, + }, + // NXDOMAIN Test + { + Qname: "a.server1.nonexistent.region1.skydns.test.", Qtype: dns.TypeHINFO, Rcode: dns.RcodeNameError, + Ns: []dns.RR{test.SOA("skydns.test. 300 SOA ns.dns.skydns.test. hostmaster.skydns.test. 0 0 0 0 0")}, + }, + { + Qname: "skydns.test.", Qtype: dns.TypeSOA, + Answer: []dns.RR{test.SOA("skydns.test. 300 IN SOA ns.dns.skydns.test. hostmaster.skydns.test. 1460498836 14400 3600 604800 60")}, + }, + // NS Record Test + { + Qname: "skydns.test.", Qtype: dns.TypeNS, + Answer: []dns.RR{ + test.NS("skydns.test. 300 NS a.ns.dns.skydns.test."), + test.NS("skydns.test. 300 NS b.ns.dns.skydns.test."), + }, + Extra: []dns.RR{ + test.A("a.ns.dns.skydns.test. 300 A 10.0.0.2"), + test.A("b.ns.dns.skydns.test. 300 A 10.0.0.3"), + }, + }, + // NS Record Test + { + Qname: "a.skydns.test.", Qtype: dns.TypeNS, Rcode: dns.RcodeNameError, + Ns: []dns.RR{test.SOA("skydns.test. 300 IN SOA ns.dns.skydns.test. hostmaster.skydns.test. 1460498836 14400 3600 604800 60")}, + }, + // A Record For NS Record Test + { + Qname: "ns.dns.skydns.test.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("ns.dns.skydns.test. 300 A 10.0.0.2"), + test.A("ns.dns.skydns.test. 300 A 10.0.0.3"), + }, + }, + { + Qname: "skydns_extra.test.", Qtype: dns.TypeSOA, + Answer: []dns.RR{test.SOA("skydns_extra.test. 300 IN SOA ns.dns.skydns_extra.test. hostmaster.skydns_extra.test. 1460498836 14400 3600 604800 60")}, + }, + // Reverse lookup + { + Qname: "1.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{test.PTR("1.0.0.10.in-addr.arpa. 300 PTR reverse.example.com.")}, + }, +} + +func newEtcdMiddleware() *Etcd { + ctxt = context.TODO() + + endpoints := []string{"http://localhost:2379"} + tlsc, _ := tls.NewTLSConfigFromArgs() + client, _ := newEtcdClient(endpoints, tlsc) + + return &Etcd{ + Proxy: proxy.NewLookup([]string{"8.8.8.8:53"}), + PathPrefix: "skydns", + Ctx: context.Background(), + Inflight: &singleflight.Group{}, + Zones: []string{"skydns.test.", "skydns_extra.test.", "in-addr.arpa."}, + Client: client, + } +} + +func set(t *testing.T, e *Etcd, k string, ttl time.Duration, m *msg.Service) { + b, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + path, _ := msg.PathWithWildcard(k, e.PathPrefix) + e.Client.Set(ctxt, path, string(b), &etcdc.SetOptions{TTL: ttl}) +} + +func delete(t *testing.T, e *Etcd, k string) { + path, _ := msg.PathWithWildcard(k, e.PathPrefix) + e.Client.Delete(ctxt, path, &etcdc.DeleteOptions{Recursive: false}) +} + +func TestLookup(t *testing.T) { + etc := newEtcdMiddleware() + for _, serv := range services { + set(t, etc, serv.Key, 0, serv) + defer delete(t, etc, serv.Key) + } + + for _, tc := range dnsTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + etc.ServeDNS(ctxt, rec, m) + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +var ctxt context.Context diff --git a/plugin/etcd/msg/path.go b/plugin/etcd/msg/path.go new file mode 100644 index 000000000..2abdec0fe --- /dev/null +++ b/plugin/etcd/msg/path.go @@ -0,0 +1,48 @@ +package msg + +import ( + "path" + "strings" + + "github.com/coredns/coredns/plugin/pkg/dnsutil" + + "github.com/miekg/dns" +) + +// Path converts a domainname to an etcd path. If s looks like service.staging.skydns.local., +// the resulting key will be /skydns/local/skydns/staging/service . +func Path(s, prefix string) string { + l := dns.SplitDomainName(s) + for i, j := 0, len(l)-1; i < j; i, j = i+1, j-1 { + l[i], l[j] = l[j], l[i] + } + return path.Join(append([]string{"/" + prefix + "/"}, l...)...) +} + +// Domain is the opposite of Path. +func Domain(s string) string { + l := strings.Split(s, "/") + // start with 1, to strip /skydns + for i, j := 1, len(l)-1; i < j; i, j = i+1, j-1 { + l[i], l[j] = l[j], l[i] + } + return dnsutil.Join(l[1 : len(l)-1]) +} + +// PathWithWildcard ascts as Path, but if a name contains wildcards (* or any), the name will be +// chopped of before the (first) wildcard, and we do a highler evel search and +// later find the matching names. So service.*.skydns.local, will look for all +// services under skydns.local and will later check for names that match +// service.*.skydns.local. If a wildcard is found the returned bool is true. +func PathWithWildcard(s, prefix string) (string, bool) { + l := dns.SplitDomainName(s) + for i, j := 0, len(l)-1; i < j; i, j = i+1, j-1 { + l[i], l[j] = l[j], l[i] + } + for i, k := range l { + if k == "*" || k == "any" { + return path.Join(append([]string{"/" + prefix + "/"}, l[:i]...)...), true + } + } + return path.Join(append([]string{"/" + prefix + "/"}, l...)...), false +} diff --git a/plugin/etcd/msg/path_test.go b/plugin/etcd/msg/path_test.go new file mode 100644 index 000000000..a9ec59713 --- /dev/null +++ b/plugin/etcd/msg/path_test.go @@ -0,0 +1,12 @@ +package msg + +import "testing" + +func TestPath(t *testing.T) { + for _, path := range []string{"mydns", "skydns"} { + result := Path("service.staging.skydns.local.", path) + if result != "/"+path+"/local/skydns/staging/service" { + t.Errorf("Failure to get domain's path with prefix: %s", result) + } + } +} diff --git a/plugin/etcd/msg/service.go b/plugin/etcd/msg/service.go new file mode 100644 index 000000000..9250cb634 --- /dev/null +++ b/plugin/etcd/msg/service.go @@ -0,0 +1,203 @@ +// Package msg defines the Service structure which is used for service discovery. +package msg + +import ( + "fmt" + "net" + "strings" + + "github.com/miekg/dns" +) + +// Service defines a discoverable service in etcd. It is the rdata from a SRV +// record, but with a twist. Host (Target in SRV) must be a domain name, but +// if it looks like an IP address (4/6), we will treat it like an IP address. +type Service struct { + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Priority int `json:"priority,omitempty"` + Weight int `json:"weight,omitempty"` + Text string `json:"text,omitempty"` + Mail bool `json:"mail,omitempty"` // Be an MX record. Priority becomes Preference. + TTL uint32 `json:"ttl,omitempty"` + + // When a SRV record with a "Host: IP-address" is added, we synthesize + // a srv.Target domain name. Normally we convert the full Key where + // the record lives to a DNS name and use this as the srv.Target. When + // TargetStrip > 0 we strip the left most TargetStrip labels from the + // DNS name. + TargetStrip int `json:"targetstrip,omitempty"` + + // Group is used to group (or *not* to group) different services + // together. Services with an identical Group are returned in the same + // answer. + Group string `json:"group,omitempty"` + + // Etcd key where we found this service and ignored from json un-/marshalling + Key string `json:"-"` +} + +// RR returns an RR representation of s. It is in a condensed form to minimize space +// when this is returned in a DNS message. +// The RR will look like: +// 1.rails.production.east.skydns.local. 300 CH TXT "service1.example.com:8080(10,0,,false)[0,]" +// etcd Key Ttl Host:Port < see below > +// between parens: (Priority, Weight, Text (only first 200 bytes!), Mail) +// between blockquotes: [TargetStrip,Group] +// If the record is synthesised by CoreDNS (i.e. no lookup in etcd happened): +// +// TODO(miek): what to put here? +// +func (s *Service) RR() *dns.TXT { + l := len(s.Text) + if l > 200 { + l = 200 + } + t := new(dns.TXT) + t.Hdr.Class = dns.ClassCHAOS + t.Hdr.Ttl = s.TTL + t.Hdr.Rrtype = dns.TypeTXT + t.Hdr.Name = Domain(s.Key) + + t.Txt = make([]string, 1) + t.Txt[0] = fmt.Sprintf("%s:%d(%d,%d,%s,%t)[%d,%s]", + s.Host, s.Port, + s.Priority, s.Weight, s.Text[:l], s.Mail, + s.TargetStrip, s.Group) + return t +} + +// NewSRV returns a new SRV record based on the Service. +func (s *Service) NewSRV(name string, weight uint16) *dns.SRV { + host := targetStrip(dns.Fqdn(s.Host), s.TargetStrip) + + return &dns.SRV{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: s.TTL}, + Priority: uint16(s.Priority), Weight: weight, Port: uint16(s.Port), Target: dns.Fqdn(host)} +} + +// NewMX returns a new MX record based on the Service. +func (s *Service) NewMX(name string) *dns.MX { + host := targetStrip(dns.Fqdn(s.Host), s.TargetStrip) + + return &dns.MX{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: s.TTL}, + Preference: uint16(s.Priority), Mx: host} +} + +// NewA returns a new A record based on the Service. +func (s *Service) NewA(name string, ip net.IP) *dns.A { + return &dns.A{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: s.TTL}, A: ip} +} + +// NewAAAA returns a new AAAA record based on the Service. +func (s *Service) NewAAAA(name string, ip net.IP) *dns.AAAA { + return &dns.AAAA{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: s.TTL}, AAAA: ip} +} + +// NewCNAME returns a new CNAME record based on the Service. +func (s *Service) NewCNAME(name string, target string) *dns.CNAME { + return &dns.CNAME{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: s.TTL}, Target: dns.Fqdn(target)} +} + +// NewTXT returns a new TXT record based on the Service. +func (s *Service) NewTXT(name string) *dns.TXT { + return &dns.TXT{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: s.TTL}, Txt: split255(s.Text)} +} + +// NewPTR returns a new PTR record based on the Service. +func (s *Service) NewPTR(name string, target string) *dns.PTR { + return &dns.PTR{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: s.TTL}, Ptr: dns.Fqdn(target)} +} + +// NewNS returns a new NS record based on the Service. +func (s *Service) NewNS(name string) *dns.NS { + host := targetStrip(dns.Fqdn(s.Host), s.TargetStrip) + return &dns.NS{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: s.TTL}, Ns: host} +} + +// Group checks the services in sx, it looks for a Group attribute on the shortest +// keys. If there are multiple shortest keys *and* the group attribute disagrees (and +// is not empty), we don't consider it a group. +// If a group is found, only services with *that* group (or no group) will be returned. +func Group(sx []Service) []Service { + if len(sx) == 0 { + return sx + } + + // Shortest key with group attribute sets the group for this set. + group := sx[0].Group + slashes := strings.Count(sx[0].Key, "/") + length := make([]int, len(sx)) + for i, s := range sx { + x := strings.Count(s.Key, "/") + length[i] = x + if x < slashes { + if s.Group == "" { + break + } + slashes = x + group = s.Group + } + } + + if group == "" { + return sx + } + + ret := []Service{} // with slice-tricks in sx we can prolly save this allocation (TODO) + + for i, s := range sx { + if s.Group == "" { + ret = append(ret, s) + continue + } + + // Disagreement on the same level + if length[i] == slashes && s.Group != group { + return sx + } + + if s.Group == group { + ret = append(ret, s) + } + } + return ret +} + +// Split255 splits a string into 255 byte chunks. +func split255(s string) []string { + if len(s) < 255 { + return []string{s} + } + sx := []string{} + p, i := 0, 255 + for { + if i <= len(s) { + sx = append(sx, s[p:i]) + } else { + sx = append(sx, s[p:]) + break + + } + p, i = p+255, i+255 + } + + return sx +} + +// targetStrip strips "targetstrip" labels from the left side of the fully qualified name. +func targetStrip(name string, targetStrip int) string { + if targetStrip == 0 { + return name + } + + offset, end := 0, false + for i := 0; i < targetStrip; i++ { + offset, end = dns.NextLabel(name, offset) + } + if end { + // We overshot the name, use the orignal one. + offset = 0 + } + name = name[offset:] + return name +} diff --git a/plugin/etcd/msg/service_test.go b/plugin/etcd/msg/service_test.go new file mode 100644 index 000000000..0c19ba95b --- /dev/null +++ b/plugin/etcd/msg/service_test.go @@ -0,0 +1,125 @@ +package msg + +import "testing" + +func TestSplit255(t *testing.T) { + xs := split255("abc") + if len(xs) != 1 && xs[0] != "abc" { + t.Errorf("Failure to split abc") + } + s := "" + for i := 0; i < 255; i++ { + s += "a" + } + xs = split255(s) + if len(xs) != 1 && xs[0] != s { + t.Errorf("failure to split 255 char long string") + } + s += "b" + xs = split255(s) + if len(xs) != 2 || xs[1] != "b" { + t.Errorf("failure to split 256 char long string: %d", len(xs)) + } + for i := 0; i < 255; i++ { + s += "a" + } + xs = split255(s) + if len(xs) != 3 || xs[2] != "a" { + t.Errorf("failure to split 510 char long string: %d", len(xs)) + } +} + +func TestGroup(t *testing.T) { + // Key are in the wrong order, but for this test it does not matter. + sx := Group( + []Service{ + {Host: "127.0.0.1", Group: "g1", Key: "b/sub/dom1/skydns/test"}, + {Host: "127.0.0.2", Group: "g2", Key: "a/dom1/skydns/test"}, + }, + ) + // Expecting to return the shortest key with a Group attribute. + if len(sx) != 1 { + t.Fatalf("failure to group zeroth set: %v", sx) + } + if sx[0].Key != "a/dom1/skydns/test" { + t.Fatalf("failure to group zeroth set: %v, wrong Key", sx) + } + + // Groups disagree, so we will not do anything. + sx = Group( + []Service{ + {Host: "server1", Group: "g1", Key: "region1/skydns/test"}, + {Host: "server2", Group: "g2", Key: "region1/skydns/test"}, + }, + ) + if len(sx) != 2 { + t.Fatalf("failure to group first set: %v", sx) + } + + // Group is g1, include only the top-level one. + sx = Group( + []Service{ + {Host: "server1", Group: "g1", Key: "a/dom/region1/skydns/test"}, + {Host: "server2", Group: "g2", Key: "a/subdom/dom/region1/skydns/test"}, + }, + ) + if len(sx) != 1 { + t.Fatalf("failure to group second set: %v", sx) + } + + // Groupless services must be included. + sx = Group( + []Service{ + {Host: "server1", Group: "g1", Key: "a/dom/region1/skydns/test"}, + {Host: "server2", Group: "g2", Key: "a/subdom/dom/region1/skydns/test"}, + {Host: "server2", Group: "", Key: "b/subdom/dom/region1/skydns/test"}, + }, + ) + if len(sx) != 2 { + t.Fatalf("failure to group third set: %v", sx) + } + + // Empty group on the highest level: include that one also. + sx = Group( + []Service{ + {Host: "server1", Group: "g1", Key: "a/dom/region1/skydns/test"}, + {Host: "server1", Group: "", Key: "b/dom/region1/skydns/test"}, + {Host: "server2", Group: "g2", Key: "a/subdom/dom/region1/skydns/test"}, + }, + ) + if len(sx) != 2 { + t.Fatalf("failure to group fourth set: %v", sx) + } + + // Empty group on the highest level: include that one also, and the rest. + sx = Group( + []Service{ + {Host: "server1", Group: "g5", Key: "a/dom/region1/skydns/test"}, + {Host: "server1", Group: "", Key: "b/dom/region1/skydns/test"}, + {Host: "server2", Group: "g5", Key: "a/subdom/dom/region1/skydns/test"}, + }, + ) + if len(sx) != 3 { + t.Fatalf("failure to group fith set: %v", sx) + } + + // One group. + sx = Group( + []Service{ + {Host: "server1", Group: "g6", Key: "a/dom/region1/skydns/test"}, + }, + ) + if len(sx) != 1 { + t.Fatalf("failure to group sixth set: %v", sx) + } + + // No group, once service + sx = Group( + []Service{ + {Host: "server1", Key: "a/dom/region1/skydns/test"}, + }, + ) + if len(sx) != 1 { + t.Fatalf("failure to group seventh set: %v", sx) + } +} diff --git a/plugin/etcd/msg/type.go b/plugin/etcd/msg/type.go new file mode 100644 index 000000000..7f3bfdbb9 --- /dev/null +++ b/plugin/etcd/msg/type.go @@ -0,0 +1,33 @@ +package msg + +import ( + "net" + + "github.com/miekg/dns" +) + +// HostType returns the DNS type of what is encoded in the Service Host field. We're reusing +// dns.TypeXXX to not reinvent a new set of identifiers. +// +// dns.TypeA: the service's Host field contains an A record. +// dns.TypeAAAA: the service's Host field contains an AAAA record. +// dns.TypeCNAME: the service's Host field contains a name. +// +// Note that a service can double/triple as a TXT record or MX record. +func (s *Service) HostType() (what uint16, normalized net.IP) { + + ip := net.ParseIP(s.Host) + + switch { + case ip == nil: + return dns.TypeCNAME, nil + + case ip.To4() != nil: + return dns.TypeA, ip.To4() + + case ip.To4() == nil: + return dns.TypeAAAA, ip.To16() + } + // This should never be reached. + return dns.TypeNone, nil +} diff --git a/plugin/etcd/msg/type_test.go b/plugin/etcd/msg/type_test.go new file mode 100644 index 000000000..bad1eead0 --- /dev/null +++ b/plugin/etcd/msg/type_test.go @@ -0,0 +1,31 @@ +package msg + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestType(t *testing.T) { + tests := []struct { + serv Service + expectedType uint16 + }{ + {Service{Host: "example.org"}, dns.TypeCNAME}, + {Service{Host: "127.0.0.1"}, dns.TypeA}, + {Service{Host: "2000::3"}, dns.TypeAAAA}, + {Service{Host: "2000..3"}, dns.TypeCNAME}, + {Service{Host: "127.0.0.257"}, dns.TypeCNAME}, + {Service{Host: "127.0.0.252", Mail: true}, dns.TypeA}, + {Service{Host: "127.0.0.252", Mail: true, Text: "a"}, dns.TypeA}, + {Service{Host: "127.0.0.254", Mail: false, Text: "a"}, dns.TypeA}, + } + + for i, tc := range tests { + what, _ := tc.serv.HostType() + if what != tc.expectedType { + t.Errorf("Test %d: Expected what %v, but got %v", i, tc.expectedType, what) + } + } + +} diff --git a/plugin/etcd/multi_test.go b/plugin/etcd/multi_test.go new file mode 100644 index 000000000..56b5af265 --- /dev/null +++ b/plugin/etcd/multi_test.go @@ -0,0 +1,59 @@ +// +build etcd + +package etcd + +import ( + "testing" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestMultiLookup(t *testing.T) { + etc := newEtcdMiddleware() + etc.Zones = []string{"skydns.test.", "miek.nl."} + etc.Fallthrough = true + etc.Next = test.ErrorHandler() + + for _, serv := range servicesMulti { + set(t, etc, serv.Key, 0, serv) + defer delete(t, etc, serv.Key) + } + for _, tc := range dnsTestCasesMulti { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := etc.ServeDNS(ctxt, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +// Note the key is encoded as DNS name, while in "reality" it is a etcd path. +var servicesMulti = []*msg.Service{ + {Host: "dev.server1", Port: 8080, Key: "a.server1.dev.region1.skydns.test."}, + {Host: "dev.server1", Port: 8080, Key: "a.server1.dev.region1.miek.nl."}, + {Host: "dev.server1", Port: 8080, Key: "a.server1.dev.region1.example.org."}, +} + +var dnsTestCasesMulti = []test.Case{ + { + Qname: "a.server1.dev.region1.skydns.test.", Qtype: dns.TypeSRV, + Answer: []dns.RR{test.SRV("a.server1.dev.region1.skydns.test. 300 SRV 10 100 8080 dev.server1.")}, + }, + { + Qname: "a.server1.dev.region1.miek.nl.", Qtype: dns.TypeSRV, + Answer: []dns.RR{test.SRV("a.server1.dev.region1.miek.nl. 300 SRV 10 100 8080 dev.server1.")}, + }, + { + Qname: "a.server1.dev.region1.example.org.", Qtype: dns.TypeSRV, Rcode: dns.RcodeServerFailure, + }, +} diff --git a/plugin/etcd/other_test.go b/plugin/etcd/other_test.go new file mode 100644 index 000000000..d28c33537 --- /dev/null +++ b/plugin/etcd/other_test.go @@ -0,0 +1,150 @@ +// +build etcd + +// tests mx and txt records + +package etcd + +import ( + "fmt" + "strings" + "testing" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestOtherLookup(t *testing.T) { + etc := newEtcdMiddleware() + + for _, serv := range servicesOther { + set(t, etc, serv.Key, 0, serv) + defer delete(t, etc, serv.Key) + } + for _, tc := range dnsTestCasesOther { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := etc.ServeDNS(ctxt, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + continue + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +// Note the key is encoded as DNS name, while in "reality" it is a etcd path. +var servicesOther = []*msg.Service{ + {Host: "dev.server1", Port: 8080, Key: "a.server1.dev.region1.skydns.test."}, + + // mx + {Host: "mx.skydns.test", Priority: 50, Mail: true, Key: "a.mail.skydns.test."}, + {Host: "mx.miek.nl", Priority: 50, Mail: true, Key: "b.mail.skydns.test."}, + {Host: "a.ipaddr.skydns.test", Priority: 30, Mail: true, Key: "a.mx.skydns.test."}, + + {Host: "a.ipaddr.skydns.test", Mail: true, Key: "a.mx2.skydns.test."}, + {Host: "b.ipaddr.skydns.test", Mail: true, Key: "b.mx2.skydns.test."}, + + {Host: "a.ipaddr.skydns.test", Priority: 20, Mail: true, Key: "a.mx3.skydns.test."}, + {Host: "a.ipaddr.skydns.test", Priority: 30, Mail: true, Key: "b.mx3.skydns.test."}, + + {Host: "172.16.1.1", Key: "a.ipaddr.skydns.test."}, + {Host: "172.16.1.2", Key: "b.ipaddr.skydns.test."}, + + // txt + {Text: "abc", Key: "a1.txt.skydns.test."}, + {Text: "abc abc", Key: "a2.txt.skydns.test."}, + // txt sizes + {Text: strings.Repeat("0", 400), Key: "large400.skydns.test."}, + {Text: strings.Repeat("0", 600), Key: "large600.skydns.test."}, + {Text: strings.Repeat("0", 2000), Key: "large2000.skydns.test."}, + + // duplicate ip address + {Host: "10.11.11.10", Key: "http.multiport.http.skydns.test.", Port: 80}, + {Host: "10.11.11.10", Key: "https.multiport.http.skydns.test.", Port: 443}, +} + +var dnsTestCasesOther = []test.Case{ + // MX Tests + { + // NODATA as this is not an Mail: true record. + Qname: "a.server1.dev.region1.skydns.test.", Qtype: dns.TypeMX, + Ns: []dns.RR{ + test.SOA("skydns.test. 300 SOA ns.dns.skydns.test. hostmaster.skydns.test. 0 0 0 0 0"), + }, + }, + { + Qname: "a.mail.skydns.test.", Qtype: dns.TypeMX, + Answer: []dns.RR{test.MX("a.mail.skydns.test. 300 IN MX 50 mx.skydns.test.")}, + Extra: []dns.RR{ + test.A("a.ipaddr.skydns.test. 300 IN A 172.16.1.1"), + test.CNAME("mx.skydns.test. 300 IN CNAME a.ipaddr.skydns.test."), + }, + }, + { + Qname: "mx2.skydns.test.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("mx2.skydns.test. 300 IN MX 10 a.ipaddr.skydns.test."), + test.MX("mx2.skydns.test. 300 IN MX 10 b.ipaddr.skydns.test."), + }, + Extra: []dns.RR{ + test.A("a.ipaddr.skydns.test. 300 A 172.16.1.1"), + test.A("b.ipaddr.skydns.test. 300 A 172.16.1.2"), + }, + }, + // different priority, same host + { + Qname: "mx3.skydns.test.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("mx3.skydns.test. 300 IN MX 20 a.ipaddr.skydns.test."), + test.MX("mx3.skydns.test. 300 IN MX 30 a.ipaddr.skydns.test."), + }, + Extra: []dns.RR{ + test.A("a.ipaddr.skydns.test. 300 A 172.16.1.1"), + }, + }, + // Txt + { + Qname: "a1.txt.skydns.test.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT("a1.txt.skydns.test. 300 IN TXT \"abc\""), + }, + }, + { + Qname: "a2.txt.skydns.test.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT("a2.txt.skydns.test. 300 IN TXT \"abc abc\""), + }, + }, + // Large txt less than 512 + { + Qname: "large400.skydns.test.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(fmt.Sprintf("large400.skydns.test. 300 IN TXT \"%s\"", strings.Repeat("0", 400))), + }, + }, + // Large txt greater than 512 (UDP) + { + Qname: "large600.skydns.test.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(fmt.Sprintf("large600.skydns.test. 300 IN TXT \"%s\"", strings.Repeat("0", 600))), + }, + }, + // Large txt greater than 1500 (typical Ethernet) + { + Qname: "large2000.skydns.test.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(fmt.Sprintf("large2000.skydns.test. 300 IN TXT \"%s\"", strings.Repeat("0", 2000))), + }, + }, + // Duplicate IP address test + { + Qname: "multiport.http.skydns.test.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A("multiport.http.skydns.test. 300 IN A 10.11.11.10")}, + }, +} diff --git a/plugin/etcd/setup.go b/plugin/etcd/setup.go new file mode 100644 index 000000000..415feb2ef --- /dev/null +++ b/plugin/etcd/setup.go @@ -0,0 +1,144 @@ +package etcd + +import ( + "crypto/tls" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/pkg/singleflight" + mwtls "github.com/coredns/coredns/plugin/pkg/tls" + "github.com/coredns/coredns/plugin/proxy" + + etcdc "github.com/coreos/etcd/client" + "github.com/mholt/caddy" + "golang.org/x/net/context" +) + +func init() { + caddy.RegisterPlugin("etcd", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + e, stubzones, err := etcdParse(c) + if err != nil { + return plugin.Error("etcd", err) + } + + if stubzones { + c.OnStartup(func() error { + e.UpdateStubZones() + return nil + }) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + e.Next = next + return e + }) + + return nil +} + +func etcdParse(c *caddy.Controller) (*Etcd, bool, error) { + stub := make(map[string]proxy.Proxy) + etc := Etcd{ + // Don't default to a proxy for lookups. + // Proxy: proxy.NewLookup([]string{"8.8.8.8:53", "8.8.4.4:53"}), + PathPrefix: "skydns", + Ctx: context.Background(), + Inflight: &singleflight.Group{}, + Stubmap: &stub, + } + var ( + tlsConfig *tls.Config + err error + endpoints = []string{defaultEndpoint} + stubzones = false + ) + for c.Next() { + etc.Zones = c.RemainingArgs() + if len(etc.Zones) == 0 { + etc.Zones = make([]string, len(c.ServerBlockKeys)) + copy(etc.Zones, c.ServerBlockKeys) + } + for i, str := range etc.Zones { + etc.Zones[i] = plugin.Host(str).Normalize() + } + + if c.NextBlock() { + for { + switch c.Val() { + case "stubzones": + stubzones = true + case "fallthrough": + etc.Fallthrough = true + case "debug": + /* it is a noop now */ + case "path": + if !c.NextArg() { + return &Etcd{}, false, c.ArgErr() + } + etc.PathPrefix = c.Val() + case "endpoint": + args := c.RemainingArgs() + if len(args) == 0 { + return &Etcd{}, false, c.ArgErr() + } + endpoints = args + case "upstream": + args := c.RemainingArgs() + if len(args) == 0 { + return &Etcd{}, false, c.ArgErr() + } + ups, err := dnsutil.ParseHostPortOrFile(args...) + if err != nil { + return &Etcd{}, false, err + } + etc.Proxy = proxy.NewLookup(ups) + case "tls": // cert key cacertfile + args := c.RemainingArgs() + tlsConfig, err = mwtls.NewTLSConfigFromArgs(args...) + if err != nil { + return &Etcd{}, false, err + } + default: + if c.Val() != "}" { + return &Etcd{}, false, c.Errf("unknown property '%s'", c.Val()) + } + } + + if !c.Next() { + break + } + } + + } + client, err := newEtcdClient(endpoints, tlsConfig) + if err != nil { + return &Etcd{}, false, err + } + etc.Client = client + etc.endpoints = endpoints + + return &etc, stubzones, nil + } + return &Etcd{}, false, nil +} + +func newEtcdClient(endpoints []string, cc *tls.Config) (etcdc.KeysAPI, error) { + etcdCfg := etcdc.Config{ + Endpoints: endpoints, + Transport: mwtls.NewHTTPSTransport(cc), + } + cli, err := etcdc.New(etcdCfg) + if err != nil { + return nil, err + } + return etcdc.NewKeysAPI(cli), nil +} + +const defaultEndpoint = "http://localhost:2379" diff --git a/plugin/etcd/setup_test.go b/plugin/etcd/setup_test.go new file mode 100644 index 000000000..833e2ba4c --- /dev/null +++ b/plugin/etcd/setup_test.go @@ -0,0 +1,64 @@ +package etcd + +import ( + "strings" + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupEtcd(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedPath string + expectedEndpoint string + expectedErrContent string // substring from the expected error. Empty for positive cases. + }{ + // positive + { + `etcd`, false, "skydns", "http://localhost:2379", "", + }, + { + `etcd skydns.local { + endpoint localhost:300 +} +`, false, "skydns", "localhost:300", "", + }, + // negative + { + `etcd { + endpoints localhost:300 +} +`, true, "", "", "unknown property 'endpoints'", + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + etcd, _ /*stubzones*/, err := etcdParse(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + continue + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + continue + } + } + + if !test.shouldErr && etcd.PathPrefix != test.expectedPath { + t.Errorf("Etcd not correctly set for input %s. Expected: %s, actual: %s", test.input, test.expectedPath, etcd.PathPrefix) + } + if !test.shouldErr && etcd.endpoints[0] != test.expectedEndpoint { // only checks the first + t.Errorf("Etcd not correctly set for input %s. Expected: '%s', actual: '%s'", test.input, test.expectedEndpoint, etcd.endpoints[0]) + } + } +} diff --git a/plugin/etcd/stub.go b/plugin/etcd/stub.go new file mode 100644 index 000000000..d7b9d5036 --- /dev/null +++ b/plugin/etcd/stub.go @@ -0,0 +1,82 @@ +package etcd + +import ( + "log" + "net" + "strconv" + "time" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/proxy" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// UpdateStubZones checks etcd for an update on the stubzones. +func (e *Etcd) UpdateStubZones() { + go func() { + for { + e.updateStubZones() + time.Sleep(15 * time.Second) + } + }() +} + +// Look in .../dns/stub/<zone>/xx for msg.Services. Loop through them +// extract <zone> and add them as forwarders (ip:port-combos) for +// the stub zones. Only numeric (i.e. IP address) hosts are used. +// Only the first zone configured on e is used for the lookup. +func (e *Etcd) updateStubZones() { + zone := e.Zones[0] + + fakeState := request.Request{W: nil, Req: new(dns.Msg)} + fakeState.Req.SetQuestion(stubDomain+"."+zone, dns.TypeA) + + services, err := e.Records(fakeState, false) + if err != nil { + return + } + + stubmap := make(map[string]proxy.Proxy) + // track the nameservers on a per domain basis, but allow a list on the domain. + nameservers := map[string][]string{} + +Services: + for _, serv := range services { + if serv.Port == 0 { + serv.Port = 53 + } + ip := net.ParseIP(serv.Host) + if ip == nil { + log.Printf("[WARNING] Non IP address stub nameserver: %s", serv.Host) + continue + } + + domain := msg.Domain(serv.Key) + labels := dns.SplitDomainName(domain) + + // If the remaining name equals any of the zones we have, we ignore it. + for _, z := range e.Zones { + // Chop of left most label, because that is used as the nameserver place holder + // and drop the right most labels that belong to zone. + // We must *also* chop of dns.stub. which means cutting two more labels. + domain = dnsutil.Join(labels[1 : len(labels)-dns.CountLabel(z)-2]) + if domain == z { + log.Printf("[WARNING] Skipping nameserver for domain we are authoritative for: %s", domain) + continue Services + } + } + nameservers[domain] = append(nameservers[domain], net.JoinHostPort(serv.Host, strconv.Itoa(serv.Port))) + } + + for domain, nss := range nameservers { + stubmap[domain] = proxy.NewLookup(nss) + } + // atomic swap (at least that's what we hope it is) + if len(stubmap) > 0 { + e.Stubmap = &stubmap + } + return +} diff --git a/plugin/etcd/stub_handler.go b/plugin/etcd/stub_handler.go new file mode 100644 index 000000000..6f4a49950 --- /dev/null +++ b/plugin/etcd/stub_handler.go @@ -0,0 +1,86 @@ +package etcd + +import ( + "errors" + "log" + + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Stub wraps an Etcd. We have this type so that it can have a ServeDNS method. +type Stub struct { + *Etcd + Zone string // for what zone (and thus what nameservers are we called) +} + +// ServeDNS implements the plugin.Handler interface. +func (s Stub) ServeDNS(ctx context.Context, w dns.ResponseWriter, req *dns.Msg) (int, error) { + if hasStubEdns0(req) { + log.Printf("[WARNING] Forwarding cycle detected, refusing msg: %s", req.Question[0].Name) + return dns.RcodeRefused, errors.New("stub forward cycle") + } + req = addStubEdns0(req) + proxy, ok := (*s.Etcd.Stubmap)[s.Zone] + if !ok { // somebody made a mistake.. + return dns.RcodeServerFailure, nil + } + + state := request.Request{W: w, Req: req} + m, e := proxy.Forward(state) + if e != nil { + return dns.RcodeServerFailure, e + } + m.RecursionAvailable, m.Compress = true, true + state.SizeAndDo(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +// hasStubEdns0 checks if the message is carrying our special edns0 zero option. +func hasStubEdns0(m *dns.Msg) bool { + option := m.IsEdns0() + if option == nil { + return false + } + for _, o := range option.Option { + if o.Option() == ednsStubCode && len(o.(*dns.EDNS0_LOCAL).Data) == 1 && + o.(*dns.EDNS0_LOCAL).Data[0] == 1 { + return true + } + } + return false +} + +// addStubEdns0 adds our special option to the message's OPT record. +func addStubEdns0(m *dns.Msg) *dns.Msg { + option := m.IsEdns0() + // Add a custom EDNS0 option to the packet, so we can detect loops when 2 stubs are forwarding to each other. + if option != nil { + option.Option = append(option.Option, &dns.EDNS0_LOCAL{Code: ednsStubCode, Data: []byte{1}}) + return m + } + + m.Extra = append(m.Extra, ednsStub) + return m +} + +const ( + ednsStubCode = dns.EDNS0LOCALSTART + 10 + stubDomain = "stub.dns" +) + +var ednsStub = func() *dns.OPT { + o := new(dns.OPT) + o.Hdr.Name = "." + o.Hdr.Rrtype = dns.TypeOPT + o.SetUDPSize(4096) + + e := new(dns.EDNS0_LOCAL) + e.Code = ednsStubCode + e.Data = []byte{1} + o.Option = append(o.Option, e) + return o +}() diff --git a/plugin/etcd/stub_test.go b/plugin/etcd/stub_test.go new file mode 100644 index 000000000..56fd481b7 --- /dev/null +++ b/plugin/etcd/stub_test.go @@ -0,0 +1,88 @@ +// +build etcd + +package etcd + +import ( + "net" + "strconv" + "testing" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func fakeStubServerExampleNet(t *testing.T) (*dns.Server, string) { + server, addr, err := test.UDPServer("127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create a UDP server: %s", err) + } + // add handler for example.net + dns.HandleFunc("example.net.", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Answer = []dns.RR{test.A("example.net. 86400 IN A 93.184.216.34")} + w.WriteMsg(m) + }) + + return server, addr +} + +func TestStubLookup(t *testing.T) { + server, addr := fakeStubServerExampleNet(t) + defer server.Shutdown() + + host, p, _ := net.SplitHostPort(addr) + port, _ := strconv.Atoi(p) + exampleNetStub := &msg.Service{Host: host, Port: port, Key: "a.example.net.stub.dns.skydns.test."} + servicesStub = append(servicesStub, exampleNetStub) + + etc := newEtcdMiddleware() + + for _, serv := range servicesStub { + set(t, etc, serv.Key, 0, serv) + defer delete(t, etc, serv.Key) + } + + etc.updateStubZones() + + for _, tc := range dnsTestCasesStub { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := etc.ServeDNS(ctxt, rec, m) + if err != nil && m.Question[0].Name == "example.org." { + // This is OK, we expect this backend to *not* work. + continue + } + if err != nil { + t.Errorf("expected no error, got %v for %s\n", err, m.Question[0].Name) + } + resp := rec.Msg + if resp == nil { + // etcd not running? + continue + } + + test.SortAndCheck(t, resp, tc) + } +} + +var servicesStub = []*msg.Service{ + // Two tests, ask a question that should return servfail because remote it no accessible + // and one with edns0 option added, that should return refused. + {Host: "127.0.0.1", Port: 666, Key: "b.example.org.stub.dns.skydns.test."}, +} + +var dnsTestCasesStub = []test.Case{ + { + Qname: "example.org.", Qtype: dns.TypeA, Rcode: dns.RcodeServerFailure, + }, + { + Qname: "example.net.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A("example.net. 86400 IN A 93.184.216.34")}, + Extra: []dns.RR{test.OPT(4096, false)}, // This will have an EDNS0 section, because *we* added our local stub forward to detect loops. + }, +} diff --git a/plugin/federation/README.md b/plugin/federation/README.md new file mode 100644 index 000000000..fb3d44e8c --- /dev/null +++ b/plugin/federation/README.md @@ -0,0 +1,43 @@ +# federation + +The *federation* plugin enables +[federated](https://kubernetes.io/docs/tasks/federation/federation-service-discovery/) queries to be +resolved via the kubernetes plugin. + +Enabling *federation* without also having *kubernetes* is a noop. + +## Syntax + +~~~ +federation [ZONES...] { + NAME DOMAIN +~~~ + +* Each **NAME** and **DOMAIN** defines federation membership. One entry for each. A duplicate + **NAME** will silently overwrite any previous value. + +## Examples + +Here we handle all service requests in the `prod` and `stage` federations. + +~~~ txt +. { + kubernetes cluster.local + federation cluster.local { + prod prod.feddomain.com + staging staging.feddomain.com + } +} +~~~ + +Or slightly shorter: + +~~~ txt +cluster.local { + kubernetes + federation { + prod prod.feddomain.com + staging staging.feddomain.com + } +} +~~~ diff --git a/plugin/federation/federation.go b/plugin/federation/federation.go new file mode 100644 index 000000000..c94e8f819 --- /dev/null +++ b/plugin/federation/federation.go @@ -0,0 +1,141 @@ +/* +Package federation implements kubernetes federation. It checks if the qname matches +a possible federation. If this is the case and the captured answer is an NXDOMAIN, +federation is performed. If this is not the case the original answer is returned. + +The federation label is always the 2nd to last once the zone is chopped of. For +instance "nginx.mynamespace.myfederation.svc.example.com" has "myfederation" as +the federation label. For federation to work we do a normal k8s lookup +*without* that label, if that comes back with NXDOMAIN or NODATA(??) we create +a federation record and return that. + +Federation is only useful in conjunction with the kubernetes plugin, without it is a noop. +*/ +package federation + +import ( + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/pkg/nonwriter" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Federation contains the name to zone mapping used for federation in kubernetes. +type Federation struct { + f map[string]string + zones []string + + Next plugin.Handler + Federations Func +} + +// Func needs to be implemented by any plugin that implements +// federation. Right now this is only the kubernetes plugin. +type Func func(state request.Request, fname, fzone string) (msg.Service, error) + +// New returns a new federation. +func New() *Federation { + return &Federation{f: make(map[string]string)} +} + +// ServeDNS implements the plugin.Handle interface. +func (f *Federation) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + if f.Federations == nil { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + + state := request.Request{W: w, Req: r} + zone := plugin.Zones(f.zones).Matches(state.Name()) + if zone == "" { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + + state.Zone = zone + + // Remove the federation label from the qname to see if something exists. + without, label := f.isNameFederation(state.Name(), state.Zone) + if without == "" { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + + qname := r.Question[0].Name + r.Question[0].Name = without + state.Clear() + + // Start the next plugin, but with a nowriter, capture the result, if NXDOMAIN + // perform federation, otherwise just write the result. + nw := nonwriter.New(w) + ret, err := plugin.NextOrFailure(f.Name(), f.Next, ctx, nw, r) + + if !plugin.ClientWrite(ret) { + // something went wrong + r.Question[0].Name = qname + return ret, err + } + + if m := nw.Msg; m.Rcode != dns.RcodeNameError { + // If positive answer we need to substitute the original qname in the answer. + m.Question[0].Name = qname + for _, a := range m.Answer { + a.Header().Name = qname + } + + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + + return dns.RcodeSuccess, nil + } + + // Still here, we've seen NXDOMAIN and need to perform federation. + service, err := f.Federations(state, label, f.f[label]) // state references Req which has updated qname + if err != nil { + r.Question[0].Name = qname + return dns.RcodeServerFailure, err + } + + r.Question[0].Name = qname + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + + m.Answer = []dns.RR{service.NewCNAME(state.QName(), service.Host)} + + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + + return dns.RcodeSuccess, nil +} + +// Name implements the plugin.Handle interface. +func (f *Federation) Name() string { return "federation" } + +// IsNameFederation checks the qname to see if it is a potential federation. The federation +// label is always the 2nd to last once the zone is chopped of. For instance +// "nginx.mynamespace.myfederation.svc.example.com" has "myfederation" as the federation label. +// IsNameFederation returns a new qname with the federation label and the label itself or two +// empty strings if there wasn't a hit. +func (f *Federation) isNameFederation(name, zone string) (string, string) { + base, _ := dnsutil.TrimZone(name, zone) + + // TODO(miek): dns.PrevLabel is better for memory, or dns.Split. + labels := dns.SplitDomainName(base) + ll := len(labels) + if ll < 2 { + return "", "" + } + + fed := labels[ll-2] + + if _, ok := f.f[fed]; ok { + without := dnsutil.Join(labels[:ll-2]) + labels[ll-1] + "." + zone + return without, fed + } + return "", "" +} diff --git a/plugin/federation/federation_test.go b/plugin/federation/federation_test.go new file mode 100644 index 000000000..920f1a340 --- /dev/null +++ b/plugin/federation/federation_test.go @@ -0,0 +1,81 @@ +package federation + +import ( + "testing" + + "github.com/coredns/coredns/plugin/kubernetes" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestIsNameFederation(t *testing.T) { + tests := []struct { + fed string + qname string + expectedZone string + }{ + {"prod", "nginx.mynamespace.prod.svc.example.com.", "nginx.mynamespace.svc.example.com."}, + {"prod", "nginx.mynamespace.staging.svc.example.com.", ""}, + {"prod", "nginx.mynamespace.example.com.", ""}, + {"prod", "example.com.", ""}, + {"prod", "com.", ""}, + } + + fed := New() + for i, tc := range tests { + fed.f[tc.fed] = "test-name" + if x, _ := fed.isNameFederation(tc.qname, "example.com."); x != tc.expectedZone { + t.Errorf("Test %d, failed to get zone, expected %s, got %s", i, tc.expectedZone, x) + } + } +} + +func TestFederationKubernetes(t *testing.T) { + tests := []test.Case{ + { + // service exists so we return the IP address associated with it. + Qname: "svc1.testns.prod.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("svc1.testns.prod.svc.cluster.local. 303 IN A 10.0.0.1"), + }, + }, + { + // service does not exist, do the federation dance. + Qname: "svc0.testns.prod.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.CNAME("svc0.testns.prod.svc.cluster.local. 303 IN CNAME svc0.testns.prod.svc.fd-az.fd-r.federal.example."), + }, + }, + } + + k := kubernetes.New([]string{"cluster.local."}) + k.APIConn = &APIConnFederationTest{} + + fed := New() + fed.zones = []string{"cluster.local."} + fed.Federations = k.Federations + fed.Next = k + fed.f = map[string]string{ + "prod": "federal.example.", + } + + ctx := context.TODO() + for i, tc := range tests { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fed.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Test %d, expected no error, got %v\n", i, err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/federation/kubernetes_api_test.go b/plugin/federation/kubernetes_api_test.go new file mode 100644 index 000000000..48a03666e --- /dev/null +++ b/plugin/federation/kubernetes_api_test.go @@ -0,0 +1,111 @@ +package federation + +import ( + "github.com/coredns/coredns/plugin/kubernetes" + + "k8s.io/client-go/1.5/pkg/api" +) + +type APIConnFederationTest struct{} + +func (APIConnFederationTest) Run() { return } +func (APIConnFederationTest) Stop() error { return nil } + +func (APIConnFederationTest) PodIndex(string) []interface{} { + a := make([]interface{}, 1) + a[0] = &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Namespace: "podns", + }, + Status: api.PodStatus{ + PodIP: "10.240.0.1", // Remote IP set in test.ResponseWriter + }, + } + return a +} + +func (APIConnFederationTest) ServiceList() []*api.Service { + svcs := []*api.Service{ + { + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: "10.0.0.1", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: api.ClusterIPNone, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "external", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ExternalName: "ext.interwebs.test", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + } + return svcs + +} + +func (APIConnFederationTest) EndpointsList() api.EndpointsList { + return api.EndpointsList{ + Items: []api.Endpoints{ + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.1", + Hostname: "ep1a", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + }, + }, + } +} + +func (APIConnFederationTest) GetNodeByName(name string) (api.Node, error) { + return api.Node{ + ObjectMeta: api.ObjectMeta{ + Name: "test.node.foo.bar", + Labels: map[string]string{ + kubernetes.LabelRegion: "fd-r", + kubernetes.LabelZone: "fd-az", + }, + }, + }, nil +} diff --git a/plugin/federation/setup.go b/plugin/federation/setup.go new file mode 100644 index 000000000..72514fe8f --- /dev/null +++ b/plugin/federation/setup.go @@ -0,0 +1,89 @@ +package federation + +import ( + "fmt" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/kubernetes" + "github.com/miekg/dns" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("federation", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + fed, err := federationParse(c) + if err != nil { + return plugin.Error("federation", err) + } + + // Do this in OnStartup, so all plugin has been initialized. + c.OnStartup(func() error { + m := dnsserver.GetConfig(c).Handler("kubernetes") + if m == nil { + return nil + } + if x, ok := m.(*kubernetes.Kubernetes); ok { + fed.Federations = x.Federations + } + return nil + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + fed.Next = next + return fed + }) + + return nil +} + +func federationParse(c *caddy.Controller) (*Federation, error) { + fed := New() + + for c.Next() { + // federation [zones..] + zones := c.RemainingArgs() + origins := []string{} + if len(zones) > 0 { + origins = make([]string, len(zones)) + copy(origins, zones) + } else { + origins = make([]string, len(c.ServerBlockKeys)) + copy(origins, c.ServerBlockKeys) + } + + for c.NextBlock() { + x := c.Val() + switch x { + default: + args := c.RemainingArgs() + if x := len(args); x != 1 { + return fed, fmt.Errorf("need two arguments for federation, got %d", x) + } + + fed.f[x] = dns.Fqdn(args[0]) + } + } + + for i := range origins { + origins[i] = plugin.Host(origins[i]).Normalize() + } + + fed.zones = origins + + if len(fed.f) == 0 { + return fed, fmt.Errorf("at least one name to zone federation expected") + } + + return fed, nil + } + + return fed, nil +} diff --git a/plugin/federation/setup_test.go b/plugin/federation/setup_test.go new file mode 100644 index 000000000..e85b01772 --- /dev/null +++ b/plugin/federation/setup_test.go @@ -0,0 +1,65 @@ +package federation + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestSetup(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedLen int + expectedNameZone []string // contains only entry for now + }{ + // ok + {`federation { + prod prod.example.org + }`, false, 1, []string{"prod", "prod.example.org."}}, + + {`federation { + staging staging.example.org + prod prod.example.org + }`, false, 2, []string{"prod", "prod.example.org."}}, + {`federation { + staging staging.example.org + prod prod.example.org + }`, false, 2, []string{"staging", "staging.example.org."}}, + {`federation example.com { + staging staging.example.org + prod prod.example.org + }`, false, 2, []string{"staging", "staging.example.org."}}, + // errors + {`federation { + }`, true, 0, []string{}}, + {`federation { + staging + }`, true, 0, []string{}}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + fed, err := federationParse(c) + if test.shouldErr && err == nil { + t.Errorf("Test %v: Expected error but found nil", i) + continue + } else if !test.shouldErr && err != nil { + t.Errorf("Test %v: Expected no error but found error: %v", i, err) + continue + } + if test.shouldErr && err != nil { + continue + } + + if x := len(fed.f); x != test.expectedLen { + t.Errorf("Test %v: Expected map length of %d, got: %d", i, test.expectedLen, x) + } + if x, ok := fed.f[test.expectedNameZone[0]]; !ok { + t.Errorf("Test %v: Expected name for %s, got nothing", i, test.expectedNameZone[0]) + } else { + if x != test.expectedNameZone[1] { + t.Errorf("Test %v: Expected zone: %s, got %s", i, test.expectedNameZone[1], x) + } + } + } +} diff --git a/plugin/file/README.md b/plugin/file/README.md new file mode 100644 index 000000000..d7e1590b4 --- /dev/null +++ b/plugin/file/README.md @@ -0,0 +1,55 @@ +# file + +*file* enables serving zone data from an RFC 1035-style master file. + +The file plugin is used for an "old-style" DNS server. It serves from a preloaded file that exists +on disk. If the zone file contains signatures (i.e. is signed, i.e. DNSSEC) correct DNSSEC answers +are returned. Only NSEC is supported! If you use this setup *you* are responsible for resigning the +zonefile. + +## Syntax + +~~~ +file DBFILE [ZONES...] +~~~ + +* **DBFILE** the database file to read and parse. If the path is relative the path from the *root* + directive will be prepended to it. +* **ZONES** zones it should be authoritative for. If empty, the zones from the configuration block + are used. + +If you want to round robin A and AAAA responses look at the *loadbalance* plugin. + +TSIG key configuration is TODO; directive format for transfer will probably be extended with +TSIG key information, something like `transfer out [ADDRESS...] key [NAME[:ALG]] [BASE64]` + +~~~ +file DBFILE [ZONES... ] { + transfer to ADDRESS... + no_reload + upstream ADDRESS... +} +~~~ + +* `transfer` enables zone transfers. It may be specified multiples times. `To` or `from` signals + the direction. **ADDRESS** must be denoted in CIDR notation (127.0.0.1/32 etc.) or just as plain + addresses. The special wildcard `*` means: the entire internet (only valid for 'transfer to'). + When an address is specified a notify message will be send whenever the zone is reloaded. +* `no_reload` by default CoreDNS will reload a zone from disk whenever it detects a change to the + file. This option disables that behavior. +* `upstream` defines upstream resolvers to be used resolve external names found (think CNAMEs) + pointing to external names. This is only really useful when CoreDNS is configured as a proxy, for + normal authoritative serving you don't need *or* want to use this. **ADDRESS** can be an IP + address, and IP:port or a string pointing to a file that is structured as /etc/resolv.conf. + +## Examples + +Load the `example.org` zone from `example.org.signed` and allow transfers to the internet, but send +notifies to 10.240.1.1 + +~~~ +file example.org.signed example.org { + transfer to * + transfer to 10.240.1.1 +} +~~~ diff --git a/plugin/file/closest.go b/plugin/file/closest.go new file mode 100644 index 000000000..64652af83 --- /dev/null +++ b/plugin/file/closest.go @@ -0,0 +1,24 @@ +package file + +import ( + "github.com/coredns/coredns/plugin/file/tree" + + "github.com/miekg/dns" +) + +// ClosestEncloser returns the closest encloser for qname. +func (z *Zone) ClosestEncloser(qname string) (*tree.Elem, bool) { + + offset, end := dns.NextLabel(qname, 0) + for !end { + elem, _ := z.Tree.Search(qname) + if elem != nil { + return elem, true + } + qname = qname[offset:] + + offset, end = dns.NextLabel(qname, offset) + } + + return z.Tree.Search(z.origin) +} diff --git a/plugin/file/closest_test.go b/plugin/file/closest_test.go new file mode 100644 index 000000000..b37495493 --- /dev/null +++ b/plugin/file/closest_test.go @@ -0,0 +1,38 @@ +package file + +import ( + "strings" + "testing" +) + +func TestClosestEncloser(t *testing.T) { + z, err := Parse(strings.NewReader(dbMiekNL), testzone, "stdin", 0) + if err != nil { + t.Fatalf("expect no error when reading zone, got %q", err) + } + + tests := []struct { + in, out string + }{ + {"miek.nl.", "miek.nl."}, + {"www.miek.nl.", "www.miek.nl."}, + + {"blaat.miek.nl.", "miek.nl."}, + {"blaat.www.miek.nl.", "www.miek.nl."}, + {"www.blaat.miek.nl.", "miek.nl."}, + {"blaat.a.miek.nl.", "a.miek.nl."}, + } + + for _, tc := range tests { + ce, _ := z.ClosestEncloser(tc.in) + if ce == nil { + if z.origin != tc.out { + t.Errorf("Expected ce to be %s for %s, got %s", tc.out, tc.in, ce.Name()) + } + continue + } + if ce.Name() != tc.out { + t.Errorf("Expected ce to be %s for %s, got %s", tc.out, tc.in, ce.Name()) + } + } +} diff --git a/plugin/file/cname_test.go b/plugin/file/cname_test.go new file mode 100644 index 000000000..1178a7512 --- /dev/null +++ b/plugin/file/cname_test.go @@ -0,0 +1,124 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/proxy" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestLookupCNAMEChain(t *testing.T) { + name := "example.org." + zone, err := Parse(strings.NewReader(dbExampleCNAME), name, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{name: zone}, Names: []string{name}}} + ctx := context.TODO() + + for _, tc := range cnameTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +var cnameTestCases = []test.Case{ + { + Qname: "a.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("a.example.org. 1800 IN A 127.0.0.1"), + }, + }, + { + Qname: "www3.example.org.", Qtype: dns.TypeCNAME, + Answer: []dns.RR{ + test.CNAME("www3.example.org. 1800 IN CNAME www2.example.org."), + }, + }, + { + Qname: "dangling.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.CNAME("dangling.example.org. 1800 IN CNAME foo.example.org."), + }, + }, + { + Qname: "www3.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("a.example.org. 1800 IN A 127.0.0.1"), + test.CNAME("www.example.org. 1800 IN CNAME a.example.org."), + test.CNAME("www1.example.org. 1800 IN CNAME www.example.org."), + test.CNAME("www2.example.org. 1800 IN CNAME www1.example.org."), + test.CNAME("www3.example.org. 1800 IN CNAME www2.example.org."), + }, + }, +} + +func TestLookupCNAMEExternal(t *testing.T) { + name := "example.org." + zone, err := Parse(strings.NewReader(dbExampleCNAME), name, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + zone.Proxy = proxy.NewLookup([]string{"8.8.8.8:53"}) // TODO(miek): point to local instance + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{name: zone}, Names: []string{name}}} + ctx := context.TODO() + + for _, tc := range exernalTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +var exernalTestCases = []test.Case{ + { + Qname: "external.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.CNAME("external.example.org. 1800 CNAME www.example.net."), + // magic 303 TTL that says: don't check TTL. + test.A("www.example.net. 303 IN A 93.184.216.34"), + }, + }, +} + +const dbExampleCNAME = ` +$TTL 30M +$ORIGIN example.org. +@ IN SOA linode.atoom.net. miek.miek.nl. ( + 1282630057 ; Serial + 4H ; Refresh + 1H ; Retry + 7D ; Expire + 4H ) ; Negative Cache TTL + +a IN A 127.0.0.1 +www3 IN CNAME www2 +www2 IN CNAME www1 +www1 IN CNAME www +www IN CNAME a +dangling IN CNAME foo +external IN CNAME www.example.net.` diff --git a/plugin/file/delegation_test.go b/plugin/file/delegation_test.go new file mode 100644 index 000000000..1ad9804f4 --- /dev/null +++ b/plugin/file/delegation_test.go @@ -0,0 +1,207 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var delegationTestCases = []test.Case{ + { + Qname: "a.delegated.miek.nl.", Qtype: dns.TypeTXT, + Ns: []dns.RR{ + test.NS("delegated.miek.nl. 1800 IN NS a.delegated.miek.nl."), + test.NS("delegated.miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + }, + Extra: []dns.RR{ + test.A("a.delegated.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "delegated.miek.nl.", Qtype: dns.TypeNS, + Answer: []dns.RR{ + test.NS("delegated.miek.nl. 1800 IN NS a.delegated.miek.nl."), + test.NS("delegated.miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + }, + Extra: []dns.RR{ + test.A("a.delegated.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "foo.delegated.miek.nl.", Qtype: dns.TypeA, + Ns: []dns.RR{ + test.NS("delegated.miek.nl. 1800 IN NS a.delegated.miek.nl."), + test.NS("delegated.miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + }, + Extra: []dns.RR{ + test.A("a.delegated.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "foo.delegated.miek.nl.", Qtype: dns.TypeTXT, + Ns: []dns.RR{ + test.NS("delegated.miek.nl. 1800 IN NS a.delegated.miek.nl."), + test.NS("delegated.miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + }, + Extra: []dns.RR{ + test.A("a.delegated.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Ns: miekAuth, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeAAAA, + Ns: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + }, +} + +var secureDelegationTestCases = []test.Case{ + { + Qname: "a.delegated.example.org.", Qtype: dns.TypeTXT, + Do: true, + Ns: []dns.RR{ + test.DS("delegated.example.org. 1800 IN DS 10056 5 1 EE72CABD1927759CDDA92A10DBF431504B9E1F13"), + test.DS("delegated.example.org. 1800 IN DS 10056 5 2 E4B05F87725FA86D9A64F1E53C3D0E6250946599DFE639C45955B0ED416CDDFA"), + test.NS("delegated.example.org. 1800 IN NS a.delegated.example.org."), + test.NS("delegated.example.org. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.RRSIG("delegated.example.org. 1800 IN RRSIG DS 13 3 1800 20161129153240 20161030153240 49035 example.org. rlNNzcUmtbjLSl02ZzQGUbWX75yCUx0Mug1jHtKVqRq1hpPE2S3863tIWSlz+W9wz4o19OI4jbznKKqk+DGKog=="), + }, + Extra: []dns.RR{ + test.OPT(4096, true), + test.A("a.delegated.example.org. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.example.org. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "delegated.example.org.", Qtype: dns.TypeNS, + Do: true, + Answer: []dns.RR{ + test.NS("delegated.example.org. 1800 IN NS a.delegated.example.org."), + test.NS("delegated.example.org. 1800 IN NS ns-ext.nlnetlabs.nl."), + }, + Extra: []dns.RR{ + test.OPT(4096, true), + test.A("a.delegated.example.org. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.example.org. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "foo.delegated.example.org.", Qtype: dns.TypeA, + Do: true, + Ns: []dns.RR{ + test.DS("delegated.example.org. 1800 IN DS 10056 5 1 EE72CABD1927759CDDA92A10DBF431504B9E1F13"), + test.DS("delegated.example.org. 1800 IN DS 10056 5 2 E4B05F87725FA86D9A64F1E53C3D0E6250946599DFE639C45955B0ED416CDDFA"), + test.NS("delegated.example.org. 1800 IN NS a.delegated.example.org."), + test.NS("delegated.example.org. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.RRSIG("delegated.example.org. 1800 IN RRSIG DS 13 3 1800 20161129153240 20161030153240 49035 example.org. rlNNzcUmtbjLSl02ZzQGUbWX75yCUx0Mug1jHtKVqRq1hpPE2S3863tIWSlz+W9wz4o19OI4jbznKKqk+DGKog=="), + }, + Extra: []dns.RR{ + test.OPT(4096, true), + test.A("a.delegated.example.org. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.example.org. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "foo.delegated.example.org.", Qtype: dns.TypeTXT, + Do: true, + Ns: []dns.RR{ + test.DS("delegated.example.org. 1800 IN DS 10056 5 1 EE72CABD1927759CDDA92A10DBF431504B9E1F13"), + test.DS("delegated.example.org. 1800 IN DS 10056 5 2 E4B05F87725FA86D9A64F1E53C3D0E6250946599DFE639C45955B0ED416CDDFA"), + test.NS("delegated.example.org. 1800 IN NS a.delegated.example.org."), + test.NS("delegated.example.org. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.RRSIG("delegated.example.org. 1800 IN RRSIG DS 13 3 1800 20161129153240 20161030153240 49035 example.org. rlNNzcUmtbjLSl02ZzQGUbWX75yCUx0Mug1jHtKVqRq1hpPE2S3863tIWSlz+W9wz4o19OI4jbznKKqk+DGKog=="), + }, + Extra: []dns.RR{ + test.OPT(4096, true), + test.A("a.delegated.example.org. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.example.org. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, +} + +var miekAuth = []dns.RR{ + test.NS("miek.nl. 1800 IN NS ext.ns.whyscream.net."), + test.NS("miek.nl. 1800 IN NS linode.atoom.net."), + test.NS("miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.NS("miek.nl. 1800 IN NS omval.tednet.nl."), +} + +func TestLookupDelegation(t *testing.T) { + testDelegation(t, dbMiekNLDelegation, testzone, delegationTestCases) +} + +func TestLookupSecureDelegation(t *testing.T) { + testDelegation(t, exampleOrgSigned, "example.org.", secureDelegationTestCases) +} + +func testDelegation(t *testing.T, z, origin string, testcases []test.Case) { + zone, err := Parse(strings.NewReader(z), origin, "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{origin: zone}, Names: []string{origin}}} + ctx := context.TODO() + + for _, tc := range testcases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %q\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +const dbMiekNLDelegation = ` +$TTL 30M +$ORIGIN miek.nl. +@ IN SOA linode.atoom.net. miek.miek.nl. ( + 1282630057 ; Serial + 4H ; Refresh + 1H ; Retry + 7D ; Expire + 4H ) ; Negative Cache TTL + IN NS linode.atoom.net. + IN NS ns-ext.nlnetlabs.nl. + IN NS omval.tednet.nl. + IN NS ext.ns.whyscream.net. + + IN MX 1 aspmx.l.google.com. + IN MX 5 alt1.aspmx.l.google.com. + IN MX 5 alt2.aspmx.l.google.com. + IN MX 10 aspmx2.googlemail.com. + IN MX 10 aspmx3.googlemail.com. + +delegated IN NS a.delegated + IN NS ns-ext.nlnetlabs.nl. + +a.delegated IN TXT "obscured" + IN A 139.162.196.78 + IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 + +a IN A 139.162.196.78 + IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 +www IN CNAME a +archive IN CNAME a` diff --git a/plugin/file/dname.go b/plugin/file/dname.go new file mode 100644 index 000000000..f552bfdfd --- /dev/null +++ b/plugin/file/dname.go @@ -0,0 +1,44 @@ +package file + +import ( + "github.com/coredns/coredns/plugin/pkg/dnsutil" + + "github.com/miekg/dns" +) + +// substituteDNAME performs the DNAME substitution defined by RFC 6672, +// assuming the QTYPE of the query is not DNAME. It returns an empty +// string if there is no match. +func substituteDNAME(qname, owner, target string) string { + if dns.IsSubDomain(owner, qname) && qname != owner { + labels := dns.SplitDomainName(qname) + labels = append(labels[0:len(labels)-dns.CountLabel(owner)], dns.SplitDomainName(target)...) + + return dnsutil.Join(labels) + } + + return "" +} + +// synthesizeCNAME returns a CNAME RR pointing to the resulting name of +// the DNAME substitution. The owner name of the CNAME is the QNAME of +// the query and the TTL is the same as the corresponding DNAME RR. +// +// It returns nil if the DNAME substitution has no match. +func synthesizeCNAME(qname string, d *dns.DNAME) *dns.CNAME { + target := substituteDNAME(qname, d.Header().Name, d.Target) + if target == "" { + return nil + } + + r := new(dns.CNAME) + r.Hdr = dns.RR_Header{ + Name: qname, + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: d.Header().Ttl, + } + r.Target = target + + return r +} diff --git a/plugin/file/dname_test.go b/plugin/file/dname_test.go new file mode 100644 index 000000000..92e33dde7 --- /dev/null +++ b/plugin/file/dname_test.go @@ -0,0 +1,300 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// RFC 6672, Section 2.2. Assuming QTYPE != DNAME. +var dnameSubstitutionTestCases = []struct { + qname string + owner string + target string + expected string +}{ + {"com.", "example.com.", "example.net.", ""}, + {"example.com.", "example.com.", "example.net.", ""}, + {"a.example.com.", "example.com.", "example.net.", "a.example.net."}, + {"a.b.example.com.", "example.com.", "example.net.", "a.b.example.net."}, + {"ab.example.com.", "b.example.com.", "example.net.", ""}, + {"foo.example.com.", "example.com.", "example.net.", "foo.example.net."}, + {"a.x.example.com.", "x.example.com.", "example.net.", "a.example.net."}, + {"a.example.com.", "example.com.", "y.example.net.", "a.y.example.net."}, + {"cyc.example.com.", "example.com.", "example.com.", "cyc.example.com."}, + {"cyc.example.com.", "example.com.", "c.example.com.", "cyc.c.example.com."}, + {"shortloop.x.x.", "x.", ".", "shortloop.x."}, + {"shortloop.x.", "x.", ".", "shortloop."}, +} + +func TestDNAMESubstitution(t *testing.T) { + for i, tc := range dnameSubstitutionTestCases { + result := substituteDNAME(tc.qname, tc.owner, tc.target) + if result != tc.expected { + if result == "" { + result = "<no match>" + } + + t.Errorf("Case %d: Expected %s -> %s, got %v", i, tc.qname, tc.expected, result) + return + } + } +} + +var dnameTestCases = []test.Case{ + { + Qname: "dname.miek.nl.", Qtype: dns.TypeDNAME, + Answer: []dns.RR{ + test.DNAME("dname.miek.nl. 1800 IN DNAME test.miek.nl."), + }, + Ns: miekAuth, + }, + { + Qname: "dname.miek.nl.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("dname.miek.nl. 1800 IN A 127.0.0.1"), + }, + Ns: miekAuth, + }, + { + Qname: "dname.miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{}, + Ns: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + }, + { + Qname: "a.dname.miek.nl.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.CNAME("a.dname.miek.nl. 1800 IN CNAME a.test.miek.nl."), + test.A("a.test.miek.nl. 1800 IN A 139.162.196.78"), + test.DNAME("dname.miek.nl. 1800 IN DNAME test.miek.nl."), + }, + Ns: miekAuth, + }, + { + Qname: "www.dname.miek.nl.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("a.test.miek.nl. 1800 IN A 139.162.196.78"), + test.DNAME("dname.miek.nl. 1800 IN DNAME test.miek.nl."), + test.CNAME("www.dname.miek.nl. 1800 IN CNAME www.test.miek.nl."), + test.CNAME("www.test.miek.nl. 1800 IN CNAME a.test.miek.nl."), + }, + Ns: miekAuth, + }, +} + +func TestLookupDNAME(t *testing.T) { + zone, err := Parse(strings.NewReader(dbMiekNLDNAME), testzone, "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}} + ctx := context.TODO() + + for _, tc := range dnameTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +var dnameDnssecTestCases = []test.Case{ + { + // We have no auth section, because the test zone does not have nameservers. + Qname: "ns.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("ns.example.org. 1800 IN A 127.0.0.1"), + }, + }, + { + Qname: "dname.example.org.", Qtype: dns.TypeDNAME, + Do: true, + Answer: []dns.RR{ + test.DNAME("dname.example.org. 1800 IN DNAME test.example.org."), + test.RRSIG("dname.example.org. 1800 IN RRSIG DNAME 5 3 1800 20170702091734 20170602091734 54282 example.org. HvXtiBM="), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "a.dname.example.org.", Qtype: dns.TypeA, + Do: true, + Answer: []dns.RR{ + test.CNAME("a.dname.example.org. 1800 IN CNAME a.test.example.org."), + test.DNAME("dname.example.org. 1800 IN DNAME test.example.org."), + test.RRSIG("dname.example.org. 1800 IN RRSIG DNAME 5 3 1800 20170702091734 20170602091734 54282 example.org. HvXtiBM="), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, +} + +func TestLookupDNAMEDNSSEC(t *testing.T) { + zone, err := Parse(strings.NewReader(dbExampleDNAMESigned), testzone, "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{"example.org.": zone}, Names: []string{"example.org."}}} + ctx := context.TODO() + + for _, tc := range dnameDnssecTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +const dbMiekNLDNAME = ` +$TTL 30M +$ORIGIN miek.nl. +@ IN SOA linode.atoom.net. miek.miek.nl. ( + 1282630057 ; Serial + 4H ; Refresh + 1H ; Retry + 7D ; Expire + 4H ) ; Negative Cache TTL + IN NS linode.atoom.net. + IN NS ns-ext.nlnetlabs.nl. + IN NS omval.tednet.nl. + IN NS ext.ns.whyscream.net. + +test IN MX 1 aspmx.l.google.com. + IN MX 5 alt1.aspmx.l.google.com. + IN MX 5 alt2.aspmx.l.google.com. + IN MX 10 aspmx2.googlemail.com. + IN MX 10 aspmx3.googlemail.com. +a.test IN A 139.162.196.78 + IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 +www.test IN CNAME a.test + +dname IN DNAME test +dname IN A 127.0.0.1 +a.dname IN A 127.0.0.1 +` + +const dbExampleDNAMESigned = ` +; File written on Fri Jun 2 10:17:34 2017 +; dnssec_signzone version 9.10.3-P4-Debian +example.org. 1800 IN SOA a.example.org. b.example.org. ( + 1282630057 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 14400 ; minimum (4 hours) + ) + 1800 RRSIG SOA 5 2 1800 ( + 20170702091734 20170602091734 54282 example.org. + mr5eQtFs1GubgwaCcqrpiF6Cgi822OkESPeV + X0OJYq3JzthJjHw8TfYAJWQ2yGqhlePHir9h + FT/uFZdYyytHq+qgIUbJ9IVCrq0gZISZdHML + Ry1DNffMR9CpD77KocOAUABfopcvH/3UGOHn + TFxkAr447zPaaoC68JYGxYLfZk8= ) + 1800 NS ns.example.org. + 1800 RRSIG NS 5 2 1800 ( + 20170702091734 20170602091734 54282 example.org. + McM4UdMxkscVQkJnnEbdqwyjpPgq5a/EuOLA + r2MvG43/cwOaWULiZoNzLi5Rjzhf+GTeVTan + jw6EsL3gEuYI1nznwlLQ04/G0XAHjbq5VvJc + rlscBD+dzf774yfaTjRNoeo2xTem6S7nyYPW + Y+1f6xkrsQPLYJfZ6VZ9QqyupBw= ) + 14400 NSEC dname.example.org. NS SOA RRSIG NSEC DNSKEY + 14400 RRSIG NSEC 5 2 14400 ( + 20170702091734 20170602091734 54282 example.org. + VT+IbjDFajM0doMKFipdX3+UXfCn3iHIxg5x + LElp4Q/YddTbX+6tZf53+EO+G8Kye3JDLwEl + o8VceijNeF3igZ+LiZuXCei5Qg/TJ7IAUnAO + xd85IWwEYwyKkKd6Z2kXbAN2pdcHE8EmboQd + wfTr9oyWhpZk1Z+pN8vdejPrG0M= ) + 1800 DNSKEY 256 3 5 ( + AwEAAczLlmTk5bMXUzpBo/Jta6MWSZYy3Nfw + gz8t/pkfSh4IlFF6vyXZhEqCeQsCBdD7ltkD + h5qd4A+nFrYOMwsi5XIjoHMlJN15xwFS9EgS + ZrZmuxePIEiYB5KccEf9JQMgM1t07Iu1FnrY + 02OuAqGWcO4tuyTLaK3QP4MLQOfAgKqf + ) ; ZSK; alg = RSASHA1; key id = 54282 + 1800 RRSIG DNSKEY 5 2 1800 ( + 20170702091734 20170602091734 54282 example.org. + MBgSRtZ6idJblLIHxZWpWL/1oqIwImb1mkl7 + hDFxqV6Hw19yLX06P7gcJEWiisdZBkVEfcOK + LeMJly05vgKfrMzLgIu2Ry4bL8AMKc8NMXBG + b1VDCEBW69P2omogj2KnORHDCZQr/BX9+wBU + 5rIMTTKlMSI5sT6ecJHHEymtiac= ) +dname.example.org. 1800 IN A 127.0.0.1 + 1800 RRSIG A 5 3 1800 ( + 20170702091734 20170602091734 54282 example.org. + LPCK2nLyDdGwvmzGLkUO2atEUjoc+aEspkC3 + keZCdXZaLnAwBH7dNAjvvXzzy0WrgWeiyDb4 + +rJ2N0oaKEZicM4QQDHKhugJblKbU5G4qTey + LSEaV3vvQnzGd0S6dCqnwfPj9czagFN7Zlf5 + DmLtdxx0aiDPCUpqT0+H/vuGPfk= ) + 1800 DNAME test.example.org. + 1800 RRSIG DNAME 5 3 1800 ( + 20170702091734 20170602091734 54282 example.org. + HvX79T1flWJ8H9/1XZjX6gz8rP/o2jbfPXJ9 + vC7ids/ZJilSReabLru4DCqcw1IV2DM/CZdE + tBnED/T2PJXvMut9tnYMrz+ZFPxoV6XyA3Z7 + bok3B0OuxizzAN2EXdol04VdbMHoWUzjQCzi + 0Ri12zLGRPzDepZ7FolgD+JtiBM= ) + 14400 NSEC a.dname.example.org. A DNAME RRSIG NSEC + 14400 RRSIG NSEC 5 3 14400 ( + 20170702091734 20170602091734 54282 example.org. + U3ZPYMUBJl3wF2SazQv/kBf6ec0CH+7n0Hr9 + w6lBKkiXz7P9WQzJDVnTHEZOrbDI6UetFGyC + 6qcaADCASZ9Wxc+riyK1Hl4ox+Y/CHJ97WHy + oS2X//vEf6qmbHQXin0WQtFdU/VCRYF40X5v + 8VfqOmrr8iKiEqXND8XNVf58mTw= ) +a.dname.example.org. 1800 IN A 127.0.0.1 + 1800 RRSIG A 5 4 1800 ( + 20170702091734 20170602091734 54282 example.org. + y7RHBWZwli8SJQ4BgTmdXmYS3KGHZ7AitJCx + zXFksMQtNoOfVEQBwnFqjAb8ezcV5u92h1gN + i1EcuxCFiElML1XFT8dK2GnlPAga9w3oIwd5 + wzW/YHcnR0P9lF56Sl7RoIt6+jJqOdRfixS6 + TDoLoXsNbOxQ+qV3B8pU2Tam204= ) + 14400 NSEC ns.example.org. A RRSIG NSEC + 14400 RRSIG NSEC 5 4 14400 ( + 20170702091734 20170602091734 54282 example.org. + Tmu27q3+xfONSZZtZLhejBUVtEw+83ZU1AFb + Rsxctjry/x5r2JSxw/sgSAExxX/7tx/okZ8J + oJqtChpsr91Kiw3eEBgINi2lCYIpMJlW4cWz + 8bYlHfR81VsKYgy/cRgrq1RRvBoJnw+nwSty + mKPIvUtt67LAvLxJheSCEMZLCKI= ) +ns.example.org. 1800 IN A 127.0.0.1 + 1800 RRSIG A 5 3 1800 ( + 20170702091734 20170602091734 54282 example.org. + mhi1SGaaAt+ndQEg5uKWKCH0HMzaqh/9dUK3 + p2wWMBrLbTZrcWyz10zRnvehicXDCasbBrer + ZpDQnz5AgxYYBURvdPfUzx1XbNuRJRE4l5PN + CEUTlTWcqCXnlSoPKEJE5HRf7v0xg2BrBUfM + 4mZnW2bFLwjrRQ5mm/mAmHmTROk= ) + 14400 NSEC example.org. A RRSIG NSEC + 14400 RRSIG NSEC 5 3 14400 ( + 20170702091734 20170602091734 54282 example.org. + loHcdjX+NIWLAkUDfPSy2371wrfUvrBQTfMO + 17eO2Y9E/6PE935NF5bjQtZBRRghyxzrFJhm + vY1Ad5ZTb+NLHvdSWbJQJog+eCc7QWp64WzR + RXpMdvaE6ZDwalWldLjC3h8QDywDoFdndoRY + eHOsmTvvtWWqtO6Fa5A8gmHT5HA= ) +` diff --git a/plugin/file/dnssec_test.go b/plugin/file/dnssec_test.go new file mode 100644 index 000000000..17b122c7e --- /dev/null +++ b/plugin/file/dnssec_test.go @@ -0,0 +1,358 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var dnssecTestCases = []test.Case{ + { + Qname: "miek.nl.", Qtype: dns.TypeSOA, Do: true, + Answer: []dns.RR{ + test.RRSIG("miek.nl. 1800 IN RRSIG SOA 8 2 1800 20160426031301 20160327031301 12051 miek.nl. FIrzy07acBbtyQczy1dc="), + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Ns: auth, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeAAAA, Do: true, + Answer: []dns.RR{ + test.AAAA("miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + test.RRSIG("miek.nl. 1800 IN RRSIG AAAA 8 2 1800 20160426031301 20160327031301 12051 miek.nl. SsRT="), + }, + Ns: auth, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeNS, Do: true, + Answer: []dns.RR{ + test.NS("miek.nl. 1800 IN NS ext.ns.whyscream.net."), + test.NS("miek.nl. 1800 IN NS linode.atoom.net."), + test.NS("miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.NS("miek.nl. 1800 IN NS omval.tednet.nl."), + test.RRSIG("miek.nl. 1800 IN RRSIG NS 8 2 1800 20160426031301 20160327031301 12051 miek.nl. ZLtsQhwaz+lHfNpztFoR1Vxs="), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeMX, Do: true, + Answer: []dns.RR{ + test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx2.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx3.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 5 alt1.aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 5 alt2.aspmx.l.google.com."), + test.RRSIG("miek.nl. 1800 IN RRSIG MX 8 2 1800 20160426031301 20160327031301 12051 miek.nl. kLqG+iOr="), + }, + Ns: auth, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "www.miek.nl.", Qtype: dns.TypeA, Do: true, + Answer: []dns.RR{ + test.A("a.miek.nl. 1800 IN A 139.162.196.78"), + test.RRSIG("a.miek.nl. 1800 IN RRSIG A 8 3 1800 20160426031301 20160327031301 12051 miek.nl. lxLotCjWZ3kihTxk="), + test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl."), + test.RRSIG("www.miek.nl. 1800 RRSIG CNAME 8 3 1800 20160426031301 20160327031301 12051 miek.nl. NVZmMJaypS+wDL2Lar4Zw1zF"), + }, + Ns: auth, + Extra: []dns.RR{ + test.OPT(4096, true), + }, + }, + { + // NoData + Qname: "a.miek.nl.", Qtype: dns.TypeSRV, Do: true, + Ns: []dns.RR{ + test.NSEC("a.miek.nl. 14400 IN NSEC archive.miek.nl. A AAAA RRSIG NSEC"), + test.RRSIG("a.miek.nl. 14400 IN RRSIG NSEC 8 3 14400 20160426031301 20160327031301 12051 miek.nl. GqnF6cutipmSHEao="), + test.RRSIG("miek.nl. 1800 IN RRSIG SOA 8 2 1800 20160426031301 20160327031301 12051 miek.nl. FIrzy07acBbtyQczy1dc="), + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "b.miek.nl.", Qtype: dns.TypeA, Do: true, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.NSEC("archive.miek.nl. 14400 IN NSEC go.dns.miek.nl. CNAME RRSIG NSEC"), + test.RRSIG("archive.miek.nl. 14400 IN RRSIG NSEC 8 3 14400 20160426031301 20160327031301 12051 miek.nl. jEpx8lcp4do5fWXg="), + test.NSEC("miek.nl. 14400 IN NSEC a.miek.nl. A NS SOA MX AAAA RRSIG NSEC DNSKEY"), + test.RRSIG("miek.nl. 14400 IN RRSIG NSEC 8 2 14400 20160426031301 20160327031301 12051 miek.nl. mFfc3r/9PSC1H6oSpdC"), + test.RRSIG("miek.nl. 1800 IN RRSIG SOA 8 2 1800 20160426031301 20160327031301 12051 miek.nl. FIrzy07acBbtyQczy1dc="), + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "b.blaat.miek.nl.", Qtype: dns.TypeA, Do: true, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.NSEC("archive.miek.nl. 14400 IN NSEC go.dns.miek.nl. CNAME RRSIG NSEC"), + test.RRSIG("archive.miek.nl. 14400 IN RRSIG NSEC 8 3 14400 20160426031301 20160327031301 12051 miek.nl. jEpx8lcp4do5fWXg="), + test.NSEC("miek.nl. 14400 IN NSEC a.miek.nl. A NS SOA MX AAAA RRSIG NSEC DNSKEY"), + test.RRSIG("miek.nl. 14400 IN RRSIG NSEC 8 2 14400 20160426031301 20160327031301 12051 miek.nl. mFfc3r/9PSC1H6oSpdC"), + test.RRSIG("miek.nl. 1800 IN RRSIG SOA 8 2 1800 20160426031301 20160327031301 12051 miek.nl. FIrzy07acBbtyQczy1dc="), + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "b.a.miek.nl.", Qtype: dns.TypeA, Do: true, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + // dedupped NSEC, because 1 nsec tells all + test.NSEC("a.miek.nl. 14400 IN NSEC archive.miek.nl. A AAAA RRSIG NSEC"), + test.RRSIG("a.miek.nl. 14400 IN RRSIG NSEC 8 3 14400 20160426031301 20160327031301 12051 miek.nl. GqnF6cut/RRGPQ1QGQE1ipmSHEao="), + test.RRSIG("miek.nl. 1800 IN RRSIG SOA 8 2 1800 20160426031301 20160327031301 12051 miek.nl. FIrzy07acBbtyQczy1dc="), + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, +} + +var auth = []dns.RR{ + test.NS("miek.nl. 1800 IN NS ext.ns.whyscream.net."), + test.NS("miek.nl. 1800 IN NS linode.atoom.net."), + test.NS("miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.NS("miek.nl. 1800 IN NS omval.tednet.nl."), + test.RRSIG("miek.nl. 1800 IN RRSIG NS 8 2 1800 20160426031301 20160327031301 12051 miek.nl. ZLtsQhwazbqSpztFoR1Vxs="), +} + +func TestLookupDNSSEC(t *testing.T) { + zone, err := Parse(strings.NewReader(dbMiekNLSigned), testzone, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}} + ctx := context.TODO() + + for _, tc := range dnssecTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +func BenchmarkFileLookupDNSSEC(b *testing.B) { + zone, err := Parse(strings.NewReader(dbMiekNLSigned), testzone, "stdin", 0) + if err != nil { + return + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}} + ctx := context.TODO() + rec := dnsrecorder.New(&test.ResponseWriter{}) + + tc := test.Case{ + Qname: "b.miek.nl.", Qtype: dns.TypeA, Do: true, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.NSEC("archive.miek.nl. 14400 IN NSEC go.dns.miek.nl. CNAME RRSIG NSEC"), + test.RRSIG("archive.miek.nl. 14400 IN RRSIG NSEC 8 3 14400 20160426031301 20160327031301 12051 miek.nl. jEpx8lcp4do5fWXg="), + test.NSEC("miek.nl. 14400 IN NSEC a.miek.nl. A NS SOA MX AAAA RRSIG NSEC DNSKEY"), + test.RRSIG("miek.nl. 14400 IN RRSIG NSEC 8 2 14400 20160426031301 20160327031301 12051 miek.nl. mFfc3r/9PSC1H6oSpdC"), + test.RRSIG("miek.nl. 1800 IN RRSIG SOA 8 2 1800 20160426031301 20160327031301 12051 miek.nl. FIrzy07acBbtyQczy1dc="), + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + } + + m := tc.Msg() + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + fm.ServeDNS(ctx, rec, m) + } +} + +const dbMiekNLSigned = ` +; File written on Sun Mar 27 04:13:01 2016 +; dnssec_signzone version 9.10.3-P4-Ubuntu +miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. ( + 1459051981 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 14400 ; minimum (4 hours) + ) + 1800 RRSIG SOA 8 2 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + FIrzy07acBzrf6kNW13Ypmq/ahojoMqOj0qJ + ixTevTvwOEcVuw9GlJoYIHTYg+hm1sZHtx9K + RiVmYsm8SHKsJA1WzixtT4K7vQvM+T+qbeOJ + xA6YTivKUcGRWRXQlOTUAlHS/KqBEfmxKgRS + 68G4oOEClFDSJKh7RbtyQczy1dc= ) + 1800 NS ext.ns.whyscream.net. + 1800 NS omval.tednet.nl. + 1800 NS linode.atoom.net. + 1800 NS ns-ext.nlnetlabs.nl. + 1800 RRSIG NS 8 2 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + ZLtsQhwaz+CwrgzgFiEAqbqS/JH65MYjziA3 + 6EXwlGDy41lcfGm71PpxA7cDzFhWNkJNk4QF + q48wtpP4IGPPpHbnJHKDUXj6se7S+ylAGbS+ + VgVJ4YaVcE6xA9ZVhVpz8CSSjeH34vmqq9xj + zmFjofuDvraZflHfNpztFoR1Vxs= ) + 1800 A 139.162.196.78 + 1800 RRSIG A 8 2 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + hl+6Q075tsCkxIqbop8zZ6U8rlFvooz7Izzx + MgCZYVLcg75El28EXKIhBfRb1dPaKbd+v+AD + wrJMHL131pY5sU2Ly05K+7CqmmyaXgDaVsKS + rSw/TbhGDIItBemeseeuXGAKAbY2+gE7kNN9 + mZoQ9hRB3SrxE2jhctv66DzYYQQ= ) + 1800 MX 1 aspmx.l.google.com. + 1800 MX 5 alt1.aspmx.l.google.com. + 1800 MX 5 alt2.aspmx.l.google.com. + 1800 MX 10 aspmx2.googlemail.com. + 1800 MX 10 aspmx3.googlemail.com. + 1800 RRSIG MX 8 2 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + kLqG+iOrKSzms1H9Et9me8Zts1rbyeCFSVQD + G9is/u6ec3Lqg2vwJddf/yRsjVpVgadWSAkc + GSDuD2dK8oBeP24axWc3Z1OY2gdMI7w+PKWT + Z+pjHVjbjM47Ii/a6jk5SYeOwpGMsdEwhtTP + vk2O2WGljifqV3uE7GshF5WNR10= ) + 1800 AAAA 2a01:7e00::f03c:91ff:fef1:6735 + 1800 RRSIG AAAA 8 2 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + SsRTHytW4YTAuHovHQgfIMhNwMtMp4gaAU/Z + lgTO+IkBb9y9F8uHrf25gG6RqA1bnGV/gezV + NU5negXm50bf1BNcyn3aCwEbA0rCGYIL+nLJ + szlBVbBu6me/Ym9bbJlfgfHRDfsVy2ZkNL+B + jfNQtGCSDoJwshjcqJlfIVSardo= ) + 14400 NSEC a.miek.nl. A NS SOA MX AAAA RRSIG NSEC DNSKEY + 14400 RRSIG NSEC 8 2 14400 ( + 20160426031301 20160327031301 12051 miek.nl. + mFfc3r/9PSC1H6oSpdC+FDy/Iu02W2Tf0x+b + n6Lpe1gCC1uvcSUrrmBNlyAWRr5Zm+ZXssEb + cKddRGiu/5sf0bUWrs4tqokL/HUl10X/sBxb + HfwNAeD7R7+CkpMv67li5AhsDgmQzpX2r3P6 + /6oZyLvODGobysbmzeWM6ckE8IE= ) + 1800 DNSKEY 256 3 8 ( + AwEAAcNEU67LJI5GEgF9QLNqLO1SMq1EdoQ6 + E9f85ha0k0ewQGCblyW2836GiVsm6k8Kr5EC + IoMJ6fZWf3CQSQ9ycWfTyOHfmI3eQ/1Covhb + 2y4bAmL/07PhrL7ozWBW3wBfM335Ft9xjtXH + Py7ztCbV9qZ4TVDTW/Iyg0PiwgoXVesz + ) ; ZSK; alg = RSASHA256; key id = 12051 + 1800 DNSKEY 257 3 8 ( + AwEAAcWdjBl4W4wh/hPxMDcBytmNCvEngIgB + 9Ut3C2+QI0oVz78/WK9KPoQF7B74JQ/mjO4f + vIncBmPp6mFNxs9/WQX0IXf7oKviEVOXLjct + R4D1KQLX0wprvtUIsQFIGdXaO6suTT5eDbSd + 6tTwu5xIkGkDmQhhH8OQydoEuCwV245ZwF/8 + AIsqBYDNQtQ6zhd6jDC+uZJXg/9LuPOxFHbi + MTjp6j3CCW0kHbfM/YHZErWWtjPj3U3Z7knQ + SIm5PO5FRKBEYDdr5UxWJ/1/20SrzI3iztvP + wHDsA2rdHm/4YRzq7CvG4N0t9ac/T0a0Sxba + /BUX2UVPWaIVBdTRBtgHi0s= + ) ; KSK; alg = RSASHA256; key id = 33694 + 1800 RRSIG DNSKEY 8 2 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + o/D6o8+/bNGQyyRvwZ2hM0BJ+3HirvNjZoko + yGhGe9sPSrYU39WF3JVIQvNJFK6W3/iwlKir + TPOeYlN6QilnztFq1vpCxwj2kxJaIJhZecig + LsKxY/fOHwZlIbBLZZadQG6JoGRLHnImSzpf + xtyVaXQtfnJFC07HHt9np3kICfE= ) + 1800 RRSIG DNSKEY 8 2 1800 ( + 20160426031301 20160327031301 33694 miek.nl. + Ak/mbbQVQV+nUgw5Sw/c+TSoYqIwbLARzuNE + QJvJNoRR4tKVOY6qSxQv+j5S7vzyORZ+yeDp + NlEa1T9kxZVBMABoOtLX5kRqZncgijuH8fxb + L57Sv2IzINI9+DOcy9Q9p9ygtwYzQKrYoNi1 + 0hwHi6emGkVG2gGghruMinwOJASGgQy487Yd + eIpcEKJRw73nxd2le/4/Vafy+mBpKWOczfYi + 5m9MSSxcK56NFYjPG7TvdIw0m70F/smY9KBP + pGWEdzRQDlqfZ4fpDaTAFGyRX0mPFzMbs1DD + 3hQ4LHUSi/NgQakdH9eF42EVEDeL4cI69K98 + 6NNk6X9TRslO694HKw== ) +a.miek.nl. 1800 IN A 139.162.196.78 + 1800 RRSIG A 8 3 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + lxLotCjWZ3kikNNcePu6HOCqMHDINKFRJRD8 + laz2KQ9DKtgXPdnRw5RJvVITSj8GUVzw1ec1 + CYVEKu/eMw/rc953Zns528QBypGPeMNLe2vu + C6a6UhZnGHA48dSd9EX33eSJs0MP9xsC9csv + LGdzYmv++eslkKxkhSOk2j/hTxk= ) + 1800 AAAA 2a01:7e00::f03c:91ff:fef1:6735 + 1800 RRSIG AAAA 8 3 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + ji3QMlaUzlK85ppB5Pc+y2WnfqOi6qrm6dm1 + bXgsEov/5UV1Lmcv8+Y5NBbTbBlXGlWcpqNp + uWpf9z3lbguDWznpnasN2MM8t7yxo/Cr7WRf + QCzui7ewpWiA5hq7j0kVbM4nnDc6cO+U93hO + mMhVbeVI70HM2m0HaHkziEyzVZk= ) + 14400 NSEC archive.miek.nl. A AAAA RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20160426031301 20160327031301 12051 miek.nl. + GqnF6cut/KCxbnJj27MCjjVGkjObV0hLhHOP + E1/GXAUTEKG6BWxJq8hidS3p/yrOmP5PEL9T + 4FjBp0/REdVmGpuLaiHyMselES82p/uMMdY5 + QqRM6LHhZdO1zsRbyzOZbm5MsW6GR7K2kHlX + 9TdBIULiRRGPQ1QGQE1ipmSHEao= ) +archive.miek.nl. 1800 IN CNAME a.miek.nl. + 1800 RRSIG CNAME 8 3 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + s4zVJiDrVuUiUFr8CNQLuXYYfpqpl8rovL50 + BYsub/xK756NENiOTAOjYH6KYg7RSzsygJjV + YQwXolZly2/KXAr48SCtxzkGFxLexxiKcFaj + vm7ZDl7Btoa5l68qmBcxOX5E/W0IKITi4PNK + mhBs7dlaf0IbPGNgMxae72RosxM= ) + 14400 NSEC go.dns.miek.nl. CNAME RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20160426031301 20160327031301 12051 miek.nl. + jEp7LsoK++/PRFh2HieLzasA1jXBpp90NyDf + RfpfOxdM69yRKfvXMc2bazIiMuDhxht79dGI + Gj02cn1cvX60SlaHkeFtqTdJcHdK9rbI65EK + YHFZFzGh9XVnuMJKpUsm/xS1dnUSAnXN8q+0 + xBlUDlQpsAFv/cx8lcp4do5fWXg= ) +go.dns.miek.nl. 1800 IN TXT "Hello!" + 1800 RRSIG TXT 8 4 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + O0uo1NsXTq2TTfgOmGbHQQEchrcpllaDAMMX + dTDizw3t+vZ5SR32qJ8W7y6VXLgUqJgcdRxS + Fou1pp+t5juRZSQ0LKgxMpZAgHorkzPvRf1b + E9eBKrDSuLGagsQRwHeldFGFgsXtCbf07vVH + zoKR8ynuG4/cAoY0JzMhCts+56U= ) + 14400 NSEC www.miek.nl. TXT RRSIG NSEC + 14400 RRSIG NSEC 8 4 14400 ( + 20160426031301 20160327031301 12051 miek.nl. + BW6qo7kYe3Z+Y0ebaVTWTy1c3bpdf8WUEoXq + WDQxLDEj2fFiuEBDaSN5lTWRg3wj8kZmr6Uk + LvX0P29lbATFarIgkyiAdbOEdaf88nMfqBW8 + z2T5xrPQcN0F13uehmv395yAJs4tebRxErMl + KdkVF0dskaDvw8Wo3YgjHUf6TXM= ) +www.miek.nl. 1800 IN CNAME a.miek.nl. + 1800 RRSIG CNAME 8 3 1800 ( + 20160426031301 20160327031301 12051 miek.nl. + MiQQh2lScoNiNVZmMJaypS+wDL2Lar4Zw1zF + Uo4tL16BfQOt7yl8gXdAH2JMFqoKAoIdM2K6 + XwFOwKTOGSW0oNCOcaE7ts+1Z1U0H3O2tHfq + FAzfg1s9pQ5zxk8J/bJgkVIkw2/cyB0y1/PK + EmIqvChBSb4NchTuMCSqo63LJM8= ) + 14400 NSEC miek.nl. CNAME RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20160426031301 20160327031301 12051 miek.nl. + OPPZ8iaUPrVKEP4cqeCiiv1WLRAY30GRIhc/ + me0gBwFkbmTEnvB+rUp831OJZDZBNKv4QdZj + Uyc26wKUOQeUyMJqv4IRDgxH7nq9GB5JRjYZ + IVxtGD1aqWLXz+8aMaf9ARJjtYUd3K4lt8Wz + LbJSo5Wdq7GOWqhgkY5n3XD0/FA= )` diff --git a/plugin/file/dnssex_test.go b/plugin/file/dnssex_test.go new file mode 100644 index 000000000..d9a0a4568 --- /dev/null +++ b/plugin/file/dnssex_test.go @@ -0,0 +1,145 @@ +package file + +const dbDnssexNLSigned = ` +; File written on Tue Mar 29 21:02:24 2016 +; dnssec_signzone version 9.10.3-P4-Ubuntu +dnssex.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. ( + 1459281744 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 14400 ; minimum (4 hours) + ) + 1800 RRSIG SOA 8 2 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + CA/Y3m9hCOiKC/8ieSOv8SeP964BUdG/8MC3 + WtKljUosK9Z9bBGrVizDjjqgq++lyH8BZJcT + aabAsERs4xj5PRtcxicwQXZACX5VYjXHQeZm + CyytFU5wq2gcXSmvUH86zZzftx3RGPvn1aOo + TlcvoC3iF8fYUCpROlUS0YR8Cdw= ) + 1800 NS omval.tednet.nl. + 1800 NS linode.atoom.net. + 1800 NS ns-ext.nlnetlabs.nl. + 1800 RRSIG NS 8 2 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + dLIeEvP86jj5nd3orv9bH7hTvkblF4Na0sbl + k6fJA6ha+FPN1d6Pig3NNEEVQ/+wlOp/JTs2 + v07L7roEEUCbBprI8gMSld2gFDwNLW3DAB4M + WD/oayYdAnumekcLzhgvWixTABjWAGRTGQsP + sVDFXsGMf9TGGC9FEomgkCVeNC0= ) + 1800 A 139.162.196.78 + 1800 RRSIG A 8 2 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + LKJKLzPiSEDWOLAag2YpfD5EJCuDcEAJu+FZ + Xy+4VyOv9YvRHCTL4vbrevOo5+XymY2RxU1q + j+6leR/Fe7nlreSj2wzAAk2bIYn4m6r7hqeO + aKZsUFfpX8cNcFtGEywfHndCPELbRxFeEziP + utqHFLPNMX5nYCpS28w4oJ5sAnM= ) + 1800 TXT "Doing It Safe Is Better" + 1800 RRSIG TXT 8 2 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + f6S+DUfJK1UYdOb3AHgUXzFTTtu+yLp/Fv7S + Hv0CAGhXAVw+nBbK719igFvBtObS33WKwzxD + 1pQNMaJcS6zeevtD+4PKB1KDC4fyJffeEZT6 + E30jGR8Y29/xA+Fa4lqDNnj9zP3b8TiABCle + ascY5abkgWCALLocFAzFJQ/27YQ= ) + 1800 AAAA 2a01:7e00::f03c:91ff:fef1:6735 + 1800 RRSIG AAAA 8 2 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + PWcPSawEUBAfCuv0liEOQ8RYe7tfNW4rubIJ + LE+dbrub1DUer3cWrDoCYFtOufvcbkYJQ2CQ + AGjJmAQ5J2aqYDOPMrKa615V0KT3ifbZJcGC + gkIic4U/EXjaQpRoLdDzR9MyVXOmbA6sKYzj + ju1cNkLqM8D7Uunjl4pIr6rdSFo= ) + 14400 NSEC *.dnssex.nl. A NS SOA TXT AAAA RRSIG NSEC DNSKEY + 14400 RRSIG NSEC 8 2 14400 ( + 20160428190224 20160329190224 14460 dnssex.nl. + oIvM6JZIlNc1aNKGTxv58ApSnDr1nDPPgnD9 + 9oJZRIn7eb5WnpeDz2H3z5+x6Bhlp5hJJaUp + KJ3Ss6Jg/IDnrmIvKmgq6L6gHj1Y1IiHmmU8 + VeZTRzdTsDx/27OsN23roIvsytjveNSEMfIm + iLZ23x5kg1kBdJ9p3xjYHm5lR+8= ) + 1800 DNSKEY 256 3 8 ( + AwEAAazSO6uvLPEVknDA8yxjFe8nnAMU7txp + wb19k55hQ81WV3G4bpBM1NdN6sbYHrkXaTNx + 2bQWAkvX6pz0XFx3z/MPhW+vkakIWFYpyQ7R + AT5LIJfToVfiCDiyhhF0zVobKBInO9eoGjd9 + BAW3TUt+LmNAO/Ak5D5BX7R3CuA7v9k7 + ) ; ZSK; alg = RSASHA256; key id = 14460 + 1800 DNSKEY 257 3 8 ( + AwEAAbyeaV9zg0IqdtgYoqK5jJ239anzwG2i + gvH1DxSazLyaoNvEkCIvPgMLW/JWfy7Z1mQp + SMy9DtzL5pzRyQgw7kIeXLbi6jufUFd9pxN+ + xnzKLf9mY5AcnGToTrbSL+jnMT67wG+c34+Q + PeVfucHNUePBxsbz2+4xbXiViSQyCQGv + ) ; KSK; alg = RSASHA256; key id = 18772 + 1800 RRSIG DNSKEY 8 2 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + cFSFtJE+DBGNxb52AweFaVHBe5Ue5MDpqNdC + TIneUnEhP2m+vK4zJ/TraK0WdQFpsX63pod8 + PZ9y03vHUfewivyonCCBD3DcNdoU9subhN22 + tez9Ct8Z5/9E4RAz7orXal4M1VUEhRcXSEH8 + SJW20mfVsqJAiKqqNeGB/pAj23I= ) + 1800 RRSIG DNSKEY 8 2 1800 ( + 20160428190224 20160329190224 18772 dnssex.nl. + oiiwo/7NYacePqohEp50261elhm6Dieh4j2S + VZGAHU5gqLIQeW9CxKJKtSCkBVgUo4cvO4Rn + 2tzArAuclDvBrMXRIoct8u7f96moeFE+x5FI + DYqICiV6k449ljj9o4t/5G7q2CRsEfxZKpTI + A/L0+uDk0RwVVzL45+TnilcsmZs= ) +*.dnssex.nl. 1800 IN TXT "Doing It Safe Is Better" + 1800 RRSIG TXT 8 2 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + FUZSTyvZfeuuOpCmNzVKOfITRHJ6/ygjmnnb + XGBxVUyQjoLuYXwD5XqZWGw4iKH6QeSDfGCx + 4MPqA4qQmW7Wwth7mat9yMfA4+p2sO84bysl + 7/BG9+W2G+q1uQiM9bX9V42P2X/XuW5Y/t9Y + 8u1sljQ7D8WwS6naH/vbaJxnDBw= ) + 14400 NSEC a.dnssex.nl. TXT RRSIG NSEC + 14400 RRSIG NSEC 8 2 14400 ( + 20160428190224 20160329190224 14460 dnssex.nl. + os6INm6q2eXknD5z8TpfbK00uxVbQefMvHcR + /RNX/kh0xXvzAaaDOV+Ge/Ko+2dXnKP+J1LY + G9ffXNpdbaQy5ygzH5F041GJst4566GdG/jt + 7Z7vLHYxEBTpZfxo+PLsXQXH3VTemZyuWyDf + qJzafXJVH1F0nDrcXmMlR6jlBHA= ) +www.dnssex.nl. 1800 IN CNAME a.dnssex.nl. + 1800 RRSIG CNAME 8 3 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + Omv42q/uVvdNsWQoSrQ6m6w6U7r7Abga7uF4 + 25b3gZlse0C+WyMyGFMGUbapQm7azvBpreeo + uKJHjzd+ufoG+Oul6vU9vyoj+ejgHzGLGbJQ + HftfP+UqP5SWvAaipP/LULTWKPuiBcLDLiBI + PGTfsq0DB6R+qCDTV0fNnkgxEBQ= ) + 14400 NSEC dnssex.nl. CNAME RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20160428190224 20160329190224 14460 dnssex.nl. + TBN3ddfZW+kC84/g3QlNNJMeLZoyCalPQylt + KXXLPGuxfGpl3RYRY8KaHbP+5a8MnHjqjuMB + Lofb7yKMFxpSzMh8E36vnOqry1mvkSakNj9y + 9jM8PwDjcpYUwn/ql76MsmNgEV5CLeQ7lyH4 + AOrL79yOSQVI3JHJIjKSiz88iSw= ) +a.dnssex.nl. 1800 IN A 139.162.196.78 + 1800 RRSIG A 8 3 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + OXHpFj9nSpKi5yA/ULH7MOpGAWfyJ2yC/2xa + Pw0fqSY4QvcRt+V3adcFA4H9+P1b32GpxEjB + lXmCJID+H4lYkhUR4r4IOZBVtKG2SJEBZXip + pH00UkOIBiXxbGzfX8VL04v2G/YxUgLW57kA + aknaeTOkJsO20Y+8wmR9EtzaRFI= ) + 1800 AAAA 2a01:7e00::f03c:91ff:fef1:6735 + 1800 RRSIG AAAA 8 3 1800 ( + 20160428190224 20160329190224 14460 dnssex.nl. + jrepc/VnRzJypnrG0WDEqaAr3HMjWrPxJNX0 + 86gbFjZG07QxBmrA1rj0jM9YEWTjjyWb2tT7 + lQhzKDYX/0XdOVUeeOM4FoSks80V+pWR8fvj + AZ5HmX69g36tLosMDKNR4lXcrpv89QovG4Hr + /r58fxEKEFJqrLDjMo6aOrg+uKA= ) + 14400 NSEC www.dnssex.nl. A AAAA RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20160428190224 20160329190224 14460 dnssex.nl. + S+UM62wXRNNFN3QDWK5YFWUbHBXC4aqaqinZ + A2ZDeC+IQgyw7vazPz7cLI5T0YXXks0HTMlr + soEjKnnRZsqSO9EuUavPNE1hh11Jjm0fB+5+ + +Uro0EmA5Dhgc0Z2VpbXVQEhNDf/pI1gem15 + RffN2tBYNykZn4Has2ySgRaaRYQ= )` diff --git a/plugin/file/ds_test.go b/plugin/file/ds_test.go new file mode 100644 index 000000000..e1087a81d --- /dev/null +++ b/plugin/file/ds_test.go @@ -0,0 +1,75 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var dsTestCases = []test.Case{ + { + Qname: "a.delegated.miek.nl.", Qtype: dns.TypeDS, + Ns: []dns.RR{ + test.NS("delegated.miek.nl. 1800 IN NS a.delegated.miek.nl."), + test.NS("delegated.miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + }, + Extra: []dns.RR{ + test.A("a.delegated.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + Qname: "_udp.delegated.miek.nl.", Qtype: dns.TypeDS, + Ns: []dns.RR{ + test.NS("delegated.miek.nl. 1800 IN NS a.delegated.miek.nl."), + test.NS("delegated.miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + }, + Extra: []dns.RR{ + test.A("a.delegated.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.delegated.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + }, + { + // This works *here* because we skip the server routing for DS in core/dnsserver/server.go + Qname: "_udp.miek.nl.", Qtype: dns.TypeDS, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeDS, + Ns: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + }, +} + +func TestLookupDS(t *testing.T) { + zone, err := Parse(strings.NewReader(dbMiekNLDelegation), testzone, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}} + ctx := context.TODO() + + for _, tc := range dsTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/file/ent_test.go b/plugin/file/ent_test.go new file mode 100644 index 000000000..6f4f1db6c --- /dev/null +++ b/plugin/file/ent_test.go @@ -0,0 +1,159 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var entTestCases = []test.Case{ + { + Qname: "b.c.miek.nl.", Qtype: dns.TypeA, + Ns: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + }, + { + Qname: "b.c.miek.nl.", Qtype: dns.TypeA, Do: true, + Ns: []dns.RR{ + test.NSEC("a.miek.nl. 14400 IN NSEC a.b.c.miek.nl. A RRSIG NSEC"), + test.RRSIG("a.miek.nl. 14400 IN RRSIG NSEC 8 3 14400 20160502144311 20160402144311 12051 miek.nl. d5XZEy6SUpq98ZKUlzqhAfkLI9pQPc="), + test.RRSIG("miek.nl. 1800 IN RRSIG SOA 8 2 1800 20160502144311 20160402144311 12051 miek.nl. KegoBxA3Tbrhlc4cEdkRiteIkOfsq"), + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, +} + +func TestLookupEnt(t *testing.T) { + zone, err := Parse(strings.NewReader(dbMiekENTNL), testzone, "stdin", 0) + if err != nil { + t.Fatalf("expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}} + ctx := context.TODO() + + for _, tc := range entTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +// fdjfdjkf +const dbMiekENTNL = `; File written on Sat Apr 2 16:43:11 2016 +; dnssec_signzone version 9.10.3-P4-Ubuntu +miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. ( + 1282630057 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 14400 ; minimum (4 hours) + ) + 1800 RRSIG SOA 8 2 1800 ( + 20160502144311 20160402144311 12051 miek.nl. + KegoBxA3Tbrhlc4cEdkRiteIkOfsqD4oCLLM + ISJ5bChWy00LGHUlAnHVu5Ti96hUjVNmGSxa + xtGSuAAMFCr52W8pAB8LBIlu9B6QZUPHMccr + SuzxAX3ioawk2uTjm+k8AGPT4RoQdXemGLAp + zJTASolTVmeMTh5J0sZTZJrtvZ0= ) + 1800 NS linode.atoom.net. + 1800 RRSIG NS 8 2 1800 ( + 20160502144311 20160402144311 12051 miek.nl. + m0cOHL6Rre/0jZPXe+0IUjs/8AFASRCvDbSx + ZQsRDSlZgS6RoMP3OC77cnrKDVlfZ2Vhq3Ce + nYPoGe0/atB92XXsilmstx4HTSU64gsV9iLN + Xkzk36617t7zGOl/qumqfaUXeA9tihItzEim + 6SGnufVZI4o8xeyaVCNDDuN0bvY= ) + 14400 NSEC a.miek.nl. NS SOA RRSIG NSEC DNSKEY + 14400 RRSIG NSEC 8 2 14400 ( + 20160502144311 20160402144311 12051 miek.nl. + BCWVgwxWrs4tBjS9QXKkftCUbiLi40NyH1yA + nbFy1wCKQ2jDH00810+ia4b66QrjlAKgxE9z + 9U7MKSMV86sNkyAtlCi+2OnjtWF6sxPdJO7k + CHeg46XBjrQuiJRY8CneQX56+IEPdufLeqPR + l+ocBQ2UkGhXmQdWp3CFDn2/eqU= ) + 1800 DNSKEY 256 3 8 ( + AwEAAcNEU67LJI5GEgF9QLNqLO1SMq1EdoQ6 + E9f85ha0k0ewQGCblyW2836GiVsm6k8Kr5EC + IoMJ6fZWf3CQSQ9ycWfTyOHfmI3eQ/1Covhb + 2y4bAmL/07PhrL7ozWBW3wBfM335Ft9xjtXH + Py7ztCbV9qZ4TVDTW/Iyg0PiwgoXVesz + ) ; ZSK; alg = RSASHA256; key id = 12051 + 1800 DNSKEY 257 3 8 ( + AwEAAcWdjBl4W4wh/hPxMDcBytmNCvEngIgB + 9Ut3C2+QI0oVz78/WK9KPoQF7B74JQ/mjO4f + vIncBmPp6mFNxs9/WQX0IXf7oKviEVOXLjct + R4D1KQLX0wprvtUIsQFIGdXaO6suTT5eDbSd + 6tTwu5xIkGkDmQhhH8OQydoEuCwV245ZwF/8 + AIsqBYDNQtQ6zhd6jDC+uZJXg/9LuPOxFHbi + MTjp6j3CCW0kHbfM/YHZErWWtjPj3U3Z7knQ + SIm5PO5FRKBEYDdr5UxWJ/1/20SrzI3iztvP + wHDsA2rdHm/4YRzq7CvG4N0t9ac/T0a0Sxba + /BUX2UVPWaIVBdTRBtgHi0s= + ) ; KSK; alg = RSASHA256; key id = 33694 + 1800 RRSIG DNSKEY 8 2 1800 ( + 20160502144311 20160402144311 12051 miek.nl. + YNpi1jRDQKpnsQEjIjxqy+kJGaYnV16e8Iug + 40c82y4pee7kIojFUllSKP44qiJpCArxF557 + tfjfwBd6c4hkqCScGPZXJ06LMyG4u//rhVMh + 4hyKcxzQFKxmrFlj3oQGksCI8lxGX6RxiZuR + qv2ol2lUWrqetpAL+Zzwt71884E= ) + 1800 RRSIG DNSKEY 8 2 1800 ( + 20160502144311 20160402144311 33694 miek.nl. + jKpLDEeyadgM0wDgzEk6sBBdWr2/aCrkAOU/ + w6dYIafN98f21oIYQfscV1gc7CTsA0vwzzUu + x0QgwxoNLMvSxxjOiW/2MzF8eozczImeCWbl + ad/pVCYH6Jn5UBrZ5RCWMVcs2RP5KDXWeXKs + jEN/0EmQg5qNd4zqtlPIQinA9I1HquJAnS56 + pFvYyGIbZmGEbhR18sXVBeTWYr+zOMHn2quX + 0kkrx2udz+sPg7i4yRsLdhw138gPRy1qvbaC + 8ELs1xo1mC9pTlDOhz24Q3iXpVAU1lXLYOh9 + nUP1/4UvZEYXHBUQk/XPRciojniWjAF825x3 + QoSivMHblBwRdAKJSg== ) +a.miek.nl. 1800 IN A 127.0.0.1 + 1800 RRSIG A 8 3 1800 ( + 20160502144311 20160402144311 12051 miek.nl. + lUOYdSxScjyYz+Ebc+nb6iTNgCohqj7K+Dat + 97KE7haV2nP3LxdYuDCJYZpeyhsXDLHd4bFI + bInYPwJiC6DUCxPCuCWy0KYlZOWW8KCLX3Ia + BOPQbvIwLsJhnX+/tyMD9mXortoqATO79/6p + nNxvFeM8pFDwaih17fXMuFR/BsI= ) + 14400 NSEC a.b.c.miek.nl. A RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20160502144311 20160402144311 12051 miek.nl. + d5XZEy6SUp+TPRJQED+0R65zf2Yeo/1dlEA2 + jYYvkXGSHXke4sg9nH8U3nr1rLcuqA1DsQgH + uMIjdENvXuZ+WCSwvIbhC+JEI6AyQ6Gfaf/D + I3mfu60C730IRByTrKM5C2rt11lwRQlbdaUY + h23/nn/q98ZKUlzqhAfkLI9pQPc= ) +a.b.c.miek.nl. 1800 IN A 127.0.0.1 + 1800 RRSIG A 8 5 1800 ( + 20160502144311 20160402144311 12051 miek.nl. + FwgU5+fFD4hEebco3gvKQt3PXfY+dcOJr8dl + Ky4WLsONIdhP+4e9oprPisSLxImErY21BcrW + xzu1IZrYDsS8XBVV44lBx5WXEKvAOrUcut/S + OWhFZW7ncdIQCp32ZBIatiLRJEqXUjx+guHs + noFLiHix35wJWsRKwjGLIhH1fbs= ) + 14400 NSEC miek.nl. A RRSIG NSEC + 14400 RRSIG NSEC 8 5 14400 ( + 20160502144311 20160402144311 12051 miek.nl. + lXgOqm9/jRRYvaG5jC1CDvTtGYxMroTzf4t4 + jeYGb60+qI0q9sHQKfAJvoQ5o8o1qfR7OuiF + f544ipYT9eTcJRyGAOoJ37yMie7ZIoVJ91tB + r8YdzZ9Q6x3v1cbwTaQiacwhPZhGYOw63qIs + q5IQErIPos2sNk+y9D8BEce2DO4= )` diff --git a/plugin/file/example_org.go b/plugin/file/example_org.go new file mode 100644 index 000000000..eba18e0e4 --- /dev/null +++ b/plugin/file/example_org.go @@ -0,0 +1,113 @@ +package file + +// exampleOrgSigned is a fake signed example.org zone with two delegations, +// one signed (with DSs) and one "normal". +const exampleOrgSigned = ` +example.org. 1800 IN SOA a.iana-servers.net. devnull.example.org. ( + 1282630057 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 14400 ; minimum (4 hours) + ) + 1800 RRSIG SOA 13 2 1800 ( + 20161129153240 20161030153240 49035 example.org. + GVnMpFmN+6PDdgCtlYDEYBsnBNDgYmEJNvos + Bk9+PNTPNWNst+BXCpDadTeqRwrr1RHEAQ7j + YWzNwqn81pN+IA== ) + 1800 NS a.iana-servers.net. + 1800 NS b.iana-servers.net. + 1800 RRSIG NS 13 2 1800 ( + 20161129153240 20161030153240 49035 example.org. + llrHoIuwjnbo28LOt4p5zWAs98XGqrXicKVI + Qxyaf/ORM8boJvW2XrKr3nj6Y8FKMhzd287D + 5PBzVCL6MZyjQg== ) + 14400 NSEC a.example.org. NS SOA RRSIG NSEC DNSKEY + 14400 RRSIG NSEC 13 2 14400 ( + 20161129153240 20161030153240 49035 example.org. + BQROf1swrmYi3GqpP5M/h5vTB8jmJ/RFnlaX + 7fjxvV7aMvXCsr3ekWeB2S7L6wWFihDYcKJg + 9BxVPqxzBKeaqg== ) + 1800 DNSKEY 256 3 13 ( + UNTqlHbC51EbXuY0rshW19Iz8SkCuGVS+L0e + bQj53dvtNlaKfWmtTauC797FoyVLbQwoMy/P + G68SXgLCx8g+9g== + ) ; ZSK; alg = ECDSAP256SHA256; key id = 49035 + 1800 RRSIG DNSKEY 13 2 1800 ( + 20161129153240 20161030153240 49035 example.org. + LnLHyqYJaCMOt7EHB4GZxzAzWLwEGCTFiEhC + jj1X1VuQSjJcN42Zd3yF+jihSW6huknrig0Z + Mqv0FM6mJ/qPKg== ) +a.delegated.example.org. 1800 IN A 139.162.196.78 + 1800 TXT "obscured" + 1800 AAAA 2a01:7e00::f03c:91ff:fef1:6735 +archive.example.org. 1800 IN CNAME a.example.org. + 1800 RRSIG CNAME 13 3 1800 ( + 20161129153240 20161030153240 49035 example.org. + SDFW1z/PN9knzH8BwBvmWK0qdIwMVtGrMgRw + 7lgy4utRrdrRdCSLZy3xpkmkh1wehuGc4R0S + 05Z3DPhB0Fg5BA== ) + 14400 NSEC delegated.example.org. CNAME RRSIG NSEC + 14400 RRSIG NSEC 13 3 14400 ( + 20161129153240 20161030153240 49035 example.org. + DQqLSVNl8F6v1K09wRU6/M6hbHy2VUddnOwn + JusJjMlrAOmoOctCZ/N/BwqCXXBA+d9yFGdH + knYumXp+BVPBAQ== ) +www.example.org. 1800 IN CNAME a.example.org. + 1800 RRSIG CNAME 13 3 1800 ( + 20161129153240 20161030153240 49035 example.org. + adzujOxCV0uBV4OayPGfR11iWBLiiSAnZB1R + slmhBFaDKOKSNYijGtiVPeaF+EuZs63pzd4y + 6Nm2Iq9cQhAwAA== ) + 14400 NSEC example.org. CNAME RRSIG NSEC + 14400 RRSIG NSEC 13 3 14400 ( + 20161129153240 20161030153240 49035 example.org. + jy3f96GZGBaRuQQjuqsoP1YN8ObZF37o+WkV + PL7TruzI7iNl0AjrUDy9FplP8Mqk/HWyvlPe + N3cU+W8NYlfDDQ== ) +a.example.org. 1800 IN A 139.162.196.78 + 1800 RRSIG A 13 3 1800 ( + 20161129153240 20161030153240 49035 example.org. + 41jFz0Dr8tZBN4Kv25S5dD4vTmviFiLx7xSA + qMIuLFm0qibKL07perKpxqgLqM0H1wreT4xz + I9Y4Dgp1nsOuMA== ) + 1800 AAAA 2a01:7e00::f03c:91ff:fef1:6735 + 1800 RRSIG AAAA 13 3 1800 ( + 20161129153240 20161030153240 49035 example.org. + brHizDxYCxCHrSKIu+J+XQbodRcb7KNRdN4q + VOWw8wHqeBsFNRzvFF6jwPQYphGP7kZh1KAb + VuY5ZVVhM2kHjw== ) + 14400 NSEC archive.example.org. A AAAA RRSIG NSEC + 14400 RRSIG NSEC 13 3 14400 ( + 20161129153240 20161030153240 49035 example.org. + zIenVlg5ScLr157EWigrTGUgrv7W/1s49Fic + i2k+OVjZfT50zw+q5X6DPKkzfAiUhIuqs53r + hZUzZwV/1Wew9Q== ) +delegated.example.org. 1800 IN NS a.delegated.example.org. + 1800 IN NS ns-ext.nlnetlabs.nl. + 1800 DS 10056 5 1 ( + EE72CABD1927759CDDA92A10DBF431504B9E + 1F13 ) + 1800 DS 10056 5 2 ( + E4B05F87725FA86D9A64F1E53C3D0E625094 + 6599DFE639C45955B0ED416CDDFA ) + 1800 RRSIG DS 13 3 1800 ( + 20161129153240 20161030153240 49035 example.org. + rlNNzcUmtbjLSl02ZzQGUbWX75yCUx0Mug1j + HtKVqRq1hpPE2S3863tIWSlz+W9wz4o19OI4 + jbznKKqk+DGKog== ) + 14400 NSEC sub.example.org. NS DS RRSIG NSEC + 14400 RRSIG NSEC 13 3 14400 ( + 20161129153240 20161030153240 49035 example.org. + lNQ5kRTB26yvZU5bFn84LYFCjwWTmBcRCDbD + cqWZvCSw4LFOcqbz1/wJKIRjIXIqnWIrfIHe + fZ9QD5xZsrPgUQ== ) +sub.example.org. 1800 IN NS sub1.example.net. + 1800 IN NS sub2.example.net. + 14400 NSEC www.example.org. NS RRSIG NSEC + 14400 RRSIG NSEC 13 3 14400 ( + 20161129153240 20161030153240 49035 example.org. + VYjahdV+TTkA3RBdnUI0hwXDm6U5k/weeZZr + ix1znORpOELbeLBMJW56cnaG+LGwOQfw9qqj + bOuULDst84s4+g== ) +` diff --git a/plugin/file/file.go b/plugin/file/file.go new file mode 100644 index 000000000..89c2df90a --- /dev/null +++ b/plugin/file/file.go @@ -0,0 +1,138 @@ +// Package file implements a file backend. +package file + +import ( + "fmt" + "io" + "log" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +type ( + // File is the plugin that reads zone data from disk. + File struct { + Next plugin.Handler + Zones Zones + } + + // Zones maps zone names to a *Zone. + Zones struct { + Z map[string]*Zone // A map mapping zone (origin) to the Zone's data + Names []string // All the keys from the map Z as a string slice. + } +) + +// ServeDNS implements the plugin.Handle interface. +func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + qname := state.Name() + // TODO(miek): match the qname better in the map + zone := plugin.Zones(f.Zones.Names).Matches(qname) + if zone == "" { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + + z, ok := f.Zones.Z[zone] + if !ok || z == nil { + return dns.RcodeServerFailure, nil + } + + // This is only for when we are a secondary zones. + if r.Opcode == dns.OpcodeNotify { + if z.isNotify(state) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + state.SizeAndDo(m) + w.WriteMsg(m) + + log.Printf("[INFO] Notify from %s for %s: checking transfer", state.IP(), zone) + ok, err := z.shouldTransfer() + if ok { + z.TransferIn() + } else { + log.Printf("[INFO] Notify from %s for %s: no serial increase seen", state.IP(), zone) + } + if err != nil { + log.Printf("[WARNING] Notify from %s for %s: failed primary check: %s", state.IP(), zone, err) + } + return dns.RcodeSuccess, nil + } + log.Printf("[INFO] Dropping notify from %s for %s", state.IP(), zone) + return dns.RcodeSuccess, nil + } + + if z.Expired != nil && *z.Expired { + log.Printf("[ERROR] Zone %s is expired", zone) + return dns.RcodeServerFailure, nil + } + + if state.QType() == dns.TypeAXFR || state.QType() == dns.TypeIXFR { + xfr := Xfr{z} + return xfr.ServeDNS(ctx, w, r) + } + + answer, ns, extra, result := z.Lookup(state, qname) + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + m.Answer, m.Ns, m.Extra = answer, ns, extra + + switch result { + case Success: + case NoData: + case NameError: + m.Rcode = dns.RcodeNameError + case Delegation: + m.Authoritative = false + case ServerFailure: + return dns.RcodeServerFailure, nil + } + + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +// Name implements the Handler interface. +func (f File) Name() string { return "file" } + +// Parse parses the zone in filename and returns a new Zone or an error. +// If serial >= 0 it will reload the zone, if the SOA hasn't changed +// it returns an error indicating nothing was read. +func Parse(f io.Reader, origin, fileName string, serial int64) (*Zone, error) { + tokens := dns.ParseZone(f, dns.Fqdn(origin), fileName) + z := NewZone(origin, fileName) + seenSOA := false + for x := range tokens { + if x.Error != nil { + return nil, x.Error + } + + if !seenSOA && serial >= 0 { + if s, ok := x.RR.(*dns.SOA); ok { + if s.Serial == uint32(serial) { // same zone + return nil, fmt.Errorf("no change in serial: %d", serial) + } + seenSOA = true + } + } + + if err := z.Insert(x.RR); err != nil { + return nil, err + } + } + if !seenSOA { + return nil, fmt.Errorf("file %q has no SOA record", fileName) + } + + return z, nil +} diff --git a/plugin/file/file_test.go b/plugin/file/file_test.go new file mode 100644 index 000000000..02668785b --- /dev/null +++ b/plugin/file/file_test.go @@ -0,0 +1,31 @@ +package file + +import ( + "strings" + "testing" +) + +func BenchmarkFileParseInsert(b *testing.B) { + for i := 0; i < b.N; i++ { + Parse(strings.NewReader(dbMiekENTNL), testzone, "stdin", 0) + } +} + +func TestParseNoSOA(t *testing.T) { + _, err := Parse(strings.NewReader(dbNoSOA), "example.org.", "stdin", 0) + if err == nil { + t.Fatalf("zone %q should have failed to load", "example.org.") + } + if !strings.Contains(err.Error(), "no SOA record") { + t.Fatalf("zone %q should have failed to load with no soa error: %s", "example.org.", err) + } +} + +const dbNoSOA = ` +$TTL 1M +$ORIGIN example.org. + +www IN A 192.168.0.14 +mail IN A 192.168.0.15 +imap IN CNAME mail +` diff --git a/plugin/file/glue_test.go b/plugin/file/glue_test.go new file mode 100644 index 000000000..3880953c2 --- /dev/null +++ b/plugin/file/glue_test.go @@ -0,0 +1,253 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// another personal zone (helps in testing as my secondary is NSD +// atoom = atom in English. +var atoomTestCases = []test.Case{ + { + Qname: atoom, Qtype: dns.TypeNS, Do: true, + Answer: []dns.RR{ + test.NS("atoom.net. 1800 IN NS linode.atoom.net."), + test.NS("atoom.net. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.NS("atoom.net. 1800 IN NS omval.tednet.nl."), + test.RRSIG("atoom.net. 1800 IN RRSIG NS 8 2 1800 20170112031301 20161213031301 53289 atoom.net. DLe+G1 jlw="), + }, + Extra: []dns.RR{ + test.OPT(4096, true), + test.A("linode.atoom.net. 1800 IN A 176.58.119.54"), + test.AAAA("linode.atoom.net. 1800 IN AAAA 2a01:7e00::f03c:91ff:fe79:234c"), + test.RRSIG("linode.atoom.net. 1800 IN RRSIG A 8 3 1800 20170112031301 20161213031301 53289 atoom.net. Z4Ka4OLDoyxj72CL vkI="), + test.RRSIG("linode.atoom.net. 1800 IN RRSIG AAAA 8 3 1800 20170112031301 20161213031301 53289 atoom.net. l+9Qc914zFH/okG2fzJ1q olQ="), + }, + }, +} + +func TestLookupGlue(t *testing.T) { + zone, err := Parse(strings.NewReader(dbAtoomNetSigned), atoom, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{atoom: zone}, Names: []string{atoom}}} + ctx := context.TODO() + + for _, tc := range atoomTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +const dbAtoomNetSigned = ` +; File written on Tue Dec 13 04:13:01 2016 +; dnssec_signzone version 9.10.3-P4-Debian +atoom.net. 1800 IN SOA linode.atoom.net. miek.miek.nl. ( + 1481602381 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 14400 ; minimum (4 hours) + ) + 1800 RRSIG SOA 8 2 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + GZ30uFuGATKzwHXgpEwK70qjdXSAqmbB5d4z + e7WTibvJDPLa1ptZBI7Zuod2KMOkT1ocSvhL + U7makhdv0BQx+5RSaP25mAmPIzfU7/T7R+DJ + 5q1GLlDSvOprfyMUlwOgZKZinesSdUa9gRmu + 8E+XnPNJ/jcTrGzzaDjn1/irrM0= ) + 1800 NS omval.tednet.nl. + 1800 NS linode.atoom.net. + 1800 NS ns-ext.nlnetlabs.nl. + 1800 RRSIG NS 8 2 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + D8Sd9JpXIOxOrUF5Hi1ASutyQwP7JNu8XZxA + rse86A6L01O8H8sCNib2VEoJjHuZ/dDEogng + OgmfqeFy04cpSX19GAk3bkx8Lr6aEat3nqIC + XA/xsCCfXy0NKZpI05zntHPbbP5tF/NvpE7n + 0+oLtlHSPEg1ZnEgwNoLe+G1jlw= ) + 1800 A 176.58.119.54 + 1800 RRSIG A 8 2 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + mrjiUFNCqDgCW8TuhjzcMh0V841uC224QvwH + 0+OvYhcve9twbX3Y12PSFmz77Xz3Jg9WAj4I + qhh3iHUac4dzUXyC702DT62yMF/9CMUO0+Ee + b6wRtvPHr2Tt0i/xV/BTbArInIvurXJrvKvo + LsZHOfsg7dZs6Mvdpe/CgwRExpk= ) + 1800 AAAA 2a01:7e00::f03c:91ff:fe79:234c + 1800 RRSIG AAAA 8 2 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + EkMxX2vUaP4h0qbWlHaT4yNhm8MrPMZTn/3R + zNw+i3oF2cLMWKh6GCfuIX/x5ID706o8kfum + bxTYwuTe1LJ+GoZHWEiH8VCa1laTlh8l3qSi + PZKU8339rr5cCYluk6p9PbAuRkYYOEruNg42 + wPOx46dsAlvp2XpOaOeJtU64QGQ= ) + 14400 NSEC deb.atoom.net. A NS SOA AAAA RRSIG NSEC DNSKEY + 14400 RRSIG NSEC 8 2 14400 ( + 20170112031301 20161213031301 53289 atoom.net. + P7Stx7lqRKl8tbTAAaJ0W6UhgJwZz3cjpM8z + eplbhXEVohKtyJ9xgptKt1vreH6lkhzciar5 + EB9Nj0VOmcthiht/+As8aEKmf8UlcJ2EbLII + NT7NUaasxsrLE2rjjX5mEtzOZ1uQAGiU8Hnk + XdGweTgIVFuiCcMCgaKpC2TRrMw= ) + 1800 DNSKEY 256 3 8 ( + AwEAAeDZTH9YT9qLMPlq4VrxX7H3GbWcqCrC + tXc9RT/hf96GN+ttnnEQVaJY8Gbly3IZpYQW + MwaCi0t30UULXE3s9FUQtl4AMbplyiz9EF8L + /XoBS1yhGm5WV5u608ihoPaRkYNyVV3egb5Y + hA5EXWy2vfsa1XWPpxvSAhlqM0YENtP3 + ) ; ZSK; alg = RSASHA256; key id = 53289 + 1800 DNSKEY 257 3 8 ( + AwEAAepN7Vo8enDCruVduVlGxTDIv7QG0wJQ + fTL1hMy4k0Yf/7dXzrn5bZT4ytBvH1hoBImH + mtTrQo6DQlBBVXDJXTyQjQozaHpN1HhTJJTz + IXl8UrdbkLWvz6QSeJPmBBYQRAqylUA2KE29 + nxyiNboheDLiIWyQ7Q/Op7lYaKMdb555kQAs + b/XT4Tb3/3BhAjcofNofNBjDjPq2i8pAo8HU + 5mW5/Pl+ZT/S0aqQPnCkHk/iofSRu3ZdBzkH + 54eoC+BdyXb7gTbPGRr+1gMbf/rzhRiZ4vnX + NoEzGAXmorKzJHANNb6KQ/932V9UDHm9wbln + 6y3s7IBvsMX5KF8vo81Stkc= + ) ; KSK; alg = RSASHA256; key id = 19114 + 1800 RRSIG DNSKEY 8 2 1800 ( + 20170112031301 20161213031301 19114 atoom.net. + IEjViubKdef8RWB5bcnirqVcqDk16irkywJZ + sBjMyNs03/a+sl0UHEGAB7qCC+Rn+RDaM5It + WF+Gha6BwRIN9NuSg3BwB2h1nJtHw61pMVU9 + 2j9Q3pq7X1xoTBAcwY95t5a1xlw0iTCaLu1L + Iu/PbVp1gj1o8BF/PiYilvZJGUjaTgsi+YNi + 2kiWpp6afO78/W4nfVx+lQBmpyfX1lwL5PEC + 9f5PMbzRmOapvUBc2XdddGywLdmlNsLHimGV + t7kkHZHOWQR1TvvMbU3dsC0bFCrBVGDhEuxC + hATR+X5YV0AyDSyrew7fOGJKrapwMWS3yRLr + FAt0Vcxno5lwQImbCQ== ) + 1800 RRSIG DNSKEY 8 2 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + sSxdgPT+gFZPN0ot6lZRGqOwvONUEsg0uEbf + kh19JlWHu/qvq5HOOK2VOW/UnswpVmtpFk0W + z/jiCNHifjpCCVn5tfCMZDLGekmPOjdobw24 + swBuGjnn0NHvxHoN6S+mb+AR6V/dLjquNUda + yzBc2Ua+XtQ7SCLKIvEhcNg9H3o= ) +deb.atoom.net. 1800 IN A 176.58.119.54 + 1800 RRSIG A 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + ZW7jm/VDa/I9DxWlE7Cm+HHymiVv4Wk5UGYI + Uf/g0EfxLCBR6SwL5QKuV1z7xoWKaiNqqrmc + gg35xgskKyS8QHgCCODhDzcIKe+MSsBXbY04 + AtrC5dV3JJQoA65Ng/48hwcyghAjXKrA2Yyq + GXf2DSvWeIV9Jmk0CsOELP24dpk= ) + 1800 TXT "v=spf1 a ip6:2a01:7e00::f03c:91ff:fe79:234c ~all" + 1800 RRSIG TXT 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + fpvVJ+Z6tzSd9yETn/PhLSCRISwRD1c3ET80 + 8twnx3XfAPQfV2R8dw7pz8Vw4TSxvf19bAZc + PWRjW682gb7gAxoJshCXBYabMfqExrBc9V1S + ezwm3D93xNMyegxzHx2b/H8qp3ZWdsMLTvvN + Azu7P4iyO+WRWT0R7bJGrdTwRz8= ) + 1800 AAAA 2a01:7e00::f03c:91ff:fe79:234c + 1800 RRSIG AAAA 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + aaPF6NqXfWamzi+xUDVeYa7StJUVM1tDsL34 + w5uozFRZ0f4K/Z88Kk5CgztxmtpNNKGdLWa0 + iryUJsbVWAbSQfrZNkNckBtczMNxGgjqn97A + 2//F6ajH/qrR3dWcCm+VJMgu3UPqAxLiCaYO + GQUx6Y8JA1VIM/RJAM6BhgNxjD0= ) + 14400 NSEC lafhart.atoom.net. A TXT AAAA RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20170112031301 20161213031301 53289 atoom.net. + 1Llad64NDWcz8CyBu2TsyANrJ9Tpfm5257sY + FPYF579p3c9Imwp9kYEO1zMEKgNoXBN/sQnd + YCugq3r2GAI6bfJj8sV5bt6GKuZcGHMESug4 + uh2gU0NDcCA4GPdBYGdusePwV0RNpcRnVCFA + fsACp+22j3uwRUbCh0re0ufbAs4= ) +lafhart.atoom.net. 1800 IN A 178.79.160.171 + 1800 RRSIG A 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + fruP6cvMVICXEV8NcheS73NWLCEKlO1FgW6B + 35D2GhtfYZe+M23V5YBRtlVCCrAdS0etdCOf + xH9yt3u2kVvDXuMRiQr1zJPRDEq3cScYumpd + bOO8cjHiCic5lEcRVWNNHXyGtpqTvrp9CxOu + IQw1WgAlZyKj43zGg3WZi6OTKLg= ) + 14400 NSEC linode.atoom.net. A RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20170112031301 20161213031301 53289 atoom.net. + 2AUWXbScL0jIJ7G6UsJAlUs+bgSprZ1zY6v/ + iVB5BAYwZD6pPky7LZdzvPEHh0aNLGIFbbU8 + SDJI7u/e4RUTlE+8yyjl6obZNfNKyJFqE5xN + 1BJ8sjFrVn6KaHIDKEOZunNb1MlMfCRkLg9O + 94zg04XEgVUfaYCPxvLs3fCEgzw= ) +voordeur.atoom.net. 1800 IN A 77.249.87.46 + 1800 RRSIG A 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + SzJz0NaKLRA/lW4CxgMHgeuQLp5QqFEjQv3I + zfPtY4joQsZn8RN8RLECcpcPKjbC8Dj6mxIJ + dd2vwhsCVlZKMNcZUOfpB7eGx1TR9HnzMkY9 + OdTt30a9+tktagrJEoy31vAhj1hJqLbSgvOa + pRr1P4ZpQ53/qH8JX/LOmqfWTdg= ) + 14400 NSEC www.atoom.net. A RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20170112031301 20161213031301 53289 atoom.net. + CETJhUJy1rKjVj9wsW1549gth+/Z37//BI6S + nxJ+2Oq63jEjlbznmyo5hvFW54DbVUod+cLo + N9PdlNQDr1XsRBgWhkKW37RkuoRVEPwqRykv + xzn9i7CgYKAAHFyWMGihBLkV9ByPp8GDR8Zr + DEkrG3ErDlBcwi3FqGZFsSOW2xg= ) +www.atoom.net. 1800 IN CNAME deb.atoom.net. + 1800 RRSIG CNAME 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + 1lhG6iTtbeesBCVOrA8a7+V2gogCuXzKgSi8 + 6K0Pzq2CwqTScdNcZvcDOIbLq45Am5p09PIj + lXnd2fw6WAxphwvRhmwCve3uTZMUt5STw7oi + 0rED7GMuFUSC/BX0XVly7NET3ECa1vaK6RhO + hDSsKPWFI7to4d1z6tQ9j9Kvm4Y= ) + 14400 NSEC atoom.net. CNAME RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20170112031301 20161213031301 53289 atoom.net. + CC4yCYP1q75/gTmPz+mVM6Lam2foPP5oTccY + RtROuTkgbt8DtAoPe304vmNazWBlGidnWJeD + YyAAe3znIHP0CgrxjD/hRL9FUzMnVrvB3mnx + 4W13wP1rE97RqJxV1kk22Wl3uCkVGy7LCjb0 + JLFvzCe2fuMe7YcTzI+t1rioTP0= ) +linode.atoom.net. 1800 IN A 176.58.119.54 + 1800 RRSIG A 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + Z4Ka4OLDha4eQNWs3GtUd1Cumr48RUnH523I + nZzGXtpQNou70qsm5Jt8n/HmsZ4L5DoxomRz + rgZTGnrqj43+A16UUGfVEk6SfUUHOgxgspQW + zoaqk5/5mQO1ROsLKY8RqaRqzvbToHvqeZEh + VkTPVA02JK9UFlKqoyxj72CLvkI= ) + 1800 AAAA 2a01:7e00::f03c:91ff:fe79:234c + 1800 RRSIG AAAA 8 3 1800 ( + 20170112031301 20161213031301 53289 atoom.net. + l+9Qce/EQyKrTJVKLv7iatjuCO285ckd5Oie + P2LzWVsL4tW04oHzieKZwIuNBRE+px8g5qrT + LIK2TikCGL1xHAd7CT7gbCtDcZ7jHmSTmMTJ + 405nOV3G3xWelreLI5Fn5ck8noEsF64kiw1y + XfkyQn2B914zFH/okG2fzJ1qolQ= ) + 14400 NSEC voordeur.atoom.net. A AAAA RRSIG NSEC + 14400 RRSIG NSEC 8 3 14400 ( + 20170112031301 20161213031301 53289 atoom.net. + Owzmz7QrVL2Gw2njEsUVEknMl2amx1HG9X3K + tO+Ihyy4tApiUFxUjAu3P/30QdqbB85h7s// + ipwX/AmQJNoxTScR3nHt9qDqJ044DPmiuh0l + NuIjguyZRANApmKCTA6AoxXIUqToIIjfVzi/ + PxXE6T3YIPlK7Bxgv1lcCBJ1fmE= )` + +const atoom = "atoom.net." diff --git a/plugin/file/include_test.go b/plugin/file/include_test.go new file mode 100644 index 000000000..fad91df5c --- /dev/null +++ b/plugin/file/include_test.go @@ -0,0 +1,32 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/test" +) + +// Make sure the external miekg/dns dependency is up to date + +func TestInclude(t *testing.T) { + + name, rm, err := test.TempFile(".", "foo\tIN\tA\t127.0.0.1\n") + if err != nil { + t.Fatalf("Unable to create tmpfile %q: %s", name, err) + } + defer rm() + + zone := `$ORIGIN example.org. +@ IN SOA sns.dns.icann.org. noc.dns.icann.org. 2017042766 7200 3600 1209600 3600 +$INCLUDE ` + name + "\n" + + z, err := Parse(strings.NewReader(zone), "example.org.", "test", 0) + if err != nil { + t.Errorf("Unable to parse zone %q: %s", "example.org.", err) + } + + if _, ok := z.Search("foo.example.org."); !ok { + t.Errorf("Failed to find %q in parsed zone", "foo.example.org.") + } +} diff --git a/plugin/file/lookup.go b/plugin/file/lookup.go new file mode 100644 index 000000000..cf2f06841 --- /dev/null +++ b/plugin/file/lookup.go @@ -0,0 +1,467 @@ +package file + +import ( + "github.com/coredns/coredns/plugin/file/tree" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// Result is the result of a Lookup +type Result int + +const ( + // Success is a successful lookup. + Success Result = iota + // NameError indicates a nameerror + NameError + // Delegation indicates the lookup resulted in a delegation. + Delegation + // NoData indicates the lookup resulted in a NODATA. + NoData + // ServerFailure indicates a server failure during the lookup. + ServerFailure +) + +// Lookup looks up qname and qtype in the zone. When do is true DNSSEC records are included. +// Three sets of records are returned, one for the answer, one for authority and one for the additional section. +func (z *Zone) Lookup(state request.Request, qname string) ([]dns.RR, []dns.RR, []dns.RR, Result) { + + qtype := state.QType() + do := state.Do() + + if !z.NoReload { + z.reloadMu.RLock() + } + defer func() { + if !z.NoReload { + z.reloadMu.RUnlock() + } + }() + + // If z is a secondary zone we might not have transferred it, meaning we have + // all zone context setup, except the actual record. This means (for one thing) the apex + // is empty and we don't have a SOA record. + soa := z.Apex.SOA + if soa == nil { + return nil, nil, nil, ServerFailure + } + + if qtype == dns.TypeSOA { + return z.soa(do), z.ns(do), nil, Success + } + if qtype == dns.TypeNS && qname == z.origin { + nsrrs := z.ns(do) + glue := z.Glue(nsrrs, do) + return nsrrs, nil, glue, Success + } + + var ( + found, shot bool + parts string + i int + elem, wildElem *tree.Elem + ) + + // Lookup: + // * Per label from the right, look if it exists. We do this to find potential + // delegation records. + // * If the per-label search finds nothing, we will look for the wildcard at the + // level. If found we keep it around. If we don't find the complete name we will + // use the wildcard. + // + // Main for-loop handles delegation and finding or not finding the qname. + // If found we check if it is a CNAME/DNAME and do CNAME processing + // We also check if we have type and do a nodata resposne. + // + // If not found, we check the potential wildcard, and use that for further processing. + // If not found and no wildcard we will process this as an NXDOMAIN response. + for { + parts, shot = z.nameFromRight(qname, i) + // We overshot the name, break and check if we previously found something. + if shot { + break + } + + elem, found = z.Tree.Search(parts) + if !found { + // Apex will always be found, when we are here we can search for a wildcard + // and save the result of that search. So when nothing match, but we have a + // wildcard we should expand the wildcard. + + wildcard := replaceWithAsteriskLabel(parts) + if wild, found := z.Tree.Search(wildcard); found { + wildElem = wild + } + + // Keep on searching, because maybe we hit an empty-non-terminal (which aren't + // stored in the tree. Only when we have match the full qname (and possible wildcard + // we can be confident that we didn't find anything. + i++ + continue + } + + // If we see DNAME records, we should return those. + if dnamerrs := elem.Types(dns.TypeDNAME); dnamerrs != nil { + // Only one DNAME is allowed per name. We just pick the first one to synthesize from. + dname := dnamerrs[0] + if cname := synthesizeCNAME(state.Name(), dname.(*dns.DNAME)); cname != nil { + answer, ns, extra, rcode := z.searchCNAME(state, elem, []dns.RR{cname}) + + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, dns.TypeDNAME) + dnamerrs = append(dnamerrs, sigs...) + } + + // The relevant DNAME RR should be included in the answer section, + // if the DNAME is being employed as a substitution instruction. + answer = append(dnamerrs, answer...) + + return answer, ns, extra, rcode + } + // The domain name that owns a DNAME record is allowed to have other RR types + // at that domain name, except those have restrictions on what they can coexist + // with (e.g. another DNAME). So there is nothing special left here. + } + + // If we see NS records, it means the name as been delegated, and we should return the delegation. + if nsrrs := elem.Types(dns.TypeNS); nsrrs != nil { + glue := z.Glue(nsrrs, do) + // If qtype == NS, we should returns success to put RRs in answer. + if qtype == dns.TypeNS { + return nsrrs, nil, glue, Success + } + + if do { + dss := z.typeFromElem(elem, dns.TypeDS, do) + nsrrs = append(nsrrs, dss...) + } + + return nil, nsrrs, glue, Delegation + } + + i++ + } + + // What does found and !shot mean - do we ever hit it? + if found && !shot { + return nil, nil, nil, ServerFailure + } + + // Found entire name. + if found && shot { + + if rrs := elem.Types(dns.TypeCNAME); len(rrs) > 0 && qtype != dns.TypeCNAME { + return z.searchCNAME(state, elem, rrs) + } + + rrs := elem.Types(qtype, qname) + + // NODATA + if len(rrs) == 0 { + ret := z.soa(do) + if do { + nsec := z.typeFromElem(elem, dns.TypeNSEC, do) + ret = append(ret, nsec...) + } + return nil, ret, nil, NoData + } + + // Additional section processing for MX, SRV. Check response and see if any of the names are in baliwick - + // if so add IP addresses to the additional section. + additional := additionalProcessing(z, rrs, do) + + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, qtype) + rrs = append(rrs, sigs...) + } + + return rrs, z.ns(do), additional, Success + + } + + // Haven't found the original name. + + // Found wildcard. + if wildElem != nil { + auth := z.ns(do) + + if rrs := wildElem.Types(dns.TypeCNAME, qname); len(rrs) > 0 { + return z.searchCNAME(state, wildElem, rrs) + } + + rrs := wildElem.Types(qtype, qname) + + // NODATA response. + if len(rrs) == 0 { + ret := z.soa(do) + if do { + nsec := z.typeFromElem(wildElem, dns.TypeNSEC, do) + ret = append(ret, nsec...) + } + return nil, ret, nil, Success + } + + if do { + // An NSEC is needed to say no longer name exists under this wildcard. + if deny, found := z.Tree.Prev(qname); found { + nsec := z.typeFromElem(deny, dns.TypeNSEC, do) + auth = append(auth, nsec...) + } + + sigs := wildElem.Types(dns.TypeRRSIG, qname) + sigs = signatureForSubType(sigs, qtype) + rrs = append(rrs, sigs...) + + } + return rrs, auth, nil, Success + } + + rcode := NameError + + // Hacky way to get around empty-non-terminals. If a longer name does exist, but this qname, does not, it + // must be an empty-non-terminal. If so, we do the proper NXDOMAIN handling, but set the rcode to be success. + if x, found := z.Tree.Next(qname); found { + if dns.IsSubDomain(qname, x.Name()) { + rcode = Success + } + } + + ret := z.soa(do) + if do { + deny, _ := z.Tree.Prev(qname) // TODO(miek): *found* was not used here. + nsec := z.typeFromElem(deny, dns.TypeNSEC, do) + ret = append(ret, nsec...) + + if rcode != NameError { + goto Out + } + + ce, found := z.ClosestEncloser(qname) + + // wildcard denial only for NXDOMAIN + if found { + // wildcard denial + wildcard := "*." + ce.Name() + if ss, found := z.Tree.Prev(wildcard); found { + // Only add this nsec if it is different than the one already added + if ss.Name() != deny.Name() { + nsec := z.typeFromElem(ss, dns.TypeNSEC, do) + ret = append(ret, nsec...) + } + } + } + + } +Out: + return nil, ret, nil, rcode +} + +// Return type tp from e and add signatures (if they exists) and do is true. +func (z *Zone) typeFromElem(elem *tree.Elem, tp uint16, do bool) []dns.RR { + rrs := elem.Types(tp) + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, tp) + if len(sigs) > 0 { + rrs = append(rrs, sigs...) + } + } + return rrs +} + +func (z *Zone) soa(do bool) []dns.RR { + if do { + ret := append([]dns.RR{z.Apex.SOA}, z.Apex.SIGSOA...) + return ret + } + return []dns.RR{z.Apex.SOA} +} + +func (z *Zone) ns(do bool) []dns.RR { + if do { + ret := append(z.Apex.NS, z.Apex.SIGNS...) + return ret + } + return z.Apex.NS +} + +// TODO(miek): should be better named, like aditionalProcessing? +func (z *Zone) searchCNAME(state request.Request, elem *tree.Elem, rrs []dns.RR) ([]dns.RR, []dns.RR, []dns.RR, Result) { + + qtype := state.QType() + do := state.Do() + + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, dns.TypeCNAME) + if len(sigs) > 0 { + rrs = append(rrs, sigs...) + } + } + + targetName := rrs[0].(*dns.CNAME).Target + elem, _ = z.Tree.Search(targetName) + if elem == nil { + if !dns.IsSubDomain(z.origin, targetName) { + rrs = append(rrs, z.externalLookup(state, targetName, qtype)...) + } + return rrs, z.ns(do), nil, Success + } + + i := 0 + +Redo: + cname := elem.Types(dns.TypeCNAME) + if len(cname) > 0 { + rrs = append(rrs, cname...) + + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, dns.TypeCNAME) + if len(sigs) > 0 { + rrs = append(rrs, sigs...) + } + } + targetName := cname[0].(*dns.CNAME).Target + elem, _ = z.Tree.Search(targetName) + if elem == nil { + if !dns.IsSubDomain(z.origin, targetName) { + if !dns.IsSubDomain(z.origin, targetName) { + rrs = append(rrs, z.externalLookup(state, targetName, qtype)...) + } + } + return rrs, z.ns(do), nil, Success + } + + i++ + if i > maxChain { + return rrs, z.ns(do), nil, Success + } + + goto Redo + } + + targets := cnameForType(elem.All(), qtype) + if len(targets) > 0 { + rrs = append(rrs, targets...) + + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, qtype) + if len(sigs) > 0 { + rrs = append(rrs, sigs...) + } + } + } + + return rrs, z.ns(do), nil, Success +} + +func cnameForType(targets []dns.RR, origQtype uint16) []dns.RR { + ret := []dns.RR{} + for _, target := range targets { + if target.Header().Rrtype == origQtype { + ret = append(ret, target) + } + } + return ret +} + +func (z *Zone) externalLookup(state request.Request, target string, qtype uint16) []dns.RR { + m, e := z.Proxy.Lookup(state, target, qtype) + if e != nil { + // TODO(miek): debugMsg for this as well? Log? + return nil + } + return m.Answer +} + +// signatureForSubType range through the signature and return the correct ones for the subtype. +func signatureForSubType(rrs []dns.RR, subtype uint16) []dns.RR { + sigs := []dns.RR{} + for _, sig := range rrs { + if s, ok := sig.(*dns.RRSIG); ok { + if s.TypeCovered == subtype { + sigs = append(sigs, s) + } + } + } + return sigs +} + +// Glue returns any potential glue records for nsrrs. +func (z *Zone) Glue(nsrrs []dns.RR, do bool) []dns.RR { + glue := []dns.RR{} + for _, rr := range nsrrs { + if ns, ok := rr.(*dns.NS); ok && dns.IsSubDomain(ns.Header().Name, ns.Ns) { + glue = append(glue, z.searchGlue(ns.Ns, do)...) + } + } + return glue +} + +// searchGlue looks up A and AAAA for name. +func (z *Zone) searchGlue(name string, do bool) []dns.RR { + glue := []dns.RR{} + + // A + if elem, found := z.Tree.Search(name); found { + glue = append(glue, elem.Types(dns.TypeA)...) + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, dns.TypeA) + glue = append(glue, sigs...) + } + } + + // AAAA + if elem, found := z.Tree.Search(name); found { + glue = append(glue, elem.Types(dns.TypeAAAA)...) + if do { + sigs := elem.Types(dns.TypeRRSIG) + sigs = signatureForSubType(sigs, dns.TypeAAAA) + glue = append(glue, sigs...) + } + } + return glue +} + +// additionalProcessing checks the current answer section and retrieves A or AAAA records +// (and possible SIGs) to need to be put in the additional section. +func additionalProcessing(z *Zone, answer []dns.RR, do bool) (extra []dns.RR) { + for _, rr := range answer { + name := "" + switch x := rr.(type) { + case *dns.SRV: + name = x.Target + case *dns.MX: + name = x.Mx + } + if !dns.IsSubDomain(z.origin, name) { + continue + } + + elem, _ := z.Tree.Search(name) + if elem == nil { + continue + } + + sigs := elem.Types(dns.TypeRRSIG) + for _, addr := range []uint16{dns.TypeA, dns.TypeAAAA} { + if a := elem.Types(addr); a != nil { + extra = append(extra, a...) + if do { + sig := signatureForSubType(sigs, addr) + extra = append(extra, sig...) + } + } + } + } + + return extra +} + +const maxChain = 8 diff --git a/plugin/file/lookup_test.go b/plugin/file/lookup_test.go new file mode 100644 index 000000000..8fd93fd8e --- /dev/null +++ b/plugin/file/lookup_test.go @@ -0,0 +1,194 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var dnsTestCases = []test.Case{ + { + Qname: "www.miek.nl.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("a.miek.nl. 1800 IN A 139.162.196.78"), + test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl."), + }, + Ns: miekAuth, + }, + { + Qname: "www.miek.nl.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("a.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl."), + }, + Ns: miekAuth, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeSOA, + Answer: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + Ns: miekAuth, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + Ns: miekAuth, + }, + { + Qname: "mIeK.NL.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + Ns: miekAuth, + }, + { + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx2.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx3.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 5 alt1.aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 5 alt2.aspmx.l.google.com."), + }, + Ns: miekAuth, + }, + { + Qname: "a.miek.nl.", Qtype: dns.TypeSRV, + Ns: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + }, + { + Qname: "b.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400"), + }, + }, + { + Qname: "srv.miek.nl.", Qtype: dns.TypeSRV, + Answer: []dns.RR{ + test.SRV("srv.miek.nl. 1800 IN SRV 10 10 8080 a.miek.nl."), + }, + Extra: []dns.RR{ + test.A("a.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + Ns: miekAuth, + }, + { + Qname: "mx.miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("mx.miek.nl. 1800 IN MX 10 a.miek.nl."), + }, + Extra: []dns.RR{ + test.A("a.miek.nl. 1800 IN A 139.162.196.78"), + test.AAAA("a.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"), + }, + Ns: miekAuth, + }, +} + +const ( + testzone = "miek.nl." + testzone1 = "dnssex.nl." +) + +func TestLookup(t *testing.T) { + zone, err := Parse(strings.NewReader(dbMiekNL), testzone, "stdin", 0) + if err != nil { + t.Fatalf("expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}} + ctx := context.TODO() + + for _, tc := range dnsTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +func TestLookupNil(t *testing.T) { + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: nil}, Names: []string{testzone}}} + ctx := context.TODO() + + m := dnsTestCases[0].Msg() + rec := dnsrecorder.New(&test.ResponseWriter{}) + fm.ServeDNS(ctx, rec, m) +} + +func BenchmarkFileLookup(b *testing.B) { + zone, err := Parse(strings.NewReader(dbMiekNL), testzone, "stdin", 0) + if err != nil { + return + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}} + ctx := context.TODO() + rec := dnsrecorder.New(&test.ResponseWriter{}) + + tc := test.Case{ + Qname: "www.miek.nl.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl."), + test.A("a.miek.nl. 1800 IN A 139.162.196.78"), + }, + } + + m := tc.Msg() + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + fm.ServeDNS(ctx, rec, m) + } +} + +const dbMiekNL = ` +$TTL 30M +$ORIGIN miek.nl. +@ IN SOA linode.atoom.net. miek.miek.nl. ( + 1282630057 ; Serial + 4H ; Refresh + 1H ; Retry + 7D ; Expire + 4H ) ; Negative Cache TTL + IN NS linode.atoom.net. + IN NS ns-ext.nlnetlabs.nl. + IN NS omval.tednet.nl. + IN NS ext.ns.whyscream.net. + + IN MX 1 aspmx.l.google.com. + IN MX 5 alt1.aspmx.l.google.com. + IN MX 5 alt2.aspmx.l.google.com. + IN MX 10 aspmx2.googlemail.com. + IN MX 10 aspmx3.googlemail.com. + + IN A 139.162.196.78 + IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 + +a IN A 139.162.196.78 + IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 +www IN CNAME a +archive IN CNAME a + +srv IN SRV 10 10 8080 a.miek.nl. +mx IN MX 10 a.miek.nl.` diff --git a/plugin/file/notify.go b/plugin/file/notify.go new file mode 100644 index 000000000..68850e0d3 --- /dev/null +++ b/plugin/file/notify.go @@ -0,0 +1,82 @@ +package file + +import ( + "fmt" + "log" + "net" + + "github.com/coredns/coredns/plugin/pkg/rcode" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// isNotify checks if state is a notify message and if so, will *also* check if it +// is from one of the configured masters. If not it will not be a valid notify +// message. If the zone z is not a secondary zone the message will also be ignored. +func (z *Zone) isNotify(state request.Request) bool { + if state.Req.Opcode != dns.OpcodeNotify { + return false + } + if len(z.TransferFrom) == 0 { + return false + } + // If remote IP matches we accept. + remote := state.IP() + for _, f := range z.TransferFrom { + from, _, err := net.SplitHostPort(f) + if err != nil { + continue + } + if from == remote { + return true + } + } + return false +} + +// Notify will send notifies to all configured TransferTo IP addresses. +func (z *Zone) Notify() { + go notify(z.origin, z.TransferTo) +} + +// notify sends notifies to the configured remote servers. It will try up to three times +// before giving up on a specific remote. We will sequentially loop through "to" +// until they all have replied (or have 3 failed attempts). +func notify(zone string, to []string) error { + m := new(dns.Msg) + m.SetNotify(zone) + c := new(dns.Client) + + for _, t := range to { + if t == "*" { + continue + } + if err := notifyAddr(c, m, t); err != nil { + log.Printf("[ERROR] " + err.Error()) + } else { + log.Printf("[INFO] Sent notify for zone %q to %q", zone, t) + } + } + return nil +} + +func notifyAddr(c *dns.Client, m *dns.Msg, s string) error { + var err error + + code := dns.RcodeServerFailure + for i := 0; i < 3; i++ { + ret, _, err := c.Exchange(m, s) + if err != nil { + continue + } + code = ret.Rcode + if code == dns.RcodeSuccess { + return nil + } + } + if err != nil { + return fmt.Errorf("notify for zone %q was not accepted by %q: %q", m.Question[0].Name, s, err) + } + return fmt.Errorf("notify for zone %q was not accepted by %q: rcode was %q", m.Question[0].Name, s, rcode.ToString(code)) +} diff --git a/plugin/file/nsec3_test.go b/plugin/file/nsec3_test.go new file mode 100644 index 000000000..6611056cb --- /dev/null +++ b/plugin/file/nsec3_test.go @@ -0,0 +1,28 @@ +package file + +import ( + "strings" + "testing" +) + +func TestParseNSEC3PARAM(t *testing.T) { + _, err := Parse(strings.NewReader(nsec3paramTest), "miek.nl", "stdin", 0) + if err == nil { + t.Fatalf("expected error when reading zone, got nothing") + } +} + +func TestParseNSEC3(t *testing.T) { + _, err := Parse(strings.NewReader(nsec3Test), "miek.nl", "stdin", 0) + if err == nil { + t.Fatalf("expected error when reading zone, got nothing") + } +} + +const nsec3paramTest = `miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1460175181 14400 3600 604800 14400 +miek.nl. 1800 IN NS omval.tednet.nl. +miek.nl. 0 IN NSEC3PARAM 1 0 5 A3DEBC9CC4F695C7` + +const nsec3Test = `example.org. 1800 IN SOA sns.dns.icann.org. noc.dns.icann.org. 2016082508 7200 3600 1209600 3600 +aub8v9ce95ie18spjubsr058h41n7pa5.example.org. 284 IN NSEC3 1 1 5 D0CBEAAF0AC77314 AUB95P93VPKP55G6U5S4SGS7LS61ND85 NS SOA TXT RRSIG DNSKEY NSEC3PARAM +aub8v9ce95ie18spjubsr058h41n7pa5.example.org. 284 IN RRSIG NSEC3 8 2 600 20160910232502 20160827231002 14028 example.org. XBNpA7KAIjorPbXvTinOHrc1f630aHic2U716GHLHA4QMx9cl9ss4QjR Wj2UpDM9zBW/jNYb1xb0yjQoez/Jv200w0taSWjRci5aUnRpOi9bmcrz STHb6wIUjUsbJ+NstQsUwVkj6679UviF1FqNwr4GlJnWG3ZrhYhE+NI6 s0k=` diff --git a/plugin/file/reload.go b/plugin/file/reload.go new file mode 100644 index 000000000..18e949a94 --- /dev/null +++ b/plugin/file/reload.go @@ -0,0 +1,72 @@ +package file + +import ( + "log" + "os" + "path" + + "github.com/fsnotify/fsnotify" +) + +// Reload reloads a zone when it is changed on disk. If z.NoRoload is true, no reloading will be done. +func (z *Zone) Reload() error { + if z.NoReload { + return nil + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + err = watcher.Add(path.Dir(z.file)) + if err != nil { + return err + } + + go func() { + // TODO(miek): needs to be killed on reload. + for { + select { + case event := <-watcher.Events: + if path.Clean(event.Name) == z.file { + + reader, err := os.Open(z.file) + if err != nil { + log.Printf("[ERROR] Failed to open `%s' for `%s': %v", z.file, z.origin, err) + continue + } + + serial := z.SOASerialIfDefined() + zone, err := Parse(reader, z.origin, z.file, serial) + if err != nil { + log.Printf("[WARNING] Parsing zone `%s': %v", z.origin, err) + continue + } + + // copy elements we need + z.reloadMu.Lock() + z.Apex = zone.Apex + z.Tree = zone.Tree + z.reloadMu.Unlock() + + log.Printf("[INFO] Successfully reloaded zone `%s'", z.origin) + z.Notify() + } + case <-z.ReloadShutdown: + watcher.Close() + return + } + } + }() + return nil +} + +// SOASerialIfDefined returns the SOA's serial if the zone has a SOA record in the Apex, or +// -1 otherwise. +func (z *Zone) SOASerialIfDefined() int64 { + z.reloadMu.Lock() + defer z.reloadMu.Unlock() + if z.Apex.SOA != nil { + return int64(z.Apex.SOA.Serial) + } + return -1 +} diff --git a/plugin/file/reload_test.go b/plugin/file/reload_test.go new file mode 100644 index 000000000..601c426d3 --- /dev/null +++ b/plugin/file/reload_test.go @@ -0,0 +1,82 @@ +package file + +import ( + "io/ioutil" + "log" + "os" + "strings" + "testing" + "time" + + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +func TestZoneReload(t *testing.T) { + log.SetOutput(ioutil.Discard) + + fileName, rm, err := test.TempFile(".", reloadZoneTest) + if err != nil { + t.Fatalf("failed to create zone: %s", err) + } + defer rm() + reader, err := os.Open(fileName) + if err != nil { + t.Fatalf("failed to open zone: %s", err) + } + z, err := Parse(reader, "miek.nl", fileName, 0) + if err != nil { + t.Fatalf("failed to parse zone: %s", err) + } + + z.Reload() + + r := new(dns.Msg) + r.SetQuestion("miek.nl", dns.TypeSOA) + state := request.Request{W: &test.ResponseWriter{}, Req: r} + if _, _, _, res := z.Lookup(state, "miek.nl."); res != Success { + t.Fatalf("failed to lookup, got %d", res) + } + + r = new(dns.Msg) + r.SetQuestion("miek.nl", dns.TypeNS) + state = request.Request{W: &test.ResponseWriter{}, Req: r} + if _, _, _, res := z.Lookup(state, "miek.nl."); res != Success { + t.Fatalf("failed to lookup, got %d", res) + } + + if len(z.All()) != 5 { + t.Fatalf("expected 5 RRs, got %d", len(z.All())) + } + if err := ioutil.WriteFile(fileName, []byte(reloadZone2Test), 0644); err != nil { + t.Fatalf("failed to write new zone data: %s", err) + } + // Could still be racy, but we need to wait a bit for the event to be seen + time.Sleep(1 * time.Second) + + if len(z.All()) != 3 { + t.Fatalf("expected 3 RRs, got %d", len(z.All())) + } +} + +func TestZoneReloadSOAChange(t *testing.T) { + _, err := Parse(strings.NewReader(reloadZoneTest), "miek.nl.", "stdin", 1460175181) + if err == nil { + t.Fatalf("zone should not have been re-parsed") + } + +} + +const reloadZoneTest = `miek.nl. 1627 IN SOA linode.atoom.net. miek.miek.nl. 1460175181 14400 3600 604800 14400 +miek.nl. 1627 IN NS ext.ns.whyscream.net. +miek.nl. 1627 IN NS omval.tednet.nl. +miek.nl. 1627 IN NS linode.atoom.net. +miek.nl. 1627 IN NS ns-ext.nlnetlabs.nl. +` + +const reloadZone2Test = `miek.nl. 1627 IN SOA linode.atoom.net. miek.miek.nl. 1460175182 14400 3600 604800 14400 +miek.nl. 1627 IN NS ext.ns.whyscream.net. +miek.nl. 1627 IN NS omval.tednet.nl. +` diff --git a/plugin/file/secondary.go b/plugin/file/secondary.go new file mode 100644 index 000000000..a37d62442 --- /dev/null +++ b/plugin/file/secondary.go @@ -0,0 +1,199 @@ +package file + +import ( + "log" + "math/rand" + "time" + + "github.com/miekg/dns" +) + +// TransferIn retrieves the zone from the masters, parses it and sets it live. +func (z *Zone) TransferIn() error { + if len(z.TransferFrom) == 0 { + return nil + } + m := new(dns.Msg) + m.SetAxfr(z.origin) + + z1 := z.Copy() + var ( + Err error + tr string + ) + +Transfer: + for _, tr = range z.TransferFrom { + t := new(dns.Transfer) + c, err := t.In(m, tr) + if err != nil { + log.Printf("[ERROR] Failed to setup transfer `%s' with `%q': %v", z.origin, tr, err) + Err = err + continue Transfer + } + for env := range c { + if env.Error != nil { + log.Printf("[ERROR] Failed to transfer `%s' from %q: %v", z.origin, tr, env.Error) + Err = env.Error + continue Transfer + } + for _, rr := range env.RR { + if err := z1.Insert(rr); err != nil { + log.Printf("[ERROR] Failed to parse transfer `%s' from: %q: %v", z.origin, tr, err) + Err = err + continue Transfer + } + } + } + Err = nil + break + } + if Err != nil { + return Err + } + + z.Tree = z1.Tree + z.Apex = z1.Apex + *z.Expired = false + log.Printf("[INFO] Transferred: %s from %s", z.origin, tr) + return nil +} + +// shouldTransfer checks the primaries of zone, retrieves the SOA record, checks the current serial +// and the remote serial and will return true if the remote one is higher than the locally configured one. +func (z *Zone) shouldTransfer() (bool, error) { + c := new(dns.Client) + c.Net = "tcp" // do this query over TCP to minimize spoofing + m := new(dns.Msg) + m.SetQuestion(z.origin, dns.TypeSOA) + + var Err error + serial := -1 + +Transfer: + for _, tr := range z.TransferFrom { + Err = nil + ret, _, err := c.Exchange(m, tr) + if err != nil || ret.Rcode != dns.RcodeSuccess { + Err = err + continue + } + for _, a := range ret.Answer { + if a.Header().Rrtype == dns.TypeSOA { + serial = int(a.(*dns.SOA).Serial) + break Transfer + } + } + } + if serial == -1 { + return false, Err + } + if z.Apex.SOA == nil { + return true, Err + } + return less(z.Apex.SOA.Serial, uint32(serial)), Err +} + +// less return true of a is smaller than b when taking RFC 1982 serial arithmetic into account. +func less(a, b uint32) bool { + if a < b { + return (b - a) <= MaxSerialIncrement + } + return (a - b) > MaxSerialIncrement +} + +// Update updates the secondary zone according to its SOA. It will run for the life time of the server +// and uses the SOA parameters. Every refresh it will check for a new SOA number. If that fails (for all +// server) it wil retry every retry interval. If the zone failed to transfer before the expire, the zone +// will be marked expired. +func (z *Zone) Update() error { + // If we don't have a SOA, we don't have a zone, wait for it to appear. + for z.Apex.SOA == nil { + time.Sleep(1 * time.Second) + } + retryActive := false + +Restart: + refresh := time.Second * time.Duration(z.Apex.SOA.Refresh) + retry := time.Second * time.Duration(z.Apex.SOA.Retry) + expire := time.Second * time.Duration(z.Apex.SOA.Expire) + + if refresh < time.Hour { + refresh = time.Hour + } + if retry < time.Hour { + retry = time.Hour + } + if refresh > 24*time.Hour { + refresh = 24 * time.Hour + } + if retry > 12*time.Hour { + retry = 12 * time.Hour + } + + refreshTicker := time.NewTicker(refresh) + retryTicker := time.NewTicker(retry) + expireTicker := time.NewTicker(expire) + + for { + select { + case <-expireTicker.C: + if !retryActive { + break + } + *z.Expired = true + + case <-retryTicker.C: + if !retryActive { + break + } + + time.Sleep(jitter(2000)) // 2s randomize + + ok, err := z.shouldTransfer() + if err != nil && ok { + if err := z.TransferIn(); err != nil { + // transfer failed, leave retryActive true + break + } + retryActive = false + // transfer OK, possible new SOA, stop timers and redo + refreshTicker.Stop() + retryTicker.Stop() + expireTicker.Stop() + goto Restart + } + + case <-refreshTicker.C: + + time.Sleep(jitter(5000)) // 5s randomize + + ok, err := z.shouldTransfer() + retryActive = err != nil + if err != nil && ok { + if err := z.TransferIn(); err != nil { + // transfer failed + retryActive = true + break + } + retryActive = false + // transfer OK, possible new SOA, stop timers and redo + refreshTicker.Stop() + retryTicker.Stop() + expireTicker.Stop() + goto Restart + } + } + } +} + +// jitter returns a random duration between [0,n) * time.Millisecond +func jitter(n int) time.Duration { + r := rand.Intn(n) + return time.Duration(r) * time.Millisecond + +} + +// MaxSerialIncrement is the maximum difference between two serial numbers. If the difference between +// two serials is greater than this number, the smaller one is considered greater. +const MaxSerialIncrement uint32 = 2147483647 diff --git a/plugin/file/secondary_test.go b/plugin/file/secondary_test.go new file mode 100644 index 000000000..8f2c2e15f --- /dev/null +++ b/plugin/file/secondary_test.go @@ -0,0 +1,168 @@ +package file + +import ( + "fmt" + "io/ioutil" + "log" + "testing" + + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// TODO(miek): should test notifies as well, ie start test server (a real coredns one)... +// setup other test server that sends notify, see if CoreDNS comes calling for a zone +// tranfer + +func TestLess(t *testing.T) { + const ( + min = 0 + max = 4294967295 + low = 12345 + high = 4000000000 + ) + + if less(min, max) { + t.Fatalf("less: should be false") + } + if !less(max, min) { + t.Fatalf("less: should be true") + } + if !less(high, low) { + t.Fatalf("less: should be true") + } + if !less(7, 9) { + t.Fatalf("less; should be true") + } +} + +type soa struct { + serial uint32 +} + +func (s *soa) Handler(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetReply(req) + switch req.Question[0].Qtype { + case dns.TypeSOA: + m.Answer = make([]dns.RR, 1) + m.Answer[0] = test.SOA(fmt.Sprintf("%s IN SOA bla. bla. %d 0 0 0 0 ", testZone, s.serial)) + w.WriteMsg(m) + case dns.TypeAXFR: + m.Answer = make([]dns.RR, 4) + m.Answer[0] = test.SOA(fmt.Sprintf("%s IN SOA bla. bla. %d 0 0 0 0 ", testZone, s.serial)) + m.Answer[1] = test.A(fmt.Sprintf("%s IN A 127.0.0.1", testZone)) + m.Answer[2] = test.A(fmt.Sprintf("%s IN A 127.0.0.1", testZone)) + m.Answer[3] = test.SOA(fmt.Sprintf("%s IN SOA bla. bla. %d 0 0 0 0 ", testZone, s.serial)) + w.WriteMsg(m) + } +} + +func (s *soa) TransferHandler(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetReply(req) + m.Answer = make([]dns.RR, 1) + m.Answer[0] = test.SOA(fmt.Sprintf("%s IN SOA bla. bla. %d 0 0 0 0 ", testZone, s.serial)) + w.WriteMsg(m) +} + +const testZone = "secondary.miek.nl." + +func TestShouldTransfer(t *testing.T) { + soa := soa{250} + log.SetOutput(ioutil.Discard) + + dns.HandleFunc(testZone, soa.Handler) + defer dns.HandleRemove(testZone) + + s, addrstr, err := test.TCPServer("127.0.0.1:0") + if err != nil { + t.Fatalf("unable to run test server: %v", err) + } + defer s.Shutdown() + + z := new(Zone) + z.origin = testZone + z.TransferFrom = []string{addrstr} + + // when we have a nil SOA (initial state) + should, err := z.shouldTransfer() + if err != nil { + t.Fatalf("unable to run shouldTransfer: %v", err) + } + if !should { + t.Fatalf("shouldTransfer should return true for serial: %d", soa.serial) + } + // Serial smaller + z.Apex.SOA = test.SOA(fmt.Sprintf("%s IN SOA bla. bla. %d 0 0 0 0 ", testZone, soa.serial-1)) + should, err = z.shouldTransfer() + if err != nil { + t.Fatalf("unable to run shouldTransfer: %v", err) + } + if !should { + t.Fatalf("shouldTransfer should return true for serial: %q", soa.serial-1) + } + // Serial equal + z.Apex.SOA = test.SOA(fmt.Sprintf("%s IN SOA bla. bla. %d 0 0 0 0 ", testZone, soa.serial)) + should, err = z.shouldTransfer() + if err != nil { + t.Fatalf("unable to run shouldTransfer: %v", err) + } + if should { + t.Fatalf("shouldTransfer should return false for serial: %d", soa.serial) + } +} + +func TestTransferIn(t *testing.T) { + soa := soa{250} + log.SetOutput(ioutil.Discard) + + dns.HandleFunc(testZone, soa.Handler) + defer dns.HandleRemove(testZone) + + s, addrstr, err := test.TCPServer("127.0.0.1:0") + if err != nil { + t.Fatalf("unable to run test server: %v", err) + } + defer s.Shutdown() + + z := new(Zone) + z.Expired = new(bool) + z.origin = testZone + z.TransferFrom = []string{addrstr} + + err = z.TransferIn() + if err != nil { + t.Fatalf("unable to run TransferIn: %v", err) + } + if z.Apex.SOA.String() != fmt.Sprintf("%s 3600 IN SOA bla. bla. 250 0 0 0 0", testZone) { + t.Fatalf("unknown SOA transferred") + } +} + +func TestIsNotify(t *testing.T) { + z := new(Zone) + z.Expired = new(bool) + z.origin = testZone + state := newRequest(testZone, dns.TypeSOA) + // need to set opcode + state.Req.Opcode = dns.OpcodeNotify + + z.TransferFrom = []string{"10.240.0.1:53"} // IP from from testing/responseWriter + if !z.isNotify(state) { + t.Fatal("should have been valid notify") + } + z.TransferFrom = []string{"10.240.0.2:53"} + if z.isNotify(state) { + t.Fatal("should have been invalid notify") + } +} + +func newRequest(zone string, qtype uint16) request.Request { + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + m.SetEdns0(4097, true) + return request.Request{W: &test.ResponseWriter{}, Req: m} +} diff --git a/plugin/file/setup.go b/plugin/file/setup.go new file mode 100644 index 000000000..bf0523c54 --- /dev/null +++ b/plugin/file/setup.go @@ -0,0 +1,171 @@ +package file + +import ( + "fmt" + "os" + "path" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/proxy" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("file", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + zones, err := fileParse(c) + if err != nil { + return plugin.Error("file", err) + } + + // Add startup functions to notify the master(s). + for _, n := range zones.Names { + z := zones.Z[n] + c.OnStartup(func() error { + z.StartupOnce.Do(func() { + if len(z.TransferTo) > 0 { + z.Notify() + } + z.Reload() + }) + return nil + }) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return File{Next: next, Zones: zones} + }) + + return nil +} + +func fileParse(c *caddy.Controller) (Zones, error) { + z := make(map[string]*Zone) + names := []string{} + origins := []string{} + + config := dnsserver.GetConfig(c) + + for c.Next() { + // file db.file [zones...] + if !c.NextArg() { + return Zones{}, c.ArgErr() + } + fileName := c.Val() + + origins = make([]string, len(c.ServerBlockKeys)) + copy(origins, c.ServerBlockKeys) + args := c.RemainingArgs() + if len(args) > 0 { + origins = args + } + + if !path.IsAbs(fileName) && config.Root != "" { + fileName = path.Join(config.Root, fileName) + } + + reader, err := os.Open(fileName) + if err != nil { + // bail out + return Zones{}, err + } + + for i := range origins { + origins[i] = plugin.Host(origins[i]).Normalize() + zone, err := Parse(reader, origins[i], fileName, 0) + if err == nil { + z[origins[i]] = zone + } else { + return Zones{}, err + } + names = append(names, origins[i]) + } + + noReload := false + prxy := proxy.Proxy{} + t := []string{} + var e error + + for c.NextBlock() { + switch c.Val() { + case "transfer": + t, _, e = TransferParse(c, false) + if e != nil { + return Zones{}, e + } + + case "no_reload": + noReload = true + + case "upstream": + args := c.RemainingArgs() + if len(args) == 0 { + return Zones{}, c.ArgErr() + } + ups, err := dnsutil.ParseHostPortOrFile(args...) + if err != nil { + return Zones{}, err + } + prxy = proxy.NewLookup(ups) + default: + return Zones{}, c.Errf("unknown property '%s'", c.Val()) + } + + for _, origin := range origins { + if t != nil { + z[origin].TransferTo = append(z[origin].TransferTo, t...) + } + z[origin].NoReload = noReload + z[origin].Proxy = prxy + } + } + } + return Zones{Z: z, Names: names}, nil +} + +// TransferParse parses transfer statements: 'transfer to [address...]'. +func TransferParse(c *caddy.Controller, secondary bool) (tos, froms []string, err error) { + if !c.NextArg() { + return nil, nil, c.ArgErr() + } + value := c.Val() + switch value { + case "to": + tos = c.RemainingArgs() + for i := range tos { + if tos[i] != "*" { + normalized, err := dnsutil.ParseHostPort(tos[i], "53") + if err != nil { + return nil, nil, err + } + tos[i] = normalized + } + } + + case "from": + if !secondary { + return nil, nil, fmt.Errorf("can't use `transfer from` when not being a secondary") + } + froms = c.RemainingArgs() + for i := range froms { + if froms[i] != "*" { + normalized, err := dnsutil.ParseHostPort(froms[i], "53") + if err != nil { + return nil, nil, err + } + froms[i] = normalized + } else { + return nil, nil, fmt.Errorf("can't use '*' in transfer from") + } + } + } + return +} diff --git a/plugin/file/setup_test.go b/plugin/file/setup_test.go new file mode 100644 index 000000000..62e8476f6 --- /dev/null +++ b/plugin/file/setup_test.go @@ -0,0 +1,77 @@ +package file + +import ( + "testing" + + "github.com/coredns/coredns/plugin/test" + + "github.com/mholt/caddy" +) + +func TestFileParse(t *testing.T) { + zoneFileName1, rm, err := test.TempFile(".", dbMiekNL) + if err != nil { + t.Fatal(err) + } + defer rm() + + zoneFileName2, rm, err := test.TempFile(".", dbDnssexNLSigned) + if err != nil { + t.Fatal(err) + } + defer rm() + + tests := []struct { + inputFileRules string + shouldErr bool + expectedZones Zones + }{ + { + `file ` + zoneFileName1 + ` miek.nl { + transfer from 127.0.0.1 + }`, + true, + Zones{}, + }, + { + `file`, + true, + Zones{}, + }, + { + `file ` + zoneFileName1 + ` miek.nl.`, + false, + Zones{Names: []string{"miek.nl."}}, + }, + { + `file ` + zoneFileName2 + ` dnssex.nl.`, + false, + Zones{Names: []string{"dnssex.nl."}}, + }, + { + `file ` + zoneFileName2 + ` 10.0.0.0/8`, + false, + Zones{Names: []string{"10.in-addr.arpa."}}, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + actualZones, err := fileParse(c) + + if err == nil && test.shouldErr { + t.Fatalf("Test %d expected errors, but got no error", i) + } else if err != nil && !test.shouldErr { + t.Fatalf("Test %d expected no errors, but got '%v'", i, err) + } else { + if len(actualZones.Names) != len(test.expectedZones.Names) { + t.Fatalf("Test %d expected %v, got %v", i, test.expectedZones.Names, actualZones.Names) + } + for j, name := range test.expectedZones.Names { + if actualZones.Names[j] != name { + t.Fatalf("Test %d expected %v for %d th zone, got %v", i, name, j, actualZones.Names[j]) + } + } + } + } +} diff --git a/plugin/file/tree/all.go b/plugin/file/tree/all.go new file mode 100644 index 000000000..fd806365f --- /dev/null +++ b/plugin/file/tree/all.go @@ -0,0 +1,48 @@ +package tree + +// All traverses tree and returns all elements +func (t *Tree) All() []*Elem { + if t.Root == nil { + return nil + } + found := t.Root.all(nil) + return found +} + +func (n *Node) all(found []*Elem) []*Elem { + if n.Left != nil { + found = n.Left.all(found) + } + found = append(found, n.Elem) + if n.Right != nil { + found = n.Right.all(found) + } + return found +} + +// Do performs fn on all values stored in the tree. A boolean is returned indicating whether the +// Do traversal was interrupted by an Operation returning true. If fn alters stored values' sort +// relationships, future tree operation behaviors are undefined. +func (t *Tree) Do(fn func(e *Elem) bool) bool { + if t.Root == nil { + return false + } + return t.Root.do(fn) +} + +func (n *Node) do(fn func(e *Elem) bool) (done bool) { + if n.Left != nil { + done = n.Left.do(fn) + if done { + return + } + } + done = fn(n.Elem) + if done { + return + } + if n.Right != nil { + done = n.Right.do(fn) + } + return +} diff --git a/plugin/file/tree/elem.go b/plugin/file/tree/elem.go new file mode 100644 index 000000000..6317cc912 --- /dev/null +++ b/plugin/file/tree/elem.go @@ -0,0 +1,136 @@ +package tree + +import "github.com/miekg/dns" + +// Elem is an element in the tree. +type Elem struct { + m map[uint16][]dns.RR + name string // owner name +} + +// newElem returns a new elem. +func newElem(rr dns.RR) *Elem { + e := Elem{m: make(map[uint16][]dns.RR)} + e.m[rr.Header().Rrtype] = []dns.RR{rr} + return &e +} + +// Types returns the RRs with type qtype from e. If qname is given (only the +// first one is used), the RR are copied and the owner is replaced with qname[0]. +func (e *Elem) Types(qtype uint16, qname ...string) []dns.RR { + rrs := e.m[qtype] + + if rrs != nil && len(qname) > 0 { + copied := make([]dns.RR, len(rrs)) + for i := range rrs { + copied[i] = dns.Copy(rrs[i]) + copied[i].Header().Name = qname[0] + } + return copied + } + return rrs +} + +// All returns all RRs from e, regardless of type. +func (e *Elem) All() []dns.RR { + list := []dns.RR{} + for _, rrs := range e.m { + list = append(list, rrs...) + } + return list +} + +// Name returns the name for this node. +func (e *Elem) Name() string { + if e.name != "" { + return e.name + } + for _, rrs := range e.m { + e.name = rrs[0].Header().Name + return e.name + } + return "" +} + +// Empty returns true is e does not contain any RRs, i.e. is an +// empty-non-terminal. +func (e *Elem) Empty() bool { + return len(e.m) == 0 +} + +// Insert inserts rr into e. If rr is equal to existing rrs this is a noop. +func (e *Elem) Insert(rr dns.RR) { + t := rr.Header().Rrtype + if e.m == nil { + e.m = make(map[uint16][]dns.RR) + e.m[t] = []dns.RR{rr} + return + } + rrs, ok := e.m[t] + if !ok { + e.m[t] = []dns.RR{rr} + return + } + for _, er := range rrs { + if equalRdata(er, rr) { + return + } + } + + rrs = append(rrs, rr) + e.m[t] = rrs +} + +// Delete removes rr from e. When e is empty after the removal the returned bool is true. +func (e *Elem) Delete(rr dns.RR) (empty bool) { + if e.m == nil { + return true + } + + t := rr.Header().Rrtype + rrs, ok := e.m[t] + if !ok { + return + } + + for i, er := range rrs { + if equalRdata(er, rr) { + rrs = removeFromSlice(rrs, i) + e.m[t] = rrs + empty = len(rrs) == 0 + if empty { + delete(e.m, t) + } + return + } + } + return +} + +// Less is a tree helper function that calls less. +func Less(a *Elem, name string) int { return less(name, a.Name()) } + +// Assuming the same type and name this will check if the rdata is equal as well. +func equalRdata(a, b dns.RR) bool { + switch x := a.(type) { + // TODO(miek): more types, i.e. all types. + tests for this. + case *dns.A: + return x.A.Equal(b.(*dns.A).A) + case *dns.AAAA: + return x.AAAA.Equal(b.(*dns.AAAA).AAAA) + case *dns.MX: + if x.Mx == b.(*dns.MX).Mx && x.Preference == b.(*dns.MX).Preference { + return true + } + } + return false +} + +// removeFromSlice removes index i from the slice. +func removeFromSlice(rrs []dns.RR, i int) []dns.RR { + if i >= len(rrs) { + return rrs + } + rrs = append(rrs[:i], rrs[i+1:]...) + return rrs +} diff --git a/plugin/file/tree/less.go b/plugin/file/tree/less.go new file mode 100644 index 000000000..3b8340088 --- /dev/null +++ b/plugin/file/tree/less.go @@ -0,0 +1,59 @@ +package tree + +import ( + "bytes" + + "github.com/miekg/dns" +) + +// less returns <0 when a is less than b, 0 when they are equal and +// >0 when a is larger than b. +// The function orders names in DNSSEC canonical order: RFC 4034s section-6.1 +// +// See http://bert-hubert.blogspot.co.uk/2015/10/how-to-do-fast-canonical-ordering-of.html +// for a blog article on this implementation, although here we still go label by label. +// +// The values of a and b are *not* lowercased before the comparison! +func less(a, b string) int { + i := 1 + aj := len(a) + bj := len(b) + for { + ai, oka := dns.PrevLabel(a, i) + bi, okb := dns.PrevLabel(b, i) + if oka && okb { + return 0 + } + + // sadly this []byte will allocate... TODO(miek): check if this is needed + // for a name, otherwise compare the strings. + ab := []byte(a[ai:aj]) + bb := []byte(b[bi:bj]) + doDDD(ab) + doDDD(bb) + + res := bytes.Compare(ab, bb) + if res != 0 { + return res + } + + i++ + aj, bj = ai, bi + } +} + +func doDDD(b []byte) { + lb := len(b) + for i := 0; i < lb; i++ { + if i+3 < lb && b[i] == '\\' && isDigit(b[i+1]) && isDigit(b[i+2]) && isDigit(b[i+3]) { + b[i] = dddToByte(b[i:]) + for j := i + 1; j < lb-3; j++ { + b[j] = b[j+3] + } + lb -= 3 + } + } +} + +func isDigit(b byte) bool { return b >= '0' && b <= '9' } +func dddToByte(s []byte) byte { return (s[1]-'0')*100 + (s[2]-'0')*10 + (s[3] - '0') } diff --git a/plugin/file/tree/less_test.go b/plugin/file/tree/less_test.go new file mode 100644 index 000000000..ed021b66f --- /dev/null +++ b/plugin/file/tree/less_test.go @@ -0,0 +1,81 @@ +package tree + +import ( + "sort" + "strings" + "testing" +) + +type set []string + +func (p set) Len() int { return len(p) } +func (p set) Swap(i, j int) { p[i], p[j] = p[j], p[i] } +func (p set) Less(i, j int) bool { d := less(p[i], p[j]); return d <= 0 } + +func TestLess(t *testing.T) { + tests := []struct { + in []string + out []string + }{ + { + []string{"aaa.powerdns.de", "bbb.powerdns.net.", "xxx.powerdns.com."}, + []string{"xxx.powerdns.com.", "aaa.powerdns.de", "bbb.powerdns.net."}, + }, + { + []string{"aaa.POWERDNS.de", "bbb.PoweRdnS.net.", "xxx.powerdns.com."}, + []string{"xxx.powerdns.com.", "aaa.POWERDNS.de", "bbb.PoweRdnS.net."}, + }, + { + []string{"aaa.aaaa.aa.", "aa.aaa.a.", "bbb.bbbb.bb."}, + []string{"aa.aaa.a.", "aaa.aaaa.aa.", "bbb.bbbb.bb."}, + }, + { + []string{"aaaaa.", "aaa.", "bbb."}, + []string{"aaa.", "aaaaa.", "bbb."}, + }, + { + []string{"a.a.a.a.", "a.a.", "a.a.a."}, + []string{"a.a.", "a.a.a.", "a.a.a.a."}, + }, + { + []string{"example.", "z.example.", "a.example."}, + []string{"example.", "a.example.", "z.example."}, + }, + { + []string{"a.example.", "Z.a.example.", "z.example.", "yljkjljk.a.example.", "\\001.z.example.", "example.", "*.z.example.", "\\200.z.example.", "zABC.a.EXAMPLE."}, + []string{"example.", "a.example.", "yljkjljk.a.example.", "Z.a.example.", "zABC.a.EXAMPLE.", "z.example.", "\\001.z.example.", "*.z.example.", "\\200.z.example."}, + }, + { + // RFC3034 example. + []string{"a.example.", "Z.a.example.", "z.example.", "yljkjljk.a.example.", "example.", "*.z.example.", "zABC.a.EXAMPLE."}, + []string{"example.", "a.example.", "yljkjljk.a.example.", "Z.a.example.", "zABC.a.EXAMPLE.", "z.example.", "*.z.example."}, + }, + } + +Tests: + for j, test := range tests { + // Need to lowercase these example as the Less function does lowercase for us anymore. + for i, b := range test.in { + test.in[i] = strings.ToLower(b) + } + for i, b := range test.out { + test.out[i] = strings.ToLower(b) + } + + sort.Sort(set(test.in)) + for i := 0; i < len(test.in); i++ { + if test.in[i] != test.out[i] { + t.Errorf("Test %d: expected %s, got %s\n", j, test.out[i], test.in[i]) + n := "" + for k, in := range test.in { + if k+1 == len(test.in) { + n = "\n" + } + t.Logf("%s <-> %s\n%s", in, test.out[k], n) + } + continue Tests + } + + } + } +} diff --git a/plugin/file/tree/print.go b/plugin/file/tree/print.go new file mode 100644 index 000000000..bd86ef690 --- /dev/null +++ b/plugin/file/tree/print.go @@ -0,0 +1,62 @@ +package tree + +import "fmt" + +// Print prints a Tree. Main use is to aid in debugging. +func (t *Tree) Print() { + if t.Root == nil { + fmt.Println("<nil>") + } + t.Root.print() +} + +func (n *Node) print() { + q := newQueue() + q.push(n) + + nodesInCurrentLevel := 1 + nodesInNextLevel := 0 + + for !q.empty() { + do := q.pop() + nodesInCurrentLevel-- + + if do != nil { + fmt.Print(do.Elem.Name(), " ") + q.push(do.Left) + q.push(do.Right) + nodesInNextLevel += 2 + } + if nodesInCurrentLevel == 0 { + fmt.Println() + } + nodesInCurrentLevel = nodesInNextLevel + nodesInNextLevel = 0 + } + fmt.Println() +} + +type queue []*Node + +// newQueue returns a new queue. +func newQueue() queue { + q := queue([]*Node{}) + return q +} + +// push pushes n to the end of the queue. +func (q *queue) push(n *Node) { + *q = append(*q, n) +} + +// pop pops the first element off the queue. +func (q *queue) pop() *Node { + n := (*q)[0] + *q = (*q)[1:] + return n +} + +// empty returns true when the queue contains zero nodes. +func (q *queue) empty() bool { + return len(*q) == 0 +} diff --git a/plugin/file/tree/tree.go b/plugin/file/tree/tree.go new file mode 100644 index 000000000..ed33c09a4 --- /dev/null +++ b/plugin/file/tree/tree.go @@ -0,0 +1,455 @@ +// Copyright ©2012 The bÃogo Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found at the end of this file. + +// Package tree implements Left-Leaning Red Black trees as described by Robert Sedgewick. +// +// More details relating to the implementation are available at the following locations: +// +// http://www.cs.princeton.edu/~rs/talks/LLRB/LLRB.pdf +// http://www.cs.princeton.edu/~rs/talks/LLRB/Java/RedBlackBST.java +// http://www.teachsolaisgames.com/articles/balanced_left_leaning.html +// +// Heavily modified by Miek Gieben for use in DNS zones. +package tree + +import "github.com/miekg/dns" + +const ( + td234 = iota + bu23 +) + +// Operation mode of the LLRB tree. +const mode = bu23 + +func init() { + if mode != td234 && mode != bu23 { + panic("tree: unknown mode") + } +} + +// A Color represents the color of a Node. +type Color bool + +const ( + // Red as false give us the defined behaviour that new nodes are red. Although this + // is incorrect for the root node, that is resolved on the first insertion. + red Color = false + black Color = true +) + +// A Node represents a node in the LLRB tree. +type Node struct { + Elem *Elem + Left, Right *Node + Color Color +} + +// A Tree manages the root node of an LLRB tree. Public methods are exposed through this type. +type Tree struct { + Root *Node // Root node of the tree. + Count int // Number of elements stored. +} + +// Helper methods + +// color returns the effect color of a Node. A nil node returns black. +func (n *Node) color() Color { + if n == nil { + return black + } + return n.Color +} + +// (a,c)b -rotL-> ((a,)b,)c +func (n *Node) rotateLeft() (root *Node) { + // Assumes: n has two children. + root = n.Right + n.Right = root.Left + root.Left = n + root.Color = n.Color + n.Color = red + return +} + +// (a,c)b -rotR-> (,(,c)b)a +func (n *Node) rotateRight() (root *Node) { + // Assumes: n has two children. + root = n.Left + n.Left = root.Right + root.Right = n + root.Color = n.Color + n.Color = red + return +} + +// (aR,cR)bB -flipC-> (aB,cB)bR | (aB,cB)bR -flipC-> (aR,cR)bB +func (n *Node) flipColors() { + // Assumes: n has two children. + n.Color = !n.Color + n.Left.Color = !n.Left.Color + n.Right.Color = !n.Right.Color +} + +// fixUp ensures that black link balance is correct, that red nodes lean left, +// and that 4 nodes are split in the case of BU23 and properly balanced in TD234. +func (n *Node) fixUp() *Node { + if n.Right.color() == red { + if mode == td234 && n.Right.Left.color() == red { + n.Right = n.Right.rotateRight() + } + n = n.rotateLeft() + } + if n.Left.color() == red && n.Left.Left.color() == red { + n = n.rotateRight() + } + if mode == bu23 && n.Left.color() == red && n.Right.color() == red { + n.flipColors() + } + return n +} + +func (n *Node) moveRedLeft() *Node { + n.flipColors() + if n.Right.Left.color() == red { + n.Right = n.Right.rotateRight() + n = n.rotateLeft() + n.flipColors() + if mode == td234 && n.Right.Right.color() == red { + n.Right = n.Right.rotateLeft() + } + } + return n +} + +func (n *Node) moveRedRight() *Node { + n.flipColors() + if n.Left.Left.color() == red { + n = n.rotateRight() + n.flipColors() + } + return n +} + +// Len returns the number of elements stored in the Tree. +func (t *Tree) Len() int { + return t.Count +} + +// Search returns the first match of qname in the Tree. +func (t *Tree) Search(qname string) (*Elem, bool) { + if t.Root == nil { + return nil, false + } + n, res := t.Root.search(qname) + if n == nil { + return nil, res + } + return n.Elem, res +} + +// search searches the tree for qname and type. +func (n *Node) search(qname string) (*Node, bool) { + for n != nil { + switch c := Less(n.Elem, qname); { + case c == 0: + return n, true + case c < 0: + n = n.Left + default: + n = n.Right + } + } + + return n, false +} + +// Insert inserts rr into the Tree at the first match found +// with e or when a nil node is reached. +func (t *Tree) Insert(rr dns.RR) { + var d int + t.Root, d = t.Root.insert(rr) + t.Count += d + t.Root.Color = black +} + +// insert inserts rr in to the tree. +func (n *Node) insert(rr dns.RR) (root *Node, d int) { + if n == nil { + return &Node{Elem: newElem(rr)}, 1 + } else if n.Elem == nil { + n.Elem = newElem(rr) + return n, 1 + } + + if mode == td234 { + if n.Left.color() == red && n.Right.color() == red { + n.flipColors() + } + } + + switch c := Less(n.Elem, rr.Header().Name); { + case c == 0: + n.Elem.Insert(rr) + case c < 0: + n.Left, d = n.Left.insert(rr) + default: + n.Right, d = n.Right.insert(rr) + } + + if n.Right.color() == red && n.Left.color() == black { + n = n.rotateLeft() + } + if n.Left.color() == red && n.Left.Left.color() == red { + n = n.rotateRight() + } + + if mode == bu23 { + if n.Left.color() == red && n.Right.color() == red { + n.flipColors() + } + } + + root = n + + return +} + +// DeleteMin deletes the node with the minimum value in the tree. +func (t *Tree) DeleteMin() { + if t.Root == nil { + return + } + var d int + t.Root, d = t.Root.deleteMin() + t.Count += d + if t.Root == nil { + return + } + t.Root.Color = black +} + +func (n *Node) deleteMin() (root *Node, d int) { + if n.Left == nil { + return nil, -1 + } + if n.Left.color() == black && n.Left.Left.color() == black { + n = n.moveRedLeft() + } + n.Left, d = n.Left.deleteMin() + + root = n.fixUp() + + return +} + +// DeleteMax deletes the node with the maximum value in the tree. +func (t *Tree) DeleteMax() { + if t.Root == nil { + return + } + var d int + t.Root, d = t.Root.deleteMax() + t.Count += d + if t.Root == nil { + return + } + t.Root.Color = black +} + +func (n *Node) deleteMax() (root *Node, d int) { + if n.Left != nil && n.Left.color() == red { + n = n.rotateRight() + } + if n.Right == nil { + return nil, -1 + } + if n.Right.color() == black && n.Right.Left.color() == black { + n = n.moveRedRight() + } + n.Right, d = n.Right.deleteMax() + + root = n.fixUp() + + return +} + +// Delete removes rr from the tree, is the node turns empty, that node is deleted with DeleteNode. +func (t *Tree) Delete(rr dns.RR) { + if t.Root == nil { + return + } + + el, _ := t.Search(rr.Header().Name) + if el == nil { + t.deleteNode(rr) + return + } + // Delete from this element. + empty := el.Delete(rr) + if empty { + t.deleteNode(rr) + return + } +} + +// DeleteNode deletes the node that matches rr according to Less(). +func (t *Tree) deleteNode(rr dns.RR) { + if t.Root == nil { + return + } + var d int + t.Root, d = t.Root.delete(rr) + t.Count += d + if t.Root == nil { + return + } + t.Root.Color = black +} + +func (n *Node) delete(rr dns.RR) (root *Node, d int) { + if Less(n.Elem, rr.Header().Name) < 0 { + if n.Left != nil { + if n.Left.color() == black && n.Left.Left.color() == black { + n = n.moveRedLeft() + } + n.Left, d = n.Left.delete(rr) + } + } else { + if n.Left.color() == red { + n = n.rotateRight() + } + if n.Right == nil && Less(n.Elem, rr.Header().Name) == 0 { + return nil, -1 + } + if n.Right != nil { + if n.Right.color() == black && n.Right.Left.color() == black { + n = n.moveRedRight() + } + if Less(n.Elem, rr.Header().Name) == 0 { + n.Elem = n.Right.min().Elem + n.Right, d = n.Right.deleteMin() + } else { + n.Right, d = n.Right.delete(rr) + } + } + } + + root = n.fixUp() + return +} + +// Min returns the minimum value stored in the tree. +func (t *Tree) Min() *Elem { + if t.Root == nil { + return nil + } + return t.Root.min().Elem +} + +func (n *Node) min() *Node { + for ; n.Left != nil; n = n.Left { + } + return n +} + +// Max returns the maximum value stored in the tree. +func (t *Tree) Max() *Elem { + if t.Root == nil { + return nil + } + return t.Root.max().Elem +} + +func (n *Node) max() *Node { + for ; n.Right != nil; n = n.Right { + } + return n +} + +// Prev returns the greatest value equal to or less than the qname according to Less(). +func (t *Tree) Prev(qname string) (*Elem, bool) { + if t.Root == nil { + return nil, false + } + + n := t.Root.floor(qname) + if n == nil { + return nil, false + } + return n.Elem, true +} + +func (n *Node) floor(qname string) *Node { + if n == nil { + return nil + } + switch c := Less(n.Elem, qname); { + case c == 0: + return n + case c <= 0: + return n.Left.floor(qname) + default: + if r := n.Right.floor(qname); r != nil { + return r + } + } + return n +} + +// Next returns the smallest value equal to or greater than the qname according to Less(). +func (t *Tree) Next(qname string) (*Elem, bool) { + if t.Root == nil { + return nil, false + } + n := t.Root.ceil(qname) + if n == nil { + return nil, false + } + return n.Elem, true +} + +func (n *Node) ceil(qname string) *Node { + if n == nil { + return nil + } + switch c := Less(n.Elem, qname); { + case c == 0: + return n + case c > 0: + return n.Right.ceil(qname) + default: + if l := n.Left.ceil(qname); l != nil { + return l + } + } + return n +} + +/* +Copyright ©2012 The bÃogo Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of the bÃogo project nor the names of its authors and + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ diff --git a/plugin/file/wildcard.go b/plugin/file/wildcard.go new file mode 100644 index 000000000..9526cb53f --- /dev/null +++ b/plugin/file/wildcard.go @@ -0,0 +1,13 @@ +package file + +import "github.com/miekg/dns" + +// replaceWithWildcard replaces the left most label with '*'. +func replaceWithAsteriskLabel(qname string) (wildcard string) { + i, shot := dns.NextLabel(qname, 0) + if shot { + return "" + } + + return "*." + qname[i:] +} diff --git a/plugin/file/wildcard_test.go b/plugin/file/wildcard_test.go new file mode 100644 index 000000000..038d37a43 --- /dev/null +++ b/plugin/file/wildcard_test.go @@ -0,0 +1,289 @@ +package file + +import ( + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var wildcardTestCases = []test.Case{ + { + Qname: "wild.dnssex.nl.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`wild.dnssex.nl. 1800 IN TXT "Doing It Safe Is Better"`), + }, + Ns: dnssexAuth[:len(dnssexAuth)-1], // remove RRSIG on the end + }, + { + Qname: "a.wild.dnssex.nl.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`a.wild.dnssex.nl. 1800 IN TXT "Doing It Safe Is Better"`), + }, + Ns: dnssexAuth[:len(dnssexAuth)-1], // remove RRSIG on the end + }, + { + Qname: "wild.dnssex.nl.", Qtype: dns.TypeTXT, Do: true, + Answer: []dns.RR{ + test.RRSIG("wild.dnssex.nl. 1800 IN RRSIG TXT 8 2 1800 20160428190224 20160329190224 14460 dnssex.nl. FUZSTyvZfeuuOpCm"), + test.TXT(`wild.dnssex.nl. 1800 IN TXT "Doing It Safe Is Better"`), + }, + Ns: append([]dns.RR{ + test.NSEC("a.dnssex.nl. 14400 IN NSEC www.dnssex.nl. A AAAA RRSIG NSEC"), + test.RRSIG("a.dnssex.nl. 14400 IN RRSIG NSEC 8 3 14400 20160428190224 20160329190224 14460 dnssex.nl. S+UMs2ySgRaaRY"), + }, dnssexAuth...), + Extra: []dns.RR{test.OPT(4096, true)}, + }, + { + Qname: "a.wild.dnssex.nl.", Qtype: dns.TypeTXT, Do: true, + Answer: []dns.RR{ + test.RRSIG("a.wild.dnssex.nl. 1800 IN RRSIG TXT 8 2 1800 20160428190224 20160329190224 14460 dnssex.nl. FUZSTyvZfeuuOpCm"), + test.TXT(`a.wild.dnssex.nl. 1800 IN TXT "Doing It Safe Is Better"`), + }, + Ns: append([]dns.RR{ + test.NSEC("a.dnssex.nl. 14400 IN NSEC www.dnssex.nl. A AAAA RRSIG NSEC"), + test.RRSIG("a.dnssex.nl. 14400 IN RRSIG NSEC 8 3 14400 20160428190224 20160329190224 14460 dnssex.nl. S+UMs2ySgRaaRY"), + }, dnssexAuth...), + Extra: []dns.RR{test.OPT(4096, true)}, + }, + // nodata responses + { + Qname: "wild.dnssex.nl.", Qtype: dns.TypeSRV, + Ns: []dns.RR{ + test.SOA(`dnssex.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1459281744 14400 3600 604800 14400`), + }, + }, + { + Qname: "wild.dnssex.nl.", Qtype: dns.TypeSRV, Do: true, + Ns: []dns.RR{ + // TODO(miek): needs closest encloser proof as well? This is the wrong answer + test.NSEC(`*.dnssex.nl. 14400 IN NSEC a.dnssex.nl. TXT RRSIG NSEC`), + test.RRSIG(`*.dnssex.nl. 14400 IN RRSIG NSEC 8 2 14400 20160428190224 20160329190224 14460 dnssex.nl. os6INm6q2eXknD5z8TaaDOV+Ge/Ko+2dXnKP+J1fqJzafXJVH1F0nDrcXmMlR6jlBHA=`), + test.RRSIG(`dnssex.nl. 1800 IN RRSIG SOA 8 2 1800 20160428190224 20160329190224 14460 dnssex.nl. CA/Y3m9hCOiKC/8ieSOv8SeP964Bq++lyH8BZJcTaabAsERs4xj5PRtcxicwQXZiF8fYUCpROlUS0YR8Cdw=`), + test.SOA(`dnssex.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1459281744 14400 3600 604800 14400`), + }, + Extra: []dns.RR{test.OPT(4096, true)}, + }, +} + +var dnssexAuth = []dns.RR{ + test.NS("dnssex.nl. 1800 IN NS linode.atoom.net."), + test.NS("dnssex.nl. 1800 IN NS ns-ext.nlnetlabs.nl."), + test.NS("dnssex.nl. 1800 IN NS omval.tednet.nl."), + test.RRSIG("dnssex.nl. 1800 IN RRSIG NS 8 2 1800 20160428190224 20160329190224 14460 dnssex.nl. dLIeEvP86jj5ndkcLzhgvWixTABjWAGRTGQsPsVDFXsGMf9TGGC9FEomgkCVeNC0="), +} + +func TestLookupWildcard(t *testing.T) { + zone, err := Parse(strings.NewReader(dbDnssexNLSigned), testzone1, "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone1: zone}, Names: []string{testzone1}}} + ctx := context.TODO() + + for _, tc := range wildcardTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +var wildcardDoubleTestCases = []test.Case{ + { + Qname: "wild.w.example.org.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`wild.w.example.org. IN TXT "Wildcard"`), + }, + Ns: exampleAuth, + }, + { + Qname: "wild.c.example.org.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`wild.c.example.org. IN TXT "c Wildcard"`), + }, + Ns: exampleAuth, + }, + { + Qname: "wild.d.example.org.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`alias.example.org. IN TXT "Wildcard CNAME expansion"`), + test.CNAME(`wild.d.example.org. IN CNAME alias.example.org`), + }, + Ns: exampleAuth, + }, + { + Qname: "alias.example.org.", Qtype: dns.TypeTXT, + Answer: []dns.RR{ + test.TXT(`alias.example.org. IN TXT "Wildcard CNAME expansion"`), + }, + Ns: exampleAuth, + }, +} + +var exampleAuth = []dns.RR{ + test.NS("example.org. 3600 IN NS a.iana-servers.net."), + test.NS("example.org. 3600 IN NS b.iana-servers.net."), +} + +func TestLookupDoubleWildcard(t *testing.T) { + zone, err := Parse(strings.NewReader(exampleOrg), "example.org.", "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{"example.org.": zone}, Names: []string{"example.org."}}} + ctx := context.TODO() + + for _, tc := range wildcardDoubleTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +func TestReplaceWithAsteriskLabel(t *testing.T) { + tests := []struct { + in, out string + }{ + {".", ""}, + {"miek.nl.", "*.nl."}, + {"www.miek.nl.", "*.miek.nl."}, + } + + for _, tc := range tests { + got := replaceWithAsteriskLabel(tc.in) + if got != tc.out { + t.Errorf("Expected to be %s, got %s", tc.out, got) + } + } +} + +var apexWildcardTestCases = []test.Case{ + { + Qname: "foo.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A(`foo.example.org. 3600 IN A 127.0.0.54`)}, + Ns: []dns.RR{test.NS(`example.org. 3600 IN NS b.iana-servers.net.`)}, + }, + { + Qname: "bar.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A(`bar.example.org. 3600 IN A 127.0.0.53`)}, + Ns: []dns.RR{test.NS(`example.org. 3600 IN NS b.iana-servers.net.`)}, + }, +} + +func TestLookupApexWildcard(t *testing.T) { + const name = "example.org." + zone, err := Parse(strings.NewReader(apexWildcard), name, "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{name: zone}, Names: []string{name}}} + ctx := context.TODO() + + for _, tc := range apexWildcardTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +var multiWildcardTestCases = []test.Case{ + { + Qname: "foo.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A(`foo.example.org. 3600 IN A 127.0.0.54`)}, + Ns: []dns.RR{test.NS(`example.org. 3600 IN NS b.iana-servers.net.`)}, + }, + { + Qname: "bar.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A(`bar.example.org. 3600 IN A 127.0.0.53`)}, + Ns: []dns.RR{test.NS(`example.org. 3600 IN NS b.iana-servers.net.`)}, + }, + { + Qname: "bar.intern.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A(`bar.intern.example.org. 3600 IN A 127.0.1.52`)}, + Ns: []dns.RR{test.NS(`example.org. 3600 IN NS b.iana-servers.net.`)}, + }, +} + +func TestLookupMultiWildcard(t *testing.T) { + const name = "example.org." + zone, err := Parse(strings.NewReader(doubleWildcard), name, "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{name: zone}, Names: []string{name}}} + ctx := context.TODO() + + for _, tc := range multiWildcardTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +const exampleOrg = `; example.org test file +example.org. IN SOA sns.dns.icann.org. noc.dns.icann.org. 2015082541 7200 3600 1209600 3600 +example.org. IN NS b.iana-servers.net. +example.org. IN NS a.iana-servers.net. +example.org. IN A 127.0.0.1 +example.org. IN A 127.0.0.2 +*.w.example.org. IN TXT "Wildcard" +a.b.c.w.example.org. IN TXT "Not a wildcard" +*.c.example.org. IN TXT "c Wildcard" +*.d.example.org. IN CNAME alias.example.org. +alias.example.org. IN TXT "Wildcard CNAME expansion" +` + +const apexWildcard = `; example.org test file with wildcard at apex +example.org. IN SOA sns.dns.icann.org. noc.dns.icann.org. 2015082541 7200 3600 1209600 3600 +example.org. IN NS b.iana-servers.net. +*.example.org. IN A 127.0.0.53 +foo.example.org. IN A 127.0.0.54 +` + +const doubleWildcard = `; example.org test file with wildcard at apex +example.org. IN SOA sns.dns.icann.org. noc.dns.icann.org. 2015082541 7200 3600 1209600 3600 +example.org. IN NS b.iana-servers.net. +*.example.org. IN A 127.0.0.53 +*.intern.example.org. IN A 127.0.1.52 +foo.example.org. IN A 127.0.0.54 +` diff --git a/plugin/file/xfr.go b/plugin/file/xfr.go new file mode 100644 index 000000000..4a03779ed --- /dev/null +++ b/plugin/file/xfr.go @@ -0,0 +1,62 @@ +package file + +import ( + "fmt" + "log" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Xfr serves up an AXFR. +type Xfr struct { + *Zone +} + +// ServeDNS implements the plugin.Handler interface. +func (x Xfr) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + if !x.TransferAllowed(state) { + return dns.RcodeServerFailure, nil + } + if state.QType() != dns.TypeAXFR && state.QType() != dns.TypeIXFR { + return 0, plugin.Error(x.Name(), fmt.Errorf("xfr called with non transfer type: %d", state.QType())) + } + + records := x.All() + if len(records) == 0 { + return dns.RcodeServerFailure, nil + } + + ch := make(chan *dns.Envelope) + defer close(ch) + tr := new(dns.Transfer) + go tr.Out(w, r, ch) + + j, l := 0, 0 + records = append(records, records[0]) // add closing SOA to the end + log.Printf("[INFO] Outgoing transfer of %d records of zone %s to %s started", len(records), x.origin, state.IP()) + for i, r := range records { + l += dns.Len(r) + if l > transferLength { + ch <- &dns.Envelope{RR: records[j:i]} + l = 0 + j = i + } + } + if j < len(records) { + ch <- &dns.Envelope{RR: records[j:]} + } + + w.Hijack() + // w.Close() // Client closes connection + return dns.RcodeSuccess, nil +} + +// Name implements the plugin.Hander interface. +func (x Xfr) Name() string { return "xfr" } + +const transferLength = 1000 // Start a new envelop after message reaches this size in bytes. Intentionally small to test multi envelope parsing. diff --git a/plugin/file/xfr_test.go b/plugin/file/xfr_test.go new file mode 100644 index 000000000..69ad68e64 --- /dev/null +++ b/plugin/file/xfr_test.go @@ -0,0 +1,34 @@ +package file + +import ( + "fmt" + "strings" +) + +func ExampleZone_All() { + zone, err := Parse(strings.NewReader(dbMiekNL), testzone, "stdin", 0) + if err != nil { + return + } + records := zone.All() + for _, r := range records { + fmt.Printf("%+v\n", r) + } + // Output + // xfr_test.go:15: miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1282630057 14400 3600 604800 14400 + // xfr_test.go:15: www.miek.nl. 1800 IN CNAME a.miek.nl. + // xfr_test.go:15: miek.nl. 1800 IN NS linode.atoom.net. + // xfr_test.go:15: miek.nl. 1800 IN NS ns-ext.nlnetlabs.nl. + // xfr_test.go:15: miek.nl. 1800 IN NS omval.tednet.nl. + // xfr_test.go:15: miek.nl. 1800 IN NS ext.ns.whyscream.net. + // xfr_test.go:15: miek.nl. 1800 IN MX 1 aspmx.l.google.com. + // xfr_test.go:15: miek.nl. 1800 IN MX 5 alt1.aspmx.l.google.com. + // xfr_test.go:15: miek.nl. 1800 IN MX 5 alt2.aspmx.l.google.com. + // xfr_test.go:15: miek.nl. 1800 IN MX 10 aspmx2.googlemail.com. + // xfr_test.go:15: miek.nl. 1800 IN MX 10 aspmx3.googlemail.com. + // xfr_test.go:15: miek.nl. 1800 IN A 139.162.196.78 + // xfr_test.go:15: miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 + // xfr_test.go:15: archive.miek.nl. 1800 IN CNAME a.miek.nl. + // xfr_test.go:15: a.miek.nl. 1800 IN A 139.162.196.78 + // xfr_test.go:15: a.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735 +} diff --git a/plugin/file/zone.go b/plugin/file/zone.go new file mode 100644 index 000000000..1cef9dc3a --- /dev/null +++ b/plugin/file/zone.go @@ -0,0 +1,190 @@ +package file + +import ( + "fmt" + "net" + "path" + "strings" + "sync" + + "github.com/coredns/coredns/plugin/file/tree" + "github.com/coredns/coredns/plugin/proxy" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// Zone defines a structure that contains all data related to a DNS zone. +type Zone struct { + origin string + origLen int + file string + *tree.Tree + Apex Apex + + TransferTo []string + StartupOnce sync.Once + TransferFrom []string + Expired *bool + + NoReload bool + reloadMu sync.RWMutex + ReloadShutdown chan bool + Proxy proxy.Proxy // Proxy for looking up names during the resolution process +} + +// Apex contains the apex records of a zone: SOA, NS and their potential signatures. +type Apex struct { + SOA *dns.SOA + NS []dns.RR + SIGSOA []dns.RR + SIGNS []dns.RR +} + +// NewZone returns a new zone. +func NewZone(name, file string) *Zone { + z := &Zone{ + origin: dns.Fqdn(name), + origLen: dns.CountLabel(dns.Fqdn(name)), + file: path.Clean(file), + Tree: &tree.Tree{}, + Expired: new(bool), + ReloadShutdown: make(chan bool), + } + *z.Expired = false + + return z +} + +// Copy copies a zone. +func (z *Zone) Copy() *Zone { + z1 := NewZone(z.origin, z.file) + z1.TransferTo = z.TransferTo + z1.TransferFrom = z.TransferFrom + z1.Expired = z.Expired + + z1.Apex = z.Apex + return z1 +} + +// Insert inserts r into z. +func (z *Zone) Insert(r dns.RR) error { + r.Header().Name = strings.ToLower(r.Header().Name) + + switch h := r.Header().Rrtype; h { + case dns.TypeNS: + r.(*dns.NS).Ns = strings.ToLower(r.(*dns.NS).Ns) + + if r.Header().Name == z.origin { + z.Apex.NS = append(z.Apex.NS, r) + return nil + } + case dns.TypeSOA: + r.(*dns.SOA).Ns = strings.ToLower(r.(*dns.SOA).Ns) + r.(*dns.SOA).Mbox = strings.ToLower(r.(*dns.SOA).Mbox) + + z.Apex.SOA = r.(*dns.SOA) + return nil + case dns.TypeNSEC3, dns.TypeNSEC3PARAM: + return fmt.Errorf("NSEC3 zone is not supported, dropping RR: %s for zone: %s", r.Header().Name, z.origin) + case dns.TypeRRSIG: + x := r.(*dns.RRSIG) + switch x.TypeCovered { + case dns.TypeSOA: + z.Apex.SIGSOA = append(z.Apex.SIGSOA, x) + return nil + case dns.TypeNS: + if r.Header().Name == z.origin { + z.Apex.SIGNS = append(z.Apex.SIGNS, x) + return nil + } + } + case dns.TypeCNAME: + r.(*dns.CNAME).Target = strings.ToLower(r.(*dns.CNAME).Target) + case dns.TypeMX: + r.(*dns.MX).Mx = strings.ToLower(r.(*dns.MX).Mx) + case dns.TypeSRV: + r.(*dns.SRV).Target = strings.ToLower(r.(*dns.SRV).Target) + } + + z.Tree.Insert(r) + return nil +} + +// Delete deletes r from z. +func (z *Zone) Delete(r dns.RR) { z.Tree.Delete(r) } + +// TransferAllowed checks if incoming request for transferring the zone is allowed according to the ACLs. +func (z *Zone) TransferAllowed(state request.Request) bool { + for _, t := range z.TransferTo { + if t == "*" { + return true + } + // If remote IP matches we accept. + remote := state.IP() + to, _, err := net.SplitHostPort(t) + if err != nil { + continue + } + if to == remote { + return true + } + } + // TODO(miek): future matching against IP/CIDR notations + return false +} + +// All returns all records from the zone, the first record will be the SOA record, +// otionally followed by all RRSIG(SOA)s. +func (z *Zone) All() []dns.RR { + if !z.NoReload { + z.reloadMu.RLock() + defer z.reloadMu.RUnlock() + } + + records := []dns.RR{} + allNodes := z.Tree.All() + for _, a := range allNodes { + records = append(records, a.All()...) + } + + if len(z.Apex.SIGNS) > 0 { + records = append(z.Apex.SIGNS, records...) + } + records = append(z.Apex.NS, records...) + + if len(z.Apex.SIGSOA) > 0 { + records = append(z.Apex.SIGSOA, records...) + } + return append([]dns.RR{z.Apex.SOA}, records...) +} + +// Print prints the zone's tree to stdout. +func (z *Zone) Print() { + z.Tree.Print() +} + +// NameFromRight returns the labels from the right, staring with the +// origin and then i labels extra. When we are overshooting the name +// the returned boolean is set to true. +func (z *Zone) nameFromRight(qname string, i int) (string, bool) { + if i <= 0 { + return z.origin, false + } + + for j := 1; j <= z.origLen; j++ { + if _, shot := dns.PrevLabel(qname, j); shot { + return qname, shot + } + } + + k := 0 + shot := false + for j := 1; j <= i; j++ { + k, shot = dns.PrevLabel(qname, j+z.origLen) + if shot { + return qname, shot + } + } + return qname[k:], false +} diff --git a/plugin/file/zone_test.go b/plugin/file/zone_test.go new file mode 100644 index 000000000..c9ff174db --- /dev/null +++ b/plugin/file/zone_test.go @@ -0,0 +1,30 @@ +package file + +import "testing" + +func TestNameFromRight(t *testing.T) { + z := NewZone("example.org.", "stdin") + + tests := []struct { + in string + labels int + shot bool + expected string + }{ + {"example.org.", 0, false, "example.org."}, + {"a.example.org.", 0, false, "example.org."}, + {"a.example.org.", 1, false, "a.example.org."}, + {"a.example.org.", 2, true, "a.example.org."}, + {"a.b.example.org.", 2, false, "a.b.example.org."}, + } + + for i, tc := range tests { + got, shot := z.nameFromRight(tc.in, tc.labels) + if got != tc.expected { + t.Errorf("Test %d: expected %s, got %s\n", i, tc.expected, got) + } + if shot != tc.shot { + t.Errorf("Test %d: expected shot to be %t, got %t\n", i, tc.shot, shot) + } + } +} diff --git a/plugin/health/README.md b/plugin/health/README.md new file mode 100644 index 000000000..59aed3b81 --- /dev/null +++ b/plugin/health/README.md @@ -0,0 +1,23 @@ +# health + +This module enables a simple health check endpoint. By default it will listen on port 8080. + +## Syntax + +~~~ +health [ADDRESS] +~~~ + +Optionally takes an address; the default is `:8080`. The health path is fixed to `/health`. The +health endpoint returns a 200 response code and the word "OK" when CoreDNS is healthy. It returns +a 503. *health* periodically (1s) polls plugin that exports health information. If any of the +plugin signals that it is unhealthy, the server will go unhealthy too. Each plugin that +supports health checks has a section "Health" in their README. + +## Examples + +Run another health endpoint on http://localhost:8091. + +~~~ +health localhost:8091 +~~~ diff --git a/plugin/health/health.go b/plugin/health/health.go new file mode 100644 index 000000000..0a256c963 --- /dev/null +++ b/plugin/health/health.go @@ -0,0 +1,69 @@ +// Package health implements an HTTP handler that responds to health checks. +package health + +import ( + "io" + "log" + "net" + "net/http" + "sync" +) + +var once sync.Once + +type health struct { + Addr string + + ln net.Listener + mux *http.ServeMux + + // A slice of Healthers that the health plugin will poll every second for their health status. + h []Healther + sync.RWMutex + ok bool // ok is the global boolean indicating an all healthy plugin stack +} + +func (h *health) Startup() error { + if h.Addr == "" { + h.Addr = defAddr + } + + once.Do(func() { + ln, err := net.Listen("tcp", h.Addr) + if err != nil { + log.Printf("[ERROR] Failed to start health handler: %s", err) + return + } + + h.ln = ln + + h.mux = http.NewServeMux() + + h.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if h.Ok() { + w.WriteHeader(http.StatusOK) + io.WriteString(w, ok) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + }) + + go func() { + http.Serve(h.ln, h.mux) + }() + }) + return nil +} + +func (h *health) Shutdown() error { + if h.ln != nil { + return h.ln.Close() + } + return nil +} + +const ( + ok = "OK" + defAddr = ":8080" + path = "/health" +) diff --git a/plugin/health/health_test.go b/plugin/health/health_test.go new file mode 100644 index 000000000..f05f53073 --- /dev/null +++ b/plugin/health/health_test.go @@ -0,0 +1,47 @@ +package health + +// TODO(miek): enable again if plugin gets health check. +/* +func TestHealth(t *testing.T) { + h := health{Addr: ":0"} + h.h = append(h.h, &erratic.Erratic{}) + + if err := h.Startup(); err != nil { + t.Fatalf("Unable to startup the health server: %v", err) + } + defer h.Shutdown() + + // Reconstruct the http address based on the port allocated by operating system. + address := fmt.Sprintf("http://%s%s", h.ln.Addr().String(), path) + + // Norhing set should be unhealthy + response, err := http.Get(address) + if err != nil { + t.Fatalf("Unable to query %s: %v", address, err) + } + if response.StatusCode != 503 { + t.Errorf("Invalid status code: expecting '503', got '%d'", response.StatusCode) + } + response.Body.Close() + + // Make healthy + h.Poll() + + response, err = http.Get(address) + if err != nil { + t.Fatalf("Unable to query %s: %v", address, err) + } + if response.StatusCode != 200 { + t.Errorf("Invalid status code: expecting '200', got '%d'", response.StatusCode) + } + content, err := ioutil.ReadAll(response.Body) + if err != nil { + t.Fatalf("Unable to get response body from %s: %v", address, err) + } + response.Body.Close() + + if string(content) != ok { + t.Errorf("Invalid response body: expecting 'OK', got '%s'", string(content)) + } +} +*/ diff --git a/plugin/health/healther.go b/plugin/health/healther.go new file mode 100644 index 000000000..ad0261dfb --- /dev/null +++ b/plugin/health/healther.go @@ -0,0 +1,42 @@ +package health + +// Healther interface needs to be implemented by each plugin willing to +// provide healthhceck information to the health plugin. As a second step +// the plugin needs to registered against the health plugin, by addding +// it to healthers map. Note this method should return quickly, i.e. just +// checking a boolean status, as it is called every second from the health +// plugin. +type Healther interface { + // Health returns a boolean indicating the health status of a plugin. + // False indicates unhealthy. + Health() bool +} + +// Ok returns the global health status of all plugin configured in this server. +func (h *health) Ok() bool { + h.RLock() + defer h.RUnlock() + return h.ok +} + +// SetOk sets the global health status of all plugin configured in this server. +func (h *health) SetOk(ok bool) { + h.Lock() + defer h.Unlock() + h.ok = ok +} + +// poll polls all healthers and sets the global state. +func (h *health) poll() { + for _, m := range h.h { + if !m.Health() { + h.SetOk(false) + return + } + } + h.SetOk(true) +} + +// Middleware that implements the Healther interface. +// TODO(miek): none yet. +var healthers = map[string]bool{} diff --git a/plugin/health/setup.go b/plugin/health/setup.go new file mode 100644 index 000000000..2fec9baa7 --- /dev/null +++ b/plugin/health/setup.go @@ -0,0 +1,73 @@ +package health + +import ( + "net" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("health", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + addr, err := healthParse(c) + if err != nil { + return plugin.Error("health", err) + } + + h := &health{Addr: addr} + + c.OnStartup(func() error { + for he := range healthers { + m := dnsserver.GetConfig(c).Handler(he) + if x, ok := m.(Healther); ok { + h.h = append(h.h, x) + } + } + return nil + }) + + c.OnStartup(func() error { + h.poll() + go func() { + for { + <-time.After(1 * time.Second) + h.poll() + } + }() + return nil + }) + + c.OnStartup(h.Startup) + c.OnFinalShutdown(h.Shutdown) + + // Don't do AddMiddleware, as health is not *really* a plugin just a separate webserver running. + return nil +} + +func healthParse(c *caddy.Controller) (string, error) { + addr := "" + for c.Next() { + args := c.RemainingArgs() + + switch len(args) { + case 0: + case 1: + addr = args[0] + if _, _, e := net.SplitHostPort(addr); e != nil { + return "", e + } + default: + return "", c.ArgErr() + } + } + return addr, nil +} diff --git a/plugin/health/setup_test.go b/plugin/health/setup_test.go new file mode 100644 index 000000000..87f4fc5fd --- /dev/null +++ b/plugin/health/setup_test.go @@ -0,0 +1,35 @@ +package health + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupHealth(t *testing.T) { + tests := []struct { + input string + shouldErr bool + }{ + {`health`, false}, + {`health localhost:1234`, false}, + {`health bla:a`, false}, + {`health bla`, true}, + {`health bla bla`, true}, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + _, err := healthParse(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + } + } +} diff --git a/plugin/hosts/README.md b/plugin/hosts/README.md new file mode 100644 index 000000000..60c738077 --- /dev/null +++ b/plugin/hosts/README.md @@ -0,0 +1,45 @@ +# hosts + +*hosts* enables serving zone data from a `/etc/hosts` style file. + +The hosts plugin is useful for serving zones from a /etc/hosts file. It serves from a preloaded +file that exists on disk. It checks the file for changes and updates the zones accordingly. This +plugin only supports A, AAAA, and PTR records. The hosts plugin can be used with readily +available hosts files that block access to advertising servers. + +## Syntax + +~~~ +hosts [FILE [ZONES...]] { + fallthrough +} +~~~ + +* **FILE** the hosts file to read and parse. If the path is relative the path from the *root* + directive will be prepended to it. Defaults to /etc/hosts if omitted +* **ZONES** zones it should be authoritative for. If empty, the zones from the configuration block + are used. +* `fallthrough` If zone matches and no record can be generated, pass request to the next plugin. + +## Examples + +Load `/etc/hosts` file. + +~~~ +hosts +~~~ + +Load `example.hosts` file in the current directory. + +~~~ +hosts example.hosts +~~~ + +Load example.hosts file and only serve example.org and example.net from it and fall through to the +next plugin if query doesn't match. + +~~~ +hosts example.hosts example.org example.net { + fallthrough +} +~~~ diff --git a/plugin/hosts/hosts.go b/plugin/hosts/hosts.go new file mode 100644 index 000000000..09dedbb64 --- /dev/null +++ b/plugin/hosts/hosts.go @@ -0,0 +1,136 @@ +package hosts + +import ( + "net" + + "golang.org/x/net/context" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + "github.com/miekg/dns" +) + +// Hosts is the plugin handler +type Hosts struct { + Next plugin.Handler + *Hostsfile + + Fallthrough bool +} + +// ServeDNS implements the plugin.Handle interface. +func (h Hosts) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + qname := state.Name() + + answers := []dns.RR{} + + zone := plugin.Zones(h.Origins).Matches(qname) + if zone == "" { + // PTR zones don't need to be specified in Origins + if state.Type() != "PTR" { + // If this doesn't match we need to fall through regardless of h.Fallthrough + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + } + } + + switch state.QType() { + case dns.TypePTR: + names := h.LookupStaticAddr(dnsutil.ExtractAddressFromReverse(qname)) + if len(names) == 0 { + // If this doesn't match we need to fall through regardless of h.Fallthrough + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + } + answers = h.ptr(qname, names) + case dns.TypeA: + ips := h.LookupStaticHostV4(qname) + answers = a(qname, ips) + case dns.TypeAAAA: + ips := h.LookupStaticHostV6(qname) + answers = aaaa(qname, ips) + } + + if len(answers) == 0 { + if h.Fallthrough { + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + } + if !h.otherRecordsExist(state.QType(), qname) { + return dns.RcodeNameError, nil + } + } + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + m.Answer = answers + + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +func (h Hosts) otherRecordsExist(qtype uint16, qname string) bool { + switch qtype { + case dns.TypeA: + if len(h.LookupStaticHostV6(qname)) > 0 { + return true + } + case dns.TypeAAAA: + if len(h.LookupStaticHostV4(qname)) > 0 { + return true + } + default: + if len(h.LookupStaticHostV4(qname)) > 0 { + return true + } + if len(h.LookupStaticHostV6(qname)) > 0 { + return true + } + } + return false + +} + +// Name implements the plugin.Handle interface. +func (h Hosts) Name() string { return "hosts" } + +// a takes a slice of net.IPs and returns a slice of A RRs. +func a(zone string, ips []net.IP) []dns.RR { + answers := []dns.RR{} + for _, ip := range ips { + r := new(dns.A) + r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypeA, + Class: dns.ClassINET, Ttl: 3600} + r.A = ip + answers = append(answers, r) + } + return answers +} + +// aaaa takes a slice of net.IPs and returns a slice of AAAA RRs. +func aaaa(zone string, ips []net.IP) []dns.RR { + answers := []dns.RR{} + for _, ip := range ips { + r := new(dns.AAAA) + r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, Ttl: 3600} + r.AAAA = ip + answers = append(answers, r) + } + return answers +} + +// ptr takes a slice of host names and filters out the ones that aren't in Origins, if specified, and returns a slice of PTR RRs. +func (h *Hosts) ptr(zone string, names []string) []dns.RR { + answers := []dns.RR{} + for _, n := range names { + r := new(dns.PTR) + r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypePTR, + Class: dns.ClassINET, Ttl: 3600} + r.Ptr = dns.Fqdn(n) + answers = append(answers, r) + } + return answers +} diff --git a/plugin/hosts/hosts_test.go b/plugin/hosts/hosts_test.go new file mode 100644 index 000000000..68b91b8c2 --- /dev/null +++ b/plugin/hosts/hosts_test.go @@ -0,0 +1,75 @@ +package hosts + +import ( + "strings" + "testing" + "time" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestLookupA(t *testing.T) { + h := Hosts{Next: test.ErrorHandler(), Hostsfile: &Hostsfile{expire: time.Now().Add(1 * time.Hour), Origins: []string{"."}}} + h.Parse(strings.NewReader(hostsExample)) + + ctx := context.TODO() + + for _, tc := range hostsTestCases { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := h.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v\n", err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} + +var hostsTestCases = []test.Case{ + { + Qname: "example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + test.A("example.org. 3600 IN A 10.0.0.1"), + }, + }, + { + Qname: "localhost.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{ + test.AAAA("localhost. 3600 IN AAAA ::1"), + }, + }, + { + Qname: "1.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("1.0.0.10.in-addr.arpa. 3600 PTR example.org."), + }, + }, + { + Qname: "1.0.0.127.in-addr.arpa.", Qtype: dns.TypePTR, + Answer: []dns.RR{ + test.PTR("1.0.0.127.in-addr.arpa. 3600 PTR localhost."), + test.PTR("1.0.0.127.in-addr.arpa. 3600 PTR localhost.domain."), + }, + }, + { + Qname: "example.org.", Qtype: dns.TypeAAAA, + Answer: []dns.RR{}, + }, + { + Qname: "example.org.", Qtype: dns.TypeMX, + Answer: []dns.RR{}, + }, +} + +const hostsExample = ` +127.0.0.1 localhost localhost.domain +::1 localhost localhost.domain +10.0.0.1 example.org` diff --git a/plugin/hosts/hostsfile.go b/plugin/hosts/hostsfile.go new file mode 100644 index 000000000..91e828099 --- /dev/null +++ b/plugin/hosts/hostsfile.go @@ -0,0 +1,193 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This file is a modified version of net/hosts.go from the golang repo + +package hosts + +import ( + "bufio" + "bytes" + "io" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/coredns/coredns/plugin" +) + +const cacheMaxAge = 5 * time.Second + +func parseLiteralIP(addr string) net.IP { + if i := strings.Index(addr, "%"); i >= 0 { + // discard ipv6 zone + addr = addr[0:i] + } + + return net.ParseIP(addr) +} + +func absDomainName(b string) string { + return plugin.Name(b).Normalize() +} + +// Hostsfile contains known host entries. +type Hostsfile struct { + sync.Mutex + + // list of zones we are authoritive for + Origins []string + + // Key for the list of literal IP addresses must be a host + // name. It would be part of DNS labels, a FQDN or an absolute + // FQDN. + // For now the key is converted to lower case for convenience. + byNameV4 map[string][]net.IP + byNameV6 map[string][]net.IP + + // Key for the list of host names must be a literal IP address + // including IPv6 address with zone identifier. + // We don't support old-classful IP address notation. + byAddr map[string][]string + + expire time.Time + path string + mtime time.Time + size int64 +} + +// ReadHosts determines if the cached data needs to be updated based on the size and modification time of the hostsfile. +func (h *Hostsfile) ReadHosts() { + now := time.Now() + + if now.Before(h.expire) && len(h.byAddr) > 0 { + return + } + stat, err := os.Stat(h.path) + if err == nil && h.mtime.Equal(stat.ModTime()) && h.size == stat.Size() { + h.expire = now.Add(cacheMaxAge) + return + } + + var file *os.File + if file, _ = os.Open(h.path); file == nil { + return + } + defer file.Close() + + h.Parse(file) + + // Update the data cache. + h.expire = now.Add(cacheMaxAge) + h.mtime = stat.ModTime() + h.size = stat.Size() +} + +// Parse reads the hostsfile and populates the byName and byAddr maps. +func (h *Hostsfile) Parse(file io.Reader) { + hsv4 := make(map[string][]net.IP) + hsv6 := make(map[string][]net.IP) + is := make(map[string][]string) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Bytes() + if i := bytes.Index(line, []byte{'#'}); i >= 0 { + // Discard comments. + line = line[0:i] + } + f := bytes.Fields(line) + if len(f) < 2 { + continue + } + addr := parseLiteralIP(string(f[0])) + if addr == nil { + continue + } + ver := ipVersion(string(f[0])) + for i := 1; i < len(f); i++ { + name := absDomainName(string(f[i])) + if plugin.Zones(h.Origins).Matches(name) == "" { + // name is not in Origins + continue + } + switch ver { + case 4: + hsv4[name] = append(hsv4[name], addr) + case 6: + hsv6[name] = append(hsv6[name], addr) + default: + continue + } + is[addr.String()] = append(is[addr.String()], name) + } + } + h.byNameV4 = hsv4 + h.byNameV6 = hsv6 + h.byAddr = is +} + +// ipVersion returns what IP version was used textually +func ipVersion(s string) int { + for i := 0; i < len(s); i++ { + switch s[i] { + case '.': + return 4 + case ':': + return 6 + } + } + return 0 +} + +// LookupStaticHostV4 looks up the IPv4 addresses for the given host from the hosts file. +func (h *Hostsfile) LookupStaticHostV4(host string) []net.IP { + h.Lock() + defer h.Unlock() + h.ReadHosts() + if len(h.byNameV4) != 0 { + if ips, ok := h.byNameV4[absDomainName(host)]; ok { + ipsCp := make([]net.IP, len(ips)) + copy(ipsCp, ips) + return ipsCp + } + } + return nil +} + +// LookupStaticHostV6 looks up the IPv6 addresses for the given host from the hosts file. +func (h *Hostsfile) LookupStaticHostV6(host string) []net.IP { + h.Lock() + defer h.Unlock() + h.ReadHosts() + if len(h.byNameV6) != 0 { + if ips, ok := h.byNameV6[absDomainName(host)]; ok { + ipsCp := make([]net.IP, len(ips)) + copy(ipsCp, ips) + return ipsCp + } + } + return nil +} + +// LookupStaticAddr looks up the hosts for the given address from the hosts file. +func (h *Hostsfile) LookupStaticAddr(addr string) []string { + h.Lock() + defer h.Unlock() + h.ReadHosts() + addr = parseLiteralIP(addr).String() + if addr == "" { + return nil + } + if len(h.byAddr) != 0 { + if hosts, ok := h.byAddr[addr]; ok { + hostsCp := make([]string, len(hosts)) + copy(hostsCp, hosts) + return hostsCp + } + } + return nil +} diff --git a/plugin/hosts/hostsfile_test.go b/plugin/hosts/hostsfile_test.go new file mode 100644 index 000000000..65841fa42 --- /dev/null +++ b/plugin/hosts/hostsfile_test.go @@ -0,0 +1,239 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package hosts + +import ( + "net" + "reflect" + "strings" + "testing" + "time" +) + +func testHostsfile(file string) *Hostsfile { + h := &Hostsfile{expire: time.Now().Add(1 * time.Hour), Origins: []string{"."}} + h.Parse(strings.NewReader(file)) + return h +} + +type staticHostEntry struct { + in string + v4 []string + v6 []string +} + +var ( + hosts = `255.255.255.255 broadcasthost + 127.0.0.2 odin + 127.0.0.3 odin # inline comment + ::2 odin + 127.1.1.1 thor + # aliases + 127.1.1.2 ullr ullrhost + fe80::1%lo0 localhost + # Bogus entries that must be ignored. + 123.123.123 loki + 321.321.321.321` + singlelinehosts = `127.0.0.2 odin` + ipv4hosts = `# See https://tools.ietf.org/html/rfc1123. + # + # The literal IPv4 address parser in the net package is a relaxed + # one. It may accept a literal IPv4 address in dotted-decimal notation + # with leading zeros such as "001.2.003.4". + + # internet address and host name + 127.0.0.1 localhost # inline comment separated by tab + 127.000.000.002 localhost # inline comment separated by space + + # internet address, host name and aliases + 127.000.000.003 localhost localhost.localdomain` + ipv6hosts = `# See https://tools.ietf.org/html/rfc5952, https://tools.ietf.org/html/rfc4007. + + # internet address and host name + ::1 localhost # inline comment separated by tab + fe80:0000:0000:0000:0000:0000:0000:0001 localhost # inline comment separated by space + + # internet address with zone identifier and host name + fe80:0000:0000:0000:0000:0000:0000:0002%lo0 localhost + + # internet address, host name and aliases + fe80::3%lo0 localhost localhost.localdomain` + casehosts = `127.0.0.1 PreserveMe PreserveMe.local + ::1 PreserveMe PreserveMe.local` +) + +var lookupStaticHostTests = []struct { + file string + ents []staticHostEntry +}{ + { + hosts, + []staticHostEntry{ + {"odin", []string{"127.0.0.2", "127.0.0.3"}, []string{"::2"}}, + {"thor", []string{"127.1.1.1"}, []string{}}, + {"ullr", []string{"127.1.1.2"}, []string{}}, + {"ullrhost", []string{"127.1.1.2"}, []string{}}, + {"localhost", []string{}, []string{"fe80::1"}}, + }, + }, + { + singlelinehosts, // see golang.org/issue/6646 + []staticHostEntry{ + {"odin", []string{"127.0.0.2"}, []string{}}, + }, + }, + { + ipv4hosts, + []staticHostEntry{ + {"localhost", []string{"127.0.0.1", "127.0.0.2", "127.0.0.3"}, []string{}}, + {"localhost.localdomain", []string{"127.0.0.3"}, []string{}}, + }, + }, + { + ipv6hosts, + []staticHostEntry{ + {"localhost", []string{}, []string{"::1", "fe80::1", "fe80::2", "fe80::3"}}, + {"localhost.localdomain", []string{}, []string{"fe80::3"}}, + }, + }, + { + casehosts, + []staticHostEntry{ + {"PreserveMe", []string{"127.0.0.1"}, []string{"::1"}}, + {"PreserveMe.local", []string{"127.0.0.1"}, []string{"::1"}}, + }, + }, +} + +func TestLookupStaticHost(t *testing.T) { + + for _, tt := range lookupStaticHostTests { + h := testHostsfile(tt.file) + for _, ent := range tt.ents { + testStaticHost(t, ent, h) + } + } +} + +func testStaticHost(t *testing.T, ent staticHostEntry, h *Hostsfile) { + ins := []string{ent.in, absDomainName(ent.in), strings.ToLower(ent.in), strings.ToUpper(ent.in)} + for k, in := range ins { + addrsV4 := h.LookupStaticHostV4(in) + if len(addrsV4) != len(ent.v4) { + t.Fatalf("%d, lookupStaticHostV4(%s) = %v; want %v", k, in, addrsV4, ent.v4) + } + for i, v4 := range addrsV4 { + if v4.String() != ent.v4[i] { + t.Fatalf("%d, lookupStaticHostV4(%s) = %v; want %v", k, in, addrsV4, ent.v4) + } + } + addrsV6 := h.LookupStaticHostV6(in) + if len(addrsV6) != len(ent.v6) { + t.Fatalf("%d, lookupStaticHostV6(%s) = %v; want %v", k, in, addrsV6, ent.v6) + } + for i, v6 := range addrsV6 { + if v6.String() != ent.v6[i] { + t.Fatalf("%d, lookupStaticHostV6(%s) = %v; want %v", k, in, addrsV6, ent.v6) + } + } + } +} + +type staticIPEntry struct { + in string + out []string +} + +var lookupStaticAddrTests = []struct { + file string + ents []staticIPEntry +}{ + { + hosts, + []staticIPEntry{ + {"255.255.255.255", []string{"broadcasthost"}}, + {"127.0.0.2", []string{"odin"}}, + {"127.0.0.3", []string{"odin"}}, + {"::2", []string{"odin"}}, + {"127.1.1.1", []string{"thor"}}, + {"127.1.1.2", []string{"ullr", "ullrhost"}}, + {"fe80::1", []string{"localhost"}}, + }, + }, + { + singlelinehosts, // see golang.org/issue/6646 + []staticIPEntry{ + {"127.0.0.2", []string{"odin"}}, + }, + }, + { + ipv4hosts, // see golang.org/issue/8996 + []staticIPEntry{ + {"127.0.0.1", []string{"localhost"}}, + {"127.0.0.2", []string{"localhost"}}, + {"127.0.0.3", []string{"localhost", "localhost.localdomain"}}, + }, + }, + { + ipv6hosts, // see golang.org/issue/8996 + []staticIPEntry{ + {"::1", []string{"localhost"}}, + {"fe80::1", []string{"localhost"}}, + {"fe80::2", []string{"localhost"}}, + {"fe80::3", []string{"localhost", "localhost.localdomain"}}, + }, + }, + { + casehosts, // see golang.org/issue/12806 + []staticIPEntry{ + {"127.0.0.1", []string{"PreserveMe", "PreserveMe.local"}}, + {"::1", []string{"PreserveMe", "PreserveMe.local"}}, + }, + }, +} + +func TestLookupStaticAddr(t *testing.T) { + for _, tt := range lookupStaticAddrTests { + h := testHostsfile(tt.file) + for _, ent := range tt.ents { + testStaticAddr(t, ent, h) + } + } +} + +func testStaticAddr(t *testing.T, ent staticIPEntry, h *Hostsfile) { + hosts := h.LookupStaticAddr(ent.in) + for i := range ent.out { + ent.out[i] = absDomainName(ent.out[i]) + } + if !reflect.DeepEqual(hosts, ent.out) { + t.Errorf("%s, lookupStaticAddr(%s) = %v; want %v", h.path, ent.in, hosts, h) + } +} + +func TestHostCacheModification(t *testing.T) { + // Ensure that programs can't modify the internals of the host cache. + // See https://github.com/golang/go/issues/14212. + + h := testHostsfile(ipv4hosts) + ent := staticHostEntry{"localhost", []string{"127.0.0.1", "127.0.0.2", "127.0.0.3"}, []string{}} + testStaticHost(t, ent, h) + // Modify the addresses return by lookupStaticHost. + addrs := h.LookupStaticHostV6(ent.in) + for i := range addrs { + addrs[i] = net.IPv4zero + } + testStaticHost(t, ent, h) + + h = testHostsfile(ipv6hosts) + entip := staticIPEntry{"::1", []string{"localhost"}} + testStaticAddr(t, entip, h) + // Modify the hosts return by lookupStaticAddr. + hosts := h.LookupStaticAddr(entip.in) + for i := range hosts { + hosts[i] += "junk" + } + testStaticAddr(t, entip, h) +} diff --git a/plugin/hosts/setup.go b/plugin/hosts/setup.go new file mode 100644 index 000000000..c7c0c728a --- /dev/null +++ b/plugin/hosts/setup.go @@ -0,0 +1,88 @@ +package hosts + +import ( + "log" + "os" + "path" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("hosts", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + h, err := hostsParse(c) + if err != nil { + return plugin.Error("hosts", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + h.Next = next + return h + }) + + return nil +} + +func hostsParse(c *caddy.Controller) (Hosts, error) { + var h = Hosts{ + Hostsfile: &Hostsfile{path: "/etc/hosts"}, + } + defer h.ReadHosts() + + config := dnsserver.GetConfig(c) + + for c.Next() { + args := c.RemainingArgs() + if len(args) >= 1 { + h.path = args[0] + args = args[1:] + + if !path.IsAbs(h.path) && config.Root != "" { + h.path = path.Join(config.Root, h.path) + } + _, err := os.Stat(h.path) + if err != nil { + if os.IsNotExist(err) { + log.Printf("[WARNING] File does not exist: %s", h.path) + } else { + return h, c.Errf("unable to access hosts file '%s': %v", h.path, err) + } + } + } + + origins := make([]string, len(c.ServerBlockKeys)) + copy(origins, c.ServerBlockKeys) + if len(args) > 0 { + origins = args + } + + for i := range origins { + origins[i] = plugin.Host(origins[i]).Normalize() + } + h.Origins = origins + + for c.NextBlock() { + switch c.Val() { + case "fallthrough": + args := c.RemainingArgs() + if len(args) == 0 { + h.Fallthrough = true + continue + } + return h, c.ArgErr() + default: + return h, c.Errf("unknown property '%s'", c.Val()) + } + } + } + return h, nil +} diff --git a/plugin/hosts/setup_test.go b/plugin/hosts/setup_test.go new file mode 100644 index 000000000..a4c95b1c6 --- /dev/null +++ b/plugin/hosts/setup_test.go @@ -0,0 +1,86 @@ +package hosts + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestHostsParse(t *testing.T) { + tests := []struct { + inputFileRules string + shouldErr bool + expectedPath string + expectedOrigins []string + expectedFallthrough bool + }{ + { + `hosts +`, + false, "/etc/hosts", nil, false, + }, + { + `hosts /tmp`, + false, "/tmp", nil, false, + }, + { + `hosts /etc/hosts miek.nl.`, + false, "/etc/hosts", []string{"miek.nl."}, false, + }, + { + `hosts /etc/hosts miek.nl. pun.gent.`, + false, "/etc/hosts", []string{"miek.nl.", "pun.gent."}, false, + }, + { + `hosts { + fallthrough + }`, + false, "/etc/hosts", nil, true, + }, + { + `hosts /tmp { + fallthrough + }`, + false, "/tmp", nil, true, + }, + { + `hosts /etc/hosts miek.nl. { + fallthrough + }`, + false, "/etc/hosts", []string{"miek.nl."}, true, + }, + { + `hosts /etc/hosts miek.nl 10.0.0.9/8 { + fallthrough + }`, + false, "/etc/hosts", []string{"miek.nl.", "10.in-addr.arpa."}, true, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + h, err := hostsParse(c) + + if err == nil && test.shouldErr { + t.Fatalf("Test %d expected errors, but got no error", i) + } else if err != nil && !test.shouldErr { + t.Fatalf("Test %d expected no errors, but got '%v'", i, err) + } else if !test.shouldErr { + if h.path != test.expectedPath { + t.Fatalf("Test %d expected %v, got %v", i, test.expectedPath, h.path) + } + } else { + if h.Fallthrough != test.expectedFallthrough { + t.Fatalf("Test %d expected fallthrough of %v, got %v", i, test.expectedFallthrough, h.Fallthrough) + } + if len(h.Origins) != len(test.expectedOrigins) { + t.Fatalf("Test %d expected %v, got %v", i, test.expectedOrigins, h.Origins) + } + for j, name := range test.expectedOrigins { + if h.Origins[j] != name { + t.Fatalf("Test %d expected %v for %d th zone, got %v", i, name, j, h.Origins[j]) + } + } + } + } +} diff --git a/plugin/kubernetes/DEV-README.md b/plugin/kubernetes/DEV-README.md new file mode 100644 index 000000000..4f652b578 --- /dev/null +++ b/plugin/kubernetes/DEV-README.md @@ -0,0 +1,43 @@ +# Basic Setup for Development and Testing + +## Launch Kubernetes + +To run the tests, you'll need a private, live Kubernetes cluster. If you don't have one, +you can try out [minikube](https://github.com/kubernetes/minikube), which is +also available via Homebrew for OS X users. + +## Configure Test Data + +The test data is all in [this manifest](https://github.com/coredns/coredns/blob/master/.travis/kubernetes/dns-test.yaml) +and you can load it with `kubectl apply -f`. It will create a couple namespaces and some services. +For the tests to pass, you should not create anything else in the cluster. + +## Proxy the API Server + +Assuming your Kuberentes API server isn't running on http://localhost:8080, you will need to proxy from that +port to your cluster. You can do this with `kubectl proxy --port 8080`. + +## Run CoreDNS Kubernetes Tests + +Now you can run the tests locally, for example: + +~~~ +$ cd $GOPATH/src/github.com/coredns/coredns/test +$ go test -v -tags k8s +~~~ + +# Implementation Notes/Ideas + +* Additional features: + * Implement IP selection and ordering (internal/external). Related to + wildcards and SkyDNS use of CNAMES. + * Expose arbitrary kubernetes repository data as TXT records? +* DNS Correctness + * Do we need to generate synthetic zone records for namespaces? + * Do we need to generate synthetic zone records for the skydns synthetic zones? +* Test cases + * Test with CoreDNS caching. CoreDNS caching for DNS response is working + using the `cache` directive. Tested working using 20s cache timeout + and A-record queries. Automate testing with cache in place. + * Automate CoreDNS performance tests. Initially for zone files, and for + pre-loaded k8s API cache. With and without CoreDNS response caching. diff --git a/plugin/kubernetes/README.md b/plugin/kubernetes/README.md new file mode 100644 index 000000000..387f1cf75 --- /dev/null +++ b/plugin/kubernetes/README.md @@ -0,0 +1,167 @@ +# kubernetes + +The *kubernetes* plugin enables the reading zone data from a Kubernetes cluster. It implements +the [Kubernetes DNS-Based Service Discovery +Specification](https://github.com/kubernetes/dns/blob/master/docs/specification.md). + +CoreDNS running the kubernetes plugin can be used as a replacement of kube-dns in a kubernetes +cluster. See the [deployment](https://github.com/coredns/deployment) repository for details on [how +to deploy CoreDNS in Kubernetes](https://github.com/coredns/deployment/tree/master/kubernetes). + +[stubDomains](http://blog.kubernetes.io/2017/04/configuring-private-dns-zones-upstream-nameservers-kubernetes.html) +are implemented via the *proxy* plugin. + +## Syntax + +~~~ +kubernetes [ZONES...] +~~~ + +With only the directive specified, the *kubernetes* plugin will default to the zone specified in +the server's block. It will handle all queries in that zone and connect to Kubernetes in-cluster. It +will not provide PTR records for services, or A records for pods. If **ZONES** is used it specifies +all the zones the plugin should be authoritative for. + +``` +kubernetes [ZONES...] { + resyncperiod DURATION + endpoint URL + tls CERT KEY CACERT + namespaces NAMESPACE... + labels EXPRESSION + pods POD-MODE + upstream ADDRESS... + ttl TTL + fallthrough +} +``` +* `resyncperiod` specifies the Kubernetes data API **DURATION** period. +* `endpoint` specifies the **URL** for a remove k8s API endpoint. + If omitted, it will connect to k8s in-cluster using the cluster service account. + Multiple k8s API endpoints could be specified, separated by `,`s, e.g. + `endpoint http://k8s-endpoint1:8080,http://k8s-endpoint2:8080`. CoreDNS + will automatically perform a healthcheck and proxy to the healthy k8s API endpoint. +* `tls` **CERT** **KEY** **CACERT** are the TLS cert, key and the CA cert file names for remote k8s connection. + This option is ignored if connecting in-cluster (i.e. endpoint is not specified). +* `namespaces` **NAMESPACE [NAMESPACE...]**, exposed only the k8s namespaces listed. + If this option is omitted all namespaces are exposed +* `labels` **EXPRESSION** only exposes the records for Kubernetes objects that match this label selector. + The label selector syntax is described in the + [Kubernetes User Guide - Labels](http://kubernetes.io/docs/user-guide/labels/). An example that + only exposes objects labeled as "application=nginx" in the "staging" or "qa" environments, would + use: `labels environment in (staging, qa),application=nginx`. +* `pods` **POD-MODE** sets the mode for handling IP-based pod A records, e.g. + `1-2-3-4.ns.pod.cluster.local. in A 1.2.3.4`. + This option is provided to facilitate use of SSL certs when connecting directly to pods. Valid + values for **POD-MODE**: + + * `disabled`: Default. Do not process pod requests, always returning `NXDOMAIN` + * `insecure`: Always return an A record with IP from request (without checking k8s). This option + is is vulnerable to abuse if used maliciously in conjunction with wildcard SSL certs. This + option is provided for backward compatibility with kube-dns. + * `verified`: Return an A record if there exists a pod in same namespace with matching IP. This + option requires substantially more memory than in insecure mode, since it will maintain a watch + on all pods. + +* `upstream` **ADDRESS [ADDRESS...]** defines the upstream resolvers used for resolving services + that point to external hosts (External Services). **ADDRESS** can be an ip, an ip:port, or a path + to a file structured like resolv.conf. +* `ttl` allows you to set a custom TTL for responses. The default (and allowed minimum) is to use + 5 seconds, the maximum is capped at 3600 seconds. +* `fallthrough` If a query for a record in the cluster zone results in NXDOMAIN, normally that is + what the response will be. However, if you specify this option, the query will instead be passed + on down the plugin chain, which can include another plugin to handle the query. + +## Examples + +Handle all queries in the `cluster.local` zone. Connect to Kubernetes in-cluster. +Also handle all `PTR` requests for `10.0.0.0/16` . Verify the existence of pods when answering pod +requests. Resolve upstream records against `10.102.3.10`. Note we show the entire server block +here: + +~~~ txt +10.0.0.0/16 cluster.local { + kubernetes { + pods verified + upstream 10.102.3.10:53 + } +} +~~~ + +Or you can selectively expose some namespaces: + +~~~ txt +kubernetes cluster.local { + namespaces test staging +} +~~~ + +Connect to Kubernetes with CoreDNS running outside the cluster: + +~~~ txt +kubernetes cluster.local { + endpoint https://k8s-endpoint:8443 + tls cert key cacert +} +~~~ + +Here we use the *proxy* plugin to implement stubDomains that forwards `example.org` and +`example.com` to another nameserver. + +~~~ txt +cluster.local { + kubernetes { + endpoint https://k8s-endpoint:8443 + tls cert key cacert + } +} +example.org { + proxy . 8.8.8.8:53 +} +example.com { + proxy . 8.8.8.8:53 +} +~~~ + +## AutoPath + +The *kubernetes* plugin can be used in conjunction with the *autopath* plugin. Using this +feature enables server-side domain search path completion in kubernetes clusters. Note: `pods` must +be set to `verified` for this to function properly. + + cluster.local { + autopath @kubernetes + kubernetes { + pods verified + } + } + +## Federation + +The *kubernetes* plugin can be used in conjunction with the *federation* plugin. Using this +feature enables serving federated domains from the kubernetes clusters. + + cluster.local { + federation { + fallthrough + prod prod.example.org + staging staging.example.org + + } + kubernetes + } + + +## Wildcards + +Some query labels accept a wildcard value to match any value. If a label is a valid wildcard (\*, +or the word "any"), then that label will match all values. The labels that accept wildcards are: + + * _service_ in an `A` record request: _service_.namespace.svc.zone. + * e.g. `*.ns.svc.myzone.local` + * _namespace_ in an `A` record request: service._namespace_.svc.zone. + * e.g. `nginx.*.svc.myzone.local` + * _port and/or protocol_ in an `SRV` request: __port_.__protocol_.service.namespace.svc.zone. + * e.g. `_http.*.service.ns.svc.` + * multiple wild cards are allowed in a single query. + * e.g. `A` Request `*.*.svc.zone.` or `SRV` request `*.*.*.*.svc.zone.` diff --git a/plugin/kubernetes/apiproxy.go b/plugin/kubernetes/apiproxy.go new file mode 100644 index 000000000..3e185f898 --- /dev/null +++ b/plugin/kubernetes/apiproxy.go @@ -0,0 +1,76 @@ +package kubernetes + +import ( + "fmt" + "io" + "log" + "net" + "net/http" + + "github.com/coredns/coredns/plugin/pkg/healthcheck" +) + +type proxyHandler struct { + healthcheck.HealthCheck +} + +type apiProxy struct { + http.Server + listener net.Listener + handler proxyHandler +} + +func (p *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + upstream := p.Select() + network := "tcp" + if upstream.Network != "" { + network = upstream.Network + } + address := upstream.Name + d, err := net.Dial(network, address) + if err != nil { + log.Printf("[ERROR] Unable to establish connection to upstream %s://%s: %s", network, address, err) + http.Error(w, fmt.Sprintf("Unable to establish connection to upstream %s://%s: %s", network, address, err), 500) + return + } + hj, ok := w.(http.Hijacker) + if !ok { + log.Printf("[ERROR] Unable to establish connection: no hijacker") + http.Error(w, "Unable to establish connection: no hijacker", 500) + return + } + nc, _, err := hj.Hijack() + if err != nil { + log.Printf("[ERROR] Unable to hijack connection: %s", err) + http.Error(w, fmt.Sprintf("Unable to hijack connection: %s", err), 500) + return + } + defer nc.Close() + defer d.Close() + + err = r.Write(d) + if err != nil { + log.Printf("[ERROR] Unable to copy connection to upstream %s://%s: %s", network, address, err) + http.Error(w, fmt.Sprintf("Unable to copy connection to upstream %s://%s: %s", network, address, err), 500) + return + } + + errChan := make(chan error, 2) + cp := func(dst io.Writer, src io.Reader) { + _, err := io.Copy(dst, src) + errChan <- err + } + go cp(d, nc) + go cp(nc, d) + <-errChan +} + +func (p *apiProxy) Run() { + p.handler.Start() + p.Serve(p.listener) +} + +func (p *apiProxy) Stop() { + p.handler.Stop() + p.listener.Close() +} diff --git a/plugin/kubernetes/autopath.go b/plugin/kubernetes/autopath.go new file mode 100644 index 000000000..f758869f1 --- /dev/null +++ b/plugin/kubernetes/autopath.go @@ -0,0 +1,53 @@ +package kubernetes + +import ( + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "k8s.io/client-go/1.5/pkg/api" +) + +// AutoPath implements the AutoPathFunc call from the autopath plugin. +// It returns a per-query search path or nil indicating no searchpathing should happen. +func (k *Kubernetes) AutoPath(state request.Request) []string { + // Check if the query falls in a zone we are actually authoriative for and thus if we want autopath. + zone := plugin.Zones(k.Zones).Matches(state.Name()) + if zone == "" { + return nil + } + + ip := state.IP() + + pod := k.podWithIP(ip) + if pod == nil { + return nil + } + + search := make([]string, 3) + if zone == "." { + search[0] = pod.Namespace + ".svc." + search[1] = "svc." + search[2] = "." + } else { + search[0] = pod.Namespace + ".svc." + zone + search[1] = "svc." + zone + search[2] = zone + } + + search = append(search, k.autoPathSearch...) + search = append(search, "") // sentinal + return search +} + +// podWithIP return the api.Pod for source IP ip. It returns nil if nothing can be found. +func (k *Kubernetes) podWithIP(ip string) (p *api.Pod) { + objList := k.APIConn.PodIndex(ip) + for _, o := range objList { + p, ok := o.(*api.Pod) + if !ok { + return nil + } + return p + } + return nil +} diff --git a/plugin/kubernetes/controller.go b/plugin/kubernetes/controller.go new file mode 100644 index 000000000..b809264e1 --- /dev/null +++ b/plugin/kubernetes/controller.go @@ -0,0 +1,399 @@ +package kubernetes + +import ( + "errors" + "fmt" + "log" + "sync" + "time" + + "k8s.io/client-go/1.5/kubernetes" + "k8s.io/client-go/1.5/pkg/api" + unversionedapi "k8s.io/client-go/1.5/pkg/api/unversioned" + "k8s.io/client-go/1.5/pkg/api/v1" + "k8s.io/client-go/1.5/pkg/labels" + "k8s.io/client-go/1.5/pkg/runtime" + "k8s.io/client-go/1.5/pkg/watch" + "k8s.io/client-go/1.5/tools/cache" +) + +var ( + namespace = api.NamespaceAll +) + +// storeToNamespaceLister makes a Store that lists Namespaces. +type storeToNamespaceLister struct { + cache.Store +} + +const podIPIndex = "PodIP" + +// List lists all Namespaces in the store. +func (s *storeToNamespaceLister) List() (ns api.NamespaceList, err error) { + for _, m := range s.Store.List() { + ns.Items = append(ns.Items, *(m.(*api.Namespace))) + } + return ns, nil +} + +type dnsController interface { + ServiceList() []*api.Service + PodIndex(string) []interface{} + EndpointsList() api.EndpointsList + + GetNodeByName(string) (api.Node, error) + + Run() + Stop() error +} + +type dnsControl struct { + client *kubernetes.Clientset + + selector *labels.Selector + + svcController *cache.Controller + podController *cache.Controller + nsController *cache.Controller + epController *cache.Controller + + svcLister cache.StoreToServiceLister + podLister cache.StoreToPodLister + nsLister storeToNamespaceLister + epLister cache.StoreToEndpointsLister + + // stopLock is used to enforce only a single call to Stop is active. + // Needed because we allow stopping through an http endpoint and + // allowing concurrent stoppers leads to stack traces. + stopLock sync.Mutex + shutdown bool + stopCh chan struct{} +} + +type dnsControlOpts struct { + initPodCache bool + resyncPeriod time.Duration + // Label handling. + labelSelector *unversionedapi.LabelSelector + selector *labels.Selector +} + +// newDNSController creates a controller for CoreDNS. +func newdnsController(kubeClient *kubernetes.Clientset, opts dnsControlOpts) *dnsControl { + dns := dnsControl{ + client: kubeClient, + selector: opts.selector, + stopCh: make(chan struct{}), + } + + dns.svcLister.Indexer, dns.svcController = cache.NewIndexerInformer( + &cache.ListWatch{ + ListFunc: serviceListFunc(dns.client, namespace, dns.selector), + WatchFunc: serviceWatchFunc(dns.client, namespace, dns.selector), + }, + &api.Service{}, + opts.resyncPeriod, + cache.ResourceEventHandlerFuncs{}, + cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) + + if opts.initPodCache { + dns.podLister.Indexer, dns.podController = cache.NewIndexerInformer( + &cache.ListWatch{ + ListFunc: podListFunc(dns.client, namespace, dns.selector), + WatchFunc: podWatchFunc(dns.client, namespace, dns.selector), + }, + &api.Pod{}, // TODO replace with a lighter-weight custom struct + opts.resyncPeriod, + cache.ResourceEventHandlerFuncs{}, + cache.Indexers{podIPIndex: podIPIndexFunc}) + } + + dns.nsLister.Store, dns.nsController = cache.NewInformer( + &cache.ListWatch{ + ListFunc: namespaceListFunc(dns.client, dns.selector), + WatchFunc: namespaceWatchFunc(dns.client, dns.selector), + }, + &api.Namespace{}, + opts.resyncPeriod, + cache.ResourceEventHandlerFuncs{}) + + dns.epLister.Store, dns.epController = cache.NewInformer( + &cache.ListWatch{ + ListFunc: endpointsListFunc(dns.client, namespace, dns.selector), + WatchFunc: endpointsWatchFunc(dns.client, namespace, dns.selector), + }, + &api.Endpoints{}, + opts.resyncPeriod, + cache.ResourceEventHandlerFuncs{}) + + return &dns +} + +func podIPIndexFunc(obj interface{}) ([]string, error) { + p, ok := obj.(*api.Pod) + if !ok { + return nil, errors.New("obj was not an *api.Pod") + } + return []string{p.Status.PodIP}, nil +} + +func serviceListFunc(c *kubernetes.Clientset, ns string, s *labels.Selector) func(api.ListOptions) (runtime.Object, error) { + return func(opts api.ListOptions) (runtime.Object, error) { + if s != nil { + opts.LabelSelector = *s + } + listV1, err := c.Core().Services(ns).List(opts) + + if err != nil { + return nil, err + } + var listAPI api.ServiceList + err = v1.Convert_v1_ServiceList_To_api_ServiceList(listV1, &listAPI, nil) + if err != nil { + return nil, err + } + return &listAPI, err + } +} + +func podListFunc(c *kubernetes.Clientset, ns string, s *labels.Selector) func(api.ListOptions) (runtime.Object, error) { + return func(opts api.ListOptions) (runtime.Object, error) { + if s != nil { + opts.LabelSelector = *s + } + listV1, err := c.Core().Pods(ns).List(opts) + + if err != nil { + return nil, err + } + var listAPI api.PodList + err = v1.Convert_v1_PodList_To_api_PodList(listV1, &listAPI, nil) + if err != nil { + return nil, err + } + + return &listAPI, err + } +} + +func v1ToAPIFilter(in watch.Event) (out watch.Event, keep bool) { + if in.Type == watch.Error { + return in, true + } + + switch v1Obj := in.Object.(type) { + case *v1.Service: + var apiObj api.Service + err := v1.Convert_v1_Service_To_api_Service(v1Obj, &apiObj, nil) + if err != nil { + log.Printf("[ERROR] Could not convert v1.Service: %s", err) + return in, true + } + return watch.Event{Type: in.Type, Object: &apiObj}, true + case *v1.Pod: + var apiObj api.Pod + err := v1.Convert_v1_Pod_To_api_Pod(v1Obj, &apiObj, nil) + if err != nil { + log.Printf("[ERROR] Could not convert v1.Pod: %s", err) + return in, true + } + return watch.Event{Type: in.Type, Object: &apiObj}, true + case *v1.Namespace: + var apiObj api.Namespace + err := v1.Convert_v1_Namespace_To_api_Namespace(v1Obj, &apiObj, nil) + if err != nil { + log.Printf("[ERROR] Could not convert v1.Namespace: %s", err) + return in, true + } + return watch.Event{Type: in.Type, Object: &apiObj}, true + case *v1.Endpoints: + var apiObj api.Endpoints + err := v1.Convert_v1_Endpoints_To_api_Endpoints(v1Obj, &apiObj, nil) + if err != nil { + log.Printf("[ERROR] Could not convert v1.Endpoint: %s", err) + return in, true + } + return watch.Event{Type: in.Type, Object: &apiObj}, true + } + + log.Printf("[WARN] Unhandled v1 type in event: %v", in) + return in, true +} + +func serviceWatchFunc(c *kubernetes.Clientset, ns string, s *labels.Selector) func(options api.ListOptions) (watch.Interface, error) { + return func(options api.ListOptions) (watch.Interface, error) { + if s != nil { + options.LabelSelector = *s + } + w, err := c.Core().Services(ns).Watch(options) + if err != nil { + return nil, err + } + return watch.Filter(w, v1ToAPIFilter), nil + } +} + +func podWatchFunc(c *kubernetes.Clientset, ns string, s *labels.Selector) func(options api.ListOptions) (watch.Interface, error) { + return func(options api.ListOptions) (watch.Interface, error) { + if s != nil { + options.LabelSelector = *s + } + w, err := c.Core().Pods(ns).Watch(options) + + if err != nil { + return nil, err + } + return watch.Filter(w, v1ToAPIFilter), nil + } +} + +func namespaceListFunc(c *kubernetes.Clientset, s *labels.Selector) func(api.ListOptions) (runtime.Object, error) { + return func(opts api.ListOptions) (runtime.Object, error) { + if s != nil { + opts.LabelSelector = *s + } + listV1, err := c.Core().Namespaces().List(opts) + if err != nil { + return nil, err + } + var listAPI api.NamespaceList + err = v1.Convert_v1_NamespaceList_To_api_NamespaceList(listV1, &listAPI, nil) + if err != nil { + return nil, err + } + return &listAPI, err + } +} + +func namespaceWatchFunc(c *kubernetes.Clientset, s *labels.Selector) func(options api.ListOptions) (watch.Interface, error) { + return func(options api.ListOptions) (watch.Interface, error) { + if s != nil { + options.LabelSelector = *s + } + w, err := c.Core().Namespaces().Watch(options) + if err != nil { + return nil, err + } + return watch.Filter(w, v1ToAPIFilter), nil + } +} + +func endpointsListFunc(c *kubernetes.Clientset, ns string, s *labels.Selector) func(api.ListOptions) (runtime.Object, error) { + return func(opts api.ListOptions) (runtime.Object, error) { + if s != nil { + opts.LabelSelector = *s + } + listV1, err := c.Core().Endpoints(ns).List(opts) + + if err != nil { + return nil, err + } + var listAPI api.EndpointsList + err = v1.Convert_v1_EndpointsList_To_api_EndpointsList(listV1, &listAPI, nil) + if err != nil { + return nil, err + } + return &listAPI, err + } +} + +func endpointsWatchFunc(c *kubernetes.Clientset, ns string, s *labels.Selector) func(options api.ListOptions) (watch.Interface, error) { + return func(options api.ListOptions) (watch.Interface, error) { + if s != nil { + options.LabelSelector = *s + } + w, err := c.Core().Endpoints(ns).Watch(options) + if err != nil { + return nil, err + } + return watch.Filter(w, v1ToAPIFilter), nil + } +} + +func (dns *dnsControl) controllersInSync() bool { + hs := dns.svcController.HasSynced() && + dns.nsController.HasSynced() && + dns.epController.HasSynced() + + if dns.podController != nil { + hs = hs && dns.podController.HasSynced() + } + + return hs +} + +// Stop stops the controller. +func (dns *dnsControl) Stop() error { + dns.stopLock.Lock() + defer dns.stopLock.Unlock() + + // Only try draining the workqueue if we haven't already. + if !dns.shutdown { + close(dns.stopCh) + dns.shutdown = true + + return nil + } + + return fmt.Errorf("shutdown already in progress") +} + +// Run starts the controller. +func (dns *dnsControl) Run() { + go dns.svcController.Run(dns.stopCh) + go dns.nsController.Run(dns.stopCh) + go dns.epController.Run(dns.stopCh) + if dns.podController != nil { + go dns.podController.Run(dns.stopCh) + } + <-dns.stopCh +} + +func (dns *dnsControl) NamespaceList() *api.NamespaceList { + nsList, err := dns.nsLister.List() + if err != nil { + return &api.NamespaceList{} + } + + return &nsList +} + +func (dns *dnsControl) ServiceList() []*api.Service { + svcs, err := dns.svcLister.List(labels.Everything()) + if err != nil { + return []*api.Service{} + } + + return svcs +} + +func (dns *dnsControl) PodIndex(ip string) []interface{} { + pods, err := dns.podLister.Indexer.ByIndex(podIPIndex, ip) + if err != nil { + return nil + } + + return pods +} + +func (dns *dnsControl) EndpointsList() api.EndpointsList { + epl, err := dns.epLister.List() + if err != nil { + return api.EndpointsList{} + } + + return epl +} + +func (dns *dnsControl) GetNodeByName(name string) (api.Node, error) { + v1node, err := dns.client.Core().Nodes().Get(name) + if err != nil { + return api.Node{}, err + } + var apinode api.Node + err = v1.Convert_v1_Node_To_api_Node(v1node, &apinode, nil) + if err != nil { + return api.Node{}, err + } + return apinode, nil +} diff --git a/plugin/kubernetes/federation.go b/plugin/kubernetes/federation.go new file mode 100644 index 000000000..df6ae948b --- /dev/null +++ b/plugin/kubernetes/federation.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" +) + +// The federation node.Labels keys used. +const ( + // TODO: Do not hardcode these labels. Pull them out of the API instead. + // + // We can get them via .... + // import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + // metav1.LabelZoneFailureDomain + // metav1.LabelZoneRegion + // + // But importing above breaks coredns with flag collision of 'log_dir' + + LabelZone = "failure-domain.beta.kubernetes.io/zone" + LabelRegion = "failure-domain.beta.kubernetes.io/region" +) + +// Federations is used from the federations plugin to return the service that should be +// returned as a CNAME for federation(s) to work. +func (k *Kubernetes) Federations(state request.Request, fname, fzone string) (msg.Service, error) { + nodeName := k.localNodeName() + node, err := k.APIConn.GetNodeByName(nodeName) + if err != nil { + return msg.Service{}, err + } + r, err := parseRequest(state) + if err != nil { + return msg.Service{}, err + } + + lz := node.Labels[LabelZone] + lr := node.Labels[LabelRegion] + + if r.endpoint == "" { + return msg.Service{Host: dnsutil.Join([]string{r.service, r.namespace, fname, r.podOrSvc, lz, lr, fzone})}, nil + } + + return msg.Service{Host: dnsutil.Join([]string{r.endpoint, r.service, r.namespace, fname, r.podOrSvc, lz, lr, fzone})}, nil +} diff --git a/plugin/kubernetes/handler.go b/plugin/kubernetes/handler.go new file mode 100644 index 000000000..9dc435111 --- /dev/null +++ b/plugin/kubernetes/handler.go @@ -0,0 +1,86 @@ +package kubernetes + +import ( + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// ServeDNS implements the plugin.Handler interface. +func (k Kubernetes) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + + zone := plugin.Zones(k.Zones).Matches(state.Name()) + if zone == "" { + return plugin.NextOrFailure(k.Name(), k.Next, ctx, w, r) + } + + state.Zone = zone + + var ( + records []dns.RR + extra []dns.RR + err error + ) + + switch state.Type() { + case "A": + records, err = plugin.A(&k, zone, state, nil, plugin.Options{}) + case "AAAA": + records, err = plugin.AAAA(&k, zone, state, nil, plugin.Options{}) + case "TXT": + records, err = plugin.TXT(&k, zone, state, plugin.Options{}) + case "CNAME": + records, err = plugin.CNAME(&k, zone, state, plugin.Options{}) + case "PTR": + records, err = plugin.PTR(&k, zone, state, plugin.Options{}) + case "MX": + records, extra, err = plugin.MX(&k, zone, state, plugin.Options{}) + case "SRV": + records, extra, err = plugin.SRV(&k, zone, state, plugin.Options{}) + case "SOA": + records, err = plugin.SOA(&k, zone, state, plugin.Options{}) + case "NS": + if state.Name() == zone { + records, extra, err = plugin.NS(&k, zone, state, plugin.Options{}) + break + } + fallthrough + default: + // Do a fake A lookup, so we can distinguish between NODATA and NXDOMAIN + _, err = plugin.A(&k, zone, state, nil, plugin.Options{}) + } + + if k.IsNameError(err) { + if k.Fallthrough { + return plugin.NextOrFailure(k.Name(), k.Next, ctx, w, r) + } + return plugin.BackendError(&k, zone, dns.RcodeNameError, state, nil /* err */, plugin.Options{}) + } + if err != nil { + return dns.RcodeServerFailure, err + } + + if len(records) == 0 { + return plugin.BackendError(&k, zone, dns.RcodeSuccess, state, nil, plugin.Options{}) + } + + m.Answer = append(m.Answer, records...) + m.Extra = append(m.Extra, extra...) + + m = dnsutil.Dedup(m) + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil +} + +// Name implements the Handler interface. +func (k Kubernetes) Name() string { return "kubernetes" } diff --git a/plugin/kubernetes/handler_pod_disabled_test.go b/plugin/kubernetes/handler_pod_disabled_test.go new file mode 100644 index 000000000..4c6e15710 --- /dev/null +++ b/plugin/kubernetes/handler_pod_disabled_test.go @@ -0,0 +1,61 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var podModeDisabledCases = []test.Case{ + { + Qname: "10-240-0-1.podns.pod.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Error: errPodsDisabled, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + { + Qname: "172-0-0-2.podns.pod.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Error: errPodsDisabled, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, +} + +func TestServeDNSModeDisabled(t *testing.T) { + + k := New([]string{"cluster.local."}) + k.APIConn = &APIConnServeTest{} + k.Next = test.NextHandler(dns.RcodeSuccess, nil) + k.podMode = podModeDisabled + ctx := context.TODO() + + for i, tc := range podModeDisabledCases { + r := tc.Msg() + + w := dnsrecorder.New(&test.ResponseWriter{}) + + _, err := k.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d expected no error, got %v", i, err) + return + } + if tc.Error != nil { + continue + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d, got nil message and no error for %q", i, r.Question[0].Name) + } + + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/kubernetes/handler_pod_insecure_test.go b/plugin/kubernetes/handler_pod_insecure_test.go new file mode 100644 index 000000000..b2df8a504 --- /dev/null +++ b/plugin/kubernetes/handler_pod_insecure_test.go @@ -0,0 +1,59 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var podModeInsecureCases = []test.Case{ + { + Qname: "10-240-0-1.podns.pod.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("10-240-0-1.podns.pod.cluster.local. 0 IN A 10.240.0.1"), + }, + }, + { + Qname: "172-0-0-2.podns.pod.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("172-0-0-2.podns.pod.cluster.local. 0 IN A 172.0.0.2"), + }, + }, +} + +func TestServeDNSModeInsecure(t *testing.T) { + + k := New([]string{"cluster.local."}) + k.APIConn = &APIConnServeTest{} + k.Next = test.NextHandler(dns.RcodeSuccess, nil) + ctx := context.TODO() + k.podMode = podModeInsecure + + for i, tc := range podModeInsecureCases { + r := tc.Msg() + + w := dnsrecorder.New(&test.ResponseWriter{}) + + _, err := k.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d expected no error, got %v", i, err) + return + } + if tc.Error != nil { + continue + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d, got nil message and no error for %q", i, r.Question[0].Name) + } + + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/kubernetes/handler_pod_verified_test.go b/plugin/kubernetes/handler_pod_verified_test.go new file mode 100644 index 000000000..ea585cc6a --- /dev/null +++ b/plugin/kubernetes/handler_pod_verified_test.go @@ -0,0 +1,59 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var podModeVerifiedCases = []test.Case{ + { + Qname: "10-240-0-1.podns.pod.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("10-240-0-1.podns.pod.cluster.local. 0 IN A 10.240.0.1"), + }, + }, + { + Qname: "172-0-0-2.podns.pod.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, +} + +func TestServeDNSModeVerified(t *testing.T) { + + k := New([]string{"cluster.local."}) + k.APIConn = &APIConnServeTest{} + k.Next = test.NextHandler(dns.RcodeSuccess, nil) + ctx := context.TODO() + k.podMode = podModeVerified + + for i, tc := range podModeVerifiedCases { + r := tc.Msg() + + w := dnsrecorder.New(&test.ResponseWriter{}) + + _, err := k.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d expected no error, got %v", i, err) + return + } + if tc.Error != nil { + continue + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d, got nil message and no error for %q", i, r.Question[0].Name) + } + + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/kubernetes/handler_test.go b/plugin/kubernetes/handler_test.go new file mode 100644 index 000000000..5413f5b4c --- /dev/null +++ b/plugin/kubernetes/handler_test.go @@ -0,0 +1,347 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" + "k8s.io/client-go/1.5/pkg/api" +) + +var dnsTestCases = []test.Case{ + // A Service + { + Qname: "svc1.testns.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("svc1.testns.svc.cluster.local. 5 IN A 10.0.0.1"), + }, + }, + // A Service (wildcard) + { + Qname: "svc1.*.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("svc1.*.svc.cluster.local. 5 IN A 10.0.0.1"), + }, + }, + { + Qname: "svc1.testns.svc.cluster.local.", Qtype: dns.TypeSRV, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{test.SRV("svc1.testns.svc.cluster.local. 303 IN SRV 0 100 80 svc1.testns.svc.cluster.local.")}, + Extra: []dns.RR{test.A("svc1.testns.svc.cluster.local. 303 IN A 10.0.0.1")}, + }, + // SRV Service (wildcard) + { + Qname: "svc1.*.svc.cluster.local.", Qtype: dns.TypeSRV, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{test.SRV("svc1.*.svc.cluster.local. 303 IN SRV 0 100 80 svc1.testns.svc.cluster.local.")}, + Extra: []dns.RR{test.A("svc1.testns.svc.cluster.local. 303 IN A 10.0.0.1")}, + }, + // SRV Service (wildcards) + { + Qname: "*.any.svc1.*.svc.cluster.local.", Qtype: dns.TypeSRV, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{test.SRV("*.any.svc1.*.svc.cluster.local. 303 IN SRV 0 100 80 svc1.testns.svc.cluster.local.")}, + Extra: []dns.RR{test.A("svc1.testns.svc.cluster.local. 303 IN A 10.0.0.1")}, + }, + // A Service (wildcards) + { + Qname: "*.any.svc1.*.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("*.any.svc1.*.svc.cluster.local. 303 IN A 10.0.0.1"), + }, + }, + // SRV Service Not udp/tcp + { + Qname: "*._not-udp-or-tcp.svc1.testns.svc.cluster.local.", Qtype: dns.TypeSRV, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + // SRV Service + { + Qname: "_http._tcp.svc1.testns.svc.cluster.local.", Qtype: dns.TypeSRV, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.SRV("_http._tcp.svc1.testns.svc.cluster.local. 303 IN SRV 0 100 80 svc1.testns.svc.cluster.local."), + }, + Extra: []dns.RR{ + test.A("svc1.testns.svc.cluster.local. 303 IN A 10.0.0.1"), + }, + }, + // A Service (Headless) + { + Qname: "hdls1.testns.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("hdls1.testns.svc.cluster.local. 303 IN A 172.0.0.2"), + test.A("hdls1.testns.svc.cluster.local. 303 IN A 172.0.0.3"), + }, + }, + // SRV Service (Headless) + { + Qname: "_http._tcp.hdls1.testns.svc.cluster.local.", Qtype: dns.TypeSRV, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.SRV("_http._tcp.hdls1.testns.svc.cluster.local. 303 IN SRV 0 50 80 172-0-0-2.hdls1.testns.svc.cluster.local."), + test.SRV("_http._tcp.hdls1.testns.svc.cluster.local. 303 IN SRV 0 50 80 172-0-0-3.hdls1.testns.svc.cluster.local."), + }, + Extra: []dns.RR{ + test.A("172-0-0-2.hdls1.testns.svc.cluster.local. 303 IN A 172.0.0.2"), + test.A("172-0-0-3.hdls1.testns.svc.cluster.local. 303 IN A 172.0.0.3"), + }, + }, + // CNAME External + { + Qname: "external.testns.svc.cluster.local.", Qtype: dns.TypeCNAME, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.CNAME("external.testns.svc.cluster.local. 303 IN CNAME ext.interwebs.test."), + }, + }, + // AAAA Service (existing service) + { + Qname: "svc1.testns.svc.cluster.local.", Qtype: dns.TypeAAAA, + Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + // AAAA Service (non-existing service) + { + Qname: "svc0.testns.svc.cluster.local.", Qtype: dns.TypeAAAA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + // A Service (non-existing service) + { + Qname: "svc0.testns.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + // TXT Schema + { + Qname: "dns-version.cluster.local.", Qtype: dns.TypeTXT, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.TXT("dns-version.cluster.local 28800 IN TXT 1.0.1"), + }, + }, + // A Service (Headless) does not exist + { + Qname: "bogusendpoint.hdls1.testns.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + // A Service does not exist + { + Qname: "bogusendpoint.svc0.testns.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, +} + +func TestServeDNS(t *testing.T) { + + k := New([]string{"cluster.local."}) + k.APIConn = &APIConnServeTest{} + k.Next = test.NextHandler(dns.RcodeSuccess, nil) + ctx := context.TODO() + + for i, tc := range dnsTestCases { + r := tc.Msg() + + w := dnsrecorder.New(&test.ResponseWriter{}) + + _, err := k.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d expected no error, got %v", i, err) + return + } + if tc.Error != nil { + continue + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d, got nil message and no error for %q", i, r.Question[0].Name) + } + + // Before sorting, make sure that CNAMES do not appear after their target records + test.CNAMEOrder(t, resp) + + test.SortAndCheck(t, resp, tc) + } +} + +type APIConnServeTest struct{} + +func (APIConnServeTest) Run() { return } +func (APIConnServeTest) Stop() error { return nil } + +func (APIConnServeTest) PodIndex(string) []interface{} { + a := make([]interface{}, 1) + a[0] = &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Namespace: "podns", + }, + Status: api.PodStatus{ + PodIP: "10.240.0.1", // Remote IP set in test.ResponseWriter + }, + } + return a +} + +func (APIConnServeTest) ServiceList() []*api.Service { + svcs := []*api.Service{ + { + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: "10.0.0.1", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: api.ClusterIPNone, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "external", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ExternalName: "ext.interwebs.test", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + } + return svcs + +} + +func (APIConnServeTest) EndpointsList() api.EndpointsList { + n := "test.node.foo.bar" + + return api.EndpointsList{ + Items: []api.Endpoints{ + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.1", + Hostname: "ep1a", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + }, + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.2", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + }, + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.3", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + }, + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "10.9.8.7", + NodeName: &n, + }, + }, + }, + }, + }, + }, + } +} + +func (APIConnServeTest) GetNodeByName(name string) (api.Node, error) { + return api.Node{ + ObjectMeta: api.ObjectMeta{ + Name: "test.node.foo.bar", + }, + }, nil +} diff --git a/plugin/kubernetes/kubernetes.go b/plugin/kubernetes/kubernetes.go new file mode 100644 index 000000000..90fcd6182 --- /dev/null +++ b/plugin/kubernetes/kubernetes.go @@ -0,0 +1,457 @@ +// Package kubernetes provides the kubernetes backend. +package kubernetes + +import ( + "errors" + "fmt" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/pkg/healthcheck" + "github.com/coredns/coredns/plugin/proxy" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "k8s.io/client-go/1.5/kubernetes" + "k8s.io/client-go/1.5/pkg/api" + unversionedapi "k8s.io/client-go/1.5/pkg/api/unversioned" + "k8s.io/client-go/1.5/pkg/labels" + "k8s.io/client-go/1.5/rest" + "k8s.io/client-go/1.5/tools/clientcmd" + clientcmdapi "k8s.io/client-go/1.5/tools/clientcmd/api" +) + +// Kubernetes implements a plugin that connects to a Kubernetes cluster. +type Kubernetes struct { + Next plugin.Handler + Zones []string + Proxy proxy.Proxy // Proxy for looking up names during the resolution process + APIServerList []string + APIProxy *apiProxy + APICertAuth string + APIClientCert string + APIClientKey string + APIConn dnsController + Namespaces map[string]bool + podMode string + Fallthrough bool + ttl uint32 + + primaryZoneIndex int + interfaceAddrsFunc func() net.IP + autoPathSearch []string // Local search path from /etc/resolv.conf. Needed for autopath. +} + +// New returns a intialized Kubernetes. It default interfaceAddrFunc to return 127.0.0.1. All other +// values default to their zero value, primaryZoneIndex will thus point to the first zone. +func New(zones []string) *Kubernetes { + k := new(Kubernetes) + k.Zones = zones + k.Namespaces = make(map[string]bool) + k.interfaceAddrsFunc = func() net.IP { return net.ParseIP("127.0.0.1") } + k.podMode = podModeDisabled + k.Proxy = proxy.Proxy{} + k.ttl = defaultTTL + + return k +} + +const ( + // podModeDisabled is the default value where pod requests are ignored + podModeDisabled = "disabled" + // podModeVerified is where Pod requests are answered only if they exist + podModeVerified = "verified" + // podModeInsecure is where pod requests are answered without verfying they exist + podModeInsecure = "insecure" + // DNSSchemaVersion is the schema version: https://github.com/kubernetes/dns/blob/master/docs/specification.md + DNSSchemaVersion = "1.0.1" +) + +var ( + errNoItems = errors.New("no items found") + errNsNotExposed = errors.New("namespace is not exposed") + errInvalidRequest = errors.New("invalid query name") + errAPIBadPodType = errors.New("expected type *api.Pod") + errPodsDisabled = errors.New("pod records disabled") +) + +// Services implements the ServiceBackend interface. +func (k *Kubernetes) Services(state request.Request, exact bool, opt plugin.Options) (svcs []msg.Service, err error) { + + // We're looking again at types, which we've already done in ServeDNS, but there are some types k8s just can't answer. + switch state.QType() { + + case dns.TypeTXT: + // 1 label + zone, label must be "dns-version". + t, _ := dnsutil.TrimZone(state.Name(), state.Zone) + + segs := dns.SplitDomainName(t) + if len(segs) != 1 { + return nil, fmt.Errorf("kubernetes: TXT query can only be for dns-version: %s", state.QName()) + } + if segs[0] != "dns-version" { + return nil, nil + } + svc := msg.Service{Text: DNSSchemaVersion, TTL: 28800, Key: msg.Path(state.QName(), "coredns")} + return []msg.Service{svc}, nil + + case dns.TypeNS: + // We can only get here if the qname equal the zone, see ServeDNS in handler.go. + ns := k.nsAddr() + svc := msg.Service{Host: ns.A.String(), Key: msg.Path(state.QName(), "coredns")} + return []msg.Service{svc}, nil + } + + if state.QType() == dns.TypeA && isDefaultNS(state.Name(), state.Zone) { + // If this is an A request for "ns.dns", respond with a "fake" record for coredns. + // SOA records always use this hardcoded name + ns := k.nsAddr() + svc := msg.Service{Host: ns.A.String(), Key: msg.Path(state.QName(), "coredns")} + return []msg.Service{svc}, nil + } + + s, e := k.Records(state, false) + + // SRV for external services is not yet implemented, so remove those records. + + if state.QType() != dns.TypeSRV { + return s, e + } + + internal := []msg.Service{} + for _, svc := range s { + if t, _ := svc.HostType(); t != dns.TypeCNAME { + internal = append(internal, svc) + } + } + + return internal, e +} + +// primaryZone will return the first non-reverse zone being handled by this plugin +func (k *Kubernetes) primaryZone() string { return k.Zones[k.primaryZoneIndex] } + +// Lookup implements the ServiceBackend interface. +func (k *Kubernetes) Lookup(state request.Request, name string, typ uint16) (*dns.Msg, error) { + return k.Proxy.Lookup(state, name, typ) +} + +// IsNameError implements the ServiceBackend interface. +func (k *Kubernetes) IsNameError(err error) bool { + return err == errNoItems || err == errNsNotExposed || err == errInvalidRequest +} + +func (k *Kubernetes) getClientConfig() (*rest.Config, error) { + loadingRules := &clientcmd.ClientConfigLoadingRules{} + overrides := &clientcmd.ConfigOverrides{} + clusterinfo := clientcmdapi.Cluster{} + authinfo := clientcmdapi.AuthInfo{} + + if len(k.APIServerList) == 0 { + cc, err := rest.InClusterConfig() + if err != nil { + return nil, err + } + return cc, err + } + + endpoint := k.APIServerList[0] + if len(k.APIServerList) > 1 { + // Use a random port for api proxy, will get the value later through listener.Addr() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes api proxy: %v", err) + } + k.APIProxy = &apiProxy{ + listener: listener, + handler: proxyHandler{ + HealthCheck: healthcheck.HealthCheck{ + FailTimeout: 3 * time.Second, + MaxFails: 1, + Future: 10 * time.Second, + Path: "/", + Interval: 5 * time.Second, + }, + }, + } + k.APIProxy.handler.Hosts = make([]*healthcheck.UpstreamHost, len(k.APIServerList)) + for i, entry := range k.APIServerList { + + uh := &healthcheck.UpstreamHost{ + Name: strings.TrimPrefix(entry, "http://"), + + CheckDown: func(upstream *proxyHandler) healthcheck.UpstreamHostDownFunc { + return func(uh *healthcheck.UpstreamHost) bool { + + down := false + + uh.CheckMu.Lock() + until := uh.OkUntil + uh.CheckMu.Unlock() + + if !until.IsZero() && time.Now().After(until) { + down = true + } + + fails := atomic.LoadInt32(&uh.Fails) + if fails >= upstream.MaxFails && upstream.MaxFails != 0 { + down = true + } + return down + } + }(&k.APIProxy.handler), + } + + k.APIProxy.handler.Hosts[i] = uh + } + k.APIProxy.Handler = &k.APIProxy.handler + + // Find the random port used for api proxy + endpoint = fmt.Sprintf("http://%s", listener.Addr()) + } + clusterinfo.Server = endpoint + + if len(k.APICertAuth) > 0 { + clusterinfo.CertificateAuthority = k.APICertAuth + } + if len(k.APIClientCert) > 0 { + authinfo.ClientCertificate = k.APIClientCert + } + if len(k.APIClientKey) > 0 { + authinfo.ClientKey = k.APIClientKey + } + + overrides.ClusterInfo = clusterinfo + overrides.AuthInfo = authinfo + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) + + return clientConfig.ClientConfig() +} + +// initKubeCache initializes a new Kubernetes cache. +func (k *Kubernetes) initKubeCache(opts dnsControlOpts) (err error) { + + config, err := k.getClientConfig() + if err != nil { + return err + } + + kubeClient, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create kubernetes notification controller: %q", err) + } + + if opts.labelSelector != nil { + var selector labels.Selector + selector, err = unversionedapi.LabelSelectorAsSelector(opts.labelSelector) + if err != nil { + return fmt.Errorf("unable to create Selector for LabelSelector '%s': %q", opts.labelSelector, err) + } + opts.selector = &selector + } + + opts.initPodCache = k.podMode == podModeVerified + + k.APIConn = newdnsController(kubeClient, opts) + + return err +} + +// Records looks up services in kubernetes. +func (k *Kubernetes) Records(state request.Request, exact bool) ([]msg.Service, error) { + r, e := parseRequest(state) + if e != nil { + return nil, e + } + + if !wildcard(r.namespace) && !k.namespaceExposed(r.namespace) { + return nil, errNsNotExposed + } + + if r.podOrSvc == Pod { + pods, err := k.findPods(r, state.Zone) + return pods, err + } + + services, err := k.findServices(r, state.Zone) + return services, err +} + +func endpointHostname(addr api.EndpointAddress) string { + if addr.Hostname != "" { + return strings.ToLower(addr.Hostname) + } + if strings.Contains(addr.IP, ".") { + return strings.Replace(addr.IP, ".", "-", -1) + } + if strings.Contains(addr.IP, ":") { + return strings.ToLower(strings.Replace(addr.IP, ":", "-", -1)) + } + return "" +} + +func (k *Kubernetes) findPods(r recordRequest, zone string) (pods []msg.Service, err error) { + if k.podMode == podModeDisabled { + return nil, errPodsDisabled + } + + namespace := r.namespace + podname := r.service + zonePath := msg.Path(zone, "coredns") + ip := "" + err = errNoItems + + if strings.Count(podname, "-") == 3 && !strings.Contains(podname, "--") { + ip = strings.Replace(podname, "-", ".", -1) + } else { + ip = strings.Replace(podname, "-", ":", -1) + } + + if k.podMode == podModeInsecure { + return []msg.Service{{Key: strings.Join([]string{zonePath, Pod, namespace, podname}, "/"), Host: ip}}, nil + } + + // PodModeVerified + objList := k.APIConn.PodIndex(ip) + + for _, o := range objList { + p, ok := o.(*api.Pod) + if !ok { + return nil, errAPIBadPodType + } + // If namespace has a wildcard, filter results against Corefile namespace list. + if wildcard(namespace) && !k.namespaceExposed(p.Namespace) { + continue + } + // check for matching ip and namespace + if ip == p.Status.PodIP && match(namespace, p.Namespace) { + s := msg.Service{Key: strings.Join([]string{zonePath, Pod, namespace, podname}, "/"), Host: ip} + pods = append(pods, s) + + err = nil + } + } + return pods, err +} + +// findServices returns the services matching r from the cache. +func (k *Kubernetes) findServices(r recordRequest, zone string) (services []msg.Service, err error) { + serviceList := k.APIConn.ServiceList() + zonePath := msg.Path(zone, "coredns") + err = errNoItems // Set to errNoItems to signal really nothing found, gets reset when name is matched. + + for _, svc := range serviceList { + if !(match(r.namespace, svc.Namespace) && match(r.service, svc.Name)) { + continue + } + + // If namespace has a wildcard, filter results against Corefile namespace list. + // (Namespaces without a wildcard were filtered before the call to this function.) + if wildcard(r.namespace) && !k.namespaceExposed(svc.Namespace) { + continue + } + + // Endpoint query or headless service + if svc.Spec.ClusterIP == api.ClusterIPNone || r.endpoint != "" { + endpointsList := k.APIConn.EndpointsList() + for _, ep := range endpointsList.Items { + if ep.ObjectMeta.Name != svc.Name || ep.ObjectMeta.Namespace != svc.Namespace { + continue + } + + for _, eps := range ep.Subsets { + for _, addr := range eps.Addresses { + + // See comments in parse.go parseRequest about the endpoint handling. + + if r.endpoint != "" { + if !match(r.endpoint, endpointHostname(addr)) { + continue + } + } + + for _, p := range eps.Ports { + if !(match(r.port, p.Name) && match(r.protocol, string(p.Protocol))) { + continue + } + s := msg.Service{Host: addr.IP, Port: int(p.Port), TTL: k.ttl} + s.Key = strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name, endpointHostname(addr)}, "/") + + err = nil + + services = append(services, s) + } + } + } + } + continue + } + + // External service + if svc.Spec.ExternalName != "" { + s := msg.Service{Key: strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name}, "/"), Host: svc.Spec.ExternalName, TTL: k.ttl} + if t, _ := s.HostType(); t == dns.TypeCNAME { + s.Key = strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name}, "/") + services = append(services, s) + + err = nil + + continue + } + } + + // ClusterIP service + for _, p := range svc.Spec.Ports { + if !(match(r.port, p.Name) && match(r.protocol, string(p.Protocol))) { + continue + } + + err = nil + + s := msg.Service{Host: svc.Spec.ClusterIP, Port: int(p.Port), TTL: k.ttl} + s.Key = strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name}, "/") + + services = append(services, s) + } + } + return services, err +} + +// match checks if a and b are equal taking wildcards into account. +func match(a, b string) bool { + if wildcard(a) { + return true + } + if wildcard(b) { + return true + } + return strings.EqualFold(a, b) +} + +// wildcard checks whether s contains a wildcard value defined as "*" or "any". +func wildcard(s string) bool { + return s == "*" || s == "any" +} + +// namespaceExposed returns true when the namespace is exposed. +func (k *Kubernetes) namespaceExposed(namespace string) bool { + _, ok := k.Namespaces[namespace] + if len(k.Namespaces) > 0 && !ok { + return false + } + return true +} + +const ( + // Svc is the DNS schema for kubernetes services + Svc = "svc" + // Pod is the DNS schema for kubernetes pods + Pod = "pod" + // defaultTTL to apply to all answers. + defaultTTL = 5 +) diff --git a/plugin/kubernetes/kubernetes_apex_test.go b/plugin/kubernetes/kubernetes_apex_test.go new file mode 100644 index 000000000..41b70b883 --- /dev/null +++ b/plugin/kubernetes/kubernetes_apex_test.go @@ -0,0 +1,68 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var kubeApexCases = [](test.Case){ + { + Qname: "cluster.local.", Qtype: dns.TypeSOA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.SOA("cluster.local. 303 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + { + Qname: "cluster.local.", Qtype: dns.TypeHINFO, + Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("cluster.local. 303 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1499347823 7200 1800 86400 60"), + }, + }, + { + Qname: "cluster.local.", Qtype: dns.TypeNS, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.NS("cluster.local. 303 IN NS ns.dns.cluster.local."), + }, + Extra: []dns.RR{ + test.A("ns.dns.cluster.local. 303 IN A 127.0.0.1"), + }, + }, +} + +func TestServeDNSApex(t *testing.T) { + + k := New([]string{"cluster.local."}) + k.APIConn = &APIConnServeTest{} + k.Next = test.NextHandler(dns.RcodeSuccess, nil) + ctx := context.TODO() + + for i, tc := range kubeApexCases { + r := tc.Msg() + + w := dnsrecorder.New(&test.ResponseWriter{}) + + _, err := k.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d, expected no error, got %v\n", i, err) + return + } + if tc.Error != nil { + continue + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d, got nil message and no error ford", i) + } + + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/kubernetes/kubernetes_test.go b/plugin/kubernetes/kubernetes_test.go new file mode 100644 index 000000000..f347f10fc --- /dev/null +++ b/plugin/kubernetes/kubernetes_test.go @@ -0,0 +1,242 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "k8s.io/client-go/1.5/pkg/api" +) + +func TestWildcard(t *testing.T) { + var tests = []struct { + s string + expected bool + }{ + {"mynamespace", false}, + {"*", true}, + {"any", true}, + {"my*space", false}, + {"*space", false}, + {"myname*", false}, + } + + for _, te := range tests { + got := wildcard(te.s) + if got != te.expected { + t.Errorf("Expected Wildcard result '%v' for example '%v', got '%v'.", te.expected, te.s, got) + } + } +} + +func TestEndpointHostname(t *testing.T) { + var tests = []struct { + ip string + hostname string + expected string + }{ + {"10.11.12.13", "", "10-11-12-13"}, + {"10.11.12.13", "epname", "epname"}, + } + for _, test := range tests { + result := endpointHostname(api.EndpointAddress{IP: test.ip, Hostname: test.hostname}) + if result != test.expected { + t.Errorf("Expected endpoint name for (ip:%v hostname:%v) to be '%v', but got '%v'", test.ip, test.hostname, test.expected, result) + } + } +} + +type APIConnServiceTest struct{} + +func (APIConnServiceTest) Run() { return } +func (APIConnServiceTest) Stop() error { return nil } +func (APIConnServiceTest) PodIndex(string) []interface{} { return nil } + +func (APIConnServiceTest) ServiceList() []*api.Service { + svcs := []*api.Service{ + { + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: "10.0.0.1", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: api.ClusterIPNone, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "external", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ExternalName: "coredns.io", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + } + return svcs +} + +func (APIConnServiceTest) EndpointsList() api.EndpointsList { + n := "test.node.foo.bar" + + return api.EndpointsList{ + Items: []api.Endpoints{ + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.1", + Hostname: "ep1a", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + }, + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.2", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + }, + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.3", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + }, + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "10.9.8.7", + NodeName: &n, + }, + }, + }, + }, + }, + }, + } +} + +func (APIConnServiceTest) GetNodeByName(name string) (api.Node, error) { + return api.Node{ + ObjectMeta: api.ObjectMeta{ + Name: "test.node.foo.bar", + }, + }, nil +} + +func TestServices(t *testing.T) { + + k := New([]string{"interwebs.test."}) + k.APIConn = &APIConnServiceTest{} + + type svcAns struct { + host string + key string + } + type svcTest struct { + qname string + qtype uint16 + answer svcAns + } + tests := []svcTest{ + // Cluster IP Services + {qname: "svc1.testns.svc.interwebs.test.", qtype: dns.TypeA, answer: svcAns{host: "10.0.0.1", key: "/coredns/test/interwebs/svc/testns/svc1"}}, + {qname: "_http._tcp.svc1.testns.svc.interwebs.test.", qtype: dns.TypeSRV, answer: svcAns{host: "10.0.0.1", key: "/coredns/test/interwebs/svc/testns/svc1"}}, + {qname: "ep1a.svc1.testns.svc.interwebs.test.", qtype: dns.TypeA, answer: svcAns{host: "172.0.0.1", key: "/coredns/test/interwebs/svc/testns/svc1/ep1a"}}, + + // External Services + {qname: "external.testns.svc.interwebs.test.", qtype: dns.TypeCNAME, answer: svcAns{host: "coredns.io", key: "/coredns/test/interwebs/svc/testns/external"}}, + } + + for i, test := range tests { + state := request.Request{ + Req: &dns.Msg{Question: []dns.Question{{Name: test.qname, Qtype: test.qtype}}}, + Zone: "interwebs.test.", // must match from k.Zones[0] + } + svcs, e := k.Services(state, false, plugin.Options{}) + if e != nil { + t.Errorf("Test %d: got error '%v'", i, e) + continue + } + if len(svcs) != 1 { + t.Errorf("Test %d, expected expected 1 answer, got %v", i, len(svcs)) + continue + } + + if test.answer.host != svcs[0].Host { + t.Errorf("Test %d, expected host '%v', got '%v'", i, test.answer.host, svcs[0].Host) + } + if test.answer.key != svcs[0].Key { + t.Errorf("Test %d, expected key '%v', got '%v'", i, test.answer.key, svcs[0].Key) + } + } +} diff --git a/plugin/kubernetes/local.go b/plugin/kubernetes/local.go new file mode 100644 index 000000000..e5b7f1e0f --- /dev/null +++ b/plugin/kubernetes/local.go @@ -0,0 +1,40 @@ +package kubernetes + +import "net" + +func localPodIP() net.IP { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + + for _, addr := range addrs { + ip, _, _ := net.ParseCIDR(addr.String()) + ip = ip.To4() + if ip == nil || ip.IsLoopback() { + continue + } + return ip + } + return nil +} + +func (k *Kubernetes) localNodeName() string { + localIP := k.interfaceAddrsFunc() + if localIP == nil { + return "" + } + + // Find endpoint matching localIP + endpointsList := k.APIConn.EndpointsList() + for _, ep := range endpointsList.Items { + for _, eps := range ep.Subsets { + for _, addr := range eps.Addresses { + if localIP.Equal(net.ParseIP(addr.IP)) { + return *addr.NodeName + } + } + } + } + return "" +} diff --git a/plugin/kubernetes/ns.go b/plugin/kubernetes/ns.go new file mode 100644 index 000000000..4cacc382f --- /dev/null +++ b/plugin/kubernetes/ns.go @@ -0,0 +1,65 @@ +package kubernetes + +import ( + "net" + "strings" + + "github.com/miekg/dns" + "k8s.io/client-go/1.5/pkg/api" +) + +func isDefaultNS(name, zone string) bool { + return strings.Index(name, defaultNSName) == 0 && strings.Index(name, zone) == len(defaultNSName) +} + +func (k *Kubernetes) nsAddr() *dns.A { + var ( + svcName string + svcNamespace string + ) + + rr := new(dns.A) + localIP := k.interfaceAddrsFunc() + endpointsList := k.APIConn.EndpointsList() + + rr.A = localIP + +FindEndpoint: + for _, ep := range endpointsList.Items { + for _, eps := range ep.Subsets { + for _, addr := range eps.Addresses { + if localIP.Equal(net.ParseIP(addr.IP)) { + svcNamespace = ep.ObjectMeta.Namespace + svcName = ep.ObjectMeta.Name + break FindEndpoint + } + } + } + } + + if len(svcName) == 0 { + rr.Hdr.Name = defaultNSName + rr.A = localIP + return rr + } + // Find service to get ClusterIP + serviceList := k.APIConn.ServiceList() + +FindService: + for _, svc := range serviceList { + if svcName == svc.Name && svcNamespace == svc.Namespace { + if svc.Spec.ClusterIP == api.ClusterIPNone { + rr.A = localIP + } else { + rr.A = net.ParseIP(svc.Spec.ClusterIP) + } + break FindService + } + } + + rr.Hdr.Name = strings.Join([]string{svcName, svcNamespace, "svc."}, ".") + + return rr +} + +const defaultNSName = "ns.dns." diff --git a/plugin/kubernetes/ns_test.go b/plugin/kubernetes/ns_test.go new file mode 100644 index 000000000..8e9e80c71 --- /dev/null +++ b/plugin/kubernetes/ns_test.go @@ -0,0 +1,69 @@ +package kubernetes + +import ( + "testing" + + "k8s.io/client-go/1.5/pkg/api" +) + +type APIConnTest struct{} + +func (APIConnTest) Run() { return } +func (APIConnTest) Stop() error { return nil } +func (APIConnTest) PodIndex(string) []interface{} { return nil } + +func (APIConnTest) ServiceList() []*api.Service { + svc := api.Service{ + ObjectMeta: api.ObjectMeta{ + Name: "dns-service", + Namespace: "kube-system", + }, + Spec: api.ServiceSpec{ + ClusterIP: "10.0.0.111", + }, + } + + return []*api.Service{&svc} + +} + +func (APIConnTest) EndpointsList() api.EndpointsList { + return api.EndpointsList{ + Items: []api.Endpoints{ + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "127.0.0.1", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "dns-service", + Namespace: "kube-system", + }, + }, + }, + } +} + +func (APIConnTest) GetNodeByName(name string) (api.Node, error) { return api.Node{}, nil } + +func TestNsAddr(t *testing.T) { + + k := New([]string{"inter.webs.test."}) + k.APIConn = &APIConnTest{} + + cdr := k.nsAddr() + expected := "10.0.0.111" + + if cdr.A.String() != expected { + t.Errorf("Expected A to be %q, got %q", expected, cdr.A.String()) + } + expected = "dns-service.kube-system.svc." + if cdr.Hdr.Name != expected { + t.Errorf("Expected Hdr.Name to be %q, got %q", expected, cdr.Hdr.Name) + } +} diff --git a/plugin/kubernetes/parse.go b/plugin/kubernetes/parse.go new file mode 100644 index 000000000..a66e77699 --- /dev/null +++ b/plugin/kubernetes/parse.go @@ -0,0 +1,112 @@ +package kubernetes + +import ( + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +type recordRequest struct { + // The named port from the kubernetes DNS spec, this is the service part (think _https) from a well formed + // SRV record. + port string + // The protocol is usually _udp or _tcp (if set), and comes from the protocol part of a well formed + // SRV record. + protocol string + endpoint string + // The servicename used in Kubernetes. + service string + // The namespace used in Kubernetes. + namespace string + // A each name can be for a pod or a service, here we track what we've seen, either "pod" or "service". + podOrSvc string +} + +// parseRequest parses the qname to find all the elements we need for querying k8s. Anything +// that is not parsed will have the wildcard "*" value (except r.endpoint). +// Potential underscores are stripped from _port and _protocol. +func parseRequest(state request.Request) (r recordRequest, err error) { + // 3 Possible cases: + // 1. _port._protocol.service.namespace.pod|svc.zone + // 2. (endpoint): endpoint.service.namespace.pod|svc.zone + // 3. (service): service.namespace.pod|svc.zone + // + // Federations are handled in the federation plugin. And aren't parsed here. + + base, _ := dnsutil.TrimZone(state.Name(), state.Zone) + segs := dns.SplitDomainName(base) + + r.port = "*" + r.protocol = "*" + r.service = "*" + r.namespace = "*" + // r.endpoint is the odd one out, we need to know if it has been set or not. If it is + // empty we should skip the endpoint check in k.get(). Hence we cannot set if to "*". + + // start at the right and fill out recordRequest with the bits we find, so we look for + // pod|svc.namespace.service and then either + // * endpoint + // *_protocol._port + + last := len(segs) - 1 + if last < 0 { + return r, nil + } + r.podOrSvc = segs[last] + if r.podOrSvc != Pod && r.podOrSvc != Svc { + return r, errInvalidRequest + } + last-- + if last < 0 { + return r, nil + } + + r.namespace = segs[last] + last-- + if last < 0 { + return r, nil + } + + r.service = segs[last] + last-- + if last < 0 { + return r, nil + } + + // Because of ambiquity we check the labels left: 1: an endpoint. 2: port and protocol. + // Anything else is a query that is too long to answer and can safely be delegated to return an nxdomain. + switch last { + + case 0: // endpoint only + r.endpoint = segs[last] + case 1: // service and port + r.protocol = stripUnderscore(segs[last]) + r.port = stripUnderscore(segs[last-1]) + + default: // too long + return r, errInvalidRequest + } + + return r, nil +} + +// stripUnderscore removes a prefixed underscore from s. +func stripUnderscore(s string) string { + if s[0] != '_' { + return s + } + return s[1:] +} + +// String return a string representation of r, it just returns all fields concatenated with dots. +// This is mostly used in tests. +func (r recordRequest) String() string { + s := r.port + s += "." + r.protocol + s += "." + r.endpoint + s += "." + r.service + s += "." + r.namespace + s += "." + r.podOrSvc + return s +} diff --git a/plugin/kubernetes/parse_test.go b/plugin/kubernetes/parse_test.go new file mode 100644 index 000000000..06d5a2aaa --- /dev/null +++ b/plugin/kubernetes/parse_test.go @@ -0,0 +1,56 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +func TestParseRequest(t *testing.T) { + tests := []struct { + query string + expected string // output from r.String() + }{ + // valid SRV request + {"_http._tcp.webs.mynamespace.svc.inter.webs.test.", "http.tcp..webs.mynamespace.svc"}, + // wildcard acceptance + {"*.any.*.any.svc.inter.webs.test.", "*.any..*.any.svc"}, + // A request of endpoint + {"1-2-3-4.webs.mynamespace.svc.inter.webs.test.", "*.*.1-2-3-4.webs.mynamespace.svc"}, + } + for i, tc := range tests { + m := new(dns.Msg) + m.SetQuestion(tc.query, dns.TypeA) + state := request.Request{Zone: zone, Req: m} + + r, e := parseRequest(state) + if e != nil { + t.Errorf("Test %d, expected no error, got '%v'.", i, e) + } + rs := r.String() + if rs != tc.expected { + t.Errorf("Test %d, expected (stringyfied) recordRequest: %s, got %s", i, tc.expected, rs) + } + } +} + +func TestParseInvalidRequest(t *testing.T) { + invalid := []string{ + "webs.mynamespace.pood.inter.webs.test.", // Request must be for pod or svc subdomain. + "too.long.for.what.I.am.trying.to.pod.inter.webs.tests.", // Too long. + } + + for i, query := range invalid { + m := new(dns.Msg) + m.SetQuestion(query, dns.TypeA) + state := request.Request{Zone: zone, Req: m} + + if _, e := parseRequest(state); e == nil { + t.Errorf("Test %d: expected error from %s, got none", i, query) + } + } +} + +const zone = "intern.webs.tests." diff --git a/plugin/kubernetes/reverse.go b/plugin/kubernetes/reverse.go new file mode 100644 index 000000000..0143b721a --- /dev/null +++ b/plugin/kubernetes/reverse.go @@ -0,0 +1,55 @@ +package kubernetes + +import ( + "strings" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" +) + +// Reverse implements the ServiceBackend interface. +func (k *Kubernetes) Reverse(state request.Request, exact bool, opt plugin.Options) ([]msg.Service, error) { + + ip := dnsutil.ExtractAddressFromReverse(state.Name()) + if ip == "" { + return nil, nil + } + + records := k.serviceRecordForIP(ip, state.Name()) + return records, nil +} + +// serviceRecordForIP gets a service record with a cluster ip matching the ip argument +// If a service cluster ip does not match, it checks all endpoints +func (k *Kubernetes) serviceRecordForIP(ip, name string) []msg.Service { + // First check services with cluster ips + svcList := k.APIConn.ServiceList() + + for _, service := range svcList { + if (len(k.Namespaces) > 0) && !k.namespaceExposed(service.Namespace) { + continue + } + if service.Spec.ClusterIP == ip { + domain := strings.Join([]string{service.Name, service.Namespace, Svc, k.primaryZone()}, ".") + return []msg.Service{{Host: domain}} + } + } + // If no cluster ips match, search endpoints + epList := k.APIConn.EndpointsList() + for _, ep := range epList.Items { + if (len(k.Namespaces) > 0) && !k.namespaceExposed(ep.ObjectMeta.Namespace) { + continue + } + for _, eps := range ep.Subsets { + for _, addr := range eps.Addresses { + if addr.IP == ip { + domain := strings.Join([]string{endpointHostname(addr), ep.ObjectMeta.Name, ep.ObjectMeta.Namespace, Svc, k.primaryZone()}, ".") + return []msg.Service{{Host: domain}} + } + } + } + } + return nil +} diff --git a/plugin/kubernetes/reverse_test.go b/plugin/kubernetes/reverse_test.go new file mode 100644 index 000000000..aa9d09585 --- /dev/null +++ b/plugin/kubernetes/reverse_test.go @@ -0,0 +1,125 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" + "k8s.io/client-go/1.5/pkg/api" +) + +type APIConnReverseTest struct{} + +func (APIConnReverseTest) Run() { return } +func (APIConnReverseTest) Stop() error { return nil } +func (APIConnReverseTest) PodIndex(string) []interface{} { return nil } + +func (APIConnReverseTest) ServiceList() []*api.Service { + svcs := []*api.Service{ + { + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: "192.168.1.100", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + } + return svcs +} + +func (APIConnReverseTest) EndpointsList() api.EndpointsList { + return api.EndpointsList{ + Items: []api.Endpoints{ + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "10.0.0.100", + Hostname: "ep1a", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + }, + }, + } +} + +func (APIConnReverseTest) GetNodeByName(name string) (api.Node, error) { + return api.Node{ + ObjectMeta: api.ObjectMeta{ + Name: "test.node.foo.bar", + }, + }, nil +} + +func TestReverse(t *testing.T) { + + k := New([]string{"cluster.local.", "0.10.in-addr.arpa."}) + k.APIConn = &APIConnReverseTest{} + + tests := []test.Case{ + { + Qname: "100.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.PTR("100.0.0.10.in-addr.arpa. 303 IN PTR ep1a.svc1.testns.svc.cluster.local."), + }, + }, + { + Qname: "101.0.0.10.in-addr.arpa.", Qtype: dns.TypePTR, + Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("0.10.in-addr.arpa. 300 IN SOA ns.dns.0.10.in-addr.arpa. hostmaster.0.10.in-addr.arpa. 1502782828 7200 1800 86400 60"), + }, + }, + { + Qname: "example.org.cluster.local.", Qtype: dns.TypePTR, + Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("cluster.local. 300 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1502989566 7200 1800 86400 60"), + }, + }, + } + + ctx := context.TODO() + for i, tc := range tests { + r := tc.Msg() + + w := dnsrecorder.New(&test.ResponseWriter{}) + + _, err := k.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d: expected no error, got %v", i, err) + return + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d: got nil message and no error for: %s %d", i, r.Question[0].Name, r.Question[0].Qtype) + } + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/kubernetes/setup.go b/plugin/kubernetes/setup.go new file mode 100644 index 000000000..e60239d42 --- /dev/null +++ b/plugin/kubernetes/setup.go @@ -0,0 +1,208 @@ +package kubernetes + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/proxy" + "github.com/miekg/dns" + + "github.com/mholt/caddy" + unversionedapi "k8s.io/client-go/1.5/pkg/api/unversioned" +) + +func init() { + caddy.RegisterPlugin("kubernetes", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + kubernetes, initOpts, err := kubernetesParse(c) + if err != nil { + return plugin.Error("kubernetes", err) + } + + err = kubernetes.initKubeCache(initOpts) + if err != nil { + return plugin.Error("kubernetes", err) + } + + // Register KubeCache start and stop functions with Caddy + c.OnStartup(func() error { + go kubernetes.APIConn.Run() + if kubernetes.APIProxy != nil { + go kubernetes.APIProxy.Run() + } + return nil + }) + + c.OnShutdown(func() error { + if kubernetes.APIProxy != nil { + kubernetes.APIProxy.Stop() + } + return kubernetes.APIConn.Stop() + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + kubernetes.Next = next + return kubernetes + }) + + return nil +} + +func kubernetesParse(c *caddy.Controller) (*Kubernetes, dnsControlOpts, error) { + k8s := New([]string{""}) + k8s.interfaceAddrsFunc = localPodIP + k8s.autoPathSearch = searchFromResolvConf() + + opts := dnsControlOpts{ + resyncPeriod: defaultResyncPeriod, + } + + for c.Next() { + zones := c.RemainingArgs() + + if len(zones) != 0 { + k8s.Zones = zones + for i := 0; i < len(k8s.Zones); i++ { + k8s.Zones[i] = plugin.Host(k8s.Zones[i]).Normalize() + } + } else { + k8s.Zones = make([]string, len(c.ServerBlockKeys)) + for i := 0; i < len(c.ServerBlockKeys); i++ { + k8s.Zones[i] = plugin.Host(c.ServerBlockKeys[i]).Normalize() + } + } + + k8s.primaryZoneIndex = -1 + for i, z := range k8s.Zones { + if strings.HasSuffix(z, "in-addr.arpa.") || strings.HasSuffix(z, "ip6.arpa.") { + continue + } + k8s.primaryZoneIndex = i + break + } + + if k8s.primaryZoneIndex == -1 { + return nil, opts, errors.New("non-reverse zone name must be used") + } + + for c.NextBlock() { + switch c.Val() { + case "pods": + args := c.RemainingArgs() + if len(args) == 1 { + switch args[0] { + case podModeDisabled, podModeInsecure, podModeVerified: + k8s.podMode = args[0] + default: + return nil, opts, fmt.Errorf("wrong value for pods: %s, must be one of: disabled, verified, insecure", args[0]) + } + continue + } + return nil, opts, c.ArgErr() + case "namespaces": + args := c.RemainingArgs() + if len(args) > 0 { + for _, a := range args { + k8s.Namespaces[a] = true + } + continue + } + return nil, opts, c.ArgErr() + case "endpoint": + args := c.RemainingArgs() + if len(args) > 0 { + for _, endpoint := range strings.Split(args[0], ",") { + k8s.APIServerList = append(k8s.APIServerList, strings.TrimSpace(endpoint)) + } + continue + } + return nil, opts, c.ArgErr() + case "tls": // cert key cacertfile + args := c.RemainingArgs() + if len(args) == 3 { + k8s.APIClientCert, k8s.APIClientKey, k8s.APICertAuth = args[0], args[1], args[2] + continue + } + return nil, opts, c.ArgErr() + case "resyncperiod": + args := c.RemainingArgs() + if len(args) > 0 { + rp, err := time.ParseDuration(args[0]) + if err != nil { + return nil, opts, fmt.Errorf("unable to parse resync duration value: '%v': %v", args[0], err) + } + opts.resyncPeriod = rp + continue + } + return nil, opts, c.ArgErr() + case "labels": + args := c.RemainingArgs() + if len(args) > 0 { + labelSelectorString := strings.Join(args, " ") + ls, err := unversionedapi.ParseToLabelSelector(labelSelectorString) + if err != nil { + return nil, opts, fmt.Errorf("unable to parse label selector value: '%v': %v", labelSelectorString, err) + } + opts.labelSelector = ls + continue + } + return nil, opts, c.ArgErr() + case "fallthrough": + args := c.RemainingArgs() + if len(args) == 0 { + k8s.Fallthrough = true + continue + } + return nil, opts, c.ArgErr() + case "upstream": + args := c.RemainingArgs() + if len(args) == 0 { + return nil, opts, c.ArgErr() + } + ups, err := dnsutil.ParseHostPortOrFile(args...) + if err != nil { + return nil, opts, err + } + k8s.Proxy = proxy.NewLookup(ups) + case "ttl": + args := c.RemainingArgs() + if len(args) == 0 { + return nil, opts, c.ArgErr() + } + t, err := strconv.Atoi(args[0]) + if err != nil { + return nil, opts, err + } + if t < 5 || t > 3600 { + return nil, opts, c.Errf("ttl must be in range [5, 3600]: %d", t) + } + k8s.ttl = uint32(t) + default: + return nil, opts, c.Errf("unknown property '%s'", c.Val()) + } + } + } + return k8s, opts, nil +} + +func searchFromResolvConf() []string { + rc, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil { + return nil + } + plugin.Zones(rc.Search).Normalize() + return rc.Search +} + +const defaultResyncPeriod = 5 * time.Minute diff --git a/plugin/kubernetes/setup_reverse_test.go b/plugin/kubernetes/setup_reverse_test.go new file mode 100644 index 000000000..ed51a7410 --- /dev/null +++ b/plugin/kubernetes/setup_reverse_test.go @@ -0,0 +1,35 @@ +package kubernetes + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestKubernetesParseReverseZone(t *testing.T) { + tests := []struct { + input string // Corefile data as string + expectedZones []string // expected count of defined zones. + }{ + {`kubernetes coredns.local 10.0.0.0/16`, []string{"coredns.local.", "0.10.in-addr.arpa."}}, + {`kubernetes coredns.local 10.0.0.0/17`, []string{"coredns.local.", "10.0.0.0/17."}}, + } + + for i, tc := range tests { + c := caddy.NewTestController("dns", tc.input) + k, _, err := kubernetesParse(c) + if err != nil { + t.Fatalf("Test %d: Expected no error, got %q", i, err) + } + + zl := len(k.Zones) + if zl != len(tc.expectedZones) { + t.Errorf("Test %d: Expected kubernetes to be initialized with %d zones, found %d zones", i, len(tc.expectedZones), zl) + } + for i, z := range tc.expectedZones { + if k.Zones[i] != z { + t.Errorf("Test %d: Expected zones to be %q, got %q", i, z, k.Zones[i]) + } + } + } +} diff --git a/plugin/kubernetes/setup_test.go b/plugin/kubernetes/setup_test.go new file mode 100644 index 000000000..2fdc38a9c --- /dev/null +++ b/plugin/kubernetes/setup_test.go @@ -0,0 +1,473 @@ +package kubernetes + +import ( + "strings" + "testing" + "time" + + "github.com/mholt/caddy" + "k8s.io/client-go/1.5/pkg/api/unversioned" +) + +func TestKubernetesParse(t *testing.T) { + tests := []struct { + input string // Corefile data as string + shouldErr bool // true if test case is exected to produce an error. + expectedErrContent string // substring from the expected error. Empty for positive cases. + expectedZoneCount int // expected count of defined zones. + expectedNSCount int // expected count of namespaces. + expectedResyncPeriod time.Duration // expected resync period value + expectedLabelSelector string // expected label selector value + expectedPodMode string + expectedFallthrough bool + expectedUpstreams []string + }{ + // positive + { + `kubernetes coredns.local`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local test.local`, + false, + "", + 2, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + endpoint http://localhost:9090 +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + namespaces demo +}`, + false, + "", + 1, + 1, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + namespaces demo test +}`, + false, + "", + 1, + 2, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + resyncperiod 30s +}`, + false, + "", + 1, + 0, + 30 * time.Second, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + resyncperiod 15m +}`, + false, + "", + 1, + 0, + 15 * time.Minute, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + labels environment=prod +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "environment=prod", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + labels environment in (production, staging, qa),application=nginx +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "application=nginx,environment in (production,qa,staging)", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local test.local { + resyncperiod 15m + endpoint http://localhost:8080 + namespaces demo test + labels environment in (production, staging, qa),application=nginx + fallthrough +}`, + false, + "", + 2, + 2, + 15 * time.Minute, + "application=nginx,environment in (production,qa,staging)", + podModeDisabled, + true, + nil, + }, + // negative + { + `kubernetes coredns.local { + endpoint +}`, + true, + "rong argument count or unexpected line ending", + -1, + -1, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + namespaces +}`, + true, + "rong argument count or unexpected line ending", + -1, + -1, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + resyncperiod +}`, + true, + "rong argument count or unexpected line ending", + -1, + 0, + 0 * time.Minute, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + resyncperiod 15 +}`, + true, + "unable to parse resync duration value", + -1, + 0, + 0 * time.Second, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + resyncperiod abc +}`, + true, + "unable to parse resync duration value", + -1, + 0, + 0 * time.Second, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + labels +}`, + true, + "rong argument count or unexpected line ending", + -1, + 0, + 0 * time.Second, + "", + podModeDisabled, + false, + nil, + }, + { + `kubernetes coredns.local { + labels environment in (production, qa +}`, + true, + "unable to parse label selector", + -1, + 0, + 0 * time.Second, + "", + podModeDisabled, + false, + nil, + }, + // pods disabled + { + `kubernetes coredns.local { + pods disabled +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + // pods insecure + { + `kubernetes coredns.local { + pods insecure +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "", + podModeInsecure, + false, + nil, + }, + // pods verified + { + `kubernetes coredns.local { + pods verified +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "", + podModeVerified, + false, + nil, + }, + // pods invalid + { + `kubernetes coredns.local { + pods giant_seed +}`, + true, + "rong value for pods", + -1, + 0, + defaultResyncPeriod, + "", + podModeVerified, + false, + nil, + }, + // fallthrough invalid + { + `kubernetes coredns.local { + fallthrough junk +}`, + true, + "rong argument count", + -1, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + // Valid upstream + { + `kubernetes coredns.local { + upstream 13.14.15.16:53 +}`, + false, + "", + 1, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + []string{"13.14.15.16:53"}, + }, + // Invalid upstream + { + `kubernetes coredns.local { + upstream 13.14.15.16orange +}`, + true, + "not an IP address or file: \"13.14.15.16orange\"", + -1, + 0, + defaultResyncPeriod, + "", + podModeDisabled, + false, + nil, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + k8sController, opts, err := kubernetesParse(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error, but did not find error for input '%s'. Error was: '%v'", i, test.input, err) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + continue + } + + if test.shouldErr && (len(test.expectedErrContent) < 1) { + t.Fatalf("Test %d: Test marked as expecting an error, but no expectedErrContent provided for input '%s'. Error was: '%v'", i, test.input, err) + } + + if test.shouldErr && (test.expectedZoneCount >= 0) { + t.Errorf("Test %d: Test marked as expecting an error, but provides value for expectedZoneCount!=-1 for input '%s'. Error was: '%v'", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + } + continue + } + + // No error was raised, so validate initialization of k8sController + // Zones + foundZoneCount := len(k8sController.Zones) + if foundZoneCount != test.expectedZoneCount { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with %d zones, instead found %d zones: '%v' for input '%s'", i, test.expectedZoneCount, foundZoneCount, k8sController.Zones, test.input) + } + + // Namespaces + foundNSCount := len(k8sController.Namespaces) + if foundNSCount != test.expectedNSCount { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with %d namespaces. Instead found %d namespaces: '%v' for input '%s'", i, test.expectedNSCount, foundNSCount, k8sController.Namespaces, test.input) + } + + // ResyncPeriod + foundResyncPeriod := opts.resyncPeriod + if foundResyncPeriod != test.expectedResyncPeriod { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with resync period '%s'. Instead found period '%s' for input '%s'", i, test.expectedResyncPeriod, foundResyncPeriod, test.input) + } + + // Labels + if opts.labelSelector != nil { + foundLabelSelectorString := unversioned.FormatLabelSelector(opts.labelSelector) + if foundLabelSelectorString != test.expectedLabelSelector { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with label selector '%s'. Instead found selector '%s' for input '%s'", i, test.expectedLabelSelector, foundLabelSelectorString, test.input) + } + } + // Pods + foundPodMode := k8sController.podMode + if foundPodMode != test.expectedPodMode { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with pod mode '%s'. Instead found pod mode '%s' for input '%s'", i, test.expectedPodMode, foundPodMode, test.input) + } + + // fallthrough + foundFallthrough := k8sController.Fallthrough + if foundFallthrough != test.expectedFallthrough { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with fallthrough '%v'. Instead found fallthrough '%v' for input '%s'", i, test.expectedFallthrough, foundFallthrough, test.input) + } + // upstream + foundUpstreams := k8sController.Proxy.Upstreams + if test.expectedUpstreams == nil { + if foundUpstreams != nil { + t.Errorf("Test %d: Expected kubernetes controller to not be initialized with upstreams for input '%s'", i, test.input) + } + } else { + if foundUpstreams == nil { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with upstreams for input '%s'", i, test.input) + } else { + if len(*foundUpstreams) != len(test.expectedUpstreams) { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with %d upstreams. Instead found %d upstreams for input '%s'", i, len(test.expectedUpstreams), len(*foundUpstreams), test.input) + } + for j, want := range test.expectedUpstreams { + got := (*foundUpstreams)[j].Select().Name + if got != want { + t.Errorf("Test %d: Expected kubernetes controller to be initialized with upstream '%s'. Instead found upstream '%s' for input '%s'", i, want, got, test.input) + } + } + + } + } + } +} diff --git a/plugin/kubernetes/setup_ttl_test.go b/plugin/kubernetes/setup_ttl_test.go new file mode 100644 index 000000000..d58f91576 --- /dev/null +++ b/plugin/kubernetes/setup_ttl_test.go @@ -0,0 +1,45 @@ +package kubernetes + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestKubernetesParseTTL(t *testing.T) { + tests := []struct { + input string // Corefile data as string + expectedTTL uint32 // expected count of defined zones. + shouldErr bool + }{ + {`kubernetes cluster.local { + ttl 56 + }`, 56, false}, + {`kubernetes cluster.local`, defaultTTL, false}, + {`kubernetes cluster.local { + ttl -1 + }`, 0, true}, + {`kubernetes cluster.local { + ttl 3601 + }`, 0, true}, + } + + for i, tc := range tests { + c := caddy.NewTestController("dns", tc.input) + k, _, err := kubernetesParse(c) + if err != nil && !tc.shouldErr { + t.Fatalf("Test %d: Expected no error, got %q", i, err) + } + if err == nil && tc.shouldErr { + t.Fatalf("Test %d: Expected error, got none", i) + } + if err != nil && tc.shouldErr { + // input should error + continue + } + + if k.ttl != tc.expectedTTL { + t.Errorf("Test %d: Expected TTl to be %d, got %d", i, tc.expectedTTL, k.ttl) + } + } +} diff --git a/plugin/loadbalance/README.md b/plugin/loadbalance/README.md new file mode 100644 index 000000000..1cce54ebf --- /dev/null +++ b/plugin/loadbalance/README.md @@ -0,0 +1,22 @@ +# loadbalance + +*loadbalance* acts as a round-robin DNS loadbalancer by randomizing the order of A and AAAA records + in the answer. + + See [Wikipedia](https://en.wikipedia.org/wiki/Round-robin_DNS) about the pros and cons on this + setup. It will take care to sort any CNAMEs before any address records, because some stub resolver + implementations (like glibc) are particular about that. + +## Syntax + +~~~ +loadbalance [POLICY] +~~~ + +* **POLICY** is how to balance, the default is "round_robin" + +## Examples + +~~~ +loadbalance round_robin +~~~ diff --git a/plugin/loadbalance/handler.go b/plugin/loadbalance/handler.go new file mode 100644 index 000000000..da4cf1549 --- /dev/null +++ b/plugin/loadbalance/handler.go @@ -0,0 +1,23 @@ +// Package loadbalance is plugin for rewriting responses to do "load balancing" +package loadbalance + +import ( + "github.com/coredns/coredns/plugin" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// RoundRobin is plugin to rewrite responses for "load balancing". +type RoundRobin struct { + Next plugin.Handler +} + +// ServeDNS implements the plugin.Handler interface. +func (rr RoundRobin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + wrr := &RoundRobinResponseWriter{w} + return plugin.NextOrFailure(rr.Name(), rr.Next, ctx, wrr, r) +} + +// Name implements the Handler interface. +func (rr RoundRobin) Name() string { return "loadbalance" } diff --git a/plugin/loadbalance/loadbalance.go b/plugin/loadbalance/loadbalance.go new file mode 100644 index 000000000..7df0b31c6 --- /dev/null +++ b/plugin/loadbalance/loadbalance.go @@ -0,0 +1,87 @@ +// Package loadbalance shuffles A and AAAA records. +package loadbalance + +import ( + "log" + + "github.com/miekg/dns" +) + +// RoundRobinResponseWriter is a response writer that shuffles A and AAAA records. +type RoundRobinResponseWriter struct { + dns.ResponseWriter +} + +// WriteMsg implements the dns.ResponseWriter interface. +func (r *RoundRobinResponseWriter) WriteMsg(res *dns.Msg) error { + if res.Rcode != dns.RcodeSuccess { + return r.ResponseWriter.WriteMsg(res) + } + + res.Answer = roundRobin(res.Answer) + res.Ns = roundRobin(res.Ns) + res.Extra = roundRobin(res.Extra) + + return r.ResponseWriter.WriteMsg(res) +} + +func roundRobin(in []dns.RR) []dns.RR { + cname := []dns.RR{} + address := []dns.RR{} + mx := []dns.RR{} + rest := []dns.RR{} + for _, r := range in { + switch r.Header().Rrtype { + case dns.TypeCNAME: + cname = append(cname, r) + case dns.TypeA, dns.TypeAAAA: + address = append(address, r) + case dns.TypeMX: + mx = append(mx, r) + default: + rest = append(rest, r) + } + } + + roundRobinShuffle(address) + roundRobinShuffle(mx) + + out := append(cname, rest...) + out = append(out, address...) + out = append(out, mx...) + return out +} + +func roundRobinShuffle(records []dns.RR) { + switch l := len(records); l { + case 0, 1: + break + case 2: + if dns.Id()%2 == 0 { + records[0], records[1] = records[1], records[0] + } + default: + for j := 0; j < l*(int(dns.Id())%4+1); j++ { + q := int(dns.Id()) % l + p := int(dns.Id()) % l + if q == p { + p = (p + 1) % l + } + records[q], records[p] = records[p], records[q] + } + } +} + +// Write implements the dns.ResponseWriter interface. +func (r *RoundRobinResponseWriter) Write(buf []byte) (int, error) { + // Should we pack and unpack here to fiddle with the packet... Not likely. + log.Printf("[WARNING] RoundRobin called with Write: no shuffling records") + n, err := r.ResponseWriter.Write(buf) + return n, err +} + +// Hijack implements the dns.ResponseWriter interface. +func (r *RoundRobinResponseWriter) Hijack() { + r.ResponseWriter.Hijack() + return +} diff --git a/plugin/loadbalance/loadbalance_test.go b/plugin/loadbalance/loadbalance_test.go new file mode 100644 index 000000000..bde92b543 --- /dev/null +++ b/plugin/loadbalance/loadbalance_test.go @@ -0,0 +1,168 @@ +package loadbalance + +import ( + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestLoadBalance(t *testing.T) { + rm := RoundRobin{Next: handler()} + + // the first X records must be cnames after this test + tests := []struct { + answer []dns.RR + extra []dns.RR + cnameAnswer int + cnameExtra int + addressAnswer int + addressExtra int + mxAnswer int + mxExtra int + }{ + { + answer: []dns.RR{ + test.CNAME("cname1.region2.skydns.test. 300 IN CNAME cname2.region2.skydns.test."), + test.CNAME("cname2.region2.skydns.test. 300 IN CNAME cname3.region2.skydns.test."), + test.CNAME("cname5.region2.skydns.test. 300 IN CNAME cname6.region2.skydns.test."), + test.CNAME("cname6.region2.skydns.test. 300 IN CNAME endpoint.region2.skydns.test."), + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.1"), + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx1.region2.skydns.test."), + test.MX("mx.region2.skydns.test. 300 IN MX 2 mx2.region2.skydns.test."), + test.MX("mx.region2.skydns.test. 300 IN MX 3 mx3.region2.skydns.test."), + }, + cnameAnswer: 4, + addressAnswer: 1, + mxAnswer: 3, + }, + { + answer: []dns.RR{ + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.1"), + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx1.region2.skydns.test."), + test.CNAME("cname.region2.skydns.test. 300 IN CNAME endpoint.region2.skydns.test."), + }, + cnameAnswer: 1, + addressAnswer: 1, + mxAnswer: 1, + }, + { + answer: []dns.RR{ + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx1.region2.skydns.test."), + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.1"), + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.2"), + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx2.region2.skydns.test."), + test.CNAME("cname2.region2.skydns.test. 300 IN CNAME cname3.region2.skydns.test."), + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.3"), + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx3.region2.skydns.test."), + }, + extra: []dns.RR{ + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.1"), + test.AAAA("endpoint.region2.skydns.test. 300 IN AAAA ::1"), + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx1.region2.skydns.test."), + test.CNAME("cname2.region2.skydns.test. 300 IN CNAME cname3.region2.skydns.test."), + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx2.region2.skydns.test."), + test.A("endpoint.region2.skydns.test. 300 IN A 10.240.0.3"), + test.AAAA("endpoint.region2.skydns.test. 300 IN AAAA ::2"), + test.MX("mx.region2.skydns.test. 300 IN MX 1 mx3.region2.skydns.test."), + }, + cnameAnswer: 1, + cnameExtra: 1, + addressAnswer: 3, + addressExtra: 4, + mxAnswer: 3, + mxExtra: 3, + }, + } + + rec := dnsrecorder.New(&test.ResponseWriter{}) + + for i, test := range tests { + req := new(dns.Msg) + req.SetQuestion("region2.skydns.test.", dns.TypeSRV) + req.Answer = test.answer + req.Extra = test.extra + + _, err := rm.ServeDNS(context.TODO(), rec, req) + if err != nil { + t.Errorf("Test %d: Expected no error, but got %s", i, err) + continue + + } + + cname, address, mx, sorted := countRecords(rec.Msg.Answer) + if !sorted { + t.Errorf("Test %d: Expected CNAMEs, then AAAAs, then MX in Answer, but got mixed", i) + } + if cname != test.cnameAnswer { + t.Errorf("Test %d: Expected %d CNAMEs in Answer, but got %d", i, test.cnameAnswer, cname) + } + if address != test.addressAnswer { + t.Errorf("Test %d: Expected %d A/AAAAs in Answer, but got %d", i, test.addressAnswer, address) + } + if mx != test.mxAnswer { + t.Errorf("Test %d: Expected %d MXs in Answer, but got %d", i, test.mxAnswer, mx) + } + + cname, address, mx, sorted = countRecords(rec.Msg.Extra) + if !sorted { + t.Errorf("Test %d: Expected CNAMEs, then AAAAs, then MX in Extra, but got mixed", i) + } + if cname != test.cnameExtra { + t.Errorf("Test %d: Expected %d CNAMEs in Extra, but got %d", i, test.cnameAnswer, cname) + } + if address != test.addressExtra { + t.Errorf("Test %d: Expected %d A/AAAAs in Extra, but got %d", i, test.addressAnswer, address) + } + if mx != test.mxExtra { + t.Errorf("Test %d: Expected %d MXs in Extra, but got %d", i, test.mxAnswer, mx) + } + } +} + +func countRecords(result []dns.RR) (cname int, address int, mx int, sorted bool) { + const ( + Start = iota + CNAMERecords + ARecords + MXRecords + Any + ) + + // The order of the records is used to determine if the round-robin actually did anything. + sorted = true + cname = 0 + address = 0 + mx = 0 + state := Start + for _, r := range result { + switch r.Header().Rrtype { + case dns.TypeCNAME: + sorted = sorted && state <= CNAMERecords + state = CNAMERecords + cname++ + case dns.TypeA, dns.TypeAAAA: + sorted = sorted && state <= ARecords + state = ARecords + address++ + case dns.TypeMX: + sorted = sorted && state <= MXRecords + state = MXRecords + mx++ + default: + state = Any + } + } + return +} + +func handler() plugin.Handler { + return plugin.HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + w.WriteMsg(r) + return dns.RcodeSuccess, nil + }) +} diff --git a/plugin/loadbalance/setup.go b/plugin/loadbalance/setup.go new file mode 100644 index 000000000..c2d90958e --- /dev/null +++ b/plugin/loadbalance/setup.go @@ -0,0 +1,26 @@ +package loadbalance + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("loadbalance", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + for c.Next() { + // TODO(miek): block and option parsing + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return RoundRobin{Next: next} + }) + + return nil +} diff --git a/plugin/log/README.md b/plugin/log/README.md new file mode 100644 index 000000000..223888ccc --- /dev/null +++ b/plugin/log/README.md @@ -0,0 +1,102 @@ +# log + +*log* enables query logging to standard output. + +## Syntax + +~~~ txt +log +~~~ + +* With no arguments, a query log entry is written to *stdout* in the common log format for all requests + +~~~ txt +log FILE +~~~ + +* **FILE** is the log file to create (or append to). The *only* valid name for **FILE** is *stdout*. + +~~~ txt +log [NAME] FILE [FORMAT] +~~~ + +* `NAME` is the name to match in order to be logged +* `FILE` is the log file (again only *stdout* is allowed here). +* `FORMAT` is the log format to use (default is Common Log Format) + +You can further specify the class of responses that get logged: + +~~~ txt +log [NAME] FILE [FORMAT] { + class [success|denial|error|all] +} +~~~ + +Here `success` `denial` and `error` denotes the class of responses that should be logged. The +classes have the following meaning: + +* `success`: successful response +* `denial`: either NXDOMAIN or NODATA (name exists, type does not) +* `error`: SERVFAIL, NOTIMP, REFUSED, etc. Anything that indicates the remote server is not willing to + resolve the request. +* `all`: the default - nothing is specified. + +If no class is specified, it defaults to *all*. + +## Log File + +The "log file" can only be *stdout*. CoreDNS expects another service to pick up this output and deal +with it, i.e. journald when using systemd or Docker's logging capabilities. + +## Log Format + +You can specify a custom log format with any placeholder values. Log supports both request and +response placeholders. + +The following place holders are supported: + +* `{type}`: qtype of the request +* `{name}`: qname of the request +* `{class}`: qclass of the request +* `{proto}`: protocol used (tcp or udp) +* `{when}`: time of the query +* `{remote}`: client's IP address +* `{size}`: request size in bytes +* `{port}`: client's port +* `{duration}`: response duration +* `{rcode}`: response RCODE +* `{rsize}`: response size +* `{>rflags}`: response flags, each set flag will be displayed, e.g. "aa, tc". This includes the qr + bit as well. +* `{>bufsize}`: the EDNS0 buffer size advertised in the query +* `{>do}`: is the EDNS0 DO (DNSSEC OK) bit set in the query +* `{>id}`: query ID +* `{>opcode}`: query OPCODE + +The default Common Log Format is: + +~~~ txt +`{remote} - [{when}] "{type} {class} {name} {proto} {size} {>do} {>bufsize}" {rcode} {>rflags} {rsize} {duration}` +~~~ + +## Examples + +Log all requests to stdout + +~~~ +log stdout +~~~ + +Custom log format, for all zones (`.`) + +~~~ +log . stdout "{proto} Request: {name} {type} {>id}" +~~~ + +Only log denials for example.org (and below to a file) + +~~~ +log example.org stdout { + class denial +} +~~~ diff --git a/plugin/log/log.go b/plugin/log/log.go new file mode 100644 index 000000000..52af79d35 --- /dev/null +++ b/plugin/log/log.go @@ -0,0 +1,91 @@ +// Package log implements basic but useful request (access) logging plugin. +package log + +import ( + "log" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metrics/vars" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/pkg/rcode" + "github.com/coredns/coredns/plugin/pkg/replacer" + "github.com/coredns/coredns/plugin/pkg/response" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Logger is a basic request logging plugin. +type Logger struct { + Next plugin.Handler + Rules []Rule + ErrorFunc func(dns.ResponseWriter, *dns.Msg, int) // failover error handler +} + +// ServeDNS implements the plugin.Handler interface. +func (l Logger) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + for _, rule := range l.Rules { + if !plugin.Name(rule.NameScope).Matches(state.Name()) { + continue + } + + rrw := dnsrecorder.New(w) + rc, err := plugin.NextOrFailure(l.Name(), l.Next, ctx, rrw, r) + + if rc > 0 { + // There was an error up the chain, but no response has been written yet. + // The error must be handled here so the log entry will record the response size. + if l.ErrorFunc != nil { + l.ErrorFunc(rrw, r, rc) + } else { + answer := new(dns.Msg) + answer.SetRcode(r, rc) + state.SizeAndDo(answer) + + vars.Report(state, vars.Dropped, rcode.ToString(rc), answer.Len(), time.Now()) + + w.WriteMsg(answer) + } + rc = 0 + } + + tpe, _ := response.Typify(rrw.Msg, time.Now().UTC()) + class := response.Classify(tpe) + if rule.Class == response.All || rule.Class == class { + rep := replacer.New(r, rrw, CommonLogEmptyValue) + rule.Log.Println(rep.Replace(rule.Format)) + } + + return rc, err + + } + return plugin.NextOrFailure(l.Name(), l.Next, ctx, w, r) +} + +// Name implements the Handler interface. +func (l Logger) Name() string { return "log" } + +// Rule configures the logging plugin. +type Rule struct { + NameScope string + Class response.Class + OutputFile string + Format string + Log *log.Logger +} + +const ( + // DefaultLogFilename is the default output name. This is the only supported value. + DefaultLogFilename = "stdout" + // CommonLogFormat is the common log format. + CommonLogFormat = `{remote} ` + CommonLogEmptyValue + ` [{when}] "{type} {class} {name} {proto} {size} {>do} {>bufsize}" {rcode} {>rflags} {rsize} {duration}` + // CommonLogEmptyValue is the common empty log value. + CommonLogEmptyValue = "-" + // CombinedLogFormat is the combined log format. + CombinedLogFormat = CommonLogFormat + ` "{>opcode}"` + // DefaultLogFormat is the default log format. + DefaultLogFormat = CommonLogFormat +) diff --git a/plugin/log/log_test.go b/plugin/log/log_test.go new file mode 100644 index 000000000..ee1201a13 --- /dev/null +++ b/plugin/log/log_test.go @@ -0,0 +1,101 @@ +package log + +import ( + "bytes" + "log" + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/pkg/response" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestLoggedStatus(t *testing.T) { + var f bytes.Buffer + rule := Rule{ + NameScope: ".", + Format: DefaultLogFormat, + Log: log.New(&f, "", 0), + } + + logger := Logger{ + Rules: []Rule{rule}, + Next: test.ErrorHandler(), + } + + ctx := context.TODO() + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + + rcode, _ := logger.ServeDNS(ctx, rec, r) + if rcode != 0 { + t.Errorf("Expected rcode to be 0 - was: %d", rcode) + } + + logged := f.String() + if !strings.Contains(logged, "A IN example.org. udp 29 false 512") { + t.Errorf("Expected it to be logged. Logged string: %s", logged) + } +} + +func TestLoggedClassDenial(t *testing.T) { + var f bytes.Buffer + rule := Rule{ + NameScope: ".", + Format: DefaultLogFormat, + Log: log.New(&f, "", 0), + Class: response.Denial, + } + + logger := Logger{ + Rules: []Rule{rule}, + Next: test.ErrorHandler(), + } + + ctx := context.TODO() + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + + logger.ServeDNS(ctx, rec, r) + + logged := f.String() + if len(logged) != 0 { + t.Errorf("Expected it not to be logged, but got string: %s", logged) + } +} + +func TestLoggedClassError(t *testing.T) { + var f bytes.Buffer + rule := Rule{ + NameScope: ".", + Format: DefaultLogFormat, + Log: log.New(&f, "", 0), + Class: response.Error, + } + + logger := Logger{ + Rules: []Rule{rule}, + Next: test.ErrorHandler(), + } + + ctx := context.TODO() + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeA) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + + logger.ServeDNS(ctx, rec, r) + + logged := f.String() + if !strings.Contains(logged, "SERVFAIL") { + t.Errorf("Expected it to be logged. Logged string: %s", logged) + } +} diff --git a/plugin/log/setup.go b/plugin/log/setup.go new file mode 100644 index 000000000..673962f10 --- /dev/null +++ b/plugin/log/setup.go @@ -0,0 +1,116 @@ +package log + +import ( + "fmt" + "log" + "os" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/response" + + "github.com/mholt/caddy" + "github.com/miekg/dns" +) + +func init() { + caddy.RegisterPlugin("log", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + rules, err := logParse(c) + if err != nil { + return plugin.Error("log", err) + } + + // Open the log files for writing when the server starts + c.OnStartup(func() error { + for i := 0; i < len(rules); i++ { + // We only support stdout + writer := os.Stdout + if rules[i].OutputFile != "stdout" { + return plugin.Error("log", fmt.Errorf("invalid log file: %s", rules[i].OutputFile)) + } + + rules[i].Log = log.New(writer, "", 0) + } + + return nil + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return Logger{Next: next, Rules: rules, ErrorFunc: dnsserver.DefaultErrorFunc} + }) + + return nil +} + +func logParse(c *caddy.Controller) ([]Rule, error) { + var rules []Rule + + for c.Next() { + args := c.RemainingArgs() + + if len(args) == 0 { + // Nothing specified; use defaults + rules = append(rules, Rule{ + NameScope: ".", + OutputFile: DefaultLogFilename, + Format: DefaultLogFormat, + }) + } else if len(args) == 1 { + // Only an output file specified. + rules = append(rules, Rule{ + NameScope: ".", + OutputFile: args[0], + Format: DefaultLogFormat, + }) + } else { + // Name scope, output file, and maybe a format specified + + format := DefaultLogFormat + + if len(args) > 2 { + switch args[2] { + case "{common}": + format = CommonLogFormat + case "{combined}": + format = CombinedLogFormat + default: + format = args[2] + } + } + + rules = append(rules, Rule{ + NameScope: dns.Fqdn(args[0]), + OutputFile: args[1], + Format: format, + }) + } + + // Class refinements in an extra block. + for c.NextBlock() { + switch c.Val() { + // class followed by all, denial, error or success. + case "class": + classes := c.RemainingArgs() + if len(classes) == 0 { + return nil, c.ArgErr() + } + cls, err := response.ClassFromString(classes[0]) + if err != nil { + return nil, err + } + // update class and the last added Rule (bit icky) + rules[len(rules)-1].Class = cls + default: + return nil, c.ArgErr() + } + } + } + + return rules, nil +} diff --git a/plugin/log/setup_test.go b/plugin/log/setup_test.go new file mode 100644 index 000000000..161f674be --- /dev/null +++ b/plugin/log/setup_test.go @@ -0,0 +1,130 @@ +package log + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/response" + + "github.com/mholt/caddy" +) + +func TestLogParse(t *testing.T) { + tests := []struct { + inputLogRules string + shouldErr bool + expectedLogRules []Rule + }{ + {`log`, false, []Rule{{ + NameScope: ".", + OutputFile: DefaultLogFilename, + Format: DefaultLogFormat, + }}}, + {`log log.txt`, false, []Rule{{ + NameScope: ".", + OutputFile: "log.txt", + Format: DefaultLogFormat, + }}}, + {`log example.org log.txt`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "log.txt", + Format: DefaultLogFormat, + }}}, + {`log example.org. stdout`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "stdout", + Format: DefaultLogFormat, + }}}, + {`log example.org log.txt {common}`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "log.txt", + Format: CommonLogFormat, + }}}, + {`log example.org accesslog.txt {combined}`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "accesslog.txt", + Format: CombinedLogFormat, + }}}, + {`log example.org. log.txt + log example.net accesslog.txt {combined}`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "log.txt", + Format: DefaultLogFormat, + }, { + NameScope: "example.net.", + OutputFile: "accesslog.txt", + Format: CombinedLogFormat, + }}}, + {`log example.org stdout {host} + log example.org log.txt {when}`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "stdout", + Format: "{host}", + }, { + NameScope: "example.org.", + OutputFile: "log.txt", + Format: "{when}", + }}}, + + {`log example.org log.txt { + class all + }`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "log.txt", + Format: CommonLogFormat, + Class: response.All, + }}}, + {`log example.org log.txt { + class denial + }`, false, []Rule{{ + NameScope: "example.org.", + OutputFile: "log.txt", + Format: CommonLogFormat, + Class: response.Denial, + }}}, + {`log { + class denial + }`, false, []Rule{{ + NameScope: ".", + OutputFile: DefaultLogFilename, + Format: CommonLogFormat, + Class: response.Denial, + }}}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputLogRules) + actualLogRules, err := logParse(c) + + if err == nil && test.shouldErr { + t.Errorf("Test %d didn't error, but it should have", i) + } else if err != nil && !test.shouldErr { + t.Errorf("Test %d errored, but it shouldn't have; got '%v'", i, err) + } + if len(actualLogRules) != len(test.expectedLogRules) { + t.Fatalf("Test %d expected %d no of Log rules, but got %d ", + i, len(test.expectedLogRules), len(actualLogRules)) + } + for j, actualLogRule := range actualLogRules { + + if actualLogRule.NameScope != test.expectedLogRules[j].NameScope { + t.Errorf("Test %d expected %dth LogRule NameScope to be %s , but got %s", + i, j, test.expectedLogRules[j].NameScope, actualLogRule.NameScope) + } + + if actualLogRule.OutputFile != test.expectedLogRules[j].OutputFile { + t.Errorf("Test %d expected %dth LogRule OutputFile to be %s , but got %s", + i, j, test.expectedLogRules[j].OutputFile, actualLogRule.OutputFile) + } + + if actualLogRule.Format != test.expectedLogRules[j].Format { + t.Errorf("Test %d expected %dth LogRule Format to be %s , but got %s", + i, j, test.expectedLogRules[j].Format, actualLogRule.Format) + } + + if actualLogRule.Class != test.expectedLogRules[j].Class { + t.Errorf("Test %d expected %dth LogRule Class to be %s , but got %s", + i, j, test.expectedLogRules[j].Class, actualLogRule.Class) + } + } + } + +} diff --git a/plugin/metrics/README.md b/plugin/metrics/README.md new file mode 100644 index 000000000..ead1f7e75 --- /dev/null +++ b/plugin/metrics/README.md @@ -0,0 +1,53 @@ +# prometheus + +This module enables prometheus metrics for CoreDNS. + +The default location for the metrics is `localhost:9153`. The metrics path is fixed to `/metrics`. +The following metrics are exported: + +* coredns_dns_request_count_total{zone, proto, family} +* coredns_dns_request_duration_milliseconds{zone} +* coredns_dns_request_size_bytes{zone, proto} +* coredns_dns_request_do_count_total{zone} +* coredns_dns_request_type_count_total{zone, type} +* coredns_dns_response_size_bytes{zone, proto} +* coredns_dns_response_rcode_count_total{zone, rcode} + +Each counter has a label `zone` which is the zonename used for the request/response. + +Extra labels used are: + +* `proto` which holds the transport of the response ("udp" or "tcp") +* The address family (`family`) of the transport (1 = IP (IP version 4), 2 = IP6 (IP version 6)). +* `type` which holds the query type. It holds most common types (A, AAAA, MX, SOA, CNAME, PTR, TXT, + NS, SRV, DS, DNSKEY, RRSIG, NSEC, NSEC3, IXFR, AXFR and ANY) and "other" which lumps together all + other types. +* The `response_rcode_count_total` has an extra label `rcode` which holds the rcode of the response. + +If monitoring is enabled, queries that do not enter the plugin chain are exported under the fake +name "dropped" (without a closing dot - this is never a valid domain name). + + +## Syntax + +~~~ +prometheus [ADDRESS] +~~~ + +For each zone that you want to see metrics for. + +It optionally takes an address to which the metrics are exported; the default +is `localhost:9153`. The metrics path is fixed to `/metrics`. + +## Examples + +Use an alternative address: + +~~~ +prometheus localhost:9253 +~~~ + +# Bugs + +When reloading, we keep the handler running, meaning that any changes to the handler's address +aren't picked up. You'll need to restart CoreDNS for that to happen. diff --git a/plugin/metrics/handler.go b/plugin/metrics/handler.go new file mode 100644 index 000000000..bc9a6ec47 --- /dev/null +++ b/plugin/metrics/handler.go @@ -0,0 +1,34 @@ +package metrics + +import ( + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metrics/vars" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/pkg/rcode" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// ServeDNS implements the Handler interface. +func (m *Metrics) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + qname := state.QName() + zone := plugin.Zones(m.ZoneNames()).Matches(qname) + if zone == "" { + zone = "." + } + + // Record response to get status code and size of the reply. + rw := dnsrecorder.New(w) + status, err := plugin.NextOrFailure(m.Name(), m.Next, ctx, rw, r) + + vars.Report(state, zone, rcode.ToString(rw.Rcode), rw.Len, rw.Start) + + return status, err +} + +// Name implements the Handler interface. +func (m *Metrics) Name() string { return "prometheus" } diff --git a/plugin/metrics/metrics.go b/plugin/metrics/metrics.go new file mode 100644 index 000000000..0dabcdf96 --- /dev/null +++ b/plugin/metrics/metrics.go @@ -0,0 +1,101 @@ +// Package metrics implement a handler and plugin that provides Prometheus metrics. +package metrics + +import ( + "log" + "net" + "net/http" + "sync" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metrics/vars" + + "github.com/prometheus/client_golang/prometheus" +) + +func init() { + prometheus.MustRegister(vars.RequestCount) + prometheus.MustRegister(vars.RequestDuration) + prometheus.MustRegister(vars.RequestSize) + prometheus.MustRegister(vars.RequestDo) + prometheus.MustRegister(vars.RequestType) + + prometheus.MustRegister(vars.ResponseSize) + prometheus.MustRegister(vars.ResponseRcode) +} + +// Metrics holds the prometheus configuration. The metrics' path is fixed to be /metrics +type Metrics struct { + Next plugin.Handler + Addr string + ln net.Listener + mux *http.ServeMux + + zoneNames []string + zoneMap map[string]bool + zoneMu sync.RWMutex +} + +// AddZone adds zone z to m. +func (m *Metrics) AddZone(z string) { + m.zoneMu.Lock() + m.zoneMap[z] = true + m.zoneNames = keys(m.zoneMap) + m.zoneMu.Unlock() +} + +// RemoveZone remove zone z from m. +func (m *Metrics) RemoveZone(z string) { + m.zoneMu.Lock() + delete(m.zoneMap, z) + m.zoneNames = keys(m.zoneMap) + m.zoneMu.Unlock() +} + +// ZoneNames returns the zones of m. +func (m *Metrics) ZoneNames() []string { + m.zoneMu.RLock() + s := m.zoneNames + m.zoneMu.RUnlock() + return s +} + +// OnStartup sets up the metrics on startup. +func (m *Metrics) OnStartup() error { + ln, err := net.Listen("tcp", m.Addr) + if err != nil { + log.Printf("[ERROR] Failed to start metrics handler: %s", err) + return err + } + + m.ln = ln + ListenAddr = m.ln.Addr().String() + + m.mux = http.NewServeMux() + m.mux.Handle("/metrics", prometheus.Handler()) + + go func() { + http.Serve(m.ln, m.mux) + }() + return nil +} + +// OnShutdown tears down the metrics on shutdown and restart. +func (m *Metrics) OnShutdown() error { + if m.ln != nil { + return m.ln.Close() + } + return nil +} + +func keys(m map[string]bool) []string { + sx := []string{} + for k := range m { + sx = append(sx, k) + } + return sx +} + +// ListenAddr is assigned the address of the prometheus listener. Its use is mainly in tests where +// we listen on "localhost:0" and need to retrieve the actual address. +var ListenAddr string diff --git a/plugin/metrics/metrics_test.go b/plugin/metrics/metrics_test.go new file mode 100644 index 000000000..f5a17607c --- /dev/null +++ b/plugin/metrics/metrics_test.go @@ -0,0 +1,83 @@ +package metrics + +import ( + "testing" + + "github.com/coredns/coredns/plugin" + mtest "github.com/coredns/coredns/plugin/metrics/test" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestMetrics(t *testing.T) { + met := &Metrics{Addr: "localhost:0", zoneMap: make(map[string]bool)} + if err := met.OnStartup(); err != nil { + t.Fatalf("Failed to start metrics handler: %s", err) + } + defer met.OnShutdown() + + met.AddZone("example.org.") + + tests := []struct { + next plugin.Handler + qname string + qtype uint16 + metric string + expectedValue string + }{ + // This all works because 1 bucket (1 zone, 1 type) + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "example.org", + metric: "coredns_dns_request_count_total", + expectedValue: "1", + }, + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "example.org", + metric: "coredns_dns_request_count_total", + expectedValue: "2", + }, + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "example.org", + metric: "coredns_dns_request_type_count_total", + expectedValue: "3", + }, + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "example.org", + metric: "coredns_dns_response_rcode_count_total", + expectedValue: "4", + }, + } + + ctx := context.TODO() + + for i, tc := range tests { + req := new(dns.Msg) + if tc.qtype == 0 { + tc.qtype = dns.TypeA + } + req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype) + met.Next = tc.next + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := met.ServeDNS(ctx, rec, req) + if err != nil { + t.Fatalf("Test %d: Expected no error, but got %s", i, err) + } + + result := mtest.Scrape(t, "http://"+ListenAddr+"/metrics") + + if tc.expectedValue != "" { + got, _ := mtest.MetricValue(tc.metric, result) + if got != tc.expectedValue { + t.Errorf("Test %d: Expected value %s for metrics %s, but got %s", i, tc.expectedValue, tc.metric, got) + } + } + } +} diff --git a/plugin/metrics/setup.go b/plugin/metrics/setup.go new file mode 100644 index 000000000..eecfac62c --- /dev/null +++ b/plugin/metrics/setup.go @@ -0,0 +1,100 @@ +package metrics + +import ( + "net" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("prometheus", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) + + uniqAddr = addrs{a: make(map[string]int)} +} + +func setup(c *caddy.Controller) error { + m, err := prometheusParse(c) + if err != nil { + return plugin.Error("prometheus", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + m.Next = next + return m + }) + + for a, v := range uniqAddr.a { + if v == todo { + // During restarts we will keep this handler running, BUG. + c.OncePerServerBlock(m.OnStartup) + } + uniqAddr.a[a] = done + } + c.OnFinalShutdown(m.OnShutdown) + + return nil +} + +func prometheusParse(c *caddy.Controller) (*Metrics, error) { + var ( + met = &Metrics{Addr: addr, zoneMap: make(map[string]bool)} + err error + ) + + defer func() { + uniqAddr.SetAddress(met.Addr) + }() + + for c.Next() { + if len(met.ZoneNames()) > 0 { + return met, c.Err("can only have one metrics module per server") + } + + for _, z := range c.ServerBlockKeys { + met.AddZone(plugin.Host(z).Normalize()) + } + args := c.RemainingArgs() + + switch len(args) { + case 0: + case 1: + met.Addr = args[0] + _, _, e := net.SplitHostPort(met.Addr) + if e != nil { + return met, e + } + default: + return met, c.ArgErr() + } + } + return met, err +} + +var uniqAddr addrs + +// Keep track on which addrs we listen, so we only start one listener. +type addrs struct { + a map[string]int +} + +func (a *addrs) SetAddress(addr string) { + // If already there and set to done, we've already started this listener. + if a.a[addr] == done { + return + } + a.a[addr] = todo +} + +// Addr is the address the where the metrics are exported by default. +const addr = "localhost:9153" + +const ( + todo = 1 + done = 2 +) diff --git a/plugin/metrics/setup_test.go b/plugin/metrics/setup_test.go new file mode 100644 index 000000000..73555427e --- /dev/null +++ b/plugin/metrics/setup_test.go @@ -0,0 +1,42 @@ +package metrics + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestPrometheusParse(t *testing.T) { + tests := []struct { + input string + shouldErr bool + addr string + }{ + // oks + {`prometheus`, false, "localhost:9153"}, + {`prometheus localhost:53`, false, "localhost:53"}, + // fails + {`prometheus {}`, true, ""}, + {`prometheus /foo`, true, ""}, + {`prometheus a b c`, true, ""}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + m, err := prometheusParse(c) + if test.shouldErr && err == nil { + t.Errorf("Test %v: Expected error but found nil", i) + continue + } else if !test.shouldErr && err != nil { + t.Errorf("Test %v: Expected no error but found error: %v", i, err) + continue + } + + if test.shouldErr { + continue + } + + if test.addr != m.Addr { + t.Errorf("Test %v: Expected address %s but found: %s", i, test.addr, m.Addr) + } + } +} diff --git a/plugin/metrics/test/scrape.go b/plugin/metrics/test/scrape.go new file mode 100644 index 000000000..a21c0061d --- /dev/null +++ b/plugin/metrics/test/scrape.go @@ -0,0 +1,225 @@ +// Adapted by Miek Gieben for CoreDNS testing. +// +// License from prom2json +// Copyright 2014 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package test will scrape a target and you can inspect the variables. +// Basic usage: +// +// result := Scrape("http://localhost:9153/metrics") +// v := MetricValue("coredns_cache_capacity", result) +// +package test + +import ( + "fmt" + "io" + "mime" + "net/http" + "testing" + + "github.com/matttproud/golang_protobuf_extensions/pbutil" + "github.com/prometheus/common/expfmt" + + dto "github.com/prometheus/client_model/go" +) + +type ( + // MetricFamily holds a prometheus metric. + MetricFamily struct { + Name string `json:"name"` + Help string `json:"help"` + Type string `json:"type"` + Metrics []interface{} `json:"metrics,omitempty"` // Either metric or summary. + } + + // metric is for all "single value" metrics. + metric struct { + Labels map[string]string `json:"labels,omitempty"` + Value string `json:"value"` + } + + summary struct { + Labels map[string]string `json:"labels,omitempty"` + Quantiles map[string]string `json:"quantiles,omitempty"` + Count string `json:"count"` + Sum string `json:"sum"` + } + + histogram struct { + Labels map[string]string `json:"labels,omitempty"` + Buckets map[string]string `json:"buckets,omitempty"` + Count string `json:"count"` + Sum string `json:"sum"` + } +) + +// Scrape returns the all the vars a []*metricFamily. +func Scrape(t *testing.T, url string) []*MetricFamily { + mfChan := make(chan *dto.MetricFamily, 1024) + + go fetchMetricFamilies(url, mfChan) + + result := []*MetricFamily{} + for mf := range mfChan { + result = append(result, newMetricFamily(mf)) + } + return result +} + +// MetricValue returns the value associated with name as a string as well as the labels. +// It only returns the first metrics of the slice. +func MetricValue(name string, mfs []*MetricFamily) (string, map[string]string) { + for _, mf := range mfs { + if mf.Name == name { + // Only works with Gauge and Counter... + return mf.Metrics[0].(metric).Value, mf.Metrics[0].(metric).Labels + } + } + return "", nil +} + +// MetricValueLabel returns the value for name *and* label *value*. +func MetricValueLabel(name, label string, mfs []*MetricFamily) (string, map[string]string) { + // bit hacky is this really handy...? + for _, mf := range mfs { + if mf.Name == name { + for _, m := range mf.Metrics { + for _, v := range m.(metric).Labels { + if v == label { + return m.(metric).Value, m.(metric).Labels + } + } + + } + } + } + return "", nil +} + +func newMetricFamily(dtoMF *dto.MetricFamily) *MetricFamily { + mf := &MetricFamily{ + Name: dtoMF.GetName(), + Help: dtoMF.GetHelp(), + Type: dtoMF.GetType().String(), + Metrics: make([]interface{}, len(dtoMF.Metric)), + } + for i, m := range dtoMF.Metric { + if dtoMF.GetType() == dto.MetricType_SUMMARY { + mf.Metrics[i] = summary{ + Labels: makeLabels(m), + Quantiles: makeQuantiles(m), + Count: fmt.Sprint(m.GetSummary().GetSampleCount()), + Sum: fmt.Sprint(m.GetSummary().GetSampleSum()), + } + } else if dtoMF.GetType() == dto.MetricType_HISTOGRAM { + mf.Metrics[i] = histogram{ + Labels: makeLabels(m), + Buckets: makeBuckets(m), + Count: fmt.Sprint(m.GetHistogram().GetSampleCount()), + Sum: fmt.Sprint(m.GetSummary().GetSampleSum()), + } + } else { + mf.Metrics[i] = metric{ + Labels: makeLabels(m), + Value: fmt.Sprint(value(m)), + } + } + } + return mf +} + +func value(m *dto.Metric) float64 { + if m.Gauge != nil { + return m.GetGauge().GetValue() + } + if m.Counter != nil { + return m.GetCounter().GetValue() + } + if m.Untyped != nil { + return m.GetUntyped().GetValue() + } + return 0. +} + +func makeLabels(m *dto.Metric) map[string]string { + result := map[string]string{} + for _, lp := range m.Label { + result[lp.GetName()] = lp.GetValue() + } + return result +} + +func makeQuantiles(m *dto.Metric) map[string]string { + result := map[string]string{} + for _, q := range m.GetSummary().Quantile { + result[fmt.Sprint(q.GetQuantile())] = fmt.Sprint(q.GetValue()) + } + return result +} + +func makeBuckets(m *dto.Metric) map[string]string { + result := map[string]string{} + for _, b := range m.GetHistogram().Bucket { + result[fmt.Sprint(b.GetUpperBound())] = fmt.Sprint(b.GetCumulativeCount()) + } + return result +} + +func fetchMetricFamilies(url string, ch chan<- *dto.MetricFamily) { + defer close(ch) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return + } + req.Header.Add("Accept", acceptHeader) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return + } + + mediatype, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err == nil && mediatype == "application/vnd.google.protobuf" && + params["encoding"] == "delimited" && + params["proto"] == "io.prometheus.client.MetricFamily" { + for { + mf := &dto.MetricFamily{} + if _, err = pbutil.ReadDelimited(resp.Body, mf); err != nil { + if err == io.EOF { + break + } + return + } + ch <- mf + } + } else { + // We could do further content-type checks here, but the + // fallback for now will anyway be the text format + // version 0.0.4, so just go for it and see if it works. + var parser expfmt.TextParser + metricFamilies, err := parser.TextToMetricFamilies(resp.Body) + if err != nil { + return + } + for _, mf := range metricFamilies { + ch <- mf + } + } +} + +const acceptHeader = `application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited;q=0.7,text/plain;version=0.0.4;q=0.3` diff --git a/plugin/metrics/vars/report.go b/plugin/metrics/vars/report.go new file mode 100644 index 000000000..5d8f2ba64 --- /dev/null +++ b/plugin/metrics/vars/report.go @@ -0,0 +1,62 @@ +package vars + +import ( + "time" + + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// Report reports the metrics data associcated with request. +func Report(req request.Request, zone, rcode string, size int, start time.Time) { + // Proto and Family. + net := req.Proto() + fam := "1" + if req.Family() == 2 { + fam = "2" + } + + typ := req.QType() + + RequestCount.WithLabelValues(zone, net, fam).Inc() + RequestDuration.WithLabelValues(zone).Observe(float64(time.Since(start) / time.Millisecond)) + + if req.Do() { + RequestDo.WithLabelValues(zone).Inc() + } + + if _, known := monitorType[typ]; known { + RequestType.WithLabelValues(zone, dns.Type(typ).String()).Inc() + } else { + RequestType.WithLabelValues(zone, other).Inc() + } + + ResponseSize.WithLabelValues(zone, net).Observe(float64(size)) + RequestSize.WithLabelValues(zone, net).Observe(float64(req.Len())) + + ResponseRcode.WithLabelValues(zone, rcode).Inc() +} + +var monitorType = map[uint16]bool{ + dns.TypeAAAA: true, + dns.TypeA: true, + dns.TypeCNAME: true, + dns.TypeDNSKEY: true, + dns.TypeDS: true, + dns.TypeMX: true, + dns.TypeNSEC3: true, + dns.TypeNSEC: true, + dns.TypeNS: true, + dns.TypePTR: true, + dns.TypeRRSIG: true, + dns.TypeSOA: true, + dns.TypeSRV: true, + dns.TypeTXT: true, + // Meta Qtypes + dns.TypeIXFR: true, + dns.TypeAXFR: true, + dns.TypeANY: true, +} + +const other = "other" diff --git a/plugin/metrics/vars/vars.go b/plugin/metrics/vars/vars.go new file mode 100644 index 000000000..826f9ebed --- /dev/null +++ b/plugin/metrics/vars/vars.go @@ -0,0 +1,69 @@ +package vars + +import ( + "github.com/coredns/coredns/plugin" + + "github.com/prometheus/client_golang/prometheus" +) + +// Request* and Response* are the prometheus counters and gauges we are using for exporting metrics. +var ( + RequestCount = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "request_count_total", + Help: "Counter of DNS requests made per zone, protocol and family.", + }, []string{"zone", "proto", "family"}) + + RequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "request_duration_milliseconds", + Buckets: append(prometheus.DefBuckets, []float64{50, 100, 200, 500, 1000, 2000, 3000, 4000, 5000, 10000}...), + Help: "Histogram of the time (in milliseconds) each request took.", + }, []string{"zone"}) + + RequestSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "request_size_bytes", + Help: "Size of the EDNS0 UDP buffer in bytes (64K for TCP).", + Buckets: []float64{0, 100, 200, 300, 400, 511, 1023, 2047, 4095, 8291, 16e3, 32e3, 48e3, 64e3}, + }, []string{"zone", "proto"}) + + RequestDo = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "request_do_count_total", + Help: "Counter of DNS requests with DO bit set per zone.", + }, []string{"zone"}) + + RequestType = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "request_type_count_total", + Help: "Counter of DNS requests per type, per zone.", + }, []string{"zone", "type"}) + + ResponseSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "response_size_bytes", + Help: "Size of the returned response in bytes.", + Buckets: []float64{0, 100, 200, 300, 400, 511, 1023, 2047, 4095, 8291, 16e3, 32e3, 48e3, 64e3}, + }, []string{"zone", "proto"}) + + ResponseRcode = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "response_rcode_count_total", + Help: "Counter of response status codes.", + }, []string{"zone", "rcode"}) +) + +const ( + subsystem = "dns" + + // Dropped indicates we dropped the query before any handling. It has no closing dot, so it can not be a valid zone. + Dropped = "dropped" +) diff --git a/plugin/normalize.go b/plugin/normalize.go new file mode 100644 index 000000000..75b9c53c8 --- /dev/null +++ b/plugin/normalize.go @@ -0,0 +1,137 @@ +package plugin + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/miekg/dns" +) + +// See core/dnsserver/address.go - we should unify these two impls. + +// Zones respresents a lists of zone names. +type Zones []string + +// Matches checks is qname is a subdomain of any of the zones in z. The match +// will return the most specific zones that matches other. The empty string +// signals a not found condition. +func (z Zones) Matches(qname string) string { + zone := "" + for _, zname := range z { + if dns.IsSubDomain(zname, qname) { + // We want the *longest* matching zone, otherwise we may end up in a parent + if len(zname) > len(zone) { + zone = zname + } + } + } + return zone +} + +// Normalize fully qualifies all zones in z. The zones in Z must be domain names, without +// a port or protocol prefix. +func (z Zones) Normalize() { + for i := range z { + z[i] = Name(z[i]).Normalize() + } +} + +// Name represents a domain name. +type Name string + +// Matches checks to see if other is a subdomain (or the same domain) of n. +// This method assures that names can be easily and consistently matched. +func (n Name) Matches(child string) bool { + if dns.Name(n) == dns.Name(child) { + return true + } + return dns.IsSubDomain(string(n), child) +} + +// Normalize lowercases and makes n fully qualified. +func (n Name) Normalize() string { return strings.ToLower(dns.Fqdn(string(n))) } + +type ( + // Host represents a host from the Corefile, may contain port. + Host string +) + +// Normalize will return the host portion of host, stripping +// of any port or transport. The host will also be fully qualified and lowercased. +func (h Host) Normalize() string { + + s := string(h) + + switch { + case strings.HasPrefix(s, TransportTLS+"://"): + s = s[len(TransportTLS+"://"):] + case strings.HasPrefix(s, TransportDNS+"://"): + s = s[len(TransportDNS+"://"):] + case strings.HasPrefix(s, TransportGRPC+"://"): + s = s[len(TransportGRPC+"://"):] + } + + // The error can be ignore here, because this function is called after the corefile + // has already been vetted. + host, _, _ := SplitHostPort(s) + return Name(host).Normalize() +} + +// SplitHostPort splits s up in a host and port portion, taking reverse address notation into account. +// String the string s should *not* be prefixed with any protocols, i.e. dns:// +func SplitHostPort(s string) (host, port string, err error) { + // If there is: :[0-9]+ on the end we assume this is the port. This works for (ascii) domain + // names and our reverse syntax, which always needs a /mask *before* the port. + // So from the back, find first colon, and then check if its a number. + host = s + + colon := strings.LastIndex(s, ":") + if colon == len(s)-1 { + return "", "", fmt.Errorf("expecting data after last colon: %q", s) + } + if colon != -1 { + if p, err := strconv.Atoi(s[colon+1:]); err == nil { + port = strconv.Itoa(p) + host = s[:colon] + } + } + + // TODO(miek): this should take escaping into account. + if len(host) > 255 { + return "", "", fmt.Errorf("specified zone is too long: %d > 255", len(host)) + } + + _, d := dns.IsDomainName(host) + if !d { + return "", "", fmt.Errorf("zone is not a valid domain name: %s", host) + } + + // Check if it parses as a reverse zone, if so we use that. Must be fully + // specified IP and mask and mask % 8 = 0. + ip, net, err := net.ParseCIDR(host) + if err == nil { + if rev, e := dns.ReverseAddr(ip.String()); e == nil { + ones, bits := net.Mask.Size() + if (bits-ones)%8 == 0 { + offset, end := 0, false + for i := 0; i < (bits-ones)/8; i++ { + offset, end = dns.NextLabel(rev, offset) + if end { + break + } + } + host = rev[offset:] + } + } + } + return host, port, nil +} + +// Duplicated from core/dnsserver/address.go ! +const ( + TransportDNS = "dns" + TransportTLS = "tls" + TransportGRPC = "grpc" +) diff --git a/plugin/normalize_test.go b/plugin/normalize_test.go new file mode 100644 index 000000000..3eb9c5231 --- /dev/null +++ b/plugin/normalize_test.go @@ -0,0 +1,84 @@ +package plugin + +import "testing" + +func TestZoneMatches(t *testing.T) { + child := "example.org." + zones := Zones([]string{"org.", "."}) + actual := zones.Matches(child) + if actual != "org." { + t.Errorf("Expected %v, got %v", "org.", actual) + } + + child = "bla.example.org." + zones = Zones([]string{"bla.example.org.", "org.", "."}) + actual = zones.Matches(child) + + if actual != "bla.example.org." { + t.Errorf("Expected %v, got %v", "org.", actual) + } +} + +func TestZoneNormalize(t *testing.T) { + zones := Zones([]string{"example.org", "Example.ORG.", "example.org."}) + expected := "example.org." + zones.Normalize() + + for _, actual := range zones { + if actual != expected { + t.Errorf("Expected %v, got %v\n", expected, actual) + } + } +} + +func TestNameMatches(t *testing.T) { + matches := []struct { + child string + parent string + expected bool + }{ + {".", ".", true}, + {"example.org.", ".", true}, + {"example.org.", "example.org.", true}, + {"example.org.", "org.", true}, + {"org.", "example.org.", false}, + } + + for _, m := range matches { + actual := Name(m.parent).Matches(m.child) + if actual != m.expected { + t.Errorf("Expected %v for %s/%s, got %v", m.expected, m.parent, m.child, actual) + } + + } +} + +func TestNameNormalize(t *testing.T) { + names := []string{ + "example.org", "example.org.", + "Example.ORG.", "example.org."} + + for i := 0; i < len(names); i += 2 { + ts := names[i] + expected := names[i+1] + actual := Name(ts).Normalize() + if expected != actual { + t.Errorf("Expected %v, got %v\n", expected, actual) + } + } +} + +func TestHostNormalize(t *testing.T) { + hosts := []string{".:53", ".", "example.org:53", "example.org.", "example.org.:53", "example.org.", + "10.0.0.0/8:53", "10.in-addr.arpa.", "10.0.0.0/9", "10.0.0.0/9.", + "dns://example.org", "example.org."} + + for i := 0; i < len(hosts); i += 2 { + ts := hosts[i] + expected := hosts[i+1] + actual := Host(ts).Normalize() + if expected != actual { + t.Errorf("Expected %v, got %v\n", expected, actual) + } + } +} diff --git a/plugin/pkg/cache/cache.go b/plugin/pkg/cache/cache.go new file mode 100644 index 000000000..56cae2180 --- /dev/null +++ b/plugin/pkg/cache/cache.go @@ -0,0 +1,129 @@ +// Package cache implements a cache. The cache hold 256 shards, each shard +// holds a cache: a map with a mutex. There is no fancy expunge algorithm, it +// just randomly evicts elements when it gets full. +package cache + +import ( + "hash/fnv" + "sync" +) + +// Hash returns the FNV hash of what. +func Hash(what []byte) uint32 { + h := fnv.New32() + h.Write(what) + return h.Sum32() +} + +// Cache is cache. +type Cache struct { + shards [shardSize]*shard +} + +// shard is a cache with random eviction. +type shard struct { + items map[uint32]interface{} + size int + + sync.RWMutex +} + +// New returns a new cache. +func New(size int) *Cache { + ssize := size / shardSize + if ssize < 512 { + ssize = 512 + } + + c := &Cache{} + + // Initialize all the shards + for i := 0; i < shardSize; i++ { + c.shards[i] = newShard(ssize) + } + return c +} + +// Add adds a new element to the cache. If the element already exists it is overwritten. +func (c *Cache) Add(key uint32, el interface{}) { + shard := key & (shardSize - 1) + c.shards[shard].Add(key, el) +} + +// Get looks up element index under key. +func (c *Cache) Get(key uint32) (interface{}, bool) { + shard := key & (shardSize - 1) + return c.shards[shard].Get(key) +} + +// Remove removes the element indexed with key. +func (c *Cache) Remove(key uint32) { + shard := key & (shardSize - 1) + c.shards[shard].Remove(key) +} + +// Len returns the number of elements in the cache. +func (c *Cache) Len() int { + l := 0 + for _, s := range c.shards { + l += s.Len() + } + return l +} + +// newShard returns a new shard with size. +func newShard(size int) *shard { return &shard{items: make(map[uint32]interface{}), size: size} } + +// Add adds element indexed by key into the cache. Any existing element is overwritten +func (s *shard) Add(key uint32, el interface{}) { + l := s.Len() + if l+1 > s.size { + s.Evict() + } + + s.Lock() + s.items[key] = el + s.Unlock() +} + +// Remove removes the element indexed by key from the cache. +func (s *shard) Remove(key uint32) { + s.Lock() + delete(s.items, key) + s.Unlock() +} + +// Evict removes a random element from the cache. +func (s *shard) Evict() { + s.Lock() + defer s.Unlock() + + key := -1 + for k := range s.items { + key = int(k) + break + } + if key == -1 { + // empty cache + return + } + delete(s.items, uint32(key)) +} + +// Get looks up the element indexed under key. +func (s *shard) Get(key uint32) (interface{}, bool) { + s.RLock() + el, found := s.items[key] + s.RUnlock() + return el, found +} + +// Len returns the current length of the cache. +func (s *shard) Len() int { + s.RLock() + l := len(s.items) + s.RUnlock() + return l +} + +const shardSize = 256 diff --git a/plugin/pkg/cache/cache_test.go b/plugin/pkg/cache/cache_test.go new file mode 100644 index 000000000..2c92bf438 --- /dev/null +++ b/plugin/pkg/cache/cache_test.go @@ -0,0 +1,31 @@ +package cache + +import "testing" + +func TestCacheAddAndGet(t *testing.T) { + c := New(4) + c.Add(1, 1) + + if _, found := c.Get(1); !found { + t.Fatal("Failed to find inserted record") + } +} + +func TestCacheLen(t *testing.T) { + c := New(4) + + c.Add(1, 1) + if l := c.Len(); l != 1 { + t.Fatalf("Cache size should %d, got %d", 1, l) + } + + c.Add(1, 1) + if l := c.Len(); l != 1 { + t.Fatalf("Cache size should %d, got %d", 1, l) + } + + c.Add(2, 2) + if l := c.Len(); l != 2 { + t.Fatalf("Cache size should %d, got %d", 2, l) + } +} diff --git a/plugin/pkg/cache/shard_test.go b/plugin/pkg/cache/shard_test.go new file mode 100644 index 000000000..26675cee1 --- /dev/null +++ b/plugin/pkg/cache/shard_test.go @@ -0,0 +1,60 @@ +package cache + +import "testing" + +func TestShardAddAndGet(t *testing.T) { + s := newShard(4) + s.Add(1, 1) + + if _, found := s.Get(1); !found { + t.Fatal("Failed to find inserted record") + } +} + +func TestShardLen(t *testing.T) { + s := newShard(4) + + s.Add(1, 1) + if l := s.Len(); l != 1 { + t.Fatalf("Shard size should %d, got %d", 1, l) + } + + s.Add(1, 1) + if l := s.Len(); l != 1 { + t.Fatalf("Shard size should %d, got %d", 1, l) + } + + s.Add(2, 2) + if l := s.Len(); l != 2 { + t.Fatalf("Shard size should %d, got %d", 2, l) + } +} + +func TestShardEvict(t *testing.T) { + s := newShard(1) + s.Add(1, 1) + s.Add(2, 2) + // 1 should be gone + + if _, found := s.Get(1); found { + t.Fatal("Found item that should have been evicted") + } +} + +func TestShardLenEvict(t *testing.T) { + s := newShard(4) + s.Add(1, 1) + s.Add(2, 1) + s.Add(3, 1) + s.Add(4, 1) + + if l := s.Len(); l != 4 { + t.Fatalf("Shard size should %d, got %d", 4, l) + } + + // This should evict one element + s.Add(5, 1) + if l := s.Len(); l != 4 { + t.Fatalf("Shard size should %d, got %d", 4, l) + } +} diff --git a/plugin/pkg/dnsrecorder/recorder.go b/plugin/pkg/dnsrecorder/recorder.go new file mode 100644 index 000000000..3ca5f00d0 --- /dev/null +++ b/plugin/pkg/dnsrecorder/recorder.go @@ -0,0 +1,58 @@ +// Package dnsrecorder allows you to record a DNS response when it is send to the client. +package dnsrecorder + +import ( + "time" + + "github.com/miekg/dns" +) + +// Recorder is a type of ResponseWriter that captures +// the rcode code written to it and also the size of the message +// written in the response. A rcode code does not have +// to be written, however, in which case 0 must be assumed. +// It is best to have the constructor initialize this type +// with that default status code. +type Recorder struct { + dns.ResponseWriter + Rcode int + Len int + Msg *dns.Msg + Start time.Time +} + +// New makes and returns a new Recorder, +// which captures the DNS rcode from the ResponseWriter +// and also the length of the response message written through it. +func New(w dns.ResponseWriter) *Recorder { + return &Recorder{ + ResponseWriter: w, + Rcode: 0, + Msg: nil, + Start: time.Now(), + } +} + +// WriteMsg records the status code and calls the +// underlying ResponseWriter's WriteMsg method. +func (r *Recorder) WriteMsg(res *dns.Msg) error { + r.Rcode = res.Rcode + // We may get called multiple times (axfr for instance). + // Save the last message, but add the sizes. + r.Len += res.Len() + r.Msg = res + return r.ResponseWriter.WriteMsg(res) +} + +// Write is a wrapper that records the length of the message that gets written. +func (r *Recorder) Write(buf []byte) (int, error) { + n, err := r.ResponseWriter.Write(buf) + if err == nil { + r.Len += n + } + return n, err +} + +// Hijack implements dns.Hijacker. It simply wraps the underlying +// ResponseWriter's Hijack method if there is one, or returns an error. +func (r *Recorder) Hijack() { r.ResponseWriter.Hijack(); return } diff --git a/plugin/pkg/dnsrecorder/recorder_test.go b/plugin/pkg/dnsrecorder/recorder_test.go new file mode 100644 index 000000000..c9c2f6ce4 --- /dev/null +++ b/plugin/pkg/dnsrecorder/recorder_test.go @@ -0,0 +1,28 @@ +package dnsrecorder + +/* +func TestNewResponseRecorder(t *testing.T) { + w := httptest.NewRecorder() + recordRequest := NewResponseRecorder(w) + if !(recordRequest.ResponseWriter == w) { + t.Fatalf("Expected Response writer in the Recording to be same as the one sent\n") + } + if recordRequest.status != http.StatusOK { + t.Fatalf("Expected recorded status to be http.StatusOK (%d) , but found %d\n ", http.StatusOK, recordRequest.status) + } +} + +func TestWrite(t *testing.T) { + w := httptest.NewRecorder() + responseTestString := "test" + recordRequest := NewResponseRecorder(w) + buf := []byte(responseTestString) + recordRequest.Write(buf) + if recordRequest.size != len(buf) { + t.Fatalf("Expected the bytes written counter to be %d, but instead found %d\n", len(buf), recordRequest.size) + } + if w.Body.String() != responseTestString { + t.Fatalf("Expected Response Body to be %s , but found %s\n", responseTestString, w.Body.String()) + } +} +*/ diff --git a/plugin/pkg/dnsutil/cname.go b/plugin/pkg/dnsutil/cname.go new file mode 100644 index 000000000..281e03218 --- /dev/null +++ b/plugin/pkg/dnsutil/cname.go @@ -0,0 +1,15 @@ +package dnsutil + +import "github.com/miekg/dns" + +// DuplicateCNAME returns true if r already exists in records. +func DuplicateCNAME(r *dns.CNAME, records []dns.RR) bool { + for _, rec := range records { + if v, ok := rec.(*dns.CNAME); ok { + if v.Target == r.Target { + return true + } + } + } + return false +} diff --git a/plugin/pkg/dnsutil/cname_test.go b/plugin/pkg/dnsutil/cname_test.go new file mode 100644 index 000000000..5fb8d3029 --- /dev/null +++ b/plugin/pkg/dnsutil/cname_test.go @@ -0,0 +1,55 @@ +package dnsutil + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestDuplicateCNAME(t *testing.T) { + tests := []struct { + cname string + records []string + expected bool + }{ + { + "1.0.0.192.IN-ADDR.ARPA. 3600 IN CNAME 1.0.0.0.192.IN-ADDR.ARPA.", + []string{ + "US. 86400 IN NSEC 0-.us. NS SOA RRSIG NSEC DNSKEY TYPE65534", + "1.0.0.192.IN-ADDR.ARPA. 3600 IN CNAME 1.0.0.0.192.IN-ADDR.ARPA.", + }, + true, + }, + { + "1.0.0.192.IN-ADDR.ARPA. 3600 IN CNAME 1.0.0.0.192.IN-ADDR.ARPA.", + []string{ + "US. 86400 IN NSEC 0-.us. NS SOA RRSIG NSEC DNSKEY TYPE65534", + }, + false, + }, + { + "1.0.0.192.IN-ADDR.ARPA. 3600 IN CNAME 1.0.0.0.192.IN-ADDR.ARPA.", + []string{}, + false, + }, + } + for i, test := range tests { + cnameRR, err := dns.NewRR(test.cname) + if err != nil { + t.Fatalf("Test %d, cname ('%s') error (%s)!", i, test.cname, err) + } + cname := cnameRR.(*dns.CNAME) + records := []dns.RR{} + for j, r := range test.records { + rr, err := dns.NewRR(r) + if err != nil { + t.Fatalf("Test %d, record %d ('%s') error (%s)!", i, j, r, err) + } + records = append(records, rr) + } + got := DuplicateCNAME(cname, records) + if got != test.expected { + t.Errorf("Test %d, expected '%v', got '%v' for CNAME ('%s') and RECORDS (%v)", i, test.expected, got, test.cname, test.records) + } + } +} diff --git a/plugin/pkg/dnsutil/dedup.go b/plugin/pkg/dnsutil/dedup.go new file mode 100644 index 000000000..dae656a01 --- /dev/null +++ b/plugin/pkg/dnsutil/dedup.go @@ -0,0 +1,12 @@ +package dnsutil + +import "github.com/miekg/dns" + +// Dedup de-duplicates a message. +func Dedup(m *dns.Msg) *dns.Msg { + // TODO(miek): expensive! + m.Answer = dns.Dedup(m.Answer, nil) + m.Ns = dns.Dedup(m.Ns, nil) + m.Extra = dns.Dedup(m.Extra, nil) + return m +} diff --git a/plugin/pkg/dnsutil/doc.go b/plugin/pkg/dnsutil/doc.go new file mode 100644 index 000000000..75d1e8c7a --- /dev/null +++ b/plugin/pkg/dnsutil/doc.go @@ -0,0 +1,2 @@ +// Package dnsutil contains DNS related helper functions. +package dnsutil diff --git a/plugin/pkg/dnsutil/host.go b/plugin/pkg/dnsutil/host.go new file mode 100644 index 000000000..aaab586e8 --- /dev/null +++ b/plugin/pkg/dnsutil/host.go @@ -0,0 +1,82 @@ +package dnsutil + +import ( + "fmt" + "net" + "os" + + "github.com/miekg/dns" +) + +// ParseHostPortOrFile parses the strings in s, each string can either be a address, +// address:port or a filename. The address part is checked and the filename case a +// resolv.conf like file is parsed and the nameserver found are returned. +func ParseHostPortOrFile(s ...string) ([]string, error) { + var servers []string + for _, host := range s { + addr, _, err := net.SplitHostPort(host) + if err != nil { + // Parse didn't work, it is not a addr:port combo + if net.ParseIP(host) == nil { + // Not an IP address. + ss, err := tryFile(host) + if err == nil { + servers = append(servers, ss...) + continue + } + return servers, fmt.Errorf("not an IP address or file: %q", host) + } + ss := net.JoinHostPort(host, "53") + servers = append(servers, ss) + continue + } + + if net.ParseIP(addr) == nil { + // No an IP address. + ss, err := tryFile(host) + if err == nil { + servers = append(servers, ss...) + continue + } + return servers, fmt.Errorf("not an IP address or file: %q", host) + } + servers = append(servers, host) + } + return servers, nil +} + +// Try to open this is a file first. +func tryFile(s string) ([]string, error) { + c, err := dns.ClientConfigFromFile(s) + if err == os.ErrNotExist { + return nil, fmt.Errorf("failed to open file %q: %q", s, err) + } else if err != nil { + return nil, err + } + + servers := []string{} + for _, s := range c.Servers { + servers = append(servers, net.JoinHostPort(s, c.Port)) + } + return servers, nil +} + +// ParseHostPort will check if the host part is a valid IP address, if the +// IP address is valid, but no port is found, defaultPort is added. +func ParseHostPort(s, defaultPort string) (string, error) { + addr, port, err := net.SplitHostPort(s) + if port == "" { + port = defaultPort + } + if err != nil { + if net.ParseIP(s) == nil { + return "", fmt.Errorf("must specify an IP address: `%s'", s) + } + return net.JoinHostPort(s, port), nil + } + + if net.ParseIP(addr) == nil { + return "", fmt.Errorf("must specify an IP address: `%s'", addr) + } + return net.JoinHostPort(addr, port), nil +} diff --git a/plugin/pkg/dnsutil/host_test.go b/plugin/pkg/dnsutil/host_test.go new file mode 100644 index 000000000..cc55f4570 --- /dev/null +++ b/plugin/pkg/dnsutil/host_test.go @@ -0,0 +1,85 @@ +package dnsutil + +import ( + "io/ioutil" + "os" + "testing" +) + +func TestParseHostPortOrFile(t *testing.T) { + tests := []struct { + in string + expected string + shouldErr bool + }{ + { + "8.8.8.8", + "8.8.8.8:53", + false, + }, + { + "8.8.8.8:153", + "8.8.8.8:153", + false, + }, + { + "/etc/resolv.conf:53", + "", + true, + }, + { + "resolv.conf", + "127.0.0.1:53", + false, + }, + } + + err := ioutil.WriteFile("resolv.conf", []byte("nameserver 127.0.0.1\n"), 0600) + if err != nil { + t.Fatalf("Failed to write test resolv.conf") + } + defer os.Remove("resolv.conf") + + for i, tc := range tests { + got, err := ParseHostPortOrFile(tc.in) + if err == nil && tc.shouldErr { + t.Errorf("Test %d, expected error, got nil", i) + continue + } + if err != nil && tc.shouldErr { + continue + } + if got[0] != tc.expected { + t.Errorf("Test %d, expected %q, got %q", i, tc.expected, got[0]) + } + } +} + +func TestParseHostPort(t *testing.T) { + tests := []struct { + in string + expected string + shouldErr bool + }{ + {"8.8.8.8:53", "8.8.8.8:53", false}, + {"a.a.a.a:153", "", true}, + {"8.8.8.8", "8.8.8.8:53", false}, + {"8.8.8.8:", "8.8.8.8:53", false}, + {"8.8.8.8::53", "", true}, + {"resolv.conf", "", true}, + } + + for i, tc := range tests { + got, err := ParseHostPort(tc.in, "53") + if err == nil && tc.shouldErr { + t.Errorf("Test %d, expected error, got nil", i) + continue + } + if err != nil && !tc.shouldErr { + t.Errorf("Test %d, expected no error, got %q", i, err) + } + if got != tc.expected { + t.Errorf("Test %d, expected %q, got %q", i, tc.expected, got) + } + } +} diff --git a/plugin/pkg/dnsutil/join.go b/plugin/pkg/dnsutil/join.go new file mode 100644 index 000000000..515bf3dad --- /dev/null +++ b/plugin/pkg/dnsutil/join.go @@ -0,0 +1,19 @@ +package dnsutil + +import ( + "strings" + + "github.com/miekg/dns" +) + +// Join joins labels to form a fully qualified domain name. If the last label is +// the root label it is ignored. Not other syntax checks are performed. +func Join(labels []string) string { + ll := len(labels) + if labels[ll-1] == "." { + s := strings.Join(labels[:ll-1], ".") + return dns.Fqdn(s) + } + s := strings.Join(labels, ".") + return dns.Fqdn(s) +} diff --git a/plugin/pkg/dnsutil/join_test.go b/plugin/pkg/dnsutil/join_test.go new file mode 100644 index 000000000..26eeb5897 --- /dev/null +++ b/plugin/pkg/dnsutil/join_test.go @@ -0,0 +1,20 @@ +package dnsutil + +import "testing" + +func TestJoin(t *testing.T) { + tests := []struct { + in []string + out string + }{ + {[]string{"bla", "bliep", "example", "org"}, "bla.bliep.example.org."}, + {[]string{"example", "."}, "example."}, + {[]string{"."}, "."}, + } + + for i, tc := range tests { + if x := Join(tc.in); x != tc.out { + t.Errorf("Test %d, expected %s, got %s", i, tc.out, x) + } + } +} diff --git a/plugin/pkg/dnsutil/reverse.go b/plugin/pkg/dnsutil/reverse.go new file mode 100644 index 000000000..daf9cc600 --- /dev/null +++ b/plugin/pkg/dnsutil/reverse.go @@ -0,0 +1,68 @@ +package dnsutil + +import ( + "net" + "strings" +) + +// ExtractAddressFromReverse turns a standard PTR reverse record name +// into an IP address. This works for ipv4 or ipv6. +// +// 54.119.58.176.in-addr.arpa. becomes 176.58.119.54. If the conversion +// failes the empty string is returned. +func ExtractAddressFromReverse(reverseName string) string { + search := "" + + f := reverse + + switch { + case strings.HasSuffix(reverseName, v4arpaSuffix): + search = strings.TrimSuffix(reverseName, v4arpaSuffix) + case strings.HasSuffix(reverseName, v6arpaSuffix): + search = strings.TrimSuffix(reverseName, v6arpaSuffix) + f = reverse6 + default: + return "" + } + + // Reverse the segments and then combine them. + return f(strings.Split(search, ".")) +} + +func reverse(slice []string) string { + for i := 0; i < len(slice)/2; i++ { + j := len(slice) - i - 1 + slice[i], slice[j] = slice[j], slice[i] + } + ip := net.ParseIP(strings.Join(slice, ".")).To4() + if ip == nil { + return "" + } + return ip.String() +} + +// reverse6 reverse the segments and combine them according to RFC3596: +// b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2 +// is reversed to 2001:db8::567:89ab +func reverse6(slice []string) string { + for i := 0; i < len(slice)/2; i++ { + j := len(slice) - i - 1 + slice[i], slice[j] = slice[j], slice[i] + } + slice6 := []string{} + for i := 0; i < len(slice)/4; i++ { + slice6 = append(slice6, strings.Join(slice[i*4:i*4+4], "")) + } + ip := net.ParseIP(strings.Join(slice6, ":")).To16() + if ip == nil { + return "" + } + return ip.String() +} + +const ( + // v4arpaSuffix is the reverse tree suffix for v4 IP addresses. + v4arpaSuffix = ".in-addr.arpa." + // v6arpaSuffix is the reverse tree suffix for v6 IP addresses. + v6arpaSuffix = ".ip6.arpa." +) diff --git a/plugin/pkg/dnsutil/reverse_test.go b/plugin/pkg/dnsutil/reverse_test.go new file mode 100644 index 000000000..25bd897ac --- /dev/null +++ b/plugin/pkg/dnsutil/reverse_test.go @@ -0,0 +1,51 @@ +package dnsutil + +import ( + "testing" +) + +func TestExtractAddressFromReverse(t *testing.T) { + tests := []struct { + reverseName string + expectedAddress string + }{ + { + "54.119.58.176.in-addr.arpa.", + "176.58.119.54", + }, + { + ".58.176.in-addr.arpa.", + "", + }, + { + "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.in-addr.arpa.", + "", + }, + { + "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + "2001:db8::567:89ab", + }, + { + "d.0.1.0.0.2.ip6.arpa.", + "", + }, + { + "54.119.58.176.ip6.arpa.", + "", + }, + { + "NONAME", + "", + }, + { + "", + "", + }, + } + for i, test := range tests { + got := ExtractAddressFromReverse(test.reverseName) + if got != test.expectedAddress { + t.Errorf("Test %d, expected '%s', got '%s'", i, test.expectedAddress, got) + } + } +} diff --git a/plugin/pkg/dnsutil/zone.go b/plugin/pkg/dnsutil/zone.go new file mode 100644 index 000000000..579fef1ba --- /dev/null +++ b/plugin/pkg/dnsutil/zone.go @@ -0,0 +1,20 @@ +package dnsutil + +import ( + "errors" + + "github.com/miekg/dns" +) + +// TrimZone removes the zone component from q. It returns the trimmed +// name or an error is zone is longer then qname. The trimmed name will be returned +// without a trailing dot. +func TrimZone(q string, z string) (string, error) { + zl := dns.CountLabel(z) + i, ok := dns.PrevLabel(q, zl) + if ok || i-1 < 0 { + return "", errors.New("trimzone: overshot qname: " + q + "for zone " + z) + } + // This includes the '.', remove on return + return q[:i-1], nil +} diff --git a/plugin/pkg/dnsutil/zone_test.go b/plugin/pkg/dnsutil/zone_test.go new file mode 100644 index 000000000..81cd1adad --- /dev/null +++ b/plugin/pkg/dnsutil/zone_test.go @@ -0,0 +1,39 @@ +package dnsutil + +import ( + "errors" + "testing" + + "github.com/miekg/dns" +) + +func TestTrimZone(t *testing.T) { + tests := []struct { + qname string + zone string + expected string + err error + }{ + {"a.example.org", "example.org", "a", nil}, + {"a.b.example.org", "example.org", "a.b", nil}, + {"b.", ".", "b", nil}, + {"example.org", "example.org", "", errors.New("should err")}, + {"org", "example.org", "", errors.New("should err")}, + } + + for i, tc := range tests { + got, err := TrimZone(dns.Fqdn(tc.qname), dns.Fqdn(tc.zone)) + if tc.err != nil && err == nil { + t.Errorf("Test %d, expected error got nil", i) + continue + } + if tc.err == nil && err != nil { + t.Errorf("Test %d, expected no error got %v", i, err) + continue + } + if got != tc.expected { + t.Errorf("Test %d, expected %s, got %s", i, tc.expected, got) + continue + } + } +} diff --git a/plugin/pkg/edns/edns.go b/plugin/pkg/edns/edns.go new file mode 100644 index 000000000..3f0ea5e16 --- /dev/null +++ b/plugin/pkg/edns/edns.go @@ -0,0 +1,46 @@ +// Package edns provides function useful for adding/inspecting OPT records to/in messages. +package edns + +import ( + "errors" + + "github.com/miekg/dns" +) + +// Version checks the EDNS version in the request. If error +// is nil everything is OK and we can invoke the plugin. If non-nil, the +// returned Msg is valid to be returned to the client (and should). For some +// reason this response should not contain a question RR in the question section. +func Version(req *dns.Msg) (*dns.Msg, error) { + opt := req.IsEdns0() + if opt == nil { + return nil, nil + } + if opt.Version() == 0 { + return nil, nil + } + m := new(dns.Msg) + m.SetReply(req) + // zero out question section, wtf. + m.Question = nil + + o := new(dns.OPT) + o.Hdr.Name = "." + o.Hdr.Rrtype = dns.TypeOPT + o.SetVersion(0) + o.SetExtendedRcode(dns.RcodeBadVers) + m.Extra = []dns.RR{o} + + return m, errors.New("EDNS0 BADVERS") +} + +// Size returns a normalized size based on proto. +func Size(proto string, size int) int { + if proto == "tcp" { + return dns.MaxMsgSize + } + if size < dns.MinMsgSize { + return dns.MinMsgSize + } + return size +} diff --git a/plugin/pkg/edns/edns_test.go b/plugin/pkg/edns/edns_test.go new file mode 100644 index 000000000..89ac6d2ec --- /dev/null +++ b/plugin/pkg/edns/edns_test.go @@ -0,0 +1,37 @@ +package edns + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestVersion(t *testing.T) { + m := ednsMsg() + m.Extra[0].(*dns.OPT).SetVersion(2) + + _, err := Version(m) + if err == nil { + t.Errorf("expected wrong version, but got OK") + } +} + +func TestVersionNoEdns(t *testing.T) { + m := ednsMsg() + m.Extra = nil + + _, err := Version(m) + if err != nil { + t.Errorf("expected no error, but got one: %s", err) + } +} + +func ednsMsg() *dns.Msg { + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + o := new(dns.OPT) + o.Hdr.Name = "." + o.Hdr.Rrtype = dns.TypeOPT + m.Extra = append(m.Extra, o) + return m +} diff --git a/plugin/pkg/healthcheck/healthcheck.go b/plugin/pkg/healthcheck/healthcheck.go new file mode 100644 index 000000000..18f09087c --- /dev/null +++ b/plugin/pkg/healthcheck/healthcheck.go @@ -0,0 +1,243 @@ +package healthcheck + +import ( + "io" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" +) + +// UpstreamHostDownFunc can be used to customize how Down behaves. +type UpstreamHostDownFunc func(*UpstreamHost) bool + +// UpstreamHost represents a single proxy upstream +type UpstreamHost struct { + Conns int64 // must be first field to be 64-bit aligned on 32-bit systems + Name string // IP address (and port) of this upstream host + Network string // Network (tcp, unix, etc) of the host, default "" is "tcp" + Fails int32 + FailTimeout time.Duration + OkUntil time.Time + CheckDown UpstreamHostDownFunc + CheckURL string + WithoutPathPrefix string + Checking bool + CheckMu sync.Mutex +} + +// Down checks whether the upstream host is down or not. +// Down will try to use uh.CheckDown first, and will fall +// back to some default criteria if necessary. +func (uh *UpstreamHost) Down() bool { + if uh.CheckDown == nil { + // Default settings + fails := atomic.LoadInt32(&uh.Fails) + after := false + + uh.CheckMu.Lock() + until := uh.OkUntil + uh.CheckMu.Unlock() + + if !until.IsZero() && time.Now().After(until) { + after = true + } + + return after || fails > 0 + } + return uh.CheckDown(uh) +} + +// HostPool is a collection of UpstreamHosts. +type HostPool []*UpstreamHost + +// HealthCheck is used for performing healthcheck +// on a collection of upstream hosts and select +// one based on the policy. +type HealthCheck struct { + wg sync.WaitGroup // Used to wait for running goroutines to stop. + stop chan struct{} // Signals running goroutines to stop. + Hosts HostPool + Policy Policy + Spray Policy + FailTimeout time.Duration + MaxFails int32 + Future time.Duration + Path string + Port string + Interval time.Duration +} + +// Start starts the healthcheck +func (u *HealthCheck) Start() { + u.stop = make(chan struct{}) + if u.Path != "" { + u.wg.Add(1) + go func() { + defer u.wg.Done() + u.healthCheckWorker(u.stop) + }() + } +} + +// Stop sends a signal to all goroutines started by this staticUpstream to exit +// and waits for them to finish before returning. +func (u *HealthCheck) Stop() error { + close(u.stop) + u.wg.Wait() + return nil +} + +// This was moved into a thread so that each host could throw a health +// check at the same time. The reason for this is that if we are checking +// 3 hosts, and the first one is gone, and we spend minutes timing out to +// fail it, we would not have been doing any other health checks in that +// time. So we now have a per-host lock and a threaded health check. +// +// We use the Checking bool to avoid concurrent checks against the same +// host; if one is taking a long time, the next one will find a check in +// progress and simply return before trying. +// +// We are carefully avoiding having the mutex locked while we check, +// otherwise checks will back up, potentially a lot of them if a host is +// absent for a long time. This arrangement makes checks quickly see if +// they are the only one running and abort otherwise. +func healthCheckURL(nextTs time.Time, host *UpstreamHost) { + + // lock for our bool check. We don't just defer the unlock because + // we don't want the lock held while http.Get runs + host.CheckMu.Lock() + + // are we mid check? Don't run another one + if host.Checking { + host.CheckMu.Unlock() + return + } + + host.Checking = true + host.CheckMu.Unlock() + + //log.Printf("[DEBUG] Healthchecking %s, nextTs is %s\n", url, nextTs.Local()) + + // fetch that url. This has been moved into a go func because + // when the remote host is not merely not serving, but actually + // absent, then tcp syn timeouts can be very long, and so one + // fetch could last several check intervals + if r, err := http.Get(host.CheckURL); err == nil { + io.Copy(ioutil.Discard, r.Body) + r.Body.Close() + + if r.StatusCode < 200 || r.StatusCode >= 400 { + log.Printf("[WARNING] Host %s health check returned HTTP code %d\n", + host.Name, r.StatusCode) + nextTs = time.Unix(0, 0) + } + } else { + log.Printf("[WARNING] Host %s health check probe failed: %v\n", host.Name, err) + nextTs = time.Unix(0, 0) + } + + host.CheckMu.Lock() + host.Checking = false + host.OkUntil = nextTs + host.CheckMu.Unlock() +} + +func (u *HealthCheck) healthCheck() { + for _, host := range u.Hosts { + + if host.CheckURL == "" { + var hostName, checkPort string + + // The DNS server might be an HTTP server. If so, extract its name. + ret, err := url.Parse(host.Name) + if err == nil && len(ret.Host) > 0 { + hostName = ret.Host + } else { + hostName = host.Name + } + + // Extract the port number from the parsed server name. + checkHostName, checkPort, err := net.SplitHostPort(hostName) + if err != nil { + checkHostName = hostName + } + + if u.Port != "" { + checkPort = u.Port + } + + host.CheckURL = "http://" + net.JoinHostPort(checkHostName, checkPort) + u.Path + } + + // calculate this before the get + nextTs := time.Now().Add(u.Future) + + // locks/bools should prevent requests backing up + go healthCheckURL(nextTs, host) + } +} + +func (u *HealthCheck) healthCheckWorker(stop chan struct{}) { + ticker := time.NewTicker(u.Interval) + u.healthCheck() + for { + select { + case <-ticker.C: + u.healthCheck() + case <-stop: + ticker.Stop() + return + } + } +} + +// Select selects an upstream host based on the policy +// and the healthcheck result. +func (u *HealthCheck) Select() *UpstreamHost { + pool := u.Hosts + if len(pool) == 1 { + if pool[0].Down() && u.Spray == nil { + return nil + } + return pool[0] + } + allDown := true + for _, host := range pool { + if !host.Down() { + allDown = false + break + } + } + if allDown { + if u.Spray == nil { + return nil + } + return u.Spray.Select(pool) + } + + if u.Policy == nil { + h := (&Random{}).Select(pool) + if h != nil { + return h + } + if h == nil && u.Spray == nil { + return nil + } + return u.Spray.Select(pool) + } + + h := u.Policy.Select(pool) + if h != nil { + return h + } + + if u.Spray == nil { + return nil + } + return u.Spray.Select(pool) +} diff --git a/plugin/pkg/healthcheck/policy.go b/plugin/pkg/healthcheck/policy.go new file mode 100644 index 000000000..6a828fc4d --- /dev/null +++ b/plugin/pkg/healthcheck/policy.go @@ -0,0 +1,120 @@ +package healthcheck + +import ( + "log" + "math/rand" + "sync/atomic" +) + +var ( + // SupportedPolicies is the collection of policies registered + SupportedPolicies = make(map[string]func() Policy) +) + +// RegisterPolicy adds a custom policy to the proxy. +func RegisterPolicy(name string, policy func() Policy) { + SupportedPolicies[name] = policy +} + +// Policy decides how a host will be selected from a pool. When all hosts are unhealthy, it is assumed the +// healthchecking failed. In this case each policy will *randomly* return a host from the pool to prevent +// no traffic to go through at all. +type Policy interface { + Select(pool HostPool) *UpstreamHost +} + +func init() { + RegisterPolicy("random", func() Policy { return &Random{} }) + RegisterPolicy("least_conn", func() Policy { return &LeastConn{} }) + RegisterPolicy("round_robin", func() Policy { return &RoundRobin{} }) +} + +// Random is a policy that selects up hosts from a pool at random. +type Random struct{} + +// Select selects an up host at random from the specified pool. +func (r *Random) Select(pool HostPool) *UpstreamHost { + // instead of just generating a random index + // this is done to prevent selecting a down host + var randHost *UpstreamHost + count := 0 + for _, host := range pool { + if host.Down() { + continue + } + count++ + if count == 1 { + randHost = host + } else { + r := rand.Int() % count + if r == (count - 1) { + randHost = host + } + } + } + return randHost +} + +// Spray is a policy that selects a host from a pool at random. This should be used as a last ditch +// attempt to get a host when all hosts are reporting unhealthy. +type Spray struct{} + +// Select selects an up host at random from the specified pool. +func (r *Spray) Select(pool HostPool) *UpstreamHost { + rnd := rand.Int() % len(pool) + randHost := pool[rnd] + log.Printf("[WARNING] All hosts reported as down, spraying to target: %s", randHost.Name) + return randHost +} + +// LeastConn is a policy that selects the host with the least connections. +type LeastConn struct{} + +// Select selects the up host with the least number of connections in the +// pool. If more than one host has the same least number of connections, +// one of the hosts is chosen at random. +func (r *LeastConn) Select(pool HostPool) *UpstreamHost { + var bestHost *UpstreamHost + count := 0 + leastConn := int64(1<<63 - 1) + for _, host := range pool { + if host.Down() { + continue + } + hostConns := host.Conns + if hostConns < leastConn { + bestHost = host + leastConn = hostConns + count = 1 + } else if hostConns == leastConn { + // randomly select host among hosts with least connections + count++ + if count == 1 { + bestHost = host + } else { + r := rand.Int() % count + if r == (count - 1) { + bestHost = host + } + } + } + } + return bestHost +} + +// RoundRobin is a policy that selects hosts based on round robin ordering. +type RoundRobin struct { + Robin uint32 +} + +// Select selects an up host from the pool using a round robin ordering scheme. +func (r *RoundRobin) Select(pool HostPool) *UpstreamHost { + poolLen := uint32(len(pool)) + selection := atomic.AddUint32(&r.Robin, 1) % poolLen + host := pool[selection] + // if the currently selected host is down, just ffwd to up host + for i := uint32(1); host.Down() && i < poolLen; i++ { + host = pool[(selection+i)%poolLen] + } + return host +} diff --git a/plugin/pkg/healthcheck/policy_test.go b/plugin/pkg/healthcheck/policy_test.go new file mode 100644 index 000000000..4c667952c --- /dev/null +++ b/plugin/pkg/healthcheck/policy_test.go @@ -0,0 +1,143 @@ +package healthcheck + +import ( + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" +) + +var workableServer *httptest.Server + +func TestMain(m *testing.M) { + workableServer = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // do nothing + })) + r := m.Run() + workableServer.Close() + os.Exit(r) +} + +type customPolicy struct{} + +func (r *customPolicy) Select(pool HostPool) *UpstreamHost { + return pool[0] +} + +func testPool() HostPool { + pool := []*UpstreamHost{ + { + Name: workableServer.URL, // this should resolve (healthcheck test) + }, + { + Name: "http://shouldnot.resolve", // this shouldn't + }, + { + Name: "http://C", + }, + } + return HostPool(pool) +} + +func TestRegisterPolicy(t *testing.T) { + name := "custom" + customPolicy := &customPolicy{} + RegisterPolicy(name, func() Policy { return customPolicy }) + if _, ok := SupportedPolicies[name]; !ok { + t.Error("Expected supportedPolicies to have a custom policy.") + } + +} + +// TODO(miek): Disabled for now, we should get out of the habit of using +// realtime in these tests . +func testHealthCheck(t *testing.T) { + log.SetOutput(ioutil.Discard) + + u := &HealthCheck{ + Hosts: testPool(), + FailTimeout: 10 * time.Second, + Future: 60 * time.Second, + MaxFails: 1, + } + + u.healthCheck() + // sleep a bit, it's async now + time.Sleep(time.Duration(2 * time.Second)) + + if u.Hosts[0].Down() { + t.Error("Expected first host in testpool to not fail healthcheck.") + } + if !u.Hosts[1].Down() { + t.Error("Expected second host in testpool to fail healthcheck.") + } +} + +func TestSelect(t *testing.T) { + u := &HealthCheck{ + Hosts: testPool()[:3], + FailTimeout: 10 * time.Second, + Future: 60 * time.Second, + MaxFails: 1, + } + u.Hosts[0].OkUntil = time.Unix(0, 0) + u.Hosts[1].OkUntil = time.Unix(0, 0) + u.Hosts[2].OkUntil = time.Unix(0, 0) + if h := u.Select(); h != nil { + t.Error("Expected select to return nil as all host are down") + } + u.Hosts[2].OkUntil = time.Time{} + if h := u.Select(); h == nil { + t.Error("Expected select to not return nil") + } +} + +func TestRoundRobinPolicy(t *testing.T) { + pool := testPool() + rrPolicy := &RoundRobin{} + h := rrPolicy.Select(pool) + // First selected host is 1, because counter starts at 0 + // and increments before host is selected + if h != pool[1] { + t.Error("Expected first round robin host to be second host in the pool.") + } + h = rrPolicy.Select(pool) + if h != pool[2] { + t.Error("Expected second round robin host to be third host in the pool.") + } + // mark host as down + pool[0].OkUntil = time.Unix(0, 0) + h = rrPolicy.Select(pool) + if h != pool[1] { + t.Error("Expected third round robin host to be first host in the pool.") + } +} + +func TestLeastConnPolicy(t *testing.T) { + pool := testPool() + lcPolicy := &LeastConn{} + pool[0].Conns = 10 + pool[1].Conns = 10 + h := lcPolicy.Select(pool) + if h != pool[2] { + t.Error("Expected least connection host to be third host.") + } + pool[2].Conns = 100 + h = lcPolicy.Select(pool) + if h != pool[0] && h != pool[1] { + t.Error("Expected least connection host to be first or second host.") + } +} + +func TestCustomPolicy(t *testing.T) { + pool := testPool() + customPolicy := &customPolicy{} + h := customPolicy.Select(pool) + if h != pool[0] { + t.Error("Expected custom policy host to be the first host.") + } +} diff --git a/plugin/pkg/nonwriter/nonwriter.go b/plugin/pkg/nonwriter/nonwriter.go new file mode 100644 index 000000000..7819a320f --- /dev/null +++ b/plugin/pkg/nonwriter/nonwriter.go @@ -0,0 +1,23 @@ +// Package nonwriter implements a dns.ResponseWriter that never writes, but captures the dns.Msg being written. +package nonwriter + +import ( + "github.com/miekg/dns" +) + +// Writer is a type of ResponseWriter that captures the message, but never writes to the client. +type Writer struct { + dns.ResponseWriter + Msg *dns.Msg +} + +// New makes and returns a new NonWriter. +func New(w dns.ResponseWriter) *Writer { return &Writer{ResponseWriter: w} } + +// WriteMsg records the message, but doesn't write it itself. +func (w *Writer) WriteMsg(res *dns.Msg) error { + w.Msg = res + return nil +} + +func (w *Writer) Write(buf []byte) (int, error) { return len(buf), nil } diff --git a/plugin/pkg/nonwriter/nonwriter_test.go b/plugin/pkg/nonwriter/nonwriter_test.go new file mode 100644 index 000000000..d8433af55 --- /dev/null +++ b/plugin/pkg/nonwriter/nonwriter_test.go @@ -0,0 +1,19 @@ +package nonwriter + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestNonWriter(t *testing.T) { + nw := New(nil) + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeA) + if err := nw.WriteMsg(m); err != nil { + t.Errorf("Got error when writing to nonwriter: %s", err) + } + if x := nw.Msg.Question[0].Name; x != "example.org." { + t.Errorf("Expacted 'example.org.' got %q:", x) + } +} diff --git a/plugin/pkg/rcode/rcode.go b/plugin/pkg/rcode/rcode.go new file mode 100644 index 000000000..32863f0b2 --- /dev/null +++ b/plugin/pkg/rcode/rcode.go @@ -0,0 +1,16 @@ +package rcode + +import ( + "strconv" + + "github.com/miekg/dns" +) + +// ToString convert the rcode to the official DNS string, or to "RCODE"+value if the RCODE +// value is unknown. +func ToString(rcode int) string { + if str, ok := dns.RcodeToString[rcode]; ok { + return str + } + return "RCODE" + strconv.Itoa(rcode) +} diff --git a/plugin/pkg/rcode/rcode_test.go b/plugin/pkg/rcode/rcode_test.go new file mode 100644 index 000000000..bfca32f1d --- /dev/null +++ b/plugin/pkg/rcode/rcode_test.go @@ -0,0 +1,29 @@ +package rcode + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestToString(t *testing.T) { + tests := []struct { + in int + expected string + }{ + { + dns.RcodeSuccess, + "NOERROR", + }, + { + 28, + "RCODE28", + }, + } + for i, test := range tests { + got := ToString(test.in) + if got != test.expected { + t.Errorf("Test %d, expected %s, got %s", i, test.expected, got) + } + } +} diff --git a/plugin/pkg/replacer/replacer.go b/plugin/pkg/replacer/replacer.go new file mode 100644 index 000000000..fc98e5d29 --- /dev/null +++ b/plugin/pkg/replacer/replacer.go @@ -0,0 +1,161 @@ +package replacer + +import ( + "strconv" + "strings" + "time" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// Replacer is a type which can replace placeholder +// substrings in a string with actual values from a +// dns.Msg and responseRecorder. Always use +// NewReplacer to get one of these. +type Replacer interface { + Replace(string) string + Set(key, value string) +} + +type replacer struct { + replacements map[string]string + emptyValue string +} + +// New makes a new replacer based on r and rr. +// Do not create a new replacer until r and rr have all +// the needed values, because this function copies those +// values into the replacer. rr may be nil if it is not +// available. emptyValue should be the string that is used +// in place of empty string (can still be empty string). +func New(r *dns.Msg, rr *dnsrecorder.Recorder, emptyValue string) Replacer { + req := request.Request{W: rr, Req: r} + rep := replacer{ + replacements: map[string]string{ + "{type}": req.Type(), + "{name}": req.Name(), + "{class}": req.Class(), + "{proto}": req.Proto(), + "{when}": func() string { + return time.Now().Format(timeFormat) + }(), + "{size}": strconv.Itoa(req.Len()), + "{remote}": req.IP(), + "{port}": req.Port(), + }, + emptyValue: emptyValue, + } + if rr != nil { + rcode := dns.RcodeToString[rr.Rcode] + if rcode == "" { + rcode = strconv.Itoa(rr.Rcode) + } + rep.replacements["{rcode}"] = rcode + rep.replacements["{rsize}"] = strconv.Itoa(rr.Len) + rep.replacements["{duration}"] = time.Since(rr.Start).String() + if rr.Msg != nil { + rep.replacements[headerReplacer+"rflags}"] = flagsToString(rr.Msg.MsgHdr) + } + } + + // Header placeholders (case-insensitive) + rep.replacements[headerReplacer+"id}"] = strconv.Itoa(int(r.Id)) + rep.replacements[headerReplacer+"opcode}"] = strconv.Itoa(r.Opcode) + rep.replacements[headerReplacer+"do}"] = boolToString(req.Do()) + rep.replacements[headerReplacer+"bufsize}"] = strconv.Itoa(req.Size()) + + return rep +} + +// Replace performs a replacement of values on s and returns +// the string with the replaced values. +func (r replacer) Replace(s string) string { + // Header replacements - these are case-insensitive, so we can't just use strings.Replace() + for strings.Contains(s, headerReplacer) { + idxStart := strings.Index(s, headerReplacer) + endOffset := idxStart + len(headerReplacer) + idxEnd := strings.Index(s[endOffset:], "}") + if idxEnd > -1 { + placeholder := strings.ToLower(s[idxStart : endOffset+idxEnd+1]) + replacement := r.replacements[placeholder] + if replacement == "" { + replacement = r.emptyValue + } + s = s[:idxStart] + replacement + s[endOffset+idxEnd+1:] + } else { + break + } + } + + // Regular replacements - these are easier because they're case-sensitive + for placeholder, replacement := range r.replacements { + if replacement == "" { + replacement = r.emptyValue + } + s = strings.Replace(s, placeholder, replacement, -1) + } + + return s +} + +// Set sets key to value in the replacements map. +func (r replacer) Set(key, value string) { + r.replacements["{"+key+"}"] = value +} + +func boolToString(b bool) string { + if b { + return "true" + } + return "false" +} + +// flagsToString checks all header flags and returns those +// that are set as a string separated with commas +func flagsToString(h dns.MsgHdr) string { + flags := make([]string, 7) + i := 0 + + if h.Response { + flags[i] = "qr" + i++ + } + + if h.Authoritative { + flags[i] = "aa" + i++ + } + if h.Truncated { + flags[i] = "tc" + i++ + } + if h.RecursionDesired { + flags[i] = "rd" + i++ + } + if h.RecursionAvailable { + flags[i] = "ra" + i++ + } + if h.Zero { + flags[i] = "z" + i++ + } + if h.AuthenticatedData { + flags[i] = "ad" + i++ + } + if h.CheckingDisabled { + flags[i] = "cd" + i++ + } + return strings.Join(flags[:i], ",") +} + +const ( + timeFormat = "02/Jan/2006:15:04:05 -0700" + headerReplacer = "{>" +) diff --git a/plugin/pkg/replacer/replacer_test.go b/plugin/pkg/replacer/replacer_test.go new file mode 100644 index 000000000..95c3bbd52 --- /dev/null +++ b/plugin/pkg/replacer/replacer_test.go @@ -0,0 +1,61 @@ +package replacer + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestNewReplacer(t *testing.T) { + w := dnsrecorder.New(&test.ResponseWriter{}) + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeHINFO) + r.MsgHdr.AuthenticatedData = true + + replaceValues := New(r, w, "") + + switch v := replaceValues.(type) { + case replacer: + + if v.replacements["{type}"] != "HINFO" { + t.Errorf("Expected type to be HINFO, got %q", v.replacements["{type}"]) + } + if v.replacements["{name}"] != "example.org." { + t.Errorf("Expected request name to be example.org., got %q", v.replacements["{name}"]) + } + if v.replacements["{size}"] != "29" { // size of request + t.Errorf("Expected size to be 29, got %q", v.replacements["{size}"]) + } + + default: + t.Fatal("Return Value from New Replacer expected pass type assertion into a replacer type\n") + } +} + +func TestSet(t *testing.T) { + w := dnsrecorder.New(&test.ResponseWriter{}) + + r := new(dns.Msg) + r.SetQuestion("example.org.", dns.TypeHINFO) + r.MsgHdr.AuthenticatedData = true + + repl := New(r, w, "") + + repl.Set("name", "coredns.io.") + repl.Set("type", "A") + repl.Set("size", "20") + + if repl.Replace("This name is {name}") != "This name is coredns.io." { + t.Error("Expected name replacement failed") + } + if repl.Replace("This type is {type}") != "This type is A" { + t.Error("Expected type replacement failed") + } + if repl.Replace("The request size is {size}") != "The request size is 20" { + t.Error("Expected size replacement failed") + } +} diff --git a/plugin/pkg/response/classify.go b/plugin/pkg/response/classify.go new file mode 100644 index 000000000..2e705cb0b --- /dev/null +++ b/plugin/pkg/response/classify.go @@ -0,0 +1,61 @@ +package response + +import "fmt" + +// Class holds sets of Types +type Class int + +const ( + // All is a meta class encompassing all the classes. + All Class = iota + // Success is a class for a successful response. + Success + // Denial is a class for denying existence (NXDOMAIN, or a nodata: type does not exist) + Denial + // Error is a class for errors, right now defined as not Success and not Denial + Error +) + +func (c Class) String() string { + switch c { + case All: + return "all" + case Success: + return "success" + case Denial: + return "denial" + case Error: + return "error" + } + return "" +} + +// ClassFromString returns the class from the string s. If not class matches +// the All class and an error are returned +func ClassFromString(s string) (Class, error) { + switch s { + case "all": + return All, nil + case "success": + return Success, nil + case "denial": + return Denial, nil + case "error": + return Error, nil + } + return All, fmt.Errorf("invalid Class: %s", s) +} + +// Classify classifies the Type t, it returns its Class. +func Classify(t Type) Class { + switch t { + case NoError, Delegation: + return Success + case NameError, NoData: + return Denial + case OtherError: + fallthrough + default: + return Error + } +} diff --git a/plugin/pkg/response/typify.go b/plugin/pkg/response/typify.go new file mode 100644 index 000000000..7cfaab497 --- /dev/null +++ b/plugin/pkg/response/typify.go @@ -0,0 +1,146 @@ +package response + +import ( + "fmt" + "time" + + "github.com/miekg/dns" +) + +// Type is the type of the message. +type Type int + +const ( + // NoError indicates a positive reply + NoError Type = iota + // NameError is a NXDOMAIN in header, SOA in auth. + NameError + // NoData indicates name found, but not the type: NOERROR in header, SOA in auth. + NoData + // Delegation is a msg with a pointer to another nameserver: NOERROR in header, NS in auth, optionally fluff in additional (not checked). + Delegation + // Meta indicates a meta message, NOTIFY, or a transfer: qType is IXFR or AXFR. + Meta + // Update is an dynamic update message. + Update + // OtherError indicates any other error: don't cache these. + OtherError +) + +var toString = map[Type]string{ + NoError: "NOERROR", + NameError: "NXDOMAIN", + NoData: "NODATA", + Delegation: "DELEGATION", + Meta: "META", + Update: "UPDATE", + OtherError: "OTHERERROR", +} + +func (t Type) String() string { return toString[t] } + +// TypeFromString returns the type from the string s. If not type matches +// the OtherError type and an error are returned. +func TypeFromString(s string) (Type, error) { + for t, str := range toString { + if s == str { + return t, nil + } + } + return NoError, fmt.Errorf("invalid Type: %s", s) +} + +// Typify classifies a message, it returns the Type. +func Typify(m *dns.Msg, t time.Time) (Type, *dns.OPT) { + if m == nil { + return OtherError, nil + } + opt := m.IsEdns0() + do := false + if opt != nil { + do = opt.Do() + } + + if m.Opcode == dns.OpcodeUpdate { + return Update, opt + } + + // Check transfer and update first + if m.Opcode == dns.OpcodeNotify { + return Meta, opt + } + + if len(m.Question) > 0 { + if m.Question[0].Qtype == dns.TypeAXFR || m.Question[0].Qtype == dns.TypeIXFR { + return Meta, opt + } + } + + // If our message contains any expired sigs and we care about that, we should return expired + if do { + if expired := typifyExpired(m, t); expired { + return OtherError, opt + } + } + + if len(m.Answer) > 0 && m.Rcode == dns.RcodeSuccess { + return NoError, opt + } + + soa := false + ns := 0 + for _, r := range m.Ns { + if r.Header().Rrtype == dns.TypeSOA { + soa = true + continue + } + if r.Header().Rrtype == dns.TypeNS { + ns++ + } + } + + // Check length of different sections, and drop stuff that is just to large? TODO(miek). + + if soa && m.Rcode == dns.RcodeSuccess { + return NoData, opt + } + if soa && m.Rcode == dns.RcodeNameError { + return NameError, opt + } + + if ns > 0 && m.Rcode == dns.RcodeSuccess { + return Delegation, opt + } + + if m.Rcode == dns.RcodeSuccess { + return NoError, opt + } + + return OtherError, opt +} + +func typifyExpired(m *dns.Msg, t time.Time) bool { + if expired := typifyExpiredRRSIG(m.Answer, t); expired { + return true + } + if expired := typifyExpiredRRSIG(m.Ns, t); expired { + return true + } + if expired := typifyExpiredRRSIG(m.Extra, t); expired { + return true + } + return false +} + +func typifyExpiredRRSIG(rrs []dns.RR, t time.Time) bool { + for _, r := range rrs { + if r.Header().Rrtype != dns.TypeRRSIG { + continue + } + ok := r.(*dns.RRSIG).ValidityPeriod(t) + if !ok { + return true + } + } + return false +} diff --git a/plugin/pkg/response/typify_test.go b/plugin/pkg/response/typify_test.go new file mode 100644 index 000000000..faeaf3579 --- /dev/null +++ b/plugin/pkg/response/typify_test.go @@ -0,0 +1,84 @@ +package response + +import ( + "testing" + "time" + + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestTypifyNilMsg(t *testing.T) { + var m *dns.Msg + + ty, _ := Typify(m, time.Now().UTC()) + if ty != OtherError { + t.Errorf("message wrongly typified, expected OtherError, got %s", ty) + } +} + +func TestTypifyDelegation(t *testing.T) { + m := delegationMsg() + mt, _ := Typify(m, time.Now().UTC()) + if mt != Delegation { + t.Errorf("message is wrongly typified, expected Delegation, got %s", mt) + } +} + +func TestTypifyRRSIG(t *testing.T) { + now, _ := time.Parse(time.UnixDate, "Fri Apr 21 10:51:21 BST 2017") + utc := now.UTC() + + m := delegationMsgRRSIGOK() + if mt, _ := Typify(m, utc); mt != Delegation { + t.Errorf("message is wrongly typified, expected Delegation, got %s", mt) + } + + // Still a Delegation because EDNS0 OPT DO bool is not set, so we won't check the sigs. + m = delegationMsgRRSIGFail() + if mt, _ := Typify(m, utc); mt != Delegation { + t.Errorf("message is wrongly typified, expected Delegation, got %s", mt) + } + + m = delegationMsgRRSIGFail() + m = addOpt(m) + if mt, _ := Typify(m, utc); mt != OtherError { + t.Errorf("message is wrongly typified, expected OtherError, got %s", mt) + } +} + +func delegationMsg() *dns.Msg { + return &dns.Msg{ + Ns: []dns.RR{ + test.NS("miek.nl. 3600 IN NS linode.atoom.net."), + test.NS("miek.nl. 3600 IN NS ns-ext.nlnetlabs.nl."), + test.NS("miek.nl. 3600 IN NS omval.tednet.nl."), + }, + Extra: []dns.RR{ + test.A("omval.tednet.nl. 3600 IN A 185.49.141.42"), + test.AAAA("omval.tednet.nl. 3600 IN AAAA 2a04:b900:0:100::42"), + }, + } +} + +func delegationMsgRRSIGOK() *dns.Msg { + del := delegationMsg() + del.Ns = append(del.Ns, + test.RRSIG("miek.nl. 1800 IN RRSIG NS 8 2 1800 20170521031301 20170421031301 12051 miek.nl. PIUu3TKX/sB/N1n1E1yWxHHIcPnc2q6Wq9InShk+5ptRqChqKdZNMLDm gCq+1bQAZ7jGvn2PbwTwE65JzES7T+hEiqR5PU23DsidvZyClbZ9l0xG JtKwgzGXLtUHxp4xv/Plq+rq/7pOG61bNCxRyS7WS7i7QcCCWT1BCcv+ wZ0="), + ) + return del +} + +func delegationMsgRRSIGFail() *dns.Msg { + del := delegationMsg() + del.Ns = append(del.Ns, + test.RRSIG("miek.nl. 1800 IN RRSIG NS 8 2 1800 20160521031301 20160421031301 12051 miek.nl. PIUu3TKX/sB/N1n1E1yWxHHIcPnc2q6Wq9InShk+5ptRqChqKdZNMLDm gCq+1bQAZ7jGvn2PbwTwE65JzES7T+hEiqR5PU23DsidvZyClbZ9l0xG JtKwgzGXLtUHxp4xv/Plq+rq/7pOG61bNCxRyS7WS7i7QcCCWT1BCcv+ wZ0="), + ) + return del +} + +func addOpt(m *dns.Msg) *dns.Msg { + m.Extra = append(m.Extra, test.OPT(4096, true)) + return m +} diff --git a/plugin/pkg/singleflight/singleflight.go b/plugin/pkg/singleflight/singleflight.go new file mode 100644 index 000000000..365e3ef58 --- /dev/null +++ b/plugin/pkg/singleflight/singleflight.go @@ -0,0 +1,64 @@ +/* +Copyright 2012 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package singleflight provides a duplicate function call suppression +// mechanism. +package singleflight + +import "sync" + +// call is an in-flight or completed Do call +type call struct { + wg sync.WaitGroup + val interface{} + err error +} + +// Group represents a class of work and forms a namespace in which +// units of work can be executed with duplicate suppression. +type Group struct { + mu sync.Mutex // protects m + m map[uint32]*call // lazily initialized +} + +// Do executes and returns the results of the given function, making +// sure that only one execution is in-flight for a given key at a +// time. If a duplicate comes in, the duplicate caller waits for the +// original to complete and receives the same results. +func (g *Group) Do(key uint32, fn func() (interface{}, error)) (interface{}, error) { + g.mu.Lock() + if g.m == nil { + g.m = make(map[uint32]*call) + } + if c, ok := g.m[key]; ok { + g.mu.Unlock() + c.wg.Wait() + return c.val, c.err + } + c := new(call) + c.wg.Add(1) + g.m[key] = c + g.mu.Unlock() + + c.val, c.err = fn() + c.wg.Done() + + g.mu.Lock() + delete(g.m, key) + g.mu.Unlock() + + return c.val, c.err +} diff --git a/plugin/pkg/singleflight/singleflight_test.go b/plugin/pkg/singleflight/singleflight_test.go new file mode 100644 index 000000000..d1d406e0b --- /dev/null +++ b/plugin/pkg/singleflight/singleflight_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2012 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package singleflight + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestDo(t *testing.T) { + var g Group + v, err := g.Do(1, func() (interface{}, error) { + return "bar", nil + }) + if got, want := fmt.Sprintf("%v (%T)", v, v), "bar (string)"; got != want { + t.Errorf("Do = %v; want %v", got, want) + } + if err != nil { + t.Errorf("Do error = %v", err) + } +} + +func TestDoErr(t *testing.T) { + var g Group + someErr := errors.New("Some error") + v, err := g.Do(1, func() (interface{}, error) { + return nil, someErr + }) + if err != someErr { + t.Errorf("Do error = %v; want someErr", err) + } + if v != nil { + t.Errorf("unexpected non-nil value %#v", v) + } +} + +func TestDoDupSuppress(t *testing.T) { + var g Group + c := make(chan string) + var calls int32 + fn := func() (interface{}, error) { + atomic.AddInt32(&calls, 1) + return <-c, nil + } + + const n = 10 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + v, err := g.Do(1, fn) + if err != nil { + t.Errorf("Do error: %v", err) + } + if v.(string) != "bar" { + t.Errorf("got %q; want %q", v, "bar") + } + wg.Done() + }() + } + time.Sleep(100 * time.Millisecond) // let goroutines above block + c <- "bar" + wg.Wait() + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("number of calls = %d; want 1", got) + } +} diff --git a/plugin/pkg/tls/tls.go b/plugin/pkg/tls/tls.go new file mode 100644 index 000000000..6fc10dd8e --- /dev/null +++ b/plugin/pkg/tls/tls.go @@ -0,0 +1,128 @@ +package tls + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io/ioutil" + "net" + "net/http" + "time" +) + +// NewTLSConfigFromArgs returns a TLS config based upon the passed +// in list of arguments. Typically these come straight from the +// Corefile. +// no args +// - creates a Config with no cert and using system CAs +// - use for a client that talks to a server with a public signed cert (CA installed in system) +// - the client will not be authenticated by the server since there is no cert +// one arg: the path to CA PEM file +// - creates a Config with no cert using a specific CA +// - use for a client that talks to a server with a private signed cert (CA not installed in system) +// - the client will not be authenticated by the server since there is no cert +// two args: path to cert PEM file, the path to private key PEM file +// - creates a Config with a cert, using system CAs to validate the other end +// - use for: +// - a server; or, +// - a client that talks to a server with a public cert and needs certificate-based authentication +// - the other end will authenticate this end via the provided cert +// - the cert of the other end will be verified via system CAs +// three args: path to cert PEM file, path to client private key PEM file, path to CA PEM file +// - creates a Config with the cert, using specified CA to validate the other end +// - use for: +// - a server; or, +// - a client that talks to a server with a privately signed cert and needs certificate-based +// authentication +// - the other end will authenticate this end via the provided cert +// - this end will verify the other end's cert using the specified CA +func NewTLSConfigFromArgs(args ...string) (*tls.Config, error) { + var err error + var c *tls.Config + switch len(args) { + case 0: + // No client cert, use system CA + c, err = NewTLSClientConfig("") + case 1: + // No client cert, use specified CA + c, err = NewTLSClientConfig(args[0]) + case 2: + // Client cert, use system CA + c, err = NewTLSConfig(args[0], args[1], "") + case 3: + // Client cert, use specified CA + c, err = NewTLSConfig(args[0], args[1], args[2]) + default: + err = fmt.Errorf("maximum of three arguments allowed for TLS config, found %d", len(args)) + } + if err != nil { + return nil, err + } + return c, nil +} + +// NewTLSConfig returns a TLS config that includes a certificate +// Use for server TLS config or when using a client certificate +// If caPath is empty, system CAs will be used +func NewTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("could not load TLS cert: %s", err) + } + + roots, err := loadRoots(caPath) + if err != nil { + return nil, err + } + + return &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: roots}, nil +} + +// NewTLSClientConfig returns a TLS config for a client connection +// If caPath is empty, system CAs will be used +func NewTLSClientConfig(caPath string) (*tls.Config, error) { + roots, err := loadRoots(caPath) + if err != nil { + return nil, err + } + + return &tls.Config{RootCAs: roots}, nil +} + +func loadRoots(caPath string) (*x509.CertPool, error) { + if caPath == "" { + return nil, nil + } + + roots := x509.NewCertPool() + pem, err := ioutil.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("error reading %s: %s", caPath, err) + } + ok := roots.AppendCertsFromPEM(pem) + if !ok { + return nil, fmt.Errorf("could not read root certs: %s", err) + } + return roots, nil +} + +// NewHTTPSTransport returns an HTTP transport configured using tls.Config +func NewHTTPSTransport(cc *tls.Config) *http.Transport { + // this seems like a bad idea but was here in the previous version + if cc != nil { + cc.InsecureSkipVerify = true + } + + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: cc, + MaxIdleConnsPerHost: 25, + } + + return tr +} diff --git a/plugin/pkg/tls/tls_test.go b/plugin/pkg/tls/tls_test.go new file mode 100644 index 000000000..8c88bfcc4 --- /dev/null +++ b/plugin/pkg/tls/tls_test.go @@ -0,0 +1,101 @@ +package tls + +import ( + "path/filepath" + "testing" + + "github.com/coredns/coredns/plugin/test" +) + +func getPEMFiles(t *testing.T) (rmFunc func(), cert, key, ca string) { + tempDir, rmFunc, err := test.WritePEMFiles("") + if err != nil { + t.Fatalf("Could not write PEM files: %s", err) + } + + cert = filepath.Join(tempDir, "cert.pem") + key = filepath.Join(tempDir, "key.pem") + ca = filepath.Join(tempDir, "ca.pem") + + return +} + +func TestNewTLSConfig(t *testing.T) { + rmFunc, cert, key, ca := getPEMFiles(t) + defer rmFunc() + + _, err := NewTLSConfig(cert, key, ca) + if err != nil { + t.Errorf("Failed to create TLSConfig: %s", err) + } +} + +func TestNewTLSClientConfig(t *testing.T) { + rmFunc, _, _, ca := getPEMFiles(t) + defer rmFunc() + + _, err := NewTLSClientConfig(ca) + if err != nil { + t.Errorf("Failed to create TLSConfig: %s", err) + } +} + +func TestNewTLSConfigFromArgs(t *testing.T) { + rmFunc, cert, key, ca := getPEMFiles(t) + defer rmFunc() + + _, err := NewTLSConfigFromArgs() + if err != nil { + t.Errorf("Failed to create TLSConfig: %s", err) + } + + c, err := NewTLSConfigFromArgs(ca) + if err != nil { + t.Errorf("Failed to create TLSConfig: %s", err) + } + if c.RootCAs == nil { + t.Error("RootCAs should not be nil when one arg passed") + } + + c, err = NewTLSConfigFromArgs(cert, key) + if err != nil { + t.Errorf("Failed to create TLSConfig: %s", err) + } + if c.RootCAs != nil { + t.Error("RootCAs should be nil when two args passed") + } + if len(c.Certificates) != 1 { + t.Error("Certificates should have a single entry when two args passed") + } + args := []string{cert, key, ca} + c, err = NewTLSConfigFromArgs(args...) + if err != nil { + t.Errorf("Failed to create TLSConfig: %s", err) + } + if c.RootCAs == nil { + t.Error("RootCAs should not be nil when three args passed") + } + if len(c.Certificates) != 1 { + t.Error("Certificateis should have a single entry when three args passed") + } +} + +func TestNewHTTPSTransport(t *testing.T) { + rmFunc, _, _, ca := getPEMFiles(t) + defer rmFunc() + + cc, err := NewTLSClientConfig(ca) + if err != nil { + t.Errorf("Failed to create TLSConfig: %s", err) + } + + tr := NewHTTPSTransport(cc) + if tr == nil { + t.Errorf("Failed to create https transport with cc") + } + + tr = NewHTTPSTransport(nil) + if tr == nil { + t.Errorf("Failed to create https transport without cc") + } +} diff --git a/plugin/pkg/trace/trace.go b/plugin/pkg/trace/trace.go new file mode 100644 index 000000000..35a8ddabd --- /dev/null +++ b/plugin/pkg/trace/trace.go @@ -0,0 +1,12 @@ +package trace + +import ( + "github.com/coredns/coredns/plugin" + ot "github.com/opentracing/opentracing-go" +) + +// Trace holds the tracer and endpoint info +type Trace interface { + plugin.Handler + Tracer() ot.Tracer +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 000000000..0c4d7f604 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,102 @@ +// Package plugin provides some types and functions common among plugin. +package plugin + +import ( + "errors" + "fmt" + + "github.com/miekg/dns" + ot "github.com/opentracing/opentracing-go" + "golang.org/x/net/context" +) + +type ( + // Plugin is a middle layer which represents the traditional + // idea of plugin: it chains one Handler to the next by being + // passed the next Handler in the chain. + Plugin func(Handler) Handler + + // Handler is like dns.Handler except ServeDNS may return an rcode + // and/or error. + // + // If ServeDNS writes to the response body, it should return a status + // code. If the status code is not one of the following: + // + // * SERVFAIL (dns.RcodeServerFailure) + // + // * REFUSED (dns.RecodeRefused) + // + // * FORMERR (dns.RcodeFormatError) + // + // * NOTIMP (dns.RcodeNotImplemented) + // + // CoreDNS assumes *no* reply has yet been written. All other response + // codes signal other handlers above it that the response message is + // already written, and that they should not write to it also. + // + // If ServeDNS encounters an error, it should return the error value + // so it can be logged by designated error-handling plugin. + // + // If writing a response after calling another ServeDNS method, the + // returned rcode SHOULD be used when writing the response. + // + // If handling errors after calling another ServeDNS method, the + // returned error value SHOULD be logged or handled accordingly. + // + // Otherwise, return values should be propagated down the plugin + // chain by returning them unchanged. + Handler interface { + ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) + Name() string + } + + // HandlerFunc is a convenience type like dns.HandlerFunc, except + // ServeDNS returns an rcode and an error. See Handler + // documentation for more information. + HandlerFunc func(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) +) + +// ServeDNS implements the Handler interface. +func (f HandlerFunc) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + return f(ctx, w, r) +} + +// Name implements the Handler interface. +func (f HandlerFunc) Name() string { return "handlerfunc" } + +// Error returns err with 'plugin/name: ' prefixed to it. +func Error(name string, err error) error { return fmt.Errorf("%s/%s: %s", "plugin", name, err) } + +// NextOrFailure calls next.ServeDNS when next is not nill, otherwise it will return, a ServerFailure +// and a nil error. +func NextOrFailure(name string, next Handler, ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + if next != nil { + if span := ot.SpanFromContext(ctx); span != nil { + child := span.Tracer().StartSpan(next.Name(), ot.ChildOf(span.Context())) + defer child.Finish() + ctx = ot.ContextWithSpan(ctx, child) + } + return next.ServeDNS(ctx, w, r) + } + + return dns.RcodeServerFailure, Error(name, errors.New("no next plugin found")) +} + +// ClientWrite returns true if the response has been written to the client. +// Each plugin to adhire to this protocol. +func ClientWrite(rcode int) bool { + switch rcode { + case dns.RcodeServerFailure: + fallthrough + case dns.RcodeRefused: + fallthrough + case dns.RcodeFormatError: + fallthrough + case dns.RcodeNotImplemented: + return false + } + return true +} + +// Namespace is the namespace used for the metrics. +const Namespace = "coredns" diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go new file mode 100644 index 000000000..b0736c3a0 --- /dev/null +++ b/plugin/plugin_test.go @@ -0,0 +1 @@ +package plugin diff --git a/plugin/pprof/README.md b/plugin/pprof/README.md new file mode 100644 index 000000000..06a36e442 --- /dev/null +++ b/plugin/pprof/README.md @@ -0,0 +1,41 @@ +# pprof + +*pprof* publishes runtime profiling data at endpoints under /debug/pprof. + +You can visit `/debug/pprof` on your site for an index of the available endpoints. By default it +will listen on localhost:6053. + +> This is a debugging tool. Certain requests (such as collecting execution traces) can be slow. If +> you use pprof on a live site, consider restricting access or enabling it only temporarily. + +For more information, please see [Go's pprof +documentation](https://golang.org/pkg/net/http/pprof/) and read +[Profiling Go Programs](https://blog.golang.org/profiling-go-programs). + +## Syntax + +~~~ +pprof [ADDRESS] +~~~ + +If not specified, ADDRESS defaults to localhost:6053. + +## Examples + +Enable pprof endpoints: + +~~~ +pprof +~~~ + +Listen on an alternate address: + +~~~ +pprof 10.9.8.7:6060 +~~~ + +Listen on an all addresses on port 6060: + +~~~ +pprof :6060 +~~~ diff --git a/plugin/pprof/pprof.go b/plugin/pprof/pprof.go new file mode 100644 index 000000000..020776ecf --- /dev/null +++ b/plugin/pprof/pprof.go @@ -0,0 +1,49 @@ +// Package pprof implement a debug endpoint for getting profiles using the +// go pprof tooling. +package pprof + +import ( + "log" + "net" + "net/http" + pp "net/http/pprof" +) + +type handler struct { + addr string + ln net.Listener + mux *http.ServeMux +} + +func (h *handler) Startup() error { + ln, err := net.Listen("tcp", h.addr) + if err != nil { + log.Printf("[ERROR] Failed to start pprof handler: %s", err) + return err + } + + h.ln = ln + + h.mux = http.NewServeMux() + h.mux.HandleFunc(path+"/", pp.Index) + h.mux.HandleFunc(path+"/cmdline", pp.Cmdline) + h.mux.HandleFunc(path+"/profile", pp.Profile) + h.mux.HandleFunc(path+"/symbol", pp.Symbol) + h.mux.HandleFunc(path+"/trace", pp.Trace) + + go func() { + http.Serve(h.ln, h.mux) + }() + return nil +} + +func (h *handler) Shutdown() error { + if h.ln != nil { + return h.ln.Close() + } + return nil +} + +const ( + path = "/debug/pprof" +) diff --git a/plugin/pprof/setup.go b/plugin/pprof/setup.go new file mode 100644 index 000000000..22b82e94b --- /dev/null +++ b/plugin/pprof/setup.go @@ -0,0 +1,53 @@ +package pprof + +import ( + "net" + "sync" + + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +const defaultAddr = "localhost:6053" + +func init() { + caddy.RegisterPlugin("pprof", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + found := false + h := &handler{addr: defaultAddr} + for c.Next() { + if found { + return plugin.Error("pprof", c.Err("pprof can only be specified once")) + } + args := c.RemainingArgs() + if len(args) == 1 { + h.addr = args[0] + _, _, e := net.SplitHostPort(h.addr) + if e != nil { + return e + } + } + if len(args) > 1 { + return plugin.Error("pprof", c.ArgErr()) + } + if c.NextBlock() { + return plugin.Error("pprof", c.ArgErr()) + } + found = true + } + + pprofOnce.Do(func() { + c.OnStartup(h.Startup) + c.OnShutdown(h.Shutdown) + }) + + return nil +} + +var pprofOnce sync.Once diff --git a/plugin/pprof/setup_test.go b/plugin/pprof/setup_test.go new file mode 100644 index 000000000..eaa4cb37e --- /dev/null +++ b/plugin/pprof/setup_test.go @@ -0,0 +1,34 @@ +package pprof + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestPProf(t *testing.T) { + tests := []struct { + input string + shouldErr bool + }{ + {`pprof`, false}, + {`pprof 1.2.3.4:1234`, false}, + {`pprof :1234`, false}, + {`pprof {}`, true}, + {`pprof /foo`, true}, + {`pprof { + a b + }`, true}, + {`pprof + pprof`, true}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + err := setup(c) + if test.shouldErr && err == nil { + t.Errorf("Test %v: Expected error but found nil", i) + } else if !test.shouldErr && err != nil { + t.Errorf("Test %v: Expected no error but found error: %v", i, err) + } + } +} diff --git a/plugin/proxy/README.md b/plugin/proxy/README.md new file mode 100644 index 000000000..3cccf05ee --- /dev/null +++ b/plugin/proxy/README.md @@ -0,0 +1,175 @@ +# proxy + +*proxy* facilitates both a basic reverse proxy and a robust load balancer. + +The proxy has support for multiple backends. The load balancing features include multiple policies, +health checks, and failovers. If all hosts fail their health check the proxy plugin will fail +back to randomly selecting a target and sending packets to it. + +## Syntax + +In its most basic form, a simple reverse proxy uses this syntax: + +~~~ +proxy FROM TO +~~~ + +* **FROM** is the base domain to match for the request to be proxied. +* **TO** is the destination endpoint to proxy to. + +However, advanced features including load balancing can be utilized with an expanded syntax: + +~~~ +proxy FROM TO... { + policy random|least_conn|round_robin + fail_timeout DURATION + max_fails INTEGER + health_check PATH:PORT [DURATION] + except IGNORED_NAMES... + spray + protocol [dns [force_tcp]|https_google [bootstrap ADDRESS...]|grpc [insecure|CACERT|KEY CERT|KEY CERT CACERT]] +} +~~~ + +* **FROM** is the name to match for the request to be proxied. +* **TO** is the destination endpoint to proxy to. At least one is required, but multiple may be + specified. **TO** may be an IP:Port pair, or may reference a file in resolv.conf format +* `policy` is the load balancing policy to use; applies only with multiple backends. May be one of + random, least_conn, or round_robin. Default is random. +* `fail_timeout` specifies how long to consider a backend as down after it has failed. While it is + down, requests will not be routed to that backend. A backend is "down" if CoreDNS fails to + communicate with it. The default value is 10 seconds ("10s"). +* `max_fails` is the number of failures within fail_timeout that are needed before considering + a backend to be down. If 0, the backend will never be marked as down. Default is 1. +* `health_check` will check path (on port) on each backend. If a backend returns a status code of + 200-399, then that backend is marked healthy for double the healthcheck duration. If it doesn't, + it is marked as unhealthy and no requests are routed to it. If this option is not provided then + health checks are disabled. The default duration is 30 seconds ("30s"). +* **IGNORED_NAMES** in `except` is a space-separated list of domains to exclude from proxying. + Requests that match none of these names will be passed through. +* `spray` when all backends are unhealthy, randomly pick one to send the traffic to. (This is + a failsafe.) +* `protocol` specifies what protocol to use to speak to an upstream, `dns` (the default) is plain + old DNS, and `https_google` uses `https://dns.google.com` and speaks a JSON DNS dialect. Note when + using this **TO** will be ignored. The `grpc` option will talk to a server that has implemented + the [DnsService](https://github.com/coredns/coredns/pb/dns.proto). + An out-of-tree plugin that implements the server side of this can be found at + [here](https://github.com/infobloxopen/coredns-grpc). + +## Policies + +There are three load-balancing policies available: +* `random` (default) - Randomly select a backend +* `least_conn` - Select the backend with the fewest active connections +* `round_robin` - Select the backend in round-robin fashion + +All polices implement randomly spraying packets to backend hosts when *no healthy* hosts are +available. This is to preeempt the case where the healthchecking (as a mechanism) fails. + +## Upstream Protocols + +Currently `protocol` supports `dns` (i.e., standard DNS over UDP/TCP) and `https_google` (JSON +payload over HTTPS). Note that with `https_google` the entire transport is encrypted. Only *you* and +*Google* can see your DNS activity. + +* `dns`: uses the standard DNS exchange. You can pass `force_tcp` to make sure that the proxied connection is performed + over TCP, regardless of the inbound request's protocol. +* `https_google`: bootstrap **ADDRESS...** is used to (re-)resolve `dns.google.com` to an address to + connect to. This happens every 300s. If not specified the default is used: 8.8.8.8:53/8.8.4.4:53. + Note that **TO** is *ignored* when `https_google` is used, as its upstream is defined as + `dns.google.com`. + + Debug queries are enabled by default and currently there is no way to turn them off. When CoreDNS + receives a debug query (i.e. the name is prefixed with `o-o.debug.`) a TXT record with Comment + from `dns.google.com` is added. Note this is not always set. +* `grpc`: options are used to control how the TLS connection is made to the gRPC server. + * None - No client authentication is used, and the system CAs are used to verify the server certificate. + * `insecure` - TLS is not used, the connection is made in plaintext (not good in production). + * **CACERT** - No client authentication is used, and the file **CACERT** is used to verify the server certificate. + * **KEY** **CERT** - Client authentication is used with the specified key/cert pair. The server + certificate is verified with the system CAs. + * **KEY** **CERT** **CACERT** - Client authentication is used with the specified key/cert pair. The + server certificate is verified using the **CACERT** file. + + An out-of-tree plugin that implements the server side of this can be found at + [here](https://github.com/infobloxopen/coredns-grpc). + +## Metrics + +If monitoring is enabled (via the *prometheus* directive) then the following metric is exported: + +* coredns_proxy_request_count_total{proto, proxy_proto, from} + +Where `proxy_proto` is the protocol used (`dns`, `grpc`, or `https_google`) and `from` is **FROM** +specified in the config, `proto` is the protocol used by the incoming query ("tcp" or "udp"). + +## Examples + +Proxy all requests within example.org. to a backend system: + +~~~ +proxy example.org 127.0.0.1:9005 +~~~ + +Load-balance all requests between three backends (using random policy): + +~~~ +proxy . 10.0.0.10:53 10.0.0.11:1053 10.0.0.12 +~~~ + +Same as above, but round-robin style: + +~~~ +proxy . 10.0.0.10:53 10.0.0.11:1053 10.0.0.12 { + policy round_robin +} +~~~ + +With health checks and proxy headers to pass hostname, IP, and scheme upstream: + +~~~ +proxy . 10.0.0.11:53 10.0.0.11:53 10.0.0.12:53 { + policy round_robin + health_check /health:8080 +} +~~~ + +Proxy everything except requests to miek.nl or example.org + +~~~ +proxy . 10.0.0.10:1234 { + except miek.nl example.org +} +~~~ + +Proxy everything except example.org using the host resolv.conf nameservers: + +~~~ +proxy . /etc/resolv.conf { + except miek.nl example.org +} +~~~ + +Proxy all requests within example.org to Google's dns.google.com. + +~~~ +proxy example.org 1.2.3.4:53 { + protocol https_google +} +~~~ + +Proxy everything with HTTPS to `dns.google.com`, except `example.org`. Then have another proxy in +another stanza that uses plain DNS to resolve names under `example.org`. + +~~~ +. { + proxy . 1.2.3.4:53 { + except example.org + protocol https_google + } +} + +example.org { + proxy . 8.8.8.8:53 +} +~~~ diff --git a/plugin/proxy/dns.go b/plugin/proxy/dns.go new file mode 100644 index 000000000..4d8038422 --- /dev/null +++ b/plugin/proxy/dns.go @@ -0,0 +1,106 @@ +package proxy + +import ( + "context" + "net" + "time" + + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +type dnsEx struct { + Timeout time.Duration + Options +} + +// Options define the options understood by dns.Exchange. +type Options struct { + ForceTCP bool // If true use TCP for upstream no matter what +} + +func newDNSEx() *dnsEx { + return newDNSExWithOption(Options{}) +} + +func newDNSExWithOption(opt Options) *dnsEx { + return &dnsEx{Timeout: defaultTimeout * time.Second, Options: opt} +} + +func (d *dnsEx) Transport() string { + if d.Options.ForceTCP { + return "tcp" + } + + // The protocol will be determined by `state.Proto()` during Exchange. + return "" +} +func (d *dnsEx) Protocol() string { return "dns" } +func (d *dnsEx) OnShutdown(p *Proxy) error { return nil } +func (d *dnsEx) OnStartup(p *Proxy) error { return nil } + +// Exchange implements the Exchanger interface. +func (d *dnsEx) Exchange(ctx context.Context, addr string, state request.Request) (*dns.Msg, error) { + proto := state.Proto() + if d.Options.ForceTCP { + proto = "tcp" + } + co, err := net.DialTimeout(proto, addr, d.Timeout) + if err != nil { + return nil, err + } + + reply, _, err := d.ExchangeConn(state.Req, co) + + co.Close() + + if reply != nil && reply.Truncated { + // Suppress proxy error for truncated responses + err = nil + } + + if err != nil { + return nil, err + } + // Make sure it fits in the DNS response. + reply, _ = state.Scrub(reply) + reply.Compress = true + reply.Id = state.Req.Id + + return reply, nil +} + +func (d *dnsEx) ExchangeConn(m *dns.Msg, co net.Conn) (*dns.Msg, time.Duration, error) { + start := time.Now() + r, err := exchange(m, co) + rtt := time.Since(start) + + return r, rtt, err +} + +func exchange(m *dns.Msg, co net.Conn) (*dns.Msg, error) { + opt := m.IsEdns0() + + udpsize := uint16(dns.MinMsgSize) + // If EDNS0 is used use that for size. + if opt != nil && opt.UDPSize() >= dns.MinMsgSize { + udpsize = opt.UDPSize() + } + + dnsco := &dns.Conn{Conn: co, UDPSize: udpsize} + + writeDeadline := time.Now().Add(defaultTimeout) + dnsco.SetWriteDeadline(writeDeadline) + dnsco.WriteMsg(m) + + readDeadline := time.Now().Add(defaultTimeout) + co.SetReadDeadline(readDeadline) + r, err := dnsco.ReadMsg() + + dnsco.Close() + if r == nil { + return nil, err + } + return r, err +} diff --git a/plugin/proxy/dnstap_test.go b/plugin/proxy/dnstap_test.go new file mode 100644 index 000000000..05169a1ca --- /dev/null +++ b/plugin/proxy/dnstap_test.go @@ -0,0 +1,57 @@ +package proxy + +import ( + "testing" + + "github.com/coredns/coredns/plugin/dnstap/msg" + "github.com/coredns/coredns/plugin/dnstap/test" + mwtest "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + tap "github.com/dnstap/golang-dnstap" + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func testCase(t *testing.T, ex Exchanger, q, r *dns.Msg, datq, datr *msg.Data) { + tapq := datq.ToOutsideQuery(tap.Message_FORWARDER_QUERY) + tapr := datr.ToOutsideResponse(tap.Message_FORWARDER_RESPONSE) + ctx := test.Context{} + err := toDnstap(&ctx, "10.240.0.1:40212", ex, + request.Request{W: &mwtest.ResponseWriter{}, Req: q}, r, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(ctx.Trap) != 2 { + t.Fatalf("messages: %d", len(ctx.Trap)) + } + if !test.MsgEqual(ctx.Trap[0], tapq) { + t.Errorf("want: %v\nhave: %v", tapq, ctx.Trap[0]) + } + if !test.MsgEqual(ctx.Trap[1], tapr) { + t.Errorf("want: %v\nhave: %v", tapr, ctx.Trap[1]) + } +} + +func TestDnstap(t *testing.T) { + q := mwtest.Case{Qname: "example.org", Qtype: dns.TypeA}.Msg() + r := mwtest.Case{ + Qname: "example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{ + mwtest.A("example.org. 3600 IN A 10.0.0.1"), + }, + }.Msg() + tapq, tapr := test.TestingData(), test.TestingData() + testCase(t, newDNSEx(), q, r, tapq, tapr) + tapq.SocketProto = tap.SocketProtocol_TCP + tapr.SocketProto = tap.SocketProtocol_TCP + testCase(t, newDNSExWithOption(Options{ForceTCP: true}), q, r, tapq, tapr) + testCase(t, newGoogle("", []string{"8.8.8.8:53", "8.8.4.4:53"}), q, r, tapq, tapr) +} + +func TestNoDnstap(t *testing.T) { + err := toDnstap(context.TODO(), "", nil, request.Request{}, nil, 0, 0) + if err != nil { + t.Fatal(err) + } +} diff --git a/plugin/proxy/exchanger.go b/plugin/proxy/exchanger.go new file mode 100644 index 000000000..b98a687e7 --- /dev/null +++ b/plugin/proxy/exchanger.go @@ -0,0 +1,22 @@ +package proxy + +import ( + "context" + + "github.com/coredns/coredns/request" + "github.com/miekg/dns" +) + +// Exchanger is an interface that specifies a type implementing a DNS resolver that +// can use whatever transport it likes. +type Exchanger interface { + Exchange(ctx context.Context, addr string, state request.Request) (*dns.Msg, error) + Protocol() string + + // Transport returns the only transport protocol used by this Exchanger or "". + // If the return value is "", Exchange must use `state.Proto()`. + Transport() string + + OnStartup(*Proxy) error + OnShutdown(*Proxy) error +} diff --git a/plugin/proxy/google.go b/plugin/proxy/google.go new file mode 100644 index 000000000..ecc5e6dfd --- /dev/null +++ b/plugin/proxy/google.go @@ -0,0 +1,244 @@ +package proxy + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "sync/atomic" + "time" + + "github.com/coredns/coredns/plugin/pkg/healthcheck" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +type google struct { + client *http.Client + + endpoint string // Name to resolve via 'bootstrapProxy' + + bootstrapProxy Proxy + quit chan bool +} + +func newGoogle(endpoint string, bootstrap []string) *google { + if endpoint == "" { + endpoint = ghost + } + tls := &tls.Config{ServerName: endpoint} + client := &http.Client{ + Timeout: time.Second * defaultTimeout, + Transport: &http.Transport{TLSClientConfig: tls}, + } + + boot := NewLookup(bootstrap) + + return &google{client: client, endpoint: dns.Fqdn(endpoint), bootstrapProxy: boot, quit: make(chan bool)} +} + +func (g *google) Exchange(ctx context.Context, addr string, state request.Request) (*dns.Msg, error) { + v := url.Values{} + + v.Set("name", state.Name()) + v.Set("type", fmt.Sprintf("%d", state.QType())) + + buf, backendErr := g.exchangeJSON(addr, v.Encode()) + + if backendErr == nil { + gm := new(googleMsg) + if err := json.Unmarshal(buf, gm); err != nil { + return nil, err + } + + m, err := toMsg(gm) + if err != nil { + return nil, err + } + + m.Id = state.Req.Id + return m, nil + } + + log.Printf("[WARNING] Failed to connect to HTTPS backend %q: %s", g.endpoint, backendErr) + return nil, backendErr +} + +func (g *google) exchangeJSON(addr, json string) ([]byte, error) { + url := "https://" + addr + "/resolve?" + json + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Host = g.endpoint // TODO(miek): works with the extra dot at the end? + + resp, err := g.client.Do(req) + if err != nil { + return nil, err + } + + buf, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get 200 status code, got %d", resp.StatusCode) + } + + return buf, nil +} + +func (g *google) Transport() string { return "tcp" } +func (g *google) Protocol() string { return "https_google" } + +func (g *google) OnShutdown(p *Proxy) error { + g.quit <- true + return nil +} + +func (g *google) OnStartup(p *Proxy) error { + // We fake a state because normally the proxy is called after we already got a incoming query. + // This is a non-edns0, udp request to g.endpoint. + req := new(dns.Msg) + req.SetQuestion(g.endpoint, dns.TypeA) + state := request.Request{W: new(fakeBootWriter), Req: req} + + if len(*p.Upstreams) == 0 { + return fmt.Errorf("no upstreams defined") + } + + oldUpstream := (*p.Upstreams)[0] + + log.Printf("[INFO] Bootstrapping A records %q", g.endpoint) + + new, err := g.bootstrapProxy.Lookup(state, g.endpoint, dns.TypeA) + if err != nil { + log.Printf("[WARNING] Failed to bootstrap A records %q: %s", g.endpoint, err) + } else { + addrs, err1 := extractAnswer(new) + if err1 != nil { + log.Printf("[WARNING] Failed to bootstrap A records %q: %s", g.endpoint, err1) + } else { + + up := newUpstream(addrs, oldUpstream.(*staticUpstream)) + p.Upstreams = &[]Upstream{up} + + log.Printf("[INFO] Bootstrapping A records %q found: %v", g.endpoint, addrs) + } + } + + go func() { + tick := time.NewTicker(120 * time.Second) + + for { + select { + case <-tick.C: + + log.Printf("[INFO] Resolving A records %q", g.endpoint) + + new, err := g.bootstrapProxy.Lookup(state, g.endpoint, dns.TypeA) + if err != nil { + log.Printf("[WARNING] Failed to resolve A records %q: %s", g.endpoint, err) + continue + } + + addrs, err1 := extractAnswer(new) + if err1 != nil { + log.Printf("[WARNING] Failed to resolve A records %q: %s", g.endpoint, err1) + continue + } + + up := newUpstream(addrs, oldUpstream.(*staticUpstream)) + p.Upstreams = &[]Upstream{up} + + log.Printf("[INFO] Resolving A records %q found: %v", g.endpoint, addrs) + + case <-g.quit: + return + } + } + }() + + return nil +} + +func extractAnswer(m *dns.Msg) ([]string, error) { + if len(m.Answer) == 0 { + return nil, fmt.Errorf("no answer section in response") + } + ret := []string{} + for _, an := range m.Answer { + if a, ok := an.(*dns.A); ok { + ret = append(ret, net.JoinHostPort(a.A.String(), "443")) + } + } + if len(ret) > 0 { + return ret, nil + } + + return nil, fmt.Errorf("no address records in answer section") +} + +// newUpstream returns an upstream initialized with hosts. +func newUpstream(hosts []string, old *staticUpstream) Upstream { + upstream := &staticUpstream{ + from: old.from, + HealthCheck: healthcheck.HealthCheck{ + FailTimeout: 10 * time.Second, + MaxFails: 3, + Future: 60 * time.Second, + }, + ex: old.ex, + WithoutPathPrefix: old.WithoutPathPrefix, + IgnoredSubDomains: old.IgnoredSubDomains, + } + + upstream.Hosts = make([]*healthcheck.UpstreamHost, len(hosts)) + for i, h := range hosts { + uh := &healthcheck.UpstreamHost{ + Name: h, + Conns: 0, + Fails: 0, + FailTimeout: upstream.FailTimeout, + + CheckDown: func(upstream *staticUpstream) healthcheck.UpstreamHostDownFunc { + return func(uh *healthcheck.UpstreamHost) bool { + + down := false + + uh.CheckMu.Lock() + until := uh.OkUntil + uh.CheckMu.Unlock() + + if !until.IsZero() && time.Now().After(until) { + down = true + } + + fails := atomic.LoadInt32(&uh.Fails) + if fails >= upstream.MaxFails && upstream.MaxFails != 0 { + down = true + } + return down + } + }(upstream), + WithoutPathPrefix: upstream.WithoutPathPrefix, + } + + upstream.Hosts[i] = uh + } + return upstream +} + +const ( + // Default endpoint for this service. + ghost = "dns.google.com." +) diff --git a/plugin/proxy/google_rr.go b/plugin/proxy/google_rr.go new file mode 100644 index 000000000..3b9233b7b --- /dev/null +++ b/plugin/proxy/google_rr.go @@ -0,0 +1,89 @@ +package proxy + +import ( + "fmt" + + "github.com/miekg/dns" +) + +// toMsg converts a googleMsg into the dns message. +func toMsg(g *googleMsg) (*dns.Msg, error) { + m := new(dns.Msg) + m.Response = true + m.Rcode = g.Status + m.Truncated = g.TC + m.RecursionDesired = g.RD + m.RecursionAvailable = g.RA + m.AuthenticatedData = g.AD + m.CheckingDisabled = g.CD + + m.Question = make([]dns.Question, 1) + m.Answer = make([]dns.RR, len(g.Answer)) + m.Ns = make([]dns.RR, len(g.Authority)) + m.Extra = make([]dns.RR, len(g.Additional)) + + m.Question[0] = dns.Question{Name: g.Question[0].Name, Qtype: g.Question[0].Type, Qclass: dns.ClassINET} + + var err error + for i := 0; i < len(m.Answer); i++ { + m.Answer[i], err = toRR(g.Answer[i]) + if err != nil { + return nil, err + } + } + for i := 0; i < len(m.Ns); i++ { + m.Ns[i], err = toRR(g.Authority[i]) + if err != nil { + return nil, err + } + } + for i := 0; i < len(m.Extra); i++ { + m.Extra[i], err = toRR(g.Additional[i]) + if err != nil { + return nil, err + } + } + + return m, nil +} + +// toRR transforms a "google" RR to a dns.RR. +func toRR(g googleRR) (dns.RR, error) { + typ, ok := dns.TypeToString[g.Type] + if !ok { + return nil, fmt.Errorf("failed to convert type %q", g.Type) + } + + str := fmt.Sprintf("%s %d %s %s", g.Name, g.TTL, typ, g.Data) + rr, err := dns.NewRR(str) + if err != nil { + return nil, fmt.Errorf("failed to parse %q: %s", str, err) + } + return rr, nil +} + +// googleRR represents a dns.RR in another form. +type googleRR struct { + Name string + Type uint16 + TTL uint32 + Data string +} + +// googleMsg is a JSON representation of the dns.Msg. +type googleMsg struct { + Status int + TC bool + RD bool + RA bool + AD bool + CD bool + Question []struct { + Name string + Type uint16 + } + Answer []googleRR + Authority []googleRR + Additional []googleRR + Comment string +} diff --git a/plugin/proxy/google_test.go b/plugin/proxy/google_test.go new file mode 100644 index 000000000..1ce591664 --- /dev/null +++ b/plugin/proxy/google_test.go @@ -0,0 +1,5 @@ +package proxy + +// TODO(miek): +// Test cert failures - put those in SERVFAIL messages, but attach error code in TXT +// Test connecting to a a bad host. diff --git a/plugin/proxy/grpc.go b/plugin/proxy/grpc.go new file mode 100644 index 000000000..f98fd2e91 --- /dev/null +++ b/plugin/proxy/grpc.go @@ -0,0 +1,96 @@ +package proxy + +import ( + "context" + "crypto/tls" + "log" + + "github.com/coredns/coredns/pb" + "github.com/coredns/coredns/plugin/pkg/trace" + "github.com/coredns/coredns/request" + + "github.com/grpc-ecosystem/grpc-opentracing/go/otgrpc" + "github.com/miekg/dns" + opentracing "github.com/opentracing/opentracing-go" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +type grpcClient struct { + dialOpts []grpc.DialOption + clients map[string]pb.DnsServiceClient + conns []*grpc.ClientConn + upstream *staticUpstream +} + +func newGrpcClient(tls *tls.Config, u *staticUpstream) *grpcClient { + g := &grpcClient{upstream: u} + + if tls == nil { + g.dialOpts = append(g.dialOpts, grpc.WithInsecure()) + } else { + g.dialOpts = append(g.dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tls))) + } + g.clients = map[string]pb.DnsServiceClient{} + + return g +} + +func (g *grpcClient) Exchange(ctx context.Context, addr string, state request.Request) (*dns.Msg, error) { + msg, err := state.Req.Pack() + if err != nil { + return nil, err + } + + reply, err := g.clients[addr].Query(ctx, &pb.DnsPacket{Msg: msg}) + if err != nil { + return nil, err + } + d := new(dns.Msg) + err = d.Unpack(reply.Msg) + if err != nil { + return nil, err + } + return d, nil +} + +func (g *grpcClient) Transport() string { return "tcp" } + +func (g *grpcClient) Protocol() string { return "grpc" } + +func (g *grpcClient) OnShutdown(p *Proxy) error { + g.clients = map[string]pb.DnsServiceClient{} + for i, conn := range g.conns { + err := conn.Close() + if err != nil { + log.Printf("[WARNING] Error closing connection %d: %s\n", i, err) + } + } + g.conns = []*grpc.ClientConn{} + return nil +} + +func (g *grpcClient) OnStartup(p *Proxy) error { + dialOpts := g.dialOpts + if p.Trace != nil { + if t, ok := p.Trace.(trace.Trace); ok { + onlyIfParent := func(parentSpanCtx opentracing.SpanContext, method string, req, resp interface{}) bool { + return parentSpanCtx != nil + } + intercept := otgrpc.OpenTracingClientInterceptor(t.Tracer(), otgrpc.IncludingSpans(onlyIfParent)) + dialOpts = append(dialOpts, grpc.WithUnaryInterceptor(intercept)) + } else { + log.Printf("[WARNING] Wrong type for trace plugin reference: %s", p.Trace) + } + } + for _, host := range g.upstream.Hosts { + conn, err := grpc.Dial(host.Name, dialOpts...) + if err != nil { + log.Printf("[WARNING] Skipping gRPC host '%s' due to Dial error: %s\n", host.Name, err) + } else { + g.clients[host.Name] = pb.NewDnsServiceClient(conn) + g.conns = append(g.conns, conn) + } + } + return nil +} diff --git a/plugin/proxy/grpc_test.go b/plugin/proxy/grpc_test.go new file mode 100644 index 000000000..52c5737d6 --- /dev/null +++ b/plugin/proxy/grpc_test.go @@ -0,0 +1,71 @@ +package proxy + +import ( + "testing" + "time" + + "github.com/coredns/coredns/plugin/pkg/healthcheck" + + "google.golang.org/grpc/grpclog" +) + +func pool() []*healthcheck.UpstreamHost { + return []*healthcheck.UpstreamHost{ + { + Name: "localhost:10053", + }, + { + Name: "localhost:10054", + }, + } +} + +func TestStartupShutdown(t *testing.T) { + grpclog.SetLogger(discard{}) + + upstream := &staticUpstream{ + from: ".", + HealthCheck: healthcheck.HealthCheck{ + Hosts: pool(), + FailTimeout: 10 * time.Second, + Future: 60 * time.Second, + MaxFails: 1, + }, + } + g := newGrpcClient(nil, upstream) + upstream.ex = g + + p := &Proxy{} + p.Upstreams = &[]Upstream{upstream} + + err := g.OnStartup(p) + if err != nil { + t.Errorf("Error starting grpc client exchanger: %s", err) + return + } + if len(g.clients) != len(pool()) { + t.Errorf("Expected %d grpc clients but found %d", len(pool()), len(g.clients)) + } + + err = g.OnShutdown(p) + if err != nil { + t.Errorf("Error stopping grpc client exchanger: %s", err) + return + } + if len(g.clients) != 0 { + t.Errorf("Shutdown didn't remove clients, found %d", len(g.clients)) + } + if len(g.conns) != 0 { + t.Errorf("Shutdown didn't remove conns, found %d", len(g.conns)) + } +} + +// discard is a Logger that outputs nothing. +type discard struct{} + +func (d discard) Fatal(args ...interface{}) {} +func (d discard) Fatalf(format string, args ...interface{}) {} +func (d discard) Fatalln(args ...interface{}) {} +func (d discard) Print(args ...interface{}) {} +func (d discard) Printf(format string, args ...interface{}) {} +func (d discard) Println(args ...interface{}) {} diff --git a/plugin/proxy/lookup.go b/plugin/proxy/lookup.go new file mode 100644 index 000000000..9be62edd5 --- /dev/null +++ b/plugin/proxy/lookup.go @@ -0,0 +1,132 @@ +package proxy + +// functions other plugin might want to use to do lookup in the same style as the proxy. + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/coredns/coredns/plugin/pkg/healthcheck" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// NewLookup create a new proxy with the hosts in host and a Random policy. +func NewLookup(hosts []string) Proxy { return NewLookupWithOption(hosts, Options{}) } + +// NewLookupWithOption process creates a simple round robin forward with potentially forced proto for upstream. +func NewLookupWithOption(hosts []string, opts Options) Proxy { + p := Proxy{Next: nil} + + // TODO(miek): this needs to be unified with upstream.go's NewStaticUpstreams, caddy uses NewHost + // we should copy/make something similar. + upstream := &staticUpstream{ + from: ".", + HealthCheck: healthcheck.HealthCheck{ + FailTimeout: 10 * time.Second, + MaxFails: 3, // TODO(miek): disable error checking for simple lookups? + Future: 60 * time.Second, + }, + ex: newDNSExWithOption(opts), + } + upstream.Hosts = make([]*healthcheck.UpstreamHost, len(hosts)) + + for i, host := range hosts { + uh := &healthcheck.UpstreamHost{ + Name: host, + Conns: 0, + Fails: 0, + FailTimeout: upstream.FailTimeout, + + CheckDown: func(upstream *staticUpstream) healthcheck.UpstreamHostDownFunc { + return func(uh *healthcheck.UpstreamHost) bool { + + down := false + + uh.CheckMu.Lock() + until := uh.OkUntil + uh.CheckMu.Unlock() + + if !until.IsZero() && time.Now().After(until) { + down = true + } + + fails := atomic.LoadInt32(&uh.Fails) + if fails >= upstream.MaxFails && upstream.MaxFails != 0 { + down = true + } + return down + } + }(upstream), + WithoutPathPrefix: upstream.WithoutPathPrefix, + } + + upstream.Hosts[i] = uh + } + p.Upstreams = &[]Upstream{upstream} + return p +} + +// Lookup will use name and type to forge a new message and will send that upstream. It will +// set any EDNS0 options correctly so that downstream will be able to process the reply. +func (p Proxy) Lookup(state request.Request, name string, typ uint16) (*dns.Msg, error) { + req := new(dns.Msg) + req.SetQuestion(name, typ) + state.SizeAndDo(req) + + state2 := request.Request{W: state.W, Req: req} + + return p.lookup(state2) +} + +// Forward forward the request in state as-is. Unlike Lookup that adds EDNS0 suffix to the message. +func (p Proxy) Forward(state request.Request) (*dns.Msg, error) { + return p.lookup(state) +} + +func (p Proxy) lookup(state request.Request) (*dns.Msg, error) { + upstream := p.match(state) + if upstream == nil { + return nil, errInvalidDomain + } + for { + start := time.Now() + reply := new(dns.Msg) + var backendErr error + + // Since Select() should give us "up" hosts, keep retrying + // hosts until timeout (or until we get a nil host). + for time.Since(start) < tryDuration { + host := upstream.Select() + if host == nil { + return nil, fmt.Errorf("%s: %s", errUnreachable, "no upstream host") + } + + // duplicated from proxy.go, but with a twist, we don't write the + // reply back to the client, we return it and there is no monitoring. + + atomic.AddInt64(&host.Conns, 1) + + reply, backendErr = upstream.Exchanger().Exchange(context.TODO(), host.Name, state) + + atomic.AddInt64(&host.Conns, -1) + + if backendErr == nil { + return reply, nil + } + timeout := host.FailTimeout + if timeout == 0 { + timeout = 10 * time.Second + } + atomic.AddInt32(&host.Fails, 1) + go func(host *healthcheck.UpstreamHost, timeout time.Duration) { + time.Sleep(timeout) + atomic.AddInt32(&host.Fails, -1) + }(host, timeout) + } + return nil, fmt.Errorf("%s: %s", errUnreachable, backendErr) + } +} diff --git a/plugin/proxy/metrics.go b/plugin/proxy/metrics.go new file mode 100644 index 000000000..893c26d6b --- /dev/null +++ b/plugin/proxy/metrics.go @@ -0,0 +1,30 @@ +package proxy + +import ( + "sync" + + "github.com/coredns/coredns/plugin" + + "github.com/prometheus/client_golang/prometheus" +) + +// Metrics the proxy plugin exports. +var ( + RequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: plugin.Namespace, + Subsystem: "proxy", + Name: "request_duration_milliseconds", + Buckets: append(prometheus.DefBuckets, []float64{50, 100, 200, 500, 1000, 2000, 3000, 4000, 5000, 10000}...), + Help: "Histogram of the time (in milliseconds) each request took.", + }, []string{"proto", "proxy_proto", "from"}) +) + +// OnStartupMetrics sets up the metrics on startup. This is done for all proxy protocols. +func OnStartupMetrics() error { + metricsOnce.Do(func() { + prometheus.MustRegister(RequestDuration) + }) + return nil +} + +var metricsOnce sync.Once diff --git a/plugin/proxy/proxy.go b/plugin/proxy/proxy.go new file mode 100644 index 000000000..9d1e1906b --- /dev/null +++ b/plugin/proxy/proxy.go @@ -0,0 +1,195 @@ +// Package proxy is plugin that proxies requests. +package proxy + +import ( + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/dnstap" + "github.com/coredns/coredns/plugin/dnstap/msg" + "github.com/coredns/coredns/plugin/pkg/healthcheck" + "github.com/coredns/coredns/request" + + tap "github.com/dnstap/golang-dnstap" + "github.com/miekg/dns" + ot "github.com/opentracing/opentracing-go" + "golang.org/x/net/context" +) + +var ( + errUnreachable = errors.New("unreachable backend") + errInvalidProtocol = errors.New("invalid protocol") + errInvalidDomain = errors.New("invalid path for proxy") +) + +// Proxy represents a plugin instance that can proxy requests to another (DNS) server. +type Proxy struct { + Next plugin.Handler + + // Upstreams is a pointer to a slice, so we can update the upstream (used for Google) + // midway. + + Upstreams *[]Upstream + + // Trace is the Trace plugin, if it is installed + // This is used by the grpc exchanger to trace through the grpc calls + Trace plugin.Handler +} + +// Upstream manages a pool of proxy upstream hosts. Select should return a +// suitable upstream host, or nil if no such hosts are available. +type Upstream interface { + // The domain name this upstream host should be routed on. + From() string + // Selects an upstream host to be routed to. + Select() *healthcheck.UpstreamHost + // Checks if subpdomain is not an ignored. + IsAllowedDomain(string) bool + // Exchanger returns the exchanger to be used for this upstream. + Exchanger() Exchanger + // Stops the upstream from proxying requests to shutdown goroutines cleanly. + Stop() error +} + +// tryDuration is how long to try upstream hosts; failures result in +// immediate retries until this duration ends or we get a nil host. +var tryDuration = 60 * time.Second + +// ServeDNS satisfies the plugin.Handler interface. +func (p Proxy) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + var span, child ot.Span + span = ot.SpanFromContext(ctx) + state := request.Request{W: w, Req: r} + + upstream := p.match(state) + if upstream == nil { + return plugin.NextOrFailure(p.Name(), p.Next, ctx, w, r) + } + + for { + start := time.Now() + reply := new(dns.Msg) + var backendErr error + + // Since Select() should give us "up" hosts, keep retrying + // hosts until timeout (or until we get a nil host). + for time.Since(start) < tryDuration { + host := upstream.Select() + if host == nil { + + RequestDuration.WithLabelValues(state.Proto(), upstream.Exchanger().Protocol(), upstream.From()).Observe(float64(time.Since(start) / time.Millisecond)) + + return dns.RcodeServerFailure, fmt.Errorf("%s: %s", errUnreachable, "no upstream host") + } + + if span != nil { + child = span.Tracer().StartSpan("exchange", ot.ChildOf(span.Context())) + ctx = ot.ContextWithSpan(ctx, child) + } + + atomic.AddInt64(&host.Conns, 1) + queryEpoch := msg.Epoch() + + reply, backendErr = upstream.Exchanger().Exchange(ctx, host.Name, state) + + respEpoch := msg.Epoch() + atomic.AddInt64(&host.Conns, -1) + + if child != nil { + child.Finish() + } + + taperr := toDnstap(ctx, host.Name, upstream.Exchanger(), state, reply, queryEpoch, respEpoch) + + if backendErr == nil { + w.WriteMsg(reply) + + RequestDuration.WithLabelValues(state.Proto(), upstream.Exchanger().Protocol(), upstream.From()).Observe(float64(time.Since(start) / time.Millisecond)) + + return 0, taperr + } + + timeout := host.FailTimeout + if timeout == 0 { + timeout = 10 * time.Second + } + atomic.AddInt32(&host.Fails, 1) + go func(host *healthcheck.UpstreamHost, timeout time.Duration) { + time.Sleep(timeout) + atomic.AddInt32(&host.Fails, -1) + }(host, timeout) + } + + RequestDuration.WithLabelValues(state.Proto(), upstream.Exchanger().Protocol(), upstream.From()).Observe(float64(time.Since(start) / time.Millisecond)) + + return dns.RcodeServerFailure, fmt.Errorf("%s: %s", errUnreachable, backendErr) + } +} + +func (p Proxy) match(state request.Request) (u Upstream) { + if p.Upstreams == nil { + return nil + } + + longestMatch := 0 + for _, upstream := range *p.Upstreams { + from := upstream.From() + + if !plugin.Name(from).Matches(state.Name()) || !upstream.IsAllowedDomain(state.Name()) { + continue + } + + if lf := len(from); lf > longestMatch { + longestMatch = lf + u = upstream + } + } + return u + +} + +// Name implements the Handler interface. +func (p Proxy) Name() string { return "proxy" } + +// defaultTimeout is the default networking timeout for DNS requests. +const defaultTimeout = 5 * time.Second + +func toDnstap(ctx context.Context, host string, ex Exchanger, state request.Request, reply *dns.Msg, queryEpoch, respEpoch uint64) (err error) { + if tapper := dnstap.TapperFromContext(ctx); tapper != nil { + // Query + b := tapper.TapBuilder() + b.TimeSec = queryEpoch + if err = b.HostPort(host); err != nil { + return + } + t := ex.Transport() + if t == "" { + t = state.Proto() + } + if t == "tcp" { + b.SocketProto = tap.SocketProtocol_TCP + } else { + b.SocketProto = tap.SocketProtocol_UDP + } + if err = b.Msg(state.Req); err != nil { + return + } + err = tapper.TapMessage(b.ToOutsideQuery(tap.Message_FORWARDER_QUERY)) + if err != nil { + return + } + + // Response + if reply != nil { + b.TimeSec = respEpoch + if err = b.Msg(reply); err != nil { + return + } + err = tapper.TapMessage(b.ToOutsideResponse(tap.Message_FORWARDER_RESPONSE)) + } + } + return +} diff --git a/plugin/proxy/proxy_test.go b/plugin/proxy/proxy_test.go new file mode 100644 index 000000000..b0cb9c3cb --- /dev/null +++ b/plugin/proxy/proxy_test.go @@ -0,0 +1,87 @@ +package proxy + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/mholt/caddy/caddyfile" +) + +func TestStop(t *testing.T) { + config := "proxy . %s {\n health_check /healthcheck:%s %dms \n}" + tests := []struct { + name string + intervalInMilliseconds int + numHealthcheckIntervals int + }{ + { + "No Healthchecks After Stop - 5ms, 1 intervals", + 5, + 1, + }, + { + "No Healthchecks After Stop - 5ms, 2 intervals", + 5, + 2, + }, + { + "No Healthchecks After Stop - 5ms, 3 intervals", + 5, + 3, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + // Set up proxy. + var counter int64 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body.Close() + atomic.AddInt64(&counter, 1) + })) + + defer backend.Close() + + port := backend.URL[17:] // Remove all crap up to the port + back := backend.URL[7:] // Remove http:// + c := caddyfile.NewDispenser("Testfile", strings.NewReader(fmt.Sprintf(config, back, port, test.intervalInMilliseconds))) + upstreams, err := NewStaticUpstreams(&c) + if err != nil { + t.Error("Expected no error. Got:", err.Error()) + } + + // Give some time for healthchecks to hit the server. + time.Sleep(time.Duration(test.intervalInMilliseconds*test.numHealthcheckIntervals) * time.Millisecond) + + for _, upstream := range upstreams { + if err := upstream.Stop(); err != nil { + t.Error("Expected no error stopping upstream. Got: ", err.Error()) + } + } + + counterValueAfterShutdown := atomic.LoadInt64(&counter) + + // Give some time to see if healthchecks are still hitting the server. + time.Sleep(time.Duration(test.intervalInMilliseconds*test.numHealthcheckIntervals) * time.Millisecond) + + if counterValueAfterShutdown == 0 { + t.Error("Expected healthchecks to hit test server. Got no healthchecks.") + } + + // health checks are in a go routine now, so one may well occur after we shutdown, + // but we only ever expect one more + counterValueAfterWaiting := atomic.LoadInt64(&counter) + if counterValueAfterWaiting > (counterValueAfterShutdown + 1) { + t.Errorf("Expected no more healthchecks after shutdown. Got: %d healthchecks after shutdown", counterValueAfterWaiting-counterValueAfterShutdown) + } + + }) + + } +} diff --git a/plugin/proxy/response.go b/plugin/proxy/response.go new file mode 100644 index 000000000..2ad553c41 --- /dev/null +++ b/plugin/proxy/response.go @@ -0,0 +1,21 @@ +package proxy + +import ( + "net" + + "github.com/miekg/dns" +) + +type fakeBootWriter struct { + dns.ResponseWriter +} + +func (w *fakeBootWriter) LocalAddr() net.Addr { + local := net.ParseIP("127.0.0.1") + return &net.UDPAddr{IP: local, Port: 53} // Port is not used here +} + +func (w *fakeBootWriter) RemoteAddr() net.Addr { + remote := net.ParseIP("8.8.8.8") + return &net.UDPAddr{IP: remote, Port: 53} // Port is not used here +} diff --git a/plugin/proxy/setup.go b/plugin/proxy/setup.go new file mode 100644 index 000000000..bbe65c35d --- /dev/null +++ b/plugin/proxy/setup.go @@ -0,0 +1,46 @@ +package proxy + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("proxy", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + upstreams, err := NewStaticUpstreams(&c.Dispenser) + if err != nil { + return plugin.Error("proxy", err) + } + + t := dnsserver.GetConfig(c).Handler("trace") + P := &Proxy{Trace: t} + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + P.Next = next + P.Upstreams = &upstreams + return P + }) + + c.OnStartup(OnStartupMetrics) + + for i := range upstreams { + u := upstreams[i] + c.OnStartup(func() error { + return u.Exchanger().OnStartup(P) + }) + c.OnShutdown(func() error { + return u.Exchanger().OnShutdown(P) + }) + // Register shutdown handlers. + c.OnShutdown(u.Stop) + } + + return nil +} diff --git a/plugin/proxy/upstream.go b/plugin/proxy/upstream.go new file mode 100644 index 000000000..b60b6ff58 --- /dev/null +++ b/plugin/proxy/upstream.go @@ -0,0 +1,234 @@ +package proxy + +import ( + "fmt" + "net" + "strconv" + "sync/atomic" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/pkg/healthcheck" + "github.com/coredns/coredns/plugin/pkg/tls" + "github.com/mholt/caddy/caddyfile" + "github.com/miekg/dns" +) + +type staticUpstream struct { + from string + + healthcheck.HealthCheck + + WithoutPathPrefix string + IgnoredSubDomains []string + ex Exchanger +} + +// NewStaticUpstreams parses the configuration input and sets up +// static upstreams for the proxy plugin. +func NewStaticUpstreams(c *caddyfile.Dispenser) ([]Upstream, error) { + var upstreams []Upstream + for c.Next() { + upstream := &staticUpstream{ + from: ".", + HealthCheck: healthcheck.HealthCheck{ + FailTimeout: 10 * time.Second, + MaxFails: 1, + Future: 60 * time.Second, + }, + ex: newDNSEx(), + } + + if !c.Args(&upstream.from) { + return upstreams, c.ArgErr() + } + upstream.from = plugin.Host(upstream.from).Normalize() + + to := c.RemainingArgs() + if len(to) == 0 { + return upstreams, c.ArgErr() + } + + // process the host list, substituting in any nameservers in files + toHosts, err := dnsutil.ParseHostPortOrFile(to...) + if err != nil { + return upstreams, err + } + + for c.NextBlock() { + if err := parseBlock(c, upstream); err != nil { + return upstreams, err + } + } + + upstream.Hosts = make([]*healthcheck.UpstreamHost, len(toHosts)) + for i, host := range toHosts { + uh := &healthcheck.UpstreamHost{ + Name: host, + Conns: 0, + Fails: 0, + FailTimeout: upstream.FailTimeout, + + CheckDown: func(upstream *staticUpstream) healthcheck.UpstreamHostDownFunc { + return func(uh *healthcheck.UpstreamHost) bool { + + down := false + + uh.CheckMu.Lock() + until := uh.OkUntil + uh.CheckMu.Unlock() + + if !until.IsZero() && time.Now().After(until) { + down = true + } + + fails := atomic.LoadInt32(&uh.Fails) + if fails >= upstream.MaxFails && upstream.MaxFails != 0 { + down = true + } + return down + } + }(upstream), + WithoutPathPrefix: upstream.WithoutPathPrefix, + } + + upstream.Hosts[i] = uh + } + upstream.Start() + + upstreams = append(upstreams, upstream) + } + return upstreams, nil +} + +func (u *staticUpstream) From() string { + return u.from +} + +func parseBlock(c *caddyfile.Dispenser, u *staticUpstream) error { + switch c.Val() { + case "policy": + if !c.NextArg() { + return c.ArgErr() + } + policyCreateFunc, ok := healthcheck.SupportedPolicies[c.Val()] + if !ok { + return c.ArgErr() + } + u.Policy = policyCreateFunc() + case "fail_timeout": + if !c.NextArg() { + return c.ArgErr() + } + dur, err := time.ParseDuration(c.Val()) + if err != nil { + return err + } + u.FailTimeout = dur + case "max_fails": + if !c.NextArg() { + return c.ArgErr() + } + n, err := strconv.Atoi(c.Val()) + if err != nil { + return err + } + u.MaxFails = int32(n) + case "health_check": + if !c.NextArg() { + return c.ArgErr() + } + var err error + u.HealthCheck.Path, u.HealthCheck.Port, err = net.SplitHostPort(c.Val()) + if err != nil { + return err + } + u.HealthCheck.Interval = 30 * time.Second + if c.NextArg() { + dur, err := time.ParseDuration(c.Val()) + if err != nil { + return err + } + u.HealthCheck.Interval = dur + u.Future = 2 * dur + + // set a minimum of 3 seconds + if u.Future < (3 * time.Second) { + u.Future = 3 * time.Second + } + } + case "without": + if !c.NextArg() { + return c.ArgErr() + } + u.WithoutPathPrefix = c.Val() + case "except": + ignoredDomains := c.RemainingArgs() + if len(ignoredDomains) == 0 { + return c.ArgErr() + } + for i := 0; i < len(ignoredDomains); i++ { + ignoredDomains[i] = plugin.Host(ignoredDomains[i]).Normalize() + } + u.IgnoredSubDomains = ignoredDomains + case "spray": + u.Spray = &healthcheck.Spray{} + case "protocol": + encArgs := c.RemainingArgs() + if len(encArgs) == 0 { + return c.ArgErr() + } + switch encArgs[0] { + case "dns": + if len(encArgs) > 1 { + if encArgs[1] == "force_tcp" { + opts := Options{ForceTCP: true} + u.ex = newDNSExWithOption(opts) + } else { + return fmt.Errorf("only force_tcp allowed as parameter to dns") + } + } else { + u.ex = newDNSEx() + } + case "https_google": + boot := []string{"8.8.8.8:53", "8.8.4.4:53"} + if len(encArgs) > 2 && encArgs[1] == "bootstrap" { + boot = encArgs[2:] + } + + u.ex = newGoogle("", boot) // "" for default in google.go + case "grpc": + if len(encArgs) == 2 && encArgs[1] == "insecure" { + u.ex = newGrpcClient(nil, u) + return nil + } + tls, err := tls.NewTLSConfigFromArgs(encArgs[1:]...) + if err != nil { + return err + } + u.ex = newGrpcClient(tls, u) + default: + return fmt.Errorf("%s: %s", errInvalidProtocol, encArgs[0]) + } + + default: + return c.Errf("unknown property '%s'", c.Val()) + } + return nil +} + +func (u *staticUpstream) IsAllowedDomain(name string) bool { + if dns.Name(name) == dns.Name(u.From()) { + return true + } + + for _, ignoredSubDomain := range u.IgnoredSubDomains { + if plugin.Name(ignoredSubDomain).Matches(name) { + return false + } + } + return true +} + +func (u *staticUpstream) Exchanger() Exchanger { return u.ex } diff --git a/plugin/proxy/upstream_test.go b/plugin/proxy/upstream_test.go new file mode 100644 index 000000000..42d50cac3 --- /dev/null +++ b/plugin/proxy/upstream_test.go @@ -0,0 +1,324 @@ +package proxy + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/coredns/coredns/plugin/test" + + "github.com/mholt/caddy" +) + +func TestAllowedDomain(t *testing.T) { + upstream := &staticUpstream{ + from: "miek.nl.", + IgnoredSubDomains: []string{"download.miek.nl.", "static.miek.nl."}, // closing dot mandatory + } + tests := []struct { + name string + expected bool + }{ + {"miek.nl.", true}, + {"download.miek.nl.", false}, + {"static.miek.nl.", false}, + {"blaat.miek.nl.", true}, + } + + for i, test := range tests { + isAllowed := upstream.IsAllowedDomain(test.name) + if test.expected != isAllowed { + t.Errorf("Test %d: expected %v found %v for %s", i+1, test.expected, isAllowed, test.name) + } + } +} + +func TestProxyParse(t *testing.T) { + rmFunc, cert, key, ca := getPEMFiles(t) + defer rmFunc() + + grpc1 := "proxy . 8.8.8.8:53 {\n protocol grpc " + ca + "\n}" + grpc2 := "proxy . 8.8.8.8:53 {\n protocol grpc " + cert + " " + key + "\n}" + grpc3 := "proxy . 8.8.8.8:53 {\n protocol grpc " + cert + " " + key + " " + ca + "\n}" + grpc4 := "proxy . 8.8.8.8:53 {\n protocol grpc " + key + "\n}" + + tests := []struct { + inputUpstreams string + shouldErr bool + }{ + { + `proxy . 8.8.8.8:53`, + false, + }, + { + `proxy 10.0.0.0/24 8.8.8.8:53`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + policy round_robin +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + fail_timeout 5s +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + max_fails 10 +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + health_check /health:8080 +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + without without +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + except miek.nl example.org 10.0.0.0/24 +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + spray +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + error_option +}`, + true, + }, + { + ` +proxy . some_bogus_filename`, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol dns +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol grpc +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol grpc insecure +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol dns force_tcp +}`, + false, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol grpc a b c d +}`, + true, + }, + { + grpc1, + false, + }, + { + grpc2, + false, + }, + { + grpc3, + false, + }, + { + grpc4, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol foobar +}`, + true, + }, + { + `proxy`, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol foobar +}`, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + policy +}`, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + fail_timeout +}`, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + fail_timeout junky +}`, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + health_check +}`, + true, + }, + { + ` +proxy . 8.8.8.8:53 { + protocol dns force +}`, + true, + }, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputUpstreams) + _, err := NewStaticUpstreams(&c.Dispenser) + if (err != nil) != test.shouldErr { + t.Errorf("Test %d expected no error, got %v for %s", i+1, err, test.inputUpstreams) + } + } +} + +func TestResolvParse(t *testing.T) { + tests := []struct { + inputUpstreams string + filedata string + shouldErr bool + expected []string + }{ + { + ` +proxy . FILE +`, + ` +nameserver 1.2.3.4 +nameserver 4.3.2.1 +`, + false, + []string{"1.2.3.4:53", "4.3.2.1:53"}, + }, + { + ` +proxy example.com 1.1.1.1:5000 +proxy . FILE +proxy example.org 2.2.2.2:1234 +`, + ` +nameserver 1.2.3.4 +`, + false, + []string{"1.1.1.1:5000", "1.2.3.4:53", "2.2.2.2:1234"}, + }, + { + ` +proxy example.com 1.1.1.1:5000 +proxy . FILE +proxy example.org 2.2.2.2:1234 +`, + ` +junky resolve.conf +`, + false, + []string{"1.1.1.1:5000", "2.2.2.2:1234"}, + }, + } + for i, tc := range tests { + + path, rm, err := test.TempFile(".", tc.filedata) + if err != nil { + t.Fatalf("Test %d could not creat temp file %v", i, err) + } + defer rm() + + config := strings.Replace(tc.inputUpstreams, "FILE", path, -1) + c := caddy.NewTestController("dns", config) + upstreams, err := NewStaticUpstreams(&c.Dispenser) + if (err != nil) != tc.shouldErr { + t.Errorf("Test %d expected no error, got %v", i+1, err) + } + var hosts []string + for _, u := range upstreams { + for _, h := range u.(*staticUpstream).Hosts { + hosts = append(hosts, h.Name) + } + } + if !tc.shouldErr { + if len(hosts) != len(tc.expected) { + t.Errorf("Test %d expected %d hosts got %d", i+1, len(tc.expected), len(upstreams)) + } else { + ok := true + for i, v := range tc.expected { + if v != hosts[i] { + ok = false + } + } + if !ok { + t.Errorf("Test %d expected %v got %v", i+1, tc.expected, upstreams) + } + } + } + } +} + +func getPEMFiles(t *testing.T) (rmFunc func(), cert, key, ca string) { + tempDir, rmFunc, err := test.WritePEMFiles("") + if err != nil { + t.Fatalf("Could not write PEM files: %s", err) + } + + cert = filepath.Join(tempDir, "cert.pem") + key = filepath.Join(tempDir, "key.pem") + ca = filepath.Join(tempDir, "ca.pem") + + return +} diff --git a/plugin/reverse/README.md b/plugin/reverse/README.md new file mode 100644 index 000000000..63a3a968c --- /dev/null +++ b/plugin/reverse/README.md @@ -0,0 +1,86 @@ +# reverse + +The *reverse* plugin allows CoreDNS to respond dynamically to a PTR request and the related A/AAAA request. + +## Syntax + +~~~ +reverse NETWORK... { + hostname TEMPLATE + [ttl TTL] + [fallthrough] + [wildcard] +~~~ + +* **NETWORK** one or more CIDR formatted networks to respond on. +* `hostname` injects the IP and zone to a template for the hostname. Defaults to "ip-{IP}.{zone[1]}". See below for template. +* `ttl` defaults to 60 +* `fallthrough` if zone matches and no record can be generated, pass request to the next plugin. +* `wildcard` allows matches to catch all subdomains as well. + +### Template Syntax + +The template for the hostname is used for generating the PTR for a reverse lookup and matching the +forward lookup back to an IP. + +#### `{ip}` + +The `{ip}` symbol is **required** to make reverse work. +For IPv4 lookups the IP is directly extracted +With IPv6 lookups the ":" is removed, and any zero ranged are expanded, e.g., +"ffff::ffff" results in "ffff000000000000000000000000ffff" + +#### `{zone[i]}` + +The `{zone[i]}` symbol is **optional** and can be replaced by a fixed (zone) string. +The zone will be matched by the zones listed in *this* configuration stanza. +`i` needs to be replaced with the index of the configured listener zones, starting with 1. + +## Examples + +~~~ txt +arpa compute.internal { + # proxy unmatched requests + proxy . 8.8.8.8 + + # answer requests for IPs in this network + # PTR 1.0.32.10.in-addr.arpa. 3600 ip-10.0.32.1.compute.internal. + # A ip-10.0.32.1.compute.internal. 3600 10.0.32.1 + # v6 is also possible + # PTR 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.d.f.ip6.arpa. 3600 ip-fd010000000000000000000000000001.compute.internal. + # AAAA ip-fd010000000000000000000000000001.compute.internal. 3600 fd01::1 + reverse 10.32.0.0/16 fd01::/16 { + # template of the ip injection to hostname, zone resolved to compute.internal. + hostname ip-{ip}.{zone[2]} + + ttl 3600 + + # Forward unanswered or unmatched requests to proxy + # without this flag, requesting A/AAAA records on compute.internal. will end here. + fallthrough + } +} +~~~ + + +~~~ txt +32.10.in-addr.arpa.arpa arpa.company.org { + + reverse 10.32.0.0/16 { + # template of the ip injection to hostname, zone resolved to arpa.company.org. + hostname "ip-{ip}.v4.{zone[2]}" + + ttl 3600 + + # fallthrough is not required, v4.arpa.company.org. will be only answered here + } + + # cidr closer to the ip wins, so we can overwrite the "default" + reverse 10.32.2.0/24 { + # its also possible to set fix domain suffix + hostname ip-{ip}.fix.arpa.company.org. + + ttl 3600 + } +} +~~~ diff --git a/plugin/reverse/network.go b/plugin/reverse/network.go new file mode 100644 index 000000000..80d533382 --- /dev/null +++ b/plugin/reverse/network.go @@ -0,0 +1,87 @@ +package reverse + +import ( + "bytes" + "net" + "regexp" + "strings" +) + +type network struct { + IPnet *net.IPNet + Zone string // forward lookup zone + Template string + TTL uint32 + RegexMatchIP *regexp.Regexp +} + +// TODO: we might want to get rid of these regexes. +const hexDigit = "0123456789abcdef" +const templateNameIP = "{ip}" +const regexMatchV4 = "((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))" +const regexMatchV6 = "([0-9a-fA-F]{32})" + +// hostnameToIP converts the hostname back to an ip, based on the template +// returns nil if there is no IP found. +func (network *network) hostnameToIP(rname string) net.IP { + var matchedIP net.IP + + match := network.RegexMatchIP.FindStringSubmatch(rname) + if len(match) != 2 { + return nil + } + + if network.IPnet.IP.To4() != nil { + matchedIP = net.ParseIP(match[1]) + } else { + // TODO: can probably just allocate a []byte and use that. + var buf bytes.Buffer + // convert back to an valid ipv6 string with colons + for i := 0; i < 8*4; i += 4 { + buf.WriteString(match[1][i : i+4]) + if i < 28 { + buf.WriteString(":") + } + } + matchedIP = net.ParseIP(buf.String()) + } + + // No valid ip or it does not belong to this network + if matchedIP == nil || !network.IPnet.Contains(matchedIP) { + return nil + } + + return matchedIP +} + +// ipToHostname converts an IP to an DNS compatible hostname and injects it into the template.domain. +func (network *network) ipToHostname(ip net.IP) (name string) { + if ipv4 := ip.To4(); ipv4 != nil { + // replace . to - + name = ipv4.String() + } else { + // assume v6 + // ensure zeros are present in string + buf := make([]byte, 0, len(ip)*4) + for i := 0; i < len(ip); i++ { + v := ip[i] + buf = append(buf, hexDigit[v>>4]) + buf = append(buf, hexDigit[v&0xF]) + } + name = string(buf) + } + // inject the converted ip into the fqdn template + return strings.Replace(network.Template, templateNameIP, name, 1) +} + +type networks []network + +func (n networks) Len() int { return len(n) } +func (n networks) Swap(i, j int) { n[i], n[j] = n[j], n[i] } + +// cidr closer to the ip wins (by netmask) +func (n networks) Less(i, j int) bool { + isize, _ := n[i].IPnet.Mask.Size() + jsize, _ := n[j].IPnet.Mask.Size() + return isize > jsize +} diff --git a/plugin/reverse/network_test.go b/plugin/reverse/network_test.go new file mode 100644 index 000000000..a826707e5 --- /dev/null +++ b/plugin/reverse/network_test.go @@ -0,0 +1,135 @@ +package reverse + +import ( + "net" + "reflect" + "regexp" + "testing" +) + +// Test converting from hostname to IP and back again to hostname +func TestNetworkConversion(t *testing.T) { + + _, net4, _ := net.ParseCIDR("10.1.1.0/24") + _, net6, _ := net.ParseCIDR("fd01::/64") + + regexIP4, _ := regexp.Compile("^dns-" + regexMatchV4 + "\\.domain\\.internal\\.$") + regexIP6, _ := regexp.Compile("^dns-" + regexMatchV6 + "\\.domain\\.internal\\.$") + + tests := []struct { + network network + resultHost string + resultIP net.IP + }{ + { + network{ + IPnet: net4, + Template: "dns-{ip}.domain.internal.", + RegexMatchIP: regexIP4, + }, + "dns-10.1.1.23.domain.internal.", + net.ParseIP("10.1.1.23"), + }, + { + network{ + IPnet: net6, + Template: "dns-{ip}.domain.internal.", + RegexMatchIP: regexIP6, + }, + "dns-fd01000000000000000000000000a32f.domain.internal.", + net.ParseIP("fd01::a32f"), + }, + } + + for i, test := range tests { + resultIP := test.network.hostnameToIP(test.resultHost) + if !reflect.DeepEqual(test.resultIP, resultIP) { + t.Fatalf("Test %d expected %v, got %v", i, test.resultIP, resultIP) + } + + resultHost := test.network.ipToHostname(test.resultIP) + if !reflect.DeepEqual(test.resultHost, resultHost) { + t.Fatalf("Test %d expected %v, got %v", i, test.resultHost, resultHost) + } + } +} + +func TestNetworkHostnameToIP(t *testing.T) { + + _, net4, _ := net.ParseCIDR("10.1.1.0/24") + _, net6, _ := net.ParseCIDR("fd01::/64") + + regexIP4, _ := regexp.Compile("^dns-" + regexMatchV4 + "\\.domain\\.internal\\.$") + regexIP6, _ := regexp.Compile("^dns-" + regexMatchV6 + "\\.domain\\.internal\\.$") + + // Test regex does NOT match + // All this test should return nil + testsNil := []struct { + network network + hostname string + }{ + { + network{ + IPnet: net4, + RegexMatchIP: regexIP4, + }, + // domain does not match + "dns-10.1.1.23.domain.internals.", + }, + { + network{ + IPnet: net4, + RegexMatchIP: regexIP4, + }, + // IP does match / contain in subnet + "dns-200.1.1.23.domain.internals.", + }, + { + network{ + IPnet: net4, + RegexMatchIP: regexIP4, + }, + // template does not match + "dns-10.1.1.23-x.domain.internal.", + }, + { + network{ + IPnet: net4, + RegexMatchIP: regexIP4, + }, + // template does not match + "IP-dns-10.1.1.23.domain.internal.", + }, + { + network{ + IPnet: net6, + RegexMatchIP: regexIP6, + }, + // template does not match + "dnx-fd01000000000000000000000000a32f.domain.internal.", + }, + { + network{ + IPnet: net6, + RegexMatchIP: regexIP6, + }, + // no valid v6 (missing one 0, only 31 chars) + "dns-fd0100000000000000000000000a32f.domain.internal.", + }, + { + network{ + IPnet: net6, + RegexMatchIP: regexIP6, + }, + // IP does match / contain in subnet + "dns-ed01000000000000000000000000a32f.domain.internal.", + }, + } + + for i, test := range testsNil { + resultIP := test.network.hostnameToIP(test.hostname) + if resultIP != nil { + t.Fatalf("Test %d expected nil, got %v", i, resultIP) + } + } +} diff --git a/plugin/reverse/reverse.go b/plugin/reverse/reverse.go new file mode 100644 index 000000000..7d7681867 --- /dev/null +++ b/plugin/reverse/reverse.go @@ -0,0 +1,107 @@ +package reverse + +import ( + "net" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Reverse provides dynamic reverse DNS and the related forward RR. +type Reverse struct { + Next plugin.Handler + Networks networks + Fallthrough bool +} + +// ServeDNS implements the plugin.Handler interface. +func (re Reverse) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + var rr dns.RR + + state := request.Request{W: w, Req: r} + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + + switch state.QType() { + case dns.TypePTR: + address := dnsutil.ExtractAddressFromReverse(state.Name()) + + if address == "" { + // Not an reverse lookup, but can still be an pointer for an domain + break + } + + ip := net.ParseIP(address) + // loop through the configured networks + for _, n := range re.Networks { + if n.IPnet.Contains(ip) { + rr = &dns.PTR{ + Hdr: dns.RR_Header{Name: state.QName(), Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: n.TTL}, + Ptr: n.ipToHostname(ip), + } + break + } + } + + case dns.TypeA: + for _, n := range re.Networks { + if dns.IsSubDomain(n.Zone, state.Name()) { + + // skip if requesting an v4 address and network is not v4 + if n.IPnet.IP.To4() == nil { + continue + } + + result := n.hostnameToIP(state.Name()) + if result != nil { + rr = &dns.A{ + Hdr: dns.RR_Header{Name: state.QName(), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: n.TTL}, + A: result, + } + break + } + } + } + + case dns.TypeAAAA: + for _, n := range re.Networks { + if dns.IsSubDomain(n.Zone, state.Name()) { + + // Do not use To16 which tries to make v4 in v6 + if n.IPnet.IP.To4() != nil { + continue + } + + result := n.hostnameToIP(state.Name()) + if result != nil { + rr = &dns.AAAA{ + Hdr: dns.RR_Header{Name: state.QName(), Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: n.TTL}, + AAAA: result, + } + break + } + } + } + + } + + if rr != nil { + m.Answer = append(m.Answer, rr) + state.SizeAndDo(m) + w.WriteMsg(m) + return dns.RcodeSuccess, nil + } + + if re.Fallthrough { + return plugin.NextOrFailure(re.Name(), re.Next, ctx, w, r) + } + return dns.RcodeServerFailure, nil +} + +// Name implements the Handler interface. +func (re Reverse) Name() string { return "reverse" } diff --git a/plugin/reverse/reverse_test.go b/plugin/reverse/reverse_test.go new file mode 100644 index 000000000..c7a7fea6c --- /dev/null +++ b/plugin/reverse/reverse_test.go @@ -0,0 +1,71 @@ +package reverse + +import ( + "net" + "regexp" + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestReverse(t *testing.T) { + _, net4, _ := net.ParseCIDR("10.1.1.0/24") + regexIP4, _ := regexp.Compile("^.*ip-" + regexMatchV4 + "\\.example\\.org\\.$") + + em := Reverse{ + Networks: networks{network{ + IPnet: net4, + Zone: "example.org", + Template: "ip-{ip}.example.org.", + RegexMatchIP: regexIP4, + }}, + Fallthrough: false, + } + + tests := []struct { + next plugin.Handler + qname string + qtype uint16 + expectedCode int + expectedReply string + expectedErr error + }{ + { + next: test.NextHandler(dns.RcodeSuccess, nil), + qname: "test.ip-10.1.1.2.example.org", + expectedCode: dns.RcodeSuccess, + expectedReply: "10.1.1.2", + expectedErr: nil, + }, + } + + ctx := context.TODO() + + for i, tr := range tests { + req := new(dns.Msg) + + tr.qtype = dns.TypeA + req.SetQuestion(dns.Fqdn(tr.qname), tr.qtype) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + code, err := em.ServeDNS(ctx, rec, req) + + if err != tr.expectedErr { + t.Errorf("Test %d: Expected error %v, but got %v", i, tr.expectedErr, err) + } + if code != int(tr.expectedCode) { + t.Errorf("Test %d: Expected status code %d, but got %d", i, tr.expectedCode, code) + } + if tr.expectedReply != "" { + answer := rec.Msg.Answer[0].(*dns.A).A.String() + if answer != tr.expectedReply { + t.Errorf("Test %d: Expected answer %s, but got %s", i, tr.expectedReply, answer) + } + } + } +} diff --git a/plugin/reverse/setup.go b/plugin/reverse/setup.go new file mode 100644 index 000000000..26e21eea9 --- /dev/null +++ b/plugin/reverse/setup.go @@ -0,0 +1,147 @@ +package reverse + +import ( + "net" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("reverse", caddy.Plugin{ + ServerType: "dns", + Action: setupReverse, + }) +} + +func setupReverse(c *caddy.Controller) error { + networks, fallThrough, err := reverseParse(c) + if err != nil { + return plugin.Error("reverse", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return Reverse{Next: next, Networks: networks, Fallthrough: fallThrough} + }) + + return nil +} + +func reverseParse(c *caddy.Controller) (nets networks, fall bool, err error) { + zones := make([]string, len(c.ServerBlockKeys)) + wildcard := false + + // We copy from the serverblock, these contains Hosts. + for i, str := range c.ServerBlockKeys { + zones[i] = plugin.Host(str).Normalize() + } + + for c.Next() { + var cidrs []*net.IPNet + + // parse all networks + for _, cidr := range c.RemainingArgs() { + if cidr == "{" { + break + } + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, false, c.Errf("network needs to be CIDR formatted: %q\n", cidr) + } + cidrs = append(cidrs, ipnet) + } + if len(cidrs) == 0 { + return nil, false, c.ArgErr() + } + + // set defaults + var ( + template = "ip-" + templateNameIP + ".{zone[1]}" + ttl = 60 + ) + for c.NextBlock() { + switch c.Val() { + case "hostname": + if !c.NextArg() { + return nil, false, c.ArgErr() + } + template = c.Val() + + case "ttl": + if !c.NextArg() { + return nil, false, c.ArgErr() + } + ttl, err = strconv.Atoi(c.Val()) + if err != nil { + return nil, false, err + } + + case "wildcard": + wildcard = true + + case "fallthrough": + fall = true + + default: + return nil, false, c.ArgErr() + } + } + + // prepare template + // replace {zone[index]} by the listen zone/domain of this config block + for i, zone := range zones { + // TODO: we should be smarter about actually replacing this. This works, but silently allows "zone[-1]" + // for instance. + template = strings.Replace(template, "{zone["+strconv.Itoa(i+1)+"]}", zone, 1) + } + if !strings.HasSuffix(template, ".") { + template += "." + } + + // extract zone from template + templateZone := strings.SplitAfterN(template, ".", 2) + if len(templateZone) != 2 || templateZone[1] == "" { + return nil, false, c.Errf("cannot find domain in template '%v'", template) + } + + // Create for each configured network in this stanza + for _, ipnet := range cidrs { + // precompile regex for hostname to ip matching + regexIP := regexMatchV4 + if ipnet.IP.To4() == nil { + regexIP = regexMatchV6 + } + prefix := "^" + if wildcard { + prefix += ".*" + } + regex, err := regexp.Compile( + prefix + strings.Replace( // inject ip regex into template + regexp.QuoteMeta(template), // escape dots + regexp.QuoteMeta(templateNameIP), + regexIP, + 1) + "$") + if err != nil { + return nil, false, err + } + + nets = append(nets, network{ + IPnet: ipnet, + Zone: templateZone[1], + Template: template, + RegexMatchIP: regex, + TTL: uint32(ttl), + }) + } + } + + // sort by cidr + sort.Sort(nets) + return nets, fall, nil +} diff --git a/plugin/reverse/setup_test.go b/plugin/reverse/setup_test.go new file mode 100644 index 000000000..5b4c04e82 --- /dev/null +++ b/plugin/reverse/setup_test.go @@ -0,0 +1,195 @@ +package reverse + +import ( + "net" + "reflect" + "regexp" + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupParse(t *testing.T) { + + _, net4, _ := net.ParseCIDR("10.1.1.0/24") + _, net6, _ := net.ParseCIDR("fd01::/64") + + regexIP4wildcard, _ := regexp.Compile("^.*ip-" + regexMatchV4 + "\\.domain\\.com\\.$") + regexIP6, _ := regexp.Compile("^ip-" + regexMatchV6 + "\\.domain\\.com\\.$") + regexIpv4dynamic, _ := regexp.Compile("^dynamic-" + regexMatchV4 + "-intern\\.dynamic\\.domain\\.com\\.$") + regexIpv6dynamic, _ := regexp.Compile("^dynamic-" + regexMatchV6 + "-intern\\.dynamic\\.domain\\.com\\.$") + regexIpv4vpndynamic, _ := regexp.Compile("^dynamic-" + regexMatchV4 + "-vpn\\.dynamic\\.domain\\.com\\.$") + + serverBlockKeys := []string{"domain.com.:8053", "dynamic.domain.com.:8053"} + + tests := []struct { + inputFileRules string + shouldErr bool + networks networks + }{ + { + // with defaults + `reverse fd01::/64`, + false, + networks{network{ + IPnet: net6, + Template: "ip-{ip}.domain.com.", + Zone: "domain.com.", + TTL: 60, + RegexMatchIP: regexIP6, + }}, + }, + { + `reverse`, + true, + networks{}, + }, + { + //no cidr + `reverse 10.1.1.1`, + true, + networks{}, + }, + { + //no cidr + `reverse 10.1.1.0/16 fd00::`, + true, + networks{}, + }, + { + // invalid key + `reverse 10.1.1.0/24 { + notavailable + }`, + true, + networks{}, + }, + { + // no domain suffix + `reverse 10.1.1.0/24 { + hostname ip-{ip}. + }`, + true, + networks{}, + }, + { + // hostname requires an second arg + `reverse 10.1.1.0/24 { + hostname + }`, + true, + networks{}, + }, + { + // template breaks regex compile + `reverse 10.1.1.0/24 { + hostname ip-{[-x + }`, + true, + networks{}, + }, + { + // ttl requires an (u)int + `reverse 10.1.1.0/24 { + ttl string + }`, + true, + networks{}, + }, + { + `reverse fd01::/64 { + hostname dynamic-{ip}-intern.{zone[2]} + ttl 50 + } + reverse 10.1.1.0/24 { + hostname dynamic-{ip}-vpn.{zone[2]} + fallthrough + }`, + false, + networks{network{ + IPnet: net6, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 50, + RegexMatchIP: regexIpv6dynamic, + }, network{ + IPnet: net4, + Template: "dynamic-{ip}-vpn.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 60, + RegexMatchIP: regexIpv4vpndynamic, + }}, + }, + { + // multiple networks in one stanza + `reverse fd01::/64 10.1.1.0/24 { + hostname dynamic-{ip}-intern.{zone[2]} + ttl 50 + fallthrough + }`, + false, + networks{network{ + IPnet: net6, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 50, + RegexMatchIP: regexIpv6dynamic, + }, network{ + IPnet: net4, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 50, + RegexMatchIP: regexIpv4dynamic, + }}, + }, + { + // fix domain in template + `reverse fd01::/64 { + hostname dynamic-{ip}-intern.dynamic.domain.com + ttl 300 + fallthrough + }`, + false, + networks{network{ + IPnet: net6, + Template: "dynamic-{ip}-intern.dynamic.domain.com.", + Zone: "dynamic.domain.com.", + TTL: 300, + RegexMatchIP: regexIpv6dynamic, + }}, + }, + { + `reverse 10.1.1.0/24 { + hostname ip-{ip}.{zone[1]} + ttl 50 + wildcard + fallthrough + }`, + false, + networks{network{ + IPnet: net4, + Template: "ip-{ip}.domain.com.", + Zone: "domain.com.", + TTL: 50, + RegexMatchIP: regexIP4wildcard, + }}, + }, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + c.ServerBlockKeys = serverBlockKeys + networks, _, err := reverseParse(c) + + if err == nil && test.shouldErr { + t.Fatalf("Test %d expected errors, but got no error", i) + } else if err != nil && !test.shouldErr { + t.Fatalf("Test %d expected no errors, but got '%v'", i, err) + } + for j, n := range networks { + reflect.DeepEqual(test.networks[j], n) + if !reflect.DeepEqual(test.networks[j], n) { + t.Fatalf("Test %d/%d expected %v, got %v", i, j, test.networks[j], n) + } + } + } +} diff --git a/plugin/rewrite/README.md b/plugin/rewrite/README.md new file mode 100644 index 000000000..63334d09c --- /dev/null +++ b/plugin/rewrite/README.md @@ -0,0 +1,91 @@ +# rewrite + +*rewrite* performs internal message rewriting. + +Rewrites are invisible to the client. There are simple rewrites (fast) and complex rewrites +(slower), but they're powerful enough to accommodate most dynamic back-end applications. + +## Syntax + +~~~ +rewrite FIELD FROM TO +~~~ + +* **FIELD** is (`type`, `class`, `name`, ...) +* **FROM** is the exact name of type to match +* **TO** is the destination name or type to rewrite to + +When the FIELD is `type` and FROM is (`A`, `MX`, etc.), the type of the message will be rewritten; +e.g., to rewrite ANY queries to HINFO, use `rewrite type ANY HINFO`. + +When the FIELD is `class` and FROM is (`IN`, `CH`, or `HS`) the class of the message will be +rewritten; e.g., to rewrite CH queries to IN use `rewrite class CH IN`. + +When the FIELD is `name` the query name in the message is rewritten; this +needs to be a full match of the name, e.g., `rewrite name miek.nl example.org`. + +When the FIELD is `edns0` an EDNS0 option can be appended to the request as described below. + +If you specify multiple rules and an incoming query matches on multiple (simple) rules, only +the first rewrite is applied. + +## EDNS0 Options + +Using FIELD edns0, you can set, append, or replace specific EDNS0 options on the request. + +* `replace` will modify any matching (what that means may vary based on EDNS0 type) option with the specified option +* `append` will add the option regardless of what options already exist +* `set` will modify a matching option or add one if none is found + +Currently supported are `EDNS0_LOCAL`, `EDNS0_NSID` and `EDNS0_SUBNET`. + +### `EDNS0_LOCAL` + +This has two fields, code and data. A match is defined as having the same code. Data may be a string or a variable. + +* A string data can be treated as hex if it starts with `0x`. Example: + +~~~ +rewrite edns0 local set 0xffee 0x61626364 +~~~ + +rewrites the first local option with code 0xffee, setting the data to "abcd". Equivalent: + +~~~ +rewrite edns0 local set 0xffee abcd +~~~ + +* A variable data is specified with a pair of curly brackets `{}`. Following are the supported variables: + * {qname} + * {qtype} + * {client_ip} + * {client_port} + * {protocol} + * {server_ip} + * {server_port} + +Example: + +~~~ +rewrite edns0 local set 0xffee {client_ip} +~~~ + +### `EDNS0_NSID` + +This has no fields; it will add an NSID option with an empty string for the NSID. If the option already exists +and the action is `replace` or `set`, then the NSID in the option will be set to the empty string. + +### `EDNS0_SUBNET` + +This has two fields, IPv4 bitmask length and IPv6 bitmask length. The bitmask +length is used to extract the client subnet from the source IP address in the query. + +Example: + +~~~ + rewrite edns0 subnet set 24 56 +~~~ + +* If the query has source IP as IPv4, the first 24 bits in the IP will be the network subnet. +* If the query has source IP as IPv6, the first 56 bits in the IP will be the network subnet. + diff --git a/plugin/rewrite/class.go b/plugin/rewrite/class.go new file mode 100644 index 000000000..8cc7d26b7 --- /dev/null +++ b/plugin/rewrite/class.go @@ -0,0 +1,35 @@ +package rewrite + +import ( + "fmt" + "strings" + + "github.com/miekg/dns" +) + +type classRule struct { + fromClass, toClass uint16 +} + +func newClassRule(fromS, toS string) (Rule, error) { + var from, to uint16 + var ok bool + if from, ok = dns.StringToClass[strings.ToUpper(fromS)]; !ok { + return nil, fmt.Errorf("invalid class %q", strings.ToUpper(fromS)) + } + if to, ok = dns.StringToClass[strings.ToUpper(toS)]; !ok { + return nil, fmt.Errorf("invalid class %q", strings.ToUpper(toS)) + } + return &classRule{fromClass: from, toClass: to}, nil +} + +// Rewrite rewrites the the current request. +func (rule *classRule) Rewrite(w dns.ResponseWriter, r *dns.Msg) Result { + if rule.fromClass > 0 && rule.toClass > 0 { + if r.Question[0].Qclass == rule.fromClass { + r.Question[0].Qclass = rule.toClass + return RewriteDone + } + } + return RewriteIgnored +} diff --git a/plugin/rewrite/condition.go b/plugin/rewrite/condition.go new file mode 100644 index 000000000..2f20d71aa --- /dev/null +++ b/plugin/rewrite/condition.go @@ -0,0 +1,132 @@ +package rewrite + +import ( + "fmt" + "regexp" + "strings" + + "github.com/coredns/coredns/plugin/pkg/replacer" + + "github.com/miekg/dns" +) + +// Operators +const ( + Is = "is" + Not = "not" + Has = "has" + NotHas = "not_has" + StartsWith = "starts_with" + EndsWith = "ends_with" + Match = "match" + NotMatch = "not_match" +) + +func operatorError(operator string) error { + return fmt.Errorf("invalid operator %v", operator) +} + +func newReplacer(r *dns.Msg) replacer.Replacer { + return replacer.New(r, nil, "") +} + +// condition is a rewrite condition. +type condition func(string, string) bool + +var conditions = map[string]condition{ + Is: isFunc, + Not: notFunc, + Has: hasFunc, + NotHas: notHasFunc, + StartsWith: startsWithFunc, + EndsWith: endsWithFunc, + Match: matchFunc, + NotMatch: notMatchFunc, +} + +// isFunc is condition for Is operator. +// It checks for equality. +func isFunc(a, b string) bool { + return a == b +} + +// notFunc is condition for Not operator. +// It checks for inequality. +func notFunc(a, b string) bool { + return a != b +} + +// hasFunc is condition for Has operator. +// It checks if b is a substring of a. +func hasFunc(a, b string) bool { + return strings.Contains(a, b) +} + +// notHasFunc is condition for NotHas operator. +// It checks if b is not a substring of a. +func notHasFunc(a, b string) bool { + return !strings.Contains(a, b) +} + +// startsWithFunc is condition for StartsWith operator. +// It checks if b is a prefix of a. +func startsWithFunc(a, b string) bool { + return strings.HasPrefix(a, b) +} + +// endsWithFunc is condition for EndsWith operator. +// It checks if b is a suffix of a. +func endsWithFunc(a, b string) bool { + // TODO(miek): IsSubDomain + return strings.HasSuffix(a, b) +} + +// matchFunc is condition for Match operator. +// It does regexp matching of a against pattern in b +// and returns if they match. +func matchFunc(a, b string) bool { + matched, _ := regexp.MatchString(b, a) + return matched +} + +// notMatchFunc is condition for NotMatch operator. +// It does regexp matching of a against pattern in b +// and returns if they do not match. +func notMatchFunc(a, b string) bool { + matched, _ := regexp.MatchString(b, a) + return !matched +} + +// If is statement for a rewrite condition. +type If struct { + A string + Operator string + B string +} + +// True returns true if the condition is true and false otherwise. +// If r is not nil, it replaces placeholders before comparison. +func (i If) True(r *dns.Msg) bool { + if c, ok := conditions[i.Operator]; ok { + a, b := i.A, i.B + if r != nil { + replacer := newReplacer(r) + a = replacer.Replace(i.A) + b = replacer.Replace(i.B) + } + return c(a, b) + } + return false +} + +// NewIf creates a new If condition. +func NewIf(a, operator, b string) (If, error) { + if _, ok := conditions[operator]; !ok { + return If{}, operatorError(operator) + } + return If{ + A: a, + Operator: operator, + B: b, + }, nil +} diff --git a/plugin/rewrite/condition_test.go b/plugin/rewrite/condition_test.go new file mode 100644 index 000000000..91004f9d7 --- /dev/null +++ b/plugin/rewrite/condition_test.go @@ -0,0 +1,102 @@ +package rewrite + +/* +func TestConditions(t *testing.T) { + tests := []struct { + condition string + isTrue bool + }{ + {"a is b", false}, + {"a is a", true}, + {"a not b", true}, + {"a not a", false}, + {"a has a", true}, + {"a has b", false}, + {"ba has b", true}, + {"bab has b", true}, + {"bab has bb", false}, + {"a not_has a", false}, + {"a not_has b", true}, + {"ba not_has b", false}, + {"bab not_has b", false}, + {"bab not_has bb", true}, + {"bab starts_with bb", false}, + {"bab starts_with ba", true}, + {"bab starts_with bab", true}, + {"bab ends_with bb", false}, + {"bab ends_with bab", true}, + {"bab ends_with ab", true}, + {"a match *", false}, + {"a match a", true}, + {"a match .*", true}, + {"a match a.*", true}, + {"a match b.*", false}, + {"ba match b.*", true}, + {"ba match b[a-z]", true}, + {"b0 match b[a-z]", false}, + {"b0a match b[a-z]", false}, + {"b0a match b[a-z]+", false}, + {"b0a match b[a-z0-9]+", true}, + {"a not_match *", true}, + {"a not_match a", false}, + {"a not_match .*", false}, + {"a not_match a.*", false}, + {"a not_match b.*", true}, + {"ba not_match b.*", false}, + {"ba not_match b[a-z]", false}, + {"b0 not_match b[a-z]", true}, + {"b0a not_match b[a-z]", true}, + {"b0a not_match b[a-z]+", true}, + {"b0a not_match b[a-z0-9]+", false}, + } + + for i, test := range tests { + str := strings.Fields(test.condition) + ifCond, err := NewIf(str[0], str[1], str[2]) + if err != nil { + t.Error(err) + } + isTrue := ifCond.True(nil) + if isTrue != test.isTrue { + t.Errorf("Test %v: expected %v found %v", i, test.isTrue, isTrue) + } + } + + invalidOperators := []string{"ss", "and", "if"} + for _, op := range invalidOperators { + _, err := NewIf("a", op, "b") + if err == nil { + t.Errorf("Invalid operator %v used, expected error.", op) + } + } + + replaceTests := []struct { + url string + condition string + isTrue bool + }{ + {"/home", "{uri} match /home", true}, + {"/hom", "{uri} match /home", false}, + {"/hom", "{uri} starts_with /home", false}, + {"/hom", "{uri} starts_with /h", true}, + {"/home/.hiddenfile", `{uri} match \/\.(.*)`, true}, + {"/home/.hiddendir/afile", `{uri} match \/\.(.*)`, true}, + } + + for i, test := range replaceTests { + r, err := http.NewRequest("GET", test.url, nil) + if err != nil { + t.Error(err) + } + str := strings.Fields(test.condition) + ifCond, err := NewIf(str[0], str[1], str[2]) + if err != nil { + t.Error(err) + } + isTrue := ifCond.True(r) + if isTrue != test.isTrue { + t.Errorf("Test %v: expected %v found %v", i, test.isTrue, isTrue) + } + } +} +*/ diff --git a/plugin/rewrite/edns0.go b/plugin/rewrite/edns0.go new file mode 100644 index 000000000..d8b6f4128 --- /dev/null +++ b/plugin/rewrite/edns0.go @@ -0,0 +1,425 @@ +// Package rewrite is plugin for rewriting requests internally to something different. +package rewrite + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "strconv" + "strings" + + "github.com/coredns/coredns/request" + "github.com/miekg/dns" +) + +// edns0LocalRule is a rewrite rule for EDNS0_LOCAL options +type edns0LocalRule struct { + action string + code uint16 + data []byte +} + +// edns0VariableRule is a rewrite rule for EDNS0_LOCAL options with variable +type edns0VariableRule struct { + action string + code uint16 + variable string +} + +// ends0NsidRule is a rewrite rule for EDNS0_NSID options +type edns0NsidRule struct { + action string +} + +// setupEdns0Opt will retrieve the EDNS0 OPT or create it if it does not exist +func setupEdns0Opt(r *dns.Msg) *dns.OPT { + o := r.IsEdns0() + if o == nil { + r.SetEdns0(4096, true) + o = r.IsEdns0() + } + return o +} + +// Rewrite will alter the request EDNS0 NSID option +func (rule *edns0NsidRule) Rewrite(w dns.ResponseWriter, r *dns.Msg) Result { + result := RewriteIgnored + o := setupEdns0Opt(r) + found := false +Option: + for _, s := range o.Option { + switch e := s.(type) { + case *dns.EDNS0_NSID: + if rule.action == Replace || rule.action == Set { + e.Nsid = "" // make sure it is empty for request + result = RewriteDone + } + found = true + break Option + } + } + + // add option if not found + if !found && (rule.action == Append || rule.action == Set) { + o.SetDo() + o.Option = append(o.Option, &dns.EDNS0_NSID{Code: dns.EDNS0NSID, Nsid: ""}) + result = RewriteDone + } + + return result +} + +// Rewrite will alter the request EDNS0 local options +func (rule *edns0LocalRule) Rewrite(w dns.ResponseWriter, r *dns.Msg) Result { + result := RewriteIgnored + o := setupEdns0Opt(r) + found := false + for _, s := range o.Option { + switch e := s.(type) { + case *dns.EDNS0_LOCAL: + if rule.code == e.Code { + if rule.action == Replace || rule.action == Set { + e.Data = rule.data + result = RewriteDone + } + found = true + break + } + } + } + + // add option if not found + if !found && (rule.action == Append || rule.action == Set) { + o.SetDo() + var opt dns.EDNS0_LOCAL + opt.Code = rule.code + opt.Data = rule.data + o.Option = append(o.Option, &opt) + result = RewriteDone + } + + return result +} + +// newEdns0Rule creates an EDNS0 rule of the appropriate type based on the args +func newEdns0Rule(args ...string) (Rule, error) { + if len(args) < 2 { + return nil, fmt.Errorf("too few arguments for an EDNS0 rule") + } + + ruleType := strings.ToLower(args[0]) + action := strings.ToLower(args[1]) + switch action { + case Append: + case Replace: + case Set: + default: + return nil, fmt.Errorf("invalid action: %q", action) + } + + switch ruleType { + case "local": + if len(args) != 4 { + return nil, fmt.Errorf("EDNS0 local rules require exactly three args") + } + //Check for variable option + if strings.HasPrefix(args[3], "{") && strings.HasSuffix(args[3], "}") { + return newEdns0VariableRule(action, args[2], args[3]) + } + return newEdns0LocalRule(action, args[2], args[3]) + case "nsid": + if len(args) != 2 { + return nil, fmt.Errorf("EDNS0 NSID rules do not accept args") + } + return &edns0NsidRule{action: action}, nil + case "subnet": + if len(args) != 4 { + return nil, fmt.Errorf("EDNS0 subnet rules require exactly three args") + } + return newEdns0SubnetRule(action, args[2], args[3]) + default: + return nil, fmt.Errorf("invalid rule type %q", ruleType) + } +} + +func newEdns0LocalRule(action, code, data string) (*edns0LocalRule, error) { + c, err := strconv.ParseUint(code, 0, 16) + if err != nil { + return nil, err + } + + decoded := []byte(data) + if strings.HasPrefix(data, "0x") { + decoded, err = hex.DecodeString(data[2:]) + if err != nil { + return nil, err + } + } + return &edns0LocalRule{action: action, code: uint16(c), data: decoded}, nil +} + +// newEdns0VariableRule creates an EDNS0 rule that handles variable substitution +func newEdns0VariableRule(action, code, variable string) (*edns0VariableRule, error) { + c, err := strconv.ParseUint(code, 0, 16) + if err != nil { + return nil, err + } + //Validate + if !isValidVariable(variable) { + return nil, fmt.Errorf("unsupported variable name %q", variable) + } + return &edns0VariableRule{action: action, code: uint16(c), variable: variable}, nil +} + +// ipToWire writes IP address to wire/binary format, 4 or 16 bytes depends on IPV4 or IPV6. +func (rule *edns0VariableRule) ipToWire(family int, ipAddr string) ([]byte, error) { + + switch family { + case 1: + return net.ParseIP(ipAddr).To4(), nil + case 2: + return net.ParseIP(ipAddr).To16(), nil + } + return nil, fmt.Errorf("Invalid IP address family (i.e. version) %d", family) +} + +// uint16ToWire writes unit16 to wire/binary format +func (rule *edns0VariableRule) uint16ToWire(data uint16) []byte { + buf := make([]byte, 2) + binary.BigEndian.PutUint16(buf, uint16(data)) + return buf +} + +// portToWire writes port to wire/binary format, 2 bytes +func (rule *edns0VariableRule) portToWire(portStr string) ([]byte, error) { + + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return nil, err + } + return rule.uint16ToWire(uint16(port)), nil +} + +// Family returns the family of the transport, 1 for IPv4 and 2 for IPv6. +func (rule *edns0VariableRule) family(ip net.Addr) int { + var a net.IP + if i, ok := ip.(*net.UDPAddr); ok { + a = i.IP + } + if i, ok := ip.(*net.TCPAddr); ok { + a = i.IP + } + if a.To4() != nil { + return 1 + } + return 2 +} + +// ruleData returns the data specified by the variable +func (rule *edns0VariableRule) ruleData(w dns.ResponseWriter, r *dns.Msg) ([]byte, error) { + + req := request.Request{W: w, Req: r} + switch rule.variable { + case queryName: + //Query name is written as ascii string + return []byte(req.QName()), nil + + case queryType: + return rule.uint16ToWire(req.QType()), nil + + case clientIP: + return rule.ipToWire(req.Family(), req.IP()) + + case clientPort: + return rule.portToWire(req.Port()) + + case protocol: + // Proto is written as ascii string + return []byte(req.Proto()), nil + + case serverIP: + ip, _, err := net.SplitHostPort(w.LocalAddr().String()) + if err != nil { + ip = w.RemoteAddr().String() + } + return rule.ipToWire(rule.family(w.RemoteAddr()), ip) + + case serverPort: + _, port, err := net.SplitHostPort(w.LocalAddr().String()) + if err != nil { + port = "0" + } + return rule.portToWire(port) + } + + return nil, fmt.Errorf("Unable to extract data for variable %s", rule.variable) +} + +// Rewrite will alter the request EDNS0 local options with specified variables +func (rule *edns0VariableRule) Rewrite(w dns.ResponseWriter, r *dns.Msg) Result { + result := RewriteIgnored + + data, err := rule.ruleData(w, r) + if err != nil || data == nil { + return result + } + + o := setupEdns0Opt(r) + found := false + for _, s := range o.Option { + switch e := s.(type) { + case *dns.EDNS0_LOCAL: + if rule.code == e.Code { + if rule.action == Replace || rule.action == Set { + e.Data = data + result = RewriteDone + } + found = true + break + } + } + } + + // add option if not found + if !found && (rule.action == Append || rule.action == Set) { + o.SetDo() + var opt dns.EDNS0_LOCAL + opt.Code = rule.code + opt.Data = data + o.Option = append(o.Option, &opt) + result = RewriteDone + } + + return result +} + +func isValidVariable(variable string) bool { + switch variable { + case + queryName, + queryType, + clientIP, + clientPort, + protocol, + serverIP, + serverPort: + return true + } + return false +} + +// ends0SubnetRule is a rewrite rule for EDNS0 subnet options +type edns0SubnetRule struct { + v4BitMaskLen uint8 + v6BitMaskLen uint8 + action string +} + +func newEdns0SubnetRule(action, v4BitMaskLen, v6BitMaskLen string) (*edns0SubnetRule, error) { + v4Len, err := strconv.ParseUint(v4BitMaskLen, 0, 16) + if err != nil { + return nil, err + } + // Validate V4 length + if v4Len > maxV4BitMaskLen { + return nil, fmt.Errorf("invalid IPv4 bit mask length %d", v4Len) + } + + v6Len, err := strconv.ParseUint(v6BitMaskLen, 0, 16) + if err != nil { + return nil, err + } + //Validate V6 length + if v6Len > maxV6BitMaskLen { + return nil, fmt.Errorf("invalid IPv6 bit mask length %d", v6Len) + } + + return &edns0SubnetRule{action: action, + v4BitMaskLen: uint8(v4Len), v6BitMaskLen: uint8(v6Len)}, nil +} + +// fillEcsData sets the subnet data into the ecs option +func (rule *edns0SubnetRule) fillEcsData(w dns.ResponseWriter, r *dns.Msg, + ecs *dns.EDNS0_SUBNET) error { + + req := request.Request{W: w, Req: r} + family := req.Family() + if (family != 1) && (family != 2) { + return fmt.Errorf("unable to fill data for EDNS0 subnet due to invalid IP family") + } + + ecs.DraftOption = false + ecs.Family = uint16(family) + ecs.SourceScope = 0 + + ipAddr := req.IP() + switch family { + case 1: + ipv4Mask := net.CIDRMask(int(rule.v4BitMaskLen), 32) + ipv4Addr := net.ParseIP(ipAddr) + ecs.SourceNetmask = rule.v4BitMaskLen + ecs.Address = ipv4Addr.Mask(ipv4Mask).To4() + case 2: + ipv6Mask := net.CIDRMask(int(rule.v6BitMaskLen), 128) + ipv6Addr := net.ParseIP(ipAddr) + ecs.SourceNetmask = rule.v6BitMaskLen + ecs.Address = ipv6Addr.Mask(ipv6Mask).To16() + } + return nil +} + +// Rewrite will alter the request EDNS0 subnet option +func (rule *edns0SubnetRule) Rewrite(w dns.ResponseWriter, r *dns.Msg) Result { + result := RewriteIgnored + o := setupEdns0Opt(r) + found := false + for _, s := range o.Option { + switch e := s.(type) { + case *dns.EDNS0_SUBNET: + if rule.action == Replace || rule.action == Set { + if rule.fillEcsData(w, r, e) == nil { + result = RewriteDone + } + } + found = true + break + } + } + + // add option if not found + if !found && (rule.action == Append || rule.action == Set) { + o.SetDo() + opt := dns.EDNS0_SUBNET{Code: dns.EDNS0SUBNET} + if rule.fillEcsData(w, r, &opt) == nil { + o.Option = append(o.Option, &opt) + result = RewriteDone + } + } + + return result +} + +// These are all defined actions. +const ( + Replace = "replace" + Set = "set" + Append = "append" +) + +// Supported local EDNS0 variables +const ( + queryName = "{qname}" + queryType = "{qtype}" + clientIP = "{client_ip}" + clientPort = "{client_port}" + protocol = "{protocol}" + serverIP = "{server_ip}" + serverPort = "{server_port}" +) + +// Subnet maximum bit mask length +const ( + maxV4BitMaskLen = 32 + maxV6BitMaskLen = 128 +) diff --git a/plugin/rewrite/name.go b/plugin/rewrite/name.go new file mode 100644 index 000000000..189133542 --- /dev/null +++ b/plugin/rewrite/name.go @@ -0,0 +1,24 @@ +package rewrite + +import ( + "github.com/coredns/coredns/plugin" + + "github.com/miekg/dns" +) + +type nameRule struct { + From, To string +} + +func newNameRule(from, to string) (Rule, error) { + return &nameRule{plugin.Name(from).Normalize(), plugin.Name(to).Normalize()}, nil +} + +// Rewrite rewrites the the current request. +func (rule *nameRule) Rewrite(w dns.ResponseWriter, r *dns.Msg) Result { + if rule.From == r.Question[0].Name { + r.Question[0].Name = rule.To + return RewriteDone + } + return RewriteIgnored +} diff --git a/plugin/rewrite/reverter.go b/plugin/rewrite/reverter.go new file mode 100644 index 000000000..400fb5fff --- /dev/null +++ b/plugin/rewrite/reverter.go @@ -0,0 +1,39 @@ +package rewrite + +import "github.com/miekg/dns" + +// ResponseReverter reverses the operations done on the question section of a packet. +// This is need because the client will otherwise disregards the response, i.e. +// dig will complain with ';; Question section mismatch: got miek.nl/HINFO/IN' +type ResponseReverter struct { + dns.ResponseWriter + original dns.Question +} + +// NewResponseReverter returns a pointer to a new ResponseReverter. +func NewResponseReverter(w dns.ResponseWriter, r *dns.Msg) *ResponseReverter { + return &ResponseReverter{ + ResponseWriter: w, + original: r.Question[0], + } +} + +// WriteMsg records the status code and calls the +// underlying ResponseWriter's WriteMsg method. +func (r *ResponseReverter) WriteMsg(res *dns.Msg) error { + res.Question[0] = r.original + return r.ResponseWriter.WriteMsg(res) +} + +// Write is a wrapper that records the size of the message that gets written. +func (r *ResponseReverter) Write(buf []byte) (int, error) { + n, err := r.ResponseWriter.Write(buf) + return n, err +} + +// Hijack implements dns.Hijacker. It simply wraps the underlying +// ResponseWriter's Hijack method if there is one, or returns an error. +func (r *ResponseReverter) Hijack() { + r.ResponseWriter.Hijack() + return +} diff --git a/plugin/rewrite/rewrite.go b/plugin/rewrite/rewrite.go new file mode 100644 index 000000000..d4931445c --- /dev/null +++ b/plugin/rewrite/rewrite.go @@ -0,0 +1,86 @@ +package rewrite + +import ( + "fmt" + "strings" + + "github.com/coredns/coredns/plugin" + + "github.com/miekg/dns" + + "golang.org/x/net/context" +) + +// Result is the result of a rewrite +type Result int + +const ( + // RewriteIgnored is returned when rewrite is not done on request. + RewriteIgnored Result = iota + // RewriteDone is returned when rewrite is done on request. + RewriteDone + // RewriteStatus is returned when rewrite is not needed and status code should be set + // for the request. + RewriteStatus +) + +// Rewrite is plugin to rewrite requests internally before being handled. +type Rewrite struct { + Next plugin.Handler + Rules []Rule + noRevert bool +} + +// ServeDNS implements the plugin.Handler interface. +func (rw Rewrite) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + wr := NewResponseReverter(w, r) + for _, rule := range rw.Rules { + switch result := rule.Rewrite(w, r); result { + case RewriteDone: + if rw.noRevert { + return plugin.NextOrFailure(rw.Name(), rw.Next, ctx, w, r) + } + return plugin.NextOrFailure(rw.Name(), rw.Next, ctx, wr, r) + case RewriteIgnored: + break + case RewriteStatus: + // only valid for complex rules. + // if cRule, ok := rule.(*ComplexRule); ok && cRule.Status != 0 { + // return cRule.Status, nil + // } + } + } + return plugin.NextOrFailure(rw.Name(), rw.Next, ctx, w, r) +} + +// Name implements the Handler interface. +func (rw Rewrite) Name() string { return "rewrite" } + +// Rule describes a rewrite rule. +type Rule interface { + // Rewrite rewrites the current request. + Rewrite(dns.ResponseWriter, *dns.Msg) Result +} + +func newRule(args ...string) (Rule, error) { + if len(args) == 0 { + return nil, fmt.Errorf("no rule type specified for rewrite") + } + + ruleType := strings.ToLower(args[0]) + if ruleType != "edns0" && len(args) != 3 { + return nil, fmt.Errorf("%s rules must have exactly two arguments", ruleType) + } + switch ruleType { + case "name": + return newNameRule(args[1], args[2]) + case "class": + return newClassRule(args[1], args[2]) + case "type": + return newTypeRule(args[1], args[2]) + case "edns0": + return newEdns0Rule(args[1:]...) + default: + return nil, fmt.Errorf("invalid rule type %q", args[0]) + } +} diff --git a/plugin/rewrite/rewrite_test.go b/plugin/rewrite/rewrite_test.go new file mode 100644 index 000000000..74a8594df --- /dev/null +++ b/plugin/rewrite/rewrite_test.go @@ -0,0 +1,532 @@ +package rewrite + +import ( + "bytes" + "reflect" + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func msgPrinter(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + w.WriteMsg(r) + return 0, nil +} + +func TestNewRule(t *testing.T) { + tests := []struct { + args []string + shouldError bool + expType reflect.Type + }{ + {[]string{}, true, nil}, + {[]string{"foo"}, true, nil}, + {[]string{"name"}, true, nil}, + {[]string{"name", "a.com"}, true, nil}, + {[]string{"name", "a.com", "b.com", "c.com"}, true, nil}, + {[]string{"name", "a.com", "b.com"}, false, reflect.TypeOf(&nameRule{})}, + {[]string{"type"}, true, nil}, + {[]string{"type", "a"}, true, nil}, + {[]string{"type", "any", "a", "a"}, true, nil}, + {[]string{"type", "any", "a"}, false, reflect.TypeOf(&typeRule{})}, + {[]string{"type", "XY", "WV"}, true, nil}, + {[]string{"type", "ANY", "WV"}, true, nil}, + {[]string{"class"}, true, nil}, + {[]string{"class", "IN"}, true, nil}, + {[]string{"class", "ch", "in", "in"}, true, nil}, + {[]string{"class", "ch", "in"}, false, reflect.TypeOf(&classRule{})}, + {[]string{"class", "XY", "WV"}, true, nil}, + {[]string{"class", "IN", "WV"}, true, nil}, + {[]string{"edns0"}, true, nil}, + {[]string{"edns0", "local"}, true, nil}, + {[]string{"edns0", "local", "set"}, true, nil}, + {[]string{"edns0", "local", "set", "0xffee"}, true, nil}, + {[]string{"edns0", "local", "set", "65518", "abcdefg"}, false, reflect.TypeOf(&edns0LocalRule{})}, + {[]string{"edns0", "local", "set", "0xffee", "abcdefg"}, false, reflect.TypeOf(&edns0LocalRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "abcdefg"}, false, reflect.TypeOf(&edns0LocalRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "abcdefg"}, false, reflect.TypeOf(&edns0LocalRule{})}, + {[]string{"edns0", "local", "foo", "0xffee", "abcdefg"}, true, nil}, + {[]string{"edns0", "local", "set", "0xffee", "0xabcdefg"}, true, nil}, + {[]string{"edns0", "nsid", "set", "junk"}, true, nil}, + {[]string{"edns0", "nsid", "set"}, false, reflect.TypeOf(&edns0NsidRule{})}, + {[]string{"edns0", "nsid", "append"}, false, reflect.TypeOf(&edns0NsidRule{})}, + {[]string{"edns0", "nsid", "replace"}, false, reflect.TypeOf(&edns0NsidRule{})}, + {[]string{"edns0", "nsid", "foo"}, true, nil}, + {[]string{"edns0", "local", "set", "0xffee", "{dummy}"}, true, nil}, + {[]string{"edns0", "local", "set", "0xffee", "{qname}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "set", "0xffee", "{qtype}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "set", "0xffee", "{client_ip}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "set", "0xffee", "{client_port}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "set", "0xffee", "{protocol}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "set", "0xffee", "{server_ip}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "set", "0xffee", "{server_port}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "{dummy}"}, true, nil}, + {[]string{"edns0", "local", "append", "0xffee", "{qname}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "{qtype}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "{client_ip}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "{client_port}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "{protocol}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "{server_ip}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "append", "0xffee", "{server_port}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "{dummy}"}, true, nil}, + {[]string{"edns0", "local", "replace", "0xffee", "{qname}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "{qtype}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "{client_ip}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "{client_port}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "{protocol}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "{server_ip}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "local", "replace", "0xffee", "{server_port}"}, false, reflect.TypeOf(&edns0VariableRule{})}, + {[]string{"edns0", "subnet", "set", "-1", "56"}, true, nil}, + {[]string{"edns0", "subnet", "set", "24", "-56"}, true, nil}, + {[]string{"edns0", "subnet", "set", "33", "56"}, true, nil}, + {[]string{"edns0", "subnet", "set", "24", "129"}, true, nil}, + {[]string{"edns0", "subnet", "set", "24", "56"}, false, reflect.TypeOf(&edns0SubnetRule{})}, + {[]string{"edns0", "subnet", "append", "24", "56"}, false, reflect.TypeOf(&edns0SubnetRule{})}, + {[]string{"edns0", "subnet", "replace", "24", "56"}, false, reflect.TypeOf(&edns0SubnetRule{})}, + } + + for i, tc := range tests { + r, err := newRule(tc.args...) + if err == nil && tc.shouldError { + t.Errorf("Test %d: expected error but got success", i) + } else if err != nil && !tc.shouldError { + t.Errorf("Test %d: expected success but got error: %s", i, err) + } + + if !tc.shouldError && reflect.TypeOf(r) != tc.expType { + t.Errorf("Test %d: expected %q but got %q", i, tc.expType, r) + } + } +} + +func TestRewrite(t *testing.T) { + rules := []Rule{} + r, _ := newNameRule("from.nl.", "to.nl.") + rules = append(rules, r) + r, _ = newClassRule("CH", "IN") + rules = append(rules, r) + r, _ = newTypeRule("ANY", "HINFO") + rules = append(rules, r) + + rw := Rewrite{ + Next: plugin.HandlerFunc(msgPrinter), + Rules: rules, + noRevert: true, + } + + tests := []struct { + from string + fromT uint16 + fromC uint16 + to string + toT uint16 + toC uint16 + }{ + {"from.nl.", dns.TypeA, dns.ClassINET, "to.nl.", dns.TypeA, dns.ClassINET}, + {"a.nl.", dns.TypeA, dns.ClassINET, "a.nl.", dns.TypeA, dns.ClassINET}, + {"a.nl.", dns.TypeA, dns.ClassCHAOS, "a.nl.", dns.TypeA, dns.ClassINET}, + {"a.nl.", dns.TypeANY, dns.ClassINET, "a.nl.", dns.TypeHINFO, dns.ClassINET}, + // name is rewritten, type is not. + {"from.nl.", dns.TypeANY, dns.ClassINET, "to.nl.", dns.TypeANY, dns.ClassINET}, + // name is not, type is, but class is, because class is the 2nd rule. + {"a.nl.", dns.TypeANY, dns.ClassCHAOS, "a.nl.", dns.TypeANY, dns.ClassINET}, + } + + ctx := context.TODO() + for i, tc := range tests { + m := new(dns.Msg) + m.SetQuestion(tc.from, tc.fromT) + m.Question[0].Qclass = tc.fromC + + rec := dnsrecorder.New(&test.ResponseWriter{}) + rw.ServeDNS(ctx, rec, m) + + resp := rec.Msg + if resp.Question[0].Name != tc.to { + t.Errorf("Test %d: Expected Name to be %q but was %q", i, tc.to, resp.Question[0].Name) + } + if resp.Question[0].Qtype != tc.toT { + t.Errorf("Test %d: Expected Type to be '%d' but was '%d'", i, tc.toT, resp.Question[0].Qtype) + } + if resp.Question[0].Qclass != tc.toC { + t.Errorf("Test %d: Expected Class to be '%d' but was '%d'", i, tc.toC, resp.Question[0].Qclass) + } + } +} + +func TestRewriteEDNS0Local(t *testing.T) { + rw := Rewrite{ + Next: plugin.HandlerFunc(msgPrinter), + noRevert: true, + } + + tests := []struct { + fromOpts []dns.EDNS0 + args []string + toOpts []dns.EDNS0 + }{ + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "0xabcdef"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte{0xab, 0xcd, 0xef}}}, + }, + { + []dns.EDNS0{}, + []string{"local", "append", "0xffee", "abcdefghijklmnop"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte("abcdefghijklmnop")}}, + }, + { + []dns.EDNS0{}, + []string{"local", "replace", "0xffee", "abcdefghijklmnop"}, + []dns.EDNS0{}, + }, + { + []dns.EDNS0{}, + []string{"nsid", "set"}, + []dns.EDNS0{&dns.EDNS0_NSID{Code: dns.EDNS0NSID, Nsid: ""}}, + }, + { + []dns.EDNS0{}, + []string{"nsid", "append"}, + []dns.EDNS0{&dns.EDNS0_NSID{Code: dns.EDNS0NSID, Nsid: ""}}, + }, + { + []dns.EDNS0{}, + []string{"nsid", "replace"}, + []dns.EDNS0{}, + }, + } + + ctx := context.TODO() + for i, tc := range tests { + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + m.Question[0].Qclass = dns.ClassINET + + r, err := newEdns0Rule(tc.args...) + if err != nil { + t.Errorf("Error creating test rule: %s", err) + continue + } + rw.Rules = []Rule{r} + + rec := dnsrecorder.New(&test.ResponseWriter{}) + rw.ServeDNS(ctx, rec, m) + + resp := rec.Msg + o := resp.IsEdns0() + if o == nil { + t.Errorf("Test %d: EDNS0 options not set", i) + continue + } + if !optsEqual(o.Option, tc.toOpts) { + t.Errorf("Test %d: Expected %v but got %v", i, tc.toOpts, o) + } + } +} + +func TestEdns0LocalMultiRule(t *testing.T) { + rules := []Rule{} + r, _ := newEdns0Rule("local", "replace", "0xffee", "abcdef") + rules = append(rules, r) + r, _ = newEdns0Rule("local", "set", "0xffee", "fedcba") + rules = append(rules, r) + + rw := Rewrite{ + Next: plugin.HandlerFunc(msgPrinter), + Rules: rules, + noRevert: true, + } + + tests := []struct { + fromOpts []dns.EDNS0 + toOpts []dns.EDNS0 + }{ + { + nil, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte("fedcba")}}, + }, + { + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte("foobar")}}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte("abcdef")}}, + }, + } + + ctx := context.TODO() + for i, tc := range tests { + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + m.Question[0].Qclass = dns.ClassINET + if tc.fromOpts != nil { + o := m.IsEdns0() + if o == nil { + m.SetEdns0(4096, true) + o = m.IsEdns0() + } + o.Option = append(o.Option, tc.fromOpts...) + } + rec := dnsrecorder.New(&test.ResponseWriter{}) + rw.ServeDNS(ctx, rec, m) + + resp := rec.Msg + o := resp.IsEdns0() + if o == nil { + t.Errorf("Test %d: EDNS0 options not set", i) + continue + } + if !optsEqual(o.Option, tc.toOpts) { + t.Errorf("Test %d: Expected %v but got %v", i, tc.toOpts, o) + } + } +} + +func optsEqual(a, b []dns.EDNS0) bool { + if len(a) != len(b) { + return false + } + for i := range a { + switch aa := a[i].(type) { + case *dns.EDNS0_LOCAL: + if bb, ok := b[i].(*dns.EDNS0_LOCAL); ok { + if aa.Code != bb.Code { + return false + } + if !bytes.Equal(aa.Data, bb.Data) { + return false + } + } else { + return false + } + case *dns.EDNS0_NSID: + if bb, ok := b[i].(*dns.EDNS0_NSID); ok { + if aa.Nsid != bb.Nsid { + return false + } + } else { + return false + } + case *dns.EDNS0_SUBNET: + if bb, ok := b[i].(*dns.EDNS0_SUBNET); ok { + if aa.Code != bb.Code { + return false + } + if aa.Family != bb.Family { + return false + } + if aa.SourceNetmask != bb.SourceNetmask { + return false + } + if aa.SourceScope != bb.SourceScope { + return false + } + if !bytes.Equal(aa.Address, bb.Address) { + return false + } + if aa.DraftOption != bb.DraftOption { + return false + } + } else { + return false + } + + default: + return false + } + } + return true +} + +func TestRewriteEDNS0LocalVariable(t *testing.T) { + rw := Rewrite{ + Next: plugin.HandlerFunc(msgPrinter), + noRevert: true, + } + + // test.ResponseWriter has the following values: + // The remote will always be 10.240.0.1 and port 40212. + // The local address is always 127.0.0.1 and port 53. + + tests := []struct { + fromOpts []dns.EDNS0 + args []string + toOpts []dns.EDNS0 + }{ + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "{qname}"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte("example.com.")}}, + }, + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "{qtype}"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte{0x00, 0x01}}}, + }, + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "{client_ip}"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte{0x0A, 0xF0, 0x00, 0x01}}}, + }, + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "{client_port}"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte{0x9D, 0x14}}}, + }, + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "{protocol}"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte("udp")}}, + }, + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "{server_ip}"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte{0x7F, 0x00, 0x00, 0x01}}}, + }, + { + []dns.EDNS0{}, + []string{"local", "set", "0xffee", "{server_port}"}, + []dns.EDNS0{&dns.EDNS0_LOCAL{Code: 0xffee, Data: []byte{0x00, 0x35}}}, + }, + } + + ctx := context.TODO() + for i, tc := range tests { + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + m.Question[0].Qclass = dns.ClassINET + + r, err := newEdns0Rule(tc.args...) + if err != nil { + t.Errorf("Error creating test rule: %s", err) + continue + } + rw.Rules = []Rule{r} + + rec := dnsrecorder.New(&test.ResponseWriter{}) + rw.ServeDNS(ctx, rec, m) + + resp := rec.Msg + o := resp.IsEdns0() + if o == nil { + t.Errorf("Test %d: EDNS0 options not set", i) + continue + } + if !optsEqual(o.Option, tc.toOpts) { + t.Errorf("Test %d: Expected %v but got %v", i, tc.toOpts, o) + } + } +} + +func TestRewriteEDNS0Subnet(t *testing.T) { + rw := Rewrite{ + Next: plugin.HandlerFunc(msgPrinter), + noRevert: true, + } + + tests := []struct { + writer dns.ResponseWriter + fromOpts []dns.EDNS0 + args []string + toOpts []dns.EDNS0 + }{ + { + &test.ResponseWriter{}, + []dns.EDNS0{}, + []string{"subnet", "set", "24", "56"}, + []dns.EDNS0{&dns.EDNS0_SUBNET{Code: 0x8, + Family: 0x1, + SourceNetmask: 0x18, + SourceScope: 0x0, + Address: []byte{0x0A, 0xF0, 0x00, 0x00}, + DraftOption: false}}, + }, + { + &test.ResponseWriter{}, + []dns.EDNS0{}, + []string{"subnet", "set", "32", "56"}, + []dns.EDNS0{&dns.EDNS0_SUBNET{Code: 0x8, + Family: 0x1, + SourceNetmask: 0x20, + SourceScope: 0x0, + Address: []byte{0x0A, 0xF0, 0x00, 0x01}, + DraftOption: false}}, + }, + { + &test.ResponseWriter{}, + []dns.EDNS0{}, + []string{"subnet", "set", "0", "56"}, + []dns.EDNS0{&dns.EDNS0_SUBNET{Code: 0x8, + Family: 0x1, + SourceNetmask: 0x0, + SourceScope: 0x0, + Address: []byte{0x00, 0x00, 0x00, 0x00}, + DraftOption: false}}, + }, + { + &test.ResponseWriter6{}, + []dns.EDNS0{}, + []string{"subnet", "set", "24", "56"}, + []dns.EDNS0{&dns.EDNS0_SUBNET{Code: 0x8, + Family: 0x2, + SourceNetmask: 0x38, + SourceScope: 0x0, + Address: []byte{0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + DraftOption: false}}, + }, + { + &test.ResponseWriter6{}, + []dns.EDNS0{}, + []string{"subnet", "set", "24", "128"}, + []dns.EDNS0{&dns.EDNS0_SUBNET{Code: 0x8, + Family: 0x2, + SourceNetmask: 0x80, + SourceScope: 0x0, + Address: []byte{0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x42, 0x00, 0xff, 0xfe, 0xca, 0x4c, 0x65}, + DraftOption: false}}, + }, + { + &test.ResponseWriter6{}, + []dns.EDNS0{}, + []string{"subnet", "set", "24", "0"}, + []dns.EDNS0{&dns.EDNS0_SUBNET{Code: 0x8, + Family: 0x2, + SourceNetmask: 0x0, + SourceScope: 0x0, + Address: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + DraftOption: false}}, + }, + } + + ctx := context.TODO() + for i, tc := range tests { + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + m.Question[0].Qclass = dns.ClassINET + + r, err := newEdns0Rule(tc.args...) + if err != nil { + t.Errorf("Error creating test rule: %s", err) + continue + } + rw.Rules = []Rule{r} + rec := dnsrecorder.New(tc.writer) + rw.ServeDNS(ctx, rec, m) + + resp := rec.Msg + o := resp.IsEdns0() + if o == nil { + t.Errorf("Test %d: EDNS0 options not set", i) + continue + } + if !optsEqual(o.Option, tc.toOpts) { + t.Errorf("Test %d: Expected %v but got %v", i, tc.toOpts, o) + } + } +} diff --git a/plugin/rewrite/setup.go b/plugin/rewrite/setup.go new file mode 100644 index 000000000..5954a3300 --- /dev/null +++ b/plugin/rewrite/setup.go @@ -0,0 +1,42 @@ +package rewrite + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("rewrite", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + rewrites, err := rewriteParse(c) + if err != nil { + return plugin.Error("rewrite", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return Rewrite{Next: next, Rules: rewrites} + }) + + return nil +} + +func rewriteParse(c *caddy.Controller) ([]Rule, error) { + var rules []Rule + + for c.Next() { + args := c.RemainingArgs() + rule, err := newRule(args...) + if err != nil { + return nil, err + } + rules = append(rules, rule) + } + return rules, nil +} diff --git a/plugin/rewrite/setup_test.go b/plugin/rewrite/setup_test.go new file mode 100644 index 000000000..67ef88e18 --- /dev/null +++ b/plugin/rewrite/setup_test.go @@ -0,0 +1,25 @@ +package rewrite + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestParse(t *testing.T) { + c := caddy.NewTestController("dns", `rewrite`) + _, err := rewriteParse(c) + if err == nil { + t.Errorf("Expected error but found nil for `rewrite`") + } + c = caddy.NewTestController("dns", `rewrite name`) + _, err = rewriteParse(c) + if err == nil { + t.Errorf("Expected error but found nil for `rewrite name`") + } + c = caddy.NewTestController("dns", `rewrite name a.com b.com`) + _, err = rewriteParse(c) + if err != nil { + t.Errorf("Expected success but found %s for `rewrite name a.com b.com`", err) + } +} diff --git a/plugin/rewrite/testdata/testdir/empty b/plugin/rewrite/testdata/testdir/empty new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/plugin/rewrite/testdata/testdir/empty diff --git a/plugin/rewrite/testdata/testfile b/plugin/rewrite/testdata/testfile new file mode 100644 index 000000000..7b4d68d70 --- /dev/null +++ b/plugin/rewrite/testdata/testfile @@ -0,0 +1 @@ +empty
\ No newline at end of file diff --git a/plugin/rewrite/type.go b/plugin/rewrite/type.go new file mode 100644 index 000000000..ae3efcc5a --- /dev/null +++ b/plugin/rewrite/type.go @@ -0,0 +1,37 @@ +// Package rewrite is plugin for rewriting requests internally to something different. +package rewrite + +import ( + "fmt" + "strings" + + "github.com/miekg/dns" +) + +// typeRule is a type rewrite rule. +type typeRule struct { + fromType, toType uint16 +} + +func newTypeRule(fromS, toS string) (Rule, error) { + var from, to uint16 + var ok bool + if from, ok = dns.StringToType[strings.ToUpper(fromS)]; !ok { + return nil, fmt.Errorf("invalid type %q", strings.ToUpper(fromS)) + } + if to, ok = dns.StringToType[strings.ToUpper(toS)]; !ok { + return nil, fmt.Errorf("invalid type %q", strings.ToUpper(toS)) + } + return &typeRule{fromType: from, toType: to}, nil +} + +// Rewrite rewrites the the current request. +func (rule *typeRule) Rewrite(w dns.ResponseWriter, r *dns.Msg) Result { + if rule.fromType > 0 && rule.toType > 0 { + if r.Question[0].Qtype == rule.fromType { + r.Question[0].Qtype = rule.toType + return RewriteDone + } + } + return RewriteIgnored +} diff --git a/plugin/root/README.md b/plugin/root/README.md new file mode 100644 index 000000000..bd3fe33b3 --- /dev/null +++ b/plugin/root/README.md @@ -0,0 +1,22 @@ +# root + +*root* simply specifies the root of where CoreDNS finds (e.g.) zone files. + +The default root is the current working directory of CoreDNS. A relative root path is relative to +the current working directory. + +## Syntax + +~~~ txt +root PATH +~~~ + +**PATH** is the directory to set as CoreDNS' root. + +## Examples + +Serve zone data (when the *file* plugin is used) from `/etc/coredns/zones`: + +~~~ txt +root /etc/coredns/zones +~~~ diff --git a/plugin/root/root.go b/plugin/root/root.go new file mode 100644 index 000000000..56fd42c01 --- /dev/null +++ b/plugin/root/root.go @@ -0,0 +1,43 @@ +package root + +import ( + "log" + "os" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("root", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + config := dnsserver.GetConfig(c) + + for c.Next() { + if !c.NextArg() { + return plugin.Error("root", c.ArgErr()) + } + config.Root = c.Val() + } + + // Check if root path exists + _, err := os.Stat(config.Root) + if err != nil { + if os.IsNotExist(err) { + // Allow this, because the folder might appear later. + // But make sure the user knows! + log.Printf("[WARNING] Root path does not exist: %s", config.Root) + } else { + return plugin.Error("root", c.Errf("unable to access root path '%s': %v", config.Root, err)) + } + } + + return nil +} diff --git a/plugin/root/root_test.go b/plugin/root/root_test.go new file mode 100644 index 000000000..ea0e53b5e --- /dev/null +++ b/plugin/root/root_test.go @@ -0,0 +1,107 @@ +package root + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/coredns/coredns/core/dnsserver" + + "github.com/mholt/caddy" +) + +func TestRoot(t *testing.T) { + log.SetOutput(ioutil.Discard) + + // Predefined error substrings + parseErrContent := "Error during parsing:" + unableToAccessErrContent := "unable to access root path" + + existingDirPath, err := getTempDirPath() + if err != nil { + t.Fatalf("BeforeTest: Failed to find an existing directory for testing! Error was: %v", err) + } + + nonExistingDir := filepath.Join(existingDirPath, "highly_unlikely_to_exist_dir") + + existingFile, err := ioutil.TempFile("", "root_test") + if err != nil { + t.Fatalf("BeforeTest: Failed to create temp file for testing! Error was: %v", err) + } + defer func() { + existingFile.Close() + os.Remove(existingFile.Name()) + }() + + inaccessiblePath := getInaccessiblePath(existingFile.Name()) + + tests := []struct { + input string + shouldErr bool + expectedRoot string // expected root, set to the controller. Empty for negative cases. + expectedErrContent string // substring from the expected error. Empty for positive cases. + }{ + // positive + { + fmt.Sprintf(`root %s`, nonExistingDir), false, nonExistingDir, "", + }, + { + fmt.Sprintf(`root %s`, existingDirPath), false, existingDirPath, "", + }, + // negative + { + `root `, true, "", parseErrContent, + }, + { + fmt.Sprintf(`root %s`, inaccessiblePath), true, "", unableToAccessErrContent, + }, + { + fmt.Sprintf(`root { + %s + }`, existingDirPath), true, "", parseErrContent, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + err := setup(c) + cfg := dnsserver.GetConfig(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + } + } + + // check root only if we are in a positive test. + if !test.shouldErr && test.expectedRoot != cfg.Root { + t.Errorf("Root not correctly set for input %s. Expected: %s, actual: %s", test.input, test.expectedRoot, cfg.Root) + } + } +} + +// getTempDirPath returnes the path to the system temp directory. If it does not exists - an error is returned. +func getTempDirPath() (string, error) { + tempDir := os.TempDir() + _, err := os.Stat(tempDir) + if err != nil { + return "", err + } + return tempDir, nil +} + +func getInaccessiblePath(file string) string { + return filepath.Join("C:", "file\x00name") // null byte in filename is not allowed on Windows AND unix +} diff --git a/plugin/secondary/README.md b/plugin/secondary/README.md new file mode 100644 index 000000000..d6cbe465a --- /dev/null +++ b/plugin/secondary/README.md @@ -0,0 +1,54 @@ +# secondary + +*secondary* enables serving a zone retrieved from a primary server. + +## Syntax + +~~~ +secondary [ZONES...] +~~~ + +* **ZONES** zones it should be authoritative for. If empty, the zones from the configuration block + are used. Note that without a remote address to *get* the zone from, the above is not that useful. + +A working syntax would be: + +~~~ +secondary [zones...] { + transfer from ADDRESS + transfer to ADDRESS + upstream ADDRESS... +} +~~~ + +* `transfer from` specifies from which address to fetch the zone. It can be specified multiple times; + if one does not work, another will be tried. +* `transfer to` can be enabled to allow this secondary zone to be transferred again. +* `upstream` defines upstream resolvers to be used resolve external names found (think CNAMEs) + pointing to external names. This is only really useful when CoreDNS is configured as a proxy, for + normal authoritative serving you don't need *or* want to use this. **ADDRESS** can be an IP + address, and IP:port or a string pointing to a file that is structured as /etc/resolv.conf. + +## Examples + +Transfer `example.org` from 10.0.1.1, and if that fails try 10.1.2.1. + +~~~ corefile +example.org { + secondary { + transfer from 10.0.1.1 + transfer from 10.1.2.1 + } +} +~~~ + +Or re-export the retrieved zone to other secondaries. + +~~~ corefile +. { + secondary example.net { + transfer from 10.1.2.1 + transfer to * + } +} +~~~ diff --git a/plugin/secondary/secondary.go b/plugin/secondary/secondary.go new file mode 100644 index 000000000..43934e80c --- /dev/null +++ b/plugin/secondary/secondary.go @@ -0,0 +1,10 @@ +// Package secondary implements a secondary plugin. +package secondary + +import "github.com/coredns/coredns/plugin/file" + +// Secondary implements a secondary plugin that allows CoreDNS to retrieve (via AXFR) +// zone information from a primary server. +type Secondary struct { + file.File +} diff --git a/plugin/secondary/setup.go b/plugin/secondary/setup.go new file mode 100644 index 000000000..e2819197d --- /dev/null +++ b/plugin/secondary/setup.go @@ -0,0 +1,108 @@ +package secondary + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/proxy" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("secondary", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + zones, err := secondaryParse(c) + if err != nil { + return plugin.Error("secondary", err) + } + + // Add startup functions to retrieve the zone and keep it up to date. + for _, n := range zones.Names { + z := zones.Z[n] + if len(z.TransferFrom) > 0 { + c.OnStartup(func() error { + z.StartupOnce.Do(func() { + z.TransferIn() + go func() { + z.Update() + }() + }) + return nil + }) + } + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return Secondary{file.File{Next: next, Zones: zones}} + }) + + return nil +} + +func secondaryParse(c *caddy.Controller) (file.Zones, error) { + z := make(map[string]*file.Zone) + names := []string{} + origins := []string{} + prxy := proxy.Proxy{} + for c.Next() { + + if c.Val() == "secondary" { + // secondary [origin] + origins = make([]string, len(c.ServerBlockKeys)) + copy(origins, c.ServerBlockKeys) + args := c.RemainingArgs() + if len(args) > 0 { + origins = args + } + for i := range origins { + origins[i] = plugin.Host(origins[i]).Normalize() + z[origins[i]] = file.NewZone(origins[i], "stdin") + names = append(names, origins[i]) + } + + for c.NextBlock() { + + t, f := []string{}, []string{} + var e error + + switch c.Val() { + case "transfer": + t, f, e = file.TransferParse(c, true) + if e != nil { + return file.Zones{}, e + } + case "upstream": + args := c.RemainingArgs() + if len(args) == 0 { + return file.Zones{}, c.ArgErr() + } + ups, err := dnsutil.ParseHostPortOrFile(args...) + if err != nil { + return file.Zones{}, err + } + prxy = proxy.NewLookup(ups) + default: + return file.Zones{}, c.Errf("unknown property '%s'", c.Val()) + } + + for _, origin := range origins { + if t != nil { + z[origin].TransferTo = append(z[origin].TransferTo, t...) + } + if f != nil { + z[origin].TransferFrom = append(z[origin].TransferFrom, f...) + } + z[origin].Proxy = prxy + } + } + } + } + return file.Zones{Z: z, Names: names}, nil +} diff --git a/plugin/secondary/setup_test.go b/plugin/secondary/setup_test.go new file mode 100644 index 000000000..bf2b203ad --- /dev/null +++ b/plugin/secondary/setup_test.go @@ -0,0 +1,65 @@ +package secondary + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestSecondaryParse(t *testing.T) { + tests := []struct { + inputFileRules string + shouldErr bool + transferFrom string + zones []string + }{ + { + `secondary`, + false, // TODO(miek): should actually be true, because without transfer lines this does not make sense + "", + nil, + }, + { + `secondary { + transfer from 127.0.0.1 + transfer to 127.0.0.1 + }`, + false, + "127.0.0.1:53", + nil, + }, + { + `secondary example.org { + transfer from 127.0.0.1 + transfer to 127.0.0.1 + }`, + false, + "127.0.0.1:53", + []string{"example.org."}, + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.inputFileRules) + s, err := secondaryParse(c) + + if err == nil && test.shouldErr { + t.Fatalf("Test %d expected errors, but got no error", i) + } else if err != nil && !test.shouldErr { + t.Fatalf("Test %d expected no errors, but got '%v'", i, err) + } + + for i, name := range test.zones { + if x := s.Names[i]; x != name { + t.Fatalf("Test %d zone names don't match expected %q, but got %q", i, name, x) + } + } + + // This is only set *iff* we have a zone (i.e. not in all tests above) + for _, v := range s.Z { + if x := v.TransferFrom[0]; x != test.transferFrom { + t.Fatalf("Test %d transform from names don't match expected %q, but got %q", i, test.transferFrom, x) + } + } + } +} diff --git a/plugin/test/doc.go b/plugin/test/doc.go new file mode 100644 index 000000000..75281ed8b --- /dev/null +++ b/plugin/test/doc.go @@ -0,0 +1,2 @@ +// Package test contains helper functions for writing plugin tests. +package test diff --git a/plugin/test/file.go b/plugin/test/file.go new file mode 100644 index 000000000..f87300e55 --- /dev/null +++ b/plugin/test/file.go @@ -0,0 +1,107 @@ +package test + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +// TempFile will create a temporary file on disk and returns the name and a cleanup function to remove it later. +func TempFile(dir, content string) (string, func(), error) { + f, err := ioutil.TempFile(dir, "go-test-tmpfile") + if err != nil { + return "", nil, err + } + if err := ioutil.WriteFile(f.Name(), []byte(content), 0644); err != nil { + return "", nil, err + } + rmFunc := func() { os.Remove(f.Name()) } + return f.Name(), rmFunc, nil +} + +// WritePEMFiles creates a tmp dir with ca.pem, cert.pem, and key.pem and the func to remove it +func WritePEMFiles(dir string) (string, func(), error) { + tempDir, err := ioutil.TempDir(dir, "go-test-pemfiles") + if err != nil { + return "", nil, err + } + + data := `-----BEGIN CERTIFICATE----- +MIIC9zCCAd+gAwIBAgIJALGtqdMzpDemMA0GCSqGSIb3DQEBCwUAMBIxEDAOBgNV +BAMMB2t1YmUtY2EwHhcNMTYxMDE5MTU1NDI0WhcNNDQwMzA2MTU1NDI0WjASMRAw +DgYDVQQDDAdrdWJlLWNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +pa4Wu/WkpJNRr8pMVE6jjwzNUOx5mIyoDr8WILSxVQcEeyVPPmAqbmYXtVZO11p9 +jTzoEqF7Kgts3HVYGCk5abqbE14a8Ru/DmV5avU2hJ/NvSjtNi/O+V6SzCbg5yR9 +lBR53uADDlzuJEQT9RHq7A5KitFkx4vUcXnjOQCbDogWFoYuOgNEwJPy0Raz3NJc +ViVfDqSJ0QHg02kCOMxcGFNRQ9F5aoW7QXZXZXD0tn3wLRlu4+GYyqt8fw5iNdLJ +t79yKp8I+vMTmMPz4YKUO+eCl5EY10Qs7wvoG/8QNbjH01BRN3L8iDT2WfxdvjTu +1RjPxFL92i+B7HZO7jGLfQIDAQABo1AwTjAdBgNVHQ4EFgQUZTrg+Xt87tkxDhlB +gKk9FdTOW3IwHwYDVR0jBBgwFoAUZTrg+Xt87tkxDhlBgKk9FdTOW3IwDAYDVR0T +BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEApB7JFVrZpGSOXNO3W7SlN6OCPXv9 +C7rIBc8rwOrzi2mZWcBmWheQrqBo8xHif2rlFNVQxtq3JcQ8kfg/m1fHeQ/Ygzel +Z+U1OqozynDySBZdNn9i+kXXgAUCqDPp3hEQWe0os/RRpIwo9yOloBxdiX6S0NIf +VB8n8kAynFPkH7pYrGrL1HQgDFCSfa4tUJ3+9sppnCu0pNtq5AdhYx9xFb2sn+8G +xGbtCkhVk2VQ+BiCWnjYXJ6ZMzabP7wiOFDP9Pvr2ik22PRItsW/TLfHFXM1jDmc +I1rs/VUGKzcJGVIWbHrgjP68CTStGAvKgbsTqw7aLXTSqtPw88N9XVSyRg== +-----END CERTIFICATE-----` + path := filepath.Join(tempDir, "ca.pem") + if err := ioutil.WriteFile(path, []byte(data), 0644); err != nil { + return "", nil, err + } + data = `-----BEGIN CERTIFICATE----- +MIICozCCAYsCCQCRlf5BrvPuqjANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdr +dWJlLWNhMB4XDTE2MTAxOTE2MDUxOFoXDTE3MTAxOTE2MDUxOFowFTETMBEGA1UE +AwwKa3ViZS1hZG1pbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMTw +a7wCFoiCad/N53aURfjrme+KR7FS0yf5Ur9OR/oM3BoS9stYu5Flzr35oL5T6t5G +c2ey78mUs/Cs07psnjUdKH55bDpJSdG7zW9mXNyeLwIefFcj/38SS5NBSotmLo8u +scJMGXeQpCQtfVuVJSP2bfU5u5d0KTLSg/Cor6UYonqrRB82HbOuuk8Wjaww4VHo +nCq7X8o948V6HN5ZibQOgMMo+nf0wORREHBjvwc4W7ewbaTcfoe1VNAo/QnkqxTF +ueMb2HxgghArqQSK8b44O05V0zrde25dVnmnte6sPjcV0plqMJ37jViISxsOPUFh +/ZW7zbIM/7CMcDekCiECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAYZE8OxwRR7GR +kdd5aIriDwWfcl56cq5ICyx87U8hAZhBxk46a6a901LZPzt3xKyWIFQSRj/NYiQ+ +/thjGLZI2lhkVgYtyAD4BNxDiuppQSCbkjY9tLVDdExGttEVN7+UYDWJBHy6X16Y +xSG9FE3Dvp9LI89Nq8E3dRh+Q8wu52q9HaQXjS5YtzQOtDFKPBkihXu/c6gEHj4Y +bZVk8rFiH8/CvcQxAuvNI3VVCFUKd2LeQtqwYQQ//qoiuA15krTq5Ut9eXJ8zxAw +zhDEPP4FhY+Sz+y1yWirphl7A1aZwhXVPcfWIGqpQ3jzNwUeocbH27kuLh+U4hQo +qeg10RdFnw== +-----END CERTIFICATE-----` + path = filepath.Join(tempDir, "cert.pem") + if err = ioutil.WriteFile(path, []byte(data), 0644); err != nil { + return "", nil, err + } + + data = `-----BEGIN RSA PRIVATE KEY----- +MIIEpgIBAAKCAQEAxPBrvAIWiIJp383ndpRF+OuZ74pHsVLTJ/lSv05H+gzcGhL2 +y1i7kWXOvfmgvlPq3kZzZ7LvyZSz8KzTumyeNR0ofnlsOklJ0bvNb2Zc3J4vAh58 +VyP/fxJLk0FKi2Yujy6xwkwZd5CkJC19W5UlI/Zt9Tm7l3QpMtKD8KivpRiieqtE +HzYds666TxaNrDDhUeicKrtfyj3jxXoc3lmJtA6Awyj6d/TA5FEQcGO/Bzhbt7Bt +pNx+h7VU0Cj9CeSrFMW54xvYfGCCECupBIrxvjg7TlXTOt17bl1Weae17qw+NxXS +mWownfuNWIhLGw49QWH9lbvNsgz/sIxwN6QKIQIDAQABAoIBAQDCXq9V7ZGjxWMN +OkFaLVkqJg3V91puztoMt+xNV8t+JTcOnOzrIXZuOFbl9PwLHPPP0SSRkm9LOvKl +dU26zv0OWureeKSymia7U2mcqyC3tX+bzc7WinbeSYZBnc0e7AjD1EgpBcaU1TLL +agIxY3A2oD9CKmrVPhZzTIZf/XztqTYjhvs5I2kBeT0imdYGpXkdndRyGX4I5/JQ +fnp3Czj+AW3zX7RvVnXOh4OtIAcfoG9xoNyD5LOSlJkkX0MwTS8pEBeZA+A4nb+C +ivjnOSgXWD+liisI+LpBgBbwYZ/E49x5ghZYrJt8QXSk7Bl/+UOyv6XZAm2mev6j +RLAZtoABAoGBAP2P+1PoKOwsk+d/AmHqyTCUQm0UG18LOLB/5PyWfXs/6caDmdIe +DZWeZWng1jUQLEadmoEw/CBY5+tPfHlzwzMNhT7KwUfIDQCIBoS7dzHYnwrJ3VZh +qYA05cuGHAAHqwb6UWz3y6Pa4AEVSHX6CM83CAi9jdWZ1rdZybWG+qYBAoGBAMbV +FsR/Ft+tK5ALgXGoG83TlmxzZYuZ1SnNje1OSdCQdMFCJB10gwoaRrw1ICzi40Xk +ydJwV1upGz1om9ReDAD1zQM9artmQx6+TVLiVPALuARdZE70+NrA6w3ZvxUgJjdN +ngvXUr+8SdvaYUAwFu7BulfJlwXjUS711hHW/KQhAoGBALY41QuV2mLwHlLNie7I +hlGtGpe9TXZeYB0nrG6B0CfU5LJPPSotguG1dXhDpm138/nDpZeWlnrAqdsHwpKd +yPhVjR51I7XsZLuvBdA50Q03egSM0c4UXXXPjh1XgaPb3uMi3YWMBwL4ducQXoS6 +bb5M9C8j2lxZNF+L3VPhbxwBAoGBAIEWDvX7XKpTDxkxnxRfA84ZNGusb5y2fsHp +Bd+vGBUj8+kUO8Yzwm9op8vA4ebCVrMl2jGZZd3IaDryE1lIxZpJ+pPD5+tKdQEc +o67P6jz+HrYWu+zW9klvPit71qasfKMi7Rza6oo4f+sQWFsH3ZucgpJD+pyD/Ez0 +pcpnPRaBAoGBANT/xgHBfIWt4U2rtmRLIIiZxKr+3mGnQdpA1J2BCh+/6AvrEx// +E/WObVJXDnBdViu0L9abE9iaTToBVri4cmlDlZagLuKVR+TFTCN/DSlVZTDkqkLI +8chzqtkH6b2b2R73hyRysWjsomys34ma3mEEPTX/aXeAF2MSZ/EWT9yL +-----END RSA PRIVATE KEY-----` + path = filepath.Join(tempDir, "key.pem") + if err = ioutil.WriteFile(path, []byte(data), 0644); err != nil { + return "", nil, err + } + + rmFunc := func() { os.RemoveAll(tempDir) } + return tempDir, rmFunc, nil +} diff --git a/plugin/test/file_test.go b/plugin/test/file_test.go new file mode 100644 index 000000000..ed86a8260 --- /dev/null +++ b/plugin/test/file_test.go @@ -0,0 +1,11 @@ +package test + +import "testing" + +func TestTempFile(t *testing.T) { + _, f, e := TempFile(".", "test") + if e != nil { + t.Fatalf("failed to create temp file: %s", e) + } + defer f() +} diff --git a/plugin/test/helpers.go b/plugin/test/helpers.go new file mode 100644 index 000000000..065cf8935 --- /dev/null +++ b/plugin/test/helpers.go @@ -0,0 +1,348 @@ +package test + +import ( + "sort" + "testing" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +type sect int + +const ( + // Answer is the answer section in an Msg. + Answer sect = iota + // Ns is the authoritative section in an Msg. + Ns + // Extra is the additional section in an Msg. + Extra +) + +// RRSet represents a list of RRs. +type RRSet []dns.RR + +func (p RRSet) Len() int { return len(p) } +func (p RRSet) Swap(i, j int) { p[i], p[j] = p[j], p[i] } +func (p RRSet) Less(i, j int) bool { return p[i].String() < p[j].String() } + +// Case represents a test case that encapsulates various data from a query and response. +// Note that is the TTL of a record is 303 we don't compare it with the TTL. +type Case struct { + Qname string + Qtype uint16 + Rcode int + Do bool + Answer []dns.RR + Ns []dns.RR + Extra []dns.RR + Error error +} + +// Msg returns a *dns.Msg embedded in c. +func (c Case) Msg() *dns.Msg { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(c.Qname), c.Qtype) + if c.Do { + o := new(dns.OPT) + o.Hdr.Name = "." + o.Hdr.Rrtype = dns.TypeOPT + o.SetDo() + o.SetUDPSize(4096) + m.Extra = []dns.RR{o} + } + return m +} + +// A returns an A record from rr. It panics on errors. +func A(rr string) *dns.A { r, _ := dns.NewRR(rr); return r.(*dns.A) } + +// AAAA returns an AAAA record from rr. It panics on errors. +func AAAA(rr string) *dns.AAAA { r, _ := dns.NewRR(rr); return r.(*dns.AAAA) } + +// CNAME returns a CNAME record from rr. It panics on errors. +func CNAME(rr string) *dns.CNAME { r, _ := dns.NewRR(rr); return r.(*dns.CNAME) } + +// DNAME returns a DNAME record from rr. It panics on errors. +func DNAME(rr string) *dns.DNAME { r, _ := dns.NewRR(rr); return r.(*dns.DNAME) } + +// SRV returns a SRV record from rr. It panics on errors. +func SRV(rr string) *dns.SRV { r, _ := dns.NewRR(rr); return r.(*dns.SRV) } + +// SOA returns a SOA record from rr. It panics on errors. +func SOA(rr string) *dns.SOA { r, _ := dns.NewRR(rr); return r.(*dns.SOA) } + +// NS returns an NS record from rr. It panics on errors. +func NS(rr string) *dns.NS { r, _ := dns.NewRR(rr); return r.(*dns.NS) } + +// PTR returns a PTR record from rr. It panics on errors. +func PTR(rr string) *dns.PTR { r, _ := dns.NewRR(rr); return r.(*dns.PTR) } + +// TXT returns a TXT record from rr. It panics on errors. +func TXT(rr string) *dns.TXT { r, _ := dns.NewRR(rr); return r.(*dns.TXT) } + +// MX returns an MX record from rr. It panics on errors. +func MX(rr string) *dns.MX { r, _ := dns.NewRR(rr); return r.(*dns.MX) } + +// RRSIG returns an RRSIG record from rr. It panics on errors. +func RRSIG(rr string) *dns.RRSIG { r, _ := dns.NewRR(rr); return r.(*dns.RRSIG) } + +// NSEC returns an NSEC record from rr. It panics on errors. +func NSEC(rr string) *dns.NSEC { r, _ := dns.NewRR(rr); return r.(*dns.NSEC) } + +// DNSKEY returns a DNSKEY record from rr. It panics on errors. +func DNSKEY(rr string) *dns.DNSKEY { r, _ := dns.NewRR(rr); return r.(*dns.DNSKEY) } + +// DS returns a DS record from rr. It panics on errors. +func DS(rr string) *dns.DS { r, _ := dns.NewRR(rr); return r.(*dns.DS) } + +// OPT returns an OPT record with UDP buffer size set to bufsize and the DO bit set to do. +func OPT(bufsize int, do bool) *dns.OPT { + o := new(dns.OPT) + o.Hdr.Name = "." + o.Hdr.Rrtype = dns.TypeOPT + o.SetVersion(0) + o.SetUDPSize(uint16(bufsize)) + if do { + o.SetDo() + } + return o +} + +// Header test if the header in resp matches the header as defined in tc. +func Header(t *testing.T, tc Case, resp *dns.Msg) bool { + if resp.Rcode != tc.Rcode { + t.Errorf("rcode is %q, expected %q", dns.RcodeToString[resp.Rcode], dns.RcodeToString[tc.Rcode]) + return false + } + + if len(resp.Answer) != len(tc.Answer) { + t.Errorf("answer for %q contained %d results, %d expected", tc.Qname, len(resp.Answer), len(tc.Answer)) + return false + } + if len(resp.Ns) != len(tc.Ns) { + t.Errorf("authority for %q contained %d results, %d expected", tc.Qname, len(resp.Ns), len(tc.Ns)) + return false + } + if len(resp.Extra) != len(tc.Extra) { + t.Errorf("additional for %q contained %d results, %d expected", tc.Qname, len(resp.Extra), len(tc.Extra)) + return false + } + return true +} + +// Section tests if the the section in tc matches rr. +func Section(t *testing.T, tc Case, sec sect, rr []dns.RR) bool { + section := []dns.RR{} + switch sec { + case 0: + section = tc.Answer + case 1: + section = tc.Ns + case 2: + section = tc.Extra + } + + for i, a := range rr { + if a.Header().Name != section[i].Header().Name { + t.Errorf("rr %d should have a Header Name of %q, but has %q", i, section[i].Header().Name, a.Header().Name) + return false + } + // 303 signals: don't care what the ttl is. + if section[i].Header().Ttl != 303 && a.Header().Ttl != section[i].Header().Ttl { + if _, ok := section[i].(*dns.OPT); !ok { + // we check edns0 bufize on this one + t.Errorf("rr %d should have a Header TTL of %d, but has %d", i, section[i].Header().Ttl, a.Header().Ttl) + return false + } + } + if a.Header().Rrtype != section[i].Header().Rrtype { + t.Errorf("rr %d should have a header rr type of %d, but has %d", i, section[i].Header().Rrtype, a.Header().Rrtype) + return false + } + + switch x := a.(type) { + case *dns.SRV: + if x.Priority != section[i].(*dns.SRV).Priority { + t.Errorf("rr %d should have a Priority of %d, but has %d", i, section[i].(*dns.SRV).Priority, x.Priority) + return false + } + if x.Weight != section[i].(*dns.SRV).Weight { + t.Errorf("rr %d should have a Weight of %d, but has %d", i, section[i].(*dns.SRV).Weight, x.Weight) + return false + } + if x.Port != section[i].(*dns.SRV).Port { + t.Errorf("rr %d should have a Port of %d, but has %d", i, section[i].(*dns.SRV).Port, x.Port) + return false + } + if x.Target != section[i].(*dns.SRV).Target { + t.Errorf("rr %d should have a Target of %q, but has %q", i, section[i].(*dns.SRV).Target, x.Target) + return false + } + case *dns.RRSIG: + if x.TypeCovered != section[i].(*dns.RRSIG).TypeCovered { + t.Errorf("rr %d should have a TypeCovered of %d, but has %d", i, section[i].(*dns.RRSIG).TypeCovered, x.TypeCovered) + return false + } + if x.Labels != section[i].(*dns.RRSIG).Labels { + t.Errorf("rr %d should have a Labels of %d, but has %d", i, section[i].(*dns.RRSIG).Labels, x.Labels) + return false + } + if x.SignerName != section[i].(*dns.RRSIG).SignerName { + t.Errorf("rr %d should have a SignerName of %s, but has %s", i, section[i].(*dns.RRSIG).SignerName, x.SignerName) + return false + } + case *dns.NSEC: + if x.NextDomain != section[i].(*dns.NSEC).NextDomain { + t.Errorf("rr %d should have a NextDomain of %s, but has %s", i, section[i].(*dns.NSEC).NextDomain, x.NextDomain) + return false + } + // TypeBitMap + case *dns.A: + if x.A.String() != section[i].(*dns.A).A.String() { + t.Errorf("rr %d should have a Address of %q, but has %q", i, section[i].(*dns.A).A.String(), x.A.String()) + return false + } + case *dns.AAAA: + if x.AAAA.String() != section[i].(*dns.AAAA).AAAA.String() { + t.Errorf("rr %d should have a Address of %q, but has %q", i, section[i].(*dns.AAAA).AAAA.String(), x.AAAA.String()) + return false + } + case *dns.TXT: + for j, txt := range x.Txt { + if txt != section[i].(*dns.TXT).Txt[j] { + t.Errorf("rr %d should have a Txt of %q, but has %q", i, section[i].(*dns.TXT).Txt[j], txt) + return false + } + } + case *dns.SOA: + tt := section[i].(*dns.SOA) + if x.Ns != tt.Ns { + t.Errorf("SOA nameserver should be %q, but is %q", tt.Ns, x.Ns) + return false + } + case *dns.PTR: + tt := section[i].(*dns.PTR) + if x.Ptr != tt.Ptr { + t.Errorf("PTR ptr should be %q, but is %q", tt.Ptr, x.Ptr) + return false + } + case *dns.CNAME: + tt := section[i].(*dns.CNAME) + if x.Target != tt.Target { + t.Errorf("CNAME target should be %q, but is %q", tt.Target, x.Target) + return false + } + case *dns.MX: + tt := section[i].(*dns.MX) + if x.Mx != tt.Mx { + t.Errorf("MX Mx should be %q, but is %q", tt.Mx, x.Mx) + return false + } + if x.Preference != tt.Preference { + t.Errorf("MX Preference should be %q, but is %q", tt.Preference, x.Preference) + return false + } + case *dns.NS: + tt := section[i].(*dns.NS) + if x.Ns != tt.Ns { + t.Errorf("NS nameserver should be %q, but is %q", tt.Ns, x.Ns) + return false + } + case *dns.OPT: + tt := section[i].(*dns.OPT) + if x.UDPSize() != tt.UDPSize() { + t.Errorf("OPT UDPSize should be %d, but is %d", tt.UDPSize(), x.UDPSize()) + return false + } + if x.Do() != tt.Do() { + t.Errorf("OPT DO should be %t, but is %t", tt.Do(), x.Do()) + return false + } + } + } + return true +} + +// CNAMEOrder makes sure that CNAMES do not appear after their target records +func CNAMEOrder(t *testing.T, res *dns.Msg) { + for i, c := range res.Answer { + if c.Header().Rrtype != dns.TypeCNAME { + continue + } + for _, a := range res.Answer[:i] { + if a.Header().Name != c.(*dns.CNAME).Target { + continue + } + t.Errorf("CNAME found after target record\n") + t.Logf("%v\n", res) + + } + } +} + +// SortAndCheck sorts resp and the checks the header and three sections against the testcase in tc. +func SortAndCheck(t *testing.T, resp *dns.Msg, tc Case) { + sort.Sort(RRSet(resp.Answer)) + sort.Sort(RRSet(resp.Ns)) + sort.Sort(RRSet(resp.Extra)) + + if !Header(t, tc, resp) { + t.Logf("%v\n", resp) + return + } + + if !Section(t, tc, Answer, resp.Answer) { + t.Logf("%v\n", resp) + return + } + if !Section(t, tc, Ns, resp.Ns) { + t.Logf("%v\n", resp) + return + + } + if !Section(t, tc, Extra, resp.Extra) { + t.Logf("%v\n", resp) + return + } + return +} + +// ErrorHandler returns a Handler that returns ServerFailure error when called. +func ErrorHandler() Handler { + return HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + m := new(dns.Msg) + m.SetRcode(r, dns.RcodeServerFailure) + w.WriteMsg(m) + return dns.RcodeServerFailure, nil + }) +} + +// NextHandler returns a Handler that returns rcode and err. +func NextHandler(rcode int, err error) Handler { + return HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + return rcode, err + }) +} + +// Copied here to prevent an import cycle, so that we can define to above handlers. + +type ( + // HandlerFunc is a convenience type like dns.HandlerFunc, except + // ServeDNS returns an rcode and an error. + HandlerFunc func(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) + + // Handler interface defines a plugin. + Handler interface { + ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) + Name() string + } +) + +// ServeDNS implements the Handler interface. +func (f HandlerFunc) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + return f(ctx, w, r) +} + +// Name implements the Handler interface. +func (f HandlerFunc) Name() string { return "handlerfunc" } diff --git a/plugin/test/responsewriter.go b/plugin/test/responsewriter.go new file mode 100644 index 000000000..79eaa00f3 --- /dev/null +++ b/plugin/test/responsewriter.go @@ -0,0 +1,61 @@ +package test + +import ( + "net" + + "github.com/miekg/dns" +) + +// ResponseWriter is useful for writing tests. It uses some fixed values for the client. The +// remote will always be 10.240.0.1 and port 40212. The local address is always 127.0.0.1 and +// port 53. +type ResponseWriter struct{} + +// LocalAddr returns the local address, always 127.0.0.1:53 (UDP). +func (t *ResponseWriter) LocalAddr() net.Addr { + ip := net.ParseIP("127.0.0.1") + port := 53 + return &net.UDPAddr{IP: ip, Port: port, Zone: ""} +} + +// RemoteAddr returns the remote address, always 10.240.0.1:40212 (UDP). +func (t *ResponseWriter) RemoteAddr() net.Addr { + ip := net.ParseIP("10.240.0.1") + port := 40212 + return &net.UDPAddr{IP: ip, Port: port, Zone: ""} +} + +// WriteMsg implement dns.ResponseWriter interface. +func (t *ResponseWriter) WriteMsg(m *dns.Msg) error { return nil } + +// Write implement dns.ResponseWriter interface. +func (t *ResponseWriter) Write(buf []byte) (int, error) { return len(buf), nil } + +// Close implement dns.ResponseWriter interface. +func (t *ResponseWriter) Close() error { return nil } + +// TsigStatus implement dns.ResponseWriter interface. +func (t *ResponseWriter) TsigStatus() error { return nil } + +// TsigTimersOnly implement dns.ResponseWriter interface. +func (t *ResponseWriter) TsigTimersOnly(bool) { return } + +// Hijack implement dns.ResponseWriter interface. +func (t *ResponseWriter) Hijack() { return } + +// RepsponseWrite6 returns fixed client and remote address in IPv6. The remote +// address is always fe80::42:ff:feca:4c65 and port 40212. The local address +// is always ::1 and port 53. +type ResponseWriter6 struct { + ResponseWriter +} + +// LocalAddr returns the local address, always ::1, port 53 (UDP). +func (t *ResponseWriter6) LocalAddr() net.Addr { + return &net.UDPAddr{IP: net.ParseIP("::1"), Port: 53, Zone: ""} +} + +// RemoteAddr returns the remote address, always fe80::42:ff:feca:4c65 port 40212 (UDP). +func (t *ResponseWriter6) RemoteAddr() net.Addr { + return &net.UDPAddr{IP: net.ParseIP("fe80::42:ff:feca:4c65"), Port: 40212, Zone: ""} +} diff --git a/plugin/test/server.go b/plugin/test/server.go new file mode 100644 index 000000000..eb39c7a5b --- /dev/null +++ b/plugin/test/server.go @@ -0,0 +1,52 @@ +package test + +import ( + "net" + "sync" + "time" + + "github.com/miekg/dns" +) + +// TCPServer starts a DNS server with a TCP listener on laddr. +func TCPServer(laddr string) (*dns.Server, string, error) { + l, err := net.Listen("tcp", laddr) + if err != nil { + return nil, "", err + } + + server := &dns.Server{Listener: l, ReadTimeout: time.Hour, WriteTimeout: time.Hour} + + waitLock := sync.Mutex{} + waitLock.Lock() + server.NotifyStartedFunc = func() { waitLock.Unlock() } + + go func() { + server.ActivateAndServe() + l.Close() + }() + + waitLock.Lock() + return server, l.Addr().String(), nil +} + +// UDPServer starts a DNS server with an UDP listener on laddr. +func UDPServer(laddr string) (*dns.Server, string, error) { + pc, err := net.ListenPacket("udp", laddr) + if err != nil { + return nil, "", err + } + server := &dns.Server{PacketConn: pc, ReadTimeout: time.Hour, WriteTimeout: time.Hour} + + waitLock := sync.Mutex{} + waitLock.Lock() + server.NotifyStartedFunc = func() { waitLock.Unlock() } + + go func() { + server.ActivateAndServe() + pc.Close() + }() + + waitLock.Lock() + return server, pc.LocalAddr().String(), nil +} diff --git a/plugin/tls/README.md b/plugin/tls/README.md new file mode 100644 index 000000000..d2a56f793 --- /dev/null +++ b/plugin/tls/README.md @@ -0,0 +1,52 @@ +# tls + +*tls* allows you to configure the server certificates for the TLS and gRPC servers. +For other types of servers it is ignored. + +CoreDNS supports queries that are encrypted using TLS (DNS over Transport Layer Security, RFC 7858) +or are using gRPC (https://grpc.io/, not an IETF standard). Normally DNS traffic isn't encrypted at +all (DNSSEC only signs resource records). + +The *proxy* plugin also support gRPC (`protocol gRPC`), meaning you can chain CoreDNS servers +using this protocol. + +The *tls* "plugin" allows you to configure the cryptographic keys that are needed for both +DNS-over-TLS and DNS-over-gRPC. If the `tls` directive is omitted, then no encryption takes place. + +The gRPC protobuffer is defined in `pb/dns.proto`. It defines the proto as a simple wrapper for the +wire data of a DNS message. + +## Syntax + +~~~ txt +tls CERT KEY CA +~~~ + +## Examples + +Start a DNS-over-TLS server that picks up incoming DNS-over-TLS queries on port 5553 and uses the +nameservers defined in `/etc/resolv.conf` to resolve the query. This proxy path uses plain old DNS. + +~~~ +tls://.:5553 { + tls cert.pem key.pem ca.pem + proxy . /etc/resolv.conf +} +~~~ + +Start a DNS-over-gRPC server that is similar to the previous example, but using DNS-over-gRPC for +incoming queries. + +~~~ +grpc://. { + tls cert.pem key.pem ca.pem + proxy . /etc/resolv.conf +} +~~~ + +Only Knot DNS' `kdig` supports DNS-over-TLS queries, no command line client supports gRPC making +debugging these transports harder than it should be. + +## Also See + +RFC 7858 and https://grpc.io. diff --git a/plugin/tls/tls.go b/plugin/tls/tls.go new file mode 100644 index 000000000..e0958a9aa --- /dev/null +++ b/plugin/tls/tls.go @@ -0,0 +1,37 @@ +package tls + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/tls" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("tls", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + config := dnsserver.GetConfig(c) + + if config.TLSConfig != nil { + return plugin.Error("tls", c.Errf("TLS already configured for this server instance")) + } + + for c.Next() { + args := c.RemainingArgs() + if len(args) != 3 { + return plugin.Error("tls", c.ArgErr()) + } + tls, err := tls.NewTLSConfig(args[0], args[1], args[2]) + if err != nil { + return plugin.Error("tls", err) + } + config.TLSConfig = tls + } + return nil +} diff --git a/plugin/tls/tls_test.go b/plugin/tls/tls_test.go new file mode 100644 index 000000000..2374d772c --- /dev/null +++ b/plugin/tls/tls_test.go @@ -0,0 +1,44 @@ +package tls + +import ( + "io/ioutil" + "log" + "strings" + "testing" + + "github.com/mholt/caddy" +) + +func TestTLS(t *testing.T) { + log.SetOutput(ioutil.Discard) + + tests := []struct { + input string + shouldErr bool + expectedRoot string // expected root, set to the controller. Empty for negative cases. + expectedErrContent string // substring from the expected error. Empty for positive cases. + }{ + // positive + // negative + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + err := setup(c) + //cfg := dnsserver.GetConfig(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input) + } + } + } +} diff --git a/plugin/trace/README.md b/plugin/trace/README.md new file mode 100644 index 000000000..62e6d463d --- /dev/null +++ b/plugin/trace/README.md @@ -0,0 +1,73 @@ +# trace + +This module enables OpenTracing-based tracing of DNS requests as they go through the +plugin chain. + +## Syntax + +The simplest form is just: + +~~~ +trace [ENDPOINT-TYPE] [ENDPOINT] +~~~ + +* **ENDPOINT-TYPE** is the type of tracing destination. Currently only `zipkin` is supported + and that is what it defaults to. +* **ENDPOINT** is the tracing destination, and defaults to `localhost:9411`. For Zipkin, if + ENDPOINT does not begin with `http`, then it will be transformed to `http://ENDPOINT/api/v1/spans`. + +With this form, all queries will be traced. + +Additional features can be enabled with this syntax: + +~~~ +trace [ENDPOINT-TYPE] [ENDPOINT] { + every AMOUNT + service NAME + client_server +} +~~~ + +* `every` **AMOUNT** will only trace one query of each AMOUNT queries. For example, to trace 1 in every + 100 queries, use AMOUNT of 100. The default is 1. +* `service` **NAME** allows you to specify the service name reported to the tracing server. + Default is `coredns`. +* `client_server` will enable the `ClientServerSameSpan` OpenTracing feature. + +## Zipkin +You can run Zipkin on a Docker host like this: + +``` +docker run -d -p 9411:9411 openzipkin/zipkin +``` + +## Examples + +Use an alternative Zipkin address: + +~~~ +trace tracinghost:9253 +~~~ + +or + +~~~ +trace zipkin tracinghost:9253 +~~~ + +If for some reason you are using an API reverse proxy or something and need to remap +the standard Zipkin URL you can do something like: + +~~~ +trace http://tracinghost:9411/zipkin/api/v1/spans +~~~ + +Trace one query every 10000 queries, rename the service, and enable same span: + +~~~ +trace tracinghost:9411 { + every 10000 + service dnsproxy + client_server +} +~~~ diff --git a/plugin/trace/setup.go b/plugin/trace/setup.go new file mode 100644 index 000000000..5c6e473c3 --- /dev/null +++ b/plugin/trace/setup.go @@ -0,0 +1,113 @@ +package trace + +import ( + "fmt" + "strconv" + "strings" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("trace", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + t, err := traceParse(c) + if err != nil { + return plugin.Error("trace", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + t.Next = next + return t + }) + + c.OnStartup(t.OnStartup) + + return nil +} + +func traceParse(c *caddy.Controller) (*trace, error) { + var ( + tr = &trace{Endpoint: defEP, EndpointType: defEpType, every: 1, serviceName: defServiceName} + err error + ) + + cfg := dnsserver.GetConfig(c) + tr.ServiceEndpoint = cfg.ListenHost + ":" + cfg.Port + for c.Next() { // trace + var err error + args := c.RemainingArgs() + switch len(args) { + case 0: + tr.Endpoint, err = normalizeEndpoint(tr.EndpointType, defEP) + case 1: + tr.Endpoint, err = normalizeEndpoint(defEpType, args[0]) + case 2: + tr.EndpointType = strings.ToLower(args[0]) + tr.Endpoint, err = normalizeEndpoint(tr.EndpointType, args[1]) + default: + err = c.ArgErr() + } + if err != nil { + return tr, err + } + for c.NextBlock() { + switch c.Val() { + case "every": + args := c.RemainingArgs() + if len(args) != 1 { + return nil, c.ArgErr() + } + tr.every, err = strconv.ParseUint(args[0], 10, 64) + if err != nil { + return nil, err + } + case "service": + args := c.RemainingArgs() + if len(args) != 1 { + return nil, c.ArgErr() + } + tr.serviceName = args[0] + case "client_server": + args := c.RemainingArgs() + if len(args) > 1 { + return nil, c.ArgErr() + } + tr.clientServer = true + if len(args) == 1 { + tr.clientServer, err = strconv.ParseBool(args[0]) + } + if err != nil { + return nil, err + } + } + } + } + return tr, err +} + +func normalizeEndpoint(epType, ep string) (string, error) { + switch epType { + case "zipkin": + if !strings.Contains(ep, "http") { + ep = "http://" + ep + "/api/v1/spans" + } + return ep, nil + default: + return "", fmt.Errorf("tracing endpoint type '%s' is not supported", epType) + } +} + +const ( + defEP = "localhost:9411" + defEpType = "zipkin" + defServiceName = "coredns" +) diff --git a/plugin/trace/setup_test.go b/plugin/trace/setup_test.go new file mode 100644 index 000000000..3c12b76e4 --- /dev/null +++ b/plugin/trace/setup_test.go @@ -0,0 +1,60 @@ +package trace + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestTraceParse(t *testing.T) { + tests := []struct { + input string + shouldErr bool + endpoint string + every uint64 + serviceName string + clientServer bool + }{ + // oks + {`trace`, false, "http://localhost:9411/api/v1/spans", 1, `coredns`, false}, + {`trace localhost:1234`, false, "http://localhost:1234/api/v1/spans", 1, `coredns`, false}, + {`trace http://localhost:1234/somewhere/else`, false, "http://localhost:1234/somewhere/else", 1, `coredns`, false}, + {`trace zipkin localhost:1234`, false, "http://localhost:1234/api/v1/spans", 1, `coredns`, false}, + {`trace zipkin http://localhost:1234/somewhere/else`, false, "http://localhost:1234/somewhere/else", 1, `coredns`, false}, + {"trace {\n every 100\n}", false, "http://localhost:9411/api/v1/spans", 100, `coredns`, false}, + {"trace {\n every 100\n service foobar\nclient_server\n}", false, "http://localhost:9411/api/v1/spans", 100, `foobar`, true}, + {"trace {\n every 2\n client_server true\n}", false, "http://localhost:9411/api/v1/spans", 2, `coredns`, true}, + {"trace {\n client_server false\n}", false, "http://localhost:9411/api/v1/spans", 1, `coredns`, false}, + // fails + {`trace footype localhost:4321`, true, "", 1, "", false}, + {"trace {\n every 2\n client_server junk\n}", true, "", 1, "", false}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + m, err := traceParse(c) + if test.shouldErr && err == nil { + t.Errorf("Test %v: Expected error but found nil", i) + continue + } else if !test.shouldErr && err != nil { + t.Errorf("Test %v: Expected no error but found error: %v", i, err) + continue + } + + if test.shouldErr { + continue + } + + if test.endpoint != m.Endpoint { + t.Errorf("Test %v: Expected endpoint %s but found: %s", i, test.endpoint, m.Endpoint) + } + if test.every != m.every { + t.Errorf("Test %v: Expected every %d but found: %d", i, test.every, m.every) + } + if test.serviceName != m.serviceName { + t.Errorf("Test %v: Expected service name %s but found: %s", i, test.serviceName, m.serviceName) + } + if test.clientServer != m.clientServer { + t.Errorf("Test %v: Expected client_server %t but found: %t", i, test.clientServer, m.clientServer) + } + } +} diff --git a/plugin/trace/trace.go b/plugin/trace/trace.go new file mode 100644 index 000000000..fa561945e --- /dev/null +++ b/plugin/trace/trace.go @@ -0,0 +1,84 @@ +// Package trace implements OpenTracing-based tracing +package trace + +import ( + "fmt" + "sync" + "sync/atomic" + + "github.com/coredns/coredns/plugin" + // Plugin the trace package. + _ "github.com/coredns/coredns/plugin/pkg/trace" + + "github.com/miekg/dns" + ot "github.com/opentracing/opentracing-go" + zipkin "github.com/openzipkin/zipkin-go-opentracing" + "golang.org/x/net/context" +) + +type trace struct { + Next plugin.Handler + ServiceEndpoint string + Endpoint string + EndpointType string + tracer ot.Tracer + serviceName string + clientServer bool + every uint64 + count uint64 + Once sync.Once +} + +func (t *trace) Tracer() ot.Tracer { + return t.tracer +} + +// OnStartup sets up the tracer +func (t *trace) OnStartup() error { + var err error + t.Once.Do(func() { + switch t.EndpointType { + case "zipkin": + err = t.setupZipkin() + default: + err = fmt.Errorf("unknown endpoint type: %s", t.EndpointType) + } + }) + return err +} + +func (t *trace) setupZipkin() error { + + collector, err := zipkin.NewHTTPCollector(t.Endpoint) + if err != nil { + return err + } + + recorder := zipkin.NewRecorder(collector, false, t.ServiceEndpoint, t.serviceName) + t.tracer, err = zipkin.NewTracer(recorder, zipkin.ClientServerSameSpan(t.clientServer)) + + return err +} + +// Name implements the Handler interface. +func (t *trace) Name() string { + return "trace" +} + +// ServeDNS implements the plugin.Handle interface. +func (t *trace) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + trace := false + if t.every > 0 { + queryNr := atomic.AddUint64(&t.count, 1) + + if queryNr%t.every == 0 { + trace = true + } + } + if span := ot.SpanFromContext(ctx); span == nil && trace { + span := t.Tracer().StartSpan("servedns") + defer span.Finish() + ctx = ot.ContextWithSpan(ctx, span) + } + return plugin.NextOrFailure(t.Name(), t.Next, ctx, w, r) +} diff --git a/plugin/trace/trace_test.go b/plugin/trace/trace_test.go new file mode 100644 index 000000000..b006009c3 --- /dev/null +++ b/plugin/trace/trace_test.go @@ -0,0 +1,33 @@ +package trace + +import ( + "testing" + + "github.com/mholt/caddy" +) + +// createTestTrace creates a trace plugin to be used in tests +func createTestTrace(config string) (*caddy.Controller, *trace, error) { + c := caddy.NewTestController("dns", config) + m, err := traceParse(c) + return c, m, err +} + +func TestTrace(t *testing.T) { + _, m, err := createTestTrace(`trace`) + if err != nil { + t.Errorf("Error parsing test input: %s", err) + return + } + if m.Name() != "trace" { + t.Errorf("Wrong name from GetName: %s", m.Name()) + } + err = m.OnStartup() + if err != nil { + t.Errorf("Error starting tracing plugin: %s", err) + return + } + if m.Tracer() == nil { + t.Errorf("Error, no tracer created") + } +} diff --git a/plugin/whoami/README.md b/plugin/whoami/README.md new file mode 100644 index 000000000..d16a93766 --- /dev/null +++ b/plugin/whoami/README.md @@ -0,0 +1,44 @@ +# whoami + +*whoami* returns your resolver's local IP address, port and transport. Your IP address is returned + in the additional section as either an A or AAAA record. + +The reply always has an empty answer section. The port and transport are included in the additional +section as a SRV record, transport can be "tcp" or "udp". + +~~~ txt +._<transport>.qname. 0 IN SRV 0 0 <port> . +~~~ + +If CoreDNS can't find a Corefile on startup this is the *default* plugin that gets loaded. As +such it can be used to check that CoreDNS is responding to queries. Other than that this plugin +is of limited use in production. + +The *whoami* plugin will respond to every A or AAAA query, regardless of the query name. + +## Syntax + +~~~ txt +whoami +~~~ + +## Examples + +Start a server on the default port and load the *whoami* plugin. + +~~~ corefile +. { + whoami +} +~~~ + +When queried for "example.org A", CoreDNS will respond with: + +~~~ txt +;; QUESTION SECTION: +;example.org. IN A + +;; ADDITIONAL SECTION: +example.org. 0 IN A 10.240.0.1 +_udp.example.org. 0 IN SRV 0 0 40212 +~~~ diff --git a/plugin/whoami/setup.go b/plugin/whoami/setup.go new file mode 100644 index 000000000..9797c8bbf --- /dev/null +++ b/plugin/whoami/setup.go @@ -0,0 +1,28 @@ +package whoami + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("whoami", caddy.Plugin{ + ServerType: "dns", + Action: setupWhoami, + }) +} + +func setupWhoami(c *caddy.Controller) error { + c.Next() // 'whoami' + if c.NextArg() { + return plugin.Error("whoami", c.ArgErr()) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + return Whoami{} + }) + + return nil +} diff --git a/plugin/whoami/setup_test.go b/plugin/whoami/setup_test.go new file mode 100644 index 000000000..73db67d88 --- /dev/null +++ b/plugin/whoami/setup_test.go @@ -0,0 +1,19 @@ +package whoami + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupWhoami(t *testing.T) { + c := caddy.NewTestController("dns", `whoami`) + if err := setupWhoami(c); err != nil { + t.Fatalf("Expected no errors, but got: %v", err) + } + + c = caddy.NewTestController("dns", `whoami example.org`) + if err := setupWhoami(c); err == nil { + t.Fatalf("Expected errors, but got: %v", err) + } +} diff --git a/plugin/whoami/whoami.go b/plugin/whoami/whoami.go new file mode 100644 index 000000000..9d22c43a8 --- /dev/null +++ b/plugin/whoami/whoami.go @@ -0,0 +1,57 @@ +// Package whoami implements a plugin that returns details about the resolving +// querying it. +package whoami + +import ( + "net" + "strconv" + + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Whoami is a plugin that returns your IP address, port and the protocol used for connecting +// to CoreDNS. +type Whoami struct{} + +// ServeDNS implements the plugin.Handler interface. +func (wh Whoami) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + a := new(dns.Msg) + a.SetReply(r) + a.Compress = true + a.Authoritative = true + + ip := state.IP() + var rr dns.RR + + switch state.Family() { + case 1: + rr = new(dns.A) + rr.(*dns.A).Hdr = dns.RR_Header{Name: state.QName(), Rrtype: dns.TypeA, Class: state.QClass()} + rr.(*dns.A).A = net.ParseIP(ip).To4() + case 2: + rr = new(dns.AAAA) + rr.(*dns.AAAA).Hdr = dns.RR_Header{Name: state.QName(), Rrtype: dns.TypeAAAA, Class: state.QClass()} + rr.(*dns.AAAA).AAAA = net.ParseIP(ip) + } + + srv := new(dns.SRV) + srv.Hdr = dns.RR_Header{Name: "_" + state.Proto() + "." + state.QName(), Rrtype: dns.TypeSRV, Class: state.QClass()} + port, _ := strconv.Atoi(state.Port()) + srv.Port = uint16(port) + srv.Target = "." + + a.Extra = []dns.RR{rr, srv} + + state.SizeAndDo(a) + w.WriteMsg(a) + + return 0, nil +} + +// Name implements the Handler interface. +func (wh Whoami) Name() string { return "whoami" } diff --git a/plugin/whoami/whoami_test.go b/plugin/whoami/whoami_test.go new file mode 100644 index 000000000..c8e57f80c --- /dev/null +++ b/plugin/whoami/whoami_test.go @@ -0,0 +1,56 @@ +package whoami + +import ( + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestWhoami(t *testing.T) { + wh := Whoami{} + + tests := []struct { + qname string + qtype uint16 + expectedCode int + expectedReply []string // ownernames for the records in the additional section. + expectedErr error + }{ + { + qname: "example.org", + qtype: dns.TypeA, + expectedCode: dns.RcodeSuccess, + expectedReply: []string{"example.org.", "_udp.example.org."}, + expectedErr: nil, + }, + } + + ctx := context.TODO() + + for i, tc := range tests { + req := new(dns.Msg) + req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype) + + rec := dnsrecorder.New(&test.ResponseWriter{}) + code, err := wh.ServeDNS(ctx, rec, req) + + if err != tc.expectedErr { + t.Errorf("Test %d: Expected error %v, but got %v", i, tc.expectedErr, err) + } + if code != int(tc.expectedCode) { + t.Errorf("Test %d: Expected status code %d, but got %d", i, tc.expectedCode, code) + } + if len(tc.expectedReply) != 0 { + for i, expected := range tc.expectedReply { + actual := rec.Msg.Extra[i].Header().Name + if actual != expected { + t.Errorf("Test %d: Expected answer %s, but got %s", i, expected, actual) + } + } + } + } +} |