From d3e13fe05d8e740e8a0e4feb7c4e56f3c4456d99 Mon Sep 17 00:00:00 2001 From: Filippo125 Date: Fri, 21 Nov 2025 05:01:59 +0100 Subject: [PATCH] Add basic support for DoH3 (#7677) --- README.md | 12 ++ core/dnsserver/register.go | 9 ++ core/dnsserver/server_https3.go | 194 +++++++++++++++++++++++++++ core/dnsserver/server_https3_test.go | 62 +++++++++ go.mod | 1 + go.sum | 2 + plugin/metrics/vars/vars.go | 7 + plugin/pkg/parse/transport.go | 7 +- plugin/pkg/transport/transport.go | 13 +- 9 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 core/dnsserver/server_https3.go create mode 100644 core/dnsserver/server_https3_test.go diff --git a/README.md b/README.md index 0e2b7b7d3..953f02950 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ CoreDNS can listen for DNS requests coming in over: * UDP/TCP (go'old DNS). * TLS - DoT ([RFC 7858](https://tools.ietf.org/html/rfc7858)). * DNS over HTTP/2 - DoH ([RFC 8484](https://tools.ietf.org/html/rfc8484)). +* DNS over HTTP/3 - DoH3 * DNS over QUIC - DoQ ([RFC 9250](https://tools.ietf.org/html/rfc9250)). * [gRPC](https://grpc.io) (not a standard). @@ -253,6 +254,17 @@ grpc://example.org:1443 https://example.org:1444 { } ~~~ +And for DNS over HTTP/3 (DoH3) use: + +~~~ corefile +https3://example.org { + whoami + tls mycert mykey +} +~~~ +in this setup, the CoreDNS will be responsible for TLS termination + + When no transport protocol is specified the default `dns://` is assumed. ## Community diff --git a/core/dnsserver/register.go b/core/dnsserver/register.go index f289ceaa5..9aa95e4ea 100644 --- a/core/dnsserver/register.go +++ b/core/dnsserver/register.go @@ -88,6 +88,8 @@ func (h *dnsContext) InspectServerBlocks(sourceFile string, serverBlocks []caddy port = transport.GRPCPort case transport.HTTPS: port = transport.HTTPSPort + case transport.HTTPS3: + port = transport.HTTPSPort } } @@ -347,6 +349,13 @@ func makeServersForGroup(addr string, group []*Config) ([]caddy.Server, error) { return nil, err } servers = append(servers, s) + + case transport.HTTPS3: + s, err := NewServerHTTPS3(addr, group) + if err != nil { + return nil, err + } + servers = append(servers, s) } } return servers, nil diff --git a/core/dnsserver/server_https3.go b/core/dnsserver/server_https3.go new file mode 100644 index 000000000..d6d1d85b8 --- /dev/null +++ b/core/dnsserver/server_https3.go @@ -0,0 +1,194 @@ +package dnsserver + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "strconv" + "time" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin/metrics/vars" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/pkg/doh" + "github.com/coredns/coredns/plugin/pkg/response" + "github.com/coredns/coredns/plugin/pkg/reuseport" + "github.com/coredns/coredns/plugin/pkg/transport" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" +) + +// ServerHTTPS3 represents a DNS-over-HTTP/3 server. +type ServerHTTPS3 struct { + *Server + httpsServer *http3.Server + listenAddr net.Addr + tlsConfig *tls.Config + quicConfig *quic.Config + validRequest func(*http.Request) bool +} + +// NewServerHTTPS3 builds the HTTP/3 (DoH3) server. +func NewServerHTTPS3(addr string, group []*Config) (*ServerHTTPS3, error) { + s, err := NewServer(addr, group) + if err != nil { + return nil, err + } + + // Extract TLS config (CoreDNS guarantees it is consistent) + var tlsConfig *tls.Config + for _, z := range s.zones { + for _, conf := range z { + tlsConfig = conf.TLSConfig + } + } + if tlsConfig == nil { + return nil, fmt.Errorf("DoH3 requires TLS, no TLS config found") + } + + // HTTP/3 requires ALPN "h3" + tlsConfig.NextProtos = []string{"h3"} + + // Request validator + var validator func(*http.Request) bool + for _, z := range s.zones { + for _, conf := range z { + validator = conf.HTTPRequestValidateFunc + } + } + if validator == nil { + validator = func(r *http.Request) bool { return r.URL.Path == doh.Path } + } + + // QUIC transport config + qconf := &quic.Config{ + MaxIdleTimeout: s.IdleTimeout, + Allow0RTT: true, + } + + h3srv := &http3.Server{ + Handler: nil, // set after constructing ServerHTTPS3 + TLSConfig: tlsConfig, + EnableDatagrams: true, + QUICConfig: qconf, + //Logger: stdlog.New(&loggerAdapter{}, "", 0), TODO: Fix it + } + + sh := &ServerHTTPS3{ + Server: s, + tlsConfig: tlsConfig, + httpsServer: h3srv, + quicConfig: qconf, + validRequest: validator, + } + + h3srv.Handler = sh + + return sh, nil +} + +var _ caddy.GracefulServer = &ServerHTTPS3{} + +// ListenPacket opens the UDP socket for QUIC. +func (s *ServerHTTPS3) ListenPacket() (net.PacketConn, error) { + return reuseport.ListenPacket("udp", s.Addr[len(transport.HTTPS3+"://"):]) +} + +// ServePacket starts serving QUIC+HTTP/3 on an existing UDP socket. +func (s *ServerHTTPS3) ServePacket(pc net.PacketConn) error { + s.m.Lock() + s.listenAddr = pc.LocalAddr() + s.m.Unlock() + // Serve HTTP/3 over QUIC + return s.httpsServer.Serve(pc) +} + +// Listen function not used in HTTP/3, but defined for compatibility +func (s *ServerHTTPS3) Listen() (net.Listener, error) { return nil, nil } +func (s *ServerHTTPS3) Serve(l net.Listener) error { return nil } + +// OnStartupComplete lists the sites served by this server +// and any relevant information, assuming Quiet is false. +func (s *ServerHTTPS3) OnStartupComplete() { + if Quiet { + return + } + out := startUpZones(transport.HTTPS3+"://", s.Addr, s.zones) + if out != "" { + fmt.Print(out) + } +} + +// Stop graceful shutdown. It blocks until the server is totally stopped. +func (s *ServerHTTPS3) Stop() error { + s.m.Lock() + defer s.m.Unlock() + if s.httpsServer != nil { + return s.httpsServer.Shutdown(context.Background()) + } + return nil +} + +// Shutdown stops the server (non gracefully). +func (s *ServerHTTPS3) Shutdown() error { + if s.httpsServer != nil { + s.httpsServer.Shutdown(context.Background()) + } + return nil +} + +// ServeHTTP is the handler for the DoH3 requests +func (s *ServerHTTPS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !s.validRequest(r) { + http.Error(w, "", http.StatusNotFound) + s.countResponse(http.StatusNotFound) + return + } + + msg, err := doh.RequestToMsg(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + s.countResponse(http.StatusBadRequest) + return + } + + // from HTTP request → DNS writer + h, p, _ := net.SplitHostPort(r.RemoteAddr) + port, _ := strconv.Atoi(p) + dw := &DoHWriter{ + laddr: s.listenAddr, + raddr: &net.UDPAddr{IP: net.ParseIP(h), Port: port}, + request: r, + } + + ctx := context.WithValue(r.Context(), Key{}, s.Server) + ctx = context.WithValue(ctx, LoopKey{}, 0) + ctx = context.WithValue(ctx, HTTPRequestKey{}, r) + + s.ServeDNS(ctx, dw, msg) + + if dw.Msg == nil { + http.Error(w, "No response", http.StatusInternalServerError) + s.countResponse(http.StatusInternalServerError) + return + } + + buf, _ := dw.Msg.Pack() + mt, _ := response.Typify(dw.Msg, time.Now().UTC()) + age := dnsutil.MinimalTTL(dw.Msg, mt) + + w.Header().Set("Content-Type", doh.MimeType) + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", uint32(age.Seconds()))) + w.Header().Set("Content-Length", strconv.Itoa(len(buf))) + w.WriteHeader(http.StatusOK) + + s.countResponse(http.StatusOK) + w.Write(buf) +} + +func (s *ServerHTTPS3) countResponse(status int) { + vars.HTTPS3ResponsesCount.WithLabelValues(s.Addr, strconv.Itoa(status)).Inc() +} diff --git a/core/dnsserver/server_https3_test.go b/core/dnsserver/server_https3_test.go new file mode 100644 index 000000000..c3a6c3184 --- /dev/null +++ b/core/dnsserver/server_https3_test.go @@ -0,0 +1,62 @@ +package dnsserver + +import ( + "bytes" + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + "github.com/miekg/dns" +) + +func testServerHTTPS3(t *testing.T, path string, validator func(*http.Request) bool) *http.Response { + t.Helper() + c := Config{ + Zone: "example.com.", + Transport: "https", + TLSConfig: &tls.Config{}, + ListenHosts: []string{"127.0.0.1"}, + Port: "443", + HTTPRequestValidateFunc: validator, + } + s, err := NewServerHTTPS3("127.0.0.1:443", []*Config{&c}) + if err != nil { + t.Log(err) + t.Fatal("could not create HTTPS3 server") + } + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeDNSKEY) + buf, err := m.Pack() + if err != nil { + t.Fatal(err) + } + + r := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(buf)) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + + return w.Result() +} + +func TestCustomHTTP3RequestValidator(t *testing.T) { + testCases := map[string]struct { + path string + expected int + validator func(*http.Request) bool + }{ + "default": {"/dns-query", http.StatusOK, nil}, + "custom validator": {"/b10cada", http.StatusOK, validator}, + "no validator set": {"/adb10c", http.StatusNotFound, nil}, + "invalid path with validator": {"/helloworld", http.StatusNotFound, validator}, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + res := testServerHTTPS3(t, tc.path, tc.validator) + if res.StatusCode != tc.expected { + t.Error("unexpected HTTP code", res.StatusCode) + } + res.Body.Close() + }) + } +} diff --git a/go.mod b/go.mod index a5d5e3475..4e7794dfe 100644 --- a/go.mod +++ b/go.mod @@ -150,6 +150,7 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/shirou/gopsutil/v4 v4.25.8-0.20250809033336-ffcdc2b7662f // indirect github.com/spf13/pflag v1.0.6 // indirect diff --git a/go.sum b/go.sum index aafa2b473..5bb30c8ac 100644 --- a/go.sum +++ b/go.sum @@ -317,6 +317,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY= github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVOB87y175cLJC/mbsgKmoDOjrBldtXvioEy96WY= diff --git a/plugin/metrics/vars/vars.go b/plugin/metrics/vars/vars.go index 7b8078509..04fb967b3 100644 --- a/plugin/metrics/vars/vars.go +++ b/plugin/metrics/vars/vars.go @@ -76,6 +76,13 @@ var ( Help: "Counter of DoH responses per server and http status code.", }, []string{"server", "status"}) + HTTPS3ResponsesCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: subsystem, + Name: "https3_responses_total", + Help: "Counter of DoH3 responses per server and http status code.", + }, []string{"server", "status"}) + QUICResponsesCount = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: plugin.Namespace, Subsystem: subsystem, diff --git a/plugin/pkg/parse/transport.go b/plugin/pkg/parse/transport.go index f0cf1c249..c01d29eb3 100644 --- a/plugin/pkg/parse/transport.go +++ b/plugin/pkg/parse/transport.go @@ -27,14 +27,17 @@ func Transport(s string) (trans string, addr string) { s = s[len(transport.GRPC+"://"):] return transport.GRPC, s + case strings.HasPrefix(s, transport.HTTPS3+"://"): + s = s[len(transport.HTTPS3+"://"):] + return transport.HTTPS3, s + case strings.HasPrefix(s, transport.HTTPS+"://"): s = s[len(transport.HTTPS+"://"):] - return transport.HTTPS, s + case strings.HasPrefix(s, transport.UNIX+"://"): s = s[len(transport.UNIX+"://"):] return transport.UNIX, s } - return transport.DNS, s } diff --git a/plugin/pkg/transport/transport.go b/plugin/pkg/transport/transport.go index cdb2c79b7..6b8b53b1a 100644 --- a/plugin/pkg/transport/transport.go +++ b/plugin/pkg/transport/transport.go @@ -2,12 +2,13 @@ package transport // These transports are supported by CoreDNS. const ( - DNS = "dns" - TLS = "tls" - QUIC = "quic" - GRPC = "grpc" - HTTPS = "https" - UNIX = "unix" + DNS = "dns" + TLS = "tls" + QUIC = "quic" + GRPC = "grpc" + HTTPS = "https" + HTTPS3 = "https3" + UNIX = "unix" ) // Port numbers for the various transports.