Skip to content
picatzPublic

About

🍩 DNS over HTTPS command-line client and server

Topics

Resources

Stars

122 stars

Watchers

1 watching

Forks

Latest commit

Β 

History

132 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

doh

GitHub license PRs Welcome

🍩 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, alerts to your phone (as notifications from the installable web UI, or through ntfy), by email, or to a webhook when a threat is blocked or a new device appears, and a web UI.
  • doh status, profile, allow, log, explain, audit, and doctor manage a running server, show who changed what, 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

Or download a build for Linux, macOS, Windows, or FreeBSD from releases. Each file is attested with build provenance: signed proof that it was built from this repository's source, by its release workflow. Check one before running it:

$ gh attestation verify doh_1.2.3_linux_amd64.tar.gz --repo picatz/doh
$ sha256sum --check --ignore-missing SHA256SUMS

Builds are reproducible (make dist builds the same bytes from the same commit), static, and depend on nothing at runtime.

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 admin@example.com

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, checked for changes with conditional requests (security lists every two hours, others 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. Over 90 apps, such as TikTok, Roblox, ChatGPT, or betting apps, can be blocked by name, always or on a schedule, like homework hours or bedtime (apps and schedules).

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.

Sharing a server with family or friends? Each household can be a tenant that manages its own profiles, devices, log, and people, bounded so it can't see or affect anyone else's: see Sharing a server.

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 tab shows the same, for every platform, with one-click configuration profile downloads. With --hostname, it also makes setup links: a link to send to the phone or tablet you're setting up, over AirDrop, a message, or the share sheet, or as a QR code to scan with its camera, that opens a page with one-tap install on Apple devices and plain steps for others. Each works for 30 minutes. doh setup <profile> --link prints the same QR code in a terminal. Configuration profiles are signed with the server's certificate, or --profile-signing-cert, so Apple devices can show them as verified; see Signed configuration profiles.

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. The query log then names your devices on its own, learning what each calls itself from the router or DHCP leases (device names):

$ 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 a service account key or access token, or 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": "admin@example.com",
      "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. Apple configuration profiles
are signed with that certificate, so devices can show them as verified, or with
--profile-signing-cert and --profile-signing-key.

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 admin@example.com

  # 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 check blocklists for changes, unless their category has its own interval (default 24h0m0s)
      --blocklist-refresh-category stringToString
                                       how often to check preset blocklists of a category for changes, as category=duration (security, privacy, parental) (default [security=2h0m0s])
      --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)
      --device-names-from strings      DNS servers to ask for device names by reverse lookup, such as your router at 192.168.1.1 (default the forward zones, then the default gateway)
      --dhcp-leases strings            DHCP lease files to read device names from: dnsmasq's, or Kea's CSV
      --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-device-names                don't learn device names from the network
      --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
      --profile-signing-cert string    certificate chain file (PEM) to sign Apple configuration profiles with (default the TLS certificate)
      --profile-signing-key string     private key file (PEM, ECDSA or RSA) for --profile-signing-cert
      --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])

About

🍩 DNS over HTTPS command-line client and server

Topics

Resources

Stars

122 stars

Watchers

1 watching

Forks

Used by

Contributors

Languages