Configuring DNS using dnsmasq DHCP and dynamic IPv6 firewall rules

I have managed to set up DNS on my router without the need for static IP addresses using dnsmasq as the main DHCP client. It was a bit of a process and quite a steep learning curve, so I figured I'd document what I did here on the forums in case anybody else finds themselves wanting this feature.

The steps to get this working are as follows:

  1. Install packages
  2. Set dnsmasq as the main DHCP server
  3. Configure dnsmasq to provide DHCP and DNS
  4. Bonus: Dynamic IPv6 firewall rules

Install Packages

The first step is to install the required packages. For this setup, the version of dnsmasq built-in to OpenWRT does not have DNS functionality. Instead, we'll want to install dnsmasq-full

opkg update && opkg install dnsmasq-full

For testing purposes, we may also want to install the dig command

opkg install bind-dig

Set Dnsmasq as the main dhcp provider

Now we need to edit some of the OpenWRT configuration files.

First, we have to turn off odhcpd and tweak a parameter of dnsmasq

/etc/config/dhcp

config odhcpd
    ...
    option maindhcp '0' #This is a '1' by default
    ...

config dnsmasq
    ...
    option domain 'home.example.com'
    option localservice '0' #Allows Dnsmasq to resolve queries from outside the network 
                            #Make a sensible rate-limiting firwall rule!
                            #You might leave this to a '1' during testing.
    ...

config dhcp 'lan'
    ...
    option dhcpv6 'disabled'
    ...

Configure Dnsmasq to Provide DHCP and DNS

There are my options to dnsmasq that aren't available through the standard configuration files. So, we need to add some sections to the /etc/dnsmasq.conf file that contains the remainder of the configuration.

/etc/dnsmasq.conf

#DNS settings
auth-server=home.example.com,eth1.2
#On my network, I only advertise the IPv6 addresses of the clients via DNS
#but some tools like fwknopd need an IPv4 address for the router in order
#to function properly, so eth1.2 (wan) advertises IPv4-only. IPv6 link-local
#addresses are excluded from DNS responses.
auth-zone=home.example.com,br-lan/6,eth1.2/4,exclude:fd00::1/8
interface-name=home.example.com,eth1.2/4
dhcp-fqdn

#DHCPv6 Settings
enable-ra
#On my router, I use both DHCPv6 (for ease of addressing) and SLAAC
#(for privacy)
dhcp-range=set:lan6,::ff:fd00:0002,::ff:fdff:ffff,constructor:br-lan,slaac,64,12h
dhcp-range=set:guest6,::ff:fd00:0002,::ff:fdff:ffff,constructor:br-guest,slaac,64,12h

Bonus: Dynamic IPv6 firewall rules

My whole purpose of setting up Dnsmasq rather than odhcpd to begin with was because I was trying to create dynamic IPv6 firewall rules. As you're probably aware, IPv6 prefixes can change pretty frequently depending on your ISP. If the prefix were to change, your existing firewall port forwards for IPv6 will be crippled until you manually re-set them at some point.

The problem with odhcpd is that it doesn't provide script callbacks that will notify of new or renewed DHCP leases. Dnsmasq, however does provide such callbacks making automatic updating of firewall rules a relative breeze.

First we'll add a firewall rule to be dynamically updated:

/etc/config/firewall

config rule
    option target 'ACCEPT'
    option src 'wan'
    option proto 'tcp udp'
    option dest_port '3306'
    option name 'Allow-MySQL-from-WAN'
    option family 'ipv6'
    option dest 'lan'
    option dest_ip '::1' #Dummy localhost value

Upon a new or renewed lease, Dnsmasq calls the script /usr/lib/dnsmasq/dhcp-script.sh, which, in turn calls /sbin/hotplug-call dhcp (after setting up an environment). /sbin/hotplug-call dhcp executes scripts in the /etc/hotplug.d/dhcp folder. By default, there are no scripts here. Let's add one:

/etc/hotplug.d/dhcp/update-firewall.sh

#!/bin/sh

#New, existing, or updated DHCP lease
if [ "$ACTION" == "add" ] || [ "$ACTION" == "old" ] || [ "$ACTION" == "update" ]; then
  #If the IP address is IPv6 and it's not a link-local address (i.e. fd00::/7)
  if [ "${IPADDR%:*}" != "$IPADDR" ] && [ "${IPADDR#fd}" == "$IPADDR" ]; then
    #Check the hostname
    case $HOSTNAME in
      laptop)
        #Verify the DUID -- in case another host has the same hostname
        #This value can be seen by using ip neigh (may require the ip-full package)
        if [ "$MACADDR" == "00:11:22:33:44:55:66:77:88:99:aa:bb:cd:dd:ee:ff:00:11" ]; then
          /usr/sbin/update-uci-firewall-rule "Allow-MySQL-from-WAN" "$IPADDR"
        fi
        ;;
    esac
  fi
fi

This script simply does a sanity check to ensure we're updating the firewall rule to the correct host by matching the hostname and the DUID. Then, it calls a script that I've stored in /usr/sbin/update-uci-firewall-rule. This script does the work of locating and changing the firewall rule. The script supports both IPv4 and IPv6 rules.

/usr/sbin/update-uci-firewall-rule

#!/bin/bash

if [ $# -lt 2 ]
then
  echo "Usage: $0 [rule_name] [new_ip]"
  exit
fi

rulename="$1"
newip="$2"

#Determine whether the given IP is IPv4 or IPv6
newipv4=$(echo $newip | grep -c '.')
newipv6=$(echo $newip | grep -c ':')

. /lib/functions.sh

sectionname=""
sectionid=""

#These are OpenWRT shell callbacks called during config_get
#Essentially, we know we're looking for a filewall rule with
#a specified name, so once we find that rule in the firewall
#config via these callbacks, we unset the callbacks via
#reset_cb
function config_cb()
{
  sectionname="$1"
  sectionid="$2"
}

function option_cb()
{
  if [ "$sectionname" == "rule" ]
  then
    if [ "$1" == "name" ]
    then
      if [ "$2" == "$rulename" ]
      then
        reset_cb
      fi
    fi
  fi
}

config_load firewall

if [ -z "$sectionname" ] || [ -z "$sectionid" ];
then
  echo "Error: no rule named \"$1\" found."
  exit
fi


#The callbacks will have found the $sectionid, now
#pull the dest_ip from the rule
config_get destip $sectionid dest_ip 1>/dev/null

if [ -z "$destip" ]
then
  echo "Error: no IP found for rule \"$1\"."
  exit
fi

#Determine whether previously-configured IP is IPv4 or IPv6
destipv4=$(echo $destip | grep -c '.')
destipv6=$(echo $destip | grep -c ':')
#Address protocol mismatch, display a warning and update the protocol
if [ $newipv4 != $destipv4 ]
then
  if [ $newipv4 ]
  then
    echo "Warning: The previous IP appears to be IPv6 and you are replacing it with an IPv4 address."
    echo "Updating address family"
    uci set firewall."$sectionid".family=ipv4
  else
    echo "Warning: The previous IP appears to be IPv4 and you are replacing it with an IPv6 address."
    echo "Updating address family"
    uci set firewall."$sectionid".family=ipv6
  fi
fi

#Address protocol mismatch, display a warning and update the protocol
if [ $newipv6 != $destipv6 ]
then
  if [ $newipv6 ]
  then
    echo "Warning: The previous IP appears to be IPv4 and you are replacing it with an IPv6 address."
    echo "Updating address family"
    uci set firewall."$sectionid".family=ipv6
  else
    echo "Warning: The previous IP appears to be IPv6 and you are replacing it with an IPv4 address."
    echo "Updating address family"
    uci set firewall."$sectionid".family=ipv4
  fi
fi

#Update the firewall rule with the new IP
uci set firewall."$sectionid".dest_ip="$newip"
uci commit

Conclusion

Once this is all set up, you should be able to dig @[router_ip] AAAA [hostname].home.example.com. Of course, if you change all instances of home.example.com to a real domain and set up some glue records you can get to all the devices from the outside (provided you have port forwards set, of course).

While there is a little bit of configuration involved and a couple of custom scripts to fill in the functionality gaps (be sure to include the files in your backups!), overall it's not a terribly painful process. it has also been fairly reliable for me for several months.

There is one caveat that I have found though: If the router gets reset or there's a power failure, my Linux clients don't populate in the DNS table. Windows doesn't seem to be affected. Since the Linux machines keep ahold of their lease when disconnected, something about the lease renewal process (as opposed to the full lease negotiation process) doesn't trigger Dnsmasq to add the client to the DNS. I have solved this by deleting the lease files (/var/lib/NetworkManager/.lease on Ubuntu and /var/lib/dhcpcd5/.lease on Raspbian) and then reconnecting the network. It works but it's not the most graceful solution.

2 Likes