TSIG authenticated zone transfers with Perl and Net::DNS

I wanted to transfer DNS zones with Perl using a TSIG key, but found no howto. Thus, this describes how to setup dynamic DNS zones with BIND, update them via nsupdate, and transfer them with Perl and Net::DNS.

  1. Create a minimal zone file for your dynamic zone, e.g. named dyn.test.invalid, and save it to /usr/local/etc/namedb/dynamic/dyn.test.invalid. This assumes your nameserver is ns1.test.invalid:
    $TTL		86400
    @		SOA	ns1.test.invalid. hostmaster.test.invalid. 2022082501 16384s 2048s 1048576s 2560s
    @		NS	ns1.test.invalid.
    
    If you run the test.invalid domain as well, then add a NS glue record to that zone:
    ...
    dyn		NS	ns1
    ...
    
  2. Generate a secret key:
    # dd if=/dev/random bs=1 count=64 | openssl base64 -e -A
    rLY9................................................................................KQ==
    
  3. Add this to your /usr/local/etc/namedb/named.conf:
    key "dyn.test.invalid" {
      algorithm hmac-sha512;
      secret "rLY9................................................................................KQ==";
    };
    zone "dyn.test.invalid" {
      type master;
      file "/usr/local/etc/namedb/dynamic/dyn.test.invalid";
      allow-transfer {
        key "dyn.test.invalid";
      };
      update-policy {
        grant "dyn.test.invalid" zonesub;
      };
    };
    
    This allows anyone, who knows the secret key, to transfer and to update the dyn.test.invalid zone.
  4. Instead of generating the secret key manually, you might use tsig-keygen, which outputs a key configuration block:
    # tsig-keygen -a hmac-sha512 dyn.test.invalid
    key "dyn.test.invalid" {
    	algorithm hmac-sha512;
    	secret "rLY9................................................................................KQ==";
    };
    
  5. Save the key configuration block to a file, e.g. dyn.test.invalid.key:
    # cat > dyn.test.invalid.key
    key "dyn.test.invalid" {
    	algorithm hmac-sha512;
    	secret "rLY9................................................................................KQ==";
    };
    ^D
    
    You might have piped the output of tsig-keygen to tee:
    # tsig-keygen -a hmac-sha512 dyn.test.invalid | tee dyn.test.invalid.key
    
  6. Reload the nameserver configuration:
    # rndc reload
    
  7. Verify that your nameserver answers queries for specific records of that zone:
    # dig @ns1.test.invalid dyn.test.invalid NS +short
    ns1.test.invalid.
    
    # dig @ns1.test.invalid dyn.test.invalid SOA +short
    ns1.test.invalid. hostmaster.test.invalid. 2022082501 16384 2048 1048576 2560
    
  8. Try to transfer that zone without providing the key:
    # dig @ns1.test.invalid dyn.test.invalid AXFR +short
    ; Transfer failed.
    
  9. Now use the key file:
    # dig -k dyn.test.invalid.key @ns1.test.invalid dyn.test.invalid AXFR
    
    ; <<>> DiG 9.16.32 <<>> -k dyn.test.invalid.key @ns1.test.invalid dyn.test.invalid AXFR
    ; (1 server found)
    ;; global options: +cmd
    
    dyn.test.invalid.	86400	IN	SOA	ns1.test.invalid. hostmaster.test.invalid. 2022082501 16384 2048 1048576 2560
    dyn.test.invalid.	86400	IN	NS	ns1.test.invalid.
    dyn.test.invalid.	86400	IN	SOA	ns1.test.invalid. hostmaster.test.invalid. 2022082501 16384 2048 1048576 2560
    dyn.test.invalid.	0	ANY	TSIG	hmac-sha512 ... NOERROR 0
    ;; ....
    
  10. Add a new TXT record to that zone:
    # nsupdate -k dyn.test.invalid.key
    > server ns1.test.invalid
    > update add dyn.test.invalid 23 TXT "hello, world"
    > send
    > quit
    
  11. Query the SOA record whose serial number has automatically been incremented:
    # dig @ns1.test.invalid dyn.test.invalid SOA +short
    ns1.test.invalid. hostmaster.test.invalid. 2022082502 16384 2048 1048576 2560
    
    And check the newly added TXT record as well:
    # dig @ns1.test.invalid dyn.test.invalid TXT +short
    "hello, world"
    
  12. Create a Perl script, e.g. tsig.pl:
    #!/usr/bin/perl -w
    
    use strict;
    use warnings;
    use Net::DNS;
    
    my $resolver = Net::DNS::Resolver->new(
      nameservers => [ "ns1.test.invalid", ],
    );
    
    my $tsig = Net::DNS::RR->new(
      owner     => "dyn.test.invalid",
      type      => "TSIG",
      algorithm => "hmac-sha512",
      key       => "rLY9................................................................................KQ==",
    );
    
    $resolver->tsig($tsig);
    
    my @records = $resolver->axfr("dyn.test.invalid");
    
    foreach my $rr(@records) {
      $rr->print;
    }
    
  13. Make that script executable and run it:
    # chmod 0755 tsig.pl
    # ./tsig.pl
    dyn.test.invalid.	86400	IN	SOA	( ns1.test.invalid. hostmaster.test.invalid.
    				2022082502	;serial
    				16384		;refresh
    				2048		;retry
    				1048576		;expire
    				2560		;minimum
    	)
    dyn.test.invalid.	23	IN	TXT	"hello, world"
    dyn.test.invalid.	86400	IN	NS	ns1.test.invalid.
    
  14. Now edit tsig.pl, comment out line #18 in which the TSIG record is added to the resolver, and run it again in order to get no output:
    # ./tsig.pl