# 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)