Does the idea of a network-wide ad blocker sound interesting? Luckily, not only is this easy to implement, it can provide more privacy and faster load times when browsing the web! Thanks to a handy service called DNS, which is easy to self-host, you can fine tune what domains the devices on your network can access.

This is the second post about my NixOS router project, written as a follow up to this post. This guide will not implement any IPv6 functionality, and is intended for a NixOS system.

What is DNS?

The Domain Name System (DNS) is one of many fundamental building blocks of the Internet. It’s the Internet’s lookup table, allowing for the translation of a domain name like example.com to an IP address. When a device wants to access a website like example.com it queries a recursive DNS server (or resolver), which then responds back to the device with the IP of the domain, which is used to connect to the website.

I recommend watching this video by Fireship as a more complete introduction to DNS.

On a standard home network a device generally will have its DNS configuration pointing to an external recursive resolver, such as one provided by the ISP. This means that every time a device loads a web page, it has to reach outside of its local network, query that DNS server, then wait for the a response containing the website’s IP. This introduces latency, and while it may be measured in dozens of milliseconds for a single DNS request, a web page may have to load data from multiple other servers with different domain names, adding even more latency. One way to reduce this is to host a DNS server on your local network.

Local DNS & caching

Instead of accessing a DNS server over the Internet, resolving a domain using a local server can drastically reduce latency. There is a caveat: the local server still needs to reach out to the Internet to know the IP of a website. So how does having a middleman when querying DNS reduce latency? The answer: caching.

By caching the queries performed on a network, a local DNS server can remember the IPs of all visited websites and store a local lookup table. This means that the initial request for a website like example.com may take a moment, but future requests to example.com can be resolved nearly instantly.

DNS filtering

Setting up a local DNS server is the first step to implementing ad blocking functionality. Because websites often rely on third parties to provide ads on their page, those websites will attempt to resolve domains belonging to the advertisers to load the ads. If websites can connect to servers that provide ads, then they can be load the ads, but if they can’t be reached then there won’t be any ads to show.

A local DNS server can be set up to recognize the domains that serve ads. So when queried for those domains it can provide an invalid IP address, such as 0.0.0.0, making that domain unreachable. This enables the implementation of a network-wide ad blocker, which can speed up load times as advertiser domains will not have to be resolved or content from advertisers downloaded.

Conveniently, there are advertising block lists for DNS available online, some capable of more than just blocking advertising. Some lists are designed to block sites with malware, adult content, gambling, or telemetry, to name a few. This provides the ability to fine tune the filtering provided to devices on the local network and a higher level of security.

Recursive resolver

A local DNS server capable of filtering provides the most tangible benefits, although this can be taken farther by implementing a local recursive DNS server, rather than using an external resolver. Instead of relying on a third party to resolve your DNS queries, which could give them the ability to log all your requests or even censor some websites, your local server can perform the lookup itself.

Unfortunately, this comes with a compromise: the initial query for an uncached domain will take longer than it would through an external resolver. This is because recursive resolving has to query along a chain to get the IP. A query for example.com starts at the root server, that once queried points to the resolver for the top level domain .com, which then points to the authoritative DNS server for example.com. This means that for a single query multiple requests have to be performed to resolve the domain.

Recursive DNS Diagram

Thankfully, recursive DNS can also be sped up using caching, minimizing the added latency. Just as frequently used domains are cached, when a top level or authoritative DNS server is used, their IPs can be cached as well. When a new domain is requested for a previously accessed top level domain, the recursive resolver can start from there instead of querying the root servers every time.

The Ad Blocking

Blocky is a DNS server that provides caching and filtering designed to run locally. It is stateless and relies on a single configuration file, making it a great fit for NixOS. It can download and update block lists on its own, allowing for the use of online lists that are crowdsourced and regularly updated.

Below is an example configuration for Blocky that is capable of caching and filtering. For more details on how to configure Blocky I recommend going through its documentation, some translation from YAML to Nix will be needed if using the documentation.

{
  # Ad blocking and caching DNS server
  services.blocky = {
    enable = true;
    settings = {
      ports.dns = "53";
      connectIPVersion = "v4"; # Limiting current implementation to IPv4

      upstreams.groups.default = [ "1.1.1.1" "1.0.0.1" ]; # Cloudflare's DNS

      blocking = {
        denylists = {
          # Block lists taken from https://github.com/hagezi/dns-blocklists
          "pro" = [ "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/wildcard/pro.txt" ];
          "tif" = [ "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/wildcard/tif.txt" ];
        };
        clientGroupsBlock.default = [ "pro" "tif" ];
      };

      caching = {
        prefetching = true;
        minTime = "1m";
      };
    };
  };
}

The first step is to enable Blocky as a service, just add services.blocky.enable = true; in the Nix configuration. Then, the bulk of the configuration is set in the services.blocky.settings parameter.

For networking, the port used to query Blocky can be set with the ports.dns parameter. DNS is assigned to port 53 by default, but a different port or specific interface can be configured (e.g. 53 for just the port or 127.0.0.1:53 to specify interface). The specific IP protocol can also be defined using connectIPVersion, which can be set to v4 for IPv4 only, and dual for IPv4 and IPv6.

Then, a set of upstream servers, which are the DNS resolvers Blocky will query, can be configured. These can be separated into client groups; to keep it simple only the default group will be used. Using the upstreams.groups.default parameter, a list of DNS resolvers can be set.

Next, the blocking parameters are defined in blocking. Here you can add list groups, that consist of individual block lists, which are defined under denylists. These individual lists can be a file on the system, or downloaded from the Internet, which are by default refreshed every 6 hours. These block list groups are then applied to client groups. Here, we define the block lists used by clientGroupsBlock.default.

I recommend using online block lists that are regularly updated. In this example, I used Hagezi’s DNS Blocklists; they are effective and actively maintained. Two block lists are used in this example: pro, a block list for ads, and tif, a block list for network security.

Finally, caching has to be configured, which is defined under the caching parameter. Generally, the default options work great, although I recommend enabling prefetching. It allows for the most often accessed domains to be automatically refreshed and cached when they expire. With prefetching enabled, it’s recommended to set a minimum amount of time entries remain in the cache, which can be set using minTime. This ensures a prefetched domain with a short lifetime doesn’t constantly get refreshed.

Recursive DNS

Unbound is a recursive DNS resolver that has caching and validation capabilities. It is capable of performing recursive DNS queries, and validate the results using DNSSEC, which verifies the authenticity of the results. This prevents a bad actor from modifying the results of the requested domains, ensuring it points to the correct IP.

Using Unbound is optional, Blocky should work as is. Unbound is only used to provide recursive resolving capabilities, which may lead to additional latency, but improve privacy.

Below is an example of an Unbound implementation in Nix. It should be able to perform and validate recursive DNS requests, and has some minor cache optimizations to speed up requests. For additional configuration there is extensive documentation available.

{
  services.unbound = {
    enable = true;
    settings = {
      server = {
        interface = [ "127.0.0.8" ];
        do-ip4 = true;
        do-ip6 = false; # Limiting current implementation to IPv4

        # Minimal caching needed on Unbound as this is handled by Blocky
        # 60 second cache and prefetch implemented to speed up recursive queries
        prefetch = true;
        cache-max-ttl = 60;
        cache-max-negative-ttl = 60;
        serve-original-ttl = true;
      };
    };
  };
}

As usual, the Unbound service needs to be enabled: services.unbound.enable = true;, and the settings are configured under services.unbound.settings. Network configuration can be added under the interface parameter, specifying what interface to listen on. Then the IP protocols used can be set with do-ip4 and do-ip6.

Caching is highly recommended, the recursive resolver has to regularly query the root server, so adding a short term cache can help Unbound accelerate the resolving process. Prefetching is enabled so often requested TLDs are preloaded, which is enabled using prefetch. The cache here is implemented for a maximum lifespan of a minute with cache-max-ttl and cache-max-negative-ttl, which are defined in seconds. Then to make sure Blocky itself gets the correct lifespan of the requested domain we set serve-original-ttl = true;.

Combining the two

With Unbound added to the configuration, Blocky needs to be tweaked to be used as a resolver. This also allows all DNS requests on a network to go through the configured services. It also ensures that on initialization Blocky is able to use Unbound to download the block lists.

The only flaw I have identified is on initialization, just before the block lists are downloaded: devices on the network may be able to access unfiltered DNS through Blocky, allowing them to access undesired domains. This is because Blocky needs to be able to resolve the domains for the block lists, which reequires enabling DNS functionality before they are downloaded and the filter is active.

{
  # To add to Blocky configuration
  services.blocky = {
    settings = {
      upstreams.groups = {
        default = [ "127.0.0.8" ]; # Unbound
      };

      blocking = {
        # Due to Blocky using itself as resolver it needs to startup without loading block lists
        # Added a delay for block list downloads so that Unbound has time to start
        loading = {
          downloads = {
            attempts = 8;
            cooldown = "2s";
          };
          strategy = "fast";
          concurrency = 1;
        };
      };
    };
  };
}

First, the upstream DNS servers configured in Blocky needs to be changed to point to Unbound. In the provided Unbound configuration it is set to listen to 172.0.0.8, so we can set the default upstream group to use that IP as nameserver.

Next, some adjustments are done to Blocky’s blocking parameter. A loading section is added that allow the block lists to be downloaded through Unbound. First, the number of attempts allowed and time to cooldown are increased when downloading a list. This gives more time for Unbound to start after a boot, and resolve the requested block list domains. Then, the strategy parameter is adjusted to fast so that Blocky can start resolving domains before the block lists are downloaded. Finally, the concurrency parameter is reduced to 1, which (from personal observation) reduces issues that come up when downloading large block lists.

With the DNS server now capable of using Blocky and Unbound, it is time to start telling devices on the network to use it. The server hosting DNS can have its networking.nameservers set to its local IP: 127.0.0.1. If hosting a DHCP server, make sure to point to the local DNS server. In KEA this can be set using the domain-name-servers option.

For external devices to access the DNS, the server’s firewall also needs to be opened, specifically for port 53. This can be opened up by adding networking.firewall.allowedUDPPorts = [ 53 ]; to your NixOS configuration. For nftables, this can be done by adding iifname "LAN" tcp dport 53 counter accept to your configuration, make sure to use the correct interface name for your system.

End Note

Implementing a local DNS server in one’s network is one of the simplest starter projects for a homelab. I originally got started by setting up a Pi-hole server on a Raspberry Pi, which evolved into much more advanced projects. This DNS set up can easily be implemented into my NixOS router, allowing for the consolidation of one more service to a single system. The next service I aim to implement is WireGuard, which is explained here (to be linked when published)