Skip to content

Commit 028680c

Browse files
committed
dotdoh: add DNS-over-QUIC (RFC 9250) up- and downstream
DoQ is the last IETF standards-track encrypted DNS transport we did not speak, and the cheapest one to add: the DNS message rides a QUIC stream with the same 2-byte length prefix DoT already uses, so on top of the OpenSSL QUIC stack the DoH3 work brought in there is no HTTP layer at all. Both directions land together because they share that stack. 1. Outbound: `doq://host` becomes a `dns.upstreams` scheme (the `quic://` spelling AdGuard and dnsproxy configs use is accepted as an alias), defaulting to UDP port 853. The exchange is fail-closed exactly like DoT/DoH/DoH3 - a bad chain, a reset stream or a timeout drops the query so dnsmasq fails over rather than downgrading to plaintext. Per RFC 9250 Sec. 4.2.1 the Message ID goes out as 0 and dnsmasq's own ID is restored on the answer. 2. Inbound: `dns.doq` (default on, like `dns.dot`) brings up a listener on UDP/853 - the same port number DoT uses on TCP, which does not collide. It is a single-threaded event loop in the shape of the DoT listener: OpenSSL owns the QUIC transport while every in-flight query is a small non-blocking state machine, so a slow resolve never stalls another connection and a flood costs a bounded state record rather than a thread. Queries are attributed to the real downstream client through the same private-EDNS handoff as DoT/DoH, and answers are padded per RFC 8467 when the client asked for it. 3. The socket, handshake and timer plumbing the DoH3 client already carried moves into `quic_common.c`, so both QUIC clients share one context, one trust store and one fail-closed verify instead of duplicating them. DoQ is gated on the new `HAVE_QUIC` (OpenSSL >= 4.0) rather than `HAVE_HTTP3`, so a build without nghttp3 still speaks it. Worth calling out on the hardening side: QUIC address validation (Retry) stays on so we cannot be used to amplify towards a forged source, 0-RTT is explicitly disabled because a replayed early-data query would be answered and logged twice, only the `doq` ALPN is accepted, and connections, per-source connections, concurrent streams and per-connection queries are each capped. RFC 9250 Sec. 5.5.2 makes `edns-tcp-keepalive` a protocol error on DoQ, so a query carrying it closes the connection. One wrinkle needed solving: OpenSSL's QUIC API exposes the peer address but no per-connection *local* address, and the listener is wildcard-bound, so - unlike DoT and DoH - we cannot simply read off which of our addresses the client reached. Conveying nothing is not an option: the answer would then be built from the interface of our own loopback handoff, so `pi.hole` would resolve to 127.0.0.1 for every DoQ client, and a CNAME chain reaching it would write that into the shared cache record. We therefore ask the kernel which source address it would use to reach that peer - the address it would itself put on a reply datagram, and so the one a plain-DNS answer is built from - and convey that. Signed-off-by: DL6ER <dl6er@dl6er.de>
1 parent 32d8217 commit 028680c

30 files changed

Lines changed: 2724 additions & 306 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pihole-FTL*
44
# Generated standalone regression test binaries (built via ./build.sh test)
55
/tar_regression
66
/gzip_regression
7+
/dotdoh_regression
78

89
# Generated pihole.toml file
910
pihole.toml

src/CMakeLists.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,17 @@ if(LIBSSL AND LIBCRYPTO AND USE_TLS)
594594
#endif
595595
int main(void) { return 0; }
596596
" OPENSSL_HAS_QUIC_PEER_ADDR)
597+
598+
# DoQ (RFC 9250) rides on OpenSSL's QUIC alone - it carries DNS directly on
599+
# QUIC streams, so unlike DoH3 it needs no HTTP/3 library. Gate it on the
600+
# OpenSSL version only, so a build without nghttp3 still speaks DoQ.
601+
if(OPENSSL_HAS_QUIC_PEER_ADDR)
602+
message(STATUS "Building FTL with DoQ (DNS-over-QUIC) support: YES")
603+
target_compile_definitions(dotdoh PRIVATE HAVE_QUIC)
604+
else()
605+
message(STATUS "Building FTL with DoQ (DNS-over-QUIC) support: NO (OpenSSL < 4.0)")
606+
endif()
607+
597608
find_library(LIBNGHTTP3 NAMES libnghttp3${LIBRARY_SUFFIX} nghttp3)
598609
if(LIBNGHTTP3 AND OPENSSL_HAS_QUIC_PEER_ADDR)
599610
message(STATUS "Building FTL with HTTP/3 support: YES")

src/api/docs/content/specs/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ components:
301301
type: boolean
302302
dot:
303303
type: boolean
304+
doq:
305+
type: boolean
304306
blocking:
305307
type: object
306308
properties:
@@ -775,6 +777,7 @@ components:
775777
upstreamCA: ""
776778
doh: true
777779
dot: true
780+
doq: true
778781
CNAMEdeepInspect: true
779782
blockESNI: true
780783
EDNS0ECS: true

src/config/config.c

Lines changed: 8 additions & 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 the tls:// or https:// scheme.";
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://).";
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*)"";
@@ -625,6 +625,13 @@ void initConfig(struct config *conf)
625625
conf->dns.dot.f = FLAG_RESTART_FTL;
626626
conf->dns.dot.c = validate_stub;
627627

628+
conf->dns.doq.k = "dns.doq";
629+
conf->dns.doq.h = "Enable the inbound DNS-over-QUIC (DoQ) server on UDP port 853. When enabled, FTL terminates DoQ connections directly (RFC 9250) so downstream clients can use this Pi-hole as their encrypted resolver. DoQ carries DNS on QUIC streams and shares port number 853 with DoT without colliding (UDP vs. TCP). Requires a valid TLS certificate (the same one configured for the webserver) and an FTL built with QUIC support.";
630+
conf->dns.doq.t = CONF_BOOL;
631+
conf->dns.doq.d.b = true;
632+
conf->dns.doq.f = FLAG_RESTART_FTL;
633+
conf->dns.doq.c = validate_stub;
634+
628635
// sub-struct dns.cache
629636
conf->dns.domain.name.k = "dns.domain.name";
630637
conf->dns.domain.name.h = "The DNS domain used by your Pi-hole.\n\n This DNS domain is purely local. FTL may answer queries from its local cache and configuration but *never* forwards any requests upstream *unless* you have configured a dns.revServer exactly for this domain. In the latter case, all queries for this domain are sent exclusively to this server (including reverse lookups).\n\n For DHCP, this has two effects; firstly it causes the DHCP server to return the domain to any hosts which request it, and secondly it sets the domain which it is legal for DHCP-configured hosts to claim. The intention is to constrain hostnames so that an untrusted host on the LAN cannot advertise its name via DHCP as e.g. \"google.com\" and capture traffic not meant for it. If no domain suffix is specified, then any DHCP hostname with a domain part (ie with a period) will be disallowed and logged. If a domain is specified, then hostnames with a domain part are allowed, provided the domain part matches the suffix. In addition, when a suffix is set then hostnames without a domain part have the suffix added as an optional domain part. For instance, we can set domain=mylab.com and have a machine whose DHCP hostname is \"laptop\". The IP address for that machine is available both as \"laptop\" and \"laptop.mylab.com\".\n\n You can disable setting a domain by setting this option to an empty string.";

src/config/config.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ struct config {
158158
struct conf_item upstreamCA;
159159
struct conf_item doh;
160160
struct conf_item dot;
161+
struct conf_item doq;
161162
struct {
162163
struct conf_item name;
163164
struct conf_item local;

src/config/validator.c

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -810,8 +810,8 @@ 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:// or https://) to parse as a valid encrypted-upstream URI. Plaintext
814-
// entries are left untouched - dnsmasq validates those itself.
813+
// (tls://, https://, h3:// or doq://) to parse as a valid encrypted-upstream
814+
// URI. Plaintext entries are left untouched - dnsmasq validates those itself.
815815
bool validate_upstreams(union conf_value *val, const char *key, char err[VALIDATOR_ERRBUF_LEN])
816816
{
817817
if(!validate_array_no_newline(val, key, err))
@@ -825,11 +825,11 @@ bool validate_upstreams(union conf_value *val, const char *key, char err[VALIDAT
825825
continue;
826826

827827
// Anything carrying a URI scheme ("://") must be a supported encrypted
828-
// upstream (tls:// or https://) that parses cleanly. A plaintext server
829-
// specification handled downstream by dnsmasq never contains "://", so
830-
// an entry that does but is not a valid encrypted URI (e.g. http://,
831-
// ftp:// or a malformed tls://) is rejected here rather than being
832-
// written into dnsmasq.conf and breaking DNS startup.
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.
833833
if(strstr(s, "://") != NULL)
834834
{
835835
struct upstream_uri u;

src/dnsmasq_interface.c

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3746,6 +3746,14 @@ void FTL_fork_and_bind_sockets(struct passwd *ent_pw, bool dnsmasq_start)
37463746
log_crit("Unable to create dotdoh DoT thread. Exiting...");
37473747
exit(EXIT_FAILURE);
37483748
}
3749+
3750+
// Likewise for the inbound DoQ (DNS-over-QUIC) listener on UDP/853.
3751+
if(config.dns.doq.v.b &&
3752+
pthread_create( &threads[DOTDOH_DOQ], &attr, dotdoh_doq_thread, NULL ) != 0)
3753+
{
3754+
log_crit("Unable to create dotdoh DoQ thread. Exiting...");
3755+
exit(EXIT_FAILURE);
3756+
}
37493757
}
37503758

37513759
// Chown files if FTL started as user root but a dnsmasq config

src/dotdoh/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,20 @@ set(dotdoh_sources
1919
registry.h
2020
tls_client.c
2121
tls_client.h
22+
quic_common.c
23+
quic_common.h
2224
quic_client.c
2325
quic_client.h
26+
doq_client.c
27+
doq_client.h
2428
proxy.c
2529
proxy.h
2630
server.c
2731
server.h
2832
source_filter.c
2933
source_filter.h
3034
dot_server.c
35+
doq_server.c
3136
)
3237

3338
add_library(dotdoh OBJECT ${dotdoh_sources})

0 commit comments

Comments
 (0)