DNS over HTTPS with nginx, dnsdist and Pi-hole

When I was looking for something new to build I ended up building a DNS over HTTPS server. This way I can use my Pi-hole server wherever I am, without exposing port 53. I let nginx handle the encryption of the HTTPS connection, send the information to dnsdist for translation to DNS, and let Pi-hole filter the queries using my blocklists.

The following is assumed:

  • You have nginx up and running
  • You have a subdomain (doh.domain.tld, or dns.domain.tld) with valid certificates (Lets Encrypt, or commercial)
  • You have installed dnsdist, but not yet configured it
  • You have a Pi-hole server up and running, configured to your wishes

This instruction is based upon this tutorial from nginx.com, which I could not get to work.

So, that’s why I adopted their configuration to use dnsdist instead of their njs script language.

nginx

The configuration of nginx (saved as dns.domain.nl):

# Proxy Cache storage - so we can cache the DoH response from the upstream
proxy_cache_path /var/run/doh_cache levels=1:2 keys_zone=doh_cache:10m;

server {
    listen 80;
    server_name dns.domain.nl;
    return 301 https://dns.domain.nl/$request_uri;
}

# This virtual server accepts HTTP/2 over HTTPS
server {
    listen 443 ssl http2;
    server_name dns.domain.nl;

    access_log /var/log/nginx/doh.access;
    error_log /var/log/nginx/doh.error error;

    ssl_certificate /etc/letsencrypt/live/dns.domain.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dns.domain.nl/privkey.pem;

    # DoH may use GET or POST requests, Cache both
    proxy_cache_methods GET POST;

    # Return 404 to all responses, except for those using our published DoH URI
    location / {
        try_files $uri $uri/ =404;
    }

    # This is our published DoH URI
    location /dns-query {

      # Proxy HTTP/1.1, clear the connection header to enable Keep-Alive
      proxy_http_version 1.1;
      proxy_set_header Connection "";

      # Enable Cache, and set the cache_key to include the request_body
      proxy_cache doh_cache;
      proxy_cache_key $scheme$proxy_host$uri$is_args$args$request_body;

      # proxy pass to dnsdist
      proxy_pass http://127.0.0.1:5300;
    }
}

nginx sends an 404 error when you visit the address https://dns.domain.nl/. It is only a proxy to 127.0.0.1:5300 when data is sent to https://dns.domain.nl/dns-query.

Check the configuration of nginx

nginx -t

It should give the following output:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Restart nginx to load the new configuration:

systemctl restart nginx.service

dnsdist

The (minimal working) configuration of dnsdist (saved as dnsdist.conf)

Note: this is a minimal configuration. No measures have been taken with regard to security or abuse. Consult the documentation for more information.

-- dnsdist configuration file, an example can be found in /usr/share/doc/dnsdist/examples/

-- disable security status polling via DNS
-- setSecurityPollSuffix("")

-- fix up possibly badly truncated answers from pdns 2.9.22
-- truncateTC(true)

-- Answer to only clients from this subnet
setACL("127.0.0.1/8")

-- Define upstream DNS server (Pi-hole)
newServer({address="192.168.2.100", name="Pi-hole", checkName="dc01.domain.nl.", checkInterval=60, mustResolve=true})

-- Create local DOH server listener in DNS over HTTP mode, otherwise the information coming from nginx won't be processed well
addDOHLocal("127.0.0.1:5300", nil, nil, "/dns-query", { reusePort=true })

A few things are important here:

  • I’ve set an ACL that allows dnsdist to only answer to queries from the subnet 127.0.0.1/8.
  • I’ve added an upstream (downstream according to dnsdist) DNS server with the IP address 192.168.2.100. It’s configured with a custom checkName and checkInterval. Normally, dnsdist sends a query to a.root-servers.net every second(!). With this configuration, it checks another server – my domain controller – every 60 seconds.
  • I’ve added a DOH listener on the loopback address 127.0.0.1:5300. This is configured as DNS over HTTP, because nginx takes care of the decryption of the connection.

Check the configuration of dnsdist:

dnsdist --check-config

It should give the following output:

No certificate provided for DoH endpoint 127.0.0.1:5300, running in DNS over HTTP mode instead of DNS over HTTPS
Configuration '/etc/dnsdist/dnsdist.conf' OK!

Restart dnsdist to load the new configuration:

systemctl restart dnsdist.service

To check if dnsdist is listening to 127.0.0.1:5300:

netstat -tapn | grep 5300

It should give the following output:

tcp 0 0 127.0.0.1:5300 0.0.0.0:* LISTEN 4435/dnsdist

Configuring the browser

Now it’s time to configure your browser to use your new DNS over HTTPS server. This website explains how to configure your web browser to use DNS over HTTPS:

https://developers.cloudflare.com/1.1.1.1/dns-over-https/web-browser/

Final inspection

To make sure it’s working properly, we need to inspect the logs. nginx keeps a log of access and error messages. We will look at those logs to see if the information is passed on correctly to dnsdist.

Take a look at the access logs of nginx:

cat /var/log/nginx/doh.access

It should give the following output:

192.168.2.1 - - [09/Aug/2020:11:55:05 +0200] "POST /dns-query HTTP/2.0" 200 107 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:05 +0200] "POST /dns-query HTTP/2.0" 200 107 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:05 +0200] "POST /dns-query HTTP/2.0" 200 122 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:05 +0200] "POST /dns-query HTTP/2.0" 200 102 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:05 +0200] "POST /dns-query HTTP/2.0" 200 125 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:06 +0200] "POST /dns-query HTTP/2.0" 200 102 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:06 +0200] "POST /dns-query HTTP/2.0" 200 122 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:06 +0200] "POST /dns-query HTTP/2.0" 200 125 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:08 +0200] "POST /dns-query HTTP/2.0" 200 112 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:08 +0200] "POST /dns-query HTTP/2.0" 200 112 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:19 +0200] "POST /dns-query HTTP/2.0" 200 140 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:19 +0200] "POST /dns-query HTTP/2.0" 200 152 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:20 +0200] "POST /dns-query HTTP/2.0" 200 175 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:55:20 +0200] "POST /dns-query HTTP/2.0" 200 137 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:56:15 +0200] "POST /dns-query HTTP/2.0" 200 64 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:56:15 +0200] "POST /dns-query HTTP/2.0" 200 64 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:56:15 +0200] "POST /dns-query HTTP/2.0" 200 64 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:56:15 +0200] "POST /dns-query HTTP/2.0" 200 64 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:56:21 +0200] "POST /dns-query HTTP/2.0" 200 59 "-" "-"
192.168.2.1 - - [09/Aug/2020:11:56:30 +0200] "POST /dns-query HTTP/2.0" 200 55 "-" "-"

It’s important that you see 200 (after HTTP/2.0) in the logs. This means that nginx was able to pass the information to dnsdist. Otherwise, something has gone wrong.

When something has gone wrong, it should show up in the error logs

cat /var/log/nginx/doh.error

It should (hopefully not) give the following output:

2020/08/09 11:15:26 [error] 946#946: *511 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.2.1, server: dns.domain.nl, request: "POST /dns-query HTTP/2.0", upstream: "http://127.0.0.1:5300/dns-query", host: "dns.domain.nl"