aboutsummaryrefslogtreecommitdiff
path: root/plugin/autopath
diff options
context:
space:
mode:
Diffstat (limited to 'plugin/autopath')
-rw-r--r--plugin/autopath/README.md45
-rw-r--r--plugin/autopath/autopath.go152
-rw-r--r--plugin/autopath/autopath_test.go166
-rw-r--r--plugin/autopath/cname.go25
-rw-r--r--plugin/autopath/setup.go93
-rw-r--r--plugin/autopath/setup_test.go77
6 files changed, 558 insertions, 0 deletions
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
+`