# Enable DoH and DoT nxdns can answer encrypted queries on two extra listeners: DNS over HTTPS (`doh_server`) and DNS over TLS (`dot_server`). Both are off by default and both need a certificate and a private key in PEM form. This page uses a scratch lab under `/tmp/nxdns-lab` so the commands run without root and without touching a real install. On a real install the files live under `/etc/nxdns` and the data directory is `/var/lib/nxdns`; the ports are 443 and 853 rather than the unprivileged ones below. Every field mentioned here is documented in [configuration reference](../reference/configuration.md). ## 1. Get a certificate and key For a LAN service the practical options are a certificate from your ACME client (certbot, lego, caddy) for a name you control, or a self-signed pair. The lab below uses a self-signed pair, because it needs no domain: ```sh mkdir -p /tmp/nxdns-lab/etc cd /tmp/nxdns-lab openssl req -x509 -newkey rsa:2048 -nodes \ -keyout etc/key.pem -out etc/cert.pem -days 365 \ -subj "/CN=nxdns.lan" \ -addext "subjectAltName=DNS:nxdns.lan,DNS:localhost,IP:127.0.0.1" chmod 0600 etc/key.pem ``` A self-signed certificate means every client has to be told to trust it, or told to skip verification. That is why the client commands further down pass `--insecure` and `+tls` without a CA. A real deployment uses a real certificate and drops those flags. ## 2. Turn the listeners on `cert_path` and `key_path` must be absolute, or relative to the process working directory. Point both endpoints at the same pair unless you have a reason not to: ```zon .{ .groups = .{.{ .name = "default" }}, .upstreams = .{.{ .url = "https://cloudflare-dns.com/dns-query" }}, .dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 15400 }, .web = .{ .bind = "127.0.0.1", .port = 8451, .password = "lab-password" }, .doh_server = .{ .enabled = true, .bind = "127.0.0.1", .port = 8443, .cert_path = "/tmp/nxdns-lab/etc/cert.pem", .key_path = "/tmp/nxdns-lab/etc/key.pem", }, .dot_server = .{ .enabled = true, .bind = "127.0.0.1", .port = 8853, .cert_path = "/tmp/nxdns-lab/etc/cert.pem", .key_path = "/tmp/nxdns-lab/etc/key.pem", }, } ``` Write that to `/tmp/nxdns-lab/etc/config.zon`. The configuration file seeds an empty database and is then ignored; to change these settings on a server that already has a database, edit them through the API or through `export`/`import` — see [the configuration model](../explanation/configuration-model.md). ## 3. Check the files before starting ```sh nxdns check --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon ``` ``` checking configuration file /tmp/nxdns-lab/etc/config.zon OK https://cloudflare-dns.com/dns-query OK: no problems found ``` `check` reads both endpoints' certificate and key. An unreadable file is a failure and exits 2: ``` FAIL doh_server.cert_path: '/tmp/nxdns-lab/etc/cert.pem' is not readable FAIL dot_server.cert_path: '/tmp/nxdns-lab/etc/cert.pem' is not readable ``` A key readable by anyone but its owner is a warning, and does not change the exit code, because the service still starts: ``` WARN doh_server.key_path: '/tmp/nxdns-lab/etc/key.pem' is mode 644; a TLS key must be readable by its owner only ``` ## 4. Start and confirm the listeners ```sh nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon ``` Two lines in the log say the listeners bound: ``` info(nxdns): doh listener on 127.0.0.1:8443 info(nxdns): dot listener on 127.0.0.1:8853 ``` If a certificate cannot be loaded while its endpoint is enabled, nxdns refuses to start and exits 2 rather than serving DNS without the listener you asked for: ``` doh_server: '/tmp/nxdns-lab/etc/cert.pem' + '/tmp/nxdns-lab/etc/key.pem': private key file is not readable nxdns run failed: BadCertificate run `nxdns check` to see the configuration in full ``` ## 5. Query DoT Recent `dig` speaks DNS over TLS with `+tls` (this page was checked with BIND 9.20.26): ```sh dig @127.0.0.1 -p 8853 +tls example.com A +short ``` ``` 172.66.147.243 104.20.23.154 ``` ## 6. Query DoH The only path the DoH listener serves is `/dns-query`; anything else is a 404. It accepts both the POST form (the query as an `application/dns-message` body) and the GET form (`?dns=` with base64url of the same bytes). The body is a raw DNS query in wire format. Build one for `example.com A` — header with the recursion-desired bit, one question, then the QNAME as length-prefixed labels: ```sh printf '%s' '000001000001000000000000076578616d706c6503636f6d0000010001' \ | xxd -r -p > /tmp/nxdns-lab/query.bin ``` POST it: ```sh curl -sS --insecure --http1.1 \ -H 'content-type: application/dns-message' \ --data-binary @/tmp/nxdns-lab/query.bin \ --output /tmp/nxdns-lab/answer.bin \ -w 'http %{http_code}, %{size_download} bytes\n' \ https://127.0.0.1:8443/dns-query xxd /tmp/nxdns-lab/answer.bin ``` ``` http 200, 72 bytes 00000000: 0000 8180 0001 0002 0000 0001 0765 7861 .............exa 00000010: 6d70 6c65 0363 6f6d 0000 0100 01c0 0c00 mple.com........ 00000020: 0100 0100 0000 0400 04ac 4293 f3c0 0c00 ..........B..... 00000030: 0100 0100 0000 0400 0468 1417 9a00 0029 .........h.....) 00000040: 04d0 0000 0000 0000 ........ ``` The second flag byte `80` and the third answer-count field `0002` say: response, no error, two answer records. The GET form takes the same bytes, base64url-encoded with the padding removed: ```sh Q=$(base64 -w0 /tmp/nxdns-lab/query.bin | tr '+/' '-_' | tr -d '=') curl -sS --insecure --http1.1 -o /tmp/nxdns-lab/get.bin \ -w 'GET http %{http_code}, %{size_download} bytes\n' \ "https://127.0.0.1:8443/dns-query?dns=$Q" ``` ``` GET http 200, 61 bytes ``` `--http1.1` matters: without it curl offers HTTP/2 over ALPN, and the DoH listener negotiates only what it advertises. `--insecure` is only needed for the self-signed lab certificate. ## 7. Renewals A watcher polls both files every 30 seconds and compares their modification time and size against the pair currently loaded. When either differs it reloads and swaps the new pair in; connections already open finish on the old certificate. Nothing has to restart. Replace the pair and wait one poll interval: ```sh openssl req -x509 -newkey rsa:2048 -nodes \ -keyout /tmp/nxdns-lab/etc/key.pem -out /tmp/nxdns-lab/etc/cert.pem -days 365 \ -subj "/CN=nxdns.lan" \ -addext "subjectAltName=DNS:nxdns.lan,DNS:localhost,IP:127.0.0.1" chmod 0600 /tmp/nxdns-lab/etc/key.pem ``` Within 30 seconds the log says so, once per endpoint that watches the file: ``` info(cert_store): certificate reloaded from /tmp/nxdns-lab/etc/cert.pem info(cert_store): certificate reloaded from /tmp/nxdns-lab/etc/cert.pem ``` ## 8. Reload immediately To skip the wait — from an ACME deploy hook, for example — call `POST /api/certs/reload`. It needs a session; see [set up admin authentication](set-up-admin-authentication.md) for the login call that fills `cookies.txt`. ```sh curl -sS -b /tmp/nxdns-lab/cookies.txt -X POST http://127.0.0.1:8451/api/certs/reload ``` ```json {"doh":{"enabled":true,"reloaded":true,"error":null},"dot":{"enabled":true,"reloaded":true,"error":null}} ``` The route always answers 200: the per-endpoint outcome is the payload, not the status code. A disabled endpoint reports `"enabled":false`. A reload that fails names the reason and leaves the old certificate serving: ```sh chmod 000 /tmp/nxdns-lab/etc/key.pem curl -sS -b /tmp/nxdns-lab/cookies.txt -X POST http://127.0.0.1:8451/api/certs/reload ``` ```json {"doh":{"enabled":true,"reloaded":false,"error":"private key file is not readable"},"dot":{"enabled":true,"reloaded":false,"error":"private key file is not readable"}} ``` The listeners keep working through that failure: ```sh dig @127.0.0.1 -p 8853 +tls example.com A +short ``` ``` 104.20.23.154 172.66.147.243 ``` Undo it with `chmod 0600 /tmp/nxdns-lab/etc/key.pem` and reload again. The counters are on `/metrics`: ``` nxdns_cert_reloads_total{endpoint="doh"} 3 nxdns_cert_reloads_total{endpoint="dot"} 3 nxdns_cert_reload_failures_total{endpoint="doh"} 2 nxdns_cert_reload_failures_total{endpoint="dot"} 2 ``` ## File ownership on a real install The key must be readable by the user nxdns runs as, and by nobody else. Under systemd the service runs as the `nxdns` user: ```sh chown nxdns:nxdns /etc/nxdns/cert.pem /etc/nxdns/key.pem chmod 0644 /etc/nxdns/cert.pem chmod 0600 /etc/nxdns/key.pem ``` Under Docker the container runs as uid 65532, fixed in the image, and `/etc/nxdns` is a read-only bind mount — the container cannot fix permissions itself, so the host-side files must already be readable by that uid. It has no name on the host, so chown it numerically: ```sh cd deploy/docker chown 65532:65532 etc-nxdns/cert.pem etc-nxdns/key.pem chmod 0644 etc-nxdns/cert.pem chmod 0600 etc-nxdns/key.pem ``` **Not verified on this host:** the two `chown` blocks above. Both need root, and the systemd one needs an `nxdns` user this development machine does not have. Everything else on this page was executed as written. ## Ports 443 and 853 The defaults are the standard ports, which are privileged. Under the packaged systemd unit that is already handled: it grants `CAP_NET_BIND_SERVICE` for port 53 and the same capability covers 443 and 853. See [install with systemd](install-with-systemd.md).