Profile
Back to NewsBack
GitHub Trending 18 min
Reader Mode
picatz/doh: 🍩  DNS over HTTPS command-line client and server

picatz/doh: 🍩 DNS over HTTPS command-line client and server

15 hours ago

doh

GitHub license</a> PRs Welcome</a>

🍩 DNS over HTTPS command-line client and server

doh is a single binary for DNS over HTTPS (DoH):

  • doh query looks up records from several DoH servers at once (Cloudflare, Google, and Quad9 by default, or your own), streaming the results as JSON.
  • doh serve runs a DoH, DNS-over-TLS, and plain DNS server (with experimental DNS-over-QUIC) that forwards queries to upstream resolvers over DoH, DNS-over-TLS, DNS-over-QUIC, or plain DNS, with TLS from your own certificates or Let's Encrypt. It protects every device that uses it: threat intelligence, DNS rebinding and look-alike domain protection, newly registered domain checks, profiles for groups of devices, a query log you can trace and undo blocks from, and a web UI.
  • doh status, profile, allow, log, explain, and doctor manage a running server, and check that a device's DNS actually goes through it.
It's also a Go library: pkg/doh provides the client and server building blocks.

Install

With Go 1.27 or later:

$ go install github.com/picatz/doh@latest

Querying

Query a domain's A records. Without --servers, Google, Cloudflare, and Quad9 are queried concurrently, and each response is printed as a line of JSON as it arrives:

$ doh query example.com --servers https://dns.google/dns-query
{"server":"https://dns.google/dns-query","resp":{"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"example.com.","type":1}],"Answer":[{"name":"example.com.","type":1,"TTL":300,"data":"172.66.147.243"},{"name":"example.com.","type":1,"TTL":300,"data":"104.20.23.154"}]}}
[!TIP]
Since doh outputs JSON lines, it pairs well with jq:
>
> $ doh query example.com | jq -c '{server, ips: [.resp.Answer[].data]}'
> {"server":"https://dns.google/dns-query","ips":["172.66.147.243","104.20.23.154"]} > {"server":"https://cloudflare-dns.com/dns-query","ips":["104.20.23.154","172.66.147.243"]} > ... >

Query several domains at once, or other record types with --type:

$ doh query example.com example.org --type AAAA
$ doh query google.com --type MX | jq -r '.resp.Answer[].data'
smtp.google.com.

Use any DoH server with --servers, and --resolver-addr to resolve the servers' own names through a specific DNS resolver (connecting directly, without HTTP(S)_PROXY):

$ doh query example.com --servers https://dns.example/dns-query --resolver-addr 192.168.1.1:53

Serving

doh serve runs a DoH server (RFC 8484) that forwards queries to upstream resolvers, trying each in order until one answers. Upstreams can use DoH (https://…), DNS-over-TLS (tls://host[:853]), or plain DNS (udp://host[:53], tcp://host[:53], or just host), and default to Cloudflare and Google over DoH.

DoH requires TLS unless you opt out with --plaintext, to run behind a reverse proxy or load balancer that terminates TLS:

$ doh serve --plaintext --listen :8080 --upstream tls://dns.quad9.net

Certificates can come from files, which are reloaded automatically when they change:

$ doh serve --tls-cert cert.pem --tls-key key.pem --upstream 192.168.1.1

Or be obtained and renewed automatically from Let's Encrypt (or another ACME CA with --acme-directory), answering TLS-ALPN-01 challenges on the TLS port:

$ doh serve --acme-domain doh.example.com --acme-email [email protected]

Local network resolver

To encrypt DNS for every device on your network, including the ones that can't speak DoH, serve plain DNS on port 53 and let doh forward it over DoH:

$ sudo doh serve --dns-listen :53
level=INFO msg="DoH disabled: no TLS certificate or plaintext configured, serving plain DNS only"
level=INFO msg="serving dns" udp=[::]:53 tcp=[::]:53
level=INFO msg="set devices, or your router's DHCP DNS server setting, to this address to use it as their resolver" addrs=[192.168.1.10]

Then point your router's DHCP DNS server setting (or individual devices) at the address it logs. Add --tls-cert/--tls-key or --plaintext to serve DoH on the same machine too.

[!TIP]
Instead of running as root, allow the binary to bind port 53 with sudo setcap cap_net_bind_service=+ep $(command -v doh). On Linux, if systemd-resolved already holds port 53, listen on your LAN address instead (--dns-listen 192.168.1.10:53), or set DNSStubListener=no in /etc/systemd/resolved.conf.

To keep it running, a minimal systemd unit (/etc/systemd/system/doh.service):

[Unit]
Description=doh DNS resolver
After=network-online.target
Wants=network-online.target

[Service] ExecStart=/usr/local/bin/doh serve --dns-listen :53 --admin-listen localhost:9090 AmbientCapabilities=CAP_NET_BIND_SERVICE DynamicUser=yes

Keeps the policy, tokens, and query log in /var/lib/doh, and lists in /var/cache/doh.

StateDirectory=doh CacheDirectory=doh Restart=on-failure

[Install] WantedBy=multi-user.target

$ sudo systemctl enable --now doh

Blocking ads and trackers

Add --blocklist default to block ads, trackers, and malware for every device using the server:

$ sudo doh serve --dns-listen :53 --blocklist default
level=INFO msg="blocklist loaded" rules=171454 sources=1

default is HaGeZi's Multi NORMAL, which rarely breaks sites. Other presets (doh blocklist presets) include stricter HaGeZi and OISD lists, StevenBlack's hosts, and EasyList and EasyPrivacy. Any list in Adblock Plus (||ads.example.com^), hosts file, or plain domain format works too, by URL or file path, optionally named as name=location:

$ doh serve --dns-listen :53 --blocklist hagezi-pro --blocklist mine=/etc/doh/block.txt --allow-domain example.com

Blocked names get NXDOMAIN (or 0.0.0.0 and :: with --block-response null), with an Extended DNS Error saying which list blocked them. Lists are cached on disk, revalidated daily, and kept if a download fails. To see why a name is (or isn't) blocked:

$ doh blocklist check www.google-analytics.com
{"name":"www.google-analytics.com","blocked":true,"rule":{"domain":"google-analytics.com","subdomains":true,"allow":false},"source":"hagezi-normal"}
[!TIP]
doh pairs with CUBE, which blocks in Chrome using the same list formats. CUBE sees full URLs and pages, so it can block paths and hide page elements; doh sees only names, but covers every app and device on the network.

Protections and profiles

Out of the box, doh serve blocks known malware, phishing, and scam domains, DNS rebinding attacks, and look-alike internationalized domains such as Π°pple.com (with a Cyrillic Π°), for every device that uses it. More protections are a toggle away: newly registered domains, DNS tunneling, bypass methods, safe search, and lists of ads, trackers, and content households restrict.

Protections belong to profiles, so a group of devices gets the rules it needs. Streaming sticks running sketchy apps can get stricter rules and closer logging, and the kids' tablets safe search, without affecting anyone else. Devices join a profile by address, or over DoH by path, as https://doh.example.com/dns-query/kids/tablet:

$ sudo doh serve --dns-listen :53 --admin-listen localhost:9090
$ doh profile create streaming --name "Streaming sticks"
$ doh profile set streaming clients=192.168.1.40/29 security.newly_registered=true lists=threat-intel,hagezi-pro

When something is blocked, find it in the log, see exactly why, and allow it, for one profile or all of them:

$ doh log --blocked --profile streaming -n 1
TIME      CLIENT        STATUS   TYPE  NAME                    REASON
21:04:11  192.168.1.42  blocked  A     fresh-cdn.example.com   newly-registered
$ doh explain fresh-cdn.example.com --profile streaming
$ doh allow example.com --profile streaming

Or do all of it in the web UI: doh ui prints a single-use sign-in link, and once signed in you can add a passkey, which then signs you in on its own and is required to finish any sign-in link. See Signing in.

Profiles live in policy.json in the data directory, created with the secure defaults on first run, so they can be kept in version control and edited by hand as well as through the UI and API. A home server can also follow the policy of a server in the cloud, so one place manages a family's devices. See Profiles and protections for every protection, how each works, and its limits.

Connecting phones and laptops, anywhere

On a server with a public name, add --dot-listen :853 so Android's Private DNS can use it, and every profile becomes reachable by name, over DNS-over-TLS and DoH: kids.dns.example.com selects profile kids, and tablet--kids.dns.example.com also names the device. With an ACME domain, certificates for those names are obtained as they're first used, only for profiles that exist; with your own certificate, use a wildcard (*.dns.example.com).

$ doh serve --acme-domain dns.example.com --dot-listen :853 --admin-listen localhost:9090
$ doh setup kids --device tablet --platform android
Connect a device to profile kids as tablet
  DNS-over-HTTPS  https://dns.example.com/dns-query/kids/tablet
  DNS-over-TLS    tablet--kids.dns.example.com

Android 1. Open Settings, then Network & internet (or Connections), then Private DNS. 2. Choose Private DNS provider hostname, enter tablet--kids.dns.example.com, and save. $ doh setup kids --device ipad --mobileconfig ipad.mobileconfig # open it on the iPad

The web UI's Connect a device panel shows the same, for every platform, with one-click configuration profile downloads.

DNS-over-QUIC (experimental)

Add --doq-listen :853 to also serve DNS-over-QUIC on UDP port 853, with the same certificate, and the same profile host names, as DNS-over-TLS. Queries share one connection without waiting on each other, and responses are padded when queries are. Upstreams can use it too, as quic://dns.example.com.

$ doh serve --acme-domain dns.example.com --dot-listen :853 --doq-listen :853
$ doh query example.com --servers quic://dns.example.com

It's built on golang.org/x/net/quic, which its authors don't yet consider ready for production, so DoQ is opt-in. DNS-over-HTTPS over HTTP/3 is waiting on Go's standard library (#73).

Your network's own names

Special-use and private names, such as localhost, home.arpa, and reverse lookups of private addresses, are answered locally rather than leaked upstream. To resolve your network's hostnames, forward their domain to the router, or give names fixed answers with a profile's rewrites:

$ sudo doh serve --dns-listen :53 --forward lan=192.168.1.1 --forward 168.192.in-addr.arpa=192.168.1.1

Checking a device

doh doctor checks that a device's DNS goes through doh, and finds what routes around it, such as Tailscale's MagicDNS or Firefox's own DNS over HTTPS:

$ doh doctor
βœ“ system nameservers: 192.168.1.10
βœ“ 192.168.1.10:53 is answered by doh (profile=default, device=laptop, client=192.168.1.23)
βœ“ Firefox will use this network's resolver

Observability

Add --admin-listen to serve the web UI, the admin API, metrics, and profiling on a separate, operator-only address:

$ doh serve --dns-listen :53 --admin-listen localhost:9090
level=INFO msg="serving admin" metrics=http://127.0.0.1:9090/metrics vars=http://127.0.0.1:9090/debug/vars pprof=http://127.0.0.1:9090/debug/pprof/ flight_recorder=false

| Endpoint | What it serves | | --- | --- | | / | The web UI, signed in to with a link from doh ui, or a passkey | | /api/v1/ | The admin API, with the admin.token or reader.token from the data directory | | /metrics | Prometheus counters: queries by transport, responses by code, blocks by list and by reason, per-upstream queries, errors, and latency, slow queries, and Go runtime gauges | | /debug/vars | The same counters as JSON, with runtime metrics and build information | | /debug/pprof/ | pprof profiles | | /debug/flightrecorder | With --flight-recorder, a flight recorder snapshot of the last few seconds of execution, for go tool trace |

$ curl -s localhost:9090/metrics | grep doh_queries_total
doh_queries_total{transport="udp"} 42
$ curl -so doh.trace localhost:9090/debug/flightrecorder && go tool trace doh.trace

To capture what happened during slow queries automatically, --slow-query 500ms --trace-dir /var/lib/doh/traces saves a snapshot whenever a query takes that long (at most once a minute).

[!WARNING]
Profiles and traces reveal details about the process, so keep --admin-listen on a loopback or otherwise private address. doh warns if it isn't.

Querying your server

Query it like any other DoH server:

$ doh query example.com --servers https://doh.example.com/dns-query

Configuration

Every flag can also be set with a DOH_-prefixed environment variable (--tls-cert is DOH_TLS_CERT, with commas separating lists), or in a JSON file passed with --config. Flags take precedence over the environment, which takes precedence over the file:

{
  "listen": ":443",
  "upstreams": ["https://cloudflare-dns.com/dns-query", "tls://dns.quad9.net"],
  "timeout": "5s",
  "tls": {
    "acme": {
      "domains": ["doh.example.com"],
      "email": "[email protected]",
      "http_listen": ":80"
    }
  },
  "log": { "level": "info", "format": "json" }
}

Answers are cached for as long as their records live (up to a day), so repeated lookups, common on a local network, skip the upstream round trip. --cache-size sets how many are kept (10,000 by default; 0 disables the cache).

The server also exposes a GET /healthz liveness endpoint, logs every request at --log-level debug, and shuts down gracefully on interrupt.

Go library

pkg/doh works with any *http.Client and github.com/miekg/dns messages:

resp, err := doh.Query(ctx, http.DefaultClient, doh.Cloudflare, new(dns.Msg).SetQuestion("example.com.", dns.TypeA))

And serving DoH is a handler away (or, with doh.NewHandler, mounted on any path pattern):

mux := doh.NewServerMux(doh.Forwarder(http.DefaultClient, doh.Cloudflare, doh.Google))
log.Fatal(http.ListenAndServeTLS(":443", "cert.pem", "key.pem", mux))

Reference

doh query --help

$ doh query --help
Query DNS records from DoH servers using the given domains and record type.

Users can specify which servers to use for the query, or use the default servers from Google, Cloudflare, and Quad9. Servers can use DNS-over-HTTPS (https://, or http:// for a local server), DNS-over-TLS (tls://host[:853]), DNS-over-QUIC (quic://host[:853], experimental), or plain DNS (udp://host[:53], tcp://host[:53], or just host), the same forms doh serve accepts as upstreams. They can also specify a timeout for the query, which defaults to 30 seconds if not specified. Each server is queried in parallel, and each domain is queried in parallel. Results are streamed to STDOUT as JSON newline delimited objects, including the TLS version, cipher suite, and ALPN protocol for encrypted servers, which can be piped to other commands (e.g. jq) or redirected to a file.

Usage: doh query domains... [flags]

Flags: -h, --help help for query -k, --insecure-skip-verify allow insecure server connections (e.g. self-signed TLS certificates) --resolver-addr string address of a DNS resolver to use for resolving DoH server names (e.g. 8.8.8.8:53); connects directly, ignoring HTTP(S)_PROXY --resolver-network string protocol to use for resolving DoH server names (e.g. udp, tcp) (default "udp") --retry-max int maximum number of retries for each DoH query (default 10) --servers strings servers to query: https://, http://, tls://, quic://, udp://, tcp://, or host (default [https://dns.google/dns-query,https://cloudflare-dns.com/dns-query,https://dns.quad9.net:5053/dns-query]) --timeout duration timeout for query, 0s for none (plain DNS and DNS-over-TLS exchanges still give up after 5s) (default 30s) --type string dns record type to query for each domain, such as A, AAAA, MX, etc. (default "A")

doh serve --help

$ doh serve --help
Serve DNS-over-HTTPS (RFC 8484), forwarding queries to upstream resolvers.

Upstreams are asked in order of preference: if one is slow to answer, or fails, the next is asked too, and the first answer wins. Upstreams that keep failing are asked last until they recover. They may use DNS-over-HTTPS (https://), DNS-over-TLS (tls://host[:853]), DNS-over-QUIC (quic://host[:853], experimental), or plain DNS (udp://host[:53], tcp://host[:53], or just host).

DoH requires TLS unless --plaintext is set, for running behind a reverse proxy or load balancer that terminates TLS. Certificates come from files, reloaded when they change, or are obtained automatically from Let's Encrypt (or another ACME certificate authority) with --acme-domain.

With --dns-listen, plain DNS is also served over UDP and TCP, so any device can use the server as its resolver. With --dns-listen and no TLS or --plaintext setting, only plain DNS is served.

Profiles group devices under their own policy: protections (DNS rebinding, look-alike domains, newly registered domains, tunneling, bypass methods), blocklists, allowed and denied domains, safe search, and local rewrites. They live in a policy file in --data-dir, created with secure defaults on first run, and can be changed by hand, through the admin API and web UI on --admin-listen, or with "doh profile", "doh allow", and "doh deny". Devices choose a profile by address, or over DoH by path: /dns-query/{profile}/{device}. A server can follow another server's policy with --policy https://.../api/v1/policy.

Settings are read from, in increasing order of precedence: a JSON --config file, DOH_* environment variables (e.g. DOH_TLS_CERT for --tls-cert, with commas separating lists), and flags.

Examples: # Behind a TLS-terminating proxy, forwarding to Quad9 over DNS-over-TLS doh serve --plaintext --listen :8080 --upstream tls://dns.quad9.net

# Public server with an automatic Let's Encrypt certificate doh serve --acme-domain doh.example.com --acme-email [email protected]

# With your own certificate, forwarding to a local resolver doh serve --tls-cert cert.pem --tls-key key.pem --upstream 192.168.1.1

# Local network resolver: plain DNS in, encrypted DNS out doh serve --dns-listen :53

# ...with the web UI and admin API, and the router answering for .lan doh serve --dns-listen :53 --admin-listen localhost:9090 --forward lan=192.168.1.1

# Phones and laptops anywhere: DoH and DoT, a profile per host name doh serve --acme-domain dns.example.com --dot-listen :853 --admin-listen localhost:9090 doh setup kids --device tablet

# A home resolver following the policy of a server in the cloud doh serve --dns-listen :53 --policy https://doh.example.com:9090/api/v1/policy --policy-token-file reader.token

Usage: doh serve [flags]

Flags: --acme-cache-dir string directory for ACME accounts and certificates (default user cache dir) --acme-directory string ACME directory URL (default Let's Encrypt) --acme-domain strings domains to obtain ACME certificates for, enabling ACME --acme-email string contact email for the ACME account --acme-http-listen string address to answer ACME HTTP-01 challenges on, such as :80 --admin-listen string serve metrics, pprof, and flight recorder snapshots on this address, such as localhost:9090 --admin-origin strings origins the web UI is opened at, such as https://admin.dns.example.com, for browser sign-in and passkeys (default http://localhost:{port} on a loopback --admin-listen) --admin-token-file string file holding the admin API token (default admin.token in --data-dir, generated) --allow-domain strings never block these domains and their subdomains --block-domain strings also block these domains and their subdomains --block-response string answer blocked names with nxdomain, or null (0.0.0.0 and ::) (default "nxdomain") --blocklist strings block names on these lists: presets (default, abused-tlds, adult, bypass-services, dynamic-dns, easylist, easyprivacy, gambling, hagezi-light, hagezi-normal, hagezi-pro, oisd-big, oisd-small, scams, social, stevenblack, threat-intel, threat-intel-full), URLs, or files, as [name=]location --blocklist-refresh duration how often to reload blocklists (default 24h0m0s) --cache-size int most upstream answers to cache, for as long as their records live; 0 disables the cache (default 10000) --config string path to a JSON config file --data-dir string directory for the policy, admin API tokens, query log, and registration cache (default $STATE_DIRECTORY, or doh in the user config dir) --dns-listen string also serve plain DNS over UDP and TCP on this address, such as :53 --doq-listen string also serve DNS-over-QUIC (experimental) on this UDP address, such as :853, with the DoH certificate --dot-listen string also serve DNS-over-TLS on this address, such as :853, with the DoH certificate --flight-recorder keep recent execution traces in memory, to snapshot from the admin listener --forward strings send names under a domain to other upstreams, as domain=upstream, such as lan=192.168.1.1 -h, --help help for serve --hostname string public host name, such as dns.example.com, under which profiles are reachable as {profile}.{hostname} (default the first --acme-domain) --listen string address to listen on (default ":443", or ":8080" with --plaintext) --log-format string log format: text or json (default "text") --log-level string log level: debug, info, warn, or error (default "info") --no-query-log don't log queries --path string URI path of the DoH endpoint (default "/dns-query") --plaintext serve unencrypted HTTP, behind a TLS-terminating proxy --policy string policy file (default policy.json in --data-dir), URL of another doh server's /api/v1/policy to follow read-only, or "off" --policy-token-file string file holding the bearer token for a --policy URL --query-log-retention duration how long to keep daily query log files (default 168h) --slow-query duration save a flight recorder snapshot to --trace-dir when a query takes this long --timeout duration maximum time to answer each query (default 5s) --tls-cert string TLS certificate chain file (PEM) --tls-key string TLS private key file (PEM) --tls-min-version string oldest TLS version to accept: 1.2 or 1.3 (TLS 1.3 is preferred either way) (default "1.2") --trace-dir string directory for slow query flight recorder snapshots --trusted-proxy strings addresses or prefixes of reverse proxies whose X-Forwarded-For names DoH clients --upstream strings upstream resolvers, in order of preference (default [https://cloudflare-dns.com/dns-query,https://dns.google/dns-query])

Chat with me