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