DNSdist and upstream DoH

2024-01-01

Dnsdist is a versatile, high-performance load balancer tailored for DNS traffic, offering cool features like DoS mitigation, caching, and query routing to improve reliability and efficiency in DNS infrastructures. The config style is lua which makes it very powerful and fun to tinker with. It does not do the tool justice to just call it a load balancer, in my opinion it is rather a programmable DNS runtime with too many features to list here.

Motivation

For this blog post we will use it to improve our online privacy by not leaking all our DNS queries over plain text over the public net. In the past I had the privilege to implement this tool for multiple MSP's, registrars and a variety of hosting companies on a larger scale. Today we'll use it for something rather small for the sake of privacy and fun. We will route our DNS queries via DNSDist to upstream DoH (DNS-over-HTTPS) servers which will shield the content of the websites we visit from preying eyes of Governments and Big tech.

For an in-depth motivation, have a look here Internetsociety article.

Scenario

The client is our local machine which will rely on Dnsdist to speak the DoH protocol upstream (external DNS resolvers). This prevents us from having to configure serveral apps like web browsers to be configured individually since all local traffic to Dnsdist is conventional DNS.

Scenario

Rules handler

The rules handler will be utilized to maintain a list of domains we want to block. This will be a text file which could easily contain a list of well-known ad servers. This offers us great flexibility to quickly block domains for all apps at once.

Build stage

Since we love containers everything will be based on OCI container images, docker is most well-known but any container runtime like podman or containerd will work.

Prerequisites

  • Container runtime (like docker)
  • Text editor of choice
  • Motivation to get your hands dirty

Config

File: servers.lua

return {
    {
        address = "76.76.2.11:443",
        tls = "openssl",
        subjectName = "freedns.controld.com",
        dohPath = "/p2",
        validateCertificates = true,
        pool = "doh"
    },
    {
        address = "46.105.222.254:443",
        tls = "openssl",
        subjectName = "idoh.cleanbrowsing.org",
        dohPath = "/doh/security-filter",
        validateCertificates = true,
        pool = "doh"
    },
    {
        address = "9.9.9.9:443",
        tls = "openssl",
        subjectName = "dns.quad9.net",
        dohPath = "/dns-query",
        validateCertificates = true,
        pool = "doh"
    }
}

Let's also create a blocklist with domains we deem undesireable File: dnsdist.blocklist.txt

facebook.com
instagram.com

File: dnsdist.conf

local env_webpass = os.getenv("WEB_PASS")
webpass = env_webpass or "supersecretpassword"

webserver("0.0.0.0:8000")
setWebserverConfig({password=webpass, apiKey=webpass, acl="0.0.0.0/0"})

-- Load the configuration file
local servers = require("servers")

-- Bind to a local address
setLocal('0.0.0.0:5300')

-- Add servers dynamically from the configuration table
for _, server in ipairs(servers) do
    newServer(server)
end

-- blackhole list from external files
blackhole_list = newSuffixMatchNode()
for l in io.lines("/app/dnsdist.blocklist.txt") do
    blackhole_list:add(newDNSName(l))
end

-- match blackhole domains and rewrite response to localhost
addAction(SuffixMatchNodeRule(blackhole_list, true), SpoofAction({"127.0.0.1", "[::1]"}, {ttl=3600}), {name="rule_blackholes"})

-- match blackhole domains and spoof response to localhost
addAction(SuffixMatchNodeRule(blackhole_list, true), SpoofAction({"127.0.0.1", "[::1]"}, {ttl=3600}), {name="rule_blackholes"})

-- allow everything else to pass
addAction({"0.0.0.0/0"}, PoolAction("doh"))

Docker

File: Dockerfile

FROM alpine:latest

WORKDIR /app
COPY servers.lua /app/
COPY dnsdist.conf /app/
COPY dnsdist.blocklist.txt /app/

RUN apk add dnsdist

EXPOSE 5300
EXPOSE 8000

CMD ["dnsdist", "--supervised", "--conf", "dnsdist.conf"]

Build your docker container image

docker build . -t dns_privacy_please:0

Run your own resolver

Run the image

docker run -p 69:5300/udp -p 8000:8000 -e WEB_PASS="my_well_protected_password" --name dnsdist docker build . -t dns_privacy_please:0

Now it is possible to run dns queries, on a regular linux/UNIX you could run dig to test the functionality

naroj@frisbee:~/playground/dnsdist$ dig +noall +answer example.nl @localhost -p 69
example.nl.     3600    IN  A   94.198.159.35

So far so good, let's test a domain from the blocklist

naroj@frisbee:~/playground/dnsdist$ dig +noall +answer facebook.com @localhost -p 69
facebook.com.       3600    IN  A   127.0.0.1

This returns 127.0.0.1 so we know for sure we will not be distracted by Zuck ;-)

Verification and insights

We can even pull up the local web interface to peak into the metrics and understand what is going on with out DNS traffic;

login: admin
password: "my_well_protected_password"
Scenario

We can not assume all works well without sniffing the wire, let's have a look with tcpdump to verify traffic to the nameservers is indeed not plain text dns;

naroj@frisbee:~/playground/dnsdist$ sudo tcpdump -i wlp0s20f3 src host 192.168.8.150 and \( dst host 76.76.2.11 or dst host 46.105.222.254 or dst host 9.9.9.9 \) and port 443 -A
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wlp0s20f3, link-type EN10MB (Ethernet), snapshot length 262144 bytes
22:04:04.139815 IP frisbee.lan.43656 > dns9.quad9.net.https: Flags [.], ack 2896489167, win 502, options [nop,nop,TS val 2578369760 ecr 1915428050], length 0
E..4.X@.?.......                ......q^.........v.....
....r+..
22:04:04.139882 IP frisbee.lan.43656 > dns9.quad9.net.https: Flags [P.], seq 0:621, ack 1, win 502, options [nop,nop,TS val 2578369760 ecr 1915428050], length 621
E....Y@.?.......                ......q^...............
....r+......h...d....C.o..6..K......Q..e... f.g.Px. .O...i.....b$4..........L.. ..N..>.......,.0.........+./...$.(.k.#.'.g.
...9.   ...3.....=.<.5./...............dns.quad9.net.........
.........................#..3t.........h2...........0...................    .
...........................+........-.....3.&.$... ...... .\..U.4...0....R.A'.......)......P.U.......B...{.......c9.3.......*w..U.......J.....}z.HH.hN.....#....|).l..:..Q.A.....O...M..K....N.Ov..U.......I.m...l.X.a..tI.......U.... .s.N..8.........DD.AH:{;.k.X4..?%.T.../G..z..........sI..'XM.6.-
....&......q.........K..10Wq..I...k..J.~.Y......t+. ...V^.w@U.^....v...q..
22:04:04.141127 IP frisbee.lan.46916 > dns-edge-europe-france-rbx.cleanbrowsing.org.https: Flags [.], ack 1214638945, win 502, options [nop,nop,TS val 3108224180 ecr 2952411526], length 0
E..4.
@.?........i...D.....CHe.a...........
.C....9.

We can safely conclude our privacy increased by a lot. Keep in mind it is important to rotate over a wider range of servers to prevent one provider from profiling you. Using the randomization feature in dnsdist this is a peace of 🎂

Is there only good?

There are down sides to this solution as well. Internet service providers and your local network administrators might by fighting evil. They have a few ways to find out if a client is infected by anything malicious. On of the way to find out is by looking at DNS queries, which they can not see anymore since we care about privacy. It's always a trade-off. But on the bright side you could find a DoH resolver which also detects malicious queries. Realize DoH only protects you from having someone on your network from seeing your mostly uninteresting DNS queries. The only place it has much value is on public networks and if you don't trust your ISP or Government. Depending on where you live this might be a reasonable concern.

What is next?

From this point we can configure our local resolver to use 127.0.0.1:69 as our namesever. The configuration varies per operating system and even per Linux distribution. Google is one of the better places to look further if it's unclear how to take it on from here.