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>&nbsp;</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">&nbsp;</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">&nbsp;</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