zonediff.py 12 KB

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