feat: limit concurrent DoQ streams and goroutines (#7296)

This commit is contained in:
Ville Vesilehto 2025-05-19 03:49:21 +03:00 committed by GitHub
parent 7391755f7c
commit efaed02c6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 678 additions and 9 deletions

View File

@ -58,6 +58,14 @@ type Config struct {
// TLSConfig when listening for encrypted connections (gRPC, DNS-over-TLS). // TLSConfig when listening for encrypted connections (gRPC, DNS-over-TLS).
TLSConfig *tls.Config 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. // Timeouts for TCP, TLS and HTTPS servers.
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout time.Duration WriteTimeout time.Duration

View File

@ -7,7 +7,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"net" "net"
"github.com/coredns/coredns/plugin/metrics/vars" "github.com/coredns/coredns/plugin/metrics/vars"
@ -32,15 +31,26 @@ const (
// DoQCodeProtocolError signals that the DoQ implementation encountered // DoQCodeProtocolError signals that the DoQ implementation encountered
// a protocol error and is forcibly aborting the connection. // a protocol error and is forcibly aborting the connection.
DoQCodeProtocolError quic.ApplicationErrorCode = 2 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. // ServerQUIC represents an instance of a DNS-over-QUIC server.
type ServerQUIC struct { type ServerQUIC struct {
*Server *Server
listenAddr net.Addr listenAddr net.Addr
tlsConfig *tls.Config tlsConfig *tls.Config
quicConfig *quic.Config quicConfig *quic.Config
quicListener *quic.Listener quicListener *quic.Listener
maxStreams int
streamProcessPool chan struct{}
} }
// NewServerQUIC returns a new CoreDNS QUIC server and compiles all plugin in to it. // 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"} 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{ var quicConfig = &quic.Config{
MaxIdleTimeout: s.idleTimeout, MaxIdleTimeout: s.idleTimeout,
MaxIncomingStreams: math.MaxUint16, MaxIncomingStreams: int64(maxStreams),
MaxIncomingUniStreams: math.MaxUint16, MaxIncomingUniStreams: int64(maxStreams),
// Enable 0-RTT by default for all connections on the server-side. // Enable 0-RTT by default for all connections on the server-side.
Allow0RTT: true, 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. // ServePacket implements caddy.UDPServer interface.
@ -119,7 +145,12 @@ func (s *ServerQUIC) serveQUICConnection(conn quic.Connection) {
return 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)
} }
} }

View File

@ -15,6 +15,7 @@ var Directives = []string{
"geoip", "geoip",
"cancel", "cancel",
"tls", "tls",
"quic",
"timeouts", "timeouts",
"multisocket", "multisocket",
"reload", "reload",

View File

@ -42,6 +42,7 @@ import (
_ "github.com/coredns/coredns/plugin/multisocket" _ "github.com/coredns/coredns/plugin/multisocket"
_ "github.com/coredns/coredns/plugin/nsid" _ "github.com/coredns/coredns/plugin/nsid"
_ "github.com/coredns/coredns/plugin/pprof" _ "github.com/coredns/coredns/plugin/pprof"
_ "github.com/coredns/coredns/plugin/quic"
_ "github.com/coredns/coredns/plugin/ready" _ "github.com/coredns/coredns/plugin/ready"
_ "github.com/coredns/coredns/plugin/reload" _ "github.com/coredns/coredns/plugin/reload"
_ "github.com/coredns/coredns/plugin/rewrite" _ "github.com/coredns/coredns/plugin/rewrite"

69
man/coredns-quic.7 Normal file
View File

@ -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

View File

@ -24,6 +24,7 @@ metadata:metadata
geoip:geoip geoip:geoip
cancel:cancel cancel:cancel
tls:tls tls:tls
quic:quic
timeouts:timeouts timeouts:timeouts
multisocket:multisocket multisocket:multisocket
reload:reload reload:reload

48
plugin/quic/README.md Normal file
View File

@ -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
}
```

79
plugin/quic/setup.go Normal file
View File

@ -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
}

242
plugin/quic/setup_test.go Normal file
View File

@ -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
}

View File

@ -7,6 +7,7 @@ import (
"errors" "errors"
"io" "io"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@ -22,6 +23,16 @@ var quicCorefile = `quic://.:0 {
whoami 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) { func TestQUIC(t *testing.T) {
q, udp, _, err := CoreDNSServerAndPorts(quicCorefile) q, udp, _, err := CoreDNSServerAndPorts(quicCorefile)
if err != nil { 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 { func isProtocolErr(err error) bool {
var qAppErr *quic.ApplicationError var qAppErr *quic.ApplicationError
return errors.As(err, &qAppErr) && qAppErr.ErrorCode == 2 return errors.As(err, &qAppErr) && qAppErr.ErrorCode == 2