diff --git a/core/dnsserver/config.go b/core/dnsserver/config.go index 27f5c2105..168120795 100644 --- a/core/dnsserver/config.go +++ b/core/dnsserver/config.go @@ -58,6 +58,14 @@ type Config struct { // TLSConfig when listening for encrypted connections (gRPC, DNS-over-TLS). TLSConfig *tls.Config + // MaxQUICStreams defines the maximum number of concurrent QUIC streams for a QUIC server. + // This is nil if not specified, allowing for a default to be used. + MaxQUICStreams *int + + // MaxQUICWorkerPoolSize defines the size of the worker pool for processing QUIC streams. + // This is nil if not specified, allowing for a default to be used. + MaxQUICWorkerPoolSize *int + // Timeouts for TCP, TLS and HTTPS servers. ReadTimeout time.Duration WriteTimeout time.Duration diff --git a/core/dnsserver/server_quic.go b/core/dnsserver/server_quic.go index 7acfd788c..1a3b2c456 100644 --- a/core/dnsserver/server_quic.go +++ b/core/dnsserver/server_quic.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "math" "net" "github.com/coredns/coredns/plugin/metrics/vars" @@ -32,15 +31,26 @@ const ( // DoQCodeProtocolError signals that the DoQ implementation encountered // a protocol error and is forcibly aborting the connection. DoQCodeProtocolError quic.ApplicationErrorCode = 2 + + // DefaultMaxQUICStreams is the default maximum number of concurrent QUIC streams + // on a per-connection basis. RFC 9250 (DNS-over-QUIC) does not require a high + // concurrent-stream limit; normal stub or recursive resolvers open only a handful + // of streams in parallel. This default (256) is a safe upper bound. + DefaultMaxQUICStreams = 256 + + // DefaultQUICStreamWorkers is the default number of workers for processing QUIC streams. + DefaultQUICStreamWorkers = 1024 ) // ServerQUIC represents an instance of a DNS-over-QUIC server. type ServerQUIC struct { *Server - listenAddr net.Addr - tlsConfig *tls.Config - quicConfig *quic.Config - quicListener *quic.Listener + listenAddr net.Addr + tlsConfig *tls.Config + quicConfig *quic.Config + quicListener *quic.Listener + maxStreams int + streamProcessPool chan struct{} } // NewServerQUIC returns a new CoreDNS QUIC server and compiles all plugin in to it. @@ -63,15 +73,31 @@ func NewServerQUIC(addr string, group []*Config) (*ServerQUIC, error) { tlsConfig.NextProtos = []string{"doq"} } + maxStreams := DefaultMaxQUICStreams + if len(group) > 0 && group[0] != nil && group[0].MaxQUICStreams != nil { + maxStreams = *group[0].MaxQUICStreams + } + + streamProcessPoolSize := DefaultQUICStreamWorkers + if len(group) > 0 && group[0] != nil && group[0].MaxQUICWorkerPoolSize != nil { + streamProcessPoolSize = *group[0].MaxQUICWorkerPoolSize + } + var quicConfig = &quic.Config{ MaxIdleTimeout: s.idleTimeout, - MaxIncomingStreams: math.MaxUint16, - MaxIncomingUniStreams: math.MaxUint16, + MaxIncomingStreams: int64(maxStreams), + MaxIncomingUniStreams: int64(maxStreams), // Enable 0-RTT by default for all connections on the server-side. Allow0RTT: true, } - return &ServerQUIC{Server: s, tlsConfig: tlsConfig, quicConfig: quicConfig}, nil + return &ServerQUIC{ + Server: s, + tlsConfig: tlsConfig, + quicConfig: quicConfig, + maxStreams: maxStreams, + streamProcessPool: make(chan struct{}, streamProcessPoolSize), + }, nil } // ServePacket implements caddy.UDPServer interface. @@ -119,7 +145,12 @@ func (s *ServerQUIC) serveQUICConnection(conn quic.Connection) { return } - go s.serveQUICStream(stream, conn) + // Use a bounded worker pool + s.streamProcessPool <- struct{}{} // Acquire a worker slot, may block + go func(st quic.Stream, cn quic.Connection) { + defer func() { <-s.streamProcessPool }() // Release worker slot + s.serveQUICStream(st, cn) + }(stream, conn) } } diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index 56174955c..9bfcb1f31 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -15,6 +15,7 @@ var Directives = []string{ "geoip", "cancel", "tls", + "quic", "timeouts", "multisocket", "reload", diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index 12bb4ce15..3aa6b10b5 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -42,6 +42,7 @@ import ( _ "github.com/coredns/coredns/plugin/multisocket" _ "github.com/coredns/coredns/plugin/nsid" _ "github.com/coredns/coredns/plugin/pprof" + _ "github.com/coredns/coredns/plugin/quic" _ "github.com/coredns/coredns/plugin/ready" _ "github.com/coredns/coredns/plugin/reload" _ "github.com/coredns/coredns/plugin/rewrite" diff --git a/man/coredns-quic.7 b/man/coredns-quic.7 new file mode 100644 index 000000000..6301ec229 --- /dev/null +++ b/man/coredns-quic.7 @@ -0,0 +1,69 @@ +.\" Generated by Mmark Markdown Processer - mmark.miek.nl +.TH "COREDNS-QUIC" 7 "May 2025" "CoreDNS" "CoreDNS Plugins" + +.SH "NAME" +.PP +\fIquic\fP - configures DNS-over-QUIC (DoQ) server options. + +.SH "DESCRIPTION" +.PP +The \fIquic\fP plugin allows you to configure parameters for the DNS-over-QUIC (DoQ) server to fine-tune the security posture and performance of the server. + +.PP +This plugin can only be used once per quic Server Block. + +.SH "SYNTAX" +.PP +.RS + +.nf +quic { + max\_streams POSITIVE\_INTEGER + worker\_pool\_size POSITIVE\_INTEGER +} + +.fi +.RE + +.IP \(bu 4 +\fB\fCmax_streams\fR limits the number of concurrent QUIC streams per connection. This helps prevent DoS attacks where an attacker could open many streams on a single connection, exhausting server resources. The default value is 256 if not specified. +.IP \(bu 4 +\fB\fCworker_pool_size\fR defines the size of the worker pool for processing QUIC streams across all connections. The default value is 512 if not specified. This limits the total number of concurrent streams that can be processed across all connections. + + +.SH "EXAMPLES" +.PP +Enable DNS-over-QUIC with default settings (256 concurrent streams per connection, 512 worker pool size): + +.PP +.RS + +.nf +quic://.:8853 { + tls cert.pem key.pem + quic + whoami +} + +.fi +.RE + +.PP +Set custom limits for maximum QUIC streams per connection and worker pool size: + +.PP +.RS + +.nf +quic://.:8853 { + tls cert.pem key.pem + quic { + max\_streams 16 + worker\_pool\_size 65536 + } + whoami +} + +.fi +.RE + diff --git a/plugin.cfg b/plugin.cfg index 36bd367da..6b3d716df 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -24,6 +24,7 @@ metadata:metadata geoip:geoip cancel:cancel tls:tls +quic:quic timeouts:timeouts multisocket:multisocket reload:reload diff --git a/plugin/quic/README.md b/plugin/quic/README.md new file mode 100644 index 000000000..63fe56d12 --- /dev/null +++ b/plugin/quic/README.md @@ -0,0 +1,48 @@ +# quic + +## Name + +*quic* - configures DNS-over-QUIC (DoQ) server options. + +## Description + +The *quic* plugin allows you to configure parameters for the DNS-over-QUIC (DoQ) server to fine-tune the security posture and performance of the server. + +This plugin can only be used once per quic Server Block. + +## Syntax + +```txt +quic { + max_streams POSITIVE_INTEGER + worker_pool_size POSITIVE_INTEGER +} +``` + +* `max_streams` limits the number of concurrent QUIC streams per connection. This helps prevent DoS attacks where an attacker could open many streams on a single connection, exhausting server resources. The default value is 256 if not specified. +* `worker_pool_size` defines the size of the worker pool for processing QUIC streams across all connections. The default value is 512 if not specified. This limits the total number of concurrent streams that can be processed across all connections. + +## Examples + +Enable DNS-over-QUIC with default settings (256 concurrent streams per connection, 512 worker pool size): + +``` +quic://.:8853 { + tls cert.pem key.pem + quic + whoami +} +``` + +Set custom limits for maximum QUIC streams per connection and worker pool size: + +``` +quic://.:8853 { + tls cert.pem key.pem + quic { + max_streams 16 + worker_pool_size 65536 + } + whoami +} +``` diff --git a/plugin/quic/setup.go b/plugin/quic/setup.go new file mode 100644 index 000000000..4c49101fd --- /dev/null +++ b/plugin/quic/setup.go @@ -0,0 +1,79 @@ +package quic + +import ( + "strconv" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" +) + +func init() { + caddy.RegisterPlugin("quic", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + err := parseQuic(c) + if err != nil { + return plugin.Error("quic", err) + } + return nil +} + +func parseQuic(c *caddy.Controller) error { + config := dnsserver.GetConfig(c) + + // Skip the "quic" directive itself + c.Next() + + // Get any arguments on the "quic" line + args := c.RemainingArgs() + if len(args) > 0 { + return c.ArgErr() + } + + // Process all nested directives in the block + for c.NextBlock() { + switch c.Val() { + case "max_streams": + args := c.RemainingArgs() + if len(args) != 1 { + return c.ArgErr() + } + val, err := strconv.Atoi(args[0]) + if err != nil { + return c.Errf("invalid max_streams value '%s': %v", args[0], err) + } + if val <= 0 { + return c.Errf("max_streams must be a positive integer: %d", val) + } + if config.MaxQUICStreams != nil { + return c.Err("max_streams already defined for this server block") + } + config.MaxQUICStreams = &val + case "worker_pool_size": + args := c.RemainingArgs() + if len(args) != 1 { + return c.ArgErr() + } + val, err := strconv.Atoi(args[0]) + if err != nil { + return c.Errf("invalid worker_pool_size value '%s': %v", args[0], err) + } + if val <= 0 { + return c.Errf("worker_pool_size must be a positive integer: %d", val) + } + if config.MaxQUICWorkerPoolSize != nil { + return c.Err("worker_pool_size already defined for this server block") + } + config.MaxQUICWorkerPoolSize = &val + default: + return c.Errf("unknown property '%s'", c.Val()) + } + } + + return nil +} diff --git a/plugin/quic/setup_test.go b/plugin/quic/setup_test.go new file mode 100644 index 000000000..48a982bf3 --- /dev/null +++ b/plugin/quic/setup_test.go @@ -0,0 +1,242 @@ +package quic + +import ( + "fmt" + "strings" + "testing" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" +) + +func TestQuicSetup(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedMaxStreams *int + expectedWorkerPoolSize *int + expectedErrContent string + }{ + // Valid configurations + { + input: `quic`, + shouldErr: false, + expectedMaxStreams: nil, + expectedWorkerPoolSize: nil, + }, + { + input: `quic { + }`, + shouldErr: false, + expectedMaxStreams: nil, + expectedWorkerPoolSize: nil, + }, + { + input: `quic { + max_streams 100 + }`, + shouldErr: false, + expectedMaxStreams: pint(100), + expectedWorkerPoolSize: nil, + }, + { + input: `quic { + worker_pool_size 1000 + }`, + shouldErr: false, + expectedMaxStreams: nil, + expectedWorkerPoolSize: pint(1000), + }, + { + input: `quic { + max_streams 100 + worker_pool_size 1000 + }`, + shouldErr: false, + expectedMaxStreams: pint(100), + expectedWorkerPoolSize: pint(1000), + }, + { + input: `quic { + # Comment + }`, + shouldErr: false, + expectedMaxStreams: nil, + expectedWorkerPoolSize: nil, + }, + // Invalid configurations + { + input: `quic arg`, + shouldErr: true, + expectedErrContent: "Wrong argument count", + }, + { + input: `quic { + max_streams + }`, + shouldErr: true, + expectedErrContent: "Wrong argument count", + }, + { + input: `quic { + max_streams abc + }`, + shouldErr: true, + expectedErrContent: "invalid max_streams value", + }, + { + input: `quic { + max_streams 0 + }`, + shouldErr: true, + expectedErrContent: "positive integer", + }, + { + input: `quic { + max_streams -10 + }`, + shouldErr: true, + expectedErrContent: "positive integer", + }, + { + input: `quic { + worker_pool_size + }`, + shouldErr: true, + expectedErrContent: "Wrong argument count", + }, + { + input: `quic { + worker_pool_size abc + }`, + shouldErr: true, + expectedErrContent: "invalid worker_pool_size value", + }, + { + input: `quic { + worker_pool_size 0 + }`, + shouldErr: true, + expectedErrContent: "positive integer", + }, + { + input: `quic { + worker_pool_size -10 + }`, + shouldErr: true, + expectedErrContent: "positive integer", + }, + { + input: `quic { + max_streams 100 + max_streams 200 + }`, + shouldErr: true, + expectedErrContent: "already defined", + expectedMaxStreams: pint(100), + }, + { + input: `quic { + worker_pool_size 1000 + worker_pool_size 2000 + }`, + shouldErr: true, + expectedErrContent: "already defined", + expectedWorkerPoolSize: pint(1000), + }, + { + input: `quic { + unknown_directive + }`, + shouldErr: true, + expectedErrContent: "unknown property", + }, + { + input: `quic { + max_streams 100 200 + }`, + shouldErr: true, + expectedErrContent: "Wrong argument count", + }, + { + input: `quic { + worker_pool_size 1000 2000 + }`, + shouldErr: true, + expectedErrContent: "Wrong argument count", + }, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + err := setup(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d (%s): Expected error but found none", i, test.input) + continue + } + if !test.shouldErr && err != nil { + t.Errorf("Test %d (%s): Expected no error but found: %v", i, test.input, err) + continue + } + + if test.shouldErr && !strings.Contains(err.Error(), test.expectedErrContent) { + t.Errorf("Test %d (%s): Expected error containing '%s', but got: %v", + i, test.input, test.expectedErrContent, err) + continue + } + + if !test.shouldErr || (test.shouldErr && strings.Contains(test.expectedErrContent, "already defined")) { + config := dnsserver.GetConfig(c) + assertMaxStreamsValue(t, i, test.input, config.MaxQUICStreams, test.expectedMaxStreams) + assertWorkerPoolSizeValue(t, i, test.input, config.MaxQUICWorkerPoolSize, test.expectedWorkerPoolSize) + } + } +} + +// assertMaxStreamsValue compares the actual MaxQUICStreams value with the expected one +func assertMaxStreamsValue(t *testing.T, testIndex int, testInput string, actual, expected *int) { + if actual == nil && expected == nil { + return + } + + if (actual == nil) != (expected == nil) { + t.Errorf("Test %d (%s): Expected MaxQUICStreams to be %v, but got %v", + testIndex, testInput, formatNilableInt(expected), formatNilableInt(actual)) + return + } + + if *actual != *expected { + t.Errorf("Test %d (%s): Expected MaxQUICStreams to be %d, but got %d", + testIndex, testInput, *expected, *actual) + } +} + +// assertWorkerPoolSizeValue compares the actual MaxQUICWorkerPoolSize value with the expected one +func assertWorkerPoolSizeValue(t *testing.T, testIndex int, testInput string, actual, expected *int) { + if actual == nil && expected == nil { + return + } + + if (actual == nil) != (expected == nil) { + t.Errorf("Test %d (%s): Expected MaxQUICWorkerPoolSize to be %v, but got %v", + testIndex, testInput, formatNilableInt(expected), formatNilableInt(actual)) + return + } + + if *actual != *expected { + t.Errorf("Test %d (%s): Expected MaxQUICWorkerPoolSize to be %d, but got %d", + testIndex, testInput, *expected, *actual) + } +} + +func formatNilableInt(v *int) string { + if v == nil { + return "nil" + } + return fmt.Sprintf("%d", *v) +} + +func pint(i int) *int { + return &i +} diff --git a/test/quic_test.go b/test/quic_test.go index 002d232a9..cff86531d 100644 --- a/test/quic_test.go +++ b/test/quic_test.go @@ -7,6 +7,7 @@ import ( "errors" "io" "strings" + "sync" "testing" "time" @@ -22,6 +23,16 @@ var quicCorefile = `quic://.:0 { whoami }` +// Corefile with custom stream limits +var quicLimitCorefile = `quic://.:0 { + tls ../plugin/tls/test_cert.pem ../plugin/tls/test_key.pem ../plugin/tls/test_ca.pem + quic { + max_streams 5 + worker_pool_size 10 + } + whoami + }` + func TestQUIC(t *testing.T) { q, udp, _, err := CoreDNSServerAndPorts(quicCorefile) if err != nil { @@ -117,6 +128,184 @@ func TestQUICProtocolError(t *testing.T) { } } +// TestQUICStreamLimits tests that the max_streams limit is correctly enforced +func TestQUICStreamLimits(t *testing.T) { + q, udp, _, err := CoreDNSServerAndPorts(quicLimitCorefile) + if err != nil { + t.Fatalf("Could not get CoreDNS serving instance: %s", err) + } + defer q.Stop() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, err := quic.DialAddr(ctx, convertAddress(udp), generateTLSConfig(), nil) + if err != nil { + t.Fatalf("Expected no error but got: %s", err) + } + + m := createTestMsg() + + // Test opening exactly the max number of streams + var wg sync.WaitGroup + streamCount := 5 // Must match max_streams in quicLimitCorefile + successCount := 0 + var mu sync.Mutex + + // Create a slice to store all the streams so we can keep them open + streams := make([]quic.Stream, 0, streamCount) + streamsMu := sync.Mutex{} + + // Attempt to open exactly the configured number of streams + for i := 0; i < streamCount; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + // Open stream + streamSync, err := conn.OpenStreamSync(ctx) + if err != nil { + t.Logf("Stream %d: Failed to open: %s", idx, err) + return + } + + // Store the stream so we can keep it open + streamsMu.Lock() + streams = append(streams, streamSync) + streamsMu.Unlock() + + // Write DNS message + _, err = streamSync.Write(m) + if err != nil { + t.Logf("Stream %d: Failed to write: %s", idx, err) + return + } + + // Read response + sizeBuf := make([]byte, 2) + _, err = io.ReadFull(streamSync, sizeBuf) + if err != nil { + t.Logf("Stream %d: Failed to read size: %s", idx, err) + return + } + + size := binary.BigEndian.Uint16(sizeBuf) + buf := make([]byte, size) + _, err = io.ReadFull(streamSync, buf) + if err != nil { + t.Logf("Stream %d: Failed to read response: %s", idx, err) + return + } + + mu.Lock() + successCount++ + mu.Unlock() + }(i) + } + + wg.Wait() + + if successCount != streamCount { + t.Errorf("Expected all %d streams to succeed, but only %d succeeded", streamCount, successCount) + } + + // Now try to open more streams beyond the limit while keeping existing streams open + // The QUIC protocol doesn't immediately reject streams; they might be allowed + // to open but will be blocked (flow control) until other streams close + + // First, make sure none of our streams have been closed + for i, s := range streams { + if s == nil { + t.Errorf("Stream %d is nil", i) + continue + } + } + + // Try to open a batch of additional streams - with streams limited to 5, + // these should either block or be queued but should not allow concurrent use + extraCount := 10 + extraSuccess := 0 + var extraSuccessMu sync.Mutex + + // Set a shorter timeout for these attempts + extraCtx, extraCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer extraCancel() + + var extraWg sync.WaitGroup + + // Create a channel to signal test completion + done := make(chan struct{}) + + // Launch goroutines to attempt opening additional streams + for i := 0; i < extraCount; i++ { + extraWg.Add(1) + go func(idx int) { + defer extraWg.Done() + + select { + case <-done: + return // Test is finishing, abandon attempts + default: + // Continue with the test + } + + // Attempt to open an additional stream + stream, err := conn.OpenStreamSync(extraCtx) + if err != nil { + t.Logf("Extra stream %d correctly failed to open: %s", idx, err) + return + } + + // If we got this far, we managed to open a stream + // But we shouldn't be able to use more than max_streams concurrently + _, err = stream.Write(m) + if err != nil { + t.Logf("Extra stream %d failed to write: %s", idx, err) + return + } + + // Read response + sizeBuf := make([]byte, 2) + _, err = io.ReadFull(stream, sizeBuf) + if err != nil { + t.Logf("Extra stream %d failed to read: %s", idx, err) + return + } + + // This stream completed successfully + extraSuccessMu.Lock() + extraSuccess++ + extraSuccessMu.Unlock() + + // Close the stream explicitly + _ = stream.Close() + }(i) + } + + // Start closing original streams after a delay + // This should allow extra streams to proceed as slots become available + time.Sleep(500 * time.Millisecond) + + // Close all the original streams + for _, s := range streams { + _ = s.Close() + } + + // Allow extra streams some time to progress + extraWg.Wait() + close(done) + + // Since original streams are now closed, extra streams might succeed + // But we shouldn't see more than max_streams succeed during the blocked phase + if extraSuccess > streamCount { + t.Logf("Warning: %d extra streams succeeded, which is more than the limit of %d. This might be because original streams were closed.", + extraSuccess, streamCount) + } + + t.Logf("%d/%d extra streams were able to complete after original streams were closed", + extraSuccess, extraCount) +} + func isProtocolErr(err error) bool { var qAppErr *quic.ApplicationError return errors.As(err, &qAppErr) && qAppErr.ErrorCode == 2