server: every dns listener shares one budget of 32 queries, stream listeners take 16 connections, the task pool is capped from those bounds, and work it cannot start is counted by site

This commit is contained in:
2026-10-22 15:24:10 +02:00
parent e4e405f8e9
commit 63cc5f8dc3
23 changed files with 983 additions and 324 deletions
+44 -22
View File
@@ -18,6 +18,8 @@
//! TCP/53 each connection costs one allocation. The pool itself is still
//! fixed and pre-allocated.
//!
//! A query holds a permit from the shared query budget exactly as on TCP/53.
//!
//! ALPN belongs to the `CertStore`'s `ServerContext` (set at store init);
//! this listener only acquires entries.
@@ -26,17 +28,18 @@ const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const query_budget = @import("query_budget.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
/// record tail pass through here, while whole messages go straight to
/// `Payload.query`/`Payload.reply`.
/// `Payload.query` and from the permit's reply.
const stream_buffer_len = 1024;
pub const Options = struct {
max_connections: u16 = 64,
max_connections: u16 = listener.dns_max_connections,
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the
/// handshake runs under the same budget.
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
@@ -53,28 +56,24 @@ pub const Stats = struct {
/// walks for the `nxdns_dot_server_*` families.
pub const StatsSnapshot = struct {
connections: u64,
rejected_at_capacity: u64,
tls_handshake_failures: u64,
idle_timeouts: u64,
connection_errors: u64,
dropped_no_permit: u64,
};
pub const DotServer = struct {
core: listener.Core(Config),
handler: *handler.Handler,
budget: *query_budget.QueryBudget,
certs: *cert_store.CertStore,
options: Options,
stats: Stats,
/// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the
/// `ServerStream` bookkeeping — so the default 64 connections stay inside
/// the PLAN §18 budget.
pub const Payload = struct {
/// Read before a permit is taken, as on TCP/53.
query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8,
/// The handler's per-query working memory. It belongs to the slot so
/// that answering a message allocates nothing, and a connection is
/// answered serially, so one query uses it at a time.
scratch: handler.Scratch,
/// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it,
/// and the slot never moves.
tls: tls_server.ServerStream,
@@ -98,12 +97,14 @@ pub const DotServer = struct {
io: std.Io,
listen_address: std.Io.net.IpAddress,
h: *handler.Handler,
budget: *query_budget.QueryBudget,
certs: *cert_store.CertStore,
options: Options,
) ListenError!DotServer {
return .{
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h,
.budget = budget,
.certs = certs,
.options = options,
.stats = .{},
@@ -133,9 +134,11 @@ pub const DotServer = struct {
const core = &self.core.stats;
return .{
.connections = core.connections.load(.monotonic),
.rejected_at_capacity = core.rejected_at_capacity.load(.monotonic),
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = core.connection_errors.load(.monotonic),
.dropped_no_permit = core.dropped_no_permit.load(.monotonic),
};
}
@@ -157,7 +160,7 @@ pub const DotServer = struct {
const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (listener.handshakeStage(io, budget, stage)) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
// A stalled handshake is refused like a broken one: it must not
// pin a connection slot for longer than the idle budget.
.timed_out, .failed => {
@@ -181,7 +184,7 @@ pub const DotServer = struct {
listener.bump(&stats.idle_timeouts);
return;
},
.canceled => return,
.canceled, .unavailable => return,
// A transport EOF without close_notify lands here too: the
// stream reads it as a truncation, never as a clean end.
.failed => {
@@ -208,7 +211,7 @@ pub const DotServer = struct {
switch (listener.race(io, budget, listener.readBody, .{ reader, conn.payload.query[0..len] })) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
// A half-sent message is a broken peer, not an idle one.
.timed_out, .failed => {
listener.bump(&stats.connection_errors);
@@ -216,13 +219,20 @@ pub const DotServer = struct {
},
}
// There is no framing for "no answer", so the connection ends.
const work = self.budget.tryAcquire(io) orelse {
listener.bump(&stats.dropped_no_permit);
return;
};
defer self.budget.release(io, work);
const outcome = self.handler.handle(
io,
.tcp,
address.NetAddress.fromIp(conn.peer),
conn.payload.query[0..len],
&conn.payload.reply,
&conn.payload.scratch,
&work.reply,
&work.scratch,
);
const bytes = switch (outcome) {
// There is no framing for "no answer", so the connection ends.
@@ -233,7 +243,7 @@ pub const DotServer = struct {
const out = transport.framePrefix(@intCast(bytes.len));
switch (listener.race(io, budget, listener.writeReply, .{ writer, &out, bytes })) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
.timed_out, .failed => {
listener.bump(&stats.connection_errors);
return;
@@ -577,7 +587,9 @@ test "dot: two framed queries share one TLS connection" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
@@ -621,7 +633,9 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
@@ -661,7 +675,9 @@ test "dot: plain TCP bytes fail the handshake and are counted" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
@@ -749,7 +765,9 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
@@ -841,7 +859,9 @@ test "dot: a cert path change serves the new certificate on the next handshake"
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
@@ -883,7 +903,9 @@ test "dot: an idle connection is closed with close_notify and counted" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{
.max_connections = 2,
.idle_timeout = short_idle,
});