diff options
author | 2019-11-01 12:02:43 -0400 | |
---|---|---|
committer | 2019-11-01 12:02:43 -0400 | |
commit | a7ab592e7895ee8369885f0d41251b9adc4f8cbf (patch) | |
tree | 6068f7a746d4e5034f5bc01ab284ea7694967d65 /plugin | |
parent | 5d8bda58a9cab89bd21860e7429b93cf65a728b1 (diff) | |
download | coredns-a7ab592e7895ee8369885f0d41251b9adc4f8cbf.tar.gz coredns-a7ab592e7895ee8369885f0d41251b9adc4f8cbf.tar.zst coredns-a7ab592e7895ee8369885f0d41251b9adc4f8cbf.zip |
plugin/transfer: Zone transfer plugin (#3223)
* transfer plugin
Signed-off-by: Chris O'Haver <cohaver@infoblox.com>
Diffstat (limited to 'plugin')
-rw-r--r-- | plugin/transfer/OWNERS | 6 | ||||
-rw-r--r-- | plugin/transfer/README.md | 32 | ||||
-rw-r--r-- | plugin/transfer/setup.go | 102 | ||||
-rw-r--r-- | plugin/transfer/setup_test.go | 85 | ||||
-rw-r--r-- | plugin/transfer/transfer.go | 181 | ||||
-rw-r--r-- | plugin/transfer/transfer_test.go | 291 |
6 files changed, 697 insertions, 0 deletions
diff --git a/plugin/transfer/OWNERS b/plugin/transfer/OWNERS new file mode 100644 index 000000000..3a4ef23a1 --- /dev/null +++ b/plugin/transfer/OWNERS @@ -0,0 +1,6 @@ +reviewers: + - miekg + - chrisohaver +approvers: + - miekg + - chrisohaver diff --git a/plugin/transfer/README.md b/plugin/transfer/README.md new file mode 100644 index 000000000..df3ab3eb4 --- /dev/null +++ b/plugin/transfer/README.md @@ -0,0 +1,32 @@ +# transfer + +## Name + +*transfer* - answer zone transfers requests for compatible authoritative +plugins. + +## Description + +This plugin answers zone transfers for authoritative plugins that implement +`transfer.Transferer`. + +Transfer answers AXFR requests and IXFR requests with AXFR fallback if the +zone has changed. + +Notifies are not currently supported. + +## Syntax + +~~~ +transfer [ZONE...] { + to HOST... +} +~~~ + +* **ZONES** The zones *transfer* will answer zone requests for. If left blank, + the zones are inherited from the enclosing server block. To answer zone + transfers for a given zone, there must be another plugin in the same server + block that serves the same zone, and implements `transfer.Transferer`. + +* `to ` **HOST...** The hosts *transfer* will transfer to. Use `*` to permit + transfers to all hosts. diff --git a/plugin/transfer/setup.go b/plugin/transfer/setup.go new file mode 100644 index 000000000..e83fd6d0b --- /dev/null +++ b/plugin/transfer/setup.go @@ -0,0 +1,102 @@ +package transfer + +import ( + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + parsepkg "github.com/coredns/coredns/plugin/pkg/parse" + "github.com/coredns/coredns/plugin/pkg/transport" + + "github.com/caddyserver/caddy" +) + +func init() { + caddy.RegisterPlugin("transfer", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + t, err := parse(c) + + if err != nil { + return plugin.Error("transfer", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + t.Next = next + return t + }) + + c.OnStartup(func() error { + // find all plugins that implement Transferer and add them to Transferers + plugins := dnsserver.GetConfig(c).Handlers() + for _, pl := range plugins { + tr, ok := pl.(Transferer) + if !ok { + continue + } + t.Transferers = append(t.Transferers, tr) + } + return nil + }) + + return nil +} + +func parse(c *caddy.Controller) (*Transfer, error) { + + t := &Transfer{} + for c.Next() { + x := &xfr{} + zones := c.RemainingArgs() + + if len(zones) != 0 { + x.Zones = zones + for i := 0; i < len(x.Zones); i++ { + nzone, err := plugin.Host(x.Zones[i]).MustNormalize() + if err != nil { + return nil, err + } + x.Zones[i] = nzone + } + } else { + x.Zones = make([]string, len(c.ServerBlockKeys)) + for i := 0; i < len(c.ServerBlockKeys); i++ { + nzone, err := plugin.Host(c.ServerBlockKeys[i]).MustNormalize() + if err != nil { + return nil, err + } + x.Zones[i] = nzone + } + } + + for c.NextBlock() { + switch c.Val() { + case "to": + args := c.RemainingArgs() + if len(args) == 0 { + return nil, c.ArgErr() + } + for _, host := range args { + if host == "*" { + x.to = append(x.to, host) + continue + } + normalized, err := parsepkg.HostPort(host, transport.Port) + if err != nil { + return nil, err + } + x.to = append(x.to, normalized) + } + default: + return nil, plugin.Error("transfer", c.Errf("unknown property '%s'", c.Val())) + } + } + if len(x.to) == 0 { + return nil, plugin.Error("transfer", c.Errf("'to' is required", c.Val())) + } + t.xfrs = append(t.xfrs, x) + } + return t, nil +} diff --git a/plugin/transfer/setup_test.go b/plugin/transfer/setup_test.go new file mode 100644 index 000000000..421910d46 --- /dev/null +++ b/plugin/transfer/setup_test.go @@ -0,0 +1,85 @@ +package transfer + +import ( + "testing" + + "github.com/caddyserver/caddy" +) + +func TestParse(t *testing.T) { + tests := []struct { + input string + shouldErr bool + exp *Transfer + }{ + {`transfer example.net example.org { + to 1.2.3.4 5.6.7.8:1053 [1::2]:34 + } + transfer example.com example.edu { + to * 1.2.3.4 + }`, + false, + &Transfer{ + xfrs: []*xfr{{ + Zones: []string{"example.net.", "example.org."}, + to: []string{"1.2.3.4:53", "5.6.7.8:1053", "[1::2]:34"}, + }, { + Zones: []string{"example.com.", "example.edu."}, + to: []string{"*", "1.2.3.4:53"}, + }}, + }, + }, + // errors + {`transfer example.net example.org { + }`, + true, + nil, + }, + {`transfer example.net example.org { + invalid option + }`, + true, + nil, + }, + } + for i, tc := range tests { + c := caddy.NewTestController("dns", tc.input) + transfer, err := parse(c) + + if err == nil && tc.shouldErr { + t.Fatalf("Test %d expected errors, but got no error", i) + } + if err != nil && !tc.shouldErr { + t.Fatalf("Test %d expected no errors, but got '%v'", i, err) + } + if tc.shouldErr { + continue + } + + if len(tc.exp.xfrs) != len(transfer.xfrs) { + t.Fatalf("Test %d expected %d xfrs, got %d", i, len(tc.exp.xfrs), len(transfer.xfrs)) + } + for j, x := range transfer.xfrs { + // Check Zones + if len(tc.exp.xfrs[j].Zones) != len(x.Zones) { + t.Fatalf("Test %d expected %d zones, got %d", i, len(tc.exp.xfrs[i].Zones), len(x.Zones)) + } + for k, zone := range x.Zones { + if tc.exp.xfrs[j].Zones[k] != zone { + t.Errorf("Test %d expected zone %v, got %v", i, tc.exp.xfrs[j].Zones[k], zone) + + } + } + // Check to + if len(tc.exp.xfrs[j].to) != len(x.to) { + t.Fatalf("Test %d expected %d 'to' values, got %d", i, len(tc.exp.xfrs[i].to), len(x.to)) + } + for k, to := range x.to { + if tc.exp.xfrs[j].to[k] != to { + t.Errorf("Test %d expected %v in 'to', got %v", i, tc.exp.xfrs[j].to[k], to) + + } + } + } + } +} diff --git a/plugin/transfer/transfer.go b/plugin/transfer/transfer.go new file mode 100644 index 000000000..9f8691548 --- /dev/null +++ b/plugin/transfer/transfer.go @@ -0,0 +1,181 @@ +package transfer + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + + "github.com/coredns/coredns/plugin" + clog "github.com/coredns/coredns/plugin/pkg/log" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +var log = clog.NewWithPlugin("transfer") + +// Transfer is a plugin that handles zone transfers. +type Transfer struct { + Transferers []Transferer // the list of plugins that implement Transferer + xfrs []*xfr + Next plugin.Handler // the next plugin in the chain +} + +type xfr struct { + Zones []string + to []string +} + +// Transferer may be implemented by plugins to enable zone transfers +type Transferer interface { + // Transfer returns a channel to which it writes responses to the transfer request. + // If the plugin is not authoritative for the zone, it should immediately return the + // Transfer.ErrNotAuthoritative error. + // + // If serial is 0, handle as an AXFR request. Transfer should send all records + // in the zone to the channel. The SOA should be written to the channel first, followed + // by all other records, including all NS + glue records. + // + // If serial is not 0, handle as an IXFR request. If the serial is equal to or greater (newer) than + // the current serial for the zone, send a single SOA record to the channel. + // If the serial is less (older) than the current serial for the zone, perform an AXFR fallback + // by proceeding as if an AXFR was requested (as above). + Transfer(zone string, serial uint32) (<-chan []dns.RR, error) +} + +var ( + // ErrNotAuthoritative is returned by Transfer() when the plugin is not authoritative for the zone + ErrNotAuthoritative = errors.New("not authoritative for zone") +) + +// ServeDNS implements the plugin.Handler interface. +func (t Transfer) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + if state.QType() != dns.TypeAXFR && state.QType() != dns.TypeIXFR { + return plugin.NextOrFailure(t.Name(), t.Next, ctx, w, r) + } + + // Find the first transfer instance for which the queried zone is a subdomain. + var x *xfr + for _, xfr := range t.xfrs { + zone := plugin.Zones(xfr.Zones).Matches(state.Name()) + if zone == "" { + continue + } + x = xfr + } + if x == nil { + // Requested zone did not match any transfer instance zones. + // Pass request down chain in case later plugins are capable of handling transfer requests themselves. + return plugin.NextOrFailure(t.Name(), t.Next, ctx, w, r) + } + + if !x.allowed(state) { + return dns.RcodeRefused, nil + } + + // Get serial from request if this is an IXFR + var serial uint32 + if state.QType() == dns.TypeIXFR { + soa, ok := r.Ns[0].(*dns.SOA) + if !ok { + return dns.RcodeServerFailure, nil + } + serial = soa.Serial + } + + // Get a receiving channel from the first Transferer plugin that returns one + var fromPlugin <-chan []dns.RR + for _, p := range t.Transferers { + var err error + fromPlugin, err = p.Transfer(state.QName(), serial) + if err == ErrNotAuthoritative { + // plugin was not authoritative for the zone, try next plugin + continue + } + if err != nil { + return dns.RcodeServerFailure, err + } + break + } + + if fromPlugin == nil { + return plugin.NextOrFailure(t.Name(), t.Next, ctx, w, r) + } + + // Send response to client + ch := make(chan *dns.Envelope) + tr := new(dns.Transfer) + wg := new(sync.WaitGroup) + go func() { + wg.Add(1) + tr.Out(w, r, ch) + wg.Done() + }() + + var soa *dns.SOA + rrs := []dns.RR{} + l := 0 + +receive: + for records := range fromPlugin { + for _, record := range records { + if soa == nil { + if soa = record.(*dns.SOA); soa == nil { + break receive + } + serial = soa.Serial + } + rrs = append(rrs, record) + if len(rrs) > 500 { + ch <- &dns.Envelope{RR: rrs} + l += len(rrs) + rrs = []dns.RR{} + } + } + } + + if len(rrs) > 0 { + ch <- &dns.Envelope{RR: rrs} + l += len(rrs) + rrs = []dns.RR{} + } + + if soa != nil { + ch <- &dns.Envelope{RR: []dns.RR{soa}} // closing SOA. + l++ + } + + close(ch) // Even though we close the channel here, we still have + wg.Wait() // to wait before we can return and close the connection. + + if soa == nil { + return dns.RcodeServerFailure, fmt.Errorf("first record in zone %s is not SOA", state.QName()) + } + + log.Infof("Outgoing transfer of %d records of zone %s to %s with %d SOA serial", l, state.QName(), state.IP(), serial) + return dns.RcodeSuccess, nil +} + +func (x xfr) allowed(state request.Request) bool { + for _, h := range x.to { + if h == "*" { + return true + } + to, _, err := net.SplitHostPort(h) + if err != nil { + return false + } + // If remote IP matches we accept. + remote := state.IP() + if to == remote { + return true + } + } + return false +} + +// Name implements the Handler interface. +func (Transfer) Name() string { return "transfer" } diff --git a/plugin/transfer/transfer_test.go b/plugin/transfer/transfer_test.go new file mode 100644 index 000000000..8dce4c6e1 --- /dev/null +++ b/plugin/transfer/transfer_test.go @@ -0,0 +1,291 @@ +package transfer + +import ( + "context" + "fmt" + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +// transfererPlugin implements transfer.Transferer and plugin.Handler +type transfererPlugin struct { + Zone string + Serial uint32 + Next plugin.Handler +} + +// Name implements plugin.Handler +func (transfererPlugin) Name() string { return "transfererplugin" } + +// ServeDNS implements plugin.Handler +func (p transfererPlugin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + if r.Question[0].Name != p.Zone { + return p.Next.ServeDNS(ctx, w, r) + } + return 0, nil +} + +// Transfer implements transfer.Transferer - it returns a static AXFR response, or +// if serial is current, an abbreviated IXFR response +func (p transfererPlugin) Transfer(zone string, serial uint32) (<-chan []dns.RR, error) { + if zone != p.Zone { + return nil, ErrNotAuthoritative + } + ch := make(chan []dns.RR, 2) + defer close(ch) + ch <- []dns.RR{test.SOA(fmt.Sprintf("%s 100 IN SOA ns.dns.%s hostmaster.%s %d 7200 1800 86400 100", p.Zone, p.Zone, p.Zone, p.Serial))} + if serial >= p.Serial { + return ch, nil + } + ch <- []dns.RR{ + test.NS(fmt.Sprintf("%s 100 IN NS ns.dns.%s", p.Zone, p.Zone)), + test.A(fmt.Sprintf("ns.dns.%s 100 IN A 1.2.3.4", p.Zone)), + } + return ch, nil +} + +type terminatingPlugin struct{} + +// Name implements plugin.Handler +func (terminatingPlugin) Name() string { return "testplugin" } + +// ServeDNS implements plugin.Handler that returns NXDOMAIN for all requests +func (terminatingPlugin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + m := new(dns.Msg) + m.SetRcode(r, dns.RcodeNameError) + w.WriteMsg(m) + return dns.RcodeNameError, nil +} + +func newTestTransfer() Transfer { + nextPlugin1 := transfererPlugin{Zone: "example.com.", Serial: 12345} + nextPlugin2 := transfererPlugin{Zone: "example.org.", Serial: 12345} + nextPlugin2.Next = terminatingPlugin{} + nextPlugin1.Next = nextPlugin2 + + transfer := Transfer{ + Transferers: []Transferer{nextPlugin1, nextPlugin2}, + xfrs: []*xfr{ + { + Zones: []string{"example.org."}, + to: []string{"*"}, + }, + { + Zones: []string{"example.com."}, + to: []string{"*"}, + }, + }, + Next: nextPlugin1, + } + return transfer +} + +func TestTransferNonZone(t *testing.T) { + + transfer := newTestTransfer() + ctx := context.TODO() + + for _, tc := range []string{"sub.example.org.", "example.test."} { + w := dnstest.NewRecorder(&test.ResponseWriter{}) + dnsmsg := &dns.Msg{} + dnsmsg.SetAxfr(tc) + + _, err := transfer.ServeDNS(ctx, w, dnsmsg) + if err != nil { + t.Error(err) + } + + if w.Msg == nil { + t.Fatalf("Got nil message for AXFR %s", tc) + } + + if w.Msg.Rcode != dns.RcodeNameError { + t.Errorf("Expected NXDOMAIN for AXFR %s got %s", tc, dns.RcodeToString[w.Msg.Rcode]) + } + } +} + +func TestTransferNotAXFRorIXFR(t *testing.T) { + + transfer := newTestTransfer() + + ctx := context.TODO() + w := dnstest.NewRecorder(&test.ResponseWriter{}) + dnsmsg := &dns.Msg{} + dnsmsg.SetQuestion("test.domain.", dns.TypeA) + + _, err := transfer.ServeDNS(ctx, w, dnsmsg) + if err != nil { + t.Error(err) + } + + if w.Msg == nil { + t.Fatal("Got nil message") + } + + if w.Msg.Rcode != dns.RcodeNameError { + t.Errorf("Expected NXDOMAIN got %s", dns.RcodeToString[w.Msg.Rcode]) + } +} + +func TestTransferAXFRExampleOrg(t *testing.T) { + + transfer := newTestTransfer() + + ctx := context.TODO() + w := dnstest.NewMultiRecorder(&test.ResponseWriter{}) + dnsmsg := &dns.Msg{} + dnsmsg.SetAxfr(transfer.xfrs[0].Zones[0]) + + _, err := transfer.ServeDNS(ctx, w, dnsmsg) + if err != nil { + t.Error(err) + } + + validateAXFRResponse(t, w) +} + +func TestTransferAXFRExampleCom(t *testing.T) { + + transfer := newTestTransfer() + + ctx := context.TODO() + w := dnstest.NewMultiRecorder(&test.ResponseWriter{}) + dnsmsg := &dns.Msg{} + dnsmsg.SetAxfr(transfer.xfrs[1].Zones[0]) + + _, err := transfer.ServeDNS(ctx, w, dnsmsg) + if err != nil { + t.Error(err) + } + + validateAXFRResponse(t, w) +} + +func TestTransferIXFRFallback(t *testing.T) { + + transfer := newTestTransfer() + + testPlugin := transfer.Transferers[0].(transfererPlugin) + + ctx := context.TODO() + w := dnstest.NewMultiRecorder(&test.ResponseWriter{}) + dnsmsg := &dns.Msg{} + dnsmsg.SetIxfr( + transfer.xfrs[0].Zones[0], + testPlugin.Serial-1, + "ns.dns."+testPlugin.Zone, + "hostmaster.dns."+testPlugin.Zone, + ) + + _, err := transfer.ServeDNS(ctx, w, dnsmsg) + if err != nil { + t.Error(err) + } + + validateAXFRResponse(t, w) +} + +func TestTransferIXFRCurrent(t *testing.T) { + + transfer := newTestTransfer() + + testPlugin := transfer.Transferers[0].(transfererPlugin) + + ctx := context.TODO() + w := dnstest.NewMultiRecorder(&test.ResponseWriter{}) + dnsmsg := &dns.Msg{} + dnsmsg.SetIxfr( + transfer.xfrs[0].Zones[0], + testPlugin.Serial, + "ns.dns."+testPlugin.Zone, + "hostmaster.dns."+testPlugin.Zone, + ) + + _, err := transfer.ServeDNS(ctx, w, dnsmsg) + if err != nil { + t.Error(err) + } + + if len(w.Msgs) == 0 { + t.Logf("%+v\n", w) + t.Fatal("Did not get back a zone response") + } + + if len(w.Msgs[0].Answer) != 1 { + t.Logf("%+v\n", w) + t.Fatalf("Expected 1 answer, got %d", len(w.Msgs[0].Answer)) + } + + // Ensure the answer is the SOA + if w.Msgs[0].Answer[0].Header().Rrtype != dns.TypeSOA { + t.Error("Answer does not contain the SOA record") + } +} + +func validateAXFRResponse(t *testing.T, w *dnstest.MultiRecorder) { + if len(w.Msgs) == 0 { + t.Logf("%+v\n", w) + t.Fatal("Did not get back a zone response") + } + + if len(w.Msgs[0].Answer) == 0 { + t.Logf("%+v\n", w) + t.Fatal("Did not get back an answer") + } + + // Ensure the answer starts with SOA + if w.Msgs[0].Answer[0].Header().Rrtype != dns.TypeSOA { + t.Error("Answer does not start with SOA record") + } + + // Ensure the answer ends with SOA + if w.Msgs[len(w.Msgs)-1].Answer[len(w.Msgs[len(w.Msgs)-1].Answer)-1].Header().Rrtype != dns.TypeSOA { + t.Error("Answer does not end with SOA record") + } + + // Ensure the answer is the expected length + c := 0 + for _, m := range w.Msgs { + c += len(m.Answer) + } + if c != 4 { + t.Errorf("Answer is not the expected length (expected 4, got %d)", c) + } +} + +func TestTransferNotAllowed(t *testing.T) { + nextPlugin := transfererPlugin{Zone: "example.org.", Serial: 12345} + + transfer := Transfer{ + Transferers: []Transferer{nextPlugin}, + xfrs: []*xfr{ + { + Zones: []string{"example.org."}, + to: []string{"1.2.3.4"}, + }, + }, + Next: nextPlugin, + } + + ctx := context.TODO() + w := dnstest.NewMultiRecorder(&test.ResponseWriter{}) + dnsmsg := &dns.Msg{} + dnsmsg.SetAxfr(transfer.xfrs[0].Zones[0]) + + rcode, err := transfer.ServeDNS(ctx, w, dnsmsg) + + if err != nil { + t.Error(err) + } + + if rcode != dns.RcodeRefused { + t.Errorf("Expected REFUSED response code, got %s", dns.RcodeToString[rcode]) + } + +} |