zonediff.py 11 KB

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