DHCP-assigned addresses are very convenient, except when they change, and your DNS server becomes out of sync.
This post consists of three parts. In the first part, I look at mirage/ocaml-dns as a drop-in replacement for BIND. The second part covers the limitations of our Ubiquity Edge Router and the final part covers a BIND deployment.
The Setup
The router issues a DHCP address, and a separate machine acts as the authoritative DNS server. The goal is to automatically create forward (A) and reverse (PTR) DNS records when an IP address is allocated via DHCP.
Client --DHCPREQUEST--> DHCP Server --DNS UPDATE (TSIG)--> BIND Server
Part 1: The OCaml Solution
The ocaml-dns library provides a complete DNS implementation in OCaml, including DNS UPDATE (RFC 2136) and TSIG authentication with HMAC-SHA256. There is an authoritative DNS server available dns-primary-git which is built on the library, it accepts authenticated dynamic updates and persists zone changes to a git repository.
While dns-primary-git is designed as a MirageOS unikernel, it can also be compiled as a Unix application. Hannes Mehnert’s blog post walks through the full setup: building the server, configuring zone files, deploying with Let’s Encrypt certificates, and running secondary servers. It reads standard zone files, serves authoritative DNS on UDP and TCP, and accepts TSIG-signed updates.
TSIG Keys
In ocaml-dns, the TSIG key name encodes the permissions. A key named mykey._update.example.local grants update access to the example.local zone. This differs from BIND, where key names are arbitrary identifiers and authorisation is configured separately with allow-update. The key is expressed as a DNSKEY record in the zone file, as below, where algorithm 163 is HMAC-SHA256.
mykey._update.example.local. DNSKEY 0 3 163 K8aJhr8FpOhKGH0bP1dGa4VPCqmM3bRJf2TUGB1JyT0=
ISC DHCP configuration
The DHCP server needs to know:
- The TSIG key
- Which DNS server to send updates to
- Which zone names to update
- What domain name to append to hostnames
Here’s the relevant dhcpd.conf configuration:
ddns-update-style interim;
ddns-domainname "example.local.";
key mykey._update.example.local {
algorithm hmac-sha256;
secret "K8aJhr8FpOhKGH0bP1dGa4VPCqmM3bRJf2TUGB1JyT0=";
};
key mykey._update.1.168.192.in-addr.arpa {
algorithm hmac-sha256;
secret "K8aJhr8FpOhKGH0bP1dGa4VPCqmM3bRJf2TUGB1JyT0=";
};
zone example.local. {
primary 192.168.1.1;
key mykey._update.example.local;
}
zone 1.168.192.in-addr.arpa. {
primary 192.168.1.1;
key mykey._update.1.168.192.in-addr.arpa;
}
subnet 192.168.1.0 netmask 255.255.255.0 {
range 192.168.1.100 192.168.1.200;
option routers 192.168.1.1;
option domain-name-servers 192.168.1.1;
option domain-name "example.local";
default-lease-time 7200;
max-lease-time 7200;
}
ddns-update-style interim tells ISC DHCP to use a TXT record alongside each A record to track the ownership of the DNS entry. This prevents one client from overwriting another client’s record.
ddns-domainname is the domain appended to the client’s hostname. If a machine sends hostname webserver, the DHCP server registers webserver.example.local.
Each zone block references a key whose name matches the ocaml-dns convention: name._update.zone. The same secret is used for both, but the key names tell the DNS server which zone each key is authorised to update. The underscore in the key name requires ISC DHCP 4.4+ (see Part 2 for why this matters).
Compatibility
This approach works with ISC DHCP 4.4+, which supports HMAC-SHA256 TSIG and allows underscores in key names, or the newer ISC Kea, or any DNS UPDATE client that supports HMAC-SHA256.
It is a great option, giving you a single statically-linked binary that replaces BIND entirely. Zone changes are persisted to git, giving you version history for free. The TSIG key management is simple and self-contained. No configuration files beyond the zone files themselves.
If your DHCP server supports HMAC-SHA256, use this!
Part 2: Ubiquiti EdgeRouter
My Ubiquiti EdgeRouter runs ISC DHCP 4.1-ESV-R15-P1, which is pretty old. It has two problems that make it incompatible with ocaml-dns:
Problem 1: Hardcoded HMAC-MD5
ISC DHCP 4.1-ESV accepts algorithm hmac-sha256 in the configuration file without error:
key rndc-key {
algorithm hmac-sha256;
secret "K8aJhr8FpOhKGH0bP1dGa4VPCqmM3bRJf2TUGB1JyT0=";
};
But it always sends HMAC-MD5.SIG-ALG.REG.INT on the wire. The algorithm selection is silently ignored. This was confirmed by deploying an OCaml server and watching the actual packets arrive:
error not implemented while w.x.y.z sent ...
... HMAC-MD5.SIG-ALG.REG.INT ...
The ocaml-dns library deliberately does not support HMAC-MD5, which is a sound security decision, but makes it incompatible with this DHCP server.
Problem 2: No Underscores in Key Names
The ocaml-dns convention encodes permissions in the key name: mykey._update.zone. But ISC DHCP 4.1-ESV’s parser treats the underscore as a token separator. This was fixed in later ISC DHCP versions (4.4+)
key rndc-key._update.example.local { algorithm hmac-sha256; ...
^
expecting left brace
The Result
The EdgeRouter’s DHCP server can only send DNS UPDATE packets signed with HMAC-MD5 using bare key names like rndc-key. ocaml-dns requires HMAC-SHA256 with convention key names like rndc-key._update.zone.
ISC DHCP reached end-of-life at the end of 2022. ISC recommends migrating to Kea, which would solve both problems. But until the EdgeRouter is replaced or the DHCP server upgraded, we need a DNS server that speaks MD5.
Part 3: The BIND Fallback
BIND handles HMAC-MD5, HMAC-SHA256, bare key names, and convention key names. It separates key identity from authorisation, so a key named rndc-key can be granted update access to any zone via allow-update.
Generating the TSIG Key
rndc-confgen -a -k rndc-key -A hmac-md5
This writes /etc/bind/rndc.key using the HMAC-MD5 algorithm, matching what the Edge Router sends.
Configuring BIND
In /etc/bind/named.conf.local:
include "/etc/bind/rndc.key";
zone "example.local" {
type master;
file "/var/lib/bind/db.example";
allow-update { key rndc-key; };
allow-transfer { none; };
};
zone "1.168.192.in-addr.arpa" {
type master;
file "/var/lib/bind/db.192.168.1";
allow-update { key rndc-key; };
allow-transfer { none; };
};
Create minimal zone files, and DDNS will populate the rest.
Forward zone (/var/lib/bind/db.example):
$ORIGIN .
$TTL 604800
example.local IN SOA ns.example.local. admin.example.local. (
1 ; serial
604800 ; refresh (1 week)
86400 ; retry (1 day)
2419200 ; expire (4 weeks)
604800 ; minimum (1 week)
)
NS ns.example.local.
$ORIGIN example.local.
ns A 192.168.1.1
Reverse zone (/var/lib/bind/db.192.168.1):
$ORIGIN .
$TTL 604800
1.168.192.in-addr.arpa IN SOA ns.example.local. admin.example.local. (
1 ; serial
604800 ; refresh (1 week)
86400 ; retry (1 day)
2419200 ; expire (4 weeks)
604800 ; minimum (1 week)
)
NS ns.example.local.
chown bind:bind /var/lib/bind/db.example /var/lib/bind/db.192.168.1
Test with nsupdate:
nsupdate -k /etc/bind/rndc.key <<EOF
server 127.0.0.1
zone example.local
update add testhost.example.local. 3600 A 192.168.1.100
send
EOF
Configuring the DHCP Server
ddns-update-style interim;
ddns-domainname "example.local.";
key rndc-key {
algorithm hmac-md5;
secret "K8aJhr8FpOhKGH0bP1dGa4VPCqmM3bRJf2TUGB1JyT0=";
};
zone example.local. {
primary 192.168.1.1;
key rndc-key;
}
zone 1.168.192.in-addr.arpa. {
primary 192.168.1.1;
key rndc-key;
}
subnet 192.168.1.0 netmask 255.255.255.0 {
range 192.168.1.100 192.168.1.200;
option routers 192.168.1.1;
option domain-name-servers 192.168.1.1;
option domain-name "example.local";
default-lease-time 7200;
max-lease-time 7200;
}
On an Ubiquiti EdgeRouter:
set service dhcp-server dynamic-dns-update enable
set service dhcp-server global-parameters "key rndc-key { algorithm hmac-md5; secret "dTuFR8GiHHFgfam+yLkaWQ=="; };"
set service dhcp-server global-parameters "zone example.local. { primary 192.168.1.1; key rndc-key; }"
set service dhcp-server global-parameters "zone 1.168.192.in-addr.arpa { primary 192.168.1.1; key rndc-key; }"
set service dhcp-server global-parameters "ddns-domainname "example.local.";"
Client Configuration
DHCP clients need to send their hostname. On Ubuntu/Debian with systemd-networkd:
network:
version: 2
renderer: networkd
ethernets:
eth0:
dhcp4: yes
Delegating the Zone Publicly
To make dynamic records resolvable from the internet, delegate from your public DNS provider:
internal NS ns.internal.example.com.
ns.internal A 203.0.113.10
The second record is “glue” which is needed because the nameserver is inside the zone it serves. The full resolution can be verify with dig myhost.internal.example.com +trace.