aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md102
-rwxr-xr-xrzc.py130
-rw-r--r--rzc.toml37
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.
diff --git a/rzc.py b/rzc.py
new file mode 100755
index 0000000..da1107d
--- /dev/null
+++ b/rzc.py
@@ -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"