diff options
Diffstat (limited to 'plugin/autopath')
-rw-r--r-- | plugin/autopath/README.md | 45 | ||||
-rw-r--r-- | plugin/autopath/autopath.go | 152 | ||||
-rw-r--r-- | plugin/autopath/autopath_test.go | 166 | ||||
-rw-r--r-- | plugin/autopath/cname.go | 25 | ||||
-rw-r--r-- | plugin/autopath/setup.go | 93 | ||||
-rw-r--r-- | plugin/autopath/setup_test.go | 77 |
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 +` |