Building a DNS over TLS (DoT) server

WARNING: This article is outdated and has been superseded by Configuring Unbound for Downstream DoT.

Since Unbound 1.6.7 there's a better way than the one described here

I previously posted some documentation on how to build a DNS over HTTPS (DoH) Server running Pi-Hole and/or Unbound.

There's another standard available, however - RFC 7858 DNS over TLS (DoT)

DoT isn't as censorship resistant as DoH (as it's easier to block), but does provide you with additional privacy. It also has the advantage of being natively supported in Android Pie (9), so can be used to regain control of your queries without needing to run a dedicated app link Intra, with all the issues that might entail.

In this documentation we're going to trivially build and place queries against a DoT server.

 

Initial Setup

This documentation assumes you've already built a DoH server, in which case we're simply bolting additional functionality on.

If that's not the case, you need to do something approaching the following sections as a minimum (if you don't want to setup DoH that's fine)

Essentially where you want to end up is that you have Nginx running (and configured with a cert, LetsEncrypt or otherwise) and a DNS stack running on your server.

 

Proxying DoT

All we're going to do is use NGinx's Stream module to terminate the SSL connection and pass plain TCP over loopback to the DNS stack

So, we need to edit /etc/nginx/nginx.conf and append the following to the end (remembering to replace the SSL certificate path with the details of your cert)

stream {
        server {
                listen                  *:853 ssl;
                proxy_pass              127.0.0.1:53;
                proxy_connect_timeout   1s;
                preread_timeout         2s;
        }


        ssl_certificate /etc/letsencrypt/live/dns.bentasker.co.uk/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/dns.bentasker.co.uk/privkey.pem;

        ssl_session_timeout             1d;
        ssl_session_tickets             off;

        ssl_protocols                   TLSv1.2 TLSv1.3;
        ssl_prefer_server_ciphers       on;

        # If you're new enough to support DoT you're new enough not to support old broken ciphers
        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;

        ssl_session_cache               shared:DoT:10m;

        log_format dot '$remote_addr\t-\t-\t[$time_local]\t$ssl_protocol\t'
            '$ssl_session_reused\t$ssl_cipher\t$ssl_server_name\t$status\t'
            '$bytes_sent\t$bytes_received';

        access_log /var/log/nginx/dot.log dot;
}

What we're doing here is more or less boilerplate, we're binding a server to port 853 (the DoT port) using SSL/TLS with a fairly restricted set of ciphers.

We've also enabled TLSv1.3 - keep in mind it needs a fairly recent Nginx for that to work, so if you don't have the means to use that, just enable TLSV1.2 for now

Now we can reload Nginx and verify it's bound the the appropriate port

systemctl reload nginx
netstat -lnp | grep nginx
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      6009/nginx: master  
tcp        0      0 0.0.0.0:853             0.0.0.0:*               LISTEN      6009/nginx: master  
tcp        0      0 0.0.0.0:443             0.0.0.0:*               LISTEN      6009/nginx: master  
tcp6       0      0 :::853                  :::*                    LISTEN      6009/nginx: master  

We also need to punch a hole in the firewall so that clients can use the service

iptables -I INPUT -p tcp --dport 853 -j ACCEPT
iptables-save > /etc/iptables/rules.v4

 

Testing

We should now be in a position where we can place some test queries against the server.

At time of writing, this is easier said than done, as although (at a technical level) DoT is a saner solution that DoT it really suffers from a lack of adoption and lack of available tooling.

If you've got an Android Pie device, then support is baked in, so you can configure your DNS resolver on your device and it should just work.

Your best bet if it's available for your system is to install Stubby, but failing that clone down something like phpdnstls

$ php dnstls.php www.google.com dns.bentasker.co.uk A
www.google.com has address 216.58.210.196

We can see the connection at the far end in /var/log/nginx/dot.log

86.138.218.68  -       -       [13/Jun/2019:14:25:43 +0000]    TLSv1.2 .       ECDHE-RSA-AES256-GCM-SHA384     dns.bentasker.co.uk     200     50      34

You can also test at https://getdnsapi.net/query/ using the following config

  • Domain name: A name to query (e.g. www.google.com)
  • Extensions: return_call_reporting
  • Transport Order: TLS
  • TLS Resolver IP: The IP of your DoT server
  • TLS Auth name: The name on your SSL Certificate
It should return you a nice blob of JSON. You want to check the cert validated, and that you got a result

Cert status is under "call_reporting"

[
      "tls_auth_status": <bindata of "Success">,
      "tls_peer_cert": <bindata of 0x3082055d30820445a003020102021203...>,
      "tls_version": <bindata of "TLSv1.2">,
]

and result is "just_address_answers"

  [
    {
      "address_data": <bindata for 216.58.208.164>,
      "address_type": <bindata of "IPv4">
    }
  ],

Downstream Unbound Config

Assuming you're running a recent enough version of Unbound on a downstream server (for example on your LAN), it's possible to configure it to use DoT for upstream connections, It should be noted, though, that at time of writing (Unbound v1.9.1) Unbound cannot currently re-use TCP/TLS connections so will open a new one for every query that needs to be sent upstream).

There are two main steps to this, the first is to provide it with the path to your OS's cert bundle so that it validates certs. We do this by adding tls-cert-bundle to the server section of /etc/unbound/unbound.conf. The path to the bundle differs depending on your OS (you'll also need to have installed the package ca-certificates)

Debian

tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt

CentOS

tls-cert-bundle: /etc/pki/tls/certs/ca-bundle.crt 

The next thing to do is to tell Unbound to use our DNS server as an upstream by setting a forward zone for "."

forward-zone:
        name: "."
        forward-tls-upstream: yes
        forward-first: yes
        forward-addr: 1.2.3.4@853#dns.bentasker.co.uk

Where the format of forward-addr is [dns server ip]@[port]#[name to check on certificate]

We set forward-first to yes so that Unbound will fallback to resolving names itself if the TLS connection fails. This is a silent failure though and will lead to plaintext queries hitting the wire without notice if it triggers, so you need to think carefully about whether you actually want that set (I've removed it from my server).

Alternatively, you can set multiple forwarding addresses, for example this would spread our queries across Google and our own server

forward-zone:
        name: "."
        forward-tls-upstream: yes
        forward-addr: 1.2.3.4@853#dns.bentasker.co.uk
        forward-addr: 8.8.8.8@853#dns.google.com

However, this has a downside. Unbound spreads queries across forwarders initially, but then starts to prefer the fastest responding server (which will normally be Google rather than your own one). So you'll start finding your queries no longer get the benefit of your remote Pi-Hole. If your intention is simply to hide your queries from your ISP, though, this is fine.

Then just reload Unbound

systemctl reload unbound

Job Done. Assuming you followed the earlier DoH article too, you should now have a DNS resolver running Pi-Hole that can be used for DoT by Android Pie devices and DoH for (mostly) everything else.