diff options
author | 2016-11-26 17:57:22 +0000 | |
---|---|---|
committer | 2016-11-26 17:57:22 +0000 | |
commit | 96222927a3309570b7ac7c618a28731f52845544 (patch) | |
tree | bcd3efd7c666c204b709890243e80cb5d8ee96ff /middleware | |
parent | f8b93322659fee6d0456d2da920d92b35cae9273 (diff) | |
download | coredns-96222927a3309570b7ac7c618a28731f52845544.tar.gz coredns-96222927a3309570b7ac7c618a28731f52845544.tar.zst coredns-96222927a3309570b7ac7c618a28731f52845544.zip |
middleware/httpproxy: Add (#439)
This PR adds a middleware that talks to dns.google.com over HTTPS,
meaning all your DNS traffic is encrypted when traversing your ISP and
the internet.
The `dns.google.com` address is re-resolved every 30 seconds.
Diffstat (limited to 'middleware')
-rw-r--r-- | middleware/cache/handler.go | 2 | ||||
-rw-r--r-- | middleware/httpproxy/README.md | 50 | ||||
-rw-r--r-- | middleware/httpproxy/google.go | 307 | ||||
-rw-r--r-- | middleware/httpproxy/google_test.go | 5 | ||||
-rw-r--r-- | middleware/httpproxy/metrics.go | 32 | ||||
-rw-r--r-- | middleware/httpproxy/proxy.go | 45 | ||||
-rw-r--r-- | middleware/httpproxy/setup.go | 96 | ||||
-rw-r--r-- | middleware/httpproxy/setup_test.go | 68 | ||||
-rw-r--r-- | middleware/httpproxy/tls.go | 32 | ||||
-rw-r--r-- | middleware/httpproxy/upstream.go | 92 | ||||
-rw-r--r-- | middleware/proxy/README.md | 2 |
11 files changed, 729 insertions, 2 deletions
diff --git a/middleware/cache/handler.go b/middleware/cache/handler.go index aae7206e2..77a0cea48 100644 --- a/middleware/cache/handler.go +++ b/middleware/cache/handler.go @@ -22,7 +22,7 @@ func (c *Cache) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) return c.Next.ServeDNS(ctx, w, r) } - do := state.Do() // might need more from OPT record? Like the actual bufsize? + do := state.Do() // TODO(): might need more from OPT record? Like the actual bufsize? if i, ok, expired := c.get(qname, qtype, do); ok && !expired { diff --git a/middleware/httpproxy/README.md b/middleware/httpproxy/README.md new file mode 100644 index 000000000..026fbdc71 --- /dev/null +++ b/middleware/httpproxy/README.md @@ -0,0 +1,50 @@ +# httpproxy + +*httpproxy* proxies DNS request to a proxy using HTTPS (or HTTP/2 - not implemented). Usually this + involves sending a JSON payload over this transport and translating the response back to DNS. The + current supported backend is Google, using the URL: https://dns.google.com . + +## Syntax + +In its most basic form, a simple http proxy uses this syntax: + +~~~ +httpproxy FROM TO +~~~ + +* **FROM** is the base domain to match for the request to be proxied. +* **TO** is the destination endpoint to proxy to, accepted values here are `dns.google.com`. + +For changing the defaults you can use the expanded syntax: + +~~~ +proxy FROM TO { + upstream ADDRESS... +} +~~~ + +* `upstream` defines upstream resolvers to be used (re-)resolve `dns.google.com` (or other names in the + future) every 30 seconds. When not specified the combo 8.8.8.8, 8.8.4.4 is used. + +## Metrics + +If monitoring is enabled (via the *prometheus* directive) then the following metric is exported: + +* coredns_httpproxy_request_count_total{zone, proto, family} + +## Examples + +Proxy all requests within example.org to Google's dns.google.com. + +~~~ +proxy example.org dns.google.com +~~~ + +Proxy everything, and re-lookup `dns.google.com` every 30 seconds using the resolvers specified +in /etc/resolv.conf. + +~~~ +proxy . dns.google.com { + upstream /etc/resolv.conf +} +~~~ diff --git a/middleware/httpproxy/google.go b/middleware/httpproxy/google.go new file mode 100644 index 000000000..78b5ea864 --- /dev/null +++ b/middleware/httpproxy/google.go @@ -0,0 +1,307 @@ +package httpproxy + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" + + "github.com/miekg/coredns/middleware/proxy" + + "github.com/miekg/dns" +) + +// immediate retries until this duration ends or we get a nil host. +var tryDuration = 60 * time.Second + +type google struct { + client *http.Client + upstream *simpleUpstream + addr *simpleUpstream + quit chan bool + sync.RWMutex +} + +func newGoogle() *google { return &google{client: newClient(ghost), quit: make(chan bool)} } + +func (g *google) Exchange(req *dns.Msg) (*dns.Msg, error) { + v := url.Values{} + + v.Set("name", req.Question[0].Name) + v.Set("type", fmt.Sprintf("%d", req.Question[0].Qtype)) + + start := time.Now() + + for time.Now().Sub(start) < tryDuration { + + g.RLock() + addr := g.addr.Select() + g.RUnlock() + + if addr == nil { + return nil, fmt.Errorf("no healthy upstream http hosts") + } + + atomic.AddInt64(&addr.Conns, 1) + + buf, backendErr := g.do(addr.Name, v.Encode()) + + atomic.AddInt64(&addr.Conns, -1) + + if backendErr == nil { + gm := new(googleMsg) + if err := json.Unmarshal(buf, gm); err != nil { + return nil, err + } + + m, err := toMsg(gm) + if err != nil { + return nil, err + } + + m.Id = req.Id + return m, nil + } + + log.Printf("[WARNING] Failed to connect to HTTPS backend %q: %s", ghost, backendErr) + + timeout := addr.FailTimeout + if timeout == 0 { + timeout = 10 * time.Second + } + atomic.AddInt32(&addr.Fails, 1) + go func(host *proxy.UpstreamHost, timeout time.Duration) { + time.Sleep(timeout) + atomic.AddInt32(&host.Fails, -1) + }(addr, timeout) + } + + return nil, errUnreachable +} + +// OnStartup looks up the IP address for "ghost" every 30 seconds. +func (g *google) OnStartup() error { + r := new(dns.Msg) + r.SetQuestion(dns.Fqdn(ghost), dns.TypeA) + new, err := g.lookup(r) + if err != nil { + return err + } + r.SetQuestion(dns.Fqdn(ghost), dns.TypeAAAA) + new6, err := g.lookup(r) + if err != nil { + return err + } + + up, _ := newSimpleUpstream(append(new, new6...)) + g.Lock() + g.addr = up + g.Unlock() + + go func() { + tick := time.NewTicker(30 * time.Second) + + for { + select { + case <-tick.C: + + r.SetQuestion(dns.Fqdn(ghost), dns.TypeA) + new, err := g.lookup(r) + if err != nil { + log.Printf("[WARNING] Failed to lookup A records %q: %s", ghost, err) + continue + } + r.SetQuestion(dns.Fqdn(ghost), dns.TypeAAAA) + new6, err := g.lookup(r) + if err != nil { + log.Printf("[WARNING] Failed to lookup AAAA records %q: %s", ghost, err) + continue + } + + up, _ := newSimpleUpstream(append(new, new6...)) + g.Lock() + g.addr = up + g.Unlock() + case <-g.quit: + return + } + } + }() + + return nil +} + +func (g *google) OnShutdown() error { + g.quit <- true + return nil +} + +func (g *google) SetUpstream(u *simpleUpstream) error { + g.upstream = u + return nil +} + +func (g *google) lookup(r *dns.Msg) ([]string, error) { + c := new(dns.Client) + start := time.Now() + + for time.Now().Sub(start) < tryDuration { + host := g.upstream.Select() + if host == nil { + return nil, fmt.Errorf("no healthy upstream hosts") + } + + atomic.AddInt64(&host.Conns, 1) + + m, _, backendErr := c.Exchange(r, host.Name) + + atomic.AddInt64(&host.Conns, -1) + + if backendErr == nil { + if len(m.Answer) == 0 { + return nil, fmt.Errorf("no answer section in response") + } + ret := []string{} + for _, an := range m.Answer { + if a, ok := an.(*dns.A); ok { + ret = append(ret, net.JoinHostPort(a.A.String(), "443")) + } + if a, ok := an.(*dns.AAAA); ok { + ret = append(ret, net.JoinHostPort(a.AAAA.String(), "443")) + } + } + if len(ret) > 0 { + return ret, nil + } + + return nil, fmt.Errorf("no address records in answer section") + } + + timeout := host.FailTimeout + if timeout == 0 { + timeout = 10 * time.Second + } + atomic.AddInt32(&host.Fails, 1) + go func(host *proxy.UpstreamHost, timeout time.Duration) { + time.Sleep(timeout) + atomic.AddInt32(&host.Fails, -1) + }(host, timeout) + } + return nil, fmt.Errorf("no healthy upstream hosts") +} + +func (g *google) do(addr, json string) ([]byte, error) { + url := "https://" + addr + "/resolve?" + json + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Host = ghost + + resp, err := g.client.Do(req) + if err != nil { + return nil, err + } + + buf, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get 200 status code, got %d", resp.StatusCode) + } + + return buf, nil +} + +func toMsg(g *googleMsg) (*dns.Msg, error) { + m := new(dns.Msg) + m.Rcode = g.Status + m.Truncated = g.TC + m.RecursionDesired = g.RD + m.RecursionAvailable = g.RA + m.AuthenticatedData = g.AD + m.CheckingDisabled = g.CD + + m.Question = make([]dns.Question, 1) + m.Answer = make([]dns.RR, len(g.Answer)) + m.Ns = make([]dns.RR, len(g.Authority)) + m.Extra = make([]dns.RR, len(g.Additional)) + + m.Question[0] = dns.Question{Name: g.Question[0].Name, Qtype: g.Question[0].Type, Qclass: dns.ClassINET} + + var err error + for i := 0; i < len(m.Answer); i++ { + m.Answer[i], err = toRR(g.Answer[i]) + if err != nil { + return nil, err + } + } + for i := 0; i < len(m.Ns); i++ { + m.Ns[i], err = toRR(g.Authority[i]) + if err != nil { + return nil, err + } + } + for i := 0; i < len(m.Extra); i++ { + m.Extra[i], err = toRR(g.Additional[i]) + if err != nil { + return nil, err + } + } + + return m, nil +} + +func toRR(g googleRR) (dns.RR, error) { + typ, ok := dns.TypeToString[g.Type] + if !ok { + return nil, fmt.Errorf("failed to convert type %q", g.Type) + } + + str := fmt.Sprintf("%s %d %s %s", g.Name, g.TTL, typ, g.Data) + rr, err := dns.NewRR(str) + if err != nil { + return nil, fmt.Errorf("failed to parse %q: %s", str, err) + } + return rr, nil +} + +// googleRR represents a dns.RR in another form. +type googleRR struct { + Name string + Type uint16 + TTL uint32 + Data string +} + +// googleMsg is a JSON representation of the dns.Msg. +type googleMsg struct { + Status int + TC bool + RD bool + RA bool + AD bool + CD bool + Question []struct { + Name string + Type uint16 + } + Answer []googleRR + Authority []googleRR + Additional []googleRR + Comment string +} + +const ( + ghost = "dns.google.com" +) diff --git a/middleware/httpproxy/google_test.go b/middleware/httpproxy/google_test.go new file mode 100644 index 000000000..bd435a6ff --- /dev/null +++ b/middleware/httpproxy/google_test.go @@ -0,0 +1,5 @@ +package httpproxy + +// TODO(miek): +// Test cert failures - put those in SERVFAIL messages, but attach error code in TXT +// Test connecting to a a bad host. diff --git a/middleware/httpproxy/metrics.go b/middleware/httpproxy/metrics.go new file mode 100644 index 000000000..c3822e523 --- /dev/null +++ b/middleware/httpproxy/metrics.go @@ -0,0 +1,32 @@ +package httpproxy + +import ( + "sync" + + "github.com/miekg/coredns/middleware" + + "github.com/prometheus/client_golang/prometheus" +) + +// Metrics the httpproxy middleware exports. +var ( + RequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: middleware.Namespace, + Subsystem: subsystem, + Name: "request_duration_milliseconds", + Buckets: append(prometheus.DefBuckets, []float64{50, 100, 200, 500, 1000, 2000, 3000, 4000, 5000, 10000}...), + Help: "Histogram of the time (in milliseconds) each request took.", + }, []string{"zone"}) +) + +// OnStartupMetrics sets up the metrics on startup. +func OnStartupMetrics() error { + metricsOnce.Do(func() { + prometheus.MustRegister(RequestDuration) + }) + return nil +} + +var metricsOnce sync.Once + +const subsystem = "httpproxy" diff --git a/middleware/httpproxy/proxy.go b/middleware/httpproxy/proxy.go new file mode 100644 index 000000000..6b1243dff --- /dev/null +++ b/middleware/httpproxy/proxy.go @@ -0,0 +1,45 @@ +// Package httpproxy is middleware that proxies requests to a HTTPs server doing DNS. +package httpproxy + +import ( + "errors" + "time" + + "github.com/miekg/coredns/middleware" + "github.com/miekg/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +var errUnreachable = errors.New("unreachable backend") + +// Proxy represents a middleware instance that can proxy requests to HTTPS servers. +type Proxy struct { + from string + e Exchanger + + Next middleware.Handler +} + +// ServeDNS satisfies the middleware.Handler interface. +func (p *Proxy) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + start := time.Now() + state := request.Request{W: w, Req: r} + + reply, backendErr := p.e.Exchange(r) + + if backendErr == nil { + state.SizeAndDo(reply) + + w.WriteMsg(reply) + RequestDuration.WithLabelValues(p.from).Observe(float64(time.Since(start) / time.Millisecond)) + return 0, nil + } + RequestDuration.WithLabelValues(p.from).Observe(float64(time.Since(start) / time.Millisecond)) + + return dns.RcodeServerFailure, errUnreachable +} + +// Name implements the Handler interface. +func (p Proxy) Name() string { return "httpproxy" } diff --git a/middleware/httpproxy/setup.go b/middleware/httpproxy/setup.go new file mode 100644 index 000000000..01094c908 --- /dev/null +++ b/middleware/httpproxy/setup.go @@ -0,0 +1,96 @@ +package httpproxy + +import ( + "fmt" + + "github.com/miekg/coredns/core/dnsserver" + "github.com/miekg/coredns/middleware" + + "github.com/mholt/caddy" + "github.com/mholt/caddy/caddyfile" +) + +func init() { + caddy.RegisterPlugin("httpproxy", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + p, err := httpproxyParse(c) + if err != nil { + return middleware.Error("httpproxy", err) + } + + dnsserver.GetConfig(c).AddMiddleware(func(next middleware.Handler) middleware.Handler { + p.Next = next + return p + }) + + c.OnStartup(func() error { + OnStartupMetrics() + e := p.e.OnStartup() + if e != nil { + return middleware.Error("httpproxy", e) + } + return nil + }) + c.OnShutdown(func() error { + e := p.e.OnShutdown() + if e != nil { + return middleware.Error("httpproxy", e) + } + return nil + }) + + return nil +} + +func httpproxyParse(c *caddy.Controller) (*Proxy, error) { + var p = &Proxy{} + + for c.Next() { + if !c.Args(&p.from) { + return p, c.ArgErr() + } + to := c.RemainingArgs() + if len(to) != 1 { + return p, c.ArgErr() + } + switch to[0] { + case "dns.google.com": + p.e = newGoogle() + u, _ := newSimpleUpstream([]string{"8.8.8.8:53", "8.8.4.4:53"}) + p.e.SetUpstream(u) + default: + return p, fmt.Errorf("unknown http proxy %q", to[0]) + } + + for c.NextBlock() { + if err := parseBlock(&c.Dispenser, p); err != nil { + return p, err + } + } + } + + return p, nil +} + +func parseBlock(c *caddyfile.Dispenser, p *Proxy) error { + switch c.Val() { + case "upstream": + upstreams := c.RemainingArgs() + if len(upstreams) == 0 { + return c.ArgErr() + } + u, err := newSimpleUpstream(upstreams) + if err != nil { + return err + } + p.e.SetUpstream(u) + default: + return c.Errf("unknown property '%s'", c.Val()) + } + return nil +} diff --git a/middleware/httpproxy/setup_test.go b/middleware/httpproxy/setup_test.go new file mode 100644 index 000000000..71d631220 --- /dev/null +++ b/middleware/httpproxy/setup_test.go @@ -0,0 +1,68 @@ +package httpproxy + +import ( + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/mholt/caddy" +) + +func TestSetupChaos(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedFrom string // expected from. + expectedErrContent string // substring from the expected error. Empty for positive cases. + }{ + // ok + { + `httpproxy . dns.google.com`, false, "", "", + }, + { + `httpproxy . dns.google.com { + upstream 8.8.8.8:53 + }`, false, "", "", + }, + { + `httpproxy . dns.google.com { + upstream resolv.conf + }`, false, "", "", + }, + // fail + { + `httpproxy`, true, "", "Wrong argument count or unexpected line ending after", + }, + { + `httpproxy . wns.google.com`, true, "", "unknown http proxy", + }, + } + + // Write fake resolv.conf for test + err := ioutil.WriteFile("resolv.conf", []byte("nameserver 127.0.0.1\n"), 0600) + if err != nil { + t.Fatalf("Failed to write test resolv.conf") + } + defer os.Remove("resolv.conf") + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + _, err := httpproxyParse(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 { + t.Logf("%q", err) + 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) + } + } + } +} diff --git a/middleware/httpproxy/tls.go b/middleware/httpproxy/tls.go new file mode 100644 index 000000000..9651ac1c6 --- /dev/null +++ b/middleware/httpproxy/tls.go @@ -0,0 +1,32 @@ +package httpproxy + +import ( + "crypto/tls" + "net/http" + "time" + + "github.com/miekg/dns" +) + +// Exchanger is an interface that specifies a type implementing a DNS resolver that +// uses a HTTPS server. +type Exchanger interface { + Exchange(*dns.Msg) (*dns.Msg, error) + + SetUpstream(*simpleUpstream) error + OnStartup() error + OnShutdown() error +} + +func newClient(sni string) *http.Client { + tls := &tls.Config{ServerName: sni} + + c := &http.Client{ + Timeout: time.Second * timeOut, + Transport: &http.Transport{TLSClientConfig: tls}, + } + + return c +} + +const timeOut = 5 diff --git a/middleware/httpproxy/upstream.go b/middleware/httpproxy/upstream.go new file mode 100644 index 000000000..f12de51fb --- /dev/null +++ b/middleware/httpproxy/upstream.go @@ -0,0 +1,92 @@ +package httpproxy + +import ( + "sync/atomic" + "time" + + "github.com/miekg/coredns/middleware/pkg/dnsutil" + "github.com/miekg/coredns/middleware/proxy" +) + +type simpleUpstream struct { + from string + Hosts proxy.HostPool + Policy proxy.Policy + + FailTimeout time.Duration + MaxFails int32 +} + +// newSimpleUpstream return a new simpleUpstream initialized with the addresses. +func newSimpleUpstream(hosts []string) (*simpleUpstream, error) { + upstream := &simpleUpstream{ + Hosts: nil, + Policy: &proxy.Random{}, + FailTimeout: 10 * time.Second, + MaxFails: 3, + } + + toHosts, err := dnsutil.ParseHostPortOrFile(hosts...) + if err != nil { + return upstream, err + } + + upstream.Hosts = make([]*proxy.UpstreamHost, len(toHosts)) + for i, host := range toHosts { + uh := &proxy.UpstreamHost{ + Name: host, + Conns: 0, + Fails: 0, + FailTimeout: upstream.FailTimeout, + Unhealthy: false, + + CheckDown: func(upstream *simpleUpstream) proxy.UpstreamHostDownFunc { + return func(uh *proxy.UpstreamHost) bool { + if uh.Unhealthy { + return true + } + + fails := atomic.LoadInt32(&uh.Fails) + if fails >= upstream.MaxFails && upstream.MaxFails != 0 { + return true + } + return false + } + }(upstream), + } + upstream.Hosts[i] = uh + } + return upstream, nil +} + +func (u *simpleUpstream) From() string { return u.from } +func (u *simpleUpstream) Options() proxy.Options { return proxy.Options{} } +func (u *simpleUpstream) IsAllowedPath(name string) bool { return true } + +func (u *simpleUpstream) Select() *proxy.UpstreamHost { + pool := u.Hosts + if len(pool) == 1 { + if pool[0].Down() { + return nil + } + return pool[0] + } + allDown := true + for _, host := range pool { + if !host.Down() { + allDown = false + break + } + } + if allDown { + return nil + } + + if u.Policy == nil { + h := (&proxy.Random{}).Select(pool) + return h + } + + h := u.Policy.Select(pool) + return h +} diff --git a/middleware/proxy/README.md b/middleware/proxy/README.md index 837f166fc..e59a5b060 100644 --- a/middleware/proxy/README.md +++ b/middleware/proxy/README.md @@ -13,7 +13,7 @@ In its most basic form, a simple reverse proxy uses this syntax: proxy FROM TO ~~~ -* **FROM** is the base path to match for the request to be proxied +* **FROM** is the base domain to match for the request to be proxied * **TO** is the destination endpoint to proxy to However, advanced features including load balancing can be utilized with an expanded syntax: |