Files
coredns/core/dnsserver/server_https3.go
myohannes 3909cc187c plugin/proxyproto: expose PPv2 TLVs to downstream plugins as metadata (#8641)
The proxyproto plugin parsed PROXY protocol v2 headers only to recover the real
client address; the TLV vector was discarded, so downstream plugins had no way to
read values such as the AWS VPC endpoint ID, SSL client CN, or vendor-specific
custom TLVs.

Add an opt-in `tlv_metadata` option. When set, the connection's raw PPv2 TLV
vector is captured and the plugin, acting as a metadata.Provider, publishes it
under the `proxyproto/tlv` label as a hex-encoded string. Consumers decode and
split it with proxyproto.SplitTLVs. The option is off by default, so existing
deployments skip the extra per-connection processing and behave exactly as before.
PROXY protocol v1 connections, which carry no TLVs, are unaffected.

Capture spans all transports: dns:// (UDP and TCP), tls:// (DoT), https:// (DoH),
grpc://, quic:// (DoQ), and https3:// (DoH3). QUIC-based transports require
udp_session_tracking, since the PPv2 header arrives as a standalone first datagram
ahead of the QUIC handshake.

Includes unit tests for capture, the metadata provider (direct and through the
real metadata chain), v1 compatibility, and option parsing, plus README and man
page updates.

Fixes #8633

Signed-off-by: Mekias Yohannes <mekias@amazon.com>
Co-authored-by: Mekias Yohannes <mekias@amazon.com>
2026-10-15 19:28:43 -07:00

295 lines
7.9 KiB
Go

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"
clog "github.com/coredns/coredns/plugin/pkg/log"
cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto"
"github.com/coredns/coredns/plugin/pkg/response"
"github.com/coredns/coredns/plugin/pkg/reuseport"
"github.com/coredns/coredns/plugin/pkg/transport"
"github.com/miekg/dns"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
)
const (
// DefaultHTTPS3MaxStreams is the default maximum number of concurrent QUIC streams per connection.
DefaultHTTPS3MaxStreams = 256
// DefaultHTTPS3MaxHeaderBytes limits HTTP/3 header memory before requests reach the DoH handler.
DefaultHTTPS3MaxHeaderBytes = 16 << 10 // 16 KiB
)
// 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
maxStreams int
maxConnections int
}
type limitQUICListener struct {
http3.QUICListener
sem chan struct{}
}
func newLimitQUICListener(ln http3.QUICListener, maxConnections int) http3.QUICListener {
return &limitQUICListener{
QUICListener: ln,
sem: make(chan struct{}, maxConnections),
}
}
func (l *limitQUICListener) Accept(ctx context.Context) (*quic.Conn, error) {
for {
conn, err := l.QUICListener.Accept(ctx)
if err != nil {
return nil, err
}
select {
case l.sem <- struct{}{}:
go func() {
<-conn.Context().Done()
<-l.sem
}()
return conn, nil
default:
_ = conn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeExcessiveLoad), "too many connections")
}
}
}
// NewServerHTTPS3 builds the HTTP/3 (DoH3) server.
func NewServerHTTPS3(addr string, group []*Config) (*ServerHTTPS3, error) {
tlsConfig, err := sharedTLSConfig(addr, group)
if err != nil {
return nil, err
}
s, err := NewServer(addr, group)
if err != nil {
return nil, err
}
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 }
}
maxStreams := DefaultHTTPS3MaxStreams
if len(group) > 0 && group[0] != nil && group[0].MaxHTTPS3Streams != nil {
maxStreams = *group[0].MaxHTTPS3Streams
}
maxConnections := DefaultHTTPSMaxConnections
if len(group) > 0 && group[0] != nil && group[0].MaxHTTPS3Connections != nil {
maxConnections = *group[0].MaxHTTPS3Connections
}
// QUIC transport config with stream limits (0 means use QUIC default)
qconf := &quic.Config{
MaxIdleTimeout: s.IdleTimeout,
Allow0RTT: true,
}
if maxStreams > 0 {
qconf.MaxIncomingStreams = int64(maxStreams)
qconf.MaxIncomingUniStreams = int64(maxStreams)
}
h3srv := &http3.Server{
Handler: nil, // set after constructing ServerHTTPS3
TLSConfig: tlsConfig,
EnableDatagrams: true,
QUICConfig: qconf,
MaxHeaderBytes: DefaultHTTPS3MaxHeaderBytes,
// Logger: stdlog.New(&loggerAdapter{}, "", 0), TODO: Fix it
}
sh := &ServerHTTPS3{
Server: s,
tlsConfig: tlsConfig,
httpsServer: h3srv,
quicConfig: qconf,
validRequest: validator,
maxStreams: maxStreams,
maxConnections: maxConnections,
}
h3srv.Handler = sh
return sh, nil
}
var _ caddy.GracefulServer = &ServerHTTPS3{}
// ListenPacket opens the UDP socket for QUIC.
func (s *ServerHTTPS3) ListenPacket() (net.PacketConn, error) {
p, err := reuseport.ListenPacket("udp", s.Addr[len(transport.HTTPS3+"://"):])
if err != nil {
return nil, err
}
if s.connPolicy != nil {
p = &cproxyproto.PacketConn{PacketConn: p, ConnPolicy: s.connPolicy, UDPSessionTrackingTTL: s.udpSessionTrackingTTL, UDPSessionTrackingMaxSessions: s.udpSessionTrackingMaxSessions, CaptureTLVs: s.proxyProtoTLVMetadata}
}
return p, nil
}
// 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()
if s.maxConnections <= 0 {
return s.httpsServer.Serve(pc)
}
quicConfig := s.quicConfig.Clone()
if s.httpsServer.EnableDatagrams {
quicConfig.EnableDatagrams = true
}
tr := &quic.Transport{Conn: pc}
defer tr.Close()
ln, err := tr.ListenEarly(http3.ConfigureTLSConfig(s.tlsConfig), quicConfig)
if err != nil {
return err
}
defer ln.Close()
return s.httpsServer.ServeListener(newLimitQUICListener(ln, s.maxConnections))
}
// 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 != "" {
printStartup(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, raw, err := doh.RequestToMsgWire(r)
if err != nil {
clog.Debugf("DoH3 request could not be parsed: %v", err)
http.Error(w, "invalid request", 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,
}
// If proxyproto captured PPv2 TLVs, http3 stores the QUIC connection's
// RemoteAddr (our *Addr) under RemoteAddrContextKey; use it so the metadata
// provider exposes the TLVs, same as the other transports.
if ra, ok := r.Context().Value(http3.RemoteAddrContextKey).(*cproxyproto.Addr); ok {
dw.raddr = ra
}
if tsig := msg.IsTsig(); tsig != nil {
if s.TsigSecret == nil {
dw.tsigStatus = dns.ErrSecret
} else if secret, ok := s.TsigSecret[tsig.Hdr.Name]; !ok {
dw.tsigStatus = dns.ErrSecret
} else {
dw.tsigStatus = dns.TsigVerify(raw, secret, "", false)
}
}
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.MinimalTTLWithMaximum(dw.Msg, mt, dnsutil.MaximumDefaultTTL)
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()
}