[Home](https://codefionn.eu/) · [About](https://codefionn.eu/about/) · [GitHub](https://github.com/codefionn)

---

# DDNS with unbound and kea

> Dynamic DNS with unbound and kea for giving local devices DNS-names

*Published on 2023-11-14 · [View as HTML](https://codefionn.eu/ddns-with-unbound/)*

---


My [NixOS router](/nixos-router-with-j4125-and-intel-i225/) uses
[unboud](https://nlnetlabs.nl/projects/unbound/about/) as a DNS server and
[kea](https://www.isc.org/kea/) as the DHCPv4/6 server (currently the IPv6 DHCP
isn't working). This article contains a script for listening to changes in
*kea* DHCP-leases and writing the hostnames to unbound. This gives your local
devices domain names.

You might know that, *kea* also supports
<abbr title="dynamic DNS">DDNS</abbr> configuration. While that may be true,
this only works, if the DNS server supports TSIG. The problem is that unbound
doesn't support it. I looked into moving from unbound to a differnt DNS
server (like *Bind9* or *Knot DNS*), but those are complicated to configure with
NixOS, so I stayed with unbound. 

The solution: a bash script running as systemd service. The script itself
requires that unbound can be remote controlled and the *inotify-tools* for
listening to file changes.

```bash
#!/usr/bin/env bash
HOSTNAME="$(hostname).lan"

function readFile() {
  if [[ "$2" == "A" ]] ; then
    cat "$1" | tail -n +2 | while IFS=, read -r address hwaddr client_id valid_lifetime expire subnet_id fqdn_fwd fqdn_rev hostname state user_context
    do
      echo "${address},${hostname}"
    done
  else
    cat "$1" | tail -n +2 | while IFS=, read -r address duid valid_lifetime expire subnet_id pref_lifetime lease_type iaid prefix_len fqdn_fwd fqdn_rev hostname hwaddr state user_context hwtype hwaddr_source
    do
      echo "${address},${hostname}"
    done
  fi
}

function readFileUnique() {
  readFile "$1" $2 | uniq | while IFS=, read -r address hostname
  do
    if [[ "${hostname}" == *.$HOSTNAME ]] ; then
      echo ${hostname} $2 ${address}
      unbound-control local_data ${hostname} $2 ${address}
      if [[ "$2" == "A" ]] ; then
        echo ${address} | while IFS=. read -r ip0 ip1 ip2 ip3
        do
          unbound-control local_data ${ip3}.${ip2}.${ip1}.${ip0}.ip4.arpa. PTR ${hostname}
          unbound-control local_data ${ip3}.${ip2}.${ip1}.${ip0}.in-addr.arpa. PTR ${hostname}
        done
      fi
    fi
  done
}

function syncFile() {
  readFileUnique "$1" "$2"
  while inotifywait -e close_write,create "$1" ; do
    readFileUnique "$1" "$2"
  done
}

syncFile "/var/lib/kea/dhcp4.leases" A &
syncFile "/var/lib/kea/dhcp6.leases" AAAA &
wait
```

This scripts reads the DHCP4/6-Leases CSV database stored by *kea* and then
reads the address and hostname from that table. *unbound-control* is called to
set the `A` or `AAAA` record. If it is IPv4, we reverse the IP-address and store
a PTR record for reverse IP-lookup (`dig -x YOUR.IP.GOES.HERE`). The wait at
the end waits for the to forked `syncFile`-functions to finish.

This script first reads all DHCP-leases and sets the DNS records accordingly
and then waits for changes in the DHCP-leases with inotify.

Hostnames used for creating these DNS-Records must end here with your
hostname + local-domain, That's why, you have to make sure that your kea DHCPv4/6 has
the following configuration option:

```
ddns-qualifying-suffix = "YOUR-HOSTNAME.lan"
```

In your systemd-Service set `PartOf`, `After` and `Wants` to `unbound.service`.
This will ensure that, the script is started, after `unbound` and that the
script is always restarted, when unbound itself is. `WantedBy` must be set to
`multi-user.target`.

The configuration in NixOS is:

```nix
systemd.services.unbound-sync = {
  enable = true;
  path = with pkgs; [ unbound inotify-tools ];
  script = ''
    function readFile() {
      if [[ "''\$2" == "A" ]] ; then
        cat "''\$1" | tail -n +2 | while IFS=, read -r address hwaddr client_id valid_lifetime expire subnet_id fqdn_fwd fqdn_rev hostname state user_context
        do
          echo "''\${address},''\${hostname}"
        done
      else
        cat "''\$1" | tail -n +2 | while IFS=, read -r address duid valid_lifetime expire subnet_id pref_lifetime lease_type iaid prefix_len fqdn_fwd fqdn_rev hostname hwaddr state user_context hwtype hwaddr_source
        do
          echo "''\${address},''\${hostname}"
        done
      fi    
    }     
        
    function readFileUnique() {
      readFile "''\$1" ''\$2 | uniq | while IFS=, read -r address hostname
      do  
        if [[ "''\${hostname}" == *.${config.networking.hostName}.lan ]] ; then
          echo ''\${hostname} ''\$2 ''\${address}
          unbound-control local_data ''\${hostname} ''\$2 ''\${address}
          if [[ "''\$2" == "A" ]] ; then
            echo ''\${address} | while IFS=. read -r ip0 ip1 ip2 ip3
            do
              unbound-control local_data ''\${ip3}.''\${ip2}.''\${ip1}.''\${ip0}.ip4.arpa. PTR ''\${hostname}
              unbound-control local_data ''\${ip3}.''\${ip2}.''\${ip1}.''\${ip0}.in-addr.arpa. PTR ''\${hostname}
            done
          fi
        fi
      done
    } 
    
    function syncFile() {
      readFileUnique "''\$1" "''\$2"
      while inotifywait -e close_write,create "''\$1" ; do
        readFileUnique "''\$1" "''\$2"
      done 
    }

    syncFile "/var/lib/kea/dhcp4.leases" A &
    syncFile "/var/lib/kea/dhcp6.leases" AAAA &
    wait
  '';
  wants = [ "network-online.target" "unbound.service" ];
  after = [ "network-online.target" "unbound.service" ];
  partOf = [ "unbound.service" ];
  wantedBy = [ "multi-user.target" ];
};
```

---

[Impressum](https://codefionn.eu/impressum/) · [Datenschutzerklärung](https://codefionn.eu/datenschutz/) · [Mastodon](https://c.im/@codefionn)

© Copyright 2022-2026 Fionn Langhans
