dynamically updating private DNS zone records in OpenWRT

My home lab is connected to the Internet via a router running OpenWRT firmware. When deploying a local ACME server, I realized that regardless of the type of request validation used, ACME must find the fully qualified domain name of the server for which the certificate is requested in DNS.

While thinking about where to host my private DNS zone, it dawned on me: “But we already have a DNS server in OpenWRT at home. Surely we can remotely update records in its local zone.”
TL;DR: In the end I had to install BIND.

Unfortunately, I couldn't find a good way to update records remotely in dnsmasq (Default DNS forwarder in OpenWRT).

For my purposes I needed something simple, authoritative and TSIG supported. When comparing DNS server functionality matrices and the OpenWRT repository caught my eye on the following options:

  • BIND 9.18.24-1

  • PowerDNS 4.7.4-1

  • Knot DNS 3.3.5-1

  • NSD 4.6.1-1

Here they are sorted according to the principle “the more green in the table, the higher in the list”.

At that time, when choosing an option, I was guided by the lack of personal preferences and understanding of what nuances need to be taken into account. Therefore, I bravely started with BIND, which is first on the list.

Plan:

  1. Install BIND;

  2. Disable DNS forwarding in dnsmasqleaving it to service DHCP requests;

  3. Set up basic things;

  4. Enable and test dynamic zone updates;

  5. Automate updating DNS records for hosts initialized via DHCP.

Router settings:

  • .lan — suffix for names in the local network,

  • CIDR of local network: 192.168.1.0/24

Versions:

  • OpenWrt 23.05.3 arm64

  • BIND 9.18.24

Disclaimer: By following the instructions below, you risk breaking name resolution on your network, depleting your router's flash memory, or robbing yourself of internet. Before repeating these steps on a real router, try them on an emulator first.

Installation

Before starting the experiment, let's take a look at the active network services:

$ netstat -tulpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address    Foreign Address  State    PID/Program name
tcp        0      0 0.0.0.0:22       0.0.0.0:*        LISTEN   1637/dropbear
tcp        0      0 0.0.0.0:80       0.0.0.0:*        LISTEN   2187/uhttpd
tcp        0      0 192.168.1.1:53   0.0.0.0:*        LISTEN   2510/dnsmasq
tcp        0      0 10.1.2.144:53    0.0.0.0:*        LISTEN   2510/dnsmasq
tcp        0      0 127.0.0.1:53     0.0.0.0:*        LISTEN   2510/dnsmasq
tcp        0      0 0.0.0.0:443      0.0.0.0:*        LISTEN   2187/uhttpd
udp        0      0 127.0.0.1:53     0.0.0.0:*                 2510/dnsmasq
udp        0      0 10.1.2.144:53    0.0.0.0:*                 2510/dnsmasq
udp        0      0 192.168.1.1:53   0.0.0.0:*                 2510/dnsmasq
udp        0      0 0.0.0.0:67       0.0.0.0:*                 2510/dnsmasq
...

As expected, the ports 53 (DNS) and 67 (DHCP) are served dnsmasq.

Installing the server bind and a set of utilities for it:

$ opkg update
$ opkg install bind-server bind-tools bind-client

Then we turn it off dnsmasq the part responsible for DNS, we specify our local domain and configure the DHCP server to send to clients 192.168.1.1 as DNS address:

$ uci set dhcp.@dnsmasq[0].port=0
$ uci set dhcp.@dnsmasq[0].domain='lan'
$ uci add_list dhcp.@dnsmasq[0].dhcp_option='6,192.168.1.1'
$ uci commit dhcp
$ /etc/init.d/dnsmasq restart
$ /etc/init.d/named restart

After restarting the services, the active ports should look something like this:

$ netstat -tulpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address   Foreign Address   State   PID/Program name
tcp        0      0 0.0.0.0:22      0.0.0.0:*         LISTEN  1637/dropbear
tcp        0      0 0.0.0.0:80      0.0.0.0:*         LISTEN  2187/uhttpd
tcp        0      0 127.0.0.1:953   0.0.0.0:*         LISTEN  8511/named
tcp        0      0 127.0.0.1:953   0.0.0.0:*         LISTEN  8511/named
tcp        0      0 0.0.0.0:443     0.0.0.0:*         LISTEN  2187/uhttpd
udp        0      0 192.168.1.1:53  0.0.0.0:*                 8511/named
udp        0      0 192.168.1.1:53  0.0.0.0:*                 8511/named
udp        0      0 10.1.2.144:53   0.0.0.0:*                 8511/named
udp        0      0 10.1.2.144:53   0.0.0.0:*                 8511/named
udp        0      0 127.0.0.1:53    0.0.0.0:*                 8511/named
udp        0      0 127.0.0.1:53    0.0.0.0:*                 8511/named
udp        0      0 0.0.0.0:67      0.0.0.0:*                 8767/dnsmasq
...

As we can see, dnsmasq listens to DHCP, and named (BIND) listens on ports 53 And 953.

Let's check that we have access to DNS:

$ dig +dnssec +multi . DNSKEY

; <<>> DiG 9.18.24 <<>> +dnssec +multi . DNSKEY
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 19567
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 4, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags: do; udp: 1232
; COOKIE: a398f023571fde5701000000667c7bbbd959a05085315b53 (good)
;; QUESTION SECTION:
;.                      IN DNSKEY
;; ANSWER SECTION:
.                       171554 IN DNSKEY 256 3 8 (
                                AwEAAZBALoOFImwcJJg9Iu7Vy7ZyLjhtXfvO1c9k4vHj
                                Opf9i7U1kKtrBvhnwsOni1sb50gkUayRtMDTUQqvljMM
                                f4bpkyEtcE5evCzhHbFLq1coL5QOix3mfJm++FvIMaAt
                                52nOvAdqR/luuI11bA1AmSCIJKAUx147DcfOHYKg3as+
                                dznn3Iah4cWBMVzDe7PPsFS1AO6gU8EpmiRJ9VMNA09f
                                OyDuq9+d6sw8UUnJRMAFAuPLhUFjUAOuWOw74BC9lOtM
                                QpbLMz8pX0CDKdOXDHjyj61nxSSWxPdUjeoxI17lQTpS
                                PRtqRHFn5Fgj2e+9BVwhhWGDQN8kUVSJHZtQiI0=
                                ) ; ZSK; alg = RSASHA256 ; key id = 5613
.                       171554 IN DNSKEY 256 3 8 (
                                AwEAAdSiy6sslYrcZSGcuMEK4DtE8DZZY1A08kAsviAD
                                49tocYO5m37AvIOyzeiKBWuPuJ4m9u5HonCM/ntxklZK
                                YFyMftv8XoRwbiXdpSjfdpNHiMYTTV2oDUNMjdLFnF6H
                                YSY48xrPbevQOYbAFGHpxqcXAQT0+BaBiAx3Ls6lXBQ3
                                /hSVOprvDWJCQiI2OT+9+saKLddSIX6DwTVy0S5T4YY4
                                EGg5R3c/eKUb2/8XgKWUzlOIZsVAZZUSTKW0tX54ccAA
                                LO7Grvsx/NW62jc1xv6wWAXocOEVgB7+4Lzb7q9p5o30
                                +sYoGpOsKgFvMSy4oCZTQMQx2Sjd/NG2bMMw6nM=
                                ) ; ZSK; alg = RSASHA256 ; key id = 20038
.                       171554 IN DNSKEY 257 3 8 (
                                AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTO
                                iW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN
                                7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5
                                LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8
                                efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7
                                pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLY
                                A4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws
                                9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=
                                ) ; KSK; alg = RSASHA256 ; key id = 20326
.                       171554 IN RRSIG DNSKEY 8 0 172800 (
                                20240711000000 20240620000000 20326 .
                                k7Tz3FFlPySd/LF69we2WyDwnqf+JTTpJ3sriFGLkq26
                                MGBD/fioXO4xqcCrnWVF50nKs8CaEQpdI9N0N2rW3fZh
                                9sVryGEvPiNnxfv8JC9MiMlt5pnVWYyOzDWpt9OAznmv
                                JVvqhZIi19MvmkEj+S/WQCuJwZUx+0r1Nv8mBrN0dbms
                                LpH3sjgs8pw8SSL4QCLFlJzmqomt1ncM5ocoWqvOU7Hb
                                Xgt40Gg0ZiZFqs9IebA62pbu5GAVzJEMoANUqxIo3lAg
                                2JIEWTpo/+hF3QpaB/SFJ0obrJMi4OULOfY2DCx1jjlq
                                C4qaiS7c/IaGux2bMwQV1zfRDpu4AA5eSw== )
;; Query time: 9 msec
;; SERVER: 127.0.0.1#53(127.0.0.1) (UDP)
;; WHEN: Wed Jun 26 20:36:11 UTC 2024
;; MSG SIZE  rcvd: 1169

We have access, we can move on to setup.

Default settings

Immediately after installation the catalog /etc/bind looks like that:

$ ls -lah /etc/bind 
drwxr-xr-x  2 root root  3.4K Jun 26 20:15 .
drwxr-xr-x  1 root root  3.4K Jun 26 20:15 ..
-rw-r--r--  1 root root  3.8K Feb 16 18:24 bind.keys
-rw-r--r--  1 root root   237 Feb 16 18:24 db.0
-rw-r--r--  1 root root   271 Feb 16 18:24 db.127
-rw-r--r--  1 root root   237 Feb 16 18:24 db.255
-rw-r--r--  1 root root   237 Feb 16 18:24 db.empty
-rw-r--r--  1 root root   256 Feb 16 18:24 db.local
-rw-r--r--  1 root root  3.1K Feb 16 18:24 db.root
-rw-r--r--  1 root root   281 Jun 26 20:15 named-rndc.conf
-rw-r--r--  1 root root   982 Feb 16 18:24 named.conf
-rw-r--r--  1 root root   225 Jun 26 20:15 rndc.conf

Here:

  • bind.keys – trust anchors for the DNS root zone(.). If you wish, you can compare the contents of the file with what was returned to us above. dig ;

  • db.root – information about root servers to pre-populate our cache;

  • db.0, db.255, db.empty – reverse lookup zones for broadcast requests;

  • db.local – forward lookup zone for localhost;

  • db.127 – reverse zone for loopback addresses;

  • named-rndc.conf – the key and policies that allow the utility rndc manage our server locally;

  • rndc.conf – settings for the most rndc ;

  • named.conf – the main BIND configuration file.

Straight out of the box /etc/bind/named.conf in OpenWRT it looks something like this:

// base named.conf file
// Recommended that you always maintain a change log in this file

// options clause defining the server-wide properties
options {
        // all relative paths use this directory as a base
        directory "/tmp";

        // If your ISP provided one or more IP addresses for stable
        // nameservers, you probably want to use them as forwarders.
        // Uncomment the following block, and insert the addresses replacing
        // the all-0's placeholder.
        // forwarders {
        //      0.0.0.0;
        // };

        auth-nxdomain no;    # conform to RFC1035

        // this ensures that any reverse map for private IPs
        // not defined in a zone file will *not* be passed 
        // to the public network
        empty-zones-enable yes;
};

include "/etc/bind/named-rndc.conf";
include "/tmp/bind/named.conf.local";

// prime the server with knowledge of the root servers
zone "." {
        type hint;
        file "/etc/bind/db.root";
};

// Provide forward mapping zone for localhost (optional)
zone "localhost" {
        type primary;
        file "/etc/bind/db.local";
};

// Provide reverse mapping zone for the loopback address 127.0.0.1
// zone "0.0.127.in-addr.arpa" 
zone "127.in-addr.arpa" {
        type primary;
        file "/etc/bind/db.127";
};

zone "0.in-addr.arpa" {
        type primary;
        file "/etc/bind/db.0";
};

zone "255.in-addr.arpa" {
        type primary;
        file "/etc/bind/db.255";
};

Setting up local zones

First, let's add a file /etc/bind/db.lan local forward lookup zone lan.:

; forward zone file for lan.
$ORIGIN .
$TTL 0  ; 0 seconds
lan                     IN SOA  ns1.lan. root.lan. (
                                1719490275 ; serial
                                43200      ; refresh (12 hours)
                                900        ; retry (15 minutes)
                                1814400    ; expire (3 weeks)
                                7200       ; minimum (2 hours)
                                )
$TTL 900        ; 15 minutes
                        NS      ns1.lan.
$ORIGIN lan.
ns1                     A       192.168.1.1
openwrt                 A       192.168.1.1
router                  CNAME   openwrt
acme                    CNAME   openwrt

and file /etc/bind/db.1.168.192 reverse lookup zones for our subnet 192.168.1.0/24:

; reverse zone file for lan.
$ORIGIN .
$TTL 0  ; 0 seconds
1.168.192.in-addr.arpa  IN SOA  ns1.lan. root.lan. (
                                1719490269 ; serial
                                43200      ; refresh (12 hours)
                                900        ; retry (15 minutes)
                                1814400    ; expire (3 weeks)
                                7200       ; minimum (2 hours)
                                )
$TTL 900        ; 15 minutes
                        NS      ns1.lan.
$ORIGIN 1.168.192.in-addr.arpa.
1                       PTR     ns1.lan.
                        PTR     openwrt.lan.

An overview of zone file syntax can be found Here,

but in short:
  • Parsing occurs from top to bottom.

  • ; begins a comment. Everything on the line after it is ignored.

  • ()Brackets are used to split long lines into several shorter ones.

  • When parsing to any domain name that does not end with a dot “.“, the current value of the variable is added $ORIGIN. For example, the entry acme CNAME openwrt is interpreted as acme.lan. CNAME openwrt.lan.since four lines above we set lan. as current value $ORIGIN.

  • After the class IN defined in the record SOAit is not necessary to write it in resource records.

  • If you do not write the TTL value in the records, it will be taken as equal to the current value of the variable. $TTL.

  • In recording SOA meaning ns1.lan. – this is the name of the DNS server that serves this zone, root.lan. is transmitted to the zone administrator's e-mail address root@lan .

  • If the very first field in a resource record (it is called OWNER) is empty, then it is considered equal to the value of this field on the previous line.

  • Names in the reverse zone in-addr.arpa.corresponding to IP addresses, are written in reverse order, that is, the IP address 10.1.2.3 turns into 3.2.1.10.in-addr.arpa.

The parsing rules given above allow to significantly reduce resource recording 1.1.168.192.in-addr.arpa. 900 IN PTR openwrt.lan. to simple PTR openwrt.lan.

Let's make sure we haven't messed up the syntax:

$ named-checkzone lan /etc/bind/db.lan 
zone lan/IN: loaded serial 1719490275
OK

$ named-checkzone 1.168.192.in-addr.arpa /etc/bind/db.1.168.192 
zone 1.168.192.in-addr.arpa/IN: loaded serial 1719490269
OK

Now we can add the zones themselves /etc/bind/named.conf:

$ cat <<EOF>> /etc/bind/named.conf
zone "lan" {
        type primary;
        file "/etc/bind/db.lan";
};
zone "1.168.192.in-addr.arpa" {
        type primary;
        file "/etc/bind/db.1.168.192";
};
EOF

Let's check the syntax of the configuration file:

$ named-checkconf -pzx

Reloading the configuration bind and see if the zones have loaded:

$ rndc reload

$ rndc zonestatus lan 
name: lan
type: primary
files: /etc/bind/db.lan
serial: 1719490275
nodes: 5
last loaded: Thu, 27 Jun 2024 13:01:34 GMT
secure: no
dynamic: no
reconfigurable via modzone: no

$ rndc zonestatus 1.168.192.in-addr.arpa 
name: 1.168.192.in-addr.arpa
type: primary
files: /etc/bind/db.1.168.192
serial: 1719490269
nodes: 2
last loaded: Thu, 27 Jun 2024 13:13:03 GMT
secure: no
dynamic: no
reconfigurable via modzone: no

And finally, let's check how name resolution works:

$ nslookup acme.lan && nslookup 192.168.1.1 
Server:         127.0.0.1
Address:        127.0.0.1#53
acme.lan        canonical name = openwrt.lan.
Name:   openwrt.lan
Address: 192.168.1.1
1.1.168.192.in-addr.arpa        name = ns1.lan.
1.1.168.192.in-addr.arpa        name = openwrt.lan.

Setting up forwarders{}

Now we have a direct connection to DNS, which means that anyone who sees our traffic will know more about what sites we visit than we do. Let's try to fix this by redirecting DNS requests.

Disclaimer: Hiding DNS traffic in the way described below is really a choice between ISP and Big Brother or Dr. Evil and the Illuminati. Of course, if the forwarded requests leave your network via port 53 unencrypted, the ISP still sees it 🙂

Starting with version 9.19.10, BIND can be configured to use TLS for forwarded requests. Unfortunately, the OpenWRT package repository currently only has version 9.18.24. To force DNS-over-TLS (DOT), let's run a DNS proxy stubbywhich “out of the box” redirects requests to CloudFlare servers:

$ opkg install ca-certificates
$ opkg install stubby

Let's check which port it is listening on:

$ uci get stubby.global.listen_address 
127.0.0.1@5453 0::1@5453

We prescribe 127.0.0.1 port 5453 V /etc/bind/named.conf and we prohibit direct requests to DNS:

options {
...
   forward only;
   forwarders {
      127.0.0.1 port 5453; 
   }; 
...
};

We check and reload the configuration:

$ named-checkconf
$ rndc reload
$ rndc flush
If after enabling redirection name resolution stopped working or you don't want CloudFlare

Here are some tips that helped me:

  • You can replace CloudFlare in /etc/config/stubby

  • You can also comment there. option log_level '7'to execute /etc/init.d/stubby reloadwhich will give more information to syslog. You can view it by running logread -f in the second ssh session

  • Installing root certificates opkg install ca-certificates helped to overcome the error TLS - *Failure* - (20) "unable to get local issuer certificate"

Setting up dynamic zone updates

At the moment, our DNS server is completely static and new records in zone files do not appear by themselves. We can manually edit the forward and reverse zone files and then reload the configuration, but there is a better way: use the utility nsupdate or any other tool for cryptographically signed DNS updates.

To do this, we will need to generate a TSIG key, adjust the zone configuration in /etc/bind/named.conf and change the owner of the files.

Note: Many examples from the Internet use the utility to generate TSIG keys dnssec-keygen. In the current version of BIND it is not suitable for this purpose, since the function of generating HMAC algorithms for use as TSIG keys via dnssec-keygen was removed in BIND 9.13.0. We will use tsig-keygen.

Since the conclusion tsig-keygen is already formatted for inclusion in the config file, we just need to run it:

$ tsig-keygen | tee /etc/bind/keys.conf
key "tsig-key" {
        algorithm hmac-sha256;
        secret "HAyLN66//YxVF2lrZ6kSZK4TZEpV7WMvzYnNUQ0BvEo=";
};

Then reference the generated file in /etc/bind/named.conf :

$ cat<<EOF>> /etc/bind/named.conf
include "/etc/bind/keys.conf";
EOF

IN /etc/bind/named.conf we add allow-update{key tsig-key;} into the zone configuration, which will allow our key to make any changes:

zone "lan" {
        type primary;
        file "/etc/bind/db.lan";
        allow-update {
          key tsig-key;
        };
};
zone "1.168.192.in-addr.arpa" {
        type primary;
        file "/etc/bind/db.1.168.192";
        allow-update {
          key tsig-key;
        };
};

Note: if unlimited rights are excessive in your case, you can instead allow-update{} set a more complex set of rules through update-policy{}.

We check and reload the configuration:

$ named-checkconf
$ rndc reload

A small but very important detail: at the moment the catalog /etc/bind with all files belongs root:rootWe need to change the owner to bind:bindto named could create and modify files. At the same time, we will correct the access rights for other system users:

$ chown -R bind:bind /etc/bind
$ chmod 600 -R /etc/bind/*

Testing updating records via nsupdate

Let's add a couple of entries to our zones. To do this, we'll create a file nsupdate.cmd with the following contents:

server 127.0.0.1 53
zone lan.
update delete host2.lan.
update add host2.lan. 900 A 192.168.1.2
show
send
zone 1.168.192.in-addr.arpa.
update delete 2.1.168.192.in-addr.arpa.
update add 2.1.168.192.in-addr.arpa. 900 PTR host2.lan.
show
send

With this sequence of commands we ask the DNS server to delete all records (if any) for host2.lan. in the zone lan.and then add A recording with TTL=900 For host2.lan.referring to 192.168.1.2. The second sequence of commands repeats the algorithm for PTR reverse zone entries 1.168.192.in-addr.arpa.

Let's launch nsupdatespecifying the path to the file with the key and to the file with the list of commands:

$ nsupdate -k /etc/bind/keys.conf nsupdate.cmd
Outgoing update query:
;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
;; ZONE SECTION:
;lan.                           IN      SOA

;; UPDATE SECTION:
host2.lan.              0       ANY     ANY
host2.lan.              900     IN      A       192.168.1.2

Outgoing update query:
;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
;; ZONE SECTION:
;1.168.192.in-addr.arpa.                IN      SOA

;; UPDATE SECTION:
2.1.168.192.in-addr.arpa. 0     ANY     ANY
2.1.168.192.in-addr.arpa. 900   IN      PTR     host2.lan.

Let's check the result:

$ host 192.168.1.2 && host host2.lan 
2.1.168.192.in-addr.arpa domain name pointer host2.lan.
host2.lan has address 192.168.1.2

If we look into the directory with configs, we will see two new files with the extension .jnlwhich store the log of changes to records in zones:

$ ls /etc/bind/*jnl 
/etc/bind/db.1.168.192.jnl  /etc/bind/db.lan.jnl

Disclaimer: in the context of OpenWRT, this was an unpleasant surprise for me, since the recording occurs in the router's flash memory and thus accelerates its degradation. I recommend that you familiarize yourself with this section documentation to estimate the dump frequency in your case. I just accepted as a necessary evil that once every 15 minutes a write to flash might occur. At least until a reliable way to keep these logs on a partition is found /tmpwhich is located in the router's RAM (accepting all the risks associated with loss of power supply). Perhaps some sensible advice will be in the comments.

For now, let's just dump the logs into zone files and look at the changes:

$ rndc sync && cat /etc/bind/db.lan 
$ORIGIN .
$TTL 0  ; 0 seconds
lan                     IN SOA  ns1.lan. root.lan. (
                                1719490277 ; serial
                                43200      ; refresh (12 hours)
                                900        ; retry (15 minutes)
                                1814400    ; expire (3 weeks)
                                7200       ; minimum (2 hours)
                                )
$TTL 900        ; 15 minutes
                        NS      ns1.lan.
$ORIGIN lan.
acme                    CNAME   openwrt
host2                   A       192.168.1.2
ns1                     A       192.168.1.1
openwrt                 A       192.168.1.1
router                  CNAME   openwrt
$ cat /etc/bind/db.1.168.192 
$ORIGIN .
$TTL 0  ; 0 seconds
1.168.192.in-addr.arpa  IN SOA  ns1.lan. root.lan. (
                                1719490271 ; serial
                                43200      ; refresh (12 hours)
                                900        ; retry (15 minutes)
                                1814400    ; expire (3 weeks)
                                7200       ; minimum (2 hours)
                                )
$TTL 900        ; 15 minutes
                        NS      ns1.lan.
$ORIGIN 1.168.192.in-addr.arpa.
1                       PTR     ns1.lan.
                        PTR     openwrt.lan.
2                       PTR     host2.lan.

As we can see, the records for host2.lan. appeared in both zones.

Run nsupdate on another host

Now let's check how we can change the record from another machine on the network. To do this, we'll transfer the contents /etc/bind/keys.conf to the host connected to the router from the LAN side. We will also generate a file nsupdate.cmd with a list of commands, indicating that the server is located at 192.168.1.1:

server 192.168.1.1 53
zone lan.
update delete host2.lan.
show
send
zone 1.168.192.in-addr.arpa.
update delete 2.1.168.192.in-addr.arpa.
show
send

Let's launch nsupdate:

[rocky@test ~]$ nsupdate -k keys.conf nsupdate.cmd 
Outgoing update query:
;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
;; ZONE SECTION:
;lan.                           IN      SOA

;; UPDATE SECTION:
host2.lan.              0       ANY     ANY

Outgoing update query:
;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
;; ZONE SECTION:
;1.168.192.in-addr.arpa.                IN      SOA

;; UPDATE SECTION:
2.1.168.192.in-addr.arpa. 0     ANY     ANY

There are no mistakes – it worked!

Managing a Zone via Terraform

In principle, it does not matter which utility we manage the zone with. We only need the name and the TSIG key material.
We create main.tf :

# Disclaimer: Storing secrets in plain text within Terraform's configuration
# and state files is strongly discouraged due to the inevitable security risks.
# It is crucial to familiarize yourself with techniques to avoid those.

terraform {
  required_providers {
    dns = {
      source = "hashicorp/dns"
      version = "3.4.1"
    }
  }
}

provider "dns" {
  update {
    server        = "192.168.1.1"
    key_name      = "tsig-key."
    key_algorithm = "hmac-sha256"
    key_secret    = "HAyLN66//YxVF2lrZ6kSZK4TZEpV7WMvzYnNUQ0BvEo="
  }
}

resource "dns_a_record_set" "host100" {
  zone = "lan."
  name = "host100"
  addresses = [
    "192.168.1.100"
  ]
  ttl = 900
}

resource "dns_ptr_record" "ptr_192_168_1_100" {
  zone = "1.168.192.in-addr.arpa."
  name = "100"
  ptr  = "host100.lan."
  ttl = 900
}

Please note the dot (.) in the content key_name. Provider hashicorp/dns requires key names to be formatted as FQDN. In this case, the value of the field name in the resource configuration it must be specified in a shortened form.

We are installing terraform/tofu and run:

[rocky@test ~]$ terraform init
[rocky@test ~]$ terraform plan
[rocky@test ~]$ terraform apply
Terraform will perform the following actions:
  # dns_a_record_set.host100 will be created
  + resource "dns_a_record_set" "host100" {
      + addresses = [
          + "192.168.1.100",
        ]
      + id        = (known after apply)
      + name      = "host100"
      + ttl       = 900
      + zone      = "lan."
    }
  # dns_ptr_record.ptr_192_168_1_100 will be created
  + resource "dns_ptr_record" "ptr_192_168_1_100" {
      + id   = (known after apply)
      + name = "100"
      + ptr  = "host100.lan."
      + ttl  = 900
      + zone = "1.168.192.in-addr.arpa."
    }
Plan: 2 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.
  Enter a value: yes
dns_ptr_record.ptr_192_168_1_100: Creating...
dns_a_record_set.host100: Creating...
dns_a_record_set.host100: Creation complete after 0s [id=host100.lan.]
dns_ptr_record.ptr_192_168_1_100: Creation complete after 0s [id=100.1.168.192.in-addr.arpa.]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Let's go back to the OpenWRT shell and look at the changes in the zones:

$ rndc sync && cat /etc/bind/db.lan 
$ORIGIN .
$TTL 0  ; 0 seconds
lan                     IN SOA  ns1.lan. root.lan. (
                                1719490287 ; serial
                                43200      ; refresh (12 hours)
                                900        ; retry (15 minutes)
                                1814400    ; expire (3 weeks)
                                7200       ; minimum (2 hours)
                                )
$TTL 900        ; 15 minutes
                        NS      ns1.lan.
$ORIGIN lan.
acme                    CNAME   openwrt
host100                 A       192.168.1.100
ns1                     A       192.168.1.1
openwrt                 A       192.168.1.1
router                  CNAME   openwrt
$ cat /etc/bind/db.1.168.192 
$ORIGIN .
$TTL 0  ; 0 seconds
1.168.192.in-addr.arpa  IN SOA  ns1.lan. root.lan. (
                                1719490281 ; serial
                                43200      ; refresh (12 hours)
                                900        ; retry (15 minutes)
                                1814400    ; expire (3 weeks)
                                7200       ; minimum (2 hours)
                                )
$TTL 900        ; 15 minutes
                        NS      ns1.lan.
$ORIGIN 1.168.192.in-addr.arpa.
1                       PTR     ns1.lan.
                        PTR     openwrt.lan.
100                     PTR     host100.lan.

Entries for host100.lan. appeared.

Now let's remove them:

[rocky@test ~]$ terraform destroy
Terraform will perform the following actions:
  # dns_a_record_set.host100 will be destroyed
  - resource "dns_a_record_set" "host100" {
      - addresses = [
          - "192.168.1.100",
        ] -> null
      - id        = "host100.lan." -> null
      - name      = "host100" -> null
      - ttl       = 900 -> null
      - zone      = "lan." -> null
    }
  # dns_ptr_record.ptr_192_168_1_100 will be destroyed
  - resource "dns_ptr_record" "ptr_192_168_1_100" {
      - id   = "100.1.168.192.in-addr.arpa." -> null
      - name = "100" -> null
      - ptr  = "host100.lan." -> null
      - ttl  = 900 -> null
      - zone = "1.168.192.in-addr.arpa." -> null
    }
Plan: 0 to add, 0 to change, 2 to destroy.
Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.
  Enter a value: yes
dns_ptr_record.ptr_192_168_1_100: Destroying... [id=100.1.168.192.in-addr.arpa.]
dns_a_record_set.host100: Destroying... [id=host100.lan.]
dns_a_record_set.host100: Destruction complete after 0s
dns_ptr_record.ptr_192_168_1_100: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.

Automatic registration of resource records for hosts initialized via DHCP

Up until now, changing DNS records has been manually initiated. Let's add some automation for DHCP clients.

OpenWRT has a mechanism hotplugwhich allows you to run custom scripts when certain events occur in various services. We are interested in DHCP events coming from dnsmasqand to run our script we just need to place it in the directory /etc/hotplug.d/dhcp/. The script placed in this directory will be called from /usr/lib/dnsmasq/dhcp-script.shwhich in turn is called by itself dnsmasq.

Our script will be passed information about the DHCP event in variables: $MACADDR, $IPADDR, $HOSTNAME And $ACTION=("add"|"remove"|"update")

Note: In the case where the client does not transmit its name to the DHCP server, there is a nuance with the $HOSTNAME variable.

Let's create a test script /etc/hotplug.d/dhcp/00-hello:

logger "======================="
logger "I've been supplied with this number of arguments: ${#}"
logger "There they are: ${@}"
logger "Here're variables available to me:"
for var_passed in $(set); do
    logger "${var_passed}"
done
logger "======================="

We initiate a DHCP event from the host on the LAN side, but we won't send the hostname:

[rocky@test ~]$ hostname
test

[rocky@test ~]$ sudo dhclient -r eth0 -v
Listening on LPF/eth0/bc:24:11:2a:92:e3
Sending on   LPF/eth0/bc:24:11:2a:92:e3
Sending on   Socket/fallback
DHCPRELEASE of 192.168.1.122 on eth0 to 192.168.1.1 port 67 (xid=0x303d9b4c)

Let's take a look at the command output logread on OpenWRT:

user.notice root: =======================
user.notice root: I've been supplied with this number of arguments: 0
user.notice root: There they are:
user.notice root: Here're variables available to me:
user.notice root: ACTION='remove'
...
user.notice root: HOSTNAME='OpenWrt'
...
user.notice root: IPADDR='192.168.1.122'
...
user.notice root: MACADDR='bc:24:11:2a:92:e3'
...
user.notice root: =======================

As we can see, our script sees instead of an empty line 'OpenWrt' in variable $HOSTNAME.

At the same time, when the client sends the host name, the system variable $HOSTNAME is correctly overridden by the value 'test'.

[rocky@test ~]$ hostname
test

[rocky@test ~]$ sudo dhclient -v -H $(hostname) eth0
Listening on LPF/eth0/bc:24:11:2a:92:e3
Sending on   LPF/eth0/bc:24:11:2a:92:e3
Sending on   Socket/fallback
DHCPDISCOVER on eth0 to 255.255.255.255 port 67 interval 6 (xid=0x4c02d471)
DHCPOFFER of 192.168.1.122 from 192.168.1.1
DHCPREQUEST for 192.168.1.122 on eth0 to 255.255.255.255 port 67 (xid=0x4c02d471)
DHCPACK of 192.168.1.122 from 192.168.1.1 (xid=0x4c02d471)
bound to 192.168.1.122 -- renewal in 17215 seconds.

Command output logread:

user.notice root: =======================
user.notice root: I've been supplied with this number of arguments: 0
user.notice root: There they are:
user.notice root: Here're variables available to me:
user.notice root: ACTION='add'
...
user.notice root: HOSTNAME='test'
...
user.notice root: IPADDR='192.168.1.122'
...
user.notice root: MACADDR='bc:24:11:2a:92:e3'
user.notice root: =======================

I assume that this behavior is intentional, since it is quite easy to filter out client DHCP requests from the router itself.

Let's create a file /etc/hotplug.d/dhcp/00-hello with the following contents:

#!/bin/sh
#set -x 

if [ "${HOSTNAME}" = "$(uci get system.@system[0].hostname)" ] || [ "${HOSTNAME}" = "" ] || [ "${IPADDR}" = "" ]; then
    exit 0
fi

if [ "${ACTION}" != 'remove' ] && [ "${ACTION}" != 'add' ]; then
    exit 0
fi

br_lan_ip=$(ip addr show dev br-lan | awk '/inet / {print $2}' | cut -d'/' -f1)
br_lan_ptr_zone=$(dig -x ${br_lan_ip} SOA | awk '/AUTHORITY SECTION:/ {getline; print $1}')
domain=$(uci get dhcp.@dnsmasq[0].domain)
REVERSE_IP=$(echo "${IPADDR}" | awk -F. '{print $4"."$3"."$2"."$1}')
TTL=900

case "${ACTION}" in
        add)
                logger -p daemon.info -t hotplug.dhcp "Processing \"DHCPAC ${IPADDR} ${MACADDR} ${HOSTNAME}\": Adding RRs for \"${HOSTNAME}.${domain}.\" and \"${REVERSE_IP}.in-addr.arpa.\""
                nsupdate -k /etc/bind/keys.conf <<EOL | logger -p daemon.info -t hotplug.dhcp
                server 127.0.0.1 53
                zone ${domain}.
                update delete ${HOSTNAME}.${domain}.
                update add ${HOSTNAME}.${domain}. ${TTL} A ${IPADDR}
                show
                send
                zone ${br_lan_ptr_zone}
                update delete ${REVERSE_IP}.in-addr.arpa.
                update add ${REVERSE_IP}.in-addr.arpa. ${TTL} PTR ${HOSTNAME}.${domain}.
                show
                send
EOL
#                rndc sync
        ;;
        remove)
                logger -p daemon.info -t hotplug.dhcp "Processing \"DHCPRELEASE ${IPADDR} ${MACADDR}\": Removing RRs for \"${HOSTNAME}.${domain}.\" and \"${REVERSE_IP}.in-addr.arpa.\""
                nsupdate -k /etc/bind/keys.conf <<EOL | logger -p daemon.info -t hotplug.dhcp
                server 127.0.0.1 53
                zone ${domain}.
                update delete ${HOSTNAME}.${domain}.
                show
                send
                zone ${br_lan_ptr_zone}
                update delete ${REVERSE_IP}.in-addr.arpa.
                show
                send
EOL
#                rndc sync
        ;;
esac
exit 0

Restarting dnsmasq :

$ /etc/init.d/dnsmasq restart

We check the execution of the script by running the following commands on the host connected to the LAN interface of the router:

[rocky@test ~]$ hostname
test

[rocky@test ~]$ sudo dhclient -v -H $(hostname) eth0 -r
Listening on LPF/eth0/bc:24:11:2a:92:e3
Sending on   LPF/eth0/bc:24:11:2a:92:e3
Sending on   Socket/fallback
DHCPRELEASE of 192.168.1.122 on eth0 to 192.168.1.1 port 67 (xid=0x6f71f069)

[rocky@test ~]$ sudo dhclient -v -H $(hostname) eth0 
Listening on LPF/eth0/bc:24:11:2a:92:e3
Sending on   LPF/eth0/bc:24:11:2a:92:e3
Sending on   Socket/fallback
DHCPDISCOVER on eth0 to 255.255.255.255 port 67 interval 7 (xid=0x46faa10b)
DHCPOFFER of 192.168.1.122 from 192.168.1.1
DHCPREQUEST for 192.168.1.122 on eth0 to 255.255.255.255 port 67 (xid=0x46faa10b)
DHCPACK of 192.168.1.122 from 192.168.1.1 (xid=0x46faa10b)
bound to 192.168.1.122 -- renewal in 20468 seconds.

while monitoring the system logs in OpenWRT:

$ logread -f 
...
dnsmasq-dhcp[1]: DHCPRELEASE(br-lan) 192.168.1.122 bc:24:11:2a:92:e3
hotplug.dhcp: Processing "DHCPRELEASE 192.168.1.122 bc:24:11:2a:92:e3": Adding RRs for "test.lan." and "122.1.168.192.in-addr.arpa."
hotplug.dhcp: Outgoing update query:
hotplug.dhcp: ;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
hotplug.dhcp: ;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
hotplug.dhcp: ;; ZONE SECTION:
hotplug.dhcp: ;lan.                                IN      SOA
hotplug.dhcp: ;; UPDATE SECTION:
hotplug.dhcp: test.lan.            0       ANY     ANY
named[1681]: client @0xffff95f64280 127.0.0.1#44123/key tsig-key: signer "tsig-key" approved
named[1681]: client @0xffff95f64280 127.0.0.1#44123/key tsig-key: updating zone 'lan/IN': delete all rrsets from name 'test.lan'
hotplug.dhcp: Outgoing update query:
hotplug.dhcp: ;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
hotplug.dhcp: ;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
hotplug.dhcp: ;; ZONE SECTION:
hotplug.dhcp: ;1.168.192.in-addr.arpa.             IN      SOA
hotplug.dhcp: ;; UPDATE SECTION:
hotplug.dhcp: 122.1.168.192.in-addr.arpa. 0        ANY     ANY
named[1681]: client @0xffff95de81d0 127.0.0.1#58206/key tsig-key: signer "tsig-key" approved
named[1681]: client @0xffff95de81d0 127.0.0.1#58206/key tsig-key: updating zone '1.168.192.in-addr.arpa/IN': delete all rrsets from name '122.1.168.192.in-addr.arpa'
...
dnsmasq-dhcp[1]: DHCPDISCOVER(br-lan) 192.168.1.122 bc:24:11:2a:92:e3
dnsmasq-dhcp[1]: DHCPOFFER(br-lan) 192.168.1.122 bc:24:11:2a:92:e3
dnsmasq-dhcp[1]: DHCPREQUEST(br-lan) 192.168.1.122 bc:24:11:2a:92:e3
dnsmasq-dhcp[1]: DHCPACK(br-lan) 192.168.1.122 bc:24:11:2a:92:e3 test
hotplug.dhcp: Processing "DHCPAC 192.168.1.122 bc:24:11:2a:92:e3 test": Adding RRs for "test.lan." and "122.1.168.192.in-addr.arpa."
hotplug.dhcp: Outgoing update query:
hotplug.dhcp: ;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
hotplug.dhcp: ;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
hotplug.dhcp: ;; ZONE SECTION:
hotplug.dhcp: ;lan.                                IN      SOA
hotplug.dhcp: ;; UPDATE SECTION:
hotplug.dhcp: test.lan.            0       ANY     ANY
hotplug.dhcp: test.lan.            900     IN      A       192.168.1.122
named[1681]: client @0xffff95f64280 127.0.0.1#46151/key tsig-key: signer "tsig-key" approved
named[1681]: client @0xffff95f64280 127.0.0.1#46151/key tsig-key: updating zone 'lan/IN': delete all rrsets from name 'test.lan'
named[1681]: client @0xffff95f64280 127.0.0.1#46151/key tsig-key: updating zone 'lan/IN': adding an RR at 'test.lan' A 192.168.1.122
hotplug.dhcp: Outgoing update query:
hotplug.dhcp: ;; ->>HEADER<<- opcode: UPDATE, status: NOERROR, id:      0
hotplug.dhcp: ;; flags:; ZONE: 0, PREREQ: 0, UPDATE: 0, ADDITIONAL: 0
hotplug.dhcp: ;; ZONE SECTION:
hotplug.dhcp: ;1.168.192.in-addr.arpa.             IN      SOA
hotplug.dhcp: ;; UPDATE SECTION:
hotplug.dhcp: 122.1.168.192.in-addr.arpa. 0        ANY     ANY
hotplug.dhcp: 122.1.168.192.in-addr.arpa. 900      IN      PTR     test.lan.
named[1681]: client @0xffff95f64280 127.0.0.1#39719/key tsig-key: signer "tsig-key" approved
named[1681]: client @0xffff95f64280 127.0.0.1#39719/key tsig-key: updating zone '1.168.192.in-addr.arpa/IN': delete all rrsets from name '122.1.168.192.in-addr.arpa'
named[1681]: client @0xffff95f64280 127.0.0.1#39719/key tsig-key: updating zone '1.168.192.in-addr.arpa/IN': adding an RR at '122.1.168.192.in-addr.arpa' PTR test.lan.
...

Judging by the logs, direct and reverse records for the host should have appeared in DNS test.lan with address 192.168.1.122. We check:

[rocky@test ~]$ nslookup test.lan
Server:         192.168.1.1
Address:        192.168.1.1#53
Name:   test.lan
Address: 192.168.1.122

This concludes the setup.

Instead of a conclusion

Okay, now we have our own DNS server with support for dynamic zones and TSIG authentication. What's next?

And then we will build our Let's Encrypt in OpenWRT TLS-ALPN-01 And DNS-01.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *