From 7b6cb76237d151ffa056c742ec281a17548fa089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1igo?= Date: Thu, 14 Mar 2019 08:12:28 +0100 Subject: [PATCH] plugin/grpc: New gRPC plugin (#2667) * plugin/grpc: New gRPC plugin * some changes after the first review: - remove healthcheck. gRPC already has this implicitly implemented - some naming and stetic changes - fix some comments - other minor fixes * plugin/grpc: New gRPC plugin * some changes after the first review: - remove healthcheck. gRPC already has this implicitly implemented - some naming and stetic changes - fix some comments - other minor fixes * add OWNERS file and change plugin order * remove Rcode checker --- core/dnsserver/zdirectives.go | 1 + core/plugin/zplugin.go | 1 + go.sum | 1 + plugin.cfg | 1 + plugin/grpc/OWNERS | 6 ++ plugin/grpc/README.md | 135 ++++++++++++++++++++++++++ plugin/grpc/grpc.go | 130 +++++++++++++++++++++++++ plugin/grpc/grpc_test.go | 75 +++++++++++++++ plugin/grpc/metrics.go | 30 ++++++ plugin/grpc/policy.go | 64 +++++++++++++ plugin/grpc/proxy.go | 81 ++++++++++++++++ plugin/grpc/proxy_test.go | 66 +++++++++++++ plugin/grpc/setup.go | 158 +++++++++++++++++++++++++++++++ plugin/grpc/setup_policy_test.go | 47 +++++++++ plugin/grpc/setup_test.go | 156 ++++++++++++++++++++++++++++++ 15 files changed, 952 insertions(+) create mode 100644 plugin/grpc/OWNERS create mode 100644 plugin/grpc/README.md create mode 100644 plugin/grpc/grpc.go create mode 100644 plugin/grpc/grpc_test.go create mode 100644 plugin/grpc/metrics.go create mode 100644 plugin/grpc/policy.go create mode 100644 plugin/grpc/proxy.go create mode 100644 plugin/grpc/proxy_test.go create mode 100644 plugin/grpc/setup.go create mode 100644 plugin/grpc/setup_policy_test.go create mode 100644 plugin/grpc/setup_test.go diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index 0d2aa5466..b97ba2070 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -47,4 +47,5 @@ var Directives = []string{ "erratic", "whoami", "on", + "grpc", } diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index 819b44cb2..4536060c0 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -19,6 +19,7 @@ import ( _ "github.com/coredns/coredns/plugin/federation" _ "github.com/coredns/coredns/plugin/file" _ "github.com/coredns/coredns/plugin/forward" + _ "github.com/coredns/coredns/plugin/grpc" _ "github.com/coredns/coredns/plugin/health" _ "github.com/coredns/coredns/plugin/hosts" _ "github.com/coredns/coredns/plugin/k8s_external" diff --git a/go.sum b/go.sum index a36445c38..a92defeec 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,7 @@ github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/openzipkin/zipkin-go-opentracing v0.3.4 h1:x/pBv/5VJNWkcHF1G9xqhug8Iw7X1y1zOMzDmyuvP2g= github.com/openzipkin/zipkin-go-opentracing v0.3.4/go.mod h1:js2AbwmHW0YD9DwIw2JhQWmbfFi/UnWyYwdVhqbCDOE= +github.com/petar/GoLLRB v0.0.0-20130427215148-53be0d36a84c/go.mod h1:HUpKUBZnpzkdx0kD/+Yfuft+uD3zHGtXF/XJB14TUr4= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= diff --git a/plugin.cfg b/plugin.cfg index a9450d50d..e31c9fdcf 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -53,6 +53,7 @@ etcd:etcd loop:loop forward:forward proxy:deprecated +grpc:grpc erratic:erratic whoami:whoami on:github.com/mholt/caddy/onevent diff --git a/plugin/grpc/OWNERS b/plugin/grpc/OWNERS new file mode 100644 index 000000000..7b778f5bd --- /dev/null +++ b/plugin/grpc/OWNERS @@ -0,0 +1,6 @@ +reviewers: + - inigohu + - miekg +approvers: + - inigohu + - miekg diff --git a/plugin/grpc/README.md b/plugin/grpc/README.md new file mode 100644 index 000000000..178ee73ae --- /dev/null +++ b/plugin/grpc/README.md @@ -0,0 +1,135 @@ +# grpc + +## Name + +*grpc* - facilitates proxying DNS messages to upstream resolvers via gRPC protocol. + +## Description + +The *grpc* plugin supports gRPC and TLS. + +This plugin can only be used once per Server Block. + +## Syntax + +In its most basic form: + +~~~ +grpc FROM TO... +~~~ + +* **FROM** is the base domain to match for the request to be proxied. +* **TO...** are the destination endpoints to proxy to. The number of upstreams is + limited to 15. + +Multiple upstreams are randomized (see `policy`) on first use. When a proxy returns an error +the next upstream in the list is tried. + +Extra knobs are available with an expanded syntax: + +~~~ +grpc FROM TO... { + except IGNORED_NAMES... + tls CERT KEY CA + tls_servername NAME + policy random|round_robin|sequential +} +~~~ + +* **FROM** and **TO...** as above. +* **IGNORED_NAMES** in `except` is a space-separated list of domains to exclude from proxying. + Requests that match none of these names will be passed through. +* `tls` **CERT** **KEY** **CA** define the TLS properties for TLS connection. From 0 to 3 arguments can be + provided with the meaning as described below + + * `tls` - no client authentication is used, and the system CAs are used to verify the server certificate + * `tls` **CA** - no client authentication is used, and the file CA is used to verify the server certificate + * `tls` **CERT** **KEY** - client authentication is used with the specified cert/key pair. + The server certificate is verified with the system CAs + * `tls` **CERT** **KEY** **CA** - client authentication is used with the specified cert/key pair. + The server certificate is verified using the specified CA file + +* `tls_servername` **NAME** allows you to set a server name in the TLS configuration; for instance 9.9.9.9 + needs this to be set to `dns.quad9.net`. Multiple upstreams are still allowed in this scenario, + but they have to use the same `tls_servername`. E.g. mixing 9.9.9.9 (QuadDNS) with 1.1.1.1 + (Cloudflare) will not work. +* `policy` specifies the policy to use for selecting upstream servers. The default is `random`. + +Also note the TLS config is "global" for the whole grpc proxy if you need a different +`tls-name` for different upstreams you're out of luck. + +## Metrics + +If monitoring is enabled (via the *prometheus* directive) then the following metric are exported: + +* `coredns_grpc_request_duration_seconds{to}` - duration per upstream interaction. +* `coredns_grpc_request_count_total{to}` - query count per upstream. +* `coredns_grpc_response_rcode_total{to, rcode}` - count of RCODEs per upstream. + and we are randomly (this always uses the `random` policy) spraying to an upstream. + +## Examples + +Proxy all requests within `example.org.` to a nameserver running on a different port: + +~~~ corefile +example.org { + grpc . 127.0.0.1:9005 +} +~~~ + +Load balance all requests between three resolvers, one of which has a IPv6 address. + +~~~ corefile +. { + grpc . 10.0.0.10:53 10.0.0.11:1053 [2003::1]:53 +} +~~~ + +Forward everything except requests to `example.org` + +~~~ corefile +. { + grpc . 10.0.0.10:1234 { + except example.org + } +} +~~~ + +Proxy everything except `example.org` using the host's `resolv.conf`'s nameservers: + +~~~ corefile +. { + grpc . /etc/resolv.conf { + except example.org + } +} +~~~ + +Proxy all requests to 9.9.9.9 using the TLS protocol, and cache every answer for up to 30 +seconds. Note the `tls_servername` is mandatory if you want a working setup, as 9.9.9.9 can't be +used in the TLS negotiation. + +~~~ corefile +. { + grpc . 9.9.9.9 { + tls_servername dns.quad9.net + } + cache 30 +} +~~~ + +Or with multiple upstreams from the same provider + +~~~ corefile +. { + grpc . 1.1.1.1 1.0.0.1 { + tls_servername cloudflare-dns.com + } + cache 30 +} +~~~ + +## Bugs + +The TLS config is global for the whole grpc proxy if you need a different `tls_servername` for +different upstreams you're out of luck. diff --git a/plugin/grpc/grpc.go b/plugin/grpc/grpc.go new file mode 100644 index 000000000..655be00c1 --- /dev/null +++ b/plugin/grpc/grpc.go @@ -0,0 +1,130 @@ +package grpc + +import ( + "context" + "crypto/tls" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/debug" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + ot "github.com/opentracing/opentracing-go" +) + +// GRPC represents a plugin instance that can proxy requests to another (DNS) server via gRPC protocol. +// It has a list of proxies each representing one upstream proxy. +type GRPC struct { + proxies []*Proxy + p Policy + + from string + ignored []string + + tlsConfig *tls.Config + tlsServerName string + + Next plugin.Handler +} + +// ServeDNS implements the plugin.Handler interface. +func (g *GRPC) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + if !g.match(state) { + return plugin.NextOrFailure(g.Name(), g.Next, ctx, w, r) + } + + var ( + span, child ot.Span + ret *dns.Msg + err error + i int + ) + span = ot.SpanFromContext(ctx) + list := g.list() + deadline := time.Now().Add(defaultTimeout) + + for time.Now().Before(deadline) { + if i >= len(list) { + // reached the end of list without any answer + if ret != nil { + // write empty response and finish + w.WriteMsg(ret) + } + break + } + + proxy := list[i] + i++ + + if span != nil { + child = span.Tracer().StartSpan("query", ot.ChildOf(span.Context())) + ctx = ot.ContextWithSpan(ctx, child) + } + + ret, err = proxy.query(ctx, r) + if err != nil { + // Continue with the next proxy + continue + } + + if child != nil { + child.Finish() + } + + // Check if the reply is correct; if not return FormErr. + if !state.Match(ret) { + debug.Hexdumpf(ret, "Wrong reply for id: %d, %s %d", ret.Id, state.QName(), state.QType()) + + formerr := state.ErrorMessage(dns.RcodeFormatError) + w.WriteMsg(formerr) + return 0, nil + } + + w.WriteMsg(ret) + return 0, nil + } + + return 0, nil +} + +// NewGRPC returns a new GRPC. +func newGRPC() *GRPC { + g := &GRPC{ + p: new(random), + } + return g +} + +// Name implements the Handler interface. +func (g *GRPC) Name() string { return "grpc" } + +// Len returns the number of configured proxies. +func (g *GRPC) len() int { return len(g.proxies) } + +func (g *GRPC) match(state request.Request) bool { + if !plugin.Name(g.from).Matches(state.Name()) || !g.isAllowedDomain(state.Name()) { + return false + } + + return true +} + +func (g *GRPC) isAllowedDomain(name string) bool { + if dns.Name(name) == dns.Name(g.from) { + return true + } + + for _, ignore := range g.ignored { + if plugin.Name(ignore).Matches(name) { + return false + } + } + return true +} + +// List returns a set of proxies to be used for this client depending on the policy in p. +func (g *GRPC) list() []*Proxy { return g.p.List(g.proxies) } + +const defaultTimeout = 5 * time.Second diff --git a/plugin/grpc/grpc_test.go b/plugin/grpc/grpc_test.go new file mode 100644 index 000000000..06375ec5e --- /dev/null +++ b/plugin/grpc/grpc_test.go @@ -0,0 +1,75 @@ +package grpc + +import ( + "context" + "errors" + "testing" + + "github.com/coredns/coredns/pb" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestGRPC(t *testing.T) { + m := &dns.Msg{} + msg, err := m.Pack() + if err != nil { + t.Fatalf("Error packing response: %s", err.Error()) + } + dnsPacket := &pb.DnsPacket{Msg: msg} + tests := map[string]struct { + proxies []*Proxy + wantErr bool + }{ + "single_proxy_ok": { + proxies: []*Proxy{ + {client: &testServiceClient{dnsPacket: dnsPacket, err: nil}}, + }, + wantErr: false, + }, + "multiple_proxies_ok": { + proxies: []*Proxy{ + {client: &testServiceClient{dnsPacket: dnsPacket, err: nil}}, + {client: &testServiceClient{dnsPacket: dnsPacket, err: nil}}, + {client: &testServiceClient{dnsPacket: dnsPacket, err: nil}}, + }, + wantErr: false, + }, + "single_proxy_ko": { + proxies: []*Proxy{ + {client: &testServiceClient{dnsPacket: nil, err: errors.New("")}}, + }, + wantErr: true, + }, + "multiple_proxies_one_ko": { + proxies: []*Proxy{ + {client: &testServiceClient{dnsPacket: dnsPacket, err: nil}}, + {client: &testServiceClient{dnsPacket: nil, err: errors.New("")}}, + {client: &testServiceClient{dnsPacket: dnsPacket, err: nil}}, + }, + wantErr: false, + }, + "multiple_proxies_ko": { + proxies: []*Proxy{ + {client: &testServiceClient{dnsPacket: nil, err: errors.New("")}}, + {client: &testServiceClient{dnsPacket: nil, err: errors.New("")}}, + {client: &testServiceClient{dnsPacket: nil, err: errors.New("")}}, + }, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + g := newGRPC() + g.from = "." + g.proxies = tt.proxies + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + if _, err := g.ServeDNS(context.TODO(), rec, m); err != nil && !tt.wantErr { + t.Fatal("Expected to receive reply, but didn't") + } + }) + } +} diff --git a/plugin/grpc/metrics.go b/plugin/grpc/metrics.go new file mode 100644 index 000000000..76b186bee --- /dev/null +++ b/plugin/grpc/metrics.go @@ -0,0 +1,30 @@ +package grpc + +import ( + "github.com/coredns/coredns/plugin" + + "github.com/prometheus/client_golang/prometheus" +) + +// Variables declared for monitoring. +var ( + RequestCount = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: "grpc", + Name: "request_count_total", + Help: "Counter of requests made per upstream.", + }, []string{"to"}) + RcodeCount = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: "grpc", + Name: "response_rcode_count_total", + Help: "Counter of requests made per upstream.", + }, []string{"rcode", "to"}) + RequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: plugin.Namespace, + Subsystem: "grpc", + Name: "request_duration_seconds", + Buckets: plugin.TimeBuckets, + Help: "Histogram of the time each request took.", + }, []string{"to"}) +) diff --git a/plugin/grpc/policy.go b/plugin/grpc/policy.go new file mode 100644 index 000000000..66351d822 --- /dev/null +++ b/plugin/grpc/policy.go @@ -0,0 +1,64 @@ +package grpc + +import ( + "math/rand" + "sync/atomic" +) + +// Policy defines a policy we use for selecting upstreams. +type Policy interface { + List([]*Proxy) []*Proxy + String() string +} + +// random is a policy that implements random upstream selection. +type random struct{} + +func (r *random) String() string { return "random" } + +func (r *random) List(p []*Proxy) []*Proxy { + switch len(p) { + case 1: + return p + case 2: + if rand.Int()%2 == 0 { + return []*Proxy{p[1], p[0]} // swap + } + return p + } + + perms := rand.Perm(len(p)) + rnd := make([]*Proxy, len(p)) + + for i, p1 := range perms { + rnd[i] = p[p1] + } + return rnd +} + +// roundRobin is a policy that selects hosts based on round robin ordering. +type roundRobin struct { + robin uint32 +} + +func (r *roundRobin) String() string { return "round_robin" } + +func (r *roundRobin) List(p []*Proxy) []*Proxy { + poolLen := uint32(len(p)) + i := atomic.AddUint32(&r.robin, 1) % poolLen + + robin := []*Proxy{p[i]} + robin = append(robin, p[:i]...) + robin = append(robin, p[i+1:]...) + + return robin +} + +// sequential is a policy that selects hosts based on sequential ordering. +type sequential struct{} + +func (r *sequential) String() string { return "sequential" } + +func (r *sequential) List(p []*Proxy) []*Proxy { + return p +} diff --git a/plugin/grpc/proxy.go b/plugin/grpc/proxy.go new file mode 100644 index 000000000..f2bee95c0 --- /dev/null +++ b/plugin/grpc/proxy.go @@ -0,0 +1,81 @@ +package grpc + +import ( + "context" + "crypto/tls" + "strconv" + "time" + + "github.com/coredns/coredns/pb" + + "github.com/miekg/dns" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" +) + +// Proxy defines an upstream host. +type Proxy struct { + addr string + + // connection + client pb.DnsServiceClient + dialOpts []grpc.DialOption +} + +// newProxy returns a new proxy. +func newProxy(addr string, tlsConfig *tls.Config) (*Proxy, error) { + p := &Proxy{ + addr: addr, + } + + if tlsConfig != nil { + p.dialOpts = append(p.dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + } else { + p.dialOpts = append(p.dialOpts, grpc.WithInsecure()) + } + + conn, err := grpc.Dial(p.addr, p.dialOpts...) + if err != nil { + return nil, err + } + p.client = pb.NewDnsServiceClient(conn) + + return p, nil +} + +// query sends the request and waits for a response. +func (p *Proxy) query(ctx context.Context, req *dns.Msg) (*dns.Msg, error) { + start := time.Now() + + msg, err := req.Pack() + if err != nil { + return nil, err + } + + reply, err := p.client.Query(ctx, &pb.DnsPacket{Msg: msg}) + if err != nil { + // if not found message, return empty message with NXDomain code + if status.Code(err) == codes.NotFound { + m := new(dns.Msg).SetRcode(req, dns.RcodeNameError) + return m, nil + } + return nil, err + } + ret := new(dns.Msg) + if err := ret.Unpack(reply.Msg); err != nil { + return nil, err + } + + rc, ok := dns.RcodeToString[ret.Rcode] + if !ok { + rc = strconv.Itoa(ret.Rcode) + } + + RequestCount.WithLabelValues(p.addr).Add(1) + RcodeCount.WithLabelValues(rc, p.addr).Add(1) + RequestDuration.WithLabelValues(p.addr).Observe(time.Since(start).Seconds()) + + return ret, nil +} diff --git a/plugin/grpc/proxy_test.go b/plugin/grpc/proxy_test.go new file mode 100644 index 000000000..cc4ebec82 --- /dev/null +++ b/plugin/grpc/proxy_test.go @@ -0,0 +1,66 @@ +package grpc + +import ( + "context" + "errors" + "testing" + + "github.com/coredns/coredns/pb" + + "github.com/miekg/dns" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +func TestProxy(t *testing.T) { + tests := map[string]struct { + p *Proxy + res *dns.Msg + wantErr bool + }{ + "response_ok": { + p: &Proxy{}, + res: &dns.Msg{}, + wantErr: false, + }, + "nil_response": { + p: &Proxy{}, + res: nil, + wantErr: true, + }, + "tls": { + p: &Proxy{dialOpts: []grpc.DialOption{grpc.WithTransportCredentials(credentials.NewTLS(nil))}}, + res: &dns.Msg{}, + wantErr: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var mock *testServiceClient + if tt.res != nil { + msg, err := tt.res.Pack() + if err != nil { + t.Fatalf("Error packing response: %s", err.Error()) + } + mock = &testServiceClient{&pb.DnsPacket{Msg: msg}, nil} + } else { + mock = &testServiceClient{nil, errors.New("server error")} + } + tt.p.client = mock + + _, err := tt.p.query(context.TODO(), new(dns.Msg)) + if err != nil && !tt.wantErr { + t.Fatalf("Error query(): %s", err.Error()) + } + }) + } +} + +type testServiceClient struct { + dnsPacket *pb.DnsPacket + err error +} + +func (m testServiceClient) Query(ctx context.Context, in *pb.DnsPacket, opts ...grpc.CallOption) (*pb.DnsPacket, error) { + return m.dnsPacket, m.err +} diff --git a/plugin/grpc/setup.go b/plugin/grpc/setup.go new file mode 100644 index 000000000..fe9f6d959 --- /dev/null +++ b/plugin/grpc/setup.go @@ -0,0 +1,158 @@ +package grpc + +import ( + "crypto/tls" + "fmt" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metrics" + "github.com/coredns/coredns/plugin/pkg/parse" + pkgtls "github.com/coredns/coredns/plugin/pkg/tls" + + "github.com/mholt/caddy" + "github.com/mholt/caddy/caddyfile" +) + +func init() { + caddy.RegisterPlugin("grpc", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + g, err := parseGRPC(c) + if err != nil { + return plugin.Error("grpc", err) + } + + if g.len() > max { + return plugin.Error("grpc", fmt.Errorf("more than %d TOs configured: %d", max, g.len())) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + g.Next = next // Set the Next field, so the plugin chaining works. + return g + }) + + c.OnStartup(func() error { + metrics.MustRegister(c, RequestCount, RcodeCount, RequestDuration) + return nil + }) + + return nil +} + +func parseGRPC(c *caddy.Controller) (*GRPC, error) { + var ( + g *GRPC + err error + i int + ) + for c.Next() { + if i > 0 { + return nil, plugin.ErrOnce + } + i++ + g, err = parseGRPCStanza(&c.Dispenser) + if err != nil { + return nil, err + } + } + return g, nil +} + +func parseGRPCStanza(c *caddyfile.Dispenser) (*GRPC, error) { + g := newGRPC() + + if !c.Args(&g.from) { + return g, c.ArgErr() + } + g.from = plugin.Host(g.from).Normalize() + + to := c.RemainingArgs() + if len(to) == 0 { + return g, c.ArgErr() + } + + toHosts, err := parse.HostPortOrFile(to...) + if err != nil { + return g, err + } + + if g.tlsServerName != "" { + if g.tlsConfig == nil { + g.tlsConfig = new(tls.Config) + } + g.tlsConfig.ServerName = g.tlsServerName + } + for _, host := range toHosts { + pr, err := newProxy(host, g.tlsConfig) + if err != nil { + return nil, err + } + g.proxies = append(g.proxies, pr) + } + + for c.NextBlock() { + if err := parseBlock(c, g); err != nil { + return g, err + } + } + + return g, nil +} + +func parseBlock(c *caddyfile.Dispenser, g *GRPC) error { + + switch c.Val() { + case "except": + ignore := c.RemainingArgs() + if len(ignore) == 0 { + return c.ArgErr() + } + for i := 0; i < len(ignore); i++ { + ignore[i] = plugin.Host(ignore[i]).Normalize() + } + g.ignored = ignore + case "tls": + args := c.RemainingArgs() + if len(args) > 3 { + return c.ArgErr() + } + + tlsConfig, err := pkgtls.NewTLSConfigFromArgs(args...) + if err != nil { + return err + } + g.tlsConfig = tlsConfig + case "tls_servername": + if !c.NextArg() { + return c.ArgErr() + } + g.tlsServerName = c.Val() + case "policy": + if !c.NextArg() { + return c.ArgErr() + } + switch x := c.Val(); x { + case "random": + g.p = &random{} + case "round_robin": + g.p = &roundRobin{} + case "sequential": + g.p = &sequential{} + default: + return c.Errf("unknown policy '%s'", x) + } + default: + if c.Val() != "}" { + return c.Errf("unknown property '%s'", c.Val()) + } + } + + return nil +} + +const max = 15 // Maximum number of upstreams. diff --git a/plugin/grpc/setup_policy_test.go b/plugin/grpc/setup_policy_test.go new file mode 100644 index 000000000..db7da6262 --- /dev/null +++ b/plugin/grpc/setup_policy_test.go @@ -0,0 +1,47 @@ +package grpc + +import ( + "strings" + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupPolicy(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedPolicy string + expectedErr string + }{ + // positive + {"grpc . 127.0.0.1 {\npolicy random\n}\n", false, "random", ""}, + {"grpc . 127.0.0.1 {\npolicy round_robin\n}\n", false, "round_robin", ""}, + {"grpc . 127.0.0.1 {\npolicy sequential\n}\n", false, "sequential", ""}, + // negative + {"grpc . 127.0.0.1 {\npolicy random2\n}\n", true, "random", "unknown policy"}, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + g, err := parseGRPC(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, got: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("Test %d: expected error to contain: %v, found error: %v, input: %s", i, test.expectedErr, err, test.input) + } + } + + if !test.shouldErr && g.p.String() != test.expectedPolicy { + t.Errorf("Test %d: expected: %s, got: %s", i, test.expectedPolicy, g.p.String()) + } + } +} diff --git a/plugin/grpc/setup_test.go b/plugin/grpc/setup_test.go new file mode 100644 index 000000000..fb470a541 --- /dev/null +++ b/plugin/grpc/setup_test.go @@ -0,0 +1,156 @@ +package grpc + +import ( + "io/ioutil" + "os" + "reflect" + "strings" + "testing" + + "github.com/mholt/caddy" +) + +func TestSetup(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedFrom string + expectedIgnored []string + expectedErr string + }{ + // positive + {"grpc . 127.0.0.1", false, ".", nil, ""}, + {"grpc . 127.0.0.1 {\nexcept miek.nl\n}\n", false, ".", nil, ""}, + {"grpc . 127.0.0.1", false, ".", nil, ""}, + {"grpc . 127.0.0.1:53", false, ".", nil, ""}, + {"grpc . 127.0.0.1:8080", false, ".", nil, ""}, + {"grpc . [::1]:53", false, ".", nil, ""}, + {"grpc . [2003::1]:53", false, ".", nil, ""}, + // negative + {"grpc . a27.0.0.1", true, "", nil, "not an IP"}, + {"grpc . 127.0.0.1 {\nblaatl\n}\n", true, "", nil, "unknown property"}, + {`grpc . ::1 + grpc com ::2`, true, "", nil, "plugin"}, + } + + for i, test := range tests { + c := caddy.NewTestController("grpc", test.input) + g, err := parseGRPC(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, got: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("Test %d: expected error to contain: %v, found error: %v, input: %s", i, test.expectedErr, err, test.input) + } + } + + if !test.shouldErr && g.from != test.expectedFrom { + t.Errorf("Test %d: expected: %s, got: %s", i, test.expectedFrom, g.from) + } + if !test.shouldErr && test.expectedIgnored != nil { + if !reflect.DeepEqual(g.ignored, test.expectedIgnored) { + t.Errorf("Test %d: expected: %q, actual: %q", i, test.expectedIgnored, g.ignored) + } + } + } +} + +func TestSetupTLS(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedServerName string + expectedErr string + }{ + // positive + {`grpc . 127.0.0.1 { +tls_servername dns +}`, false, "dns", ""}, + {`grpc . 127.0.0.1 { +tls_servername dns +}`, false, "", ""}, + {`grpc . 127.0.0.1 { +tls +}`, false, "", ""}, + {`grpc . 127.0.0.1`, false, "", ""}, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + g, err := parseGRPC(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, got: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("Test %d: expected error to contain: %v, found error: %v, input: %s", i, test.expectedErr, err, test.input) + } + } + + if !test.shouldErr && test.expectedServerName != "" && g.tlsConfig != nil && test.expectedServerName != g.tlsConfig.ServerName { + t.Errorf("Test %d: expected: %q, actual: %q", i, test.expectedServerName, g.tlsConfig.ServerName) + } + } +} + +func TestSetupResolvconf(t *testing.T) { + const resolv = "resolv.conf" + if err := ioutil.WriteFile(resolv, + []byte(`nameserver 10.10.255.252 +nameserver 10.10.255.253`), 0666); err != nil { + t.Fatalf("Failed to write resolv.conf file: %s", err) + } + defer os.Remove(resolv) + + tests := []struct { + input string + shouldErr bool + expectedErr string + expectedNames []string + }{ + // pass + {`grpc . ` + resolv, false, "", []string{"10.10.255.252:53", "10.10.255.253:53"}}, + } + + for i, test := range tests { + c := caddy.NewTestController("grpc", test.input) + f, err := parseGRPC(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: expected error but found %s for input %s", i, err, test.input) + continue + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: expected no error but found one for input %s, got: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("Test %d: expected error to contain: %v, found error: %v, input: %s", i, test.expectedErr, err, test.input) + } + } + + if !test.shouldErr { + for j, n := range test.expectedNames { + addr := f.proxies[j].addr + if n != addr { + t.Errorf("Test %d, expected %q, got %q", j, n, addr) + } + } + } + } +}