I have been using DNSMasq as my local network DNS forwarders, one reason being the easy to manage dnsmasq.hosts file. I decided to move to unbound DNS server because it has more features. I however wanted to keep the easy maintenance of my IP addresses and hostnames. So I wrote a Python script (with help from Google Search AI).

I first searched Google for a script and its AI proposed a script as you can see at the left. I used the generated script as basis for my script.

The AI generated as ok but lacking on two respects: it did not generate reverse lookup zone and did not support multiple hostnames for each IP address. The script also had hardcoded SOA record.

I added the features I was missing. The below script now takes 5 parameters:

  • hosts file, in my case /etc/unbound/zones/hosts, that contain IP address and hostname entries in the same format as used by /etc/hosts
  • zone file name (e.g. /etc/unbound/zones/example.com.zone)
  • reverse lookup zone file name (e.g. /etc/unbound/zones/rev-192.168.zone)
  • domain name (e.g. example.com)
  • reverse lookup domain name (e.g. 168.192.in-addr.arpa)

The script expects two files to be present:

  • main zone SOA declaration in zone file name appended with .header
  • reverse zone SOA declaration in the reverse zone name appended with .header
#!/usr/bin/python3

import re
import os
import sys

def hosts_to_zone(hosts_file, zone_file, reverse_file, domain, reverse_domain):
    try:
        with open(hosts_file, 'r') as infile, open(zone_file + "-new", 'w') as zonefile, open(zone_file + ".header", 'r') as zoneheader, open(reverse_file + "-new", 'w') as reversefile, open(reverse_file + ".header", 'r') as reverseheader:
            # Write the zone file header
            for line in zoneheader:
                zonefile.write(line)
            for line in reverseheader:
                reversefile.write(line)
            # Read the hosts file and parse each line
            for line in infile:
                # Skip comments and empty lines
                if line.startswith('#') or not line.strip():
                    continue

                # Extract the IP address and hostname
                match = re.match(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+(.*)$', line)
                if not match:
                    continue
                ip_address = match.group(1)
                hostnames = re.split('\s+', match.group(2).strip())

                for hostname in hostnames:
                   # Write the zone file record
                   host = hostname.removesuffix(".").removesuffix(domain)
                   zonefile.write(f"{hostname} IN A {ip_address}\n")

                octets = re.match(r'(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})', ip_address)
                inaddr = octets.group(4) + "." + octets.group(3) + "." + octets.group(2) + "." + octets.group(1) + ".in-addr.arpa"

                rev = inaddr.removesuffix(".").removesuffix(reverse_domain).removesuffix(".")
                firsthost = hostnames[0]
                reversefile.write(f"{rev} IN PTR {firsthost}\n")
    except Exception as e:
        print(f"An error occurred: {e}")
        try:
           os.remove(zone_file + "-new")
        except Exception as e:
            pass
        os.remove(reverse_file + "-new")
        sys.exit(1)

    if os.path.exists(zone_file + "-new"):
        try:
            os.remove(zone_file)
        except Exception as e:
            pass
            os.rename(zone_file + "-new", zone_file)
    if os.path.exists(reverse_file + "-new"):
        try:
            os.remove(reverse_file)
        except Exception as e:
            pass
        os.rename(reverse_file + "-new", reverse_file)

if len(sys.argv) != 6:
    print("Usage: " + sys.argv[0] + " hosts-file zone-file reverse-file domain-name reverse-domain\n" + \
          "E.g.: " + sys.argv[0] + " /etc/unbound/zones/hosts /etc/unbound/zones/example.com /etc/unbound/zones/rev-192.168 example.com 168.192.in-addr.arpa\n" + \
          "zone file with .header suffix are prepended the zone file.\n")
    sys.exit(1)

hosts_file = sys.argv[1]           # /etc/unbound/zones/hosts
zone_file = sys.argv[2]            # /etc/unbound/zones/example.com.zone
reverse_file = sys.argv[3]         # /etc/unbound/zones/rev-192.168.zone
domain_name = sys.argv[4]          # example.com
reverse_name = sys.argv[5]         # 168.192.in-addr.arpa


hosts_to_zone(hosts_file, zone_file, reverse_file, domain_name, reverse_name)
#EOF