diff options
Diffstat (limited to '')
32 files changed, 4157 insertions, 0 deletions
diff --git a/bin/python/Makefile.in b/bin/python/Makefile.in new file mode 100644 index 0000000..dc1af40 --- /dev/null +++ b/bin/python/Makefile.in @@ -0,0 +1,66 @@ +# 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. + +srcdir = @srcdir@ +VPATH = @srcdir@ +top_srcdir = @top_srcdir@ + +@BIND9_MAKE_INCLUDES@ + +PYTHON = @PYTHON@ + +SUBDIRS = isc + +TARGETS = dnssec-checkds dnssec-coverage dnssec-keymgr +PYSRCS = dnssec-checkds.py dnssec-coverage.py dnssec-keymgr.py + +@BIND9_MAKE_RULES@ + +dnssec-checkds: dnssec-checkds.py + cp -f dnssec-checkds.py dnssec-checkds + chmod +x dnssec-checkds + +dnssec-coverage: dnssec-coverage.py + cp -f dnssec-coverage.py dnssec-coverage + chmod +x dnssec-coverage + +dnssec-keymgr: dnssec-keymgr.py + cp -f dnssec-keymgr.py dnssec-keymgr + chmod +x dnssec-keymgr + +installdirs: + $(SHELL) ${top_srcdir}/mkinstalldirs ${DESTDIR}${sbindir} + +install:: ${TARGETS} installdirs + ${INSTALL_SCRIPT} dnssec-checkds ${DESTDIR}${sbindir} + ${INSTALL_SCRIPT} dnssec-coverage ${DESTDIR}${sbindir} + ${INSTALL_SCRIPT} dnssec-keymgr ${DESTDIR}${sbindir} + if test -n "${PYTHON}" ; then \ + if test -n "${DESTDIR}" ; then \ + ${PYTHON} ${srcdir}/setup.py install --root=${DESTDIR} --prefix=${prefix} @PYTHON_INSTALL_LIB@ ; \ + else \ + ${PYTHON} ${srcdir}/setup.py install --prefix=${prefix} @PYTHON_INSTALL_LIB@ ; \ + fi ; \ + rm -rf build ; \ + fi + +uninstall:: + rm -f ${DESTDIR}${sbindir}/dnssec-keymgr + rm -f ${DESTDIR}${sbindir}/dnssec-coverage + rm -f ${DESTDIR}${sbindir}/dnssec-checkds + # only manually uninstall for the python package itself + +clean distclean:: + rm -f ${TARGETS} + rm -rf build + +distclean:: + rm -f dnssec-checkds.py dnssec-coverage.py dnssec-keymgr.py diff --git a/bin/python/dnssec-checkds.py.in b/bin/python/dnssec-checkds.py.in new file mode 100644 index 0000000..3ec15e2 --- /dev/null +++ b/bin/python/dnssec-checkds.py.in @@ -0,0 +1,32 @@ +#!@PYTHON@ + +# 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. + +import os +import sys + +sys.path.insert(0, os.path.dirname(sys.argv[0])) +if os.name != "nt": + if "@PYTHON_INSTALL_DIR@": # value of --with-python-install-dir + sys.path.insert(1, "@PYTHON_INSTALL_DIR@") + else: + sys.path.insert( + 1, + os.path.join( + "@prefix@", "lib", "python" + sys.version[:3], "site-packages" + ), + ) + +import isc.checkds + +if __name__ == "__main__": + isc.checkds.main() diff --git a/bin/python/dnssec-checkds.rst b/bin/python/dnssec-checkds.rst new file mode 100644 index 0000000..aa239fa --- /dev/null +++ b/bin/python/dnssec-checkds.rst @@ -0,0 +1,68 @@ +.. 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. + +.. highlight: console + +.. _man_dnssec-checkds: + +dnssec-checkds - DNSSEC delegation consistency checking tool +------------------------------------------------------------ + +Synopsis +~~~~~~~~ + +``dnssec-checkds`` [**-d**\ *dig path*] [**-D**\ *dsfromkey path*] +[**-f**\ *file*] [**-l**\ *domain*] [**-s**\ *file*] {zone} + +Description +~~~~~~~~~~~ + +``dnssec-checkds`` verifies the correctness of Delegation Signer (DS) +resource records for keys in a specified zone. + +Options +~~~~~~~ + +**-a** *algorithm* + + Specify a digest algorithm to use when converting the zones DNSKEY + records to expected DS records. This option can be repeated, so that + multiple records are checked for each DNSKEY record. + + The *algorithm* must be one of SHA-1, SHA-256, or SHA-384. These + values are case insensitive, and the hyphen may be omitted. If no + algorithm is specified, the default is SHA-256. + +**-f** *file* + + If a ``file`` is specified, then the zone is read from that file to + find the DNSKEY records. If not, then the DNSKEY records for the zone + are looked up in the DNS. + +**-s** *file* + + Specifies a prepared dsset file, such as would be generated by + ``dnssec-signzone``, to use as a source for the DS RRset instead of + querying the parent. + +**-d** *dig path* + + Specifies a path to a ``dig`` binary. Used for testing. + +**-D** *dsfromkey path* + + Specifies a path to a ``dnssec-dsfromkey`` binary. Used for testing. + +See Also +~~~~~~~~ + +``dnssec-dsfromkey``\ (8), ``dnssec-keygen``\ (8), +``dnssec-signzone``\ (8), diff --git a/bin/python/dnssec-coverage.py.in b/bin/python/dnssec-coverage.py.in new file mode 100644 index 0000000..a82dfe3 --- /dev/null +++ b/bin/python/dnssec-coverage.py.in @@ -0,0 +1,32 @@ +#!@PYTHON@ + +# 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. + +import os +import sys + +sys.path.insert(0, os.path.dirname(sys.argv[0])) +if os.name != "nt": + if "@PYTHON_INSTALL_DIR@": # value of --with-python-install-dir + sys.path.insert(1, "@PYTHON_INSTALL_DIR@") + else: + sys.path.insert( + 1, + os.path.join( + "@prefix@", "lib", "python" + sys.version[:3], "site-packages" + ), + ) + +import isc.coverage + +if __name__ == "__main__": + isc.coverage.main() diff --git a/bin/python/dnssec-coverage.rst b/bin/python/dnssec-coverage.rst new file mode 100644 index 0000000..e2658cb --- /dev/null +++ b/bin/python/dnssec-coverage.rst @@ -0,0 +1,152 @@ +.. 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. + +.. highlight: console + +.. _man_dnssec-coverage: + +dnssec-coverage - checks future DNSKEY coverage for a zone +---------------------------------------------------------- + +Synopsis +~~~~~~~~ + +``dnssec-coverage`` [**-K**\ *directory*] [**-l**\ *length*] +[**-f**\ *file*] [**-d**\ *DNSKEY TTL*] [**-m**\ *max TTL*] +[**-r**\ *interval*] [**-c**\ *compilezone path*] [**-k**] [**-z**] +[zone...] + +Description +~~~~~~~~~~~ + +``dnssec-coverage`` verifies that the DNSSEC keys for a given zone or a +set of zones have timing metadata set properly to ensure no future +lapses in DNSSEC coverage. + +If ``zone`` is specified, then keys found in the key repository matching +that zone are scanned, and an ordered list is generated of the events +scheduled for that key (i.e., publication, activation, inactivation, +deletion). The list of events is walked in order of occurrence. Warnings +are generated if any event is scheduled which could cause the zone to +enter a state in which validation failures might occur: for example, if +the number of published or active keys for a given algorithm drops to +zero, or if a key is deleted from the zone too soon after a new key is +rolled, and cached data signed by the prior key has not had time to +expire from resolver caches. + +If ``zone`` is not specified, then all keys in the key repository will +be scanned, and all zones for which there are keys will be analyzed. +(Note: This method of reporting is only accurate if all the zones that +have keys in a given repository share the same TTL parameters.) + +Options +~~~~~~~ + +**-K** *directory* + + Sets the directory in which keys can be found. Defaults to the + current working directory. + +**-f** *file* + + If a ``file`` is specified, then the zone is read from that file; the + largest TTL and the DNSKEY TTL are determined directly from the zone + data, and the ``-m`` and ``-d`` options do not need to be specified + on the command line. + +**-l** *duration* + + The length of time to check for DNSSEC coverage. Key events scheduled + further into the future than ``duration`` will be ignored, and + assumed to be correct. + + The value of ``duration`` can be set in seconds, or in larger units + of time by adding a suffix: mi for minutes, h for hours, d for days, + w for weeks, mo for months, y for years. + +**-m** *maximum TTL* + + Sets the value to be used as the maximum TTL for the zone or zones + being analyzed when determining whether there is a possibility of + validation failure. When a zone-signing key is deactivated, there + must be enough time for the record in the zone with the longest TTL + to have expired from resolver caches before that key can be purged + from the DNSKEY RRset. If that condition does not apply, a warning + will be generated. + + The length of the TTL can be set in seconds, or in larger units of + time by adding a suffix: mi for minutes, h for hours, d for days, w + for weeks, mo for months, y for years. + + This option is not necessary if the ``-f`` has been used to specify a + zone file. If ``-f`` has been specified, this option may still be + used; it will override the value found in the file. + + If this option is not used and the maximum TTL cannot be retrieved + from a zone file, a warning is generated and a default value of 1 + week is used. + +**-d** *DNSKEY TTL* + + Sets the value to be used as the DNSKEY TTL for the zone or zones + being analyzed when determining whether there is a possibility of + validation failure. When a key is rolled (that is, replaced with a + new key), there must be enough time for the old DNSKEY RRset to have + expired from resolver caches before the new key is activated and + begins generating signatures. If that condition does not apply, a + warning will be generated. + + The length of the TTL can be set in seconds, or in larger units of + time by adding a suffix: mi for minutes, h for hours, d for days, w + for weeks, mo for months, y for years. + + This option is not necessary if ``-f`` has been used to specify a + zone file from which the TTL of the DNSKEY RRset can be read, or if a + default key TTL was set using ith the ``-L`` to ``dnssec-keygen``. If + either of those is true, this option may still be used; it will + override the values found in the zone file or the key file. + + If this option is not used and the key TTL cannot be retrieved from + the zone file or the key file, then a warning is generated and a + default value of 1 day is used. + +**-r** *resign interval* + + Sets the value to be used as the resign interval for the zone or + zones being analyzed when determining whether there is a possibility + of validation failure. This value defaults to 22.5 days, which is + also the default in ``named``. However, if it has been changed by the + ``sig-validity-interval`` option in named.conf, then it should also + be changed here. + + The length of the interval can be set in seconds, or in larger units + of time by adding a suffix: mi for minutes, h for hours, d for days, + w for weeks, mo for months, y for years. + +**-k** + + Only check KSK coverage; ignore ZSK events. Cannot be used with + ``-z``. + +**-z** + + Only check ZSK coverage; ignore KSK events. Cannot be used with + ``-k``. + +**-c** *compilezone path* + + Specifies a path to a ``named-compilezone`` binary. Used for testing. + +See Also +~~~~~~~~ + +``dnssec-checkds``\ (8), ``dnssec-dsfromkey``\ (8), +``dnssec-keygen``\ (8), ``dnssec-signzone``\ (8) diff --git a/bin/python/dnssec-keymgr.py.in b/bin/python/dnssec-keymgr.py.in new file mode 100644 index 0000000..f8ee013 --- /dev/null +++ b/bin/python/dnssec-keymgr.py.in @@ -0,0 +1,32 @@ +#!@PYTHON@ + +# 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. + +import os +import sys + +sys.path.insert(0, os.path.dirname(sys.argv[0])) +if os.name != "nt": + if "@PYTHON_INSTALL_DIR@": # value of --with-python-install-dir + sys.path.insert(1, "@PYTHON_INSTALL_DIR@") + else: + sys.path.insert( + 1, + os.path.join( + "@prefix@", "lib", "python" + sys.version[:3], "site-packages" + ), + ) + +import isc.keymgr + +if __name__ == "__main__": + isc.keymgr.main() diff --git a/bin/python/dnssec-keymgr.rst b/bin/python/dnssec-keymgr.rst new file mode 100644 index 0000000..4320541 --- /dev/null +++ b/bin/python/dnssec-keymgr.rst @@ -0,0 +1,223 @@ +.. 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. + +.. highlight: console + +.. _man_dnssec-keymgr: + +dnssec-keymgr - Ensures correct DNSKEY coverage based on a defined policy +------------------------------------------------------------------------- + +Synopsis +~~~~~~~~ + +:program:`dnssec-keymgr` [**-K**\ *directory*] [**-c**\ *file*] [**-f**] [**-k**] [**-q**] [**-v**] [**-z**] [**-g**\ *path*] [**-s**\ *path*] [zone...] + +Description +~~~~~~~~~~~ + +``dnssec-keymgr`` is a high level Python wrapper to facilitate the key +rollover process for zones handled by BIND. It uses the BIND commands +for manipulating DNSSEC key metadata: ``dnssec-keygen`` and +``dnssec-settime``. + +DNSSEC policy can be read from a configuration file (default +/etc/dnssec-policy.conf), from which the key parameters, publication and +rollover schedule, and desired coverage duration for any given zone can +be determined. This file may be used to define individual DNSSEC +policies on a per-zone basis, or to set a "default" policy used for all +zones. + +When ``dnssec-keymgr`` runs, it examines the DNSSEC keys for one or more +zones, comparing their timing metadata against the policies for those +zones. If key settings do not conform to the DNSSEC policy (for example, +because the policy has been changed), they are automatically corrected. + +A zone policy can specify a duration for which we want to ensure the key +correctness (``coverage``). It can also specify a rollover period +(``roll-period``). If policy indicates that a key should roll over +before the coverage period ends, then a successor key will automatically +be created and added to the end of the key series. + +If zones are specified on the command line, ``dnssec-keymgr`` will +examine only those zones. If a specified zone does not already have keys +in place, then keys will be generated for it according to policy. + +If zones are *not* specified on the command line, then ``dnssec-keymgr`` +will search the key directory (either the current working directory or +the directory set by the ``-K`` option), and check the keys for all the +zones represented in the directory. + +Key times that are in the past will not be updated unless the ``-f`` is +used (see below). Key inactivation and deletion times that are less than +five minutes in the future will be delayed by five minutes. + +It is expected that this tool will be run automatically and unattended +(for example, by ``cron``). + +Options +~~~~~~~ + +**-c** *file* + + If ``-c`` is specified, then the DNSSEC policy is read from ``file``. + (If not specified, then the policy is read from + /etc/dnssec-policy.conf; if that file doesnt exist, a built-in global + default policy is used.) + +**-f** + + Force: allow updating of key events even if they are already in the + past. This is not recommended for use with zones in which keys have + already been published. However, if a set of keys has been generated + all of which have publication and activation dates in the past, but + the keys have not been published in a zone as yet, then this option + can be used to clean them up and turn them into a proper series of + keys with appropriate rollover intervals. + +**-g** *keygen-path* + + Specifies a path to a ``dnssec-keygen`` binary. Used for testing. See + also the ``-s`` option. + +**-h** + + Print the ``dnssec-keymgr`` help summary and exit. + +**-K** *directory* + + Sets the directory in which keys can be found. Defaults to the + current working directory. + +**-k** + + Only apply policies to KSK keys. See also the ``-z`` option. + +**-q** + + Quiet: suppress printing of ``dnssec-keygen`` and ``dnssec-settime``. + +**-s** *settime-path* + + Specifies a path to a ``dnssec-settime`` binary. Used for testing. + See also the ``-g`` option. + +**-v** + + Print the ``dnssec-keymgr`` version and exit. + +**-z** + + Only apply policies to ZSK keys. See also the ``-k`` option. + +Policy Configuration +~~~~~~~~~~~~~~~~~~~~ + +The dnssec-policy.conf file can specify three kinds of policies: + + · *Policy classes* (``policy``\ *name*\ ``{ ... };``) can be + inherited by zone policies or other policy classes; these can be used + to create sets of different security profiles. For example, a policy + class ``normal`` might specify 1024-bit key sizes, but a class + ``extra`` might specify 2048 bits instead; ``extra`` would be used + for zones that had unusually high security needs. + +.. + + · *Algorithm policies:* (``algorithm-policy``\ *algorithm*\ ``{ ... + };`` ) override default per-algorithm settings. For example, by + default, RSASHA256 keys use 2048-bit key sizes for both KSK and ZSK. + This can be modified using ``algorithm-policy``, and the new key + sizes would then be used for any key of type RSASHA256. + + · *Zone policies:* (``zone``\ *name*\ ``{ ... };`` ) set policy for a + single zone by name. A zone policy can inherit a policy class by + including a ``policy`` option. Zone names beginning with digits + (i.e., 0-9) must be quoted. If a zone does not have its own policy + then the "default" policy applies. + +Options that can be specified in policies: + +``algorithm`` *name*; + + The key algorithm. If no policy is defined, the default is RSASHA256. + +``coverage`` *duration*; + + The length of time to ensure that keys will be correct; no action + will be taken to create new keys to be activated after this time. + This can be represented as a number of seconds, or as a duration + using human-readable units (examples: "1y" or "6 months"). A default + value for this option can be set in algorithm policies as well as in + policy classes or zone policies. If no policy is configured, the + default is six months. + +``directory`` *path*; + + Specifies the directory in which keys should be stored. + +``key-size`` *keytype* *size*; + + Specifies the number of bits to use in creating keys. The keytype is + either "zsk" or "ksk". A default value for this option can be set in + algorithm policies as well as in policy classes or zone policies. If + no policy is configured, the default is 2048 bits for RSA keys. + +``keyttl`` *duration*; + + The key TTL. If no policy is defined, the default is one hour. + +``post-publish`` *keytype* *duration*; + + How long after inactivation a key should be deleted from the zone. + Note: If ``roll-period`` is not set, this value is ignored. The + keytype is either "zsk" or "ksk". A default duration for this option + can be set in algorithm policies as well as in policy classes or zone + policies. The default is one month. + +``pre-publish`` *keytype* *duration*; + + How long before activation a key should be published. Note: If + ``roll-period`` is not set, this value is ignored. The keytype is + either "zsk" or "ksk". A default duration for this option can be set + in algorithm policies as well as in policy classes or zone policies. + The default is one month. + +``roll-period`` *keytype* *duration*; + + How frequently keys should be rolled over. The keytype is either + "zsk" or "ksk". A default duration for this option can be set in + algorithm policies as well as in policy classes or zone policies. If + no policy is configured, the default is one year for ZSKs. KSKs do + not roll over by default. + +``standby`` *keytype* *number*; + + Not yet implemented. + +Remaining Work +~~~~~~~~~~~~~~ + + · Enable scheduling of KSK rollovers using the ``-P sync`` and ``-D + sync`` options to ``dnssec-keygen`` and ``dnssec-settime``. Check the + parent zone (as in ``dnssec-checkds``) to determine when its safe for + the key to roll. + +.. + + · Allow configuration of standby keys and use of the REVOKE bit, for + keys that use RFC 5011 semantics. + +See Also +~~~~~~~~ + +``dnssec-coverage``\ (8), ``dnssec-keygen``\ (8), +``dnssec-settime``\ (8), ``dnssec-checkds``\ (8) diff --git a/bin/python/isc/Makefile.in b/bin/python/isc/Makefile.in new file mode 100644 index 0000000..f175c6c --- /dev/null +++ b/bin/python/isc/Makefile.in @@ -0,0 +1,45 @@ +# 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. + +srcdir = @srcdir@ +VPATH = @srcdir@ +top_srcdir = @top_srcdir@ + +@BIND9_MAKE_INCLUDES@ + +SUBDIRS = tests + +PYTHON = @PYTHON@ + +PYSRCS = __init__.py checkds.py coverage.py dnskey.py eventlist.py \ + keydict.py keyevent.py keymgr.py keyseries.py keyzone.py \ + policy.py rndc.py utils.py + +TARGETS = parsetab.py + +@BIND9_MAKE_RULES@ + +.SUFFIXES: .py .pyc +.py.pyc: + $(PYTHON) -m compileall . + +parsetab.py: policy.py + $(PYTHON) policy.py parse /dev/null > /dev/null + PYTHONPATH=${srcdir} $(PYTHON) -m parsetab + +check test: subdirs + +clean distclean:: + rm -f *.pyc parser.out parsetab.py + rm -rf __pycache__ build + +distclean:: + rm -rf ${PYSRCS} diff --git a/bin/python/isc/__init__.py.in b/bin/python/isc/__init__.py.in new file mode 100644 index 0000000..405c3d7 --- /dev/null +++ b/bin/python/isc/__init__.py.in @@ -0,0 +1,38 @@ +#!/usr/bin/python3 + +# 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. + +__all__ = [ + "checkds", + "coverage", + "keymgr", + "dnskey", + "eventlist", + "keydict", + "keyevent", + "keyseries", + "keyzone", + "policy", + "parsetab", + "rndc", + "utils", +] + +from isc.dnskey import * +from isc.eventlist import * +from isc.keydict import * +from isc.keyevent import * +from isc.keyseries import * +from isc.keyzone import * +from isc.policy import * +from isc.rndc import * +from isc.utils import * diff --git a/bin/python/isc/checkds.py.in b/bin/python/isc/checkds.py.in new file mode 100644 index 0000000..f2e6562 --- /dev/null +++ b/bin/python/isc/checkds.py.in @@ -0,0 +1,226 @@ +# 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. + +import argparse +import os +import sys +from subprocess import Popen, PIPE + +from isc.utils import prefix, version + +prog = "dnssec-checkds" + + +############################################################################ +# SECRR class: +# Class for DS resource record +############################################################################ +class SECRR: + hashalgs = {1: "SHA-1", 2: "SHA-256", 3: "GOST", 4: "SHA-384"} + rrname = "" + rrclass = "IN" + keyid = None + keyalg = None + hashalg = None + digest = "" + ttl = 0 + + def __init__(self, rrtext): + if not rrtext: + raise Exception + + # 'str' does not have decode method in python3 + if type(rrtext) is not str: + fields = rrtext.decode("ascii").split() + else: + fields = rrtext.split() + if len(fields) < 7: + raise Exception + + self.rrtype = "DS" + self.rrname = fields[0].lower() + + fields = fields[1:] + if fields[0].upper() in ["IN", "CH", "HS"]: + self.rrclass = fields[0].upper() + fields = fields[1:] + else: + self.ttl = int(fields[0]) + self.rrclass = fields[1].upper() + fields = fields[2:] + + if fields[0].upper() != self.rrtype: + raise Exception("%s does not match %s" % (fields[0].upper(), self.rrtype)) + + self.keyid, self.keyalg, self.hashalg = map(int, fields[1:4]) + self.digest = "".join(fields[4:]).upper() + + def __repr__(self): + return "%s %s %s %d %d %d %s" % ( + self.rrname, + self.rrclass, + self.rrtype, + self.keyid, + self.keyalg, + self.hashalg, + self.digest, + ) + + def __eq__(self, other): + return self.__repr__() == other.__repr__() + + +############################################################################ +# check: +# Fetch DS RRset for the given zone from the DNS; fetch DNSKEY +# RRset from the masterfile if specified, or from DNS if not. +# Generate a set of expected DS records from the DNSKEY RRset, +# and report on congruency. +############################################################################ +def check(zone, args): + rrlist = [] + if args.dssetfile: + fp = open(args.dssetfile).read() + else: + cmd = [args.dig, "+noall", "+answer", "-t", "ds", "-q", zone] + fp, _ = Popen(cmd, stdout=PIPE).communicate() + + for line in fp.splitlines(): + if type(line) is not str: + line = line.decode("ascii") + rrlist.append(SECRR(line)) + rrlist = sorted(rrlist, key=lambda rr: (rr.keyid, rr.keyalg, rr.hashalg)) + + klist = [] + + cmd = [args.dsfromkey] + for algo in args.algo: + cmd += ["-a", algo] + + if args.masterfile: + cmd += ["-f", args.masterfile, zone] + fp, _ = Popen(cmd, stdout=PIPE).communicate() + else: + intods, _ = Popen( + [args.dig, "+noall", "+answer", "-t", "dnskey", "-q", zone], stdout=PIPE + ).communicate() + cmd += ["-f", "-", zone] + fp, _ = Popen(cmd, stdin=PIPE, stdout=PIPE).communicate(intods) + + for line in fp.splitlines(): + if type(line) is not str: + line = line.decode("ascii") + klist.append(SECRR(line)) + + if len(klist) < 1: + print("No DNSKEY records found in zone apex") + return False + + match = True + for rr in rrlist: + if rr not in klist: + print( + "KSK for %s %s/%03d/%05d (%s) missing from child" + % ( + rr.rrtype, + rr.rrname.strip("."), + rr.keyalg, + rr.keyid, + SECRR.hashalgs[rr.hashalg], + ) + ) + match = False + for rr in klist: + if rr not in rrlist: + print( + "%s for KSK %s/%03d/%05d (%s) missing from parent" + % ( + rr.rrtype, + rr.rrname.strip("."), + rr.keyalg, + rr.keyid, + SECRR.hashalgs[rr.hashalg], + ) + ) + match = False + for rr in klist: + if rr in rrlist: + print( + "%s for KSK %s/%03d/%05d (%s) found in parent" + % ( + rr.rrtype, + rr.rrname.strip("."), + rr.keyalg, + rr.keyid, + SECRR.hashalgs[rr.hashalg], + ) + ) + + return match + + +############################################################################ +# parse_args: +# Read command line arguments, set global 'args' structure +############################################################################ +def parse_args(): + parser = argparse.ArgumentParser(description=prog + ": checks DS coverage") + + bindir = "bin" + sbindir = "bin" if os.name == "nt" else "sbin" + + parser.add_argument("zone", type=str, help="zone to check") + parser.add_argument( + "-a", + "--algo", + dest="algo", + action="append", + default=[], + type=str, + help="DS digest algorithm", + ) + parser.add_argument( + "-d", + "--dig", + dest="dig", + default=os.path.join(prefix(bindir), "dig"), + type=str, + help="path to 'dig'", + ) + parser.add_argument( + "-D", + "--dsfromkey", + dest="dsfromkey", + default=os.path.join(prefix(sbindir), "dnssec-dsfromkey"), + type=str, + help="path to 'dnssec-dsfromkey'", + ) + parser.add_argument( + "-f", "--file", dest="masterfile", type=str, help="zone master file" + ) + parser.add_argument( + "-s", "--dsset", dest="dssetfile", type=str, help="prepared DSset file" + ) + parser.add_argument("-v", "--version", action="version", version=version) + args = parser.parse_args() + + args.zone = args.zone.strip(".") + + return args + + +############################################################################ +# Main +############################################################################ +def main(): + args = parse_args() + match = check(args.zone, args) + exit(0 if match else 1) diff --git a/bin/python/isc/coverage.py.in b/bin/python/isc/coverage.py.in new file mode 100644 index 0000000..e9be265 --- /dev/null +++ b/bin/python/isc/coverage.py.in @@ -0,0 +1,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) diff --git a/bin/python/isc/dnskey.py.in b/bin/python/isc/dnskey.py.in new file mode 100644 index 0000000..c4cdc57 --- /dev/null +++ b/bin/python/isc/dnskey.py.in @@ -0,0 +1,570 @@ +# 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. + +import os +import time +import calendar +from subprocess import Popen, PIPE + + +######################################################################## +# Class dnskey +######################################################################## +class TimePast(Exception): + def __init__(self, key, prop, value): + super(TimePast, self).__init__( + "%s time for key %s (%d) is already past" % (prop, key, value) + ) + + +class dnskey: + """An individual DNSSEC key. Identified by path, name, algorithm, keyid. + Contains a dictionary of metadata events.""" + + _PROPS = ( + "Created", + "Publish", + "Activate", + "Inactive", + "Delete", + "Revoke", + "DSPublish", + "SyncPublish", + "SyncDelete", + ) + _OPTS = (None, "-P", "-A", "-I", "-D", "-R", None, "-Psync", "-Dsync") + + _ALGNAMES = ( + None, + "RSAMD5", + "DH", + "DSA", + None, + "RSASHA1", + "NSEC3DSA", + "NSEC3RSASHA1", + "RSASHA256", + None, + "RSASHA512", + None, + "ECCGOST", + "ECDSAP256SHA256", + "ECDSAP384SHA384", + "ED25519", + "ED448", + ) + + def __init__(self, key, directory=None, keyttl=None): + # this makes it possible to use algname as a class or instance method + if isinstance(key, tuple) and len(key) == 3: + self._dir = directory or "." + (name, alg, keyid) = key + self.fromtuple(name, alg, keyid, keyttl) + + self._dir = directory or os.path.dirname(key) or "." + key = os.path.basename(key) + (name, alg, keyid) = key.split("+") + name = name[1:-1] + alg = int(alg) + keyid = int(keyid.split(".")[0]) + self.fromtuple(name, alg, keyid, keyttl) + + def fromtuple(self, name, alg, keyid, keyttl): + if name.endswith("."): + fullname = name + name = name.rstrip(".") + else: + fullname = name + "." + + keystr = "K%s+%03d+%05d" % (fullname, alg, keyid) + key_file = self._dir + (self._dir and os.sep or "") + keystr + ".key" + private_file = self._dir + (self._dir and os.sep or "") + keystr + ".private" + + self.keystr = keystr + + self.name = name + self.alg = int(alg) + self.keyid = int(keyid) + self.fullname = fullname + + kfp = open(key_file, "r") + for line in kfp: + if line[0] == ";": + continue + tokens = line.split() + if not tokens: + continue + + if tokens[1].lower() in ("in", "ch", "hs"): + septoken = 3 + self.ttl = keyttl + else: + septoken = 4 + self.ttl = int(tokens[1]) if not keyttl else keyttl + + if (int(tokens[septoken]) & 0x1) == 1: + self.sep = True + else: + self.sep = False + kfp.close() + + pfp = open(private_file, "r") + + self.metadata = dict() + self._changed = dict() + self._delete = dict() + self._times = dict() + self._fmttime = dict() + self._timestamps = dict() + self._original = dict() + self._origttl = None + + for line in pfp: + line = line.strip() + if not line or line[0] in ("!#"): + continue + punctuation = [line.find(c) for c in ":= "] + [len(line)] + found = min([pos for pos in punctuation if pos != -1]) + name = line[:found].rstrip() + value = line[found:].lstrip(":= ").rstrip() + self.metadata[name] = value + + for prop in dnskey._PROPS: + self._changed[prop] = False + if prop in self.metadata: + t = self.parsetime(self.metadata[prop]) + self._times[prop] = t + self._fmttime[prop] = self.formattime(t) + self._timestamps[prop] = self.epochfromtime(t) + self._original[prop] = self._timestamps[prop] + else: + self._times[prop] = None + self._fmttime[prop] = None + self._timestamps[prop] = None + self._original[prop] = None + + pfp.close() + + def commit(self, settime_bin, **kwargs): + quiet = kwargs.get("quiet", False) + cmd = [] + first = True + + if self._origttl is not None: + cmd += ["-L", str(self.ttl)] + + for prop, opt in zip(dnskey._PROPS, dnskey._OPTS): + if not opt or not self._changed[prop]: + continue + + delete = False + if prop in self._delete and self._delete[prop]: + delete = True + + when = "none" if delete else self._fmttime[prop] + cmd += [opt, when] + first = False + + if cmd: + fullcmd = ( + [settime_bin, "-K", self._dir] + + cmd + + [ + self.keystr, + ] + ) + if not quiet: + print("# " + " ".join(fullcmd)) + try: + p = Popen(fullcmd, stdout=PIPE, stderr=PIPE) + stdout, stderr = p.communicate() + if stderr: + raise Exception(str(stderr)) + except Exception as e: + raise Exception("unable to run %s: %s" % (settime_bin, str(e))) + self._origttl = None + for prop in dnskey._PROPS: + self._original[prop] = self._timestamps[prop] + self._changed[prop] = False + + @classmethod + def generate( + cls, + keygen_bin, + randomdev, + keys_dir, + name, + alg, + keysize, + sep, + ttl, + publish=None, + activate=None, + **kwargs + ): + quiet = kwargs.get("quiet", False) + + keygen_cmd = [keygen_bin, "-q", "-K", keys_dir, "-L", str(ttl)] + + if randomdev: + keygen_cmd += ["-r", randomdev] + + if sep: + keygen_cmd.append("-fk") + + if alg: + keygen_cmd += ["-a", alg] + + if keysize: + keygen_cmd += ["-b", str(keysize)] + + if publish: + t = dnskey.timefromepoch(publish) + keygen_cmd += ["-P", dnskey.formattime(t)] + + if activate: + t = dnskey.timefromepoch(activate) + keygen_cmd += ["-A", dnskey.formattime(activate)] + + keygen_cmd.append(name) + + if not quiet: + print("# " + " ".join(keygen_cmd)) + + p = Popen(keygen_cmd, stdout=PIPE, stderr=PIPE) + stdout, stderr = p.communicate() + if stderr: + raise Exception("unable to generate key: " + str(stderr)) + + try: + keystr = stdout.splitlines()[0].decode("ascii") + newkey = dnskey(keystr, keys_dir, ttl) + return newkey + except Exception as e: + raise Exception("unable to parse generated key: %s" % str(e)) + + def generate_successor(self, keygen_bin, randomdev, prepublish, **kwargs): + quiet = kwargs.get("quiet", False) + + if not self.inactive(): + raise Exception("predecessor key %s has no inactive date" % self) + + keygen_cmd = [keygen_bin, "-q", "-K", self._dir, "-S", self.keystr] + + if self.ttl: + keygen_cmd += ["-L", str(self.ttl)] + + if randomdev: + keygen_cmd += ["-r", randomdev] + + if prepublish: + keygen_cmd += ["-i", str(prepublish)] + + if not quiet: + print("# " + " ".join(keygen_cmd)) + + p = Popen(keygen_cmd, stdout=PIPE, stderr=PIPE) + stdout, stderr = p.communicate() + if stderr: + raise Exception("unable to generate key: " + stderr) + + try: + keystr = stdout.splitlines()[0].decode("ascii") + newkey = dnskey(keystr, self._dir, self.ttl) + return newkey + except: + raise Exception("unable to generate successor for key %s" % self) + + @staticmethod + def algstr(alg): + name = None + if alg in range(len(dnskey._ALGNAMES)): + name = dnskey._ALGNAMES[alg] + return name if name else ("%03d" % alg) + + @staticmethod + def algnum(alg): + if not alg: + return None + alg = alg.upper() + try: + return dnskey._ALGNAMES.index(alg) + except ValueError: + return None + + def algname(self, alg=None): + return self.algstr(alg or self.alg) + + @staticmethod + def timefromepoch(secs): + return time.gmtime(secs) + + @staticmethod + def parsetime(string): + return time.strptime(string, "%Y%m%d%H%M%S") + + @staticmethod + def epochfromtime(t): + return calendar.timegm(t) + + @staticmethod + def formattime(t): + return time.strftime("%Y%m%d%H%M%S", t) + + def setmeta(self, prop, secs, now, **kwargs): + force = kwargs.get("force", False) + + if self._timestamps[prop] == secs: + return + + if ( + self._original[prop] is not None + and self._original[prop] < now + and not force + ): + raise TimePast(self, prop, self._original[prop]) + + if secs is None: + self._changed[prop] = False if self._original[prop] is None else True + + self._delete[prop] = True + self._timestamps[prop] = None + self._times[prop] = None + self._fmttime[prop] = None + return + + t = self.timefromepoch(secs) + self._timestamps[prop] = secs + self._times[prop] = t + self._fmttime[prop] = self.formattime(t) + self._changed[prop] = ( + False if self._original[prop] == self._timestamps[prop] else True + ) + + def gettime(self, prop): + return self._times[prop] + + def getfmttime(self, prop): + return self._fmttime[prop] + + def gettimestamp(self, prop): + return self._timestamps[prop] + + def created(self): + return self._timestamps["Created"] + + def syncpublish(self): + return self._timestamps["SyncPublish"] + + def setsyncpublish(self, secs, now=time.time(), **kwargs): + self.setmeta("SyncPublish", secs, now, **kwargs) + + def publish(self): + return self._timestamps["Publish"] + + def setpublish(self, secs, now=time.time(), **kwargs): + self.setmeta("Publish", secs, now, **kwargs) + + def activate(self): + return self._timestamps["Activate"] + + def setactivate(self, secs, now=time.time(), **kwargs): + self.setmeta("Activate", secs, now, **kwargs) + + def revoke(self): + return self._timestamps["Revoke"] + + def setrevoke(self, secs, now=time.time(), **kwargs): + self.setmeta("Revoke", secs, now, **kwargs) + + def inactive(self): + return self._timestamps["Inactive"] + + def setinactive(self, secs, now=time.time(), **kwargs): + self.setmeta("Inactive", secs, now, **kwargs) + + def delete(self): + return self._timestamps["Delete"] + + def setdelete(self, secs, now=time.time(), **kwargs): + self.setmeta("Delete", secs, now, **kwargs) + + def syncdelete(self): + return self._timestamps["SyncDelete"] + + def setsyncdelete(self, secs, now=time.time(), **kwargs): + self.setmeta("SyncDelete", secs, now, **kwargs) + + def setttl(self, ttl): + if ttl is None or self.ttl == ttl: + return + elif self._origttl is None: + self._origttl = self.ttl + self.ttl = ttl + elif self._origttl == ttl: + self._origttl = None + self.ttl = ttl + else: + self.ttl = ttl + + def keytype(self): + return "KSK" if self.sep else "ZSK" + + def __str__(self): + return "%s/%s/%05d" % (self.name, self.algname(), self.keyid) + + def __repr__(self): + return "%s/%s/%05d (%s)" % ( + self.name, + self.algname(), + self.keyid, + ("KSK" if self.sep else "ZSK"), + ) + + def date(self): + return self.activate() or self.publish() or self.created() + + # keys are sorted first by zone name, then by algorithm. within + # the same name/algorithm, they are sorted according to their + # 'date' value: the activation date if set, OR the publication + # if set, OR the creation date. + def __lt__(self, other): + if self.name != other.name: + return self.name < other.name + if self.alg != other.alg: + return self.alg < other.alg + return self.date() < other.date() + + def check_prepub(self, output=None): + def noop(*args, **kwargs): + pass + + if not output: + output = noop + + now = int(time.time()) + a = self.activate() + p = self.publish() + + if not a: + return False + + if not p: + if a > now: + output( + "WARNING: Key %s is scheduled for\n" + "\t activation but not for publication." % repr(self) + ) + return False + + if p <= now and a <= now: + return True + + if p == a: + output( + "WARNING: %s is scheduled to be\n" + "\t published and activated at the same time. This\n" + "\t could result in a coverage gap if the zone was\n" + "\t previously signed. Activation should be at least\n" + "\t %s after publication." + % (repr(self), dnskey.duration(self.ttl) or "one DNSKEY TTL") + ) + return True + + if a < p: + output("WARNING: Key %s is active before it is published" % repr(self)) + return False + + if self.ttl is not None and a - p < self.ttl: + output( + "WARNING: Key %s is activated too soon\n" + "\t after publication; this could result in coverage \n" + "\t gaps due to resolver caches containing old data.\n" + "\t Activation should be at least %s after\n" + "\t publication." + % (repr(self), dnskey.duration(self.ttl) or "one DNSKEY TTL") + ) + return False + + return True + + def check_postpub(self, output=None, timespan=None): + def noop(*args, **kwargs): + pass + + if output is None: + output = noop + + if timespan is None: + timespan = self.ttl + + if timespan is None: + output("WARNING: Key %s using default TTL." % repr(self)) + timespan = 60 * 60 * 24 + + now = time.time() + d = self.delete() + i = self.inactive() + + if not d: + return False + + if not i: + if d > now: + output( + "WARNING: Key %s is scheduled for\n" + "\t deletion but not for inactivation." % repr(self) + ) + return False + + if d < now and i < now: + return True + + if d < i: + output( + "WARNING: Key %s is scheduled for\n" + "\t deletion before inactivation." % repr(self) + ) + return False + + if d - i < timespan: + output( + "WARNING: Key %s scheduled for\n" + "\t deletion too soon after deactivation; this may \n" + "\t result in coverage gaps due to resolver caches\n" + "\t containing old data. Deletion should be at least\n" + "\t %s after inactivation." % (repr(self), dnskey.duration(timespan)) + ) + return False + + return True + + @staticmethod + def duration(secs): + if not secs: + return None + + units = [ + ("year", 60 * 60 * 24 * 365), + ("month", 60 * 60 * 24 * 30), + ("day", 60 * 60 * 24), + ("hour", 60 * 60), + ("minute", 60), + ("second", 1), + ] + + output = [] + for unit in units: + v, secs = secs // unit[1], secs % unit[1] + if v > 0: + output.append("%d %s%s" % (v, unit[0], "s" if v > 1 else "")) + + return ", ".join(output) diff --git a/bin/python/isc/eventlist.py.in b/bin/python/isc/eventlist.py.in new file mode 100644 index 0000000..f2f26aa --- /dev/null +++ b/bin/python/isc/eventlist.py.in @@ -0,0 +1,178 @@ +# 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 collections import defaultdict +from .dnskey import * +from .keydict import * +from .keyevent import * + + +class eventlist: + _K = defaultdict(lambda: defaultdict(list)) + _Z = defaultdict(lambda: defaultdict(list)) + _zones = set() + _kdict = None + + def __init__(self, kdict): + properties = [ + "SyncPublish", + "Publish", + "SyncDelete", + "Activate", + "Inactive", + "Delete", + ] + self._kdict = kdict + for zone in kdict.zones(): + self._zones.add(zone) + for alg, keys in kdict[zone].items(): + for k in keys.values(): + for prop in properties: + t = k.gettime(prop) + if not t: + continue + e = keyevent(prop, k, t) + if k.sep: + self._K[zone][alg].append(e) + else: + self._Z[zone][alg].append(e) + + self._K[zone][alg] = sorted( + self._K[zone][alg], key=lambda event: event.when + ) + self._Z[zone][alg] = sorted( + self._Z[zone][alg], key=lambda event: event.when + ) + + # scan events per zone, algorithm, and key type, in order of + # occurrence, noting inconsistent states when found + def coverage(self, zone, keytype, until, output=None): + def noop(*args, **kwargs): + pass + + if not output: + output = noop + + no_zsk = True if (keytype and keytype == "KSK") else False + no_ksk = True if (keytype and keytype == "ZSK") else False + kok = zok = True + found = False + + if zone and not zone in self._zones: + output("ERROR: No key events found for %s" % zone) + return False + + if zone: + found = True + if not no_ksk: + kok = self.checkzone(zone, "KSK", until, output) + if not no_zsk: + zok = self.checkzone(zone, "ZSK", until, output) + else: + for z in self._zones: + if not no_ksk and z in self._K.keys(): + found = True + kok = self.checkzone(z, "KSK", until, output) + if not no_zsk and z in self._Z.keys(): + found = True + zok = self.checkzone(z, "ZSK", until, output) + + if not found: + output("ERROR: No key events found") + return False + + return kok and zok + + def checkzone(self, zone, keytype, until, output): + allok = True + if keytype == "KSK": + kz = self._K[zone] + else: + kz = self._Z[zone] + + for alg in kz.keys(): + output( + "Checking scheduled %s events for zone %s, " + "algorithm %s..." % (keytype, zone, dnskey.algstr(alg)) + ) + ok = eventlist.checkset(kz[alg], keytype, until, output) + if ok: + output("No errors found") + allok = allok and ok + + return allok + + @staticmethod + def showset(eventset, output): + if not eventset: + return + output(" " + eventset[0].showtime() + ":", skip=False) + for event in eventset: + output(" %s: %s" % (event.what, repr(event.key)), skip=False) + + @staticmethod + def checkset(eventset, keytype, until, output): + groups = list() + group = list() + + # collect up all events that have the same time + eventsfound = False + for event in eventset: + # we found an event + eventsfound = True + + # add event to current group + if not group or group[0].when == event.when: + group.append(event) + + # if we're at the end of the list, we're done. if + # we've found an event with a later time, start a new group + if group[0].when != event.when: + groups.append(group) + group = list() + group.append(event) + + if group: + groups.append(group) + + if not eventsfound: + output("ERROR: No %s events found" % keytype) + return False + + active = published = None + for group in groups: + if until and calendar.timegm(group[0].when) > until: + output( + "Ignoring events after %s" + % time.strftime("%a %b %d %H:%M:%S UTC %Y", time.gmtime(until)) + ) + return True + + for event in group: + (active, published) = event.status(active, published) + + eventlist.showset(group, output) + + # and then check for inconsistencies: + if not active: + output("ERROR: No %s's are active after this event" % keytype) + return False + elif not published: + output("ERROR: No %s's are published after this event" % keytype) + return False + elif not published.intersection(active): + output( + "ERROR: No %s's are both active and published " + "after this event" % keytype + ) + return False + + return True diff --git a/bin/python/isc/keydict.py.in b/bin/python/isc/keydict.py.in new file mode 100644 index 0000000..723a32a --- /dev/null +++ b/bin/python/isc/keydict.py.in @@ -0,0 +1,87 @@ +# 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 collections import defaultdict +from . import dnskey +import os +import glob + + +######################################################################## +# Class keydict +######################################################################## +class keydict: + """A dictionary of keys, indexed by name, algorithm, and key id""" + + _keydict = defaultdict(lambda: defaultdict(dict)) + _defttl = None + _missing = [] + + def __init__(self, dp=None, **kwargs): + self._defttl = kwargs.get("keyttl", None) + zones = kwargs.get("zones", None) + + if not zones: + path = kwargs.get("path", None) or "." + self.readall(path) + else: + for zone in zones: + if "path" in kwargs and kwargs["path"] is not None: + path = kwargs["path"] + else: + path = dp and dp.policy(zone).directory or "." + if not self.readone(path, zone): + self._missing.append(zone) + + def readall(self, path): + files = glob.glob(os.path.join(path, "*.private")) + + for infile in files: + key = dnskey(infile, path, self._defttl) + self._keydict[key.name][key.alg][key.keyid] = key + + def readone(self, path, zone): + if not zone.endswith("."): + zone += "." + match = "K" + zone + "+*.private" + files = glob.glob(os.path.join(path, match)) + + found = False + for infile in files: + key = dnskey(infile, path, self._defttl) + if key.fullname != zone: # shouldn't ever happen + continue + keyname = key.name if zone != "." else "." + self._keydict[keyname][key.alg][key.keyid] = key + found = True + + return found + + def __iter__(self): + for zone, algorithms in self._keydict.items(): + for alg, keys in algorithms.items(): + for key in keys.values(): + yield key + + def __getitem__(self, name): + return self._keydict[name] + + def zones(self): + return self._keydict.keys() + + def algorithms(self, zone): + return self._keydict[zone].keys() + + def keys(self, zone, alg): + return self._keydict[zone][alg].keys() + + def missing(self): + return self._missing diff --git a/bin/python/isc/keyevent.py.in b/bin/python/isc/keyevent.py.in new file mode 100644 index 0000000..d01c97a --- /dev/null +++ b/bin/python/isc/keyevent.py.in @@ -0,0 +1,80 @@ +# 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. + +import time + + +######################################################################## +# Class keyevent +######################################################################## +class keyevent: + """A discrete key event, e.g., Publish, Activate, Inactive, Delete, + etc. Stores the date of the event, and identifying information + about the key to which the event will occur.""" + + def __init__(self, what, key, when=None): + self.what = what + self.when = when or key.gettime(what) + self.key = key + self.sep = key.sep + self.zone = key.name + self.alg = key.alg + self.keyid = key.keyid + + def __repr__(self): + return repr((self.when, self.what, self.keyid, self.sep, self.zone, self.alg)) + + def showtime(self): + return time.strftime("%a %b %d %H:%M:%S UTC %Y", self.when) + + # update sets of active and published keys, based on + # the contents of this keyevent + def status(self, active, published, output=None): + def noop(*args, **kwargs): + pass + + if not output: + output = noop + + if not active: + active = set() + if not published: + published = set() + + if self.what == "Activate": + active.add(self.keyid) + elif self.what == "Publish": + published.add(self.keyid) + elif self.what == "Inactive": + if self.keyid not in active: + output( + "\tWARNING: %s scheduled to become inactive " + "before it is active" % repr(self.key) + ) + else: + active.remove(self.keyid) + elif self.what == "Delete": + if self.keyid in published: + published.remove(self.keyid) + else: + output( + "WARNING: key %s is scheduled for deletion " + "before it is published" % repr(self.key) + ) + elif self.what == "Revoke": + # We don't need to worry about the logic of this one; + # just stop counting this key as either active or published + if self.keyid in published: + published.remove(self.keyid) + if self.keyid in active: + active.remove(self.keyid) + + return active, published diff --git a/bin/python/isc/keymgr.py.in b/bin/python/isc/keymgr.py.in new file mode 100644 index 0000000..67fe4c7 --- /dev/null +++ b/bin/python/isc/keymgr.py.in @@ -0,0 +1,207 @@ +# 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, sys, argparse, glob, re, time, calendar, pprint +from collections import defaultdict + +prog = "dnssec-keymgr" + +from isc import dnskey, keydict, keyseries, policy, parsetab, utils + + +############################################################################ +# print a fatal error and exit +############################################################################ +def fatal(*args, **kwargs): + print(*args, **kwargs) + sys.exit(1) + + +############################################################################ +# find the location of an external command +############################################################################ +def set_path(command, default=None): + """find the location of a specified command. If a default is supplied, + exists and it's an executable, we use it; otherwise we search PATH + for an alternative. + :param command: command to look for + :param default: default value to use + :return: PATH with the location of a suitable binary + """ + 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 = directory + os.sep + command + if os.path.isfile(fpath) and os.access(fpath, os.X_OK): + break + fpath = None + + return fpath + + +############################################################################ +# parse arguments +############################################################################ +def parse_args(): + """Read command line arguments, returns 'args' object + :return: args object properly prepared + """ + + keygen = set_path( + "dnssec-keygen", os.path.join(utils.prefix("sbin"), "dnssec-keygen") + ) + settime = set_path( + "dnssec-settime", os.path.join(utils.prefix("sbin"), "dnssec-settime") + ) + + parser = argparse.ArgumentParser( + description=prog + ": schedule " + "DNSSEC key rollovers according to a " + "pre-defined policy" + ) + + parser.add_argument( + "zone", + type=str, + nargs="*", + default=None, + help="Zone(s) to which the policy should be applied " + + "(default: all zones in the directory)", + ) + parser.add_argument( + "-K", dest="path", type=str, help="Directory containing keys", metavar="dir" + ) + parser.add_argument( + "-c", dest="policyfile", type=str, help="Policy definition file", metavar="file" + ) + parser.add_argument( + "-g", + dest="keygen", + default=keygen, + type=str, + help="Path to 'dnssec-keygen'", + metavar="path", + ) + parser.add_argument( + "-r", + dest="randomdev", + type=str, + default=None, + help="DEPRECATED", + metavar="path", + ) + parser.add_argument( + "-s", + dest="settime", + default=settime, + type=str, + help="Path to 'dnssec-settime'", + metavar="path", + ) + parser.add_argument( + "-k", + dest="no_zsk", + action="store_true", + default=False, + help="Only apply policy to key-signing keys (KSKs)", + ) + parser.add_argument( + "-z", + dest="no_ksk", + action="store_true", + default=False, + help="Only apply policy to zone-signing keys (ZSKs)", + ) + parser.add_argument( + "-f", + "--force", + dest="force", + action="store_true", + default=False, + help="Force updates to key events " + "even if they are in the past", + ) + parser.add_argument( + "-q", + "--quiet", + dest="quiet", + action="store_true", + default=False, + help="Update keys silently", + ) + parser.add_argument("-v", "--version", action="version", version=utils.version) + + args = parser.parse_args() + + if args.randomdev: + fatal("ERROR: -r option has been deprecated.") + + if args.no_zsk and args.no_ksk: + fatal("ERROR: -z and -k cannot be used together.") + + if args.keygen is None: + fatal("ERROR: dnssec-keygen not found") + + if args.settime is None: + fatal("ERROR: dnssec-settime not found") + + # if a policy file was specified, check that it exists. + # if not, use the default file, unless it doesn't exist + if args.policyfile is not None: + if not os.path.exists(args.policyfile): + fatal('ERROR: Policy file "%s" not found' % args.policyfile) + else: + args.policyfile = os.path.join(utils.sysconfdir, "dnssec-policy.conf") + if not os.path.exists(args.policyfile): + args.policyfile = None + + return args + + +############################################################################ +# main +############################################################################ +def main(): + args = parse_args() + + # As we may have specific locations for the binaries, we put that info + # into a context object that can be passed around + context = { + "keygen_path": args.keygen, + "settime_path": args.settime, + "keys_path": args.path, + "randomdev": args.randomdev, + } + + try: + dp = policy.dnssec_policy(args.policyfile) + except Exception as e: + fatal("Unable to load DNSSEC policy: " + str(e)) + + try: + kd = keydict(dp, path=args.path, zones=args.zone) + except Exception as e: + fatal("Unable to build key dictionary: " + str(e)) + + try: + ks = keyseries(kd, context=context) + except Exception as e: + fatal("Unable to build key series: " + str(e)) + + try: + ks.enforce_policy( + dp, ksk=args.no_zsk, zsk=args.no_ksk, force=args.force, quiet=args.quiet + ) + except Exception as e: + fatal("Unable to apply policy: " + str(e)) diff --git a/bin/python/isc/keyseries.py.in b/bin/python/isc/keyseries.py.in new file mode 100644 index 0000000..e75f82b --- /dev/null +++ b/bin/python/isc/keyseries.py.in @@ -0,0 +1,232 @@ +# 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 collections import defaultdict +from .dnskey import * +from .keydict import * +from .keyevent import * +from .policy import * +import time + + +class keyseries: + _K = defaultdict(lambda: defaultdict(list)) + _Z = defaultdict(lambda: defaultdict(list)) + _zones = set() + _kdict = None + _context = None + + def __init__(self, kdict, now=time.time(), context=None): + self._kdict = kdict + self._context = context + self._zones = set(kdict.missing()) + + for zone in kdict.zones(): + self._zones.add(zone) + for alg, keys in kdict[zone].items(): + for k in keys.values(): + if k.sep: + if not (k.delete() and k.delete() < now): + self._K[zone][alg].append(k) + else: + if not (k.delete() and k.delete() < now): + self._Z[zone][alg].append(k) + + self._K[zone][alg].sort() + self._Z[zone][alg].sort() + + def __iter__(self): + for zone in self._zones: + for collection in [self._K, self._Z]: + if zone not in collection: + continue + for alg, keys in collection[zone].items(): + for key in keys: + yield key + + def dump(self): + for k in self: + print("%s" % repr(k)) + + def fixseries(self, keys, policy, now, **kwargs): + force = kwargs.get("force", False) + if not keys: + return + + # handle the first key + key = keys[0] + if key.sep: + rp = policy.ksk_rollperiod + prepub = policy.ksk_prepublish or (30 * 86400) + postpub = policy.ksk_postpublish or (30 * 86400) + else: + rp = policy.zsk_rollperiod + prepub = policy.zsk_prepublish or (30 * 86400) + postpub = policy.zsk_postpublish or (30 * 86400) + + # the first key should be published and active + p = key.publish() + a = key.activate() + if not p or p > now: + key.setpublish(now) + p = now + if not a or a > now: + key.setactivate(now) + a = now + + i = key.inactive() + fudge = 300 + if not rp: + key.setinactive(None, **kwargs) + key.setdelete(None, **kwargs) + elif not i or a + rp != i: + if not i and a + rp > now + prepub + fudge: + key.setinactive(a + rp, **kwargs) + key.setdelete(a + rp + postpub, **kwargs) + elif not i: + key.setinactive(now + prepub + fudge, **kwargs) + key.setdelete(now + prepub + postpub + fudge, **kwargs) + elif i < now: + pass + elif a + rp > i: + key.setinactive(a + rp, **kwargs) + key.setdelete(a + rp + postpub, **kwargs) + elif a + rp > now + prepub + fudge: + key.setinactive(a + rp, **kwargs) + key.setdelete(a + rp + postpub, **kwargs) + else: + key.setinactive(now + prepub + fudge, **kwargs) + key.setdelete(now + prepub + postpub + fudge, **kwargs) + else: + d = key.delete() + if not d or i + postpub > now + fudge: + key.setdelete(i + postpub, **kwargs) + elif not d: + key.setdelete(now + postpub + fudge, **kwargs) + elif d < now + fudge: + pass + elif d < i + postpub: + key.setdelete(i + postpub, **kwargs) + + if policy.keyttl != key.ttl: + key.setttl(policy.keyttl) + + # handle all the subsequent keys + prev = key + for key in keys[1:]: + # if no rollperiod, then all keys after the first in + # the series kept inactive. + # (XXX: we need to change this to allow standby keys) + if not rp: + key.setpublish(None, **kwargs) + key.setactivate(None, **kwargs) + key.setinactive(None, **kwargs) + key.setdelete(None, **kwargs) + if policy.keyttl != key.ttl: + key.setttl(policy.keyttl) + continue + + # otherwise, ensure all dates are set correctly based on + # the initial key + a = prev.inactive() + p = a - prepub + key.setactivate(a, **kwargs) + key.setpublish(p, **kwargs) + key.setinactive(a + rp, **kwargs) + key.setdelete(a + rp + postpub, **kwargs) + prev.setdelete(a + postpub, **kwargs) + if policy.keyttl != key.ttl: + key.setttl(policy.keyttl) + prev = key + + # if we haven't got sufficient coverage, create successor key(s) + while rp and prev.inactive() and prev.inactive() < now + policy.coverage: + # commit changes to predecessor: a successor can only be + # generated if Inactive has been set in the predecessor key + prev.commit(self._context["settime_path"], **kwargs) + key = prev.generate_successor( + self._context["keygen_path"], + self._context["randomdev"], + prepub, + **kwargs + ) + + key.setinactive(key.activate() + rp, **kwargs) + key.setdelete(key.inactive() + postpub, **kwargs) + keys.append(key) + prev = key + + # last key? we already know we have sufficient coverage now, so + # disable the inactivation of the final key (if it was set), + # ensuring that if dnssec-keymgr isn't run again, the last key + # in the series will at least remain usable. + prev.setinactive(None, **kwargs) + prev.setdelete(None, **kwargs) + + # commit changes + for key in keys: + key.commit(self._context["settime_path"], **kwargs) + + def enforce_policy(self, policies, now=time.time(), **kwargs): + # If zones is provided as a parameter, use that list. + # If not, use what we have in this object + zones = kwargs.get("zones", self._zones) + keys_dir = kwargs.get("dir", self._context.get("keys_path", None)) + force = kwargs.get("force", False) + + for zone in zones: + collections = [] + policy = policies.policy(zone) + keys_dir = keys_dir or policy.directory or "." + alg = policy.algorithm + algnum = dnskey.algnum(alg) + if "ksk" not in kwargs or not kwargs["ksk"]: + if len(self._Z[zone][algnum]) == 0: + k = dnskey.generate( + self._context["keygen_path"], + self._context["randomdev"], + keys_dir, + zone, + alg, + policy.zsk_keysize, + False, + policy.keyttl or 3600, + **kwargs + ) + self._Z[zone][algnum].append(k) + collections.append(self._Z[zone]) + + if "zsk" not in kwargs or not kwargs["zsk"]: + if len(self._K[zone][algnum]) == 0: + k = dnskey.generate( + self._context["keygen_path"], + self._context["randomdev"], + keys_dir, + zone, + alg, + policy.ksk_keysize, + True, + policy.keyttl or 3600, + **kwargs + ) + self._K[zone][algnum].append(k) + collections.append(self._K[zone]) + + for collection in collections: + for algorithm, keys in collection.items(): + if algorithm != algnum: + continue + try: + self.fixseries(keys, policy, now, **kwargs) + except Exception as e: + raise Exception( + "%s/%s: %s" % (zone, dnskey.algstr(algnum), str(e)) + ) diff --git a/bin/python/isc/keyzone.py.in b/bin/python/isc/keyzone.py.in new file mode 100644 index 0000000..c0c7043 --- /dev/null +++ b/bin/python/isc/keyzone.py.in @@ -0,0 +1,59 @@ +# 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. + +import os +import sys +import re +from subprocess import Popen, PIPE + + +######################################################################## +# Exceptions +######################################################################## +class KeyZoneException(Exception): + pass + + +######################################################################## +# class keyzone +######################################################################## +class keyzone: + """reads a zone file to find data relevant to keys""" + + def __init__(self, name, filename, czpath): + self.maxttl = None + self.keyttl = None + + if not name: + return + + if not czpath or not os.path.isfile(czpath) or not os.access(czpath, os.X_OK): + raise KeyZoneException('"named-compilezone" not found') + return + + maxttl = keyttl = None + + fp, _ = Popen( + [czpath, "-o", "-", name, filename], stdout=PIPE, stderr=PIPE + ).communicate() + for line in fp.splitlines(): + if type(line) is not str: + line = line.decode("ascii") + if re.search("^[:space:]*;", line): + continue + fields = line.split() + if not maxttl or int(fields[1]) > maxttl: + maxttl = int(fields[1]) + if fields[3] == "DNSKEY": + keyttl = int(fields[1]) + + self.keyttl = keyttl + self.maxttl = maxttl diff --git a/bin/python/isc/policy.py.in b/bin/python/isc/policy.py.in new file mode 100644 index 0000000..0fa231c --- /dev/null +++ b/bin/python/isc/policy.py.in @@ -0,0 +1,761 @@ +# 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. + +import re +import ply.lex as lex +import ply.yacc as yacc +from string import * +from copy import copy + + +############################################################################ +# PolicyLex: a lexer for the policy file syntax. +############################################################################ +class PolicyLex: + reserved = ( + "POLICY", + "ALGORITHM_POLICY", + "ZONE", + "ALGORITHM", + "DIRECTORY", + "KEYTTL", + "KEY_SIZE", + "ROLL_PERIOD", + "PRE_PUBLISH", + "POST_PUBLISH", + "COVERAGE", + "STANDBY", + "NONE", + ) + + tokens = reserved + ( + "DATESUFFIX", + "KEYTYPE", + "ALGNAME", + "STR", + "QSTRING", + "NUMBER", + "LBRACE", + "RBRACE", + "SEMI", + ) + reserved_map = {} + + t_ignore = " \t" + t_ignore_olcomment = r"(//|\#).*" + + t_LBRACE = r"\{" + t_RBRACE = r"\}" + t_SEMI = r";" + + def t_newline(self, t): + r"\n+" + t.lexer.lineno += t.value.count("\n") + + def t_comment(self, t): + r"/\*(.|\n)*?\*/" + t.lexer.lineno += t.value.count("\n") + + def t_DATESUFFIX(self, t): + r"(?<=[0-9 \t])(y(?:ears|ear|ea|e)?|mo(?:nths|nth|nt|n)?|w(?:eeks|eek|ee|e)?|d(?:ays|ay|a)?|h(?:ours|our|ou|o)?|mi(?:nutes|nute|nut|nu|n)?|s(?:econds|econd|econ|eco|ec|e)?)\b" + t.value = ( + re.match(r"(y|mo|w|d|h|mi|s)([a-z]*)", t.value, re.IGNORECASE) + .group(1) + .lower() + ) + return t + + def t_KEYTYPE(self, t): + r"\b(KSK|ZSK)\b" + t.value = t.value.upper() + return t + + def t_ALGNAME(self, t): + r"\b(DH|ECC|RSASHA1|NSEC3RSASHA1|RSASHA256|RSASHA512|ECDSAP256SHA256|ECDSAP384SHA384|ED25519|ED448)\b" + t.value = t.value.upper() + return t + + def t_STR(self, t): + r"[A-Za-z._-][\w._-]*" + t.type = self.reserved_map.get(t.value, "STR") + return t + + def t_QSTRING(self, t): + r'"([^"\n]|(\\"))*"' + t.type = self.reserved_map.get(t.value, "QSTRING") + t.value = t.value[1:-1] + return t + + def t_NUMBER(self, t): + r"\d+" + t.value = int(t.value) + return t + + def t_error(self, t): + print("Illegal character '%s'" % t.value[0]) + t.lexer.skip(1) + + def __init__(self, **kwargs): + if "maketrans" in dir(str): + trans = str.maketrans("_", "-") + else: + trans = maketrans("_", "-") + for r in self.reserved: + self.reserved_map[r.lower().translate(trans)] = r + self.lexer = lex.lex(object=self, reflags=re.VERBOSE | re.IGNORECASE, **kwargs) + + def test(self, text): + self.lexer.input(text) + while True: + t = self.lexer.token() + if not t: + break + print(t) + + +############################################################################ +# Policy: this object holds a set of DNSSEC policy settings. +############################################################################ +class Policy: + is_zone = False + is_alg = False + is_constructed = False + ksk_rollperiod = None + zsk_rollperiod = None + ksk_prepublish = None + zsk_prepublish = None + ksk_postpublish = None + zsk_postpublish = None + ksk_keysize = None + zsk_keysize = None + ksk_standby = None + zsk_standby = None + keyttl = None + coverage = None + directory = None + valid_key_sz_per_algo = { + "RSASHA1": [1024, 4096], + "NSEC3RSASHA1": [512, 4096], + "RSASHA256": [1024, 4096], + "RSASHA512": [1024, 4096], + "ECDSAP256SHA256": None, + "ECDSAP384SHA384": None, + "ED25519": None, + "ED448": None, + } + + def __init__(self, name=None, algorithm=None, parent=None): + self.name = name + self.algorithm = algorithm + self.parent = parent + pass + + def __repr__(self): + return ( + "%spolicy %s:\n" + "\tinherits %s\n" + "\tdirectory %s\n" + "\talgorithm %s\n" + "\tcoverage %s\n" + "\tksk_keysize %s\n" + "\tzsk_keysize %s\n" + "\tksk_rollperiod %s\n" + "\tzsk_rollperiod %s\n" + "\tksk_prepublish %s\n" + "\tksk_postpublish %s\n" + "\tzsk_prepublish %s\n" + "\tzsk_postpublish %s\n" + "\tksk_standby %s\n" + "\tzsk_standby %s\n" + "\tkeyttl %s\n" + % ( + ( + self.is_constructed + and "constructed " + or self.is_zone + and "zone " + or self.is_alg + and "algorithm " + or "" + ), + self.name or "UNKNOWN", + self.parent and self.parent.name or "None", + self.directory and ('"' + str(self.directory) + '"') or "None", + self.algorithm or "None", + self.coverage and str(self.coverage) or "None", + self.ksk_keysize and str(self.ksk_keysize) or "None", + self.zsk_keysize and str(self.zsk_keysize) or "None", + self.ksk_rollperiod and str(self.ksk_rollperiod) or "None", + self.zsk_rollperiod and str(self.zsk_rollperiod) or "None", + self.ksk_prepublish and str(self.ksk_prepublish) or "None", + self.ksk_postpublish and str(self.ksk_postpublish) or "None", + self.zsk_prepublish and str(self.zsk_prepublish) or "None", + self.zsk_postpublish and str(self.zsk_postpublish) or "None", + self.ksk_standby and str(self.ksk_standby) or "None", + self.zsk_standby and str(self.zsk_standby) or "None", + self.keyttl and str(self.keyttl) or "None", + ) + ) + + def __verify_size(self, key_size, size_range): + return size_range[0] <= key_size <= size_range[1] + + def get_name(self): + return self.name + + def constructed(self): + return self.is_constructed + + def validate(self): + """Check if the values in the policy make sense + :return: True/False if the policy passes validation + """ + if ( + self.ksk_rollperiod + and self.ksk_prepublish is not None + and self.ksk_prepublish > self.ksk_rollperiod + ): + print(self.ksk_rollperiod) + return ( + False, + ( + "KSK pre-publish period (%d) exceeds rollover period %d" + % (self.ksk_prepublish, self.ksk_rollperiod) + ), + ) + + if ( + self.ksk_rollperiod + and self.ksk_postpublish is not None + and self.ksk_postpublish > self.ksk_rollperiod + ): + return ( + False, + ( + "KSK post-publish period (%d) exceeds rollover period %d" + % (self.ksk_postpublish, self.ksk_rollperiod) + ), + ) + + if ( + self.zsk_rollperiod + and self.zsk_prepublish is not None + and self.zsk_prepublish >= self.zsk_rollperiod + ): + return ( + False, + ( + "ZSK pre-publish period (%d) exceeds rollover period %d" + % (self.zsk_prepublish, self.zsk_rollperiod) + ), + ) + + if ( + self.zsk_rollperiod + and self.zsk_postpublish is not None + and self.zsk_postpublish >= self.zsk_rollperiod + ): + return ( + False, + ( + "ZSK post-publish period (%d) exceeds rollover period %d" + % (self.zsk_postpublish, self.zsk_rollperiod) + ), + ) + + if ( + self.ksk_rollperiod + and self.ksk_prepublish + and self.ksk_postpublish + and self.ksk_prepublish + self.ksk_postpublish >= self.ksk_rollperiod + ): + return ( + False, + ( + ( + "KSK pre/post-publish periods (%d/%d) " + + "combined exceed rollover period %d" + ) + % (self.ksk_prepublish, self.ksk_postpublish, self.ksk_rollperiod) + ), + ) + + if ( + self.zsk_rollperiod + and self.zsk_prepublish + and self.zsk_postpublish + and self.zsk_prepublish + self.zsk_postpublish >= self.zsk_rollperiod + ): + return ( + False, + ( + ( + "ZSK pre/post-publish periods (%d/%d) " + + "combined exceed rollover period %d" + ) + % (self.zsk_prepublish, self.zsk_postpublish, self.zsk_rollperiod) + ), + ) + + if self.algorithm is not None: + # Validate the key size + key_sz_range = self.valid_key_sz_per_algo.get(self.algorithm) + if key_sz_range is not None: + # Verify KSK + if not self.__verify_size(self.ksk_keysize, key_sz_range): + return False, "KSK key size %d outside valid range %s" % ( + self.ksk_keysize, + key_sz_range, + ) + + # Verify ZSK + if not self.__verify_size(self.zsk_keysize, key_sz_range): + return False, "ZSK key size %d outside valid range %s" % ( + self.zsk_keysize, + key_sz_range, + ) + + if self.algorithm in [ + "ECDSAP256SHA256", + "ECDSAP384SHA384", + "ED25519", + "ED448", + ]: + self.ksk_keysize = None + self.zsk_keysize = None + + return True, "" + + +############################################################################ +# dnssec_policy: +# This class reads a dnssec.policy file and creates a dictionary of +# DNSSEC policy rules from which a policy for a specific zone can +# be generated. +############################################################################ +class PolicyException(Exception): + pass + + +class dnssec_policy: + alg_policy = {} + named_policy = {} + zone_policy = {} + current = None + filename = None + initial = True + + def __init__(self, filename=None, **kwargs): + self.plex = PolicyLex() + self.tokens = self.plex.tokens + if "debug" not in kwargs: + kwargs["debug"] = False + if "write_tables" not in kwargs: + kwargs["write_tables"] = False + self.parser = yacc.yacc(module=self, **kwargs) + + # set defaults + self.setup( + """policy global { algorithm rsasha256; + key-size ksk 2048; + key-size zsk 2048; + roll-period ksk 0; + roll-period zsk 1y; + pre-publish ksk 1mo; + pre-publish zsk 1mo; + post-publish ksk 1mo; + post-publish zsk 1mo; + standby ksk 0; + standby zsk 0; + keyttl 1h; + coverage 6mo; }; + policy default { policy global; };""" + ) + + p = Policy() + p.algorithm = None + p.is_alg = True + p.ksk_keysize = 2048 + p.zsk_keysize = 2048 + + # set default algorithm policies + + # these can use default settings + self.alg_policy["RSASHA1"] = copy(p) + self.alg_policy["RSASHA1"].algorithm = "RSASHA1" + self.alg_policy["RSASHA1"].name = "RSASHA1" + + self.alg_policy["NSEC3RSASHA1"] = copy(p) + self.alg_policy["NSEC3RSASHA1"].algorithm = "NSEC3RSASHA1" + self.alg_policy["NSEC3RSASHA1"].name = "NSEC3RSASHA1" + + self.alg_policy["RSASHA256"] = copy(p) + self.alg_policy["RSASHA256"].algorithm = "RSASHA256" + self.alg_policy["RSASHA256"].name = "RSASHA256" + + self.alg_policy["RSASHA512"] = copy(p) + self.alg_policy["RSASHA512"].algorithm = "RSASHA512" + self.alg_policy["RSASHA512"].name = "RSASHA512" + + self.alg_policy["ECDSAP256SHA256"] = copy(p) + self.alg_policy["ECDSAP256SHA256"].algorithm = "ECDSAP256SHA256" + self.alg_policy["ECDSAP256SHA256"].name = "ECDSAP256SHA256" + self.alg_policy["ECDSAP256SHA256"].ksk_keysize = None + self.alg_policy["ECDSAP256SHA256"].zsk_keysize = None + + self.alg_policy["ECDSAP384SHA384"] = copy(p) + self.alg_policy["ECDSAP384SHA384"].algorithm = "ECDSAP384SHA384" + self.alg_policy["ECDSAP384SHA384"].name = "ECDSAP384SHA384" + self.alg_policy["ECDSAP384SHA384"].ksk_keysize = None + self.alg_policy["ECDSAP384SHA384"].zsk_keysize = None + + self.alg_policy["ED25519"] = copy(p) + self.alg_policy["ED25519"].algorithm = "ED25519" + self.alg_policy["ED25519"].name = "ED25519" + self.alg_policy["ED25519"].ksk_keysize = None + self.alg_policy["ED25519"].zsk_keysize = None + + self.alg_policy["ED448"] = copy(p) + self.alg_policy["ED448"].algorithm = "ED448" + self.alg_policy["ED448"].name = "ED448" + self.alg_policy["ED448"].ksk_keysize = None + self.alg_policy["ED448"].zsk_keysize = None + + if filename: + self.load(filename) + + def load(self, filename): + self.filename = filename + self.initial = True + with open(filename) as f: + text = f.read() + self.plex.lexer.lineno = 0 + self.parser.parse(text) + + self.filename = None + + def setup(self, text): + self.initial = True + self.plex.lexer.lineno = 0 + self.parser.parse(text) + + def policy(self, zone, **kwargs): + z = zone.lower() + p = None + + if z in self.zone_policy: + p = self.zone_policy[z] + + if p is None: + p = copy(self.named_policy["default"]) + p.name = zone + p.is_constructed = True + + if p.algorithm is None: + parent = p.parent or self.named_policy["default"] + while parent and not parent.algorithm: + parent = parent.parent + p.algorithm = parent and parent.algorithm or None + + if p.algorithm in self.alg_policy: + ap = self.alg_policy[p.algorithm] + else: + raise PolicyException("algorithm not found") + + if p.directory is None: + parent = p.parent or self.named_policy["default"] + while parent is not None and not parent.directory: + parent = parent.parent + p.directory = parent and parent.directory + + if p.coverage is None: + parent = p.parent or self.named_policy["default"] + while parent and not parent.coverage: + parent = parent.parent + p.coverage = parent and parent.coverage or ap.coverage + + if p.ksk_keysize is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.ksk_keysize: + parent = parent.parent + p.ksk_keysize = parent and parent.ksk_keysize or ap.ksk_keysize + + if p.zsk_keysize is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.zsk_keysize: + parent = parent.parent + p.zsk_keysize = parent and parent.zsk_keysize or ap.zsk_keysize + + if p.ksk_rollperiod is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.ksk_rollperiod: + parent = parent.parent + p.ksk_rollperiod = parent and parent.ksk_rollperiod or ap.ksk_rollperiod + + if p.zsk_rollperiod is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.zsk_rollperiod: + parent = parent.parent + p.zsk_rollperiod = parent and parent.zsk_rollperiod or ap.zsk_rollperiod + + if p.ksk_prepublish is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.ksk_prepublish: + parent = parent.parent + p.ksk_prepublish = parent and parent.ksk_prepublish or ap.ksk_prepublish + + if p.zsk_prepublish is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.zsk_prepublish: + parent = parent.parent + p.zsk_prepublish = parent and parent.zsk_prepublish or ap.zsk_prepublish + + if p.ksk_postpublish is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.ksk_postpublish: + parent = parent.parent + p.ksk_postpublish = parent and parent.ksk_postpublish or ap.ksk_postpublish + + if p.zsk_postpublish is None: + parent = p.parent or self.named_policy["default"] + while parent.parent and not parent.zsk_postpublish: + parent = parent.parent + p.zsk_postpublish = parent and parent.zsk_postpublish or ap.zsk_postpublish + + if p.keyttl is None: + parent = p.parent or self.named_policy["default"] + while parent is not None and not parent.keyttl: + parent = parent.parent + p.keyttl = parent and parent.keyttl + + if "novalidate" not in kwargs or not kwargs["novalidate"]: + (valid, msg) = p.validate() + if not valid: + raise PolicyException(msg) + return None + + return p + + def p_policylist(self, p): + """policylist : init policy + | policylist policy""" + pass + + def p_init(self, p): + "init :" + self.initial = False + + def p_policy(self, p): + """policy : alg_policy + | zone_policy + | named_policy""" + pass + + def p_name(self, p): + """name : STR + | KEYTYPE + | DATESUFFIX""" + p[0] = p[1] + pass + + def p_domain(self, p): + """domain : STR + | QSTRING + | KEYTYPE + | DATESUFFIX""" + p[0] = p[1].strip() + if not re.match(r"^[\w.-][\w.-]*$", p[0]): + raise PolicyException("invalid domain") + pass + + def p_new_policy(self, p): + "new_policy :" + self.current = Policy() + + def p_alg_policy(self, p): + "alg_policy : ALGORITHM_POLICY ALGNAME new_policy alg_option_group SEMI" + self.current.name = p[2] + self.current.is_alg = True + self.alg_policy[p[2]] = self.current + pass + + def p_zone_policy(self, p): + "zone_policy : ZONE domain new_policy policy_option_group SEMI" + self.current.name = p[2].rstrip(".") + self.current.is_zone = True + self.zone_policy[p[2].rstrip(".").lower()] = self.current + pass + + def p_named_policy(self, p): + "named_policy : POLICY name new_policy policy_option_group SEMI" + self.current.name = p[2] + self.named_policy[p[2].lower()] = self.current + pass + + def p_duration_1(self, p): + "duration : NUMBER" + p[0] = p[1] + pass + + def p_duration_2(self, p): + "duration : NONE" + p[0] = None + pass + + def p_duration_3(self, p): + "duration : NUMBER DATESUFFIX" + if p[2] == "y": + p[0] = p[1] * 31536000 # year + elif p[2] == "mo": + p[0] = p[1] * 2592000 # month + elif p[2] == "w": + p[0] = p[1] * 604800 # week + elif p[2] == "d": + p[0] = p[1] * 86400 # day + elif p[2] == "h": + p[0] = p[1] * 3600 # hour + elif p[2] == "mi": + p[0] = p[1] * 60 # minute + elif p[2] == "s": + p[0] = p[1] # second + else: + raise PolicyException("invalid duration") + + def p_policy_option_group(self, p): + "policy_option_group : LBRACE policy_option_list RBRACE" + pass + + def p_policy_option_list(self, p): + """policy_option_list : policy_option SEMI + | policy_option_list policy_option SEMI""" + pass + + def p_policy_option(self, p): + """policy_option : parent_option + | directory_option + | coverage_option + | rollperiod_option + | prepublish_option + | postpublish_option + | keysize_option + | algorithm_option + | keyttl_option + | standby_option""" + pass + + def p_alg_option_group(self, p): + "alg_option_group : LBRACE alg_option_list RBRACE" + pass + + def p_alg_option_list(self, p): + """alg_option_list : alg_option SEMI + | alg_option_list alg_option SEMI""" + pass + + def p_alg_option(self, p): + """alg_option : coverage_option + | rollperiod_option + | prepublish_option + | postpublish_option + | keyttl_option + | keysize_option + | standby_option""" + pass + + def p_parent_option(self, p): + "parent_option : POLICY name" + self.current.parent = self.named_policy[p[2].lower()] + + def p_directory_option(self, p): + "directory_option : DIRECTORY QSTRING" + self.current.directory = p[2] + + def p_coverage_option(self, p): + "coverage_option : COVERAGE duration" + self.current.coverage = p[2] + + def p_rollperiod_option(self, p): + "rollperiod_option : ROLL_PERIOD KEYTYPE duration" + if p[2] == "KSK": + self.current.ksk_rollperiod = p[3] + else: + self.current.zsk_rollperiod = p[3] + + def p_prepublish_option(self, p): + "prepublish_option : PRE_PUBLISH KEYTYPE duration" + if p[2] == "KSK": + self.current.ksk_prepublish = p[3] + else: + self.current.zsk_prepublish = p[3] + + def p_postpublish_option(self, p): + "postpublish_option : POST_PUBLISH KEYTYPE duration" + if p[2] == "KSK": + self.current.ksk_postpublish = p[3] + else: + self.current.zsk_postpublish = p[3] + + def p_keysize_option(self, p): + "keysize_option : KEY_SIZE KEYTYPE NUMBER" + if p[2] == "KSK": + self.current.ksk_keysize = p[3] + else: + self.current.zsk_keysize = p[3] + + def p_standby_option(self, p): + "standby_option : STANDBY KEYTYPE NUMBER" + if p[2] == "KSK": + self.current.ksk_standby = p[3] + else: + self.current.zsk_standby = p[3] + + def p_keyttl_option(self, p): + "keyttl_option : KEYTTL duration" + self.current.keyttl = p[2] + + def p_algorithm_option(self, p): + "algorithm_option : ALGORITHM ALGNAME" + self.current.algorithm = p[2] + + def p_error(self, p): + if p: + print( + "%s%s%d:syntax error near '%s'" + % (self.filename or "", ":" if self.filename else "", p.lineno, p.value) + ) + else: + if not self.initial: + raise PolicyException( + "%s%s%d:unexpected end of input" + % ( + self.filename or "", + ":" if self.filename else "", + p and p.lineno or 0, + ) + ) + + +if __name__ == "__main__": + import sys + + if sys.argv[1] == "lex": + file = open(sys.argv[2]) + text = file.read() + file.close() + plex = PolicyLex(debug=1) + plex.test(text) + elif sys.argv[1] == "parse": + try: + pp = dnssec_policy(sys.argv[2], write_tables=True, debug=True) + print(pp.named_policy["default"]) + print(pp.policy("nonexistent.zone")) + except Exception as e: + print(e.args[0]) diff --git a/bin/python/isc/rndc.py.in b/bin/python/isc/rndc.py.in new file mode 100644 index 0000000..a8af767 --- /dev/null +++ b/bin/python/isc/rndc.py.in @@ -0,0 +1,193 @@ +# 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. + +############################################################################ +# rndc.py +# This module implements the RNDC control protocol. +############################################################################ + +from collections import OrderedDict +import time +import struct +import hashlib +import hmac +import base64 +import random +import socket + + +class rndc(object): + """RNDC protocol client library""" + + __algos = { + "md5": 157, + "sha1": 161, + "sha224": 162, + "sha256": 163, + "sha384": 164, + "sha512": 165, + } + + def __init__(self, host, algo, secret): + """Creates a persistent connection to RNDC and logs in + host - (ip, port) tuple + algo - HMAC algorithm: one of md5, sha1, sha224, sha256, sha384, sha512 + (with optional prefix 'hmac-') + secret - HMAC secret, base64 encoded""" + self.host = host + algo = algo.lower() + if algo.startswith("hmac-"): + algo = algo[5:] + self.algo = algo + self.hlalgo = getattr(hashlib, algo) + self.secret = base64.b64decode(secret) + self.ser = random.randint(0, 1 << 24) + self.nonce = None + self.__connect_login() + + def call(self, cmd): + """Call a RNDC command, all parsing is done on the server side + cmd - a complete string with a command (eg 'reload zone example.com') + """ + return dict(self.__command(type=cmd)["_data"]) + + def __serialize_dict(self, data, ignore_auth=False): + rv = bytearray() + for k, v in data.items(): + if ignore_auth and k == "_auth": + continue + rv += struct.pack("B", len(k)) + k.encode("ascii") + if type(v) == str: + rv += struct.pack(">BI", 1, len(v)) + v.encode("ascii") + elif type(v) == bytes: + rv += struct.pack(">BI", 1, len(v)) + v + elif type(v) == bytearray: + rv += struct.pack(">BI", 1, len(v)) + v + elif type(v) == OrderedDict: + sd = self.__serialize_dict(v) + rv += struct.pack(">BI", 2, len(sd)) + sd + else: + raise NotImplementedError( + "Cannot serialize element of type %s" % type(v) + ) + return rv + + def __prep_message(self, *args, **kwargs): + self.ser += 1 + now = int(time.time()) + data = OrderedDict(*args, **kwargs) + + d = OrderedDict() + d["_auth"] = OrderedDict() + d["_ctrl"] = OrderedDict() + d["_ctrl"]["_ser"] = str(self.ser) + d["_ctrl"]["_tim"] = str(now) + d["_ctrl"]["_exp"] = str(now + 60) + if self.nonce is not None: + d["_ctrl"]["_nonce"] = self.nonce + d["_data"] = data + + msg = self.__serialize_dict(d, ignore_auth=True) + hash = hmac.new(self.secret, msg, self.hlalgo).digest() + bhash = base64.b64encode(hash) + if self.algo == "md5": + d["_auth"]["hmd5"] = struct.pack("22s", bhash) + else: + d["_auth"]["hsha"] = bytearray( + struct.pack("B88s", self.__algos[self.algo], bhash) + ) + msg = self.__serialize_dict(d) + msg = struct.pack(">II", len(msg) + 4, 1) + msg + return msg + + def __verify_msg(self, msg): + if self.nonce is not None and msg["_ctrl"]["_nonce"] != self.nonce: + return False + if self.algo == "md5": + bhash = msg["_auth"]["hmd5"] + else: + bhash = msg["_auth"]["hsha"][1:] + if type(bhash) == bytes: + bhash = bhash.decode("ascii") + bhash += "=" * (4 - (len(bhash) % 4)) + remote_hash = base64.b64decode(bhash) + my_msg = self.__serialize_dict(msg, ignore_auth=True) + my_hash = hmac.new(self.secret, my_msg, self.hlalgo).digest() + return my_hash == remote_hash + + def __command(self, *args, **kwargs): + msg = self.__prep_message(*args, **kwargs) + sent = self.socket.send(msg) + if sent != len(msg): + raise IOError("Cannot send the message") + + header = self.socket.recv(8) + if len(header) != 8: + # What should we throw here? Bad auth can cause this... + raise IOError("Can't read response header") + + length, version = struct.unpack(">II", header) + if version != 1: + raise NotImplementedError("Wrong message version %d" % version) + + # it includes the header + length -= 4 + data = self.socket.recv(length, socket.MSG_WAITALL) + if len(data) != length: + raise IOError("Can't read response data") + + if type(data) == str: + data = bytearray(data) + msg = self.__parse_message(data) + if not self.__verify_msg(msg): + raise IOError("Authentication failure") + + return msg + + def __connect_login(self): + self.socket = socket.create_connection(self.host) + self.nonce = None + msg = self.__command(type="null") + self.nonce = msg["_ctrl"]["_nonce"] + + def __parse_element(self, input): + pos = 0 + labellen = input[pos] + pos += 1 + label = input[pos : pos + labellen].decode("ascii") + pos += labellen + type = input[pos] + pos += 1 + datalen = struct.unpack(">I", input[pos : pos + 4])[0] + pos += 4 + data = input[pos : pos + datalen] + pos += datalen + rest = input[pos:] + + if type == 1: # raw binary value + return label, data, rest + elif type == 2: # dictionary + d = OrderedDict() + while len(data) > 0: + ilabel, value, data = self.__parse_element(data) + d[ilabel] = value + return label, d, rest + # TODO type 3 - list + else: + raise NotImplementedError("Unknown element type %d" % type) + + def __parse_message(self, input): + rv = OrderedDict() + hdata = None + while len(input) > 0: + label, value, input = self.__parse_element(input) + rv[label] = value + return rv diff --git a/bin/python/isc/tests/Makefile.in b/bin/python/isc/tests/Makefile.in new file mode 100644 index 0000000..233f7f9 --- /dev/null +++ b/bin/python/isc/tests/Makefile.in @@ -0,0 +1,33 @@ +# 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. + +srcdir = @srcdir@ +VPATH = @srcdir@ +top_srcdir = @top_srcdir@ + +@BIND9_MAKE_INCLUDES@ + +PYTHON = @PYTHON@ + +PYTESTS = dnskey_test.py policy_test.py + +@BIND9_MAKE_RULES@ + +check test: + for test in $(PYTESTS); do \ + PYTHONPATH=${srcdir}/../.. $(PYTHON) ${srcdir}/$$test; \ + done + +clean distclean:: + rm -rf *.pyc __pycache__ + +distclean:: + rm -f ${PYTESTS} diff --git a/bin/python/isc/tests/dnskey_test.py.in b/bin/python/isc/tests/dnskey_test.py.in new file mode 100644 index 0000000..e3ce3f7 --- /dev/null +++ b/bin/python/isc/tests/dnskey_test.py.in @@ -0,0 +1,54 @@ +# 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. + +import sys +import unittest + +sys.path.append("../..") +from isc import * + +kdict = None + + +def getkey(): + global kdict + if not kdict: + kd = keydict(path="testdata") + for key in kd: + return key + + +class DnskeyTest(unittest.TestCase): + def test_metdata(self): + key = getkey() + self.assertEqual(key.created(), 1448055647) + self.assertEqual(key.publish(), 1445463714) + self.assertEqual(key.activate(), 1448055714) + self.assertEqual(key.revoke(), 1479591714) + self.assertEqual(key.inactive(), 1511127714) + self.assertEqual(key.delete(), 1542663714) + self.assertEqual(key.syncpublish(), 1442871714) + self.assertEqual(key.syncdelete(), 1448919714) + + def test_fmttime(self): + key = getkey() + self.assertEqual(key.getfmttime("Created"), "20151120214047") + self.assertEqual(key.getfmttime("Publish"), "20151021214154") + self.assertEqual(key.getfmttime("Activate"), "20151120214154") + self.assertEqual(key.getfmttime("Revoke"), "20161119214154") + self.assertEqual(key.getfmttime("Inactive"), "20171119214154") + self.assertEqual(key.getfmttime("Delete"), "20181119214154") + self.assertEqual(key.getfmttime("SyncPublish"), "20150921214154") + self.assertEqual(key.getfmttime("SyncDelete"), "20151130214154") + + +if __name__ == "__main__": + unittest.main() diff --git a/bin/python/isc/tests/policy_test.py.in b/bin/python/isc/tests/policy_test.py.in new file mode 100644 index 0000000..c115a31 --- /dev/null +++ b/bin/python/isc/tests/policy_test.py.in @@ -0,0 +1,104 @@ +# 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. + +import sys +import unittest + +sys.path.append("../..") +from isc import * + + +class PolicyTest(unittest.TestCase): + def test_keysize(self): + pol = policy.dnssec_policy() + pol.load("test-policies/01-keysize.pol") + + p = pol.policy("good_rsa.test", novalidate=True) + self.assertEqual(p.get_name(), "good_rsa.test") + self.assertEqual(p.constructed(), False) + self.assertEqual(p.validate(), (True, "")) + + def test_prepublish(self): + pol = policy.dnssec_policy() + pol.load("test-policies/02-prepublish.pol") + p = pol.policy("good_prepublish.test", novalidate=True) + self.assertEqual(p.validate(), (True, "")) + + p = pol.policy("bad_prepublish.test", novalidate=True) + self.assertEqual( + p.validate(), + ( + False, + "KSK pre/post-publish periods " + "(10368000/5184000) combined exceed " + "rollover period 10368000", + ), + ) + + def test_postpublish(self): + pol = policy.dnssec_policy() + pol.load("test-policies/03-postpublish.pol") + + p = pol.policy("good_postpublish.test", novalidate=True) + self.assertEqual(p.validate(), (True, "")) + + p = pol.policy("bad_postpublish.test", novalidate=True) + self.assertEqual( + p.validate(), + ( + False, + "KSK pre/post-publish periods " + "(10368000/5184000) combined exceed " + "rollover period 10368000", + ), + ) + + def test_combined_pre_post(self): + pol = policy.dnssec_policy() + pol.load("test-policies/04-combined-pre-post.pol") + + p = pol.policy("good_combined_pre_post_ksk.test", novalidate=True) + self.assertEqual(p.validate(), (True, "")) + + p = pol.policy("bad_combined_pre_post_ksk.test", novalidate=True) + self.assertEqual( + p.validate(), + ( + False, + "KSK pre/post-publish periods " + "(5184000/5184000) combined exceed " + "rollover period 10368000", + ), + ) + + p = pol.policy("good_combined_pre_post_zsk.test", novalidate=True) + self.assertEqual(p.validate(), (True, "")) + p = pol.policy("bad_combined_pre_post_zsk.test", novalidate=True) + self.assertEqual( + p.validate(), + ( + False, + "ZSK pre/post-publish periods " + "(5184000/5184000) combined exceed " + "rollover period 7776000", + ), + ) + + def test_numeric_zone(self): + pol = policy.dnssec_policy() + pol.load("test-policies/05-numeric-zone.pol") + + p = pol.policy("99example.test", novalidate=True) + self.assertEqual(p.validate(), (True, "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/bin/python/isc/tests/test-policies/01-keysize.pol b/bin/python/isc/tests/test-policies/01-keysize.pol new file mode 100644 index 0000000..db22058 --- /dev/null +++ b/bin/python/isc/tests/test-policies/01-keysize.pol @@ -0,0 +1,54 @@ +/* + * 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 http://mozilla.org/MPL/2.0/. + * + * See the COPYRIGHT file distributed with this work for additional + * information regarding copyright ownership. + */ + +policy keysize_rsa { + algorithm rsasha1; + coverage 1y; + roll-period zsk 3mo; + pre-publish zsk 2w; + post-publish zsk 2w; + roll-period ksk 1y; + pre-publish ksk 1mo; + post-publish ksk 2mo; + keyttl 1h; + key-size ksk 2048; + key-size zsk 1024; +}; + +policy keysize_dsa { + algorithm dsa; + coverage 1y; + key-size ksk 2048; + key-size zsk 1024; +}; + +zone good_rsa.test { + policy keysize_rsa; +}; + +zone bad_rsa.test { + policy keysize_rsa; + key-size ksk 511; +}; + +zone good_dsa.test { + policy keysize_dsa; + key-size ksk 1024; + key-size zsk 768; +}; + +zone bad_dsa.test { + policy keysize_dsa; + key-size ksk 1024; + key-size zsk 769; +}; diff --git a/bin/python/isc/tests/test-policies/02-prepublish.pol b/bin/python/isc/tests/test-policies/02-prepublish.pol new file mode 100644 index 0000000..7dd1b32 --- /dev/null +++ b/bin/python/isc/tests/test-policies/02-prepublish.pol @@ -0,0 +1,44 @@ +/* + * 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 http://mozilla.org/MPL/2.0/. + * + * See the COPYRIGHT file distributed with this work for additional + * information regarding copyright ownership. + */ + +policy prepublish_rsa { + algorithm rsasha1; + coverage 1y; + roll-period zsk 3mo; + pre-publish zsk 2w; + post-publish zsk 2w; + roll-period ksk 1y; + pre-publish ksk 1mo; + post-publish ksk 2mo; + keyttl 1h; + key-size ksk 2048; + key-size zsk 1024; +}; + +// Policy that defines a pre-publish period lower than the rollover period +zone good_prepublish.test { + policy prepublish_rsa; + coverage 6mo; + roll-period ksk 4mo; + pre-publish ksk 1mo; +}; + +// Policy that defines a pre-publish period equal to the rollover period +zone bad_prepublish.test { + policy prepublish_rsa; + coverage 6mo; + roll-period ksk 4mo; + pre-publish ksk 4mo; +}; + + diff --git a/bin/python/isc/tests/test-policies/03-postpublish.pol b/bin/python/isc/tests/test-policies/03-postpublish.pol new file mode 100644 index 0000000..74bd822 --- /dev/null +++ b/bin/python/isc/tests/test-policies/03-postpublish.pol @@ -0,0 +1,44 @@ +/* + * 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 http://mozilla.org/MPL/2.0/. + * + * See the COPYRIGHT file distributed with this work for additional + * information regarding copyright ownership. + */ + +policy postpublish_rsa { + algorithm rsasha1; + coverage 1y; + roll-period zsk 3mo; + pre-publish zsk 2w; + post-publish zsk 2w; + roll-period ksk 1y; + pre-publish ksk 1mo; + post-publish ksk 2mo; + keyttl 1h; + key-size ksk 2048; + key-size zsk 1024; +}; + +// Policy that defines a post-publish period lower than the rollover period +zone good_postpublish.test { + policy postpublish_rsa; + coverage 6mo; + roll-period ksk 4mo; + pre-publish ksk 1mo; +}; + +// Policy that defines a post-publish period equal to the rollover period +zone bad_postpublish.test { + policy postpublish_rsa; + coverage 6mo; + roll-period ksk 4mo; + pre-publish ksk 4mo; +}; + + diff --git a/bin/python/isc/tests/test-policies/04-combined-pre-post.pol b/bin/python/isc/tests/test-policies/04-combined-pre-post.pol new file mode 100644 index 0000000..82c001c --- /dev/null +++ b/bin/python/isc/tests/test-policies/04-combined-pre-post.pol @@ -0,0 +1,68 @@ +/* + * 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 http://mozilla.org/MPL/2.0/. + * + * See the COPYRIGHT file distributed with this work for additional + * information regarding copyright ownership. + */ + +policy combined_pre_post_rsa { + algorithm rsasha1; + coverage 1y; + roll-period zsk 3mo; + pre-publish zsk 2w; + post-publish zsk 2w; + roll-period ksk 1y; + pre-publish ksk 1mo; + post-publish ksk 2mo; + keyttl 1h; + key-size ksk 2048; + key-size zsk 1024; +}; + +// Policy that defines a combined pre-publish and post-publish period lower +// than the rollover period +zone good_combined_pre_post_ksk.test { + policy combined_pre_post_rsa; + coverage 6mo; + roll-period ksk 4mo; + pre-publish ksk 1mo; + post-publish ksk 1mo; +}; + +// Policy that defines a combined pre-publish and post-publish period higher +// than the rollover period +zone bad_combined_pre_post_ksk.test { + policy combined_pre_post_rsa; + coverage 6mo; + roll-period ksk 4mo; + pre-publish ksk 2mo; + post-publish ksk 2mo; +}; + +// Policy that defines a combined pre-publish and post-publish period lower +// than the rollover period +zone good_combined_pre_post_zsk.test { + policy combined_pre_post_rsa; + coverage 1y; + roll-period zsk 3mo; + pre-publish zsk 1mo; + post-publish zsk 1mo; +}; + +// Policy that defines a combined pre-publish and post-publish period higher +// than the rollover period +zone bad_combined_pre_post_zsk.test { + policy combined_pre_post_rsa; + coverage 1y; + roll-period zsk 3mo; + pre-publish zsk 2mo; + post-publish zsk 2mo; +}; + + diff --git a/bin/python/isc/tests/test-policies/05-numeric-zone.pol b/bin/python/isc/tests/test-policies/05-numeric-zone.pol new file mode 100644 index 0000000..26e546b --- /dev/null +++ b/bin/python/isc/tests/test-policies/05-numeric-zone.pol @@ -0,0 +1,17 @@ +/* + * 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 http://mozilla.org/MPL/2.0/. + * + * See the COPYRIGHT file distributed with this work for additional + * information regarding copyright ownership. + */ + +// Zone policy that uses a numeric name +zone "99example.test" { + coverage 6mo; +}; diff --git a/bin/python/isc/tests/testdata/Kexample.com.+007+35529.key b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.key new file mode 100644 index 0000000..c5afbe2 --- /dev/null +++ b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.key @@ -0,0 +1,8 @@ +; This is a key-signing key, keyid 35529, for example.com. +; Created: 20151120214047 (Fri Nov 20 13:40:47 2015) +; Publish: 20151021214154 (Wed Oct 21 14:41:54 2015) +; Activate: 20151120214154 (Fri Nov 20 13:41:54 2015) +; Revoke: 20161119214154 (Sat Nov 19 13:41:54 2016) +; Inactive: 20171119214154 (Sun Nov 19 13:41:54 2017) +; Delete: 20181119214154 (Mon Nov 19 13:41:54 2018) +example.com. IN DNSKEY 257 3 7 AwEAAbbJK96tY8d4sF6RLxh9SVIhho5s2ZhrcijT5j1SNLECen7QLutj VJPEiG8UgBLaJSGkxPDxOygYv4hwh4JXBSj89o9rNabAJtCa9XzIXSpt /cfiCfvqmcOZb9nepmDCXsC7gn/gbae/4Y5ym9XOiCp8lu+tlFWgRiJ+ kxDGN48rRPrGfpq+SfwM9NUtftVa7B0EFVzDkADKedRj0SSGYOqH+WYH CnWjhPFmgJoAw3/m4slTHW1l+mDwFvsCMjXopg4JV0CNnTybnOmyuIwO LWRhB3q8ze24sYBU1fpE9VAMxZ++4Kqh/2MZFeDAs7iPPKSmI3wkRCW5 pkwDLO5lJ9c= diff --git a/bin/python/isc/tests/testdata/Kexample.com.+007+35529.private b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.private new file mode 100644 index 0000000..af22c6a --- /dev/null +++ b/bin/python/isc/tests/testdata/Kexample.com.+007+35529.private @@ -0,0 +1,18 @@ +Private-key-format: v1.3 +Algorithm: 7 (NSEC3RSASHA1) +Modulus: tskr3q1jx3iwXpEvGH1JUiGGjmzZmGtyKNPmPVI0sQJ6ftAu62NUk8SIbxSAEtolIaTE8PE7KBi/iHCHglcFKPz2j2s1psAm0Jr1fMhdKm39x+IJ++qZw5lv2d6mYMJewLuCf+Btp7/hjnKb1c6IKnyW762UVaBGIn6TEMY3jytE+sZ+mr5J/Az01S1+1VrsHQQVXMOQAMp51GPRJIZg6of5ZgcKdaOE8WaAmgDDf+biyVMdbWX6YPAW+wIyNeimDglXQI2dPJuc6bK4jA4tZGEHerzN7bixgFTV+kT1UAzFn77gqqH/YxkV4MCzuI88pKYjfCREJbmmTAMs7mUn1w== +PublicExponent: AQAB +PrivateExponent: jfiM6YU1Rd6Y5qrPsK7HP1Ko54DmNbvmzI1hfGmYYZAyQsNCXjQloix5aAW9QGdNhecrzJUhxJAMXFZC+lrKuD5a56R25JDE1Sw21nft3SHXhuQrqw5Z5hIMTWXhRrBR1lMOFnLj2PJxqCmenp+vJYjl1z20RBmbv/keE15SExFRJIJ3G0lI4V0KxprY5rgsT/vID0pS32f7rmXhgEzyWDyuxceTMidBooD5BSeEmSTYa4rvCVZ2vgnzIGSxjYDPJE2rGve2dpvdXQuujRFaf4+/FzjaOgg35rTtUmC9klfB4D6KJIfc1PNUwcH7V0VJ2fFlgZgMYi4W331QORl9sQ== +Prime1: 479rW3EeoBwHhUKDy5YeyfnMKjhaosrcYhW4resevLzatFrvS/n2KxJnsHoEzmGr2A13naI61RndgVBBOwNDWI3/tQ+aKvcr+V9m4omROV3xYa8s1FsDbEW0Z6G0UheaqRFir8WK98/Lj6Zht1uBXHSPPf91OW0qj+b5gbX7TK8= +Prime2: zXXlxgIq+Ih6kxsUw4Ith0nd/d2P3d42QYPjxYjsg4xYicPAjva9HltnbBQ2lr4JEG9Yyb8KalSnJUSuvXtn7bGfBzLu8W6omCeVWXQVH4NIu9AjpO16NpMKWGRfiHHbbSYJs1daTZKHC2FEmi18MKX/RauHGGOakFQ/3A/GMVk= +Exponent1: 0o9UQ1uHNAIWFedUEHJ/jr7LOrGVYnLpZCmu7+S0K0zzatGz8ets44+FnAyDywdUKFDzKSMm/4SFXRwE4vl2VzYZlp2RLG4PEuRYK9OCF6a6F1UsvjxTItQjIbjIDSnTjMINGnMps0lDa1EpgKsyI3eEQ46eI3TBZ//k6D6G0vM= +Exponent2: d+CYJgXRyJzo17fvT3s+0TbaHWsOq+chROyNEw4m4UIbzpW2XjO8eF/gYgERMLbEVyCAb4XVr+CgfXArfEbqhpciMHMZUyi7mbtOupiuUmqpH1v70Bj3O6xjVtuJmfTEkFSnSEppV+VsgclI26Q6V7Ai1yWTdzl2T0u4zs8tVlE= +Coefficient: E4EYw76gIChdQDn6+Uh44/xH9Uwmvq3OETR8w/kEZ0xQ8AkTdKFKUp84nlR6gN+ljb2mUxERKrVLwnBsU8EbUlo9UccMbBGkkZ/8MyfGCBb9nUyOFtOxdHY2M0MQadesRptXHt/m30XjdohwmT7qfSIENwtgUOHbwFnn7WPMc/k= +Created: 20151120214047 +Publish: 20151021214154 +Activate: 20151120214154 +Revoke: 20161119214154 +Inactive: 20171119214154 +Delete: 20181119214154 +SyncPublish: 20150921214154 +SyncDelete: 20151130214154 diff --git a/bin/python/isc/utils.py.in b/bin/python/isc/utils.py.in new file mode 100644 index 0000000..3ce4d4c --- /dev/null +++ b/bin/python/isc/utils.py.in @@ -0,0 +1,71 @@ +# 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. + +import os + +# These routines permit platform-independent location of BIND 9 tools +if os.name == "nt": + import win32con + import win32api + + +def prefix(bindir=""): + if os.name != "nt": + return os.path.join("@prefix@", bindir) + + hklm = win32con.HKEY_LOCAL_MACHINE + bind_subkey = "Software\\ISC\\BIND" + sam = win32con.KEY_READ + h_key = None + key_found = True + # can fail if the registry redirected for 32/64 bits + try: + h_key = win32api.RegOpenKeyEx(hklm, bind_subkey, 0, sam) + except: + key_found = False + # retry for 32 bit python with 64 bit bind9 + if not key_found: + key_found = True + sam64 = sam | win32con.KEY_WOW64_64KEY + try: + h_key = win32api.RegOpenKeyEx(hklm, bind_subkey, 0, sam64) + except: + key_found = False + # retry 64 bit python with 32 bit bind9 + if not key_found: + key_found = True + sam32 = sam | win32con.KEY_WOW64_32KEY + try: + h_key = win32api.RegOpenKeyEx(hklm, bind_subkey, 0, sam32) + except: + key_found = False + if key_found: + try: + (named_base, _) = win32api.RegQueryValueEx(h_key, "InstallDir") + except: + key_found = False + win32api.RegCloseKey(h_key) + if key_found: + return os.path.join(named_base, bindir) + return os.path.join(win32api.GetSystemDirectory(), bindir) + + +def shellquote(s): + if os.name == "nt": + return '"' + s.replace('"', '"\\"') + '"' + return "'" + s.replace("'", "'\\''") + "'" + + +version = "@BIND9_VERSION@" +if os.name != "nt": + sysconfdir = "@expanded_sysconfdir@" +else: + sysconfdir = prefix("etc") diff --git a/bin/python/setup.py b/bin/python/setup.py new file mode 100644 index 0000000..4440b74 --- /dev/null +++ b/bin/python/setup.py @@ -0,0 +1,28 @@ +# 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. + +try: + from setuptools import setup +except ImportError: + # pylint: disable=deprecated-module + from distutils.core import setup + +setup( + name="isc", + version="2.0", + description="Python functions to support BIND utilities", + url="https://www.isc.org/bind", + author="Internet Systems Consortium, Inc", + author_email="info@isc.org", + license="MPL", + requires=["ply"], + packages=["isc"], +) |