Building and running your own DNS-over-HTTPS Server

There's been a fair bit of controversy over DNS-over-HTTPS (DoH) vs DNS-over-TLS (DoT), and some of those arguments still rage on.

But, DoH isn't currently going anywhere, and Firefox has directly implemented support (though it calls them Trusted Recursive Resolvers or TRR for short).

Although DoH offers some fairly serious advantages when out and about (preventing blocking or tampering of DNS lookups by network operators), when left with default configuration it does currently come with some new privacy concerns of it's own. Do you really want all your DNS queries going via Cloudflare? Do you want them to be able to (roughly) tell when your mobile device is home, and when it's out and about (and potentially, also your employer - if they own the netblock)? The same questions of course go if you use Google's DNS too.

That, however, is addressable by running your own DNS-over-HTTPS server. This also has advantages if you're trying to do split-horizon DNS on your LAN, so I'll discuss that later too.

The primary purpose of this documentation is to detail how to set up your own DoH server on Linux. The main block of this documentation is concerned with getting a NGinx fronted DoH server backed by Unbound up and running, but will also discuss the steps needed to add Pi-Hole into the mix.

Unless otherwise noted, all commands are run as root

 

Overview

This documentation may look like the setup is a lot of work, but the basic setup is actually reasonably straight forward and simple. I've covered a number of potential additional options in this documentation (and as you can see from my tinkering in MISC-27 just barely scratched the surface).

Once complete, our basic setup will be:

  • Nginx (handling SSL)
  • DoH-Server (translating Wireformat between HTTP and UDP)
  • Unbound (handling adblocking and name resolution)

Further down the page, you'll find some additional references

Basic Setup

Hardware

I built this system using Digitalocean's smallest droplet size:

  • 1GB RAM
  • 1 CPU
  • 25GB root disk (only currently actually using 2.3GB of this).
  • Running Debian 9.7

It runs happily enough, but would probably benefit from additional cores and RAM. In principle there isn't any real reason you couldn't build this on a raspberry pi if needed

 

Installing the DoH Server Software

The first thing we're going to do is install some software to translate DNS over HTTPS requests into ordinary DNS requests.

apt-get update
apt-get install curl software-properties-common build-essential git
mkdir build
cd build 

# Need Go >= 1.10 to build DoH server
# so fetch latest
wget https://dl.google.com/go/go1.12.2.linux-amd64.tar.gz
tar xzf go1.12.2.linux-amd64.tar.gz 
mv go /usr/local/
export GOROOT=/usr/local/go
export PATH=$GOPATH/bin:$GOROOT/bin:$PATH
go version # verify it's working


# Now we fetch and compile the DoH server
mkdir ~/gopath
export GOPATH=~/gopath
wget https://github.com/m13253/dns-over-https/archive/v2.0.1.tar.gz
tar xzf v2.0.1.tar.gz 
cd dns-over-https-2.0.1/
make
make install

Next we're going to write out the config for that server. It won't currently work as we've not installed the other components yet

cat << EOM > /etc/dns-over-https/doh-server.conf
# HTTP listen port
listen = [
    "127.0.0.1:8053",    
    "[::1]:8053",

]

# TLS certification file
# If left empty, plain-text HTTP will be used.
# You are recommended to leave empty and to use a server load balancer (e.g.
# Caddy, Nginx) and set up TLS there, because this program does not do OCSP
# Stapling, which is necessary for client bootstrapping in a network
# environment with completely no traditional DNS service.
cert = ""

# TLS private key file
key = ""

# HTTP path for resolve application
path = "/dns-query"

# Upstream DNS resolver
# If multiple servers are specified, a random one will be chosen each time.
upstream = [
    "127.0.0.1:5353"
]

# Upstream timeout
timeout = 10

# Number of tries if upstream DNS fails
tries = 3

# Only use TCP for DNS query
tcp_only = false

# Enable logging
verbose = false

# Enable log IP from HTTPS-reverse proxy header: X-Forwarded-For or X-Real-IP
# Note: http uri/useragent log cannot be controlled by this config
log_guessed_client_ip = false

EOM

systemctl restart doh-server
systemctl status doh-server

 

Installing Nginx

Next we need to get Nginx up and running as that'll be  handling SSL termination and logging for us

apt-get install gnupg2 ca-certificates lsb-release
echo "deb http://nginx.org/packages/debian `lsb_release -cs` nginx" >> /etc/apt/sources.list.d/nginx.list
curl -fsSL https://nginx.org/keys/nginx_signing.key | apt-key add -
apt-key fingerprint ABF5BD827BD9BF62
apt-get update
apt-get -y install nginx


# We're going to set rate limits just in case the public gain access
# This sets 300 requests a second
cat << EOM > /etc/nginx/conf.d/00-rate-limits.conf
limit_req_zone \$binary_remote_addr zone=doh_limit:10m rate=300r/s;
EOM

# Create the config - remember to replace server_name with whatever name you are using
cat << EOM > /etc/nginx/conf.d/doh.conf
upstream dns-backend {
    server 127.0.0.1:8053;
    keepalive 30;
}
server {
        listen 80;
        server_name dns.bentasker.co.uk;
        root /tmp/NOEXIST;
        location /dns-query {
                limit_req zone=doh_limit burst=50 nodelay;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header Host \$http_host;
                proxy_set_header X-NginX-Proxy true;
                proxy_http_version 1.1;
                proxy_set_header Upgrade \$http_upgrade;
                proxy_set_header Connection "";
                proxy_redirect off;
                proxy_set_header        X-Forwarded-Proto \$scheme;
                proxy_read_timeout 86400;
                proxy_pass http://dns-backend/dns-query;
        }


        location / {
            return 404;
        }
        
        
}
EOM

systemctl restart nginx

Verify it's started

root@debian-9-doh-newbuild:~/build/dns-over-https-2.0.1# netstat -lnp | grep 80
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      4890/nginx: master  
tcp        0      0 127.0.0.1:8053          0.0.0.0:*               LISTEN      4029/doh-server     
tcp6       0      0 ::1:8053                :::*                    LISTEN      4029/doh-server     
udp6       0      0 fe80::343e:5bff:fee:123 :::*                                834/ntpd    

So now we want to set up the SSL. We're going to use LetsEncrypt to obtain a valid certificate for our server, so make sure whatever DNS name you want to use for your service (in my case dns.bentasker.co.uk) resolves back to the server

cat << EOM > /etc/nginx/conf.d/00-cert-stapling.conf
ssl_stapling on;
ssl_stapling_verify on;
resolver 127.0.0.1:5353;
EOM

apt-get install certbot python-certbot-nginx
certbot --nginx -d dns.bentasker.co.uk

Agree to all the terms and then when prompted, choose redirect

Certbot's default SSL options can are a little over liberal though, so overwrite them with some more conservative values

cat << EOM > /etc/letsencrypt/options-ssl-nginx.conf
ssl_session_cache shared:le_nginx_SSL:1m;
ssl_session_timeout 1440m;

ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;

ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
add_header Strict-Transport-Security "max-age=31536000;" always;
EOM

systemctl restart nginx

Nginx should now be listening on port 443

root@debian-9-doh-newbuild:~/build/dns-over-https-2.0.1# netstat -lnp | grep nginx
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      5466/nginx: master  
tcp        0      0 0.0.0.0:443             0.0.0.0:*               LISTEN      5466/nginx: master 

Now we just want to edit our Nginx config to enable HTTP/2 (add it to the listen line)

cat /etc/nginx/conf.d/doh.conf

upstream dns-backend {
    server 127.0.0.1:8053;
    keepalive 30;
}
server {
        server_name dns.bentasker.co.uk;
        root /tmp/NOEXIST;
        location /dns-query {
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
                proxy_set_header X-NginX-Proxy true;
                proxy_set_header Connection "";
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_redirect off;
                proxy_set_header        X-Forwarded-Proto $scheme;
                proxy_read_timeout 86400;
                proxy_pass http://dns-backend/dns-query;
        }


        location / {
            return 404;
        }

    listen 443 ssl http2; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/dns.bentasker.co.uk/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/dns.bentasker.co.uk/privkey.pem; # managed by Certbot

}

 

Unbound Setup

Our next step, then, is to get Unbound installed and up and running.

There are two ways to go about this depending on whether your want EDNS Client Subnet (ECS) data to be sent to authoritative resolvers or not. If you don't, then you can simply install a package, but CDN's will route you to a PoP most suitable for the location of your DoH server and not for the location of whatever client you're using.

With ECS enabled, the server will include your subnet in upstream queries (so if your client device has public IP 1.2.3.4 the DNS server will include a field to state you're in 1.2.3.0/24 so that routing systems can return a result appropriate to that location/address).

My preference is to use a version which can enable ECS, but this does mean fetching and compiling Unbound, as the version in Debian's repo has been packaged without ECS support.

Either way, we're first going to firewall the ports so that random netizens do not try and exploit our default-settings unbound whilst we're setting it up


iptables -I INPUT -p tcp --dport 53 -j REJECT
iptables -I INPUT -p udp --dport 53 -j REJECT
iptables -I INPUT -i lo -j ACCEPT
ip6tables -I INPUT -p tcp --dport 53 -j REJECT
ip6tables -I INPUT -p udp --dport 53 -j REJECT
ip6tables -I INPUT -i lo -j ACCEPT

 

Packaged Unbound

If you're happy to forsake ECS support do the following

apt-get install unbound

However, in the instructions that follow, you'll also need to replace /usr/local/etc/unbound with /etc/unbound wherever it occurs (or you can be lazy and do ln -s /etc/unbound /usr/local/etc

 

Compiled Unbound

Do the following to get unbound install and in place

apt-get install libssl-dev libexpat1-dev
cd ~/build
wget http://www.unbound.net/downloads/unbound-latest.tar.gz
tar xzf unbound-latest.tar.gz
cd unbound-1.9.1/
./configure --enable-subnet
make
make install

ln -s /usr/local/etc/unbound/ /etc/unbound
ldconfig

useradd -r unbound
mkdir /usr/local/etc/unbound/trust
mkdir /usr/local/etc/unbound/local.d
mkdir /usr/local/etc/unbound/unbound.conf.d
chown unbound /usr/local/etc/unbound/trust

sudo -u unbound unbound-control-setup -d /usr/local/etc/unbound/trust/
sudo -u unbound unbound-anchor -a /usr/local/etc/unbound/trust/root.key

cat << EOM > /lib/systemd/system/unbound.service
[Unit]
Description=Unbound DNS server
Documentation=man:unbound(8)
After=network.target
Before=nss-lookup.target
Wants=nss-lookup.target

[Service]
Type=simple
Restart=on-failure
EnvironmentFile=-/etc/default/unbound
ExecStartPre=/usr/local/sbin/unbound-anchor -a /usr/local/etc/unbound/trust/root.key
ExecStart=/usr/local/sbin/unbound -c /usr/local/etc/unbound/unbound.conf -d \$DAEMON_OPTS
ExecReload=/usr/local/sbin/unbound-control reload

[Install]
WantedBy=multi-user.target
EOM

systemctl daemon-reload

 

Configuring Unbound

However you installed unbound, the next step is to configure it. We're going to bind it to loopback (and a custom port at that) and we're going to allow it to pull in some additional config files so we can maintain adblock lists

cat << EOM > /usr/local/etc/unbound/unbound.conf
server:
    module-config: "subnetcache validator iterator"
    chroot: "/usr/local/etc/unbound"
    directory: "/usr/local/etc/unbound"
    username: "unbound"

    interface: 127.0.0.1
    port: 5353
    do-daemonize: yes
    verbosity: 1
    
    # Enable UDP, "yes" or "no".
    do-udp: yes

    # Enable TCP, "yes" or "no".
    do-tcp: yes
    
    auto-trust-anchor-file: "/usr/local/etc/unbound/trust/root.key"
    
    # ECS support
    client-subnet-zone: "." 
    client-subnet-always-forward: yes
    max-client-subnet-ipv4: 24
    max-client-subnet-ipv6: 48    
    
    
    # Randomise case to make poisioning harder
    use-caps-for-id: yes

    # Minimise QNAMEs
    qname-minimisation: yes

    harden-below-nxdomain: yes
    


    # This is where we'll put our adblock config 
    include: local.d/*.conf

include: unbound.conf.d/*.conf

remote-control:
    control-enable: yes
    control-interface: 127.0.0.1
    control-port: 8953
    server-key-file: "/usr/local/etc/unbound/trust/unbound_server.key"
    server-cert-file: "/usr/local/etc/unbound/trust/unbound_server.pem"
    control-key-file: "/usr/local/etc/unbound/trust/unbound_control.key"
    control-cert-file: "/usr/local/etc/unbound/trust/unbound_control.pem"

EOM

If you want to just forward queries rather than have unbound resolve them itself, you can do this

# Don't do this unless you've read the previous sentence!
cat << EOM > /usr/local/etc/unbound/unbound.conf.d/forwarders.conf
forward-zone:
        name: "."
        forward-addr: 8.8.8.8
        forward-addr: 8.8.4.4
EOM

Now start Unbound and it should be bound to loopback on port 5353

systemctl start unbound

root@debian-9-doh-newbuild:/usr/local/etc/unbound# netstat -lnp | grep unboun
tcp        0      0 127.0.0.1:5353          0.0.0.0:*               LISTEN      16753/unbound       
udp        0      0 127.0.0.1:5353          0.0.0.0:*                           16753/unbound       

At this point, we _should_ be able to place requests via our setup

root@debian-9-doh-newbuild:/usr/local/etc/unbound# curl -s "https://dns.bentasker.co.uk/dns-query?name=projects.bentasker.co.uk&type=A" | python -m json.tool
{
    "AD": false,
    "Answer": [
        {
            "Expires": "Wed, 01 May 2019 11:05:14 UTC",
            "TTL": 3600,
            "data": "projects.balanced.bentasker.co.uk.",
            "name": "projects.bentasker.co.uk.",
            "type": 5
        },
        {
            "Expires": "Wed, 01 May 2019 10:05:44 UTC",
            "TTL": 30,
            "data": "51.255.232.237",
            "name": "projects.balanced.bentasker.co.uk.",
            "type": 1
        }
    ],
    "CD": false,
    "Question": [
        {
            "name": "projects.bentasker.co.uk.",
            "type": 1
        }
    ],
    "RA": true,
    "RD": true,
    "Status": 0,
    "TC": false,
    "edns_client_subnet": "134.209.27.0/24"
}

 

Adblock setup

Now we want to set up an auto-pull of a list of adblocked domains so that Unbound will return 127.0.0.1 for those.

Set up a cron to periodically refresh the list:

curl -o /root/update_ads.sh https://www.bentasker.co.uk/adblock/static/update_ads.sh
chmod +x /root/update_ads.sh
echo "0 0 * * *    root    /root/update_ads.sh" > /etc/cron.d/ad_domains_update

Now go and manually populate an instance of the list

cd /usr/local/etc/unbound/local.d/
curl -o adblock.new "https://www.bentasker.co.uk/adblock/autolist.txt"
mv adblock.new adblock.conf
systemctl restart unbound
curl -s "https://dns.bentasker.co.uk/dns-query?name=google-analytics.com&type=A" | python -m json.tool # Should give an IP of 127.0.0.1

 

Firewall Rules

Although things should be relatively safely set up, we still obviously want to put some firewall rules in place as a first line of defence against accidental misconfigurations

iptables -F INPUT
iptables -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -A INPUT -i lo -s 127.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -j REJECT
ip6tables -F INPUT
ip6tables -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
ip6tables -A INPUT -i lo -s ::1 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
ip6tables -A INPUT -j REJECT
apt-get -y install iptables-persistent
iptables-save > /etc/iptables/rules.v4
ip6tables-save > /etc/iptables/rules.v6

And we're done!

 


Pi-Hole

It wasn't my original aim to use Pi-hole, but given it's huge popularity it'd be remiss not to document how to use it instead of (and as well as) Unbound. If you're planning on using it instead of unbound then skip that step (or if you've already done it, sorry. Just stop and disable unbound)

Now lets install pi-hole

curl -sSL https://install.pi-hole.net | bash

When prompted, do not install Pi-hole default firewall rules, make a note of the admin password when it's provided

Once the installer's complete, we need to reconfigure lighttpd to bind to a different port

vi /etc/lighttpd/lighttpd.conf
#change server.port to 8080
systemctl start lighttpd

root@debian-9-doh-newbuild:~# netstat -lnp | grep light
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      27041/lighttpd      
tcp6       0      0 :::8080                 :::*                    LISTEN      27041/lighttpd      
unix  2      [ ACC ]     STREAM     LISTENING     291926   27053/php-cgi        /var/run/lighttpd/php.socket-0

Edit the DNS-over-HTTPS server config to change the upstream

vi /etc/dns-over-https/doh-server.conf

upstream = [
    "127.0.0.1:53"
]

systemctl restart doh-server

We're going to tell pihole to only bind it's DNS service to loopback, as we don't want accidents with the firewall to lead to us running an open UDP resolver

cat << EOM > /etc/dnsmasq.d/99-mysettings.conf
listen-address=::1,127.0.0.1
bind-interfaces
EOM

systemctl restart pihole-FTL

We'll overwrite our cert stapling config for NGinx to point it towards pihole

cat << EOM > /etc/nginx/conf.d/00-cert-stapling.conf
ssl_stapling on;
ssl_stapling_verify on;
resolver 127.0.0.1:53;
EOM

And create basic NGinx config so we can proxy through to Pihole's web interface (I strongly recommend you add additional authentication to this basic example). Remember to replace dnsadmin.bentasker.co.uk with your own domain

cat doh-admin.conf 
server {
        server_name dnsadmin.bentasker.co.uk;
        root /tmp/NOEXIST;

        #In production, definitely want to add some auth to this
        location /admin/ {
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
                proxy_set_header X-NginX-Proxy true;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_redirect off;
                proxy_set_header        X-Forwarded-Proto $scheme;
                proxy_read_timeout 86400;
                proxy_pass http://127.0.0.1:8080;
        }


    listen 443 ssl http2; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/dnsadmin.bentasker.co.uk/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/dnsadmin.bentasker.co.uk/privkey.pem; # managed by Certbot

}

systemctl restart nginx

You should now be able to login to the admin page at https://dnsadmin.bentasker.co.uk/admin and see reports on how your queries have been handled.

If you want to add my adblocking list to Pihole, you can do this through Pihole's interface - (Login -> Settings -> Blocklists) - adding the list https://www.bentasker.co.uk/adblock/blockeddomains.txt

I'd also strongly recommend adding some protection against Phishing domains by adding the lists provided at Phishing Army

You should also consider configuring Pi-Hole to update it's blocking lists more regularly.

If your intention was to run Pihole instead of unbound, it's now done.

Running Unbound Alongside

If you still wanted to use unbound behind pihole, then there are a few additional steps. We need to rebind unbound to 127.0.1.1:53 (the reason being pihole won't seem to allow us to specify an additional port - you should be able to use a #, but it rejected mine)

Edit /usr/local/etc/unbound/unbound.conf and adjust the config so that the following are set

    interface: 127.0.1.1
    port: 53

Then restart unbound

systemctl restart unbound

Now in Pihole's admin page, we can set an upstream resolver (Login -> Settings -> DNS) by unticking the existing options and adding 127.0.1.1 into custom1

At this point it should all just work (Pihole forwards through the ECS data that doh-server passes into it.

 


Client Setup

Firefox

It's possible to set up Firefox to perform queries itself by pointing it's Trusted Recursive Resolver (TRR) config to your server. Go to about:config Set (replace with your domain):

  • network.trr.uri to https://dns.bentasker.co.uk/dns-query
  • network.trr.mode 2
  • network.trr.disable-ECS false

OS Level - Android

Go to Google's Play store and download Intra. It's released by Google's sister company Jigsaw and acts as a VPN interface in order to intercept DNS queries

  • Open Intra
  • Go into Settings and choose "Select DNS over HTTPS Server"
  • Choose custom server URL
  • enter https://dns.bentasker.co.uk/dns-query

OS Level - Linux

There are other solutions such as cloudflared but I opted just to use the client portion of the codebase we deployed onto our DoH server

wget https://dl.google.com/go/go1.12.2.linux-amd64.tar.gz
tar xvf go1.12.2.linux-amd64.tar.gz
sudo mv go /usr/local/
export GOROOT=/usr/local/go
export PATH=$GOPATH/bin:$GOROOT/bin:$PATH
wget https://github.com/m13253/dns-over-https/archive/v2.0.1.tar.gz
tar xzf v2.0.1.tar.gz
mkdir ~/gopath
export GOPATH=~/gopath
make
sudo make install

sudo vi /etc/dns-over-https/doh-client.conf

Change the URL under [[upstream.upstream_ietf]] to "https://dns.bentasker.co.uk/dns-query":

[[upstream.upstream_ietf]]
	url = "https://dns.bentasker.co.uk/dns-query"
	weight = 100

systemctl enable doh-client.service
systemctl start doh-client.service
netstat -lnp | grep 53

dig @127.0.0.1 projects.bentasker.co.uk

If you're using NetworkManager, override the DNS in it's config for your connection

# Add Under the [ipv4] section:
ignore-auto-dns=true
dns=127.0.0.1;

Otherwise, just drop it into resolv.conf

echo nameserver 127.0.0.1 | sudo tee /etc/resolv.conf

 

Mac OX X

Install cloudflared and reconfigure it to use your own server

brew install cloudflare/cloudflare/cloudflared


vi /usr/local/etc/cloudflared/config.yaml
proxy-dns: true
proxy-dns-upstream:
  - https://dns.bentasker.co.uk/dns-query

sudo cloudflared service install

Go to System Preferences->Network->Advanced->DNS and set DNS to 127.0.0.1

 

Windows

Much like with OS X install Cloudflared as described here and then reconfigure to use your server instead

 


Additional Notes

Split Horizon DNS

One of the reasons for doing this may be that you want to use DoH on mobile devices (such as yourp phone and laptop) so that your queries aren't interfered with by network operators when out and about, but don't want to need to remember to toggle it off when you get home and want to resolve local domains (Note: alternatively, if you configure Firefox with network.trr.mode 2 it'll automatically fall back to the OS configured resolver if it cannot resolve a name, so LAN names will still resolve if you haven't also configured the OS to use DoH. The downside of that is that apps etc don't get the DoH protection).

If it's your intention to do that, then you'll want to essentially end up with two DoH servers, one that's in publicly routable address space (so that your devices can reach it when out and about) and one on the LAN, carrying LAN specific records/overrides. You'd then use traditional split-horizon DNS on the name to send LAN clients to the LAN DoH server, and out-and-about devices to the main one.

Note that you don't necessarily need to run a full-fat DoH server on your public instance, and could instead proxy through to cloudflare or Google:

server {
        server_name dns.bentasker.co.uk;
        root /tmp/NOEXIST;
        location /dns-query {
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host 1.1.1.1;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_redirect off;
                proxy_read_timeout 86400;
                proxy_pass https://1.1.1.1/dns-query;
        }


        location / {
            return 404;
        }

    listen 443 ssl http2; # managed by Certbot
    ssl_certificate /etc/pki/cert/cert.pem;
    ssl_certificate_key /etc/pki/private/cert.key;
}

You will need a fuller-fat version on the LAN though so that you can configure overrides - though you could configure the DoH-server daemon to forward onto your LAN DNS server rather than having a new unbound instance - so it needn't be full fat

 

Tor

I did experiment with exposing the resolver via Tor (so that exit bandwidth could be saved etc).

However, Firefox doesn't appear to honour RFC-7838 alt-svc headers received along with DoH responses, so cannot be directed to use an Onion for transport instead. Tor Browser Bundle doesn't either, but that's not unexpected.

Some clients can be configured to use plain HTTP instead of HTTPS, however resolves then happen quite slowly due to the unavailability of HTTP/2. Intra on android, however, (correctly, IMO) will not accept a HTTP URI.

So it's not that it can't be done, just that you either need to accept some trade-offs in client availability, or need to be able to afford a cert for an Onion.

 

Running a public resolver

One of the biggest risks of running a publicly available DNS server is automatically mitigated by DNS-over-HTTPS: that of Reflection based attacks.

Because HTTPS requries TCP to function, it's no longer possible for an attacker to send a DNS query with a spoofed address in order to have an unsolicited and large response sent to the victim.

However, just because the DRDOS risk is mitigated, it would be unwise to assume it's automatically now OK to run an open DoH resolver. There are a wide variety of other potential issues that should be considered, although eagle eyed visitors will note that our Unbound config did enable mitigation for things like cache poisoning attacks, and that our Nginx config introduced rate limits at the HTTP level.

Whilst, at first glance, it might seem like this config should be safe as an Open Resolver, my advice would still be not to run one. At least not until you've done far more reading into the nuances and requirements of running open resolvers than I have. You should start with Current Best Practices.


Authentication and Access Control

Firefox only clients

If you're simply using Firefox's TRR, it's possible to provide it with the right-hand side of an authorisation header in network.trr.credentials. So you might do something like the following (please use a better password)

cd /etc/nginx
printf "dohuser:$(openssl passwd -crypt supersec)\n" >> .htpasswd
vi conf.d/doh.conf

# Add the following within the dns-query location statement:

satisfy any;
deny all;
auth_basic "Authentication is a must";
auth_basic_user_file /etc/nginx/.htpasswd;

service nginx reload

Then generate a string to pass to Firefox

echo "Basic `echo -n "dohuser:supersec" | base64`"
Basic ZG9odXNlcjpzdXBlcnNlYw==

Set that as the value for network.trr.credentials

Intra/DoH-client/Mixed clients

Most DoH clients do not include explicit support for authentication.

Assuming you're not able to predict what each client's IP will be at any point (otherwise, you'd just whitelist IPs), you will need to rely on a means of putting authentication tokens into the path, and reconfiguring your client to use those tokens.

That may be as simple as changing your NGinx location block

        location /mysupersecretstring/dns-query {
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
                proxy_set_header X-NginX-Proxy true;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_redirect off;
                proxy_set_header        X-Forwarded-Proto $scheme;
                proxy_read_timeout 86400;
                proxy_pass http://dns-backend/dns-query;
        }
        
        location /dns-query {
                return 404;
        }

to frustrate bots/attackers that might test for responses under dns-query.

Or, for slightly more complex implementations (such as having a 'user' per client) you could also switch to using OpenResty so that you can use LUA to validate credentials passed in the query string. Under that scheme you'd reconfigure Intra (or whatever) to include a fixed query string containing the credentials.

 

DNS-over-TLS Support

Android 9 (Pie) supports DNS-over-TLS (DoT) natively, so there's some value in also enabling DoT on your server. It's just a few minutes extra work, as this documentation has already laid most of the foundations.


Conclusion

There are a variety of possible deployment options, with fairly wide variation in client feature support, so it's easy to complicate the setup (as can be seen above). However, it's relatively easy to build a simple functioning DoH server to serve your own traffic, and there's sufficient client support to cover a variety of operating systems.

Using it with Pi-Hole also means you get some nice reports showing what percentage of your traffic was blocked, query rates etc.