Encrypted DNS
On this page
goethite answers DNS over TLS (DoT, RFC 7858), DNS over HTTPS (DoH, RFC 8484) and DNS over QUIC (DoQ, RFC 9250) beside plain DNS, and can be an Oblivious DoH target (RFC 9230). Encrypted DNS keeps queries private on networks you do not control, and with a client ID a phone keeps its own filtering when it is away from home.
Set it up
Section titled “Set it up”You need a certificate for the name devices will use, such as dns.example, and, for client IDs
in the server name, for *.dns.example too. A Let's Encrypt certificate with both names works.
Then add a [server.tls] table:
[server.tls]
cert = "/etc/goethite/tls/fullchain.pem"
key = "/etc/goethite/tls/privkey.pem"
server_name = "dns.example"
dot = ["0.0.0.0:853", "[::]:853"]
doh = ["0.0.0.0:443", "[::]:443"]
doq = ["0.0.0.0:853", "[::]:853"]DoT uses TCP port 853 and DoQ UDP port 853, so they share addresses; serve whichever you need.
Restart goethite. It logs DNS over TLS listening, DNS over HTTPS listening and DNS over QUIC listening for each address. DoH answers at https://dns.example/dns-query. Check them with
kdig:
kdig @dns.example +tls goethite.test
kdig @dns.example +https goethite.test
kdig @dns.example +quic goethite.testEach should answer 127.0.0.53. See the configuration reference
for every key.
Certificates
Section titled “Certificates”goethite reads the certificate and key at startup, and systemctl reload goethite (SIGHUP)
reads them again and serves the renewed certificate to new connections without dropping any. A
file that cannot be read, or a key that does not fit, leaves the certificate in use and is logged.
goethite check-config checks that the certificate and key fit together.
The shipped systemd unit runs goethite as a dynamic user, which cannot read a key that only root
may read. Create a group for it, let goethite join it in a drop-in (systemctl edit goethite),
and keep a copy of the certificate and key that the group may read:
groupadd --system goethite-tls
install -d -m 750 -g goethite-tls /etc/goethite/tls[Service]
SupplementaryGroups=goethite-tlsA certbot deploy hook copies renewed files there and has goethite reload them:
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/goethite
install -m 640 -g goethite-tls "$RENEWED_LINEAGE/fullchain.pem" /etc/goethite/tls/fullchain.pem
install -m 640 -g goethite-tls "$RENEWED_LINEAGE/privkey.pem" /etc/goethite/tls/privkey.pem
systemctl reload goethiteStarted as root without systemd, goethite reads the files before it drops its privileges, so
they may be readable by root only; to reload them it must still be able to read them as
server.user.
Client IDs
Section titled “Client IDs”A client is usually known by its address, which works on your network. Away from it, a phone has an address you do not know. A client ID names the device instead:
- Give the client an ID, such as
anna-phone, in the web UI (Clients) or through the API ("ids": ["anna-phone"]); the TUI lists it. IDs are 1 to 63 lowercase letters, digits and hyphens, not at either end; a client has up to 16. Addresses are optional for a client with an ID. - Set up the device with its ID. The client editor shows the exact values:
- DNS over HTTPS:
https://dns.example/dns-query/anna-phone - DNS over TLS:
anna-phone.dns.exampleas the hostname. On Android this is Private DNS; it needsserver_nameand a certificate for*.dns.example. - DNS over QUIC:
quic://anna-phone.dns.example, as apps such as AdGuard write it; the same requirements as DNS over TLS.
- DNS over HTTPS:
A query that carries a known ID belongs to that client, wherever it comes from; the ID wins over
the address. An unknown ID, or none, falls back to the address. When both the DoH path and the
server name carry an ID, the path wins. The query log shows the client and DoT or DoH next to
its address.
Reaching goethite from the internet
Section titled “Reaching goethite from the internet”Serving DoT, DoH or DoQ beyond your network makes goethite a resolver anyone can use who reaches
it.
Turn on require_client_id, so encrypted queries without a known client ID get REFUSED:
[server.tls]
require_client_id = truePlain DNS on port 53 is not affected; keep it on your network. A client ID is a name, not a
password: over DoT and DoQ it travels unencrypted in the TLS server name, so anyone on the path
can see it. Over DoH the ID in the path is encrypted. Treat require_client_id as keeping casual users
out, and use a firewall or VPN where that is not enough.
Oblivious DoH
Section titled “Oblivious DoH”With Oblivious DNS over HTTPS (ODoH, RFC 9230), a client encrypts its query to the resolver's public key and sends it through a proxy run by someone else: the proxy sees who asks but not what, and goethite, the target, sees what but not who. Turn it on for the DoH addresses:
[server.tls]
doh = ["0.0.0.0:443", "[::]:443"]
odoh = trueClients then use https://dns.example/dns-query as the target, through a proxy of their
choice, and fetch goethite's public key from https://dns.example/.well-known/odohconfigs. The
web UI's client editor shows the target address with a client ID.
- Who asks. goethite sees the proxy's address, so ODoH queries are filtered and logged as the
proxy is, as
ODoHin the query log. A client that wants its own filtering can name itself in the target path (/dns-query/anna-phone), which tells goethite who it is. - Keys. goethite makes its key when it starts, replaces it every day and accepts the previous one for another day. Keys are kept in memory only, never on disk, so traffic recorded today cannot be decrypted with a key stolen later. After a restart, an upgrade or a move to the other node of a pair, a client's next query gets HTTP 401 and the client fetches the new key, as RFC 9230 provides.
- Suite. X25519, HKDF-SHA256 and AES-128-GCM, the one every ODoH implementation supports. goethite's implementation matches Cloudflare's test vectors and works with its client.
- Limits. ODoH messages are at most 65,572 bytes; answers are padded to a multiple of 468 bytes so their length says less, and are never cacheable. All of a proxy's queries share the per-client connection limit; over HTTP/2 a proxy can send 64 at once on each connection.
goethite is not a proxy, and does not send its own upstream queries over ODoH.
Padding
Section titled “Padding”Encryption hides a query's name, not its length, and a name's length can give it away. So goethite pads with EDNS (RFC 7830), in the sizes RFC 8467 recommends:
- Answers to clients. A query that arrives padded over DoT, DoH or DoQ gets an answer padded to
a multiple of 468 bytes; try it with
kdig +tls +padding. ODoH pads in its own encryption layer instead, and plain DNS is never padded, since anyone on the path can read it anyway. - Queries to upstreams. goethite pads its own queries to DoT and DoH upstreams to a multiple of 128 bytes; upstreams that pad, such as Quad9 and Cloudflare, pad their answers in turn.
Padding costs a few hundred bytes per answer and needs no setting.
Limits
Section titled “Limits”Encrypted connections count against the same limits as TCP: max_tcp_connections (256) in total
and max_tcp_connections_per_client (16) from one address. A TLS handshake must finish within 10
seconds, and a connection without a query for 30 seconds is closed. DoH accepts request heads of
up to 64 KiB, bodies of up to 65,535 bytes (one DNS message), and 64 concurrent requests on an
HTTP/2 connection. Answers carry Cache-Control: max-age set to their shortest time to live.
DoQ checks a client's address before the connection counts: a new connection gets a QUIC Retry
first, which costs one round trip, so packets with forged addresses cannot take up connections.
A connection may have 64 queries open, one per stream, each a single message with ID 0 as RFC
9250 asks; a client that breaks the protocol is disconnected with DOQ_PROTOCOL_ERROR. 0-RTT is
refused, so queries cannot be replayed. Phones may move between networks without reconnecting.
The metrics count failed handshakes (goethite_tls_handshake_failures_total) and DoH requests
answered with an HTTP error (goethite_https_rejected_total), and queries by protocol (dot,
doh, doq, odoh) in goethite_queries_total.
An upgrade keeps every listener. DoT and DoH connections stay with the old process until their queries are answered; DoQ connections, which share one UDP socket with the new process, are closed once their queries are answered, and their clients reconnect.
DoH over HTTP/3 and DoH behind a reverse proxy are not supported yet.