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:
Thomas Gosteli
2026-06-15 02:54:05 +02:00
committed by GitHub
parent 3764620726
commit f2f5b5a1cc
12 changed files with 363 additions and 36 deletions

View File

@@ -1,10 +1,13 @@
package proxy
import (
"context"
"crypto/tls"
"net/http"
"sync/atomic"
"time"
"github.com/coredns/coredns/plugin/pkg/doh"
"github.com/coredns/coredns/plugin/pkg/log"
"github.com/coredns/coredns/plugin/pkg/transport"
@@ -36,14 +39,16 @@ type dnsHc struct {
proxyName string
}
const defaultTimeout = 1 * time.Second
// NewHealthChecker returns a new HealthChecker based on transport.
func NewHealthChecker(proxyName, trans string, recursionDesired bool, domain string) HealthChecker {
switch trans {
func NewHealthChecker(proxyName, protocol string, recursionDesired bool, domain string) HealthChecker {
switch protocol {
case transport.DNS, transport.TLS:
c := new(dns.Client)
c.Net = "udp"
c.ReadTimeout = 1 * time.Second
c.WriteTimeout = 1 * time.Second
c.ReadTimeout = defaultTimeout
c.WriteTimeout = defaultTimeout
return &dnsHc{
c: c,
@@ -51,9 +56,22 @@ func NewHealthChecker(proxyName, trans string, recursionDesired bool, domain str
domain: domain,
proxyName: proxyName,
}
case transport.HTTPS:
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.TLSClientConfig = new(tls.Config)
return &dohHc{
client: &http.Client{
Transport: httpTransport,
Timeout: defaultTimeout,
},
recursionDesired: recursionDesired,
domain: domain,
proxyName: proxyName,
}
}
log.Warningf("No healthchecker for transport %q", trans)
log.Warningf("No healthchecker for transport %q", protocol)
return nil
}
@@ -132,3 +150,97 @@ func (h *dnsHc) send(addr string) error {
return err
}
// dohHc is a health checker for a DNS-over-HTTPS (DoH) endpoint.
type dohHc struct {
client *http.Client
recursionDesired bool
domain string
proxyName string
}
func (h *dohHc) Check(p *Proxy) error {
err := h.send(p.addr)
if err != nil {
healthcheckFailureCount.WithLabelValues(p.proxyName, p.addr).Add(1)
p.incrementFails()
return err
}
atomic.StoreUint32(&p.fails, 0)
return nil
}
func (h *dohHc) send(addr string) error {
ping := new(dns.Msg)
ping.SetQuestion(h.domain, dns.TypeNS)
ping.RecursionDesired = h.recursionDesired
ctx, cancel := context.WithTimeout(context.Background(), h.client.Timeout)
defer cancel()
req, err := doh.NewRequestWithContext(ctx, http.MethodPost, addr, ping)
if err != nil {
return err
}
resp, err := h.client.Do(req)
if err != nil {
return err
}
// ResponseToMsg always closes the body via defer resp.Body.Close().
m, err := doh.ResponseToMsg(resp)
if err != nil {
return err
}
// If we got a header, we're alright.
if m.Response || m.Opcode == dns.OpcodeQuery {
return nil
}
return nil
}
func (h *dohHc) SetTLSConfig(cfg *tls.Config) {
h.client.Transport.(*http.Transport).TLSClientConfig = cfg
}
func (h *dohHc) GetTLSConfig() *tls.Config {
return h.client.Transport.(*http.Transport).TLSClientConfig
}
func (h *dohHc) SetRecursionDesired(recursionDesired bool) {
h.recursionDesired = recursionDesired
}
func (h *dohHc) GetRecursionDesired() bool {
return h.recursionDesired
}
func (h *dohHc) SetDomain(domain string) {
h.domain = domain
}
func (h *dohHc) GetDomain() string {
return h.domain
}
func (h *dohHc) SetTCPTransport() {
// no-op for DoH
}
func (h *dohHc) GetReadTimeout() time.Duration {
return h.client.Transport.(*http.Transport).ResponseHeaderTimeout
}
func (h *dohHc) SetReadTimeout(t time.Duration) {
h.client.Transport.(*http.Transport).ResponseHeaderTimeout = t
}
func (h *dohHc) GetWriteTimeout() time.Duration {
return h.client.Timeout
}
func (h *dohHc) SetWriteTimeout(t time.Duration) {
h.client.Timeout = t
}