DNS sinkhole with dnsmasq on local Linux machine

Recently I’ve posted a wrap-up on my new tiny DNS analyzer tool called dnseen. One of the development goals was generating a top-list of websites that I visit along with the frequencies, so that I can blacklist websites that I think I spend too much time on without long-term benefits. These are mostly news and social media that use manipulative algorithms to make you a “dophamine addict”. Another thing that I wanted to implement is blacklisting for ads/tracking services that works on the DNS level, so that I don’t need to worry about browser extensions in case I want to try a new browser.

I used to have a list of blacklisted domains in my /etc/hosts file for a while. One obvious drawback for me is the need to lump everything in one file. Instead, in a perfect solution, I’d like to split blacklisted domain into multiple files, so that I can easily break them down by topic and/or automate some hosts file generation from third-party curated lists, like hosts files from Steven Black.

One of the popular options is to set up a Pi-hole on the local network and use it as the local DNS server. I do like this option too, but before picking up ready to use products, I’d like to “reinvent the wheel” and build by own prototype first. This is how I came up with the idea of building my own DNS sinkhole with the help of dnsmasq as a DNS server on my local Linux machine.

Linux + NetworkManager + dnsmasq

After installing dnsmasq on the Fedora 39 Linux machine:

sudo dnf -y install dnsmasq

we need to make sure it’s configured as a NetworkManager plugin rather than systemd, so that dnsmasq daemon is run correctly and redefined the /etc/resolv.conf, the resolver configuration file used for DNS lookups.

First of all, we need to activate the NetworkManager plugin:

$ cat /etc/NetworkManager/conf.d/00-use-dnsmasq.conf
[main]
dns=dnsmasq

Now, we can configure the dnsmasq itself:

$ cat /etc/NetworkManager/dnsmasq.d/00-addn-hosts.conf
# NB! When making changes, don't forget restart dnsmasq & NetworkManager.service:
# $ killall dnsmasq
# $ systemctl restart NetworkManager

# No need to read /etc/resolv.conf as it contains the localhost
# addresses of dnsmasq itself
no-resolv

# Upstream DNS servers
# My router, Google, CloudFlare
server=192.168.0.1
server=8.8.8.8
server=1.1.1.1

# Add debug logging
# $ journalctl --follow -u NetworkManager
log-queries

# Ignore /etc/hosts
no-hosts

# Add custom hosts files;
# If path is a directory, all files from it are loaded
addn-hosts=/etc/hosts.d

So, basically we want to keep our multiple hosts files under addn-hosts=/etc/hosts.d directory and make sure that if a hostname cannot be resolved using these files we fallback to the upstream DNS servers that are the one my local network’s router provides, as well as public ones from Google and CloudFlare.

Now, given that my Fedora machine also runs systemd-resolved, a system service that provides network name resolution to local applications, we want to prevent both dnsmasq and systemd-resolved to mess up with the /etc/resolv.conf file. So we stop and disable systemd-resolved:

$ sudo systemctl stop systemd-resolved.service
$ sudo systemctl disable systemd-resolved.service

We can add a hosts file for testing purposes:

$ cat /etc/hosts.d/news
0.0.0.0 example.com
0.0.0.0 news.ycombinator.com

Next, let’s validate dnqmasq config and restart the relevant services:

$ dnsmasq --test
$ sudo killall dnsmasq
$ sudo systemctl restart NetworkManager

Check if local DNS works as expected

First of all, let’s look at the dnsmasq logs:

$ journalctl --follow -u NetworkManager
...
Jan 01 21:03:16 fedora dnsmasq[16321]: using nameserver 192.168.0.1#53
Jan 01 21:03:16 fedora dnsmasq[16321]: using nameserver 8.8.8.8#53
Jan 01 21:03:16 fedora dnsmasq[16321]: using nameserver 1.1.1.1#53
Jan 01 21:03:16 fedora dnsmasq[16321]: read /etc/hosts.d/news - 2 names

Let’s also make sure /etc/resolv.conf is controlled by NetworkManager:

$ cat /etc/resolv.conf
# Generated by NetworkManager
nameserver 127.0.0.1
options edns0 trust-ad

Now, let’s see if DNS lookups work properly:

$ dig news.ycombinator.com

; <<>> DiG 9.18.20 <<>> news.ycombinator.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 15461
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;news.ycombinator.com.                IN      A

;; ANSWER SECTION:
news.ycombinator.com. 0       IN      A       0.0.0.0

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1) (UDP)
;; WHEN: Mon Jan 01 21:18:57 CET 2024
;; MSG SIZE  rcvd: 65

So, yes, SERVER: 127.0.0.1#53(127.0.0.1) (UDP) is telling us a localhost DNS is being used, and A 0.0.0.0 from the ANSWER SECTION shows us it’s resolved to the 0.0.0.0 IP address from our hosts file.

One the same time, if you try to access the same domain in a web browser or using curl, you will see it’s still accessible. The thing is dig tool is using /etc/resolv.conf settings for DNS, while other applications rely on the DNS settings from the network connection. Let’s see what are these DNS setings using NetworkManager CLI tool:

$ nmcli device show | grep -i dns
IP4.DNS[1]:                             192.168.0.1
IP6.DNS[1]:                             2a02:8383:d:c::1000
IP6.DNS[2]:                             2a02:8383:d:c::1

These are actually DNS addresses from my WiFi router. Given that my machine receives IP-address from the router’s DHCP server, it also gets the DNS settings.

Configuring connection’s settings

We can override the settings in NetworkManager GUI. Open up nm-connection-editor (or Settings -> WiFi -> Network you’re connected to Settings), then IPv4:

  • Method -> Automatic (DHCP) addresses only
  • DNS Server -> 127.0.0.1 (instead of automatic one)

then for IPv6:

  • Method -> Automatic, DHCP only (watch out, it’s different than for IPv4)

This corresponds to the changes in /etc/NetworkManager/system-connections/YOUR-CONNECTION-NAME.nmconnection:

[ipv4]
dns=127.0.0.1;
ignore-auto-dns=true
method=auto

[ipv6]
addr-gen-mode=stable-privacy
dns=::1;
ignore-auto-dns=true
method=dhcp

Now restart the NetworkManager and check DNS settings:

$ sudo systemctl restart NetworkManager
$ nmcli device show | grep -i dns
IP4.DNS[1]:                             127.0.0.1
IP6.DNS[1]:                             ::1

All good! Local DNS sinkhole works. Now hosts files under /etc/hosts.d can be orginized easily and will be used for domains resolution in all the requests coming from the configured connection.

P.S. I’m deeply grateful to the Stackexchange community for the help with the dnsmasq config troubleshooting.