#!/usr/bin/env python3 # # Small library and commandline tool to do logical diffs of zonefiles # ./zonediff -h gives you help output # # Requires dnspython to do all the heavy lifting # # (c)2009 Dennis Kaarsemaker # # Permission to use, copy, modify, and distribute this software and its # documentation for any purpose with or without fee is hereby granted, # provided that the above copyright notice and this permission notice # appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """See diff_zones.__doc__ for more information""" from typing import cast, Union, Any # pylint: disable=unused-import __all__ = ["diff_zones", "format_changes_plain", "format_changes_html"] try: import dns.zone import dns.node except ImportError: raise SystemExit("Please install dnspython") def diff_zones( zone1, # type: dns.zone.Zone zone2, # type: dns.zone.Zone ignore_ttl=False, ignore_soa=False, ): # type: (...) -> list """diff_zones(zone1, zone2, ignore_ttl=False, ignore_soa=False) -> changes Compares two dns.zone.Zone objects and returns a list of all changes in the format (name, oldnode, newnode). If ignore_ttl is true, a node will not be added to this list if the only change is its TTL. If ignore_soa is true, a node will not be added to this list if the only changes is a change in a SOA Rdata set. The returned nodes do include all Rdata sets, including unchanged ones. """ changes = [] for name in zone1: namestr = str(name) n1 = cast(dns.node.Node, zone1.get_node(namestr)) n2 = cast(dns.node.Node, zone2.get_node(namestr)) if not n2: changes.append((str(name), n1, n2)) elif _nodes_differ(n1, n2, ignore_ttl, ignore_soa): changes.append((str(name), n1, n2)) for name in zone2: n3 = cast(dns.node.Node, zone1.get_node(name)) if not n3: n4 = cast(dns.node.Node, zone2.get_node(name)) changes.append((str(name), n3, n4)) return changes def _nodes_differ( n1, # type: dns.node.Node n2, # type: dns.node.Node ignore_ttl, # type: bool ignore_soa, # type: bool ): # type: (...) -> bool if ignore_soa or not ignore_ttl: # Compare datasets directly for r in n1.rdatasets: if ignore_soa and r.rdtype == dns.rdatatype.SOA: continue if r not in n2.rdatasets: return True if not ignore_ttl: return r.ttl != n2.find_rdataset(r.rdclass, r.rdtype).ttl for r in n2.rdatasets: if ignore_soa and r.rdtype == dns.rdatatype.SOA: continue if r not in n1.rdatasets: return True assert False else: return n1 != n2 def format_changes_plain( oldf, # type: str newf, # type: str changes, # type: list ignore_ttl=False, ): # type: (...) -> str """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str Given 2 filenames and a list of changes from diff_zones, produce diff-like output. If ignore_ttl is True, TTL-only changes are not displayed""" ret = "--- {}\n+++ {}\n".format(oldf, newf) for name, old, new in changes: ret += "@ %s\n" % name if not old: for r in new.rdatasets: ret += "+ %s\n" % str(r).replace("\n", "\n+ ") elif not new: for r in old.rdatasets: ret += "- %s\n" % str(r).replace("\n", "\n+ ") else: for r in old.rdatasets: if r not in new.rdatasets or ( r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl ): ret += "- %s\n" % str(r).replace("\n", "\n+ ") for r in new.rdatasets: if r not in old.rdatasets or ( r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl ): ret += "+ %s\n" % str(r).replace("\n", "\n+ ") return ret def format_changes_html( oldf, # type: str newf, # type: str changes, # type: list ignore_ttl=False, ): # type: (...) -> str """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str Given 2 filenames and a list of changes from diff_zones, produce nice html output. If ignore_ttl is True, TTL-only changes are not displayed""" ret = """\n""" % ( oldf, newf, ) for name, old, new in changes: ret += ' \n \n' % name if not old: for r in new.rdatasets: ret += ( ' \n' ' \n' ) % str(r).replace("\n", "
") elif not new: for r in old.rdatasets: ret += ( ' \n' ' \n' ) % str(r).replace("\n", "
") else: ret += ' \n" ret += ' \n" ret += " \n" return ret + " \n
  %s %s
%s %s%s ' for r in old.rdatasets: if r not in new.rdatasets or ( r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl ): ret += str(r).replace("\n", "
") ret += "
' for r in new.rdatasets: if r not in old.rdatasets or ( r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl ): ret += str(r).replace("\n", "
") ret += "
" # Make this module usable as a script too. def main(): # type: () -> None import argparse import subprocess import sys import traceback usage = """%prog zonefile1 zonefile2 - Show differences between zones in a diff-like format %prog [--git|--bzr|--rcs] zonefile rev1 [rev2] - Show differences between two revisions of a zonefile The differences shown will be logical differences, not textual differences. """ p = argparse.ArgumentParser(usage=usage) p.add_argument( "-s", "--ignore-soa", action="store_true", default=False, dest="ignore_soa", help="Ignore SOA-only changes to records", ) p.add_argument( "-t", "--ignore-ttl", action="store_true", default=False, dest="ignore_ttl", help="Ignore TTL-only changes to Rdata", ) p.add_argument( "-T", "--traceback", action="store_true", default=False, dest="tracebacks", help="Show python tracebacks when errors occur", ) p.add_argument( "-H", "--html", action="store_true", default=False, dest="html", help="Print HTML output", ) p.add_argument( "-g", "--git", action="store_true", default=False, dest="use_git", help="Use git revisions instead of real files", ) p.add_argument( "-b", "--bzr", action="store_true", default=False, dest="use_bzr", help="Use bzr revisions instead of real files", ) p.add_argument( "-r", "--rcs", action="store_true", default=False, dest="use_rcs", help="Use rcs revisions instead of real files", ) opts, args = p.parse_args() opts.use_vc = opts.use_git or opts.use_bzr or opts.use_rcs def _open(what, err): # type: (Union[list,str], str) -> Any if isinstance(what, list): # Must be a list, open subprocess try: proc = subprocess.Popen(what, stdout=subprocess.PIPE) proc.wait() if proc.returncode == 0: return proc.stdout sys.stderr.write(err + "\n") except Exception: sys.stderr.write(err + "\n") if opts.tracebacks: traceback.print_exc() else: # Open as normal file try: return open(what, "rb") except IOError: sys.stderr.write(err + "\n") if opts.tracebacks: traceback.print_exc() if not opts.use_vc and len(args) != 2: p.print_help() sys.exit(64) if opts.use_vc and len(args) not in (2, 3): p.print_help() sys.exit(64) # Open file descriptors if not opts.use_vc: oldn, newn = args else: if len(args) == 3: filename, oldr, newr = args oldn = "{}:{}".format(oldr, filename) newn = "{}:{}".format(newr, filename) else: filename, oldr = args newr = None oldn = "{}:{}".format(oldr, filename) newn = filename old, new = None, None oldz, newz = None, None if opts.use_bzr: old = _open( ["bzr", "cat", "-r" + oldr, filename], "Unable to retrieve revision {} of {}".format(oldr, filename), ) if newr is not None: new = _open( ["bzr", "cat", "-r" + newr, filename], "Unable to retrieve revision {} of {}".format(newr, filename), ) elif opts.use_git: old = _open( ["git", "show", oldn], "Unable to retrieve revision {} of {}".format(oldr, filename), ) if newr is not None: new = _open( ["git", "show", newn], "Unable to retrieve revision {} of {}".format(newr, filename), ) elif opts.use_rcs: old = _open( ["co", "-q", "-p", "-r" + oldr, filename], "Unable to retrieve revision {} of {}".format(oldr, filename), ) if newr is not None: new = _open( ["co", "-q", "-p", "-r" + newr, filename], "Unable to retrieve revision {} of {}".format(newr, filename), ) if not opts.use_vc: old = _open(oldn, "Unable to open %s" % oldn) if not opts.use_vc or newr is None: new = _open(newn, "Unable to open %s" % newn) if not old or not new: sys.exit(65) # Parse the zones try: oldz = dns.zone.from_file(old, origin=".", check_origin=False) except dns.exception.DNSException: sys.stderr.write("Incorrect zonefile: %s\n" % old) if opts.tracebacks: traceback.print_exc() try: newz = dns.zone.from_file(new, origin=".", check_origin=False) except dns.exception.DNSException: sys.stderr.write("Incorrect zonefile: %s\n" % new) if opts.tracebacks: traceback.print_exc() if not oldz or not newz: sys.exit(65) changes = diff_zones(oldz, newz, opts.ignore_ttl, opts.ignore_soa) changes.sort() if not changes: sys.exit(0) if opts.html: print(format_changes_html(oldn, newn, changes, opts.ignore_ttl)) else: print(format_changes_plain(oldn, newn, changes, opts.ignore_ttl)) sys.exit(1) if __name__ == "__main__": main()