Skip to content

Commit 5a019af

Browse files
committed
dotdoh: harden the DoQ listener and bound the dnsmasq children it occupies
Follow-up hardening on the inbound DoQ work, from review of the listener and of how the three encrypted paths share the embedded dnsmasq. Protocol handling: - Trailing bytes on a DoQ stream were discarded silently. RFC 9250, Sec. 4.2 gives each query its own stream, so anything after that one message is a protocol violation; the stream is now reset with `DOQ_PROTOCOL_ERROR` rather than letting a non-conformant client smuggle a second message past us. Not requiring the client's FIN before answering stays deliberate. - `SO_REUSEADDR` is gone from the UDP listener. UDP has no `TIME_WAIT` to work around, and with the flag a second daemon binds 853 successfully while the kernel delivers the datagrams to only one of us. Without it the clash surfaces as `EADDRINUSE`. - A pooled loopback socket the peer closed between the checkout probe and our write is now retried once on the write side, as the DoT path already did on the read side. - `ossl_err()` reported the oldest queued OpenSSL error rather than the most recent, so an unrelated stale entry could be printed in place of the failure being described. Concurrency: Every in-flight query occupies one of dnsmasq's TCP children, of which it serves 60 by default (`--max-tcp-connections`). Nothing bounded our share: DoT allowed one per connection, DoQ one per stream and DoH one per webserver thread, so the three could ask for far more than exist and starve plain TCP queries and DNSSEC fallback host-wide. Admission is now counted in `dotdoh_loopback_take()`, which every path already calls before obtaining a socket. It reserves a slot, or returns -2 once the limit is reached so the caller can refuse the query rather than queue it - DoQ resets the stream with `DOQ_EXCESSIVE_LOAD` so the client knows to back off. The new `dotdoh_loopback_drop()` releases a socket that must not be reused and frees the slot, so the error paths account as `dotdoh_loopback_give()` already did. The limit is derived from `daemon->max_procs` rather than configured, leaving `LOOPBACK_RESERVE` children for plain TCP: raising dnsmasq's own limit raises ours, with no second setting to keep in step. Refusals are logged at most once a minute with a count, naming both numbers, as a per-query line would flood the log under exactly the overload that produces it. DoH held a loopback connection per webserver thread for the life of that thread, pinning a child whether or not it was serving anything and sitting outside any accounting; DoT held one for the life of each keep-alive connection. Both now borrow one per query, so idle clients occupy no slot, and the pool is kept as large as the limit so a query at full concurrency still finds a warm socket instead of forking. A blocking exchange also arms its own send and receive timeouts now: the reactors create their sockets without any, correctly, as they never block on them, and a borrowed one would otherwise be bounded only by dnsmasq's 300 s child lifetime. Also documents the `quic://` alias of `doq://`, which `parse_upstream_uri()` has always accepted, in the `dns.upstreamCA` help text and both `validate_upstreams()` comments. Signed-off-by: DL6ER <dl6er@dl6er.de>
1 parent 68e7551 commit 5a019af

9 files changed

Lines changed: 246 additions & 55 deletions

File tree

src/config/config.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ void initConfig(struct config *conf)
604604
conf->dns.revServers.f = FLAG_RESTART_FTL;
605605

606606
conf->dns.upstreamCA.k = "dns.upstreamCA";
607-
conf->dns.upstreamCA.h = "Path to a CA certificate bundle used to verify encrypted upstream servers (DoT/DoH). If left empty, the system default trust store is used. Only relevant when at least one dns.upstreams entry uses an encrypted scheme (tls://, https://, h3:// or doq://).";
607+
conf->dns.upstreamCA.h = "Path to a CA certificate bundle used to verify encrypted upstream servers (DoT/DoH). If left empty, the system default trust store is used. Only relevant when at least one dns.upstreams entry uses an encrypted scheme (tls://, https://, h3://, doq:// or quic://).";
608608
conf->dns.upstreamCA.a = cJSON_CreateStringReference("A path to a PEM CA bundle, or empty for the system default trust store");
609609
conf->dns.upstreamCA.t = CONF_STRING;
610610
conf->dns.upstreamCA.d.s = (char*)"";

src/config/validator.c

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -810,8 +810,9 @@ bool validate_str_no_newline(union conf_value *val, const char *key, char err[VA
810810

811811
// Validator for dns.upstreams. Enforces the same array/string/newline rules as
812812
// validate_array_no_newline() and, in addition, requires every encrypted entry
813-
// (tls://, https://, h3:// or doq://) to parse as a valid encrypted-upstream
814-
// URI. Plaintext entries are left untouched - dnsmasq validates those itself.
813+
// (tls://, https://, h3://, doq:// or quic://) to parse as a valid encrypted-
814+
// upstream URI. Plaintext entries are left untouched - dnsmasq validates those
815+
// itself.
815816
bool validate_upstreams(union conf_value *val, const char *key, char err[VALIDATOR_ERRBUF_LEN])
816817
{
817818
if(!validate_array_no_newline(val, key, err))
@@ -825,11 +826,12 @@ bool validate_upstreams(union conf_value *val, const char *key, char err[VALIDAT
825826
continue;
826827

827828
// Anything carrying a URI scheme ("://") must be a supported encrypted
828-
// upstream (tls://, https://, h3:// or doq://) that parses cleanly. A
829-
// plaintext server specification handled downstream by dnsmasq never
830-
// contains "://", so an entry that does but is not a valid encrypted URI
831-
// (e.g. http://, ftp:// or a malformed tls://) is rejected here rather
832-
// than being written into dnsmasq.conf and breaking DNS startup.
829+
// upstream (tls://, https://, h3://, doq:// or quic://) that parses
830+
// cleanly. A plaintext server specification handled downstream by
831+
// dnsmasq never contains "://", so an entry that does but is not a valid
832+
// encrypted URI (e.g. http://, ftp:// or a malformed tls://) is rejected
833+
// here rather than being written into dnsmasq.conf and breaking DNS
834+
// startup.
833835
if(strstr(s, "://") != NULL)
834836
{
835837
struct upstream_uri u;

src/dnsmasq_interface.c

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4414,6 +4414,16 @@ void FTL_connection_error(const char *reason, const union mysockaddr *addr, cons
44144414
*
44154415
* @return true if the debug option is enabled, false otherwise.
44164416
*/
4417+
unsigned int __attribute__ ((pure)) dnsmasq_max_tcp_children(void)
4418+
{
4419+
// Read when the listeners start, i.e. after the config is parsed. Fall back to
4420+
// dnsmasq's own default if that ordering ever changes, so the derived cap can
4421+
// never come out as zero and refuse everything.
4422+
if(daemon != NULL && daemon->max_procs > 0)
4423+
return (unsigned int)daemon->max_procs;
4424+
return MAX_PROCS;
4425+
}
4426+
44174427
bool __attribute__ ((pure)) get_dnsmasq_debug(void)
44184428
{
44194429
return option_bool(OPT_DEBUG);

src/dnsmasq_interface.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ bool FTL_is_forward_available(const union mysockaddr *addr);
5454

5555
bool get_dnsmasq_debug(void) __attribute__ ((pure));
5656

57+
// Concurrent TCP children dnsmasq will serve (--max-tcp-connections). The
58+
// encrypted listeners hand every in-flight query to one of these, so they derive
59+
// their own concurrency limit from it.
60+
unsigned int dnsmasq_max_tcp_children(void) __attribute__ ((pure));
61+
5762
// defined in src/dnsmasq/cache.c
5863
extern char *querystr(char *desc, unsigned short type);
5964

src/dotdoh/doq_server.c

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
// Cap in-flight queries across all connections. Each holds ~192 KiB of pooled
8181
// I/O buffers plus one loopback socket to dnsmasq, so this - not the connection
8282
// count - is what bounds the listener's footprint and its load on dnsmasq.
83-
#define DOQ_MAX_STREAMS 64
83+
#define DOQ_MAX_STREAMS 32
8484
// Most concurrent streams a single connection may hold. Together with
8585
// DOQ_MAX_CONNS_PER_IP this bounds what one connection costs; the global
8686
// DOQ_MAX_STREAMS above is what actually caps total in-flight queries.
@@ -173,12 +173,12 @@ static int g_nstreams = 0;
173173
// Human-readable text for the most recent OpenSSL error. The certificate reload
174174
// path calls this from the DoQ thread while other threads use OpenSSL too, so it
175175
// renders into thread-local storage rather than ERR_error_string()'s process-wide
176-
// buffer, and drains the rest of the queue so a later message cannot report a
177-
// stale error.
176+
// buffer. Reads the most recent entry, not the oldest still queued, and drains
177+
// the queue afterwards so a later message cannot report a stale error.
178178
static const char *ossl_err(void)
179179
{
180180
static _Thread_local char buf[256];
181-
const unsigned long e = ERR_get_error();
181+
const unsigned long e = ERR_peek_last_error();
182182
if(e == 0)
183183
return "no error";
184184
ERR_error_string_n(e, buf, sizeof(buf));
@@ -287,8 +287,10 @@ static int doq_bind_socket(int family)
287287
family == AF_INET ? "IPv4" : "IPv6", strerror(errno));
288288
return -1;
289289
}
290+
// No SO_REUSEADDR here: UDP has no TIME_WAIT to work around, and with the flag
291+
// a second daemon binds the same port successfully while the kernel delivers
292+
// the datagrams to only one of us. Without it the clash surfaces as EADDRINUSE.
290293
const int one = 1;
291-
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
292294
if(family == AF_INET6)
293295
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &one, sizeof(one));
294296

@@ -441,7 +443,7 @@ static void stream_free(struct doq_stream *s)
441443
if(s->ssl != NULL)
442444
SSL_free(s->ssl);
443445
if(s->upfd >= 0)
444-
close(s->upfd);
446+
dotdoh_loopback_drop(s->upfd);
445447
if(s->conn != NULL)
446448
s->conn->nstreams--;
447449
// Keep the pooled I/O buffers attached to the slot for the next stream (they
@@ -691,10 +693,25 @@ static int stream_start_resolve(struct doq_stream *s)
691693
s->up_ev = POLLOUT;
692694
return 0;
693695
}
696+
if(s->upfd == -2)
697+
{
698+
// At the concurrency limit; server.c has already logged the summary. Tell
699+
// the client why, as the stream-cap path does - a bare teardown reads as
700+
// DOQ_NO_ERROR and gives it no reason to back off.
701+
log_debug(DEBUG_TLS, "dotdoh: DoQ query from %s refused, concurrency limit reached",
702+
s->conn->client);
703+
doq_reset_stream(s->ssl, DOQ_EXCESSIVE_LOAD);
704+
return -1;
705+
}
694706
s->up_pooled = false;
695707
s->upfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
696708
if(s->upfd < 0)
709+
{
710+
// take() reserved an in-flight slot even though the pool was empty; give
711+
// it back or the count never recovers.
712+
dotdoh_loopback_drop(-1);
697713
return -1;
714+
}
698715
struct sockaddr_in sa;
699716
memset(&sa, 0, sizeof(sa));
700717
sa.sin_family = AF_INET;
@@ -721,7 +738,7 @@ static int stream_start_resolve(struct doq_stream *s)
721738
// the drive contract: 1 keep driving, 0 yield, -1 give up.
722739
static int stream_retry_upstream(struct doq_stream *s)
723740
{
724-
close(s->upfd);
741+
dotdoh_loopback_drop(s->upfd);
725742
s->upfd = -1;
726743
s->up_pooled = false;
727744
s->up_retried = true;
@@ -791,7 +808,17 @@ static int drive_read(struct doq_stream *s)
791808
s->wlen = (size_t)flen;
792809
s->woff = 0;
793810

794-
// One query per stream (RFC 9250 Sec. 4.2): anything after it is ignored.
811+
// One query per stream (RFC 9250 Sec. 4.2). Bytes beyond that one message are
812+
// a protocol violation, so fail the stream instead of silently dropping them:
813+
// quietly ignoring them would let a non-conformant client smuggle a second
814+
// message past us.
815+
if(s->have > off + (size_t)qlen)
816+
{
817+
log_debug(DEBUG_TLS, "dotdoh: DoQ stream from %s carried %zu trailing bytes, "
818+
"resetting it", s->conn->client, s->have - off - (size_t)qlen);
819+
doq_reset_stream(s->ssl, DOQ_PROTOCOL_ERROR);
820+
return -1;
821+
}
795822
s->have = 0;
796823

797824
if(stream_start_resolve(s) != 0)
@@ -810,6 +837,10 @@ static int drive_up_write(struct doq_stream *s)
810837
if(w > 0) { s->woff += (size_t)w; continue; }
811838
if(w < 0 && errno == EINTR) continue;
812839
if(w < 0 && errno == EAGAIN) { s->up_ev = POLLOUT; return 0; }
840+
// A pooled socket the peer closed between the checkout probe and this
841+
// write fails here; resend once on a fresh one, as the DoT path does.
842+
if(s->up_pooled && !s->up_retried)
843+
return stream_retry_upstream(s);
813844
return -1;
814845
}
815846
s->alen = 0; s->agot = 0; s->up_lengot = 0;

src/dotdoh/dot_server.c

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
// then pooled across connections (freed only at thread shutdown), so the cap
6161
// bounds the worst-case footprint while a connection flood costs no per-
6262
// connection allocation. The event loop itself scales far higher.
63-
#define DOT_MAX_CONNS 64
63+
#define DOT_MAX_CONNS 32
6464
// Cap concurrent connections from a single source IP so one client cannot hold
6565
// every slot (a drip of one query per connection re-arms the per-query deadline,
6666
// so the slow-loris sweep never fires; this is what bounds a single source).
@@ -259,7 +259,7 @@ static void conn_free(struct dot_conn *c)
259259
if(c->up_idle)
260260
dotdoh_loopback_give(c->upfd);
261261
else
262-
close(c->upfd);
262+
dotdoh_loopback_drop(c->upfd);
263263
}
264264
// Keep the I/O buffers attached to the slot for the next connection to reuse
265265
// (they are freed once, at thread shutdown); reset only the bookkeeping.
@@ -344,9 +344,18 @@ static int conn_start_resolve(struct dot_conn *c)
344344
c->st = DS_UP_WRITE;
345345
return 0;
346346
}
347+
if(c->upfd == -2)
348+
{
349+
log_debug(DEBUG_TLS, "dotdoh: DoT query from %s refused, concurrency limit reached",
350+
c->client);
351+
return -1;
352+
}
347353
c->upfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
348354
if(c->upfd < 0)
355+
{
356+
dotdoh_loopback_drop(-1);
349357
return -1;
358+
}
350359
struct sockaddr_in sa;
351360
memset(&sa, 0, sizeof(sa));
352361
sa.sin_family = AF_INET;
@@ -375,7 +384,7 @@ static int conn_start_resolve(struct dot_conn *c)
375384
// (advanced, keep driving), 0 (reconnect in flight, yield), -1 (give up).
376385
static int conn_retry_upstream(struct dot_conn *c)
377386
{
378-
close(c->upfd);
387+
dotdoh_loopback_drop(c->upfd);
379388
c->upfd = -1;
380389
c->up_reused = false;
381390
c->up_retried = true;
@@ -523,6 +532,24 @@ static int drive_up_read(struct dot_conn *c)
523532
// a message boundary, so conn_free() may return it to the shared pool.
524533
c->up_idle = true;
525534

535+
// Hand the loopback socket back now that the exchange is complete, rather
536+
// than holding it until the connection closes. It stays warm in the pool for
537+
// whoever needs it next - including this connection's next query - but an
538+
// idle keep-alive client no longer occupies one of the shared admission
539+
// slots, which would otherwise let a handful of idle DoT connections starve
540+
// DoQ and DoH.
541+
if(c->upfd >= 0)
542+
{
543+
dotdoh_loopback_give(c->upfd);
544+
c->upfd = -1;
545+
// Clear the boundary flag with the fd it described: the next query takes a
546+
// fresh socket, and leaving it set would let conn_free() pool that one
547+
// while it is still connecting.
548+
c->up_idle = false;
549+
c->up_reused = false;
550+
c->up_retried = false;
551+
}
552+
526553
// RFC 8467 Sec. 4: pad the answer only if the query asked for it.
527554
if(c->client_padded)
528555
c->alen = edns_pad_response(c->abuf, c->alen, ABUF_SZ);

0 commit comments

Comments
 (0)