1#!/usr/bin/env python3 2# 3# Small library and commandline tool to do logical diffs of zonefiles 4# ./zonediff -h gives you help output 5# 6# Requires dnspython to do all the heavy lifting 7# 8# (c)2009 Dennis Kaarsemaker <dennis@kaarsemaker.net> 9# 10# Permission to use, copy, modify, and distribute this software and its 11# documentation for any purpose with or without fee is hereby granted, 12# provided that the above copyright notice and this permission notice 13# appear in all copies. 14# 15# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 16# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 17# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 18# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 19# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 20# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 21# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 22"""See diff_zones.__doc__ for more information""" 23 24from typing import cast, Union, Any # pylint: disable=unused-import 25 26__all__ = ['diff_zones', 'format_changes_plain', 'format_changes_html'] 27 28try: 29 import dns.zone 30 import dns.node 31except ImportError: 32 raise SystemExit("Please install dnspython") 33 34 35def diff_zones(zone1, # type: dns.zone.Zone 36 zone2, # type: dns.zone.Zone 37 ignore_ttl=False, 38 ignore_soa=False 39 ): # type: (...) -> list 40 """diff_zones(zone1, zone2, ignore_ttl=False, ignore_soa=False) -> changes 41 Compares two dns.zone.Zone objects and returns a list of all changes 42 in the format (name, oldnode, newnode). 43 44 If ignore_ttl is true, a node will not be added to this list if the 45 only change is its TTL. 46 47 If ignore_soa is true, a node will not be added to this list if the 48 only changes is a change in a SOA Rdata set. 49 50 The returned nodes do include all Rdata sets, including unchanged ones. 51 """ 52 53 changes = [] 54 for name in zone1: 55 namestr = str(name) 56 n1 = cast(dns.node.Node, zone1.get_node(namestr)) 57 n2 = cast(dns.node.Node, zone2.get_node(namestr)) 58 if not n2: 59 changes.append((str(name), n1, n2)) 60 elif _nodes_differ(n1, n2, ignore_ttl, ignore_soa): 61 changes.append((str(name), n1, n2)) 62 63 for name in zone2: 64 n3 = cast(dns.node.Node, zone1.get_node(name)) 65 if not n3: 66 n4 = cast(dns.node.Node, zone2.get_node(name)) 67 changes.append((str(name), n3, n4)) 68 return changes 69 70def _nodes_differ(n1, # type: dns.node.Node 71 n2, # type: dns.node.Node 72 ignore_ttl, # type: bool 73 ignore_soa # type: bool 74 ): # type: (...) -> bool 75 if ignore_soa or not ignore_ttl: 76 # Compare datasets directly 77 for r in n1.rdatasets: 78 if ignore_soa and r.rdtype == dns.rdatatype.SOA: 79 continue 80 if r not in n2.rdatasets: 81 return True 82 if not ignore_ttl: 83 return r.ttl != n2.find_rdataset(r.rdclass, r.rdtype).ttl 84 85 for r in n2.rdatasets: 86 if ignore_soa and r.rdtype == dns.rdatatype.SOA: 87 continue 88 if r not in n1.rdatasets: 89 return True 90 assert False 91 else: 92 return n1 != n2 93 94def format_changes_plain(oldf, # type: str 95 newf, # type: str 96 changes, # type: list 97 ignore_ttl=False 98 ): # type: (...) -> str 99 """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str 100 Given 2 filenames and a list of changes from diff_zones, produce diff-like 101 output. If ignore_ttl is True, TTL-only changes are not displayed""" 102 103 ret = "--- {}\n+++ {}\n".format(oldf, newf) 104 for name, old, new in changes: 105 ret += "@ %s\n" % name 106 if not old: 107 for r in new.rdatasets: 108 ret += "+ %s\n" % str(r).replace('\n', '\n+ ') 109 elif not new: 110 for r in old.rdatasets: 111 ret += "- %s\n" % str(r).replace('\n', '\n+ ') 112 else: 113 for r in old.rdatasets: 114 if r not in new.rdatasets or ( 115 r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and 116 not ignore_ttl 117 ): 118 ret += "- %s\n" % str(r).replace('\n', '\n+ ') 119 for r in new.rdatasets: 120 if r not in old.rdatasets or ( 121 r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and 122 not ignore_ttl 123 ): 124 ret += "+ %s\n" % str(r).replace('\n', '\n+ ') 125 return ret 126 127def format_changes_html(oldf, # type: str 128 newf, # type: str 129 changes, # type: list 130 ignore_ttl=False 131 ): # type: (...) -> str 132 """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str 133 Given 2 filenames and a list of changes from diff_zones, produce nice html 134 output. If ignore_ttl is True, TTL-only changes are not displayed""" 135 136 ret = '''<table class="zonediff"> 137 <thead> 138 <tr> 139 <th> </th> 140 <th class="old">%s</th> 141 <th class="new">%s</th> 142 </tr> 143 </thead> 144 <tbody>\n''' % (oldf, newf) 145 146 for name, old, new in changes: 147 ret += ' <tr class="rdata">\n <td class="rdname">%s</td>\n' % name 148 if not old: 149 for r in new.rdatasets: 150 ret += ( 151 ' <td class="old"> </td>\n' 152 ' <td class="new">%s</td>\n' 153 ) % str(r).replace('\n', '<br />') 154 elif not new: 155 for r in old.rdatasets: 156 ret += ( 157 ' <td class="old">%s</td>\n' 158 ' <td class="new"> </td>\n' 159 ) % str(r).replace('\n', '<br />') 160 else: 161 ret += ' <td class="old">' 162 for r in old.rdatasets: 163 if r not in new.rdatasets or ( 164 r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and 165 not ignore_ttl 166 ): 167 ret += str(r).replace('\n', '<br />') 168 ret += '</td>\n' 169 ret += ' <td class="new">' 170 for r in new.rdatasets: 171 if r not in old.rdatasets or ( 172 r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and 173 not ignore_ttl 174 ): 175 ret += str(r).replace('\n', '<br />') 176 ret += '</td>\n' 177 ret += ' </tr>\n' 178 return ret + ' </tbody>\n</table>' 179 180 181# Make this module usable as a script too. 182def main(): # type: () -> None 183 import argparse 184 import subprocess 185 import sys 186 import traceback 187 188 usage = """%prog zonefile1 zonefile2 - Show differences between zones in a diff-like format 189%prog [--git|--bzr|--rcs] zonefile rev1 [rev2] - Show differences between two revisions of a zonefile 190 191The differences shown will be logical differences, not textual differences. 192""" 193 p = argparse.ArgumentParser(usage=usage) 194 p.add_argument('-s', '--ignore-soa', action="store_true", default=False, dest="ignore_soa", 195 help="Ignore SOA-only changes to records") 196 p.add_argument('-t', '--ignore-ttl', action="store_true", default=False, dest="ignore_ttl", 197 help="Ignore TTL-only changes to Rdata") 198 p.add_argument('-T', '--traceback', action="store_true", default=False, dest="tracebacks", 199 help="Show python tracebacks when errors occur") 200 p.add_argument('-H', '--html', action="store_true", default=False, dest="html", 201 help="Print HTML output") 202 p.add_argument('-g', '--git', action="store_true", default=False, dest="use_git", 203 help="Use git revisions instead of real files") 204 p.add_argument('-b', '--bzr', action="store_true", default=False, dest="use_bzr", 205 help="Use bzr revisions instead of real files") 206 p.add_argument('-r', '--rcs', action="store_true", default=False, dest="use_rcs", 207 help="Use rcs revisions instead of real files") 208 opts, args = p.parse_args() 209 opts.use_vc = opts.use_git or opts.use_bzr or opts.use_rcs 210 211 def _open(what, err): # type: (Union[list,str], str) -> Any 212 if isinstance(what, list): 213 # Must be a list, open subprocess 214 try: 215 proc = subprocess.Popen(what, stdout=subprocess.PIPE) 216 proc.wait() 217 if proc.returncode == 0: 218 return proc.stdout 219 sys.stderr.write(err + "\n") 220 except Exception: 221 sys.stderr.write(err + "\n") 222 if opts.tracebacks: 223 traceback.print_exc() 224 else: 225 # Open as normal file 226 try: 227 return open(what, 'rb') 228 except IOError: 229 sys.stderr.write(err + "\n") 230 if opts.tracebacks: 231 traceback.print_exc() 232 233 if not opts.use_vc and len(args) != 2: 234 p.print_help() 235 sys.exit(64) 236 if opts.use_vc and len(args) not in (2, 3): 237 p.print_help() 238 sys.exit(64) 239 240 # Open file descriptors 241 if not opts.use_vc: 242 oldn, newn = args 243 else: 244 if len(args) == 3: 245 filename, oldr, newr = args 246 oldn = "{}:{}".format(oldr, filename) 247 newn = "{}:{}".format(newr, filename) 248 else: 249 filename, oldr = args 250 newr = None 251 oldn = "{}:{}".format(oldr, filename) 252 newn = filename 253 254 old, new = None, None 255 oldz, newz = None, None 256 if opts.use_bzr: 257 old = _open(["bzr", "cat", "-r" + oldr, filename], 258 "Unable to retrieve revision {} of {}".format(oldr, filename)) 259 if newr is not None: 260 new = _open(["bzr", "cat", "-r" + newr, filename], 261 "Unable to retrieve revision {} of {}".format(newr, filename)) 262 elif opts.use_git: 263 old = _open(["git", "show", oldn], 264 "Unable to retrieve revision {} of {}".format(oldr, filename)) 265 if newr is not None: 266 new = _open(["git", "show", newn], 267 "Unable to retrieve revision {} of {}".format(newr, filename)) 268 elif opts.use_rcs: 269 old = _open(["co", "-q", "-p", "-r" + oldr, filename], 270 "Unable to retrieve revision {} of {}".format(oldr, filename)) 271 if newr is not None: 272 new = _open(["co", "-q", "-p", "-r" + newr, filename], 273 "Unable to retrieve revision {} of {}".format(newr, filename)) 274 if not opts.use_vc: 275 old = _open(oldn, "Unable to open %s" % oldn) 276 if not opts.use_vc or newr is None: 277 new = _open(newn, "Unable to open %s" % newn) 278 279 if not old or not new: 280 sys.exit(65) 281 282 # Parse the zones 283 try: 284 oldz = dns.zone.from_file(old, origin='.', check_origin=False) 285 except dns.exception.DNSException: 286 sys.stderr.write("Incorrect zonefile: %s\n" % old) 287 if opts.tracebacks: 288 traceback.print_exc() 289 try: 290 newz = dns.zone.from_file(new, origin='.', check_origin=False) 291 except dns.exception.DNSException: 292 sys.stderr.write("Incorrect zonefile: %s\n" % new) 293 if opts.tracebacks: 294 traceback.print_exc() 295 if not oldz or not newz: 296 sys.exit(65) 297 298 changes = diff_zones(oldz, newz, opts.ignore_ttl, opts.ignore_soa) 299 changes.sort() 300 301 if not changes: 302 sys.exit(0) 303 if opts.html: 304 print(format_changes_html(oldn, newn, changes, opts.ignore_ttl)) 305 else: 306 print(format_changes_plain(oldn, newn, changes, opts.ignore_ttl)) 307 sys.exit(1) 308 309if __name__ == '__main__': 310 main() 311