diff options
author | Rob Austein <sra@hactrn.net> | 2025-06-06 22:27:49 +0000 |
---|---|---|
committer | Rob Austein <sra@hactrn.net> | 2025-06-06 22:28:58 +0000 |
commit | 0e17c12b16a471c79333d9e684a9bd56a082615a (patch) | |
tree | dd0343c193327488f5201dff2254cdb3f4c8b779 |
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README.md | 102 | ||||
-rwxr-xr-x | rzc.py | 130 | ||||
-rw-r--r-- | rzc.toml | 37 |
4 files changed, 271 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06f29c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +cache +output diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ce78bf --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Reverse Zone Compiler (rzc) + +This is a tool to automate the process of maintaining reverse DNS +zones given a collection of forward zones. Primary use case is a +small operation that runs a few POPs full of VMs for researchers, +friends, and various public benefit projects: keeping track of the DNS +names of all of one's tenents in this case is a pain, and the desired +data is already present in the forward zones, so an automatic tool to +construct the reverse zone(s) may help. + +Basic model is to configure this tool as a DNS XFR client for all the +relevant forward zones, and to specify the reverse zones to be +generated. The tool then downloads all of the specified forward +zones, trawls them for A and AAAA RRs, and translates those into PTR +RRs, throwing away any PTR RRs that don't fall within the specified +set of reverse zones. After exhausting this process, the tool writes +out a set of reverse zone files and exits. + +The tool caches local copies of the downloaded forward zones, both to +allow it to ride out temporary transfer failures and to allow it to +use IXFR rather than AXFR to save bandwidth when conditions permit. + +All of the heavy lifting is done by Bob Halley's excellent `dnspython` +library. You will also need the TOML library. + + apt install python3-toml python3-dnspython + +All configuration is stored in a TOML file. The default name of the +TOML file is the same as the tool, with the `.py` suffix changed to +`.toml`, but one can override this by passing the name of the +configuration file via the `RZC_CONFIG` environment variable or on the +command line. + +Sample configuration file: + + [output] + + directory = "output" + ttl = 3600 + soa.mname = "localhost" + soa.rname = "ns0.example.org" + soa.refresh = 10803 + soa.retry = 900 + soa.expire = 604800 + soa.minimum = 600 + + ns = [ + "ns1.example.org", + "ns2.example.org", + ] + + zones = [ + "1.0.10.in-addr.arpa", + "2.0.10.in-addr.arpa", + "3.0.10.in-addr.arpa", + "1.0.a.2.0.0.2.ip6.arpa", + "2.0.a.2.0.0.2.ip6.arpa", + "3.0.a.2.0.0.2.ip6.arpa", + ] + + [input] + xfr_timeout = 300 + cache = "cache" + + [[input.zone]] + name = "fred.example" + server = "10.100.0.1" + tsig = { "tsig.example.org" = ["hmac-sha256", "36A2xRALPymYl3Q7axrtAk1AICJyPhu2KkK7PxJhTQ8=" ] } + + [[input.zone]] + name = "barney.example" + server = "10.200.0.1" + +Most of this should be self-explanatory. The configuration is divided +into two sections, `input` and `output`: `input` is about fetching the +forward zones, `output` is about generating the reverse zones. + +`input.cache` is where cached copies of the forward zones will be +stored. `output.directory` is where the reverse zones files will be +written. Both directories will be created if needed. + +`output.ns` is the list of nameservers to be listed in `NS` RRs in the +output zones. Similarly, `output.soa` gives all the parameters needed +for the `SOA` rdata, except for the `serial` value, which will be +generated automatically using the seconds-since-epoch convention. + +`output.zones` is the list of reverse zones to be generated. + +Each `[[input.zone]]` block defines one forward zone to be retrieved. +`name` is the name of the zone, `server` is the IPv4 or IPv6 address +from which to retrieve the zone. `tsig`, if given, specifies the +`TSIG` key data to use when retrieving this zone: the syntax is a +little weird, because it's a direct TOML representation of the form +`dnspython` uses for a single-entry TSIG keyring, the general form is + + tsig = { "name.of.tsig.key" = [ "tsig-key-type", "tsig-secret-encoded-as-base64" ] } + +Since the TSIG secrets are embedded directly in the TOML file, you +might want to `chmod` the TOML file to restrict unauthorized readers. + +At some point I might add validation of the TOML via via `jsonschema`, +but the schema required would nearly double the size of the program. @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +""" +Reverse Zone Compiler (document this...) +""" + +from argparse import ArgumentParser, FileType, ArgumentDefaultsHelpFormatter +from datetime import datetime, UTC +from pathlib import Path +from os import getenv +from sys import argv + +from dns.rdatatype import A, AAAA, SOA, NS, PTR, CNAME +from dns.rdataclass import IN +from dns.node import NodeKind + +import dns.rdtypes.ANY.CNAME +import dns.rdtypes.ANY.SOA +import dns.rdtypes.ANY.PTR +import dns.tsigkeyring +import dns.reversename +import dns.rdataclass +import dns.rdatatype +import dns.rdataset +import dns.query +import dns.rdata +import dns.name +import dns.zone + +import toml + +class Main: + + def __init__(self): + self.jane = Path(argv[0]).resolve() + + ap = ArgumentParser(formatter_class = ArgumentDefaultsHelpFormatter) + ap.add_argument("-c", "--config", type = FileType("r"), + default = getenv("RZC_CONFIG", f"{self.jane.stem}.toml"), + help = "TOML configuration file") + args = ap.parse_args() + + self.cfg = toml.load(args.config, JinjaDict) + self.now = datetime.now(UTC) + + self.output_dir = Path(self.cfg.output.directory) + self.cache_dir = Path(self.cfg.input.cache) + + self.fetch_input_zones() + self.initialize_output_zones() + self.populate_output_zones() + self.write_output_zones() + + def fetch_input_zones(self): + self.cache_dir.mkdir(parents = True, exist_ok = True) + self.forward = [] + + for iz in self.cfg.input.zone: + origin = dns.name.from_text(iz.name) + path = self.cache_dir / origin.to_text(omit_final_dot = True) + try: + with path.open("r") as f: + fz = dns.zone.from_file(f, origin = origin, relativize = False) + except: + fz = dns.zone.Zone(origin = origin, relativize = False) + try: + keyring = dns.tsigkeyring.from_text(iz.tsig) if iz.tsig else None + query, serial = dns.xfr.make_query(txn_manager = fz, keyring = keyring) + dns.query.inbound_xfr(where = iz.server, txn_manager = fz, query = query) + except Exception as e: + print(f"Unable to XFR {iz.name}: {e}") + continue + with path.open("w") as f: + fz.to_file(f, sorted = True, relativize = False) + print(f"Updated {iz.name}") + self.forward.append(fz) + + def initialize_output_zones(self): + self.reverse = [] + + for oz in self.cfg.output.zones: + rz = dns.zone.Zone(dns.name.from_text(oz), rdclass = IN, relativize = False) + apex = rz.find_node(rz.origin, create = True) + apex.replace_rdataset( + dns.rdataset.from_rdata( + self.cfg.output.ttl, + dns.rdtypes.ANY.SOA.SOA( + IN, SOA, + dns.name.from_text(self.cfg.output.soa.mname), + dns.name.from_text(self.cfg.output.soa.rname), + int(self.now.timestamp()), + self.cfg.output.soa.refresh, self.cfg.output.soa.retry, + self.cfg.output.soa.expire, self.cfg.output.soa.minimum))) + apex.replace_rdataset( + dns.rdataset.from_text_list( + IN, NS, self.cfg.output.ttl, self.cfg.output.ns, + relativize = False, origin = dns.name.root)) + rz.check_origin() + self.reverse.append(rz) + + def populate_output_zones(self): + for fz in self.forward: + for qtype in (A, AAAA): + for name, ttl, addr in fz.iterate_rdatas(qtype): + name = name.derelativize(fz.origin) + rname = dns.reversename.from_address(addr.to_text()) + rdata = dns.rdtypes.ANY.PTR.PTR(IN, PTR, name) + for rz in self.reverse: + if rname.is_subdomain(rz.origin): + rz.find_rdataset(rname, PTR, create = True).add(rdata, ttl) + break + + def write_output_zones(self): + self.output_dir.mkdir(parents = True, exist_ok = True) + for rz in self.reverse: + path = self.output_dir / rz.origin.to_text(omit_final_dot = True) + with path.open("w") as f: + f.write(f";; Automatically generated {self.now} by {self.jane}. Do not edit.\n\n") + rz.to_file(f, sorted = True, relativize = False) + print(f"Wrote {path}") + +class JinjaDict(dict): + def __getattr__(self, name): + try: + return self[name] + except KeyError: + return None + +if __name__ == "__main__": + Main() diff --git a/rzc.toml b/rzc.toml new file mode 100644 index 0000000..e88b275 --- /dev/null +++ b/rzc.toml @@ -0,0 +1,37 @@ +[output] + +directory = "output" +ttl = 3600 +soa.mname = "localhost" +soa.rname = "ns0.example.org" +soa.refresh = 10803 +soa.retry = 900 +soa.expire = 604800 +soa.minimum = 600 + +ns = [ + "ns1.example.org", + "ns2.example.org", +] + +zones = [ + "1.0.10.in-addr.arpa", + "2.0.10.in-addr.arpa", + "3.0.10.in-addr.arpa", + "1.0.a.2.0.0.2.ip6.arpa", + "2.0.a.2.0.0.2.ip6.arpa", + "3.0.a.2.0.0.2.ip6.arpa", +] + +[input] +xfr_timeout = 300 +cache = "cache" + +[[input.zone]] +name = "fred.example" +server = "10.100.0.1" +tsig = { "tsig.example.org" = ["hmac-sha256", "36A2xRALPymYl3Q7axrtAk1AICJyPhu2KkK7PxJhTQ8=" ] } + +[[input.zone]] +name = "barney.example" +server = "10.200.0.1" |