1#!/usr/bin/env python
2#
3# This file is a part of DNSViz, a tool suite for DNS/DNSSEC monitoring,
4# analysis, and visualization.
5# Created by Casey Deccio (casey@deccio.net)
6#
7# Copyright 2014-2016 VeriSign, Inc.
8#
9# Copyright 2016-2021 Casey Deccio
10#
11# DNSViz is free software; you can redistribute it and/or modify
12# it under the terms of the GNU General Public License as published by
13# the Free Software Foundation; either version 2 of the License, or
14# (at your option) any later version.
15#
16# DNSViz is distributed in the hope that it will be useful,
17# but WITHOUT ANY WARRANTY; without even the implied warranty of
18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19# GNU General Public License for more details.
20#
21# You should have received a copy of the GNU General Public License along
22# with DNSViz.  If not, see <http://www.gnu.org/licenses/>.
23#
24
25from __future__ import unicode_literals
26
27import argparse
28import codecs
29import io
30import json
31import logging
32import os
33import re
34import sys
35
36# minimal support for python2.6
37try:
38    from collections import OrderedDict
39except ImportError:
40    from ordereddict import OrderedDict
41
42import dns.exception, dns.name
43
44from dnsviz.analysis import TTLAgnosticOfflineDomainNameAnalysis, DNS_RAW_VERSION
45from dnsviz.format import latin1_binary_to_string as lb2s
46from dnsviz.util import get_trusted_keys, get_default_trusted_keys
47
48# If the import of DNSAuthGraph fails because of the lack of pygraphviz, it
49# will be reported later
50try:
51    from dnsviz.viz.dnssec import DNSAuthGraph
52except ImportError:
53    try:
54        import pygraphviz
55    except ImportError:
56        pass
57    else:
58        raise
59
60logging.basicConfig(level=logging.WARNING, format='%(message)s')
61logger = logging.getLogger()
62
63class AnalysisInputError(Exception):
64    pass
65
66def finish_graph(G, name_objs, rdtypes, trusted_keys, supported_algs, filename):
67    G.add_trust(trusted_keys, supported_algs=supported_algs)
68
69    try:
70        fh = io.open(filename, 'w', encoding='utf-8')
71    except IOError as e:
72        logger.error('%s: "%s"' % (e.strerror, filename))
73        sys.exit(3)
74
75    show_colors = fh.isatty() and os.environ.get('TERM', 'dumb') != 'dumb'
76
77    tuples = []
78    processed = set()
79    for name_obj in name_objs:
80        name_obj.populate_response_component_status(G)
81        tuples.extend(name_obj.serialize_status_simple(rdtypes, processed))
82
83    fh.write(textualize_status_output(tuples, show_colors))
84
85TERM_COLOR_MAP = {
86    'BOLD': '\033[1m',
87    'RESET': '\033[0m',
88    'SECURE': '\033[36m',
89    'BOGUS': '\033[31m',
90    'INSECURE': '\033[37m',
91    'NOERROR': '\033[37m',
92    'NXDOMAIN': '\033[37m',
93    'INDETERMINATE': '\033[31m',
94    'NON_EXISTENT': '\033[37m',
95    'VALID': '\033[36m',
96    'INDETERMINATE': '\033[37m',
97    'INDETERMINATE_NO_DNSKEY': '\033[37m',
98    'INDETERMINATE_MATCH_PRE_REVOKE': '\033[37m',
99    'INDETERMINATE_UNKNOWN_ALGORITHM': '\033[33m',
100    'ALGORITHM_IGNORED': '\033[37m',
101    'EXPIRED': '\033[35m',
102    'PREMATURE': '\033[35m',
103    'INVALID_SIG': '\033[31m',
104    'INVALID': '\033[31m',
105    'INVALID_DIGEST': '\033[31m',
106    'INCOMPLETE': '\033[33m',
107    'LAME': '\033[33m',
108    'INVALID_TARGET': '\033[31m',
109    'ERROR': '\033[31m',
110    'WARNING': '\033[33m',
111}
112
113STATUS_MAP = {
114    'SECURE': '.',
115    'BOGUS': '!',
116    'INSECURE': '-',
117    'NON_EXISTENT': '-',
118    'VALID': '.',
119    'INDETERMINATE': '-',
120    'INDETERMINATE_NO_DNSKEY': '-',
121    'INDETERMINATE_MATCH_PRE_REVOKE': '-',
122    'INDETERMINATE_UNKNOWN_ALGORITHM': '?',
123    'ALGORITHM_IGNORED': '-',
124    'EXPIRED': '!',
125    'PREMATURE': '!',
126    'INVALID_SIG': '!',
127    'INVALID': '!',
128    'INVALID_DIGEST': '!',
129    'INCOMPLETE': '?',
130    'LAME': '?',
131    'INVALID_TARGET': '!',
132    'ERROR': '!',
133    'WARNING': '?',
134}
135
136def _errors_warnings_full(warnings, errors, indent, show_color):
137    # display status, errors, and warnings
138    s = ''
139    for error in errors:
140        if show_color:
141            s += '%s%sE:%s%s\n' % (indent, TERM_COLOR_MAP['ERROR'], error, TERM_COLOR_MAP['RESET'])
142        else:
143            s += '%sE:%s\n' % (indent, error)
144
145    for warning in warnings:
146        if show_color:
147            s += '%s%sW:%s%s\n' % (indent, TERM_COLOR_MAP['WARNING'], warning, TERM_COLOR_MAP['RESET'])
148        else:
149            s += '%sW:%s\n' % (indent, warning)
150
151    return s
152
153def _errors_warnings_str(status, warnings, errors, show_color):
154    # display status, errors, and warnings
155    error_str = ''
156    if errors:
157        if show_color:
158            error_str = '%s%s%s' % (TERM_COLOR_MAP['ERROR'], STATUS_MAP['ERROR'], TERM_COLOR_MAP[status])
159        else:
160            error_str = STATUS_MAP['ERROR']
161    elif warnings:
162        if show_color:
163            error_str = '%s%s%s' % (TERM_COLOR_MAP['WARNING'], STATUS_MAP['WARNING'], TERM_COLOR_MAP[status])
164        else:
165            error_str = STATUS_MAP['WARNING']
166    return '[%s%s]' % (STATUS_MAP[status], error_str)
167
168def _textualize_status_output_response(rdtype_str, status, warnings, errors, rdata, children, depth, show_color):
169    s = ''
170
171    response_prefix = '  %(status_color)s%(status)s%(preindent)s %(indent)s%(rdtype)s: '
172    response_rdata = '%(rdata)s%(color_reset)s%(status_color_rdata)s%(status_rdata)s%(color_reset)s'
173    join_str_template = '%(status_color)s, '
174
175    params = {}
176    params['status_color'] = ''
177    params['status_color_rdata'] = ''
178
179    if show_color:
180        params['color_reset'] = TERM_COLOR_MAP['RESET']
181    else:
182        params['color_reset'] = ''
183
184    # display status, errors, and warnings
185    params['status'] = _errors_warnings_str(status, warnings, errors, show_color)
186
187    # indent based on the presence of errors and warnings
188    if errors or warnings:
189        params['preindent'] = ''
190    else:
191        params['preindent'] = ' '
192
193    params['rdtype'] = rdtype_str
194    params['indent'] = '  '*depth
195    if show_color:
196        params['status_color'] = TERM_COLOR_MAP[status]
197    s += response_prefix % params
198
199    rdata_set = []
200    subwarnings_all = warnings[:]
201    suberrors_all = errors[:]
202    for i, (substatus, subwarnings, suberrors, rdata_item) in enumerate(rdata):
203        params['rdata'] = rdata_item
204        # display status, errors, and warnings
205        if substatus is not None:
206            if show_color:
207                params['status_color_rdata'] = TERM_COLOR_MAP[substatus]
208            params['status_rdata'] = ' ' + _errors_warnings_str(substatus, subwarnings, suberrors, show_color)
209        else:
210            params['status_color_rdata'] = ''
211            params['status_rdata'] = ''
212        rdata_set.append(response_rdata % params)
213
214        subwarnings_all.extend(subwarnings)
215        suberrors_all.extend(suberrors)
216
217    join_str = join_str_template % params
218    s += join_str.join(rdata_set) + '\n'
219
220    s += _errors_warnings_full(subwarnings_all, suberrors_all, '        ' + params['preindent'] + params['indent'], show_color)
221
222    for rdtype_str_child, status_child, warnings_child, errors_child, rdata_child, children_child in children:
223        s += _textualize_status_output_response(rdtype_str_child, status_child, warnings_child, errors_child, rdata_child, children_child, depth + 1, show_color)
224
225    return s
226
227def _textualize_status_output_name(name, zone_status, zone_warnings, zone_errors, delegation_status, delegation_warnings, delegation_errors, responses, show_color):
228    s = ''
229
230    name_template = '%(status_color)s%(name)s%(color_reset)s%(status_color_rdata)s%(status_rdata)s%(color_reset)s\n'
231
232    params = {}
233    params['status_color'] = ''
234    params['status_color_rdata'] = ''
235
236    if show_color:
237        params['color_reset'] = TERM_COLOR_MAP['RESET']
238    else:
239        params['color_reset'] = ''
240
241    warnings_all = zone_warnings + delegation_warnings
242    errors_all = zone_errors + delegation_errors
243
244    params['name'] = name
245    params['status_rdata'] = ''
246    if show_color:
247        params['status_color'] = TERM_COLOR_MAP['BOLD']
248        params['color_reset'] = TERM_COLOR_MAP['RESET']
249    if zone_status is not None:
250        params['status_rdata'] += ' ' + _errors_warnings_str(zone_status, zone_warnings, zone_errors, show_color)
251        if show_color:
252            params['status_color_rdata'] = TERM_COLOR_MAP[zone_status]
253    if delegation_status is not None:
254        params['status_rdata'] += ' ' + _errors_warnings_str(delegation_status, delegation_warnings, delegation_errors, show_color)
255        if show_color:
256            params['status_color_rdata'] = TERM_COLOR_MAP[delegation_status]
257    s += name_template % params
258
259    s += _errors_warnings_full(warnings_all, errors_all, '  ', show_color)
260
261    for rdtype_str, status, warnings, errors, rdata, children in responses:
262        s += _textualize_status_output_response(rdtype_str, status, warnings, errors, rdata, children, 0, show_color)
263
264    return s
265
266def textualize_status_output(names, show_color):
267    s = ''
268    for name, zone_status, zone_warnings, zone_errors, delegation_status, delegation_warnings, delegation_errors, responses in names:
269        s += _textualize_status_output_name(name, zone_status, zone_warnings, zone_errors, delegation_status, delegation_warnings, delegation_errors, responses, show_color)
270
271    return s
272
273def test_pygraphviz():
274    try:
275        try:
276            # pygraphviz < 1.7 used pygraphviz.release.version
277            from pygraphviz import release
278            version = release.version
279        except ImportError:
280            # pygraphviz 1.7 changed to pygraphviz.__version__
281            from pygraphviz import __version__
282            version = __version__
283        try:
284            major, minor = version.split('.')[:2]
285            major = int(major)
286            minor = int(re.sub(r'(\d+)[^\d].*', r'\1', minor))
287            if (major, minor) < (1,3):
288                logger.error('''pygraphviz version >= 1.3 is required, but version %s is installed.''' % version)
289                sys.exit(2)
290        except ValueError:
291            logger.error('''pygraphviz version >= 1.3 is required, but version %s is installed.''' % version)
292            sys.exit(2)
293    except ImportError:
294        logger.error('''pygraphviz is required, but not installed.''')
295        sys.exit(2)
296
297class PrintArgHelper:
298
299    def __init__(self, logger):
300        self.parser = None
301
302        self.trusted_keys = None
303        self.names = None
304        self.analysis_structured = None
305
306        self.args = None
307        self._arg_mapping = None
308
309        self._logger = logger
310
311    def build_parser(self, prog):
312        self.parser = argparse.ArgumentParser(description='Print the assessment of diagnostic DNS queries', prog=prog)
313
314        # python3/python2 dual compatibility
315        stdin_buffer = io.open(sys.stdin.fileno(), 'rb', closefd=False)
316        stdout_buffer = io.open(sys.stdout.fileno(), 'wb', closefd=False)
317
318        try:
319            self.parser.add_argument('-f', '--names-file',
320                    type=argparse.FileType('r', encoding='UTF-8'),
321                    action='store', metavar='<filename>',
322                    help='Read names from a file')
323        except TypeError:
324            # this try/except is for
325            # python3/python2 dual compatibility
326            self.parser.add_argument('-f', '--names-file',
327                    type=argparse.FileType('r'),
328                    action='store', metavar='<filename>',
329                    help='Read names from a file')
330        #self.parser.add_argument('-s', '--silent',
331        #        const=True, default=False,
332        #        action='store_const',
333        #        help='Suppress error messages')
334        try:
335            self.parser.add_argument('-r', '--input-file',
336                    type=argparse.FileType('r', encoding='UTF-8'), default=stdin_buffer,
337                    action='store', metavar='<filename>',
338                    help='Read diagnostic queries from a file')
339        except TypeError:
340            # this try/except is for
341            # python3/python2 dual compatibility
342            self.parser.add_argument('-r', '--input-file',
343                    type=argparse.FileType('r'), default=stdin_buffer,
344                    action='store', metavar='<filename>',
345                    help='Read diagnostic queries from a file')
346        try:
347            self.parser.add_argument('-t', '--trusted-keys-file',
348                    type=argparse.FileType('r', encoding='UTF-8'),
349                    action='append', metavar='<filename>',
350                    help='Use trusted keys from the designated file')
351        except TypeError:
352            # this try/except is for
353            # python3/python2 dual compatibility
354            self.parser.add_argument('-t', '--trusted-keys-file',
355                    type=argparse.FileType('r'),
356                    action='append', metavar='<filename>',
357                    help='Use trusted keys from the designated file')
358        self.parser.add_argument('-a', '--algorithms',
359                type=self.comma_separated_ints_set,
360                action='store', metavar='<alg>,[<alg>...]',
361                help='Support only the specified DNSSEC algorithm(s)')
362        self.parser.add_argument('-d', '--digest-algorithms',
363                type=self.comma_separated_ints_set,
364                action='store', metavar='<digest_alg>,[<digest_alg>...]',
365                help='Support only the specified DNSSEC digest algorithm(s)')
366        self.parser.add_argument('-b', '--validate-prohibited-algs',
367                const=True, default=False,
368                action='store_const',
369                help='Validate algorithms for which validation is otherwise prohibited')
370        self.parser.add_argument('-C', '--enforce-cookies',
371                const=True, default=False,
372                action='store_const',
373                help='Enforce DNS cookies strictly')
374        self.parser.add_argument('-P', '--allow-private',
375                const=True, default=False,
376                action='store_const',
377                help='Allow private IP addresses for authoritative DNS servers')
378        self.parser.add_argument('-R', '--rr-types',
379                type=self.comma_separated_dns_types,
380                action='store', metavar='<type>,[<type>...]',
381                help='Process queries of only the specified type(s)')
382        self.parser.add_argument('-O', '--derive-filename',
383                const=True, default=False,
384                action='store_const',
385                help='Derive the filename(s) from domain name(s)')
386        self.parser.add_argument('-o', '--output-file',
387                type=argparse.FileType('wb'), default=stdout_buffer,
388                action='store', metavar='<filename>',
389                help='Save the output to the specified file')
390        self.parser.add_argument('domain_name',
391                type=self.valid_domain_name,
392                action='store', nargs='*', metavar='<domain_name>',
393                help='Domain names')
394
395        self._arg_mapping = dict([(a.dest, '/'.join(a.option_strings)) for a in self.parser._actions])
396
397    def parse_args(self, args):
398        self.args = self.parser.parse_args(args)
399
400    @classmethod
401    def comma_separated_dns_types(cls, arg):
402        rdtypes = []
403        arg = arg.strip()
404        if not arg:
405            return rdtypes
406        for r in arg.split(','):
407            try:
408                rdtypes.append(dns.rdatatype.from_text(r.strip()))
409            except dns.rdatatype.UnknownRdatatype:
410                raise argparse.ArgumentTypeError('Invalid resource record type: %s' % (r))
411        return rdtypes
412
413    @classmethod
414    def comma_separated_ints_set(cls, arg):
415        return set(cls.comma_separated_ints(arg))
416
417    @classmethod
418    def comma_separated_ints(cls, arg):
419        ints = []
420        arg = arg.strip()
421        if not arg:
422            return ints
423        for i in arg.split(','):
424            try:
425                ints.append(int(i.strip()))
426            except ValueError:
427                raise argparse.ArgumentTypeError('Invalid integer: %s' % (i))
428        return ints
429
430    @classmethod
431    def valid_domain_name(cls, arg):
432        try:
433            return dns.name.from_text(arg)
434        except dns.exception.DNSException:
435            raise argparse.ArgumentTypeError('Invalid domain name: "%s"' % arg)
436
437    def check_args(self):
438        if self.args.names_file and self.args.domain_name:
439            raise argparse.ArgumentTypeError('If %(names_file)s is used, then domain names may not supplied as command line arguments.' % \
440                    self._arg_mapping)
441        if self.args.derive_filename and self.args.output_file.fileno() != sys.stdout.fileno():
442            raise argparse.ArgumentTypeError('The %(derive_filename)s and %(output_file)s options may not be used together.' % \
443                    self._arg_mapping)
444
445    def set_buffers(self):
446        # This entire method is for
447        # python3/python2 dual compatibility
448        if self.args.input_file is not None:
449            if self.args.input_file.fileno() == sys.stdin.fileno():
450                filename = self.args.input_file.fileno()
451            else:
452                filename = self.args.input_file.name
453                self.args.input_file.close()
454            self.args.input_file = io.open(filename, 'r', encoding='utf-8')
455        if self.args.names_file is not None:
456            if self.args.names_file.fileno() == sys.stdin.fileno():
457                filename = self.args.names_file.fileno()
458            else:
459                filename = self.args.names_file.name
460                self.args.names_file.close()
461            self.args.names_file = io.open(filename, 'r', encoding='utf-8')
462        if self.args.trusted_keys_file is not None:
463            trusted_keys_files = []
464            for tk_file in self.args.trusted_keys_file:
465                if tk_file.fileno() == sys.stdin.fileno():
466                    filename = tk_file.fileno()
467                else:
468                    filename = tk_file.name
469                    tk_file.close()
470                trusted_keys_files.append(io.open(filename, 'r', encoding='utf-8'))
471            self.args.trusted_keys_file = trusted_keys_files
472        if self.args.output_file is not None:
473            if self.args.output_file.fileno() == sys.stdout.fileno():
474                filename = self.args.output_file.fileno()
475            else:
476                filename = self.args.output_file.name
477                self.args.output_file.close()
478            self.args.output_file = io.open(filename, 'wb')
479
480    def aggregate_trusted_key_info(self):
481        if not self.args.trusted_keys_file:
482            return
483
484        self.trusted_keys = []
485        for fh in self.args.trusted_keys_file:
486            tk_str = fh.read()
487            try:
488                self.trusted_keys.extend(get_trusted_keys(tk_str))
489            except dns.exception.DNSException:
490                raise argparse.ArgumentTypeError('There was an error parsing the trusted keys file: "%s"' % \
491                        self._arg_mapping)
492
493    def update_trusted_key_info(self, latest_analysis_date):
494        if self.args.trusted_keys_file is None:
495            self.trusted_keys = get_default_trusted_keys(latest_analysis_date)
496
497    def ingest_input(self):
498        analysis_str = self.args.input_file.read()
499        if not analysis_str:
500            if self.args.input_file.fileno() != sys.stdin.fileno():
501                raise AnalysisInputError('No input')
502            else:
503                raise AnalysisInputError()
504        try:
505            self.analysis_structured = json.loads(analysis_str)
506        except ValueError:
507            raise AnalysisInputError('There was an error parsing the JSON input: "%s"' % self.args.input_file.name)
508
509        # check version
510        if '_meta._dnsviz.' not in self.analysis_structured or 'version' not in self.analysis_structured['_meta._dnsviz.']:
511            raise AnalysisInputError('No version information in JSON input: "%s"' % self.args.input_file.name)
512        try:
513            major_vers, minor_vers = [int(x) for x in str(self.analysis_structured['_meta._dnsviz.']['version']).split('.', 1)]
514        except ValueError:
515            raise AnalysisInputError('Version of JSON input is invalid: %s' % self.analysis_structured['_meta._dnsviz.']['version'])
516        # ensure major version is a match and minor version is no greater
517        # than the current minor version
518        curr_major_vers, curr_minor_vers = [int(x) for x in str(DNS_RAW_VERSION).split('.', 1)]
519        if major_vers != curr_major_vers or minor_vers > curr_minor_vers:
520            raise AnalysisInputError('Version %d.%d of JSON input is incompatible with this software.' % (major_vers, minor_vers))
521
522    def ingest_names(self):
523        self.names = OrderedDict()
524
525        if self.args.domain_name:
526            for name in self.args.domain_name:
527                if name not in self.names:
528                    self.names[name] = None
529            return
530
531        if self.args.names_file:
532            args = self.args.names_file
533        else:
534            try:
535                args = self.analysis_structured['_meta._dnsviz.']['names']
536            except KeyError:
537                raise AnalysisInputError('No names found in JSON input!')
538
539        for arg in args:
540            name = arg.strip()
541
542            # python3/python2 dual compatibility
543            if hasattr(name, 'decode'):
544                name = name.decode('utf-8')
545
546            try:
547                name = dns.name.from_text(name)
548            except UnicodeDecodeError as e:
549                self._logger.error('%s: "%s"' % (e, name))
550            except dns.exception.DNSException:
551                self._logger.error('The domain name was invalid: "%s"' % name)
552            else:
553                if name not in self.names:
554                    self.names[name] = None
555
556def build_helper(logger, cmd, subcmd):
557    arghelper = PrintArgHelper(logger)
558    arghelper.build_parser('%s %s' % (cmd, subcmd))
559    return arghelper
560
561def main(argv):
562    try:
563        test_pygraphviz()
564
565        arghelper = build_helper(logger, sys.argv[0], argv[0])
566        arghelper.parse_args(argv[1:])
567        logger.setLevel(logging.WARNING)
568
569        try:
570            arghelper.check_args()
571            arghelper.set_buffers()
572            arghelper.aggregate_trusted_key_info()
573            arghelper.ingest_input()
574            arghelper.ingest_names()
575        except argparse.ArgumentTypeError as e:
576            arghelper.parser.error(str(e))
577        except AnalysisInputError as e:
578            s = str(e)
579            if s:
580                logger.error(s)
581            sys.exit(3)
582
583        latest_analysis_date = None
584        name_objs = []
585        cache = {}
586        for name in arghelper.names:
587            name_str = lb2s(name.canonicalize().to_text())
588            if name_str not in arghelper.analysis_structured or arghelper.analysis_structured[name_str].get('stub', True):
589                logger.error('The analysis of "%s" was not found in the input.' % lb2s(name.to_text()))
590                continue
591            name_obj = TTLAgnosticOfflineDomainNameAnalysis.deserialize(name, arghelper.analysis_structured, cache, strict_cookies=arghelper.args.enforce_cookies, allow_private=arghelper.args.allow_private)
592            name_objs.append(name_obj)
593
594            if latest_analysis_date is None or latest_analysis_date > name_obj.analysis_end:
595                latest_analysis_date = name_obj.analysis_end
596
597        if not name_objs:
598            sys.exit(4)
599
600        arghelper.update_trusted_key_info(latest_analysis_date)
601
602        G = DNSAuthGraph()
603        for name_obj in name_objs:
604            name_obj.populate_status(arghelper.trusted_keys, supported_algs=arghelper.args.algorithms, supported_digest_algs=arghelper.args.digest_algorithms, validate_prohibited_algs=arghelper.args.validate_prohibited_algs)
605            for qname, rdtype in name_obj.queries:
606                if arghelper.args.rr_types is None:
607                    # if rdtypes was not specified, then graph all, with some
608                    # exceptions
609                    if name_obj.is_zone() and rdtype in (dns.rdatatype.DNSKEY, dns.rdatatype.DS, dns.rdatatype.DLV):
610                        continue
611                else:
612                    # if rdtypes was specified, then only graph rdtypes that
613                    # were specified
614                    if qname != name_obj.name or rdtype not in arghelper.args.rr_types:
615                        continue
616                G.graph_rrset_auth(name_obj, qname, rdtype)
617
618            if arghelper.args.rr_types is not None:
619                for rdtype in arghelper.args.rr_types:
620                    if (name_obj.name, rdtype) not in name_obj.queries:
621                        logger.error('No query for "%s/%s" was included in the analysis.' % (lb2s(name_obj.name.to_text()), dns.rdatatype.to_text(rdtype)))
622
623            if arghelper.args.derive_filename:
624                if name_obj.name == dns.name.root:
625                    name = 'root'
626                else:
627                    name = lb2s(name_obj.name.canonicalize().to_text()).rstrip('.')
628                    name = name.replace(os.sep, '--')
629                finish_graph(G, [name_obj], arghelper.args.rr_types, arghelper.trusted_keys, arghelper.args.algorithms, '%s.txt' % name)
630                G = DNSAuthGraph()
631
632        if not arghelper.args.derive_filename:
633            finish_graph(G, name_objs, arghelper.args.rr_types, arghelper.trusted_keys, arghelper.args.algorithms, arghelper.args.output_file.fileno())
634
635    except KeyboardInterrupt:
636        logger.error('Interrupted.')
637        sys.exit(4)
638
639if __name__ == "__main__":
640    main(sys.argv)
641