Dual stack DNS and DHCP with DDNS on Raspberry Pi

My internet connection is using DS-Lite and my ISP provided me with a router called “Connect Box” that only has very basic features. The box doesn’t have DNS settings, it is also not possible to push a custom DNS server with DHCP. But I want to be able to connect to my machines by typing in their hostnames and decided to set up my own DHCP and DNS server with dynamic updates. Since I am connected to the internet with IPv6, the server should support that too.

The problem with two IPv6 DHCP servers

In theory the following setup is working fine, but in my case the router is still interfering with the IPv6 adresses. Clients do not take IPv6 addresses from the DHCP server, instead they have some other addresses probably negotiated with the router. But they do have the IPv6 DNS server entry configured in their IP setup, which is pushed by the DHCP server. For me, this is the most important thing, since Windows is prioritising IPv6 over IPv4 and otherwise cannot resolve other local clients. This also results in no IPv6 updates in the reverse DNS zones.

Preparing the Raspberry Pi

First, the Raspberry Pi needs to have a static IPv4 and IPv6 address. By default, Raspbian Buster is using dhcpcd, a simple tool to connect to dhcp servers. It can be disabled with the following lines:

sudo systemctl stop dhcpcd
sudo systemctl disable dhcpcd

After that, /etc/networking/interfaces can be used to set up static ip addresses

# interfaces(5) file used by ifup(8) and ifdown(8)

# Please note that this file is written to be used with dhcpcd
# For static IP, consult /etc/dhcpcd.conf and 'man dhcpcd.conf'

# Include files from /etc/network/interfaces.d:
source-directory /etc/network/interfaces.d

auto lo
iface lo inet loopback

auto eth0
iface eth0 inet static
  address 192.168.0.2/24
  gateway 192.168.0.1
  dns-nameservers 127.0.0.1 8.8.8.8
  dns-search example.org

iface eth0 inet6 static
  address 2001:db8::2
  netmask 64

For the IPv6 address I used one from the subnet that my Connect Box was set up with. The comment concerning dhcpcd can be removed or ignored, since it is disabled now.

Setting up server software

Next, the DNS and DHCP packages need to be installed:

sudo apt-get install bind9 isc-dhcp-server

Create key

For the DHCP server to be able to push dynamic updates to Bind, a key needs to be generated. This can be done in the home directory of the current user:

/usr/sbin/dnssec-keygen -a HMAC-MD5 -b 128 -r /dev/urandom -n USER DDNS_UPDATE

This generates two files, similar to the following:

Kddns_update.+157+43918.key
Kddns_update.+157+43918.private

The desired key is in the file with the .private ending after Key:

# Kddns_update.+157+43918.private

...

Key: ZeN5TVNPNg1PfKOWFsA1kQ==

...

Now this key can be used in the new file ddns.key:

# ddns.key

key DDNS_UPDATE {
	algorithm HMAC-MD5.SIG-ALG.REG.INT;
	secret "ZeN5TVNPNg1PfKOWFsA1kQ==";
};

Do not forget to put in your own key here. Now this file needs to be copied to /etc/bind/ and /etc/dhcp/. This can be done with the following two lines to also set appropriate permissions:

install -o root -g bind -m 0640 ddns.key /etc/bind/ddns.key
install -o root -g root -m 0640 ddns.key /etc/dhcp/ddns.key

The files starting with Kddns_update are not needed anymore and can be deleted.

Bind

Bind uses the directory /etc/bind/ for its configuration files. /etc/bind/named.conf does not need to be edited, it only contains includes from other config files.

I commented out the forwarders option in the file /etc/bind/named.conf.options and made sure that Bind is listening on IPv6:

# /etc/bind/named.conf.options

...

forwarders {
	8.8.8.8;
};

...

listen-on-v6 { any; };

...

Now, the zones can be added to /etc/bind/named.conf.local:

//
// Do any local configuration here
//

// Consider adding the 1918 zones here, if they are not used in your
// organization
include "/etc/bind/zones.rfc1918";
include "/etc/bind/ddns.key";

zone "example.org" {
    type master;
    notify no;
    file "/var/cache/bind/db.example.org";
    allow-update { key DDNS_UPDATE; };
};

zone "0.168.192.in-addr.arpa" {
    type master;
    notify no;
    file "/var/cache/bind/db.192";
    allow-update { key DDNS_UPDATE; };
};

zone "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" {
    type master;
    notify no;
    file "/var/cache/bind/db.2001:db8:0:0";
    allow-update { key DDNS_UPDATE; };
};

The first zone is the forward zone, the second one is the reverse zone for IPv4 and the third one the reverse zone for IPv6. The DNS server ist the master of all those zones.

notify no; means, that other (public) servers will not be notified about updates, since these zones are only used in a private network.

Reverse zones need to be written backwards, so 192.168.0 becomes 0.168.192. in-addr.arpa is the suffix for reverse IPv4 zones, ip6.arpa is the equivalent for IPv6. I found a online converter at http://rdns6.com/hostRecord for reverse IPv6 addresses, since it is very tedious to reverse them by hand. Just use the second half of any address of your subnet as the zone name. In this example the subnet is 2001:db8::/64 which can also be written as 2001:db8:0:0/64. The reverse subnet is 0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.

The actual zone files are located in /var/cache/bind/, because Bind does not have writing privileges in /etc/bind/.

The easiest way to create the zone files is to copy them from the existing db.local file in /etc/bind/:

cd /var/cache/bind
sudo cp /etc/bind/db.local db.example.org
sudo cp /etc/bind/db.local db.192
sudo cp /etc/bind/db.local db.2001:db8:0:0

The files should be owned by the user and group bind.

sudo chown bind:bind db.*

Now the zone files need to be edited to look similar to the following examples.

;
; /var/cache/bind/db.example.org
;
$TTL    604800
@       IN      SOA     ns.example.org. admin.example.org. (
                              2         ; Serial
                         604800         ; Refresh
                          86400         ; Retry
                        2419200         ; Expire
                         604800 )       ; Negative Cache TTL
;
@       IN      NS      ns.example.org.
ns      IN      A       192.168.0.2
ns      IN      AAAA    2001:db8::2

host1   IN      A       192.168.0.10
host1   IN      AAAA    2001:db8::a
;
; /var/cache/bind/db.192
;
$TTL    604800
@       IN      SOA     ns.example.org. admin.example.org. (
                              2         ; Serial
                         604800         ; Refresh
                          86400         ; Retry
                        2419200         ; Expire
                         604800 )       ; Negative Cache TTL
;
@       IN      NS      ns.
2       IN      PTR     ns.example.org.

10      IN      PTR     host1.example.org.
;
; /var/cache/bind/db.2001:db8:0:0
;
$TTL    604800
@       IN      SOA     ns.example.org. admin.example.org. (
                              2         ; Serial
                         604800         ; Refresh
                          86400         ; Retry
                        2419200         ; Expire
                         604800 )       ; Negative Cache TTL
;
@       IN      NS      ns.
2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0     IN      PTR     ns.example.org.

a.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0     IN      PTR     host1.example.org.

The first line starting with a @ contains the full qualified domain of the name server and the e-mail address of the administrator. The @ sign is substituted by a dot.

The serial should be increased every time a zone file is edited. It can also be the current date and time, e.g. 202006151800 for 2020-06-15 18:00.

The first host entry in all zone files always is the name server, in this example ns. There can also be entries for hosts that have static ip’s and will not be updated by DHCP, in this example host1 with the ip addresses 192.168.0.10 and 2001:db8::a.

In the forward zone, each host has two entries. One for its IPv4 address, wich is the A record. The AAAA record ist the IPv6 address.

The reverse zone files have entries with the reverse part of the ip addresses minus the subnet part, which is already declared in /etc/bind/named.conf.local. Reverse entries are PTR records.

ISC DHCP Server

To enable IPv6, /etc/default/isc-dhcp-server needs to be edited:

# Defaults for isc-dhcp-server (sourced by /etc/init.d/isc-dhcp-server)

# Path to dhcpd's config file (default: /etc/dhcp/dhcpd.conf).
DHCPDv4_CONF=/etc/dhcp/dhcpd.conf
DHCPDv6_CONF=/etc/dhcp/dhcpd6.conf

# Path to dhcpd's PID file (default: /var/run/dhcpd.pid).
DHCPDv4_PID=/var/run/dhcpd.pid
DHCPDv6_PID=/var/run/dhcpd6.pid

# Additional options to start dhcpd with.
# Don't use options -cf or -pf here; use DHCPD_CONF/ DHCPD_PID instead
OPTIONS="-6"

# On what interfaces should the DHCP server (dhcpd) serve DHCP requests?
#       Separate multiple interfaces with spaces, e.g. "eth0 eth1".
INTERFACESv4="eth0"
INTERFACESv6="eth0"

OPTIONS="-6" enables IPv6, INTERFACESv4 and INTERFACESv6 are selecting the interfaces, the server will listen on.

The config files are specified in the top, there needs to be a separate file for IPv6, otherwise the server will not start.

# dhcpd.conf
#
authoritative;

default-lease-time 600;
max-lease-time 7200;

update-conflict-detection true;
ddns-updates on;
ddns-update-style interim;
ddns-dual-stack-mixed-mode true;
ddns-domainname "example.org.";
ignore client-updates;
update-static-leases on;

default-lease-time 600;
max-lease-time 7200;

include "/etc/dhcp/ddns.key";

zone example.org. {
  primary 127.0.0.1;
  key DDNS_UPDATE;
}

zone 0.168.192.in-addr.arpa. {
  primary 127.0.0.1;
  key DDNS_UPDATE;
}

subnet 192.168.0.0 netmask 255.255.255.0 {
  range 192.168.0.100 192.168.0.200;
  option routers 192.168.0.1;
  option domain-name-servers 192.168.0.2;
  option domain-search "example.org";
}

authoritative; means that this is the primary and default DHCP server for this network and should be placed on top of both files.

ddns-domainname sets the domain name that is added to the host name when DNS entries are updated.

Of course, the key for communicating with Bind also needs to be included here. This already should be copied to the right location in /etc/dhcp/.

After that, the zones are defined that will be automatically updated. The DNS server that should be updated ist localhost, since it is on the same machine.

The last part is defining the DHCP range. option routers pushes the gateway to the clients, option domain-name-servers pushes the DNS server(s) and option domain-search is useful for addressing other hosts only by ther hostname instead of their full domain name.

# dhcpd6.conf
#
authoritative;

default-lease-time 600;
max-lease-time 7200;

update-conflict-detection true;
ddns-updates on;
ddns-update-style standard;
ddns-dual-stack-mixed-mode true;
ddns-domainname "example.org.";
ignore client-updates;
update-static-leases on;

default-lease-time 600;
max-lease-time 7200;

include "/etc/dhcp/ddns.key";

zone example.org. {
  primary6 ::1;
  key DDNS_UPDATE;
}

zone 0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa. {
  primary6 ::1;
  key DDNS_UPDATE;
}

subnet6 2001:db8::/64 {
  range6 2001:db8::/112;
  option dhcp6.name-servers 2001:db8::2;
  option dhcp6.domain-search "example.org.";
}

The config file for IPv6 is very similar, most options even are the same. The zone declarations now use the IPv6 address for localhost, and of course the subnet is also a IPv6 address. In this case, the range can be declared in the same way like a subnet, this means that the range is a pool containing the last 16 bits of the subnet (128 – 16 = 112 or 112 + 16 = 128).

Getting everything started

Now it is time to start (or restart) the servers:

sudo systemctl start bind9
sudo systemctl start isc-dhcp-server

Troubleshooting

I had some trouble starting the DHCP server, there was always the following error:

... systemd[1]: isc-dhcp-server.service: Found left-over process 651 (dhcpd) in control group while starting unit. Ignoring.

...

... Starting ISC DHCPv4 server: dhcpddhcpd service already running (pid file /var/run/dhcpd.pid currenty exists) ... failed!

This was caused by an error in dhcpd6.conf preventing the IPv6 part of the server from starting. The IPv4 part was still starting up but the service had the status failed. After fixing an error in the config file, the service couldn’t be started up because part of isc-dhcp-server.service was still running. The following fixed the problem:

sudo kill 651
sudo rm /var/run/dhcpd.pid

After that, the server could be started without errors.

Editing zone files after dynamic updates

If changes need to be made to the zone files in /var/cache/bind/ after some dynamic updates have been written to the files, they should not just be edited. Use rndc freeze example.org to stop dynamic updates for the forward zone. This syncs the .jnl files to the zone file. After all changes have been made, dynamic updates can be started again with rndc thaw example.org. The same applies to the reverse zones, e.g. rndc freeze 0.168.192.in-addr.arpa.

To just sync the .jnl files without freezing, use rndc sync example.org.

Resources