plugin/quic: add max_connections directive (#8610)

* plugin/quic: add max_connections directive

Brings quic to parity with https and https3, which both already expose
max_connections. The server-side plumbing already exists and is already
wired up: core/dnsserver/config.go declares MaxQUICConnections *int, and
server_quic.go already consumes it (DefaultQUICMaxConnections = 200,
gates accepted connections on a semaphore sized from it). It was just
never parsed from a Corefile directive for the quic plugin itself, so
setting it today fails with "unknown property 'max_connections'".

Adds the max_connections case to parseQuic, mirroring https3's exact
validation (non-negative integer, 0 disables the limit, duplicate
definition rejected) since the two share a config field. #8187 did the
same shaped fix for https3 reaching parity with https; this does the
equivalent for quic, the one remaining sibling without it.

Extends TestQuicSetup with max_connections coverage (valid value, 0,
missing arg, non-numeric, negative, duplicate definition, extra arg).
Verified it fails without the setup.go change (unknown property error)
and passes with it.

Signed-off-by: stackedbyaradhya <aradhyaj736@gmail.com>

* core/dnsserver: propagate MaxQUICConnections across server block keys

Addresses review feedback on this PR: Caddy only runs directive setup
for the first key in a server block, and the QUIC server reads
MaxQUICConnections from group[0]. propagateConfigParams copies
listener-wide settings from the first config in a block to the others,
but it did not propagate this field. In a multi-transport block where
the quic key isn't first, e.g.:

    .:53 quic://.:8853 {
        quic {
            max_connections 1
        }
    }

the value was stored on the first (DNS) config while the QUIC config
kept MaxQUICConnections nil, so NewServerQUIC silently fell back to the
default limit of 200 instead of the configured one.

Adds the propagation line, mirroring the existing MaxHTTPSStreams entry
(and its comment) added for the equivalent key-order issue on https.

Added TestPropagateConfigParamsMaxQUICConnections, mirroring the
existing TestPropagateConfigParamsMaxHTTPSStreams. Verified it fails
without the register.go change and passes with it.

Signed-off-by: stackedbyaradhya <aradhyaj736@gmail.com>

---------

Signed-off-by: stackedbyaradhya <aradhyaj736@gmail.com>
This commit is contained in:
Aradhya Jain
2026-10-08 17:54:25 +05:30
committed by GitHub
parent e886525620
commit 45ac7fe659
5 changed files with 112 additions and 2 deletions

View File

@@ -118,6 +118,19 @@ func TestPropagateConfigParamsMaxHTTPSStreams(t *testing.T) {
} }
} }
func TestPropagateConfigParamsMaxQUICConnections(t *testing.T) {
n := 7
first := &Config{MaxQUICConnections: &n}
first.firstConfigInBlock = first
second := &Config{firstConfigInBlock: first}
propagateConfigParams([]*Config{first, second})
if second.MaxQUICConnections == nil || *second.MaxQUICConnections != n {
t.Fatalf("expected MaxQUICConnections to propagate to second config as %d, got %v", n, second.MaxQUICConnections)
}
}
func TestPropagateConfigParamsAllowedOpcodes(t *testing.T) { func TestPropagateConfigParamsAllowedOpcodes(t *testing.T) {
first := &Config{} first := &Config{}
first.firstConfigInBlock = first first.firstConfigInBlock = first

View File

@@ -320,6 +320,10 @@ func propagateConfigParams(configs []*Config) {
// Propagate MaxHTTPSStreams so a `https { max_streams N }` set once in a // Propagate MaxHTTPSStreams so a `https { max_streams N }` set once in a
// server block applies to the block's HTTPS key regardless of key order. // server block applies to the block's HTTPS key regardless of key order.
c.MaxHTTPSStreams = c.firstConfigInBlock.MaxHTTPSStreams c.MaxHTTPSStreams = c.firstConfigInBlock.MaxHTTPSStreams
// Propagate MaxQUICConnections so a `quic { max_connections N }` set once
// in a server block applies to the block's QUIC key regardless of key order.
c.MaxQUICConnections = c.firstConfigInBlock.MaxQUICConnections
} }
} }

View File

@@ -15,16 +15,18 @@ This plugin can only be used once per quic Server Block.
```txt ```txt
quic { quic {
max_streams POSITIVE_INTEGER max_streams POSITIVE_INTEGER
max_connections NON_NEGATIVE_INTEGER
worker_pool_size 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. * `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.
* `max_connections` limits the number of concurrent QUIC connections accepted by the server. The default value is 200 if not specified. Connections above the configured limit are rejected. Set to 0 to disable the CoreDNS connection limit.
* `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. * `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 ## Examples
Enable DNS-over-QUIC with default settings (256 concurrent streams per connection, 512 worker pool size): Enable DNS-over-QUIC with default settings (256 concurrent streams per connection, 200 concurrent connections, 512 worker pool size):
``` ```
quic://.:8853 { quic://.:8853 {
@@ -34,13 +36,14 @@ quic://.:8853 {
} }
``` ```
Set custom limits for maximum QUIC streams per connection and worker pool size: Set custom limits for maximum QUIC streams per connection, maximum connections, and worker pool size:
``` ```
quic://.:8853 { quic://.:8853 {
tls cert.pem key.pem tls cert.pem key.pem
quic { quic {
max_streams 16 max_streams 16
max_connections 50
worker_pool_size 65536 worker_pool_size 65536
} }
whoami whoami

View File

@@ -54,6 +54,22 @@ func parseQuic(c *caddy.Controller) error {
return c.Err("max_streams already defined for this server block") return c.Err("max_streams already defined for this server block")
} }
config.MaxQUICStreams = &val config.MaxQUICStreams = &val
case "max_connections":
args := c.RemainingArgs()
if len(args) != 1 {
return c.ArgErr()
}
val, err := strconv.Atoi(args[0])
if err != nil {
return c.Errf("invalid max_connections value '%s': %v", args[0], err)
}
if val < 0 {
return c.Errf("max_connections must be a non-negative integer: %d", val)
}
if config.MaxQUICConnections != nil {
return c.Err("max_connections already defined for this server block")
}
config.MaxQUICConnections = &val
case "worker_pool_size": case "worker_pool_size":
args := c.RemainingArgs() args := c.RemainingArgs()
if len(args) != 1 { if len(args) != 1 {

View File

@@ -14,6 +14,7 @@ func TestQuicSetup(t *testing.T) {
input string input string
shouldErr bool shouldErr bool
expectedMaxStreams *int expectedMaxStreams *int
expectedMaxConnections *int
expectedWorkerPoolSize *int expectedWorkerPoolSize *int
expectedErrContent string expectedErrContent string
}{ }{
@@ -39,6 +40,20 @@ func TestQuicSetup(t *testing.T) {
expectedMaxStreams: new(100), expectedMaxStreams: new(100),
expectedWorkerPoolSize: nil, expectedWorkerPoolSize: nil,
}, },
{
input: `quic {
max_connections 50
}`,
shouldErr: false,
expectedMaxConnections: new(50),
},
{
input: `quic {
max_connections 0
}`,
shouldErr: false,
expectedMaxConnections: new(0),
},
{ {
input: `quic { input: `quic {
worker_pool_size 1000 worker_pool_size 1000
@@ -50,10 +65,12 @@ func TestQuicSetup(t *testing.T) {
{ {
input: `quic { input: `quic {
max_streams 100 max_streams 100
max_connections 50
worker_pool_size 1000 worker_pool_size 1000
}`, }`,
shouldErr: false, shouldErr: false,
expectedMaxStreams: new(100), expectedMaxStreams: new(100),
expectedMaxConnections: new(50),
expectedWorkerPoolSize: new(1000), expectedWorkerPoolSize: new(1000),
}, },
{ {
@@ -98,6 +115,43 @@ func TestQuicSetup(t *testing.T) {
shouldErr: true, shouldErr: true,
expectedErrContent: "positive integer", expectedErrContent: "positive integer",
}, },
{
input: `quic {
max_connections
}`,
shouldErr: true,
expectedErrContent: "Wrong argument count",
},
{
input: `quic {
max_connections abc
}`,
shouldErr: true,
expectedErrContent: "invalid max_connections value",
},
{
input: `quic {
max_connections -10
}`,
shouldErr: true,
expectedErrContent: "non-negative integer",
},
{
input: `quic {
max_connections 50
max_connections 100
}`,
shouldErr: true,
expectedErrContent: "already defined",
expectedMaxConnections: new(50),
},
{
input: `quic {
max_connections 100 200
}`,
shouldErr: true,
expectedErrContent: "Wrong argument count",
},
{ {
input: `quic { input: `quic {
worker_pool_size worker_pool_size
@@ -189,6 +243,7 @@ func TestQuicSetup(t *testing.T) {
if !test.shouldErr || (test.shouldErr && strings.Contains(test.expectedErrContent, "already defined")) { if !test.shouldErr || (test.shouldErr && strings.Contains(test.expectedErrContent, "already defined")) {
config := dnsserver.GetConfig(c) config := dnsserver.GetConfig(c)
assertMaxStreamsValue(t, i, test.input, config.MaxQUICStreams, test.expectedMaxStreams) assertMaxStreamsValue(t, i, test.input, config.MaxQUICStreams, test.expectedMaxStreams)
assertMaxConnectionsValue(t, i, test.input, config.MaxQUICConnections, test.expectedMaxConnections)
assertWorkerPoolSizeValue(t, i, test.input, config.MaxQUICWorkerPoolSize, test.expectedWorkerPoolSize) assertWorkerPoolSizeValue(t, i, test.input, config.MaxQUICWorkerPoolSize, test.expectedWorkerPoolSize)
} }
} }
@@ -213,6 +268,25 @@ func assertMaxStreamsValue(t *testing.T, testIndex int, testInput string, actual
} }
} }
// assertMaxConnectionsValue compares the actual MaxQUICConnections value with the expected one
func assertMaxConnectionsValue(t *testing.T, testIndex int, testInput string, actual, expected *int) {
t.Helper()
if actual == nil && expected == nil {
return
}
if (actual == nil) != (expected == nil) {
t.Errorf("Test %d (%s): Expected MaxQUICConnections to be %v, but got %v",
testIndex, testInput, formatNilableInt(expected), formatNilableInt(actual))
return
}
if *actual != *expected {
t.Errorf("Test %d (%s): Expected MaxQUICConnections to be %d, but got %d",
testIndex, testInput, *expected, *actual)
}
}
// assertWorkerPoolSizeValue compares the actual MaxQUICWorkerPoolSize value with the expected one // assertWorkerPoolSizeValue compares the actual MaxQUICWorkerPoolSize value with the expected one
func assertWorkerPoolSizeValue(t *testing.T, testIndex int, testInput string, actual, expected *int) { func assertWorkerPoolSizeValue(t *testing.T, testIndex int, testInput string, actual, expected *int) {
t.Helper() t.Helper()