OK, let’s start with DoT definition:

DNS over TLS (DoT) is a security protocol for encrypting and wrapping Domain Name System (DNS) queries and answers via the Transport Layer Security (TLS) protocol. The goal of the method is to increase user privacy and security by preventing eavesdropping and manipulation of DNS data via man-in-the-middle attacks.

There are, of course, multiple ways to use DoT on macOS (one alternative would be to use Unbound). I took the approach of dnsmasq + Stubby simply because I had already planned playing with the former – editing /etc/hosts has bitten me so many times, that I finally gave up and decided to use some tiny DNS instead. Additionally I wanted to get rid of at least some trackers, counters, analytics and, potentially, ads.

I’m using Homebrew for installation:

brew install dnsmasq stubby

Configuration for dnsmasq can be stored in /usr/local/dnmasq.d/*.conf files. This way very little needs to be done in the main /usr/local/etc/dnsmasq.conffile. It eases future upgrades. Configuration file already has the necessary line, only it is commented out, so:

# BSD/default sed
sed -i '' 's%^#conf-dir=/usr/local/etc/dnsmasq.d/,\*.conf$%conf-dir=/usr/local/etc/dnsmasq.d/,\*.conf%' /usr/local/etc/dnsmasq.conf

# GNU sed
gsed -i 's%^#conf-dir=/usr/local/etc/dnsmasq.d/,\*.conf$%conf-dir=/usr/local/etc/dnsmasq.d/,\*.conf%' /usr/local/etc/dnsmasq.conf

Further configuration is very straight-forward:

cat << EOF > /usr/local/etc/dnsmasq.d/stubby.conf
no-resolv
proxy-dnssec
server=::1#5300
server=127.0.0.1#5300
listen-address=::1,127.0.0.1
EOF

These five lines are enough. The server part tells dnsmasq to pass-through all the DNS requests towards local server listening on port 5300. There’s nothing listening there yet, but it is where Stubby will wait for the requests.

Here’s one more, optional, step: filtering. I decided to go with a very nice list that is used by default in the AdGuard Home project. More information regarding the list itself can be found here: dbl.oisd.nl | Internet’s #1 domain blocklist.

curl --silent https://hostsmobile.oisd.nl | grep '^0\.0\.0\.0' | sort | awk '{print "address=/"$2"/"}' > /usr/local/etc/dnsmasq.d/ads.conf

After this step, all of the requests targeting domains specified in the /usr/local/etc/dnsmasq.d/ads.conf are going to be blocked. Last step is to check the configuration:

sudo dnsmasq --test
dnsmasq: syntax check OK.

sudo is necessary as dnsmasq listens on protected port 53 by default.

Time for Stubby. Here’s the outline of the /usr/local/etc/stubby/stubby.ymlconfig file:

resolution_type: GETDNS_RESOLUTION_STUB
dns_transport_list:
  - GETDNS_TRANSPORT_TLS
tls_authentication: GETDNS_AUTHENTICATION_REQUIRED
tls_query_padding_blocksize: 128
edns_client_subnet_private : 1
round_robin_upstreams: 1
idle_timeout: 9000
listen_addresses:
  - 127.0.0.1@5300
  - 0::1@5300
dnssec: GETDNS_EXTENSION_TRUE
upstream_recursive_servers:
  - address_data: 1.1.1.1
    tls_port: 853
    tls_auth_name: "cloudflare-dns.com"
  - address_data: 1.0.0.1
    tls_port: 853
    tls_auth_name: "cloudflare-dns.com"
  - address_data: 8.8.8.8
    tls_port: 853
    tls_auth_name: "dns.google"
  - address_data: 8.8.4.4
    tls_port: 853
    tls_auth_name: "dns.google"
  - address_data: 2606:4700:4700::1111
    tls_port: 853
    tls_auth_name: "cloudflare-dns.com"
  - address_data: 2606:4700:4700::1001
    tls_port: 853
    tls_auth_name: "cloudflare-dns.com"
  - address_data: 2001:4860:4860::8888
    tls_port: 853
    tls_auth_name: "dns.google"
  - address_data: 2001:4860:4860::8844
    tls_port: 853
    tls_auth_name: "dns.google"

Be sure to actually go through the file (which is extensively commented) though as YMMV and you might want to use different servers. For my location Google & Cloudflare are the fastest. You can find very handy list of DNS server supporting DoT (among other things) on these sites: Known DNS Providers at AdGuard Knolwedgebase and DNS Privacy Test Servers at DNS Privacy Project.

Few notes for the config lines: 9-11, specify address to listen on – in my case it’s localhost for IPv4 and IPv6 on port 5300; 12, enables DNSSEC verification; 13-37, specify upstream servers to forward requests to + line 7 that enables round-robin querying of these upstreams.

As it’s YAML, let’s check the configuration:

stubby -i
[...]
Result: Config file syntax is valid.

I suggest doing first run in foreground so that any errors can be picked up straight away:

# Stubby
stubby -C /usr/local/etc/stubby/stubby.yml
[14:32:40.983556] STUBBY: Stubby version: Stubby 0.3.0
[14:32:41.016976] STUBBY: Read config from file /usr/local/etc/stubby/stubby.yml
[14:32:41.018472] STUBBY: DNSSEC Validation is ON
[14:32:41.018493] STUBBY: Transport list is:
[14:32:41.018499] STUBBY:   - TLS
[14:32:41.018506] STUBBY: Privacy Usage Profile is Strict (Authentication required)
[14:32:41.018512] STUBBY: (NOTE a Strict Profile only applies when TLS is the ONLY transport!!)
[14:32:41.018517] STUBBY: Starting DAEMON....

# dnsmasq
sudo dnsmasq -d
dnsmasq: started, version 2.81 cachesize 150
dnsmasq: compile time options: IPv6 GNU-getopt no-DBus no-UBus no-i18n no-IDN DHCP DHCPv6 no-Lua TFTP no-conntrack no-ipset auth no-DNSSEC loop-detect no-inotify dumpfile
dnsmasq: setting --bind-interfaces option because of OS limitations
dnsmasq: using nameserver 127.0.0.1#5300
dnsmasq: using nameserver ::1#5300
dnsmasq: read /etc/hosts - 4 addresses

Let’s check whether both are listening on the ports they should:

# Stubby
lsof +c 15 -Pni :5300
COMMAND   PID   USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
stubby  36923 hadret    3u  IPv4 0xb0a17f6a69bb1605      0t0  UDP 127.0.0.1:5300
stubby  36923 hadret    5u  IPv4 0xb0a17f6a9078f9a5      0t0  TCP 127.0.0.1:5300 (LISTEN)
stubby  36923 hadret    6u  IPv6 0xb0a17f6a69bafbdd      0t0  UDP [::1]:5300
stubby  36923 hadret    7u  IPv6 0xb0a17f6a76543315      0t0  TCP [::1]:5300 (LISTEN)

# dnsmasq
sudo lsof +c 15 -Pni :53
COMMAND   PID   USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
dnsmasq 36980 nobody    4u  IPv4 0xb0a17f6a6fc7477d      0t0  UDP 127.0.0.1:53
dnsmasq 36980 nobody    5u  IPv4 0xb0a17f6a6e76cd65      0t0  TCP 127.0.0.1:53 (LISTEN)
dnsmasq 36980 nobody    6u  IPv6 0xb0a17f6a6fc75035      0t0  UDP [::1]:53
dnsmasq 36980 nobody    7u  IPv6 0xb0a17f6a76543935      0t0  TCP [::1]:53 (LISTEN)

Looking good, now it’s time for DNSSEC:

# Good signature, A record should return:
dig +dnssec sigok.verteiltesysteme.net @127.0.0.1

; <<>> DiG 9.10.6 <<>> +dnssec sigok.verteiltesysteme.net @127.0.0.1
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 23140
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags: do; udp: 1452
;; QUESTION SECTION:
;sigok.verteiltesysteme.net.	IN	A

;; ANSWER SECTION:
sigok.verteiltesysteme.net. 60	IN	A	134.91.78.139
sigok.verteiltesysteme.net. 60	IN	RRSIG	A 5 3 60 20200730020001 20200430020001 30665 verteiltesysteme.net. JSXmAxCGT8+Fh57r8oHy3ubeFCizJDZPZEFcy3oIj9klQdk1Br1GbTXD 3dnlFqgQdJrrkGB3phCLaZOHyy/qXAN5wt84T35DXoNXKO6e6b/wUxPw dWrcVTJ99t2X43Y43E7xo52rtDIT3csZ3DvprO99V+O0fuEq19y/poTR hwg=

;; Query time: 218 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Mon May 04 16:01:36 CEST 2020
;; MSG SIZE  rcvd: 303


# Bad signature, no A record should return:
dig +dnssec sigfail.verteiltesysteme.net @127.0.0.1

; <<>> DiG 9.10.6 <<>> +dnssec sigfail.verteiltesysteme.net @127.0.0.1
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 19928
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;sigfail.verteiltesysteme.net.	IN	A

;; Query time: 476 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Mon May 04 16:03:01 CEST 2020
;; MSG SIZE  rcvd: 46

Very good. If the foreground processes were running, they can now be stopped as we will switch them to background services. Yet again, Homebrew comes to the rescue:

brew services
Name           Status  User   Plist
dnsmasq        stopped
stubby         stopped

brew services start stubby
==> Successfully started `stubby` (label: homebrew.mxcl.stubby)

sudo brew services start dnsmasq
Warning: Taking root:admin ownership of some dnsmasq paths:
  /usr/local/Cellar/dnsmasq/2.81/sbin
  /usr/local/Cellar/dnsmasq/2.81/sbin/dnsmasq
  /usr/local/opt/dnsmasq
  /usr/local/opt/dnsmasq/sbin
  /usr/local/var/homebrew/linked/dnsmasq
This will require manual removal of these paths using `sudo rm` on
brew upgrade/reinstall/uninstall.
==> Successfully started `dnsmasq` (label: homebrew.mxcl.dnsmasq)

brew services
Name           Status  User   Plist
dnsmasq        started root   /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist
stubby         started hadret /Users/hadret/Library/LaunchAgents/homebrew.mxcl.stubby.plist

It might be a good idea to run one more time lsof to confirm that both services are up and running on the appropriate ports.

By default macOS is going to use DNS provided by the router (usually your ISPs one). I was setting this up on two Macbooks, so for me the network interface was every time “Wi-Fi”. You can list all available interfaces like so:

networksetup -listallhardwareports

Hardware Port: Wi-Fi
Device: en0
Ethernet Address: 00:00:00:00:00:00

Hardware Port: Bluetooth PAN
Device: en3
Ethernet Address: 00:00:00:00:00:00

Hardware Port: Thunderbolt 1
Device: en1
Ethernet Address: 00:00:00:00:00:00

Hardware Port: Thunderbolt 2
Device: en2
Ethernet Address: 00:00:00:00:00:00

Hardware Port: Thunderbolt Bridge
Device: bridge0
Ethernet Address: 00:00:00:00:00:00

VLAN Configurations
===================

To list currently used DNS servers:

networksetup -getdnsservers "Wi-Fi"
There aren't any DNS Servers set on Wi-Fi.

scutil --dns
DNS configuration

resolver #1
  nameserver[0] : 192.168.1.1
  if_index : 4 (en0)
  flags    : Request A records
  reach    : 0x00000000 (Reachable,Directly Reachable Address)

[...]

Now I’m going to switch it to use localhost (127.0.0.1) and default port 53:

networksetup -setdnsservers Wi-Fi 127.0.0.1

networksetup -getdnsservers Wi-Fi
127.0.0.1

scutil --dns | head
DNS configuration

resolver #1
  nameserver[0] : 127.0.0.1
  flags    : Request A records, Request AAAA records
  reach    : 0x00000000 (Reachable,Local Address,Directly Reachable Address)

👆🏻 first command set default DNS to 127.0.0.1, two other confirm the setting.

The name resolution now goes like this:

DNS query
   |
   v
dnsmasq (127.0.0.1@53)
   |
   v
Stubby (127.0.0.1@5300)
   |
   v
DoT endpoint (Google or Cloudflare on port 853)

Some of the domains are going to be blocked (assuming the ads.conf file was deployed) on the dnsmasq level.

One last piece of advice – should you ever need to rollback to the default DNS server for Wi-Fi (or any other interface your Mac is using), here’s how:

networksetup -setdnsservers Wi-Fi empty

networksetup -getdnsservers Wi-Fi
There aren't any DNS Servers set on Wi-Fi.

And that’s it when it comes to using DoT on macOS. Safe browsing! 👋🏻