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.