mirror of
https://github.com/coredns/coredns.git
synced 2026-06-15 21:50:11 -04:00
feat(forward): add doh support (#8004)
* chore(pkg/proxy): prepare for DoH implementation Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * chore(pkg/proxy): prepare for DoH implementation Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * feat(proxy): implement basic DoH resolution Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * feat(forward): implement DoH forwarding Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * feat(proxy): add basic DoH health checker Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * chore: align http transport with Go's DefaultTransport and resolve some of the TODOs Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * docs(forward): add basic documentation for DoH Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * chore: add basic tests to cover DoH Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * chore(health): unify default timeout to 1s Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * feat(forward): make doh method configurable Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * chore: remove maxIdleConnsPerHost setting & update docs Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> * chore(forward): reject https upstreams with path Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch> --------- Signed-off-by: Thomas Gosteli <thomas.gosteli@protonmail.ch>
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
|
||||
## Description
|
||||
|
||||
The *forward* plugin re-uses already opened sockets to the upstreams. It supports UDP, TCP and
|
||||
DNS-over-TLS and uses in band health checking.
|
||||
The *forward* plugin re-uses already opened sockets to the upstreams. It supports UDP, TCP,
|
||||
DNS-over-TLS, DNS-over-HTTPS and uses in band health checking.
|
||||
|
||||
When it detects an error a health check is performed. This checks runs in a loop, performing each
|
||||
check at a *0.5s* interval for as long as the upstream reports unhealthy. Once healthy we stop
|
||||
@@ -30,8 +30,8 @@ forward FROM TO...
|
||||
* **FROM** is the base domain to match for the request to be forwarded. Domains using CIDR notation
|
||||
that expand to multiple reverse zones are not fully supported; only the first expanded zone is used.
|
||||
* **TO...** are the destination endpoints to forward to. The **TO** syntax allows you to specify
|
||||
a protocol, `tls://9.9.9.9` or `dns://` (or no protocol) for plain DNS. The number of upstreams is
|
||||
limited to 15. In addition to IP addresses and files (like `/etc/resolv.conf`), **TO** can also be
|
||||
a protocol, `tls://9.9.9.9`, `https://9.9.9.9` (DoH defaults to `/dns-query` path) or `dns://` (or no protocol)
|
||||
for plain DNS. The number of upstreams is limited to 15. In addition to IP addresses and files (like `/etc/resolv.conf`), **TO** can also be
|
||||
a hostname (e.g., `my-dns.svc.cluster.local`). Hostnames are resolved to IP addresses at startup.
|
||||
See the `resolver` option below.
|
||||
|
||||
@@ -49,6 +49,7 @@ forward FROM TO... {
|
||||
max_idle_conns INTEGER
|
||||
max_fails INTEGER
|
||||
max_connect_attempts INTEGER
|
||||
doh_method GET|POST
|
||||
tls CERT KEY CA
|
||||
tls_servername NAME
|
||||
policy random|round_robin|sequential
|
||||
@@ -75,6 +76,7 @@ forward FROM TO... {
|
||||
performed for a single incoming DNS request. Default value of 0 means no per-request
|
||||
cap.
|
||||
* `expire` **DURATION**, expire (cached) connections after this time, the default is 10s.
|
||||
* `doh_method` **GET|POST**, whether to use GET or POST http method for DoH requests (defaults to POST).
|
||||
* `max_idle_conns` **INTEGER**, maximum number of idle connections to cache per upstream for reuse.
|
||||
Default is 0, which means unlimited.
|
||||
* `tls` **CERT** **KEY** **CA** define the TLS properties for TLS connection. From 0 to 3 arguments can be
|
||||
@@ -148,7 +150,7 @@ If monitoring is enabled (via the *prometheus* plugin) then the following metric
|
||||
* `coredns_proxy_conn_cache_misses_total{proxy_name="forward", to, proto}` - count of connection cache misses per upstream and protocol.
|
||||
|
||||
Where `to` is one of the upstream servers (**TO** from the config), `rcode` is the returned RCODE
|
||||
from the upstream, `proto` is the transport protocol like `udp`, `tcp`, `tcp-tls`.
|
||||
from the upstream, `proto` is the transport protocol like `udp`, `tcp`, `tcp-tls`, `https`.
|
||||
|
||||
The following metrics have recently been deprecated:
|
||||
* `coredns_forward_healthcheck_failures_total{to, rcode}`
|
||||
@@ -247,6 +249,19 @@ service with health checks.
|
||||
}
|
||||
~~~
|
||||
|
||||
The same configuration but using DNS-over-HTTPS (DoH) protocol. Note that the implementation uses the default `/dns-query`
|
||||
path (custom paths are not supported).
|
||||
|
||||
~~~ corefile
|
||||
. {
|
||||
forward . https://9.9.9.9 {
|
||||
tls_servername dns.quad9.net
|
||||
health_check 5s
|
||||
}
|
||||
cache 30
|
||||
}
|
||||
~~~
|
||||
|
||||
Or configure other domain name for health check requests
|
||||
|
||||
~~~ corefile
|
||||
@@ -330,3 +345,5 @@ Forward to an upstream identified by hostname, using a specific resolver to look
|
||||
## See Also
|
||||
|
||||
[RFC 7858](https://tools.ietf.org/html/rfc7858) for DNS over TLS.
|
||||
|
||||
[RFC 8484](https://tools.ietf.org/html/rfc8484) for DNS over HTTPS.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -52,6 +53,7 @@ type Forward struct {
|
||||
expire time.Duration
|
||||
maxAge time.Duration
|
||||
maxIdleConns int
|
||||
dohMethod string
|
||||
maxConcurrent int64
|
||||
failfastUnhealthyUpstreams bool
|
||||
failoverRcodes []int
|
||||
@@ -74,7 +76,7 @@ type Forward struct {
|
||||
|
||||
// New returns a new Forward.
|
||||
func New() *Forward {
|
||||
f := &Forward{maxfails: 2, tlsConfig: new(tls.Config), expire: defaultExpire, p: new(random), from: ".", hcInterval: hcInterval, opts: proxyPkg.Options{ForceTCP: false, PreferUDP: false, HCRecursionDesired: true, HCDomain: "."}}
|
||||
f := &Forward{maxfails: 2, tlsConfig: new(tls.Config), expire: defaultExpire, p: new(random), from: ".", hcInterval: hcInterval, dohMethod: http.MethodPost, opts: proxyPkg.Options{ForceTCP: false, PreferUDP: false, HCRecursionDesired: true, HCDomain: "."}}
|
||||
return f
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ package forward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coredns/caddy"
|
||||
"github.com/coredns/coredns/plugin/pkg/dnstest"
|
||||
"github.com/coredns/coredns/plugin/pkg/doh"
|
||||
"github.com/coredns/coredns/plugin/test"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
@@ -68,3 +71,48 @@ func TestProxyTLSFail(t *testing.T) {
|
||||
t.Fatal("Expected *not* to receive reply, but got one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHTTPS(t *testing.T) {
|
||||
s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
msg, err := doh.RequestToMsg(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ret := new(dns.Msg)
|
||||
reply := ret.SetReply(msg)
|
||||
reply.Answer = append(reply.Answer, test.A("example.org. IN A 127.0.0.1"))
|
||||
|
||||
buf, err := reply.Pack()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", doh.MimeType)
|
||||
w.Write(buf)
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
c := caddy.NewTestController("dns", "forward . "+s.URL)
|
||||
fs, err := parseForward(c)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create forwarder: %s", err)
|
||||
}
|
||||
f := fs[0]
|
||||
f.proxies[0].SetHTTPClient(s.Client())
|
||||
f.OnStartup()
|
||||
defer f.OnShutdown()
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("example.org.", dns.TypeA)
|
||||
rec := dnstest.NewRecorder(&test.ResponseWriter{})
|
||||
|
||||
if _, err := f.ServeDNS(context.TODO(), rec, m); err != nil {
|
||||
t.Fatal("Expected to receive reply, but didn't")
|
||||
}
|
||||
if x := rec.Msg.Answer[0].Header().Name; x != "example.org." {
|
||||
t.Errorf("Expected %s, got %s", "example.org.", x)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -158,6 +159,14 @@ func parseStanza(c *caddy.Controller) (*Forward, error) {
|
||||
return f, fmt.Errorf("max_age (%s) must not be less than expire (%s)", f.maxAge, f.expire)
|
||||
}
|
||||
|
||||
// Reject HTTPS upstreams that include a path, the doh implementation default to /dns-query path.
|
||||
for _, addr := range to {
|
||||
trans, h := parse.Transport(addr)
|
||||
if trans == transport.HTTPS && strings.Contains(h, "/") {
|
||||
return f, fmt.Errorf("paths are not allowed in HTTPS upstream addresses (the /dns-query path is used by default): %s", addr)
|
||||
}
|
||||
}
|
||||
|
||||
// Classify TO addresses in order, preserving config ordering.
|
||||
entries, err := classifyToAddrs(to)
|
||||
if err != nil {
|
||||
@@ -177,7 +186,7 @@ func parseStanza(c *caddy.Controller) (*Forward, error) {
|
||||
tlsServerNames := make([]string, len(toHosts))
|
||||
perServerNameProxyCount := make(map[string]int)
|
||||
transports := make([]string, len(toHosts))
|
||||
allowedTrans := map[string]bool{"dns": true, "tls": true}
|
||||
allowedTrans := map[string]bool{"dns": true, "tls": true, "https": true}
|
||||
for i, hostWithZone := range toHosts {
|
||||
host, serverName := splitZone(hostWithZone)
|
||||
trans, h := parse.Transport(host)
|
||||
@@ -223,6 +232,21 @@ func parseStanza(c *caddy.Controller) (*Forward, error) {
|
||||
f.proxies[i].SetTLSConfig(f.tlsConfig)
|
||||
}
|
||||
}
|
||||
|
||||
if transports[i] == transport.HTTPS {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.TLSClientConfig = f.tlsConfig
|
||||
httpTransport.MaxIdleConns = f.maxIdleConns
|
||||
httpTransport.MaxIdleConnsPerHost = f.maxIdleConns
|
||||
|
||||
c := http.Client{
|
||||
Transport: httpTransport,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
f.proxies[i].SetHTTPClient(&c)
|
||||
f.proxies[i].SetDOHRequestOptions(f.dohMethod)
|
||||
}
|
||||
|
||||
f.proxies[i].SetExpire(f.expire)
|
||||
f.proxies[i].SetMaxAge(f.maxAge)
|
||||
f.proxies[i].SetMaxIdleConns(f.maxIdleConns)
|
||||
@@ -365,6 +389,16 @@ func parseBlock(c *caddy.Controller, f *Forward) error {
|
||||
return fmt.Errorf("max_idle_conns can't be negative: %d", n)
|
||||
}
|
||||
f.maxIdleConns = n
|
||||
case "doh_method":
|
||||
if !c.NextArg() {
|
||||
return c.ArgErr()
|
||||
}
|
||||
switch c.Val() {
|
||||
case http.MethodPost, http.MethodGet:
|
||||
f.dohMethod = c.Val()
|
||||
default:
|
||||
return fmt.Errorf("doh_method must be either %s or %s", http.MethodPost, http.MethodGet)
|
||||
}
|
||||
case "policy":
|
||||
if !c.NextArg() {
|
||||
return c.ArgErr()
|
||||
|
||||
@@ -45,11 +45,12 @@ func TestSetup(t *testing.T) {
|
||||
{`forward . ::1
|
||||
forward com ::2`, false, ".", nil, 2, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, "plugin"},
|
||||
{"forward . tls://[2400:3200::1%dns.alidns.com]:853 {\ntls\n}\n", false, ".", nil, 2, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, ""},
|
||||
{"forward . https://127.0.0.1 \n", false, ".", nil, 2, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, ""},
|
||||
// negative
|
||||
{"forward . https://1.1.1.1/ \n", true, "", nil, 0, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, "paths are not allowed in HTTPS upstream addresses"},
|
||||
{"forward . a27.0.0.1", true, "", nil, 0, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, "failed to resolve"},
|
||||
{"forward . 127.0.0.1 {\nblaatl\n}\n", true, "", nil, 0, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, "unknown property"},
|
||||
{"forward . 127.0.0.1 {\nhealth_check 0.5s domain\n}\n", true, "", nil, 0, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, "Wrong argument count or unexpected line ending after 'domain'"},
|
||||
{"forward . https://127.0.0.1 \n", true, ".", nil, 2, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, "'https' is not supported as a destination protocol in forward: https://127.0.0.1"},
|
||||
{"forward xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 127.0.0.1 \n", true, ".", nil, 2, proxy.Options{HCRecursionDesired: true, HCDomain: "."}, "unable to normalize 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'"},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user