plugin/transfer: Zone transfer plugin (#3223)

* transfer plugin

Signed-off-by: Chris O'Haver <cohaver@infoblox.com>
This commit is contained in:
Chris O'Haver
2019-11-01 12:02:43 -04:00
committed by GitHub
parent 5d8bda58a9
commit a7ab592e78
9 changed files with 700 additions and 0 deletions

View File

@@ -35,6 +35,7 @@ var Directives = []string{
"dnssec",
"autopath",
"template",
"transfer",
"hosts",
"route53",
"azure",

View File

@@ -45,6 +45,7 @@ import (
_ "github.com/coredns/coredns/plugin/template"
_ "github.com/coredns/coredns/plugin/tls"
_ "github.com/coredns/coredns/plugin/trace"
_ "github.com/coredns/coredns/plugin/transfer"
_ "github.com/coredns/coredns/plugin/whoami"
_ "github.com/coredns/federation"
)

View File

@@ -44,6 +44,7 @@ rewrite:rewrite
dnssec:dnssec
autopath:autopath
template:template
transfer:transfer
hosts:hosts
route53:route53
azure:azure

6
plugin/transfer/OWNERS Normal file
View File

@@ -0,0 +1,6 @@
reviewers:
- miekg
- chrisohaver
approvers:
- miekg
- chrisohaver

32
plugin/transfer/README.md Normal file
View File

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

102
plugin/transfer/setup.go Normal file
View File

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

View File

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

181
plugin/transfer/transfer.go Normal file
View File

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

View File

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