DNS-over-HTTPS (DoH)¶
ShadowDNS exposes an RFC 8484 DNS-over-HTTPS endpoint at /dns-query that reuses the same authoritative query path as the UDP/TCP listeners. The intent is operational: it lets operators verify zone records over standard HTTPS (TCP/443) — for example through a firewall or middlebox that only permits TCP/443 — without opening port 53. A DoH query is decoded, handed to the identical handler the UDP/TCP path uses, and the wire-format answer is returned over HTTPS.
Warning
ShadowDNS DoH is AUTHORITATIVE and NON-RECURSIVE. It answers only the zones ShadowDNS hosts; any out-of-zone query returns REFUSED. It is not a general-purpose recursive DoH resolver — do not point browsers or client devices at it expecting public name resolution. It exists to verify ShadowDNS's own authoritative records over HTTPS, nothing more.
Enabling¶
DoH is configured entirely through the doh: section in shadowdns.yaml. When the section is absent, no DoH server starts and the binary behaves exactly as a build without the feature.
The required fields are:
| Field | Purpose |
|---|---|
listen |
Address the DoH HTTPS service binds (TCP/443) |
acme.directory_url |
ACME directory endpoint (e.g. https://acme-v02.api.letsencrypt.org/directory) |
acme.ip |
The public IP the certificate is issued for |
acme.http01_listen |
Address the ACME HTTP-01 challenge responder binds (TCP/80) |
acme.account_key_file |
Absolute path to the persisted ACME account private key (see ACME account key persistence) |
See shadowdns.yaml for the full field tables and an example block.
RFC 8484 protocol¶
The endpoint accepts both GET and POST on the /dns-query path:
- GET
/dns-query?dns=<base64url-no-padding>— the DNS query message is base64url-encoded (no padding) in thednsquery parameter. - POST
/dns-query— the raw DNS query message is the request body, withContent-Type: application/dns-message.
Responses are always returned with Content-Type: application/dns-message.
Error handling:
| Condition | Status |
|---|---|
Path other than /dns-query |
404 Not Found |
| Method other than GET or POST | 405 Method Not Allowed |
| Request that cannot be decoded into a DNS message | 400 Bad Request |
| POST body larger than 65535 bytes | 413 Payload Too Large |
curl examples¶
# GET: base64url-encoded (no padding) DNS query in the `dns` parameter
curl -sS 'https://203.0.113.10/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' \
| xxd
# POST: raw DNS message as the request body
curl -sS -H 'content-type: application/dns-message' \
--data-binary @query.bin \
https://203.0.113.10/dns-query | xxd
To build query.bin, capture a wire-format query — for example with dig +noedns +qr www.example.com A and extracting the request bytes, or any tool that emits a raw DNS message.
application/dns-json format¶
Alongside the RFC 8484 wire format, the /dns-query endpoint serves the Google Public DNS / CloudFlare de-facto application/dns-json format on GET requests. This lets you verify records with curl + jq and zero client-side encoding — no need to hand-assemble a base64url wire query.
Note
application/dns-json is not an RFC; it follows the Google Public DNS response schema. Field ordering and whitespace are not significant — only field names, types, and values are.
Format negotiation¶
The format is chosen on the GET path as follows:
- A request that carries a
?dns=parameter is always handled as RFC 8484 wire-format, regardless of itsAcceptheader. The?dns=parameter takes precedence, so a wire query is never misrouted to the JSON parser. - A request with no
?dns=parameter and anAcceptheader that listsapplication/dns-jsonis served as JSON, withContent-Type: application/dns-json. - POST is always wire-format; the JSON format is GET-only (matching Google / CloudFlare).
Query parameters¶
| Parameter | Required | Description |
|---|---|---|
name |
yes | The query name. Must be non-empty. Normalized to a trailing-dot FQDN; on-wire letter case is preserved (so ExAmple.COM is echoed verbatim). |
type |
no (default A) |
DNS record type. Accepts a mnemonic case-insensitively (TXT, txt, Txt) or a numeric code in the range 0–65535. |
edns_client_subnet |
no | A client subnet as <ip>[/<prefix>] injected as an EDNS Client Subnet option (see below). When the prefix is omitted it defaults to /24 for IPv4 and /56 for IPv6. |
cd |
no | Accepted but ignored — ShadowDNS is non-recursive and does no DNSSEC validation. It never sets the response CD bit. |
The do and ct parameters are not honored; their presence is ignored and does not cause an error.
Response schema¶
A successful response is a JSON object following the Google Public DNS schema:
{
"Status": 0,
"TC": false,
"RD": true,
"RA": false,
"AD": false,
"CD": false,
"Question": [{ "name": "www.example.com.", "type": 1 }],
"Answer": [{ "name": "www.example.com.", "type": 1, "TTL": 300, "data": "203.0.113.20" }]
}
Statusis the integer DNS RCODE (e.g.0NOERROR,3NXDOMAIN,5REFUSED).RDis alwaystrue(the dispatched query sets recursion-desired);CDis alwaysfalse.Answer[].datais the RDATA in DNS presentation format with the record header stripped, so multi-field RDATA (SOA, MX) and quoted TXT data are preserved intact.- The response carries the same
Cache-Control: max-age=Nheader as the wire path, bounded by the smallest Answer TTL.
DNS-level outcomes are conveyed in Status, not via HTTP error codes:
| Condition | HTTP status |
|---|---|
| Well-formed query (any RCODE, including REFUSED / NXDOMAIN / empty answer) | 200 OK |
Missing, empty, or malformed name (a label over 63 octets or a name over 255 octets), unparseable type, or unparseable edns_client_subnet |
400 Bad Request |
| Dispatched query produced no captured response (internal failure) | 500 Internal Server Error |
Zone transfers are refused¶
type=AXFR and type=IXFR are refused with Status 5 (REFUSED) and an empty Answer, identical to the wire path — a zone transfer is a multi-message stream with no representation in a single JSON response.
curl + jq examples¶
# Look up an A record as JSON
curl -sS -H 'accept: application/dns-json' \
'https://203.0.113.10/dns-query?name=www.example.com&type=A' | jq
# Extract just the answer data
curl -sS -H 'accept: application/dns-json' \
'https://203.0.113.10/dns-query?name=www.example.com&type=TXT' \
| jq -r '.Answer[].data'
Simulating a client subnet (ECS)¶
When ECS is enabled on the server (--ecs-enable), the edns_client_subnet parameter lets a single host simulate queries from any network, so you can verify split-horizon / GeoIP view selection without sourcing traffic from that network:
curl -sS -H 'accept: application/dns-json' \
'https://203.0.113.10/dns-query?name=www.example.com&type=A&edns_client_subnet=198.51.100.0/24' \
| jq '{Answer, edns_client_subnet}'
Host bits beyond the prefix are masked automatically (e.g. 198.51.100.5/24 becomes 198.51.100.0/24), so a sloppy value does not produce a FORMERR. When ECS is in effect, the response includes an edns_client_subnet field formatted as <network>/<source-prefix>/<scope-prefix>. ShadowDNS is authoritative and does not narrow the scope to a geo boundary, so the scope-prefix echoes the source-prefix — it confirms the subnet was accepted and used for view selection, nothing more.
Warning
When --ecs-enable is off (the default), an injected edns_client_subnet is silently ignored — exactly as for a wire query carrying ECS while ECS is disabled — and the response carries no edns_client_subnet field.
TLS and certificates¶
The DoH listener serves TLS with a certificate issued for the IP address (acme.ip), obtained automatically via ACME HTTP-01 validation using the Let's Encrypt short-lived certificate profile (~6-day validity). ShadowDNS auto-renews the certificate well before expiry and hot-swaps it into the running listener without restarting — in-flight and subsequent connections pick up the new certificate transparently.
Because the certificate is bound to the IP rather than a hostname, clients connect to the IP directly (as in the curl examples above).
ACME HTTP-01 listener hardening¶
The HTTP-01 responder on port 80 (acme.http01_listen) is, by design, the only fully public HTTP surface ShadowDNS exposes — it must accept connections from the entire Internet so the ACME server can reach it. To keep that attack surface and fingerprint as small as possible, the listener answers exactly one kind of request and drops everything else.
A request is served (200 OK with the key authorization body) only when all of the following hold:
- the method is GET, and
- the path is under
/.well-known/acme-challenge/(the trailing slash matters), and - the token names a challenge that is currently being presented for an in-flight authorization.
Every other request — an unknown path, an unknown or empty token, the bare /.well-known/acme-challenge with no trailing slash, or any non-GET method — is aborted at the connection level. ShadowDNS sends no HTTP response whatsoever: no status line, no headers, no body. The client sees a connection reset / EOF, and the server logs no stack trace. This is the same posture as nginx's return 444. In particular there is no 404 for unknown paths and no 301 redirect for the slash-less subtree path — both of those would otherwise leak that a server is listening and what it is.
This hardening has no effect on legitimate certificate issuance or renewal: the ACME validator only ever fetches the exact token ShadowDNS just began presenting, which is the one request shape that is served 200. Certificates continue to be issued and renewed normally.
ACME account key persistence¶
ShadowDNS persists its ACME account private key to the absolute path set in acme.account_key_file and reuses it across restarts and registration retries. The recommended location is under the systemd state directory:
The packaged systemd unit declares StateDirectory=shadowdns, so /var/lib/shadowdns is created on every start owned by the service user with mode 0700.
Behavior:
- First start — when the file does not exist, ShadowDNS generates a new P256 account key and writes it to the path as PKCS#8 PEM with permissions
0600, then registers the ACME account. - Restart / retry — the same key is loaded, so the ACME directory returns the existing account (RFC 8555 §7.3) instead of registering a new one. This is what keeps re-registration idempotent and avoids exhausting the per-source-IP new-account rate limit during crash loops or repeated registration failures.
- Corrupt or unreadable key file — ShadowDNS fails loudly: it logs an error naming the file and does not silently mint a replacement key or register a new account (a silent rebuild is exactly what would trip the rate limit). Because the obtainer is not cached on failure, the error recurs on every renewal retry until you repair or remove the file; DoH serves no certificate until then.
Operational notes:
- The account key is a secret. Keep it
0600and owned by the service user; do not commit it or copy it into shared locations. - Persistence relies on a static service user (
User=shadowdns). Do not switch the unit toDynamicUser=yes— a per-boot UID would changeStateDirectoryownership and make the persisted key unreadable, silently reintroducing new-account churn. - Changing
account_key_filerequires a process restart to take effect. On SIGHUP reload it is detected as DoH config drift and logged with a "restart to apply" advisory, like the otherdoh.acme.*fields.
Firewall and port deployment¶
DoH uses two TCP ports with very different exposure requirements:
- Port 80 (
acme.http01_listen) must be reachable from the public Internet so the ACME server can complete HTTP-01 validation. This responder is ShadowDNS's only fully public HTTP surface, so it is hardened to answer exactly one kind of request: a GET for a live challenge token returns200with the key authorization; every other request is dropped at the connection level — no HTTP response is sent at all (no404, no301redirect), the client just sees a reset/EOF. See ACME HTTP-01 listener hardening. It carries no DNS data. - Port 443 (
listen, the DoH service) should be restricted by firewall to trusted source IPs. It does not need to be reachable by the ACME server, only by the operators who use it to verify records.
A typical deployment opens port 80 to the world (challenge-only) and limits port 443 to a small allowlist of operator addresses.
Source IP and views¶
DoH view selection uses the TCP connection's source IP — the address ShadowDNS observes at the transport layer. X-Forwarded-For and Forwarded HTTP headers are ignored. This is a deliberate security boundary: a client cannot forge a view by setting a header.
Cache headers¶
Each DoH response carries a Cache-Control: max-age=N header, where N is bounded by the smallest Answer TTL in the response. For responses with no positive-lifetime answer (empty answer sections), N is 0.
Observability¶
DoH queries are visible in the standard metrics alongside UDP and TCP:
shadowdns_dns_requests_totalcarries aproto="doh"label, distinct fromproto="udp"andproto="tcp", so DoH traffic can be counted and rate-tracked separately.shadowdns_doh_cert_renewals_total{result="success"|"failure"}counts certificate renewal attempts by outcome.shadowdns_doh_cert_not_after_timestamp_secondsrecords the current certificate's expiry as a Unix timestamp, for alerting on imminent expiry.shadowdns_doh_acme_dropped_total{reason="unknown_path"|"unknown_token"|"bad_method"}counts the probe connections the port 80 HTTP-01 listener aborted without responding (see ACME HTTP-01 listener hardening). Use it to observe how much port 80 is being probed.
See Monitoring for how these are scraped and dashboarded.
Reload behavior (SIGHUP)¶
The doh: section is re-validated on SIGHUP, but changes to doh.listen or any doh.acme.* field require a process restart to take effect — the listener and ACME account are established at startup. When such a change is detected on reload, ShadowDNS logs an advisory entry noting that a restart is required; the running listener continues with its previous settings until then.
FAQ¶
Is the issued TLS certificate stored, or re-issued on every restart?¶
It is re-issued on every restart. The leaf certificate (and its private key) lives only in memory — it is never written to disk. On every process start, ShadowDNS obtains a fresh certificate from the ACME directory before the listener serves its first handshake. The only ACME material persisted to disk is the account key (see ACME account key persistence), which is a different thing: it lets restarts reuse the same ACME account instead of registering a new one.
This is a deliberate trade-off. The certificate is short-lived (~6 days) and restarts are expected to be far rarer than that, so re-issuing on start keeps the design simple and avoids ever writing the certificate's private key to disk.
Operational consequence: each restart is a real certificate issuance. Persisting the account key prevents the new-account rate limit, but not the per-IP certificate / new-order limits. Avoid putting ShadowDNS in a crash loop or a rapid restart cycle against the production ACME directory; use a staging directory when you need to restart repeatedly during testing.
When the certificate auto-renews, does it run a full config reload?¶
No. Renewal is independent of SIGHUP config reload — it never re-reads configuration, re-opens zone data, or restarts the listener. A background loop obtains the renewed certificate and atomically swaps it into the holder that tls.Config.GetCertificate reads on every handshake, so the next TLS handshake picks up the new certificate while in-flight connections continue uninterrupted. Nothing rebinds the port and no other subsystem is touched.
The two paths are orthogonal: certificate rotation is automatic and listener-local, while a SIGHUP reload re-reads the rest of the configuration but does not touch the certificate (and doh.* changes still require a restart — see Reload behavior (SIGHUP)).
See also¶
shadowdns.yaml— thedoh:section field reference and example.- CLI Reference for related flags.
- Monitoring for the DoH metrics above.