summaryrefslogtreecommitdiffstats
path: root/bin/python/isc/coverage.py.in
blob: e9be265a6f92cf65927702d1c376c677f6f49c40 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0.  If a copy of the MPL was not distributed with this
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.

from __future__ import print_function
import os
import sys
import argparse
import glob
import re
import time
import calendar
import pprint
from collections import defaultdict

prog = "dnssec-coverage"

from isc import dnskey, eventlist, keydict, keyevent, keyzone, utils


############################################################################
# print a fatal error and exit
############################################################################
def fatal(*args, **kwargs):
    print(*args, **kwargs)
    sys.exit(1)


############################################################################
# output:
############################################################################
_firstline = True


def output(*args, **kwargs):
    """output text, adding a vertical space this is *not* the first
    first section being printed since a call to vreset()"""
    global _firstline
    if "skip" in kwargs:
        skip = kwargs["skip"]
        kwargs.pop("skip", None)
    else:
        skip = True
    if _firstline:
        _firstline = False
    elif skip:
        print("")
    if args:
        print(*args, **kwargs)


def vreset():
    """reset vertical spacing"""
    global _firstline
    _firstline = True


############################################################################
# parse_time
############################################################################
def parse_time(s):
    """convert a formatted time (e.g., 1y, 6mo, 15mi, etc) into seconds
    :param s: String with some text representing a time interval
    :return: Integer with the number of seconds in the time interval
    """
    s = s.strip()

    # if s is an integer, we're done already
    try:
        return int(s)
    except ValueError:
        pass

    # try to parse as a number with a suffix indicating unit of time
    r = re.compile(r"([0-9][0-9]*)\s*([A-Za-z]*)")
    m = r.match(s)
    if not m:
        raise ValueError("Cannot parse %s" % s)
    n, unit = m.groups()
    n = int(n)
    unit = unit.lower()
    if unit.startswith("y"):
        return n * 31536000
    elif unit.startswith("mo"):
        return n * 2592000
    elif unit.startswith("w"):
        return n * 604800
    elif unit.startswith("d"):
        return n * 86400
    elif unit.startswith("h"):
        return n * 3600
    elif unit.startswith("mi"):
        return n * 60
    elif unit.startswith("s"):
        return n
    else:
        raise ValueError("Invalid suffix %s" % unit)


############################################################################
# set_path:
############################################################################
def set_path(command, default=None):
    """find the location of a specified command.  if a default is supplied
    and it works, we use it; otherwise we search PATH for a match.
    :param command: string with a command to look for in the path
    :param default: default location to use
    :return: detected location for the desired command
    """

    fpath = default
    if not fpath or not os.path.isfile(fpath) or not os.access(fpath, os.X_OK):
        path = os.environ["PATH"]
        if not path:
            path = os.path.defpath
        for directory in path.split(os.pathsep):
            fpath = os.path.join(directory, command)
            if os.path.isfile(fpath) and os.access(fpath, os.X_OK):
                break
            fpath = None

    return fpath


############################################################################
# parse_args:
############################################################################
def parse_args():
    """Read command line arguments, set global 'args' structure"""
    compilezone = set_path(
        "named-compilezone", os.path.join(utils.prefix("sbin"), "named-compilezone")
    )

    parser = argparse.ArgumentParser(
        description=prog + ": checks future " + "DNSKEY coverage for a zone"
    )

    parser.add_argument(
        "zone",
        type=str,
        nargs="*",
        default=None,
        help="zone(s) to check" + "(default: all zones in the directory)",
    )
    parser.add_argument(
        "-K",
        dest="path",
        default=".",
        type=str,
        help="a directory containing keys to process",
        metavar="dir",
    )
    parser.add_argument(
        "-f", dest="filename", type=str, help="zone master file", metavar="file"
    )
    parser.add_argument(
        "-m",
        dest="maxttl",
        type=str,
        help="the longest TTL in the zone(s)",
        metavar="time",
    )
    parser.add_argument(
        "-d", dest="keyttl", type=str, help="the DNSKEY TTL", metavar="time"
    )
    parser.add_argument(
        "-r",
        dest="resign",
        default="1944000",
        type=str,
        help="the RRSIG refresh interval " "in seconds [default: 22.5 days]",
        metavar="time",
    )
    parser.add_argument(
        "-c",
        dest="compilezone",
        default=compilezone,
        type=str,
        help="path to 'named-compilezone'",
        metavar="path",
    )
    parser.add_argument(
        "-l",
        dest="checklimit",
        type=str,
        default="0",
        help="Length of time to check for " "DNSSEC coverage [default: 0 (unlimited)]",
        metavar="time",
    )
    parser.add_argument(
        "-z",
        dest="no_ksk",
        action="store_true",
        default=False,
        help="Only check zone-signing keys (ZSKs)",
    )
    parser.add_argument(
        "-k",
        dest="no_zsk",
        action="store_true",
        default=False,
        help="Only check key-signing keys (KSKs)",
    )
    parser.add_argument(
        "-D",
        "--debug",
        dest="debug_mode",
        action="store_true",
        default=False,
        help="Turn on debugging output",
    )
    parser.add_argument("-v", "--version", action="version", version=utils.version)

    args = parser.parse_args()

    if args.no_zsk and args.no_ksk:
        fatal("ERROR: -z and -k cannot be used together.")
    elif args.no_zsk or args.no_ksk:
        args.keytype = "KSK" if args.no_zsk else "ZSK"
    else:
        args.keytype = None

    if args.filename and len(args.zone) > 1:
        fatal("ERROR: -f can only be used with one zone.")

    # strip trailing dots if any
    args.zone = [x[:-1] if (len(x) > 1 and x[-1] == ".") else x for x in args.zone]

    # convert from time arguments to seconds
    try:
        if args.maxttl:
            m = parse_time(args.maxttl)
            args.maxttl = m
    except ValueError:
        pass

    try:
        if args.keyttl:
            k = parse_time(args.keyttl)
            args.keyttl = k
    except ValueError:
        pass

    try:
        if args.resign:
            r = parse_time(args.resign)
            args.resign = r
    except ValueError:
        pass

    try:
        if args.checklimit:
            lim = args.checklimit
            r = parse_time(args.checklimit)
            if r == 0:
                args.checklimit = None
            else:
                args.checklimit = time.time() + r
    except ValueError:
        pass

    # if we've got the values we need from the command line, stop now
    if args.maxttl and args.keyttl:
        return args

    # load keyttl and maxttl data from zonefile
    if args.zone and args.filename:
        try:
            zone = keyzone(args.zone[0], args.filename, args.compilezone)
            args.maxttl = args.maxttl or zone.maxttl
            args.keyttl = args.maxttl or zone.keyttl
        except Exception as e:
            print("Unable to load zone data from %s: " % args.filename, e)

    if not args.maxttl:
        output(
            "WARNING: Maximum TTL value was not specified.  Using 1 week\n"
            "\t (604800 seconds); re-run with the -m option to get more\n"
            "\t accurate results."
        )
        args.maxttl = 604800

    return args


############################################################################
# Main
############################################################################
def main():
    args = parse_args()

    print("PHASE 1--Loading keys to check for internal timing problems")

    try:
        kd = keydict(path=args.path, zones=args.zone, keyttl=args.keyttl)
    except Exception as e:
        fatal("ERROR: Unable to build key dictionary: " + str(e))

    for key in kd:
        key.check_prepub(output)
        if key.sep:
            key.check_postpub(output)
        else:
            key.check_postpub(output, args.maxttl + args.resign)

    output("PHASE 2--Scanning future key events for coverage failures")
    vreset()

    try:
        elist = eventlist(kd)
    except Exception as e:
        fatal("ERROR: Unable to build event list: " + str(e))

    errors = False
    if not args.zone:
        if not elist.coverage(None, args.keytype, args.checklimit, output):
            errors = True
    else:
        for zone in args.zone:
            try:
                if not elist.coverage(zone, args.keytype, args.checklimit, output):
                    errors = True
            except:
                output("ERROR: Coverage check failed for zone " + zone)

    sys.exit(1 if errors else 0)