Tag: dns

  • Configuring DNS and DHCP For A LAN In 2025

    Configuring DNS and DHCP For A LAN In 2025

    14 years ago I described how to configure ISC BIND and DHCP(v6) Server on FreeBSD to get DHCP with local domain updates working on a dual stack LAN. However, ISC DHCP Server went End of Life on October 5th, 2022, replaced with their new Kea DHCP server. I also wouldn’t recommend running a non-filtering DNS resolver for your LAN any longer.

    AdGuard Home

    If you’re reading this blog, you’ve almost certainly heard of Pi-hole. However, I’ve found that I prefer AdGuard Home. AdGuard offers paid DNS filtering apps (I happily pay for AdGuard Pro for my iPhone), however their Home product is open source (GPL3) and free. I wont repeat the official Getting started docs, except to point out that AdGuard Home is available in FreeBSD ports so go ahead and install it with pkg install adguardhome.

    There are some configuration changes we’re going to make that cannot be done in the web UI and have to be done directly in the AdGuardHome.yaml config file. I wonder cover everything in the file, just the interesting bits.

    First, we’re going to be specific about which IPs to bind to, so we don’t accidentally create a public resolver, and also because there are LAN IPs on the router we don’t want to bind to (more on this in just a moment).

    http:
      pprof:
        port: 6060
        enabled: false
      address: 172.16.2.1:3000
      session_ttl: 720h
    ...
    dns:
      bind_hosts:
        - 127.0.0.1
        - ::1
        - 172.16.2.1
        - 2001:db8:ffff:aa02::1

    Your choice of upstream resolver is of course personal preference, but I wanted a non-filtering upstream since I want to the control and visibility into why requests are passing/failing. I’m also Canadian, so I prefer (but don’t require) that my queries stay domestic. I’m also sending requests for my LAN domains to the authoritative DNS server, which you can see is configured on local host IP 127.0.0.53 and on the similarly numbered alias IPs on my LAN interface (hence why I had to be specific about which IPs I wanted AdGuard to bind to).

      upstream_dns:
        - '# public resolvers'
        - https://private.canadianshield.cira.ca/dns-query
        - https://unfiltered.adguard-dns.com/dns-query
        - '# local network'
        - '[/lan.example.com/]127.0.0.53 172.16.2.53 2001:db8:ffff:aa02::53'
    ...
      trusted_proxies:
        - 127.0.0.0/8
        - ::1/128
        - 172.16.2.1/32
    ...
      local_ptr_upstreams:
        - 172.16.2.53
        - 2001:db8:ffff:aa02::53

    Lastly, we’re going to configure the webserver for HTTPS and DNS-over-HTTPS (DoH). I use dehydrated to manage my Let’s Encrypt certs, but any tool will do (and is outside the scope of this doc). The important thing to note is that the web UI will now run on port 8453, and will answer DoH queries.

    tls:
      enabled: true
      server_name: router.lan.example.com
      force_https: true
      port_https: 8453
      port_dns_over_tls: 853
      port_dns_over_quic: 853
      port_dnscrypt: 0
      dnscrypt_config_file: ""
      allow_unencrypted_doh: false
      certificate_chain: ""
      private_key: ""
      certificate_path: /usr/local/etc/dehydrated/certs/router.lan.example.com/fullchain.pem
      private_key_path: /usr/local/etc/dehydrated/certs/router.lan.example.com/privkey.pem
      strict_sni_check: false

    The rest of the configuration should be done to taste in the web UI. Personally, I find this filter list is effective while still having a very low false positive rate:

    • AdGuard DNS filter
    • AdAway Default Blocklist
    • AdGuard DNS popup Hosts filter
    • HaGeZi’s Threat Intelligence Feeds
    • HaGeZi’s Pro++ Blocklist
    • OISD Blocklist Big

    More than that and it just becomes unwieldy.

    BIND

    Good old BIND. It’ll outlive us all. This part is basically unchanged I first described it in 2011, except that I’m going to have BIND listen on 127.0.0.53 and alias IPs I created on my LAN networks (also using the .53 address) by setting this in my /etc/rc.conf:

    ifconfig_igb1="inet 172.16.2.1 netmask 255.255.255.0"
    ifconfig_igb1_ipv6="inet6 2001:db8:ffff:aa02::1 prefixlen 64"
    ifconfig_igb1_aliases="\
      inet 172.16.2.53 netmask 255.255.255.0 \
      inet6 2001:db8:ffff:aa02::53 prefixlen 64"

    Next, create an rndc key with rndc-confgen -a -c /usr/local/etc/namedb/rndc.example.com and configure BIND with the following in /usr/local/etc/named/named.conf (don’t remove the logging or zones at the bottom of the default named.conf).

    "acl_self" {
      127.0.0.1;
      127.0.0.53;
      172.16.2.1;
      172.16.2.53;
      ::1;
      2001:db8:ffff:aa02::1;
      2001:db8:ffff:aa02::53;
    };
    
    acl "acl_lan" {
      10.42.0.0/16;
      10.43.0.0/16;
      172.16.2.0/24;
      2001:db8:ffff:aa02::/64;
      fe80::/10;
    };
    
    options {
      directory             "/usr/local/etc/namedb/working";
      pid-file              "/var/run/named/pid";
      dump-file             "/var/dump/named_dump.db";
      statistics-file       "/var/stats/named.stats";
      allow-transfer        { acl_lan; };
      allow-notify          { "none"; };
      allow-recursion       { "none"; };
      dnssec-validation     auto;
      auth-nxdomain         no;
      recursion             no;
      listen-on             { 127.0.0.53; 172.16.2.53; };
      listen-on-v6          { 2001:db8:ffff:aa02::53; };
      disable-empty-zone "255.255.255.255.IN-ADDR.ARPA";
      disable-empty-zone "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.IP6.ARPA";
      disable-empty-zone "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.IP6.ARPA";
      version "BIND";
    };
    
    include "/usr/local/etc/namedb/rndc.example.com";
    
    controls {
      inet 127.0.0.53 allow { "acl_self"; "acl_lan"; } keys { "rndc.example.com";};
      inet 172.16.2.53 allow { "acl_self"; "acl_lan"; } keys { "rndc.example.com";};
      inet 2001:db8:ffff:aa02::53 allow { "acl_self"; "acl_lan"; } keys { "rndc.example.com";};
    };
    
    include "/usr/local/etc/namedb/named.zones.local";

    The local zones are configured in /usr/local/etc/namedb/named.zones.local:

    acl zone "lan.example.com" {
      type master;
      file "../dynamic/lan.example.com";
      update-policy { grant rndc.example.com zonesub ANY; };
    };
    
    zone "2.16.172.in-addr.arpa" {
      type master;
      file "../dynamic/2.16.172.in-addr.arpa";
      update-policy { grant rndc.example.com zonesub ANY; };
    };
    
    zone "2.0.a.a.f.f.f.f.8.B.D.0.1.0.0.2.ip6.arpa" {
      type master;
      file "../dynamic/2001.0db8.ffff.aa02.ip6.arpa";
      update-policy { grant rndc.example.com zonesub ANY; };
    };

    Here’s a starter zone for lan.example.com:

    $ORIGIN .
    $TTL 1200       ; 20 minutes
    lan.example.com      IN SOA  ns0.lan.example.com. admin.example.com. (
                                    2020138511 ; serial
                                    1200       ; refresh (20 minutes)
                                    1200       ; retry (20 minutes)
                                    2419200    ; expire (4 weeks)
                                    3600       ; minimum (1 hour)
                                    )
                            NS      ns0.lan.example.com.
                            A       172.16.2.53
                            AAAA    2001:db8:ffff:aa02::53
    $ORIGIN lan.example.com.
    router                  A       172.16.2.1
                            AAAA    2001:db8:ffff:aa02::1

    An IPv4 reverse zone:

    $ORIGIN .
    $TTL 1200       ; 20 minutes
    2.16.172.in-addr.arpa IN SOA ns0.lan.example.com. admin.example.com. (
                                    2020051192 ; serial
                                    1200       ; refresh (20 minutes)
                                    1200       ; retry (20 minutes)
                                    2419200    ; expire (4 weeks)
                                    3600       ; minimum (1 hour)
                                    )
                            NS      ns0.lan.example.com.
    $ORIGIN 2.16.172.in-addr.arpa.
    1                       PTR     router.lan.example.com.

    And an IPv6 reverse zone:

    $ORIGIN .
    $TTL 1200       ; 20 minutes
    2.0.a.a.f.f.f.f.8.B.D.0.1.0.0.2.ip6.arpa IN SOA ns0.lan.example.com. mikemacleod.gmail.com. (
                                    2020049273 ; serial
                                    1200       ; refresh (20 minutes)
                                    1200       ; retry (20 minutes)
                                    2419200    ; expire (4 weeks)
                                    3600       ; minimum (1 hour)
                                    )
                            NS      ns0.lan.example.com.
    $ORIGIN 0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.a.a.f.f.f.f.8.B.D.0.1.0.0.2.ip6.arpa.
    1.0                     PTR     router.lan.example.com.

    Kea DHCP Server

    The final piece to this is the Kea DHCP server. It’s still from ISC, but this is a from-scratch implementation of DHCP and DHCPv6 that to use modern designs and tools. We won’t be using many of the new bells and whistles, but there’s a couple things we can do now that we couldn’t with ISC DHCP.

    The first thing you’ll notice is that the Kea config files are JSON, and there are four of them. First up is kea-dhcp4.conf, where we configure our IPv4 DHCP options and pool, and also the options necessary to enable dynamic updating of our LAN domain via RFC2136 DDNS updates. Note that because I had an existing zone that had been updated by ISC DHCP and other stuff, I set "ddns-conflict-resolution-mode": "no-check-with-dhcid". You can find more info here.

    {
      "Dhcp4": {
        "ddns-send-updates": true,
        "ddns-conflict-resolution-mode": "no-check-with-dhcid",
        "hostname-char-set": "[^A-Za-z0-9.-]",
        "hostname-char-replacement": "x",
        "interfaces-config": {
          "interfaces": [
            "igb1/172.16.2.1"
          ]
        },
        "dhcp-ddns": {
          "enable-updates": true
        },
        "subnet4": [
          {
            "id": 1,
            "subnet": "172.16.2.0/24",
            "authoritative": true,
            "interface": "igb1",
            "ddns-qualifying-suffix": "lan.example.com",
            "pools": [
              {
                "pool": "172.16.2.129 - 172.16.2.254"
              }
            ],
            "option-data": [
              {
                "name": "routers",
                "data": "172.16.2.1"
              },
              {
                "name": "domain-name-servers",
                "data": "172.16.2.1"
              },
              {
                "name": "domain-name",
                "data": "lan.example.com"
              },
              {
                "name": "ntp-servers",
                "data": "172.16.2.1"
              }
            ],
            "reservations": [
              {
    
                "hw-address": "aa:bb:cc:dd:ee:ff",
                "ip-address": "172.16.2.2",
                "hostname": "foobar"
              }
            ]
          }
        ],
        "loggers": [
          {
            "name": "kea-dhcp4",
            "output-options": [
              {
                "output": "syslog"
              }
            ],
            "severity": "INFO",
            "debuglevel": 0
          }
        ]
      }
    }

    The kea-dhcp6.conf file is basically identical, except IPv6 flavoured. One nice thing about Kea is you can set a DHCPv6 reservation by MAC address, which is something you could not do with ISC DHCPv6 Server.

    {
      "Dhcp6": {
        "ddns-send-updates": true,
        "ddns-conflict-resolution-mode": "no-check-with-dhcid",
        "hostname-char-set": "[^A-Za-z0-9.-]",
        "hostname-char-replacement": "x",
        "dhcp-ddns": {
          "enable-updates": true
        },
        "interfaces-config": {
          "interfaces": [
            "igb1"
          ]
        },
        "subnet6": [
          {
            "id": 1,
            "subnet": "2001:db8:ffff:aa02::/64",
            "interface": "igb1",
            "rapid-commit": true,
            "ddns-qualifying-suffix": "lan.example.com",
            "pools": [
              {
                "pool": "2001:db8:ffff:aa02:ffff::/80"
              }
            ],
            "option-data": [
              {
                "name": "dns-servers",
                "data": "2001:db8:ffff:aa02::1"
              }
            ],
            "reservations": [
              {
                "hw-address": "aa:bb:cc:dd:ee:ff",
                "ip-addresses": [
                  "2001:db8:ffff:aa02::2"
                ],
                "hostname": "foobar"
              }
              }
            ]
          }
        ],
        "loggers": [
          {
            "name": "kea-dhcp6",
            "output-options": [
              {
                "output": "syslog"
              }
            ],
            "severity": "INFO",
            "debuglevel": 0
          }
        ]
      }
    }

    Lastly, we have kea-dhcp-ddns.conf, which configures how the zones will actuall be updated. Note that I’m connecting to BIND on 127.0.0.53.

    {
      "DhcpDdns": {
        "tsig-keys": [
          {
            "name": "rndc.example.com",
            "algorithm": "hmac-sha256",
            "secret": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
          }
        ],
        "forward-ddns": {
          "ddns-domains": [
            {
              "name": "lan.example.com.",
              "key-name": "rndc.example.com",
              "dns-servers": [
                {
                  "ip-address": "127.0.0.53",
                  "port": 53
                }
              ]
            }
          ]
        },
        "reverse-ddns": {
          "ddns-domains": [
            {
              "name": "2.16.172.in-addr.arpa.",
              "key-name": "rndc.example.com",
              "dns-servers": [
                {
                  "ip-address": "127.0.0.53",
                  "port": 53
                }
              ]
            },
            {
              "name": "2.0.a.a.f.f.f.f.8.B.D.0.1.0.0.2.ip6.arpa.",
              "key-name": "rndc.example.com",
              "dns-servers": [
                {
                  "ip-address": "127.0.0.53",
                  "port": 53
                }
              ]
            }
          ]
        },
        "loggers": [
          {
            "name": "kea-dhcp-ddns",
            "output-options": [
              {
                "output": "syslog"
              }
            ],
            "severity": "INFO",
            "debuglevel": 0
          }
        ]
      }
    }

    Extra Credit: Mobile DNS over HTTPS (DoH)

    I mentioned earlier that I pay for AdGuard Pro on my phone. Part of why I do that is it uses the MDM API in iOS to let you force your DNS to a DoH provider, including a custom one. Perhaps one you’re hosting yourself.

    I’m already running an nginx reverse proxy on my router, so let’s get mobile DoH setup. This is a simplified configuration and you’ll need to ensure you’ve got HTTPS properly configured, which is (again) outside the scope of this post.

    Note that I proxy the request to router.lan.example.com which will resolve to the LAN IP 172.16.2.1 rather than localhost, because we configured AdGuard Home to run it’s HTTP server on 172.16.2.1.

        server {
            listen 443 ssl;
            server_name  dns.example.com;
            location / {
                return 418;
            }
            location /dns-query {
                proxy_pass https://router.lan.example.com:8453/dns-query;
                proxy_set_header X-Real-IP  $proxy_protocol_addr;
                proxy_set_header X-Forwarded-For $proxy_protocol_addr;
            }
        }

    Conclusion

    That should do it. You’ve now got filtered DNS resolution for the LAN. You’ve got an authoritative LAN domain. You’ve got a modern DHCP service. And you’ve even got filtered DNS resolution when you’re out of the house.