| 1 | #!/usr/bin/env python
|
|---|
| 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 |
|
|---|
| 24 | __all__ = ['diff_zones', 'format_changes_plain', 'format_changes_html']
|
|---|
| 25 |
|
|---|
| 26 | try:
|
|---|
| 27 | import dns.zone
|
|---|
| 28 | except ImportError:
|
|---|
| 29 | import sys
|
|---|
| 30 | sys.stderr.write("Please install dnspython")
|
|---|
| 31 | sys.exit(1)
|
|---|
| 32 |
|
|---|
| 33 | def diff_zones(zone1, zone2, ignore_ttl=False, ignore_soa=False):
|
|---|
| 34 | """diff_zones(zone1, zone2, ignore_ttl=False, ignore_soa=False) -> changes
|
|---|
| 35 | Compares two dns.zone.Zone objects and returns a list of all changes
|
|---|
| 36 | in the format (name, oldnode, newnode).
|
|---|
| 37 |
|
|---|
| 38 | If ignore_ttl is true, a node will not be added to this list if the
|
|---|
| 39 | only change is its TTL.
|
|---|
| 40 |
|
|---|
| 41 | If ignore_soa is true, a node will not be added to this list if the
|
|---|
| 42 | only changes is a change in a SOA Rdata set.
|
|---|
| 43 |
|
|---|
| 44 | The returned nodes do include all Rdata sets, including unchanged ones.
|
|---|
| 45 | """
|
|---|
| 46 |
|
|---|
| 47 | changes = []
|
|---|
| 48 | for name in zone1:
|
|---|
| 49 | name = str(name)
|
|---|
| 50 | n1 = zone1.get_node(name)
|
|---|
| 51 | n2 = zone2.get_node(name)
|
|---|
| 52 | if not n2:
|
|---|
| 53 | changes.append((str(name), n1, n2))
|
|---|
| 54 | elif _nodes_differ(n1, n2, ignore_ttl, ignore_soa):
|
|---|
| 55 | changes.append((str(name), n1, n2))
|
|---|
| 56 |
|
|---|
| 57 | for name in zone2:
|
|---|
| 58 | n1 = zone1.get_node(name)
|
|---|
| 59 | if not n1:
|
|---|
| 60 | n2 = zone2.get_node(name)
|
|---|
| 61 | changes.append((str(name), n1, n2))
|
|---|
| 62 | return changes
|
|---|
| 63 |
|
|---|
| 64 | def _nodes_differ(n1, n2, ignore_ttl, ignore_soa):
|
|---|
| 65 | if ignore_soa or not ignore_ttl:
|
|---|
| 66 | # Compare datasets directly
|
|---|
| 67 | for r in n1.rdatasets:
|
|---|
| 68 | if ignore_soa and r.rdtype == dns.rdatatype.SOA:
|
|---|
| 69 | continue
|
|---|
| 70 | if r not in n2.rdatasets:
|
|---|
| 71 | return True
|
|---|
| 72 | if not ignore_ttl:
|
|---|
| 73 | return r.ttl != n2.find_rdataset(r.rdclass, r.rdtype).ttl
|
|---|
| 74 |
|
|---|
| 75 | for r in n2.rdatasets:
|
|---|
| 76 | if ignore_soa and r.rdtype == dns.rdatatype.SOA:
|
|---|
| 77 | continue
|
|---|
| 78 | if r not in n1.rdatasets:
|
|---|
| 79 | return True
|
|---|
| 80 | else:
|
|---|
| 81 | return n1 != n2
|
|---|
| 82 |
|
|---|
| 83 | def format_changes_plain(oldf, newf, changes, ignore_ttl=False):
|
|---|
| 84 | """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str
|
|---|
| 85 | Given 2 filenames and a list of changes from diff_zones, produce diff-like
|
|---|
| 86 | output. If ignore_ttl is True, TTL-only changes are not displayed"""
|
|---|
| 87 |
|
|---|
| 88 | ret = "--- %s\n+++ %s\n" % (oldf, newf)
|
|---|
| 89 | for name, old, new in changes:
|
|---|
| 90 | ret += "@ %s\n" % name
|
|---|
| 91 | if not old:
|
|---|
| 92 | for r in new.rdatasets:
|
|---|
| 93 | ret += "+ %s\n" % str(r).replace('\n','\n+ ')
|
|---|
| 94 | elif not new:
|
|---|
| 95 | for r in old.rdatasets:
|
|---|
| 96 | ret += "- %s\n" % str(r).replace('\n','\n+ ')
|
|---|
| 97 | else:
|
|---|
| 98 | for r in old.rdatasets:
|
|---|
| 99 | if r not in new.rdatasets or (r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
|---|
| 100 | ret += "- %s\n" % str(r).replace('\n','\n+ ')
|
|---|
| 101 | for r in new.rdatasets:
|
|---|
| 102 | if r not in old.rdatasets or (r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
|---|
| 103 | ret += "+ %s\n" % str(r).replace('\n','\n+ ')
|
|---|
| 104 | return ret
|
|---|
| 105 |
|
|---|
| 106 | def format_changes_html(oldf, newf, changes, ignore_ttl=False):
|
|---|
| 107 | """format_changes(oldfile, newfile, changes, ignore_ttl=False) -> str
|
|---|
| 108 | Given 2 filenames and a list of changes from diff_zones, produce nice html
|
|---|
| 109 | output. If ignore_ttl is True, TTL-only changes are not displayed"""
|
|---|
| 110 |
|
|---|
| 111 | ret = '''<table class="zonediff">
|
|---|
| 112 | <thead>
|
|---|
| 113 | <tr>
|
|---|
| 114 | <th> </th>
|
|---|
| 115 | <th class="old">%s</th>
|
|---|
| 116 | <th class="new">%s</th>
|
|---|
| 117 | </tr>
|
|---|
| 118 | </thead>
|
|---|
| 119 | <tbody>\n''' % (oldf, newf)
|
|---|
| 120 |
|
|---|
| 121 | for name, old, new in changes:
|
|---|
| 122 | ret += ' <tr class="rdata">\n <td class="rdname">%s</td>\n' % name
|
|---|
| 123 | if not old:
|
|---|
| 124 | for r in new.rdatasets:
|
|---|
| 125 | ret += ' <td class="old"> </td>\n <td class="new">%s</td>\n' % str(r).replace('\n','<br />')
|
|---|
| 126 | elif not new:
|
|---|
| 127 | for r in old.rdatasets:
|
|---|
| 128 | ret += ' <td class="old">%s</td>\n <td class="new"> </td>\n' % str(r).replace('\n','<br />')
|
|---|
| 129 | else:
|
|---|
| 130 | ret += ' <td class="old">'
|
|---|
| 131 | for r in old.rdatasets:
|
|---|
| 132 | if r not in new.rdatasets or (r.ttl != new.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
|---|
| 133 | ret += str(r).replace('\n','<br />')
|
|---|
| 134 | ret += '</td>\n'
|
|---|
| 135 | ret += ' <td class="new">'
|
|---|
| 136 | for r in new.rdatasets:
|
|---|
| 137 | if r not in old.rdatasets or (r.ttl != old.find_rdataset(r.rdclass, r.rdtype).ttl and not ignore_ttl):
|
|---|
| 138 | ret += str(r).replace('\n','<br />')
|
|---|
| 139 | ret += '</td>\n'
|
|---|
| 140 | ret += ' </tr>\n'
|
|---|
| 141 | return ret + ' </tbody>\n</table>'
|
|---|
| 142 |
|
|---|
| 143 | # Make this module usable as a script too.
|
|---|
| 144 | if __name__ == '__main__':
|
|---|
| 145 | import optparse
|
|---|
| 146 | import subprocess
|
|---|
| 147 | import sys
|
|---|
| 148 | import traceback
|
|---|
| 149 |
|
|---|
| 150 | usage = """%prog zonefile1 zonefile2 - Show differences between zones in a diff-like format
|
|---|
| 151 | %prog [--git|--bzr|--rcs] zonefile rev1 [rev2] - Show differences between two revisions of a zonefile
|
|---|
| 152 |
|
|---|
| 153 | The differences shown will be logical differences, not textual differences.
|
|---|
| 154 | """
|
|---|
| 155 | p = optparse.OptionParser(usage=usage)
|
|---|
| 156 | p.add_option('-s', '--ignore-soa', action="store_true", default=False, dest="ignore_soa",
|
|---|
| 157 | help="Ignore SOA-only changes to records")
|
|---|
| 158 | p.add_option('-t', '--ignore-ttl', action="store_true", default=False, dest="ignore_ttl",
|
|---|
| 159 | help="Ignore TTL-only changes to Rdata")
|
|---|
| 160 | p.add_option('-T', '--traceback', action="store_true", default=False, dest="tracebacks",
|
|---|
| 161 | help="Show python tracebacks when errors occur")
|
|---|
| 162 | p.add_option('-H', '--html', action="store_true", default=False, dest="html",
|
|---|
| 163 | help="Print HTML output")
|
|---|
| 164 | p.add_option('-g', '--git', action="store_true", default=False, dest="use_git",
|
|---|
| 165 | help="Use git revisions instead of real files")
|
|---|
| 166 | p.add_option('-b', '--bzr', action="store_true", default=False, dest="use_bzr",
|
|---|
| 167 | help="Use bzr revisions instead of real files")
|
|---|
| 168 | p.add_option('-r', '--rcs', action="store_true", default=False, dest="use_rcs",
|
|---|
| 169 | help="Use rcs revisions instead of real files")
|
|---|
| 170 | opts, args = p.parse_args()
|
|---|
| 171 | opts.use_vc = opts.use_git or opts.use_bzr or opts.use_rcs
|
|---|
| 172 |
|
|---|
| 173 | def _open(what, err):
|
|---|
| 174 | if isinstance(what, basestring):
|
|---|
| 175 | # Open as normal file
|
|---|
| 176 | try:
|
|---|
| 177 | return open(what, 'rb')
|
|---|
| 178 | except:
|
|---|
| 179 | sys.stderr.write(err + "\n")
|
|---|
| 180 | if opts.tracebacks:
|
|---|
| 181 | traceback.print_exc()
|
|---|
| 182 | else:
|
|---|
| 183 | # Must be a list, open subprocess
|
|---|
| 184 | try:
|
|---|
| 185 | proc = subprocess.Popen(what, stdout=subprocess.PIPE)
|
|---|
| 186 | proc.wait()
|
|---|
| 187 | if proc.returncode == 0:
|
|---|
| 188 | return proc.stdout
|
|---|
| 189 | sys.stderr.write(err + "\n")
|
|---|
| 190 | except:
|
|---|
| 191 | sys.stderr.write(err + "\n")
|
|---|
| 192 | if opts.tracebacks:
|
|---|
| 193 | traceback.print_exc()
|
|---|
| 194 |
|
|---|
| 195 | if not opts.use_vc and len(args) != 2:
|
|---|
| 196 | p.print_help()
|
|---|
| 197 | sys.exit(64)
|
|---|
| 198 | if opts.use_vc and len(args) not in (2,3):
|
|---|
| 199 | p.print_help()
|
|---|
| 200 | sys.exit(64)
|
|---|
| 201 |
|
|---|
| 202 | # Open file desriptors
|
|---|
| 203 | if not opts.use_vc:
|
|---|
| 204 | oldn, newn = args
|
|---|
| 205 | else:
|
|---|
| 206 | if len(args) == 3:
|
|---|
| 207 | filename, oldr, newr = args
|
|---|
| 208 | oldn = "%s:%s" % (oldr, filename)
|
|---|
| 209 | newn = "%s:%s" % (newr, filename)
|
|---|
| 210 | else:
|
|---|
| 211 | filename, oldr = args
|
|---|
| 212 | newr = None
|
|---|
| 213 | oldn = "%s:%s" % (oldr, filename)
|
|---|
| 214 | newn = filename
|
|---|
| 215 |
|
|---|
| 216 |
|
|---|
| 217 | old, new = None, None
|
|---|
| 218 | oldz, newz = None, None
|
|---|
| 219 | if opts.use_bzr:
|
|---|
| 220 | old = _open(["bzr", "cat", "-r" + oldr, filename],
|
|---|
| 221 | "Unable to retrieve revision %s of %s" % (oldr, filename))
|
|---|
| 222 | if newr != None:
|
|---|
| 223 | new = _open(["bzr", "cat", "-r" + newr, filename],
|
|---|
| 224 | "Unable to retrieve revision %s of %s" % (newr, filename))
|
|---|
| 225 | elif opts.use_git:
|
|---|
| 226 | old = _open(["git", "show", oldn],
|
|---|
| 227 | "Unable to retrieve revision %s of %s" % (oldr, filename))
|
|---|
| 228 | if newr != None:
|
|---|
| 229 | new = _open(["git", "show", newn],
|
|---|
| 230 | "Unable to retrieve revision %s of %s" % (newr, filename))
|
|---|
| 231 | elif opts.use_rcs:
|
|---|
| 232 | old = _open(["co", "-q", "-p", "-r" + oldr, filename],
|
|---|
| 233 | "Unable to retrieve revision %s of %s" % (oldr, filename))
|
|---|
| 234 | if newr != None:
|
|---|
| 235 | new = _open(["co", "-q", "-p", "-r" + newr, filename],
|
|---|
| 236 | "Unable to retrieve revision %s of %s" % (newr, filename))
|
|---|
| 237 | if not opts.use_vc:
|
|---|
| 238 | old = _open(oldn, "Unable to open %s" % oldn)
|
|---|
| 239 | if not opts.use_vc or newr == None:
|
|---|
| 240 | new = _open(newn, "Unable to open %s" % newn)
|
|---|
| 241 |
|
|---|
| 242 | if not old or not new:
|
|---|
| 243 | sys.exit(65)
|
|---|
| 244 |
|
|---|
| 245 | # Parse the zones
|
|---|
| 246 | try:
|
|---|
| 247 | oldz = dns.zone.from_file(old, origin = '.', check_origin=False)
|
|---|
| 248 | except dns.exception.DNSException:
|
|---|
| 249 | sys.stderr.write("Incorrect zonefile: %s\n", old)
|
|---|
| 250 | if opts.tracebacks:
|
|---|
| 251 | traceback.print_exc()
|
|---|
| 252 | try:
|
|---|
| 253 | newz = dns.zone.from_file(new, origin = '.', check_origin=False)
|
|---|
| 254 | except dns.exception.DNSException:
|
|---|
| 255 | sys.stderr.write("Incorrect zonefile: %s\n" % new)
|
|---|
| 256 | if opts.tracebacks:
|
|---|
| 257 | traceback.print_exc()
|
|---|
| 258 | if not oldz or not newz:
|
|---|
| 259 | sys.exit(65)
|
|---|
| 260 |
|
|---|
| 261 | changes = diff_zones(oldz, newz, opts.ignore_ttl, opts.ignore_soa)
|
|---|
| 262 | changes.sort()
|
|---|
| 263 |
|
|---|
| 264 | if not changes:
|
|---|
| 265 | sys.exit(0)
|
|---|
| 266 | if opts.html:
|
|---|
| 267 | print format_changes_html(oldn, newn, changes, opts.ignore_ttl)
|
|---|
| 268 | else:
|
|---|
| 269 | print format_changes_plain(oldn, newn, changes, opts.ignore_ttl)
|
|---|
| 270 | sys.exit(1)
|
|---|