WIP: autopath as middleware (#859)

autopath as middleware
This commit is contained in:
Miek Gieben
2017-08-09 03:13:38 -07:00
committed by GitHub
parent c3705ec68c
commit b46b9880bd
20 changed files with 597 additions and 642 deletions

View File

@@ -0,0 +1,39 @@
# autopath
The *autopath* middleware 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
middleware. For instance `@kubernetes`, will call out to the kubernetes middleware (for each
query) to retrieve the search list it should use.
Currently the following set of middleware has implemented *autopath*:
* *kubernetes*
## 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 middleware.

View File

@@ -0,0 +1,142 @@
package autopath
/*
Autopath 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 middleware
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 wont 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
AutoPathFunc. Note the searchpath must be ending with the empty string.
I.e:
func (m Middleware ) AutoPath(state request.Request) ([]string, error) {
return []string{"first", "second", "last", ""}, nil
}
*/
import (
"errors"
"github.com/coredns/coredns/middleware"
"github.com/coredns/coredns/middleware/pkg/dnsutil"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
"golang.org/x/net/context"
)
// AutoPathFunc defines the function middleware should implement to return a search
// path to the autopath middleware. The last element of the slice must be the empty string.
// If AutoPathFunc returns a non-nil error no autopathing is performed.
type AutoPathFunc func(request.Request) ([]string, error)
// Autopath perform autopath: service side search path completion.
type AutoPath struct {
Next middleware.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 AutoPathFunc
}
func (a *AutoPath) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
state := request.Request{W: w, Req: r}
if state.QClass() != dns.ClassINET {
return dns.RcodeServerFailure, middleware.Error(a.Name(), errors.New("can only deal with ClassINET"))
}
// 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, err = a.searchFunc(state)
if err != nil {
return middleware.NextOrFailure(a.Name(), a.Next, ctx, w, r)
}
}
match := a.FirstInSearchPath(state.Name())
if !match {
return middleware.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(), a.search[0]) // TOD(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
// 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
r.Question[0].Name = newQName
nw := NewNonWriter(w)
rcode, err := middleware.NextOrFailure(a.Name(), a.Next, ctx, nw, r)
if i == 0 {
firstReply = nw.Msg
firstRcode = rcode
firstErr = err
}
if !middleware.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 middleware.ClientWrite(firstRcode) {
w.WriteMsg(firstReply)
}
return firstRcode, firstErr
}
// FirstInSearchPath checks if name is equal to are a sibling of the first element in the search path.
func (a *AutoPath) FirstInSearchPath(name string) bool {
if name == a.search[0] {
return true
}
if dns.IsSubDomain(a.search[0], name) {
return true
}
return false
}
func (a AutoPath) Name() string { return "autopath" }

View File

@@ -0,0 +1,165 @@
package autopath
import (
"testing"
"github.com/coredns/coredns/middleware"
"github.com/coredns/coredns/middleware/pkg/dnsrecorder"
"github.com/coredns/coredns/middleware/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.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
}
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 middleware.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")
}
return dns.RcodeServerFailure, nil
})
}
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 := a.FirstInSearchPath(tc.qname)
if got != tc.b {
t.Errorf("Test %d, got %d, expected %d", i, got, tc.b)
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,22 @@
package autopath
import (
"github.com/miekg/dns"
)
// NonWriter is a type of ResponseWriter that captures the message, but never writes to the client.
type NonWriter struct {
dns.ResponseWriter
Msg *dns.Msg
}
// NewNonWriter makes and returns a new NonWriter.
func NewNonWriter(w dns.ResponseWriter) *NonWriter { return &NonWriter{ResponseWriter: w} }
// WriteMsg records the message, but doesn't write it itself.
func (r *NonWriter) WriteMsg(res *dns.Msg) error {
r.Msg = res
return nil
}
func (r *NonWriter) Write(buf []byte) (int, error) { return len(buf), nil }

View File

@@ -0,0 +1,87 @@
package autopath
import (
"fmt"
"github.com/coredns/coredns/core/dnsserver"
"github.com/coredns/coredns/middleware"
"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 middleware.Error("autopath", err)
}
c.OnStartup(func() error {
// So we know for sure the mw is initialized.
m := dnsserver.GetMiddleware(c, mw)
switch mw {
case "kubernetes":
m = m
//if k, ok := m.(kubernetes.Kubernetes); ok {
//&ap.searchFunc = k.AutoPath
//}
}
return nil
})
dnsserver.GetConfig(c).AddMiddleware(func(next middleware.Handler) middleware.Handler {
ap.Next = next
return ap
})
return nil
}
var allowedMiddleware = map[string]bool{
"@kubernetes": true,
}
func autoPathParse(c *caddy.Controller) (*AutoPath, string, error) {
ap := new(AutoPath)
mw := ""
for c.Next() {
zoneAndresolv := c.RemainingArgs()
if len(zoneAndresolv) < 1 {
return nil, "", 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 nil, "", fmt.Errorf("failed to parse %q: %v", resolv, err)
}
ap.search = rc.Search
middleware.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] = middleware.Host(str).Normalize()
}
}
return ap, mw, nil
}

View File

@@ -0,0 +1,74 @@
package autopath
import (
"os"
"reflect"
"strings"
"testing"
"github.com/coredns/coredns/middleware/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", resolvConf, err)
}
defer rm()
tests := []struct {
input string
shouldErr bool
expectedMw string // expected middleware.
expectedSearch []string // expected search path
expectedErrContent string // substring from the expected error. Empty for positive cases.
}{
// positive
{
`autopath @kubernetes`, false, "kubernetes", nil, "",
},
{
`autopath ` + resolv, false, "", []string{"bar.com.", "baz.com.", ""}, "",
},
// negative
{
`autopath kubernetes`, true, "", nil, "open kubernetes: no such file or directory",
},
}
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)
}
}
}
}
const resolvConf = `nameserver 1.2.3.4
domain foo.com
search bar.com baz.com
options ndots:5
`