From: Charles-Henri Bruyand Date: Tue, 20 Sep 2022 12:56:34 +0000 (+0200) Subject: keyroller first import X-Git-Tag: rec-4.9.0-alpha0~8^2~1 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=30150eab3853a999d99f498129c86f23fd548045;p=thirdparty%2Fpdns.git keyroller first import --- diff --git a/pdns/keyroller/Pipfile b/pdns/keyroller/Pipfile new file mode 100644 index 0000000000..75bc29aa59 --- /dev/null +++ b/pdns/keyroller/Pipfile @@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +PyYAML = "*" +pytimeparse = "*" +requests = "*" +json-tricks = "*" +nose = "*" + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/pdns/keyroller/Pipfile.lock b/pdns/keyroller/Pipfile.lock new file mode 100644 index 0000000000..ee903c7969 --- /dev/null +++ b/pdns/keyroller/Pipfile.lock @@ -0,0 +1,123 @@ +{ + "_meta": { + "hash": { + "sha256": "4e2f92539310263746373e8e7767e75dd601122d6497f7dbc650e81fea92f719" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.9" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", + "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + ], + "markers": "python_version >= '3'", + "version": "==2.0.12" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "json-tricks": { + "hashes": [ + "sha256:3432a602773b36ff0fe5b94a74f5de8612c843a256724e15c32f9f669844b6ef", + "sha256:bdf7d8677bccea722984be7f68946a981e4f50c21901e292d71b9c0c60a4ace3" + ], + "index": "pypi", + "version": "==3.15.5" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "index": "pypi", + "version": "==1.3.7" + }, + "pytimeparse": { + "hashes": [ + "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd", + "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a" + ], + "index": "pypi", + "version": "==1.1.8" + }, + "pyyaml": { + "hashes": [ + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "index": "pypi", + "version": "==6.0" + }, + "requests": { + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "index": "pypi", + "version": "==2.27.1" + }, + "urllib3": { + "hashes": [ + "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", + "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" + ], + "version": "==1.26.9" + } + }, + "develop": {} +} diff --git a/pdns/keyroller/README.md b/pdns/keyroller/README.md new file mode 100644 index 0000000000..2c08edbbf9 --- /dev/null +++ b/pdns/keyroller/README.md @@ -0,0 +1,158 @@ +# PDNS Keyroll Daemon + +## Configuration + +See `pdns-keyroller.conf.example` + +## pdns-keyroller + +Main util that should be run periodically (crontab job for instance). Will list all configured automatic rolls +and proceed to scheduled operations (start a new roll, advanced roll steps). + +## pdns-keyroller-ctl + +You can configure a zone for automatic keyroll using `pdns-keyroller-ctl` + + # use the domain defaults defined in the configuration file + $ pdns-keyroller-ctl configs roll example.com + + # Specify ZSK and KSK rollover frequency + $ pdns-keyroller-ctl configs roll example.com \ + --zsk-frequency 6w --ksk-frequency never + + # Overwrite an existing configuration + $ pdns-keyroller-ctl configs roll example.com --force \ + --zsk-frequency 6w --ksk-frequency never + + # Look at an existing configuration + $ pdns-keyroller-ctl configs show example.com + + +You can now list the configured zones and see last roll informations using + + # use the domain defaults defined in the configuration file + $ pdns-keyroller-ctl configs + INFO:pdns-keyroller:example.com. is not rolling. Last KSK roll was + never and the last ZSK roll was never + +Some steps require manual actions such as KSK roll and publishing new DS to the parent. You can list such zones + + $ pdns-keyroller-ctl roll waiting + +And advance to the next step when you have published the new DS, waiting `TTL` seconds + + $ pdns-keyroller-ctl roll step + +Removed : +- NSEC3 param roll +- keystyle roll + +## Dev environment + +Setting up the environment + + $ virtualenv .venv + $ source .venv/bin/activate + $ pip install -r requirements.txt + + +## Packaging + +For now, only `centos-7` `` is supported + + $ git submodule update --init --recursive + $ bash builder/build.sh + +## Meta content + +Zone configuration and roll status is persisted through the domain metadata system provided by the authoritative server. The format used for the content is JSON. + +### Zone configuration + +Zone configuration is attached as a meta data with the key `X-PDNSKEYROLLER-CONFIG` + +``` json + { + "key_style" : "split", + "ksk_algo" : 13, + "ksk_frequency" : "6w", + "ksk_keysize" : 3069, + "ksk_method" : "prepublish", + "version" : 1, + "zsk_algo" : 13, + "zsk_frequency" : 0, + "zsk_keysize" : 3069, + "zsk_method" : "prepublish" + } +``` + +* `version` : this json format version identifier +* `key_style` : `single` or `split` depending on the number of keys +* `xsk_algo` : algorithm to roll as name or number, see bellow +* `xsk_frequency` : the rate at which to roll the keys +* `xsk_keysize` : keysize in bits +* `xsk_method` : strategy for the rollover (for now, only `prepublish` is supported) + +Frequency is parsed as time expressions like the following : + +* `6 weeks`, `6w` +* `120 days`, `120d` +* `1w 3d 2h 32m` + +Supported algorithms are : + +* 1: `RSAMD5` +* 2: `DH` +* 3: `DSA` +* 5: `RSASHA1` +* 6: `DSA-NSEC3-SHA1` +* 7: `RSASHA1-NSEC3-SHA1` +* 8: `RSASHA256` +* 10: `RSASHA512` +* 12: `ECC-GOST` +* 13: `ECDSAP256` +* 14: `ECDSAP384` +* 15: `ED25519` +* 16: `ED448` + +### Roll status + +Roll status is attached as a meta data with the key `X-PDNSKEYROLLER-STATE` + +``` json +{ + "current_roll" : { + "__instance_type__" : [ + "pdnskeyroller.prepublishkeyroll", + "PrePublishKeyRoll" + ], + "attributes" : { + "algo" : "ECDSAP256", + "complete" : false, + "current_step" : 1, + "current_step_datetime" : 1650635957.26533, + "keytype" : "ksk", + "new_keyid" : 6, + "old_keyids" : [ + 4 + ], + "rolltype" : "prepublish", + "step_datetimes" : [ + 1650632357.26229 + ] + } + }, + "last_ksk_roll_datetime" : 0, + "last_zsk_roll_datetime" : 0, + "version" : 1 +} +``` + +* `current_roll` contains informations about the actual roll +* `current_roll.complete` tells if the roll is finished +* `current_roll.current_step` is the step number +* `current_roll.current_step_datetime` tells when the step has to be performed +* `current_roll.new_keyid` contains the identifier of the new generated key when `old_keyids` contains the keys that are being replaced +* `current_roll.step_datetimes` contains timestamp at which the steps have been performed +* `last_xsk_roll_datetime` contains the timestamp of the last keyroll +* `version` contains a document format identifier diff --git a/pdns/keyroller/pdns-keyroller-ctl.py b/pdns/keyroller/pdns-keyroller-ctl.py new file mode 100755 index 0000000000..1e43cc8a26 --- /dev/null +++ b/pdns/keyroller/pdns-keyroller-ctl.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +import argparse +import logging +import sys +from pdnskeyroller import domainstate, domainconfig, keyrollerdomain +from pdnskeyroller.config import KeyrollerConfig +from pdnskeyroller.prepublishkeyroll import PrePublishKeyRoll +from pdnsapi.api import PDNSApi +from datetime import datetime, timedelta +import random + +logger = logging.getLogger('pdns-keyroller') + +def display_keyrollerdomain_infos(zone, api): + zoneconf = keyrollerdomain.KeyrollerDomain(zone, api) + if zoneconf.state : + if zoneconf.state.is_rolling: + timeleft = zoneconf.state.current_roll.current_step_datetime - datetime.now() + logger.info( + '{} is rolling its {} using the {} method. It is in the step {}, which was made {}. Next step scheduled {}'.format( + zone, zoneconf.state.current_roll.keytype.upper(), + zoneconf.state.current_roll.rolltype, zoneconf.state.current_roll.current_step_name, + zoneconf.state.current_roll.step_datetimes[-1], + "in {}".format(timeleft) if timeleft > timedelta(0) else "ASAP" + ) + ) + else: + logger.info('{} is not rolling. Last KSK roll was {} and the last ZSK roll was {}'.format( + zone, zoneconf.state.last_ksk_roll_str, zoneconf.state.last_zsk_roll_str)) + else : + logger.info('{} is not rolling'.format(zone)) + +if __name__ == '__main__': + argp = argparse.ArgumentParser( + prog='pdns-keyroller-ctl', formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description='PowerDNS DNSSEC key-roller') + argp.add_argument('--config', '-c', metavar='PATH', type=str, default='/etc/powerdns/pdns-keyroller.conf', + help='Load this configuration file') + argp.add_argument('--baseurl', '-b', required=False, metavar='BASEURL', help='The base-URL for the authoritative webserver' + 'Overrides the one set in the config-file') + argp.add_argument('--apikey', '-k', required=False, metavar='API-KEY', help='The key needed to access the API') + argp.add_argument('--verbose', '-v', action='count', help='Be more verbose') + argp.set_defaults(command='none') + + sub_parsers = argp.add_subparsers() + + configs_parser = sub_parsers.add_parser('configs', help='Lists configured domains') + configs_parser.set_defaults(command='configs', action='list') + + configs_subparsers = configs_parser.add_subparsers() + + configs_show_parser = configs_subparsers.add_parser('show', help='Show the roll configuration of the current domain') + configs_show_parser.set_defaults(action='show') + configs_show_parser.add_argument('domain', metavar='DOMAIN') + + configs_roll_parser = configs_subparsers.add_parser('roll', help='Setup the domain for autoroll') + configs_roll_parser.set_defaults(action='roll') + configs_roll_parser.add_argument('domain', metavar='DOMAIN') + + configs_roll_parser.add_argument('--force', '-f', required=False, default=False, action="store_true", help='Force creation even if a configuration already exists') + configs_roll_parser.add_argument('--ksk-frequency', required=False) + configs_roll_parser.add_argument('--ksk-algo', required=False) + configs_roll_parser.add_argument('--zsk-algo', required=False) + configs_roll_parser.add_argument('--zsk-frequency', required=False) + + configs_list_parser = configs_subparsers.add_parser('list', help='List all configured domains') + configs_list_parser.set_defaults(action='list') + + + + # roll + roll_parser = sub_parsers.add_parser('roll', help='Manipulate current rolls') + roll_parser.set_defaults(command='roll', action='waiting') + + roll_subparsers = roll_parser.add_subparsers() + + roll_waiting_parser = roll_subparsers.add_parser('waiting', help='List waiting zones (KSK rolls waiting for DS change)') + roll_waiting_parser.set_defaults(action='waiting') + + roll_step_parser = roll_subparsers.add_parser('step', help='Step waiting roll') + roll_step_parser.set_defaults(action='step') + + roll_step_parser.add_argument('domain', metavar='DOMAIN') + roll_step_parser.add_argument('ttl', metavar='TTL') + + arguments = argp.parse_args() + + if arguments.verbose: + if arguments.verbose == 1: + logging.basicConfig(level=logging.INFO) + else: + logging.basicConfig(level=logging.DEBUG) + + config = KeyrollerConfig(arguments.config) + api_config = config.api() + try: + if arguments.baseurl: + api_config['baseurl'] = arguments.baseurl + if arguments.apikey: + api_config['apikey'] = arguments.apikey + api = PDNSApi(**api_config) + except ConnectionError as e: + logger.error("Unable to connect to PowerDNS: {}".format(e)) + sys.exit(1) + + if arguments.command == 'none': + argp.print_help() + sys.exit(1) + + if arguments.command == 'configs': + if arguments.action == 'list': + for zone in api.get_zones(): + try: + display_keyrollerdomain_infos(zone.id, api) + except FileNotFoundError: + logger.debug("No config found for domain {}".format(zone.id)) + continue + except Exception as e: + logger.error("Unable to get config for domain {}: {}".format(zone.id, e)) + if arguments.action == 'show': + try: + domaincfg = domainconfig.from_api(arguments.domain, api) + logger.info( + '{} has the following roll configuration: KSK {}, ZSK {}'.format( + arguments.domain, + domaincfg.ksk_frequency, + domaincfg.zsk_frequency, + ) + ) + display_keyrollerdomain_infos(arguments.domain, api) + except FileNotFoundError: + logger.error("{} is not under automatic keyroll".format(arguments.domain)) + except ConnectionError: + logger.error( + 'No such domain {}'.format( + arguments.domain + ) + ) + except Exception as e: + logger.error("Unable to get config for domain {}: {}".format(zone.id, e)) + + + if arguments.action == 'roll': + docreate = False + try: + domaincfg = domainconfig.from_api(arguments.domain, api) + if not arguments.force: + logger.error( + '{} already has an autoroll setup'.format( + arguments.domain + ) + ) + else: + docreate = True + except FileNotFoundError: + docreate = True + except ConnectionError: + logger.error( + 'No such domain {}'.format( + arguments.domain + ) + ) + + if docreate: + domaincfg = domainconfig.DomainConfig(**config.defaults()) + try: + if arguments.ksk_frequency: + domaincfg.ksk_frequency = arguments.ksk_frequency + if arguments.ksk_algo: + domaincfg.ksk_algo = arguments.ksk_algo + if arguments.zsk_frequency: + domaincfg.zsk_frequency = arguments.zsk_frequency + if arguments.zsk_algo: + domaincfg.zsk_algo = arguments.zsk_algo + domainconfig.to_api(arguments.domain, api, domaincfg) + logger.info( + 'Successfully created configuration for {}: KSK {}, ZSK {}'.format( + arguments.domain, + domaincfg.ksk_frequency, + domaincfg.zsk_frequency, + ) + ) + except SyntaxError as e: + logger.error( + 'Unable to setup given frequency {}: {}'.format( + arguments.domain, e + ) + ) + if arguments.command == 'roll': + if arguments.action == 'waiting': + for zone in api.get_zones(): + try: + zoneconf = keyrollerdomain.KeyrollerDomain(zone.id, api) + if zoneconf.state and zoneconf.state.is_rolling and zoneconf.state.current_roll.is_waiting_ds(): + logger.info('{} is waiting for DS replacement'.format(zone.id)) + except FileNotFoundError: + continue + elif arguments.action == 'step': + try: + zoneconf = keyrollerdomain.KeyrollerDomain(arguments.domain, api) + if zoneconf.state and zoneconf.state.current_roll.is_waiting_ds(): + zoneconf.step(force=True, customttl=int(arguments.ttl)) + logger.info( + 'Successfuly steped {}, now waiting {} before deleting the keys'.format( + arguments.domain, + arguments.ttl, + ) + ) + + except FileNotFoundError: + logger.error( + 'No such zone to step {}'.format( + arguments.domain + ) + ) diff --git a/pdns/keyroller/pdns-keyroller.conf.example b/pdns/keyroller/pdns-keyroller.conf.example new file mode 100644 index 0000000000..117925c570 --- /dev/null +++ b/pdns/keyroller/pdns-keyroller.conf.example @@ -0,0 +1,28 @@ +keyroller: + loglevel: 'info' + +# for more informations on the PowerDNS Authoritative Server HTTP API +# @see https://doc.powerdns.com/authoritative/http-api/index.html +API: + baseurl: 'http://localhost:8081' + apikey: 'secret' + server: 'localhost' + timeout: 2 + +# Default configuration for zone automatic keyroll defines the frequency of both ZSK and KSK rolls +# +# Supported algos are listed here: +# https://doc.powerdns.com/authoritative/manpages/pdnsutil.1.html#dnssec-related-commands +# +# For now only pre-publish keyroll for splitted keys is supported +# see https://datatracker.ietf.org/doc/html/rfc6781.html +# +# time expressions are parsed like described https://pypi.org/project/pytimeparse/ + +domain_defaults: + ksk_frequency: never + ksk_algo: ecdsap256 + ksk_method: prepublish + zsk_frequency: 6w + zsk_algo: ecdsap256 + zsk_method: prepublish diff --git a/pdns/keyroller/pdns-keyroller.py b/pdns/keyroller/pdns-keyroller.py new file mode 100755 index 0000000000..8554433774 --- /dev/null +++ b/pdns/keyroller/pdns-keyroller.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import argparse +import logging +import pdnskeyroller.daemon +import sys +import traceback + +logger = logging.getLogger('pdns-keyroller') + +if __name__ == '__main__': + argp = argparse.ArgumentParser( + prog='pdns-keyroller', formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description='PowerDNS DNSSEC key-roller daemon') + argp.add_argument('--verbose', '-v', action='count', help='Be more verbose') + argp.add_argument('--config', '-c', metavar='PATH', type=str, default='/etc/powerdns/pdns-keyroller.conf', + help='Load this configuration file') + + arguments = argp.parse_args() + + if arguments.verbose: + if arguments.verbose == 1: + logging.basicConfig(level=logging.INFO) + else: + logging.basicConfig(level=logging.DEBUG) + + d = None + try: + d = pdnskeyroller.daemon.Daemon(arguments.config) + except ConnectionError as e: + logger.fatal('Unable to start: {}'.format(e)) + sys.exit(1) + + try: + d.run() + except Exception as e: + print(traceback.extract_tb(e)) + logger.error("Unable to run: {}".format(e)) diff --git a/pdns/keyroller/pdnsapi/__init__.py b/pdns/keyroller/pdnsapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pdns/keyroller/pdnsapi/api.py b/pdns/keyroller/pdnsapi/api.py new file mode 100644 index 0000000000..2bc4acd8ef --- /dev/null +++ b/pdns/keyroller/pdnsapi/api.py @@ -0,0 +1,444 @@ +import re +import logging +import urllib.parse +import requests + +import pdnsapi.cryptokey +from pdnsapi.cryptokey import CryptoKey +from pdnsapi.zone import Zone +from pdnsapi.metadata import ZoneMetadata + +logger = logging.getLogger(__name__) + + +def _sanitize_dnsname(name): + """ + Appends a dot to `name` if needed + + :param name: A DNS Name + :return: A DNS Name that has a trailing dot + :rtype: str + """ + if name == '.' or name == '=2E': + # lol + return '=2E' + if name[-1] != '.': + return name + '.' + return name + + +class PDNSApi: + """ + A wrapper-class that connects to the PowerDNS REST API to perform data manipulations + + TODO: We should probably try to do some caching + """ + + def __init__(self, apikey, version=1, baseurl='http://localhost:8081', server='localhost', timeout=2): + """ + :param apikey: The API Key needed to access the API (`api-key` setting) + :param version: The version of the API used, only 1 is supported at the moment + :param baseurl: The URL where the lives, without the `/api....` + :param server: The name of the server, 'localhost' by default. Use this when connecting to the API through e.g. + pdnscontrol or zone-control + :param timeout: The timeout in seconds for a request + :raises: ConnectionError when the API is not reachable + """ + api_suffix = { + 0: '', + 1: '/api/v1', + }[int(version)] + url = baseurl + '{}/servers/{}'.format(api_suffix, server) + # Strip double (or more) slashes + self.url = urllib.parse.urljoin(url, re.sub(r'/{2,}', '/', urllib.parse.urlparse(url).path)) + if apikey is None: + raise Exception('apikey may not be None!') + self.apikey = apikey + self.timeout = timeout + + # needed for __repr__ + self._version = version + self._baseurl = baseurl + self._server = server + + # Test the API, raises in _do_request + self._do_request('', 'GET') + + def __repr__(self): + return '{}.PDNSApi(apikey="{}", version={}, baseurl="{}", server="{}", timeout={})'.format( + __name__, + self.apikey, + self._version, + self._baseurl, + self._server, + self.timeout + ) + + def _do_request(self, uri, method, data=None): + """ + Does the actual API call. + + :param uri: Sub-path for the request, e.g. '/zones' + :param method: HTTP method to use + :param data: dict or list of data to send along with the request + :return: a tuple containing the HTTP status code and the JSON response in Python format (i.e. list/dict) + :rtype: tuple(int, str) + """ + headers = { + 'Accept': 'application/json', + 'X-API-Key': self.apikey, + } + + full_url = self.url + uri + + if data is not None: + if not (isinstance(data, dict) or isinstance(data, list)): + raise ValueError('data was passed as a {}, needs to be dict or list!'.format(type(data))) + if method.upper() != 'GET': + headers.update({'Content-Type': 'application/json'}) + + logger.debug('Attempting {} request to {} with data: {}'.format(method, full_url, data)) + + ret = None + try: + res = requests.request(method, full_url, headers=headers, json=data) + try: + ret = res.json() + except ValueError: + # We don't care that the response was empty + pass + res.raise_for_status() + logger.debug("Success! Got a {} response with data: {}".format(res.status_code, ret)) + return res.status_code, ret + except requests.ConnectionError as e: + logger.debug("Got a Connection error: {}".format(str(e))) + raise ConnectionError("Unable to connect to {}: {}".format(full_url, e)) + except requests.HTTPError as e: + logger.debug("Got an HTTP {} Error: {}".format(e.response.status_code, ret)) + raise ConnectionError("HTTP error code {} received for {}: {}".format( + e.response.status_code, e.request.url, ret.get('error', ret))) + except Exception as e: + msg = "Error doing {} request to {}: {}".format(method, full_url, e) + logger.debug(msg) + raise ConnectionError(msg) + + def get_cryptokeys(self, zone): + """ + Get all CryptoKeys for `zone` + + :param str zone: The zone to get the keys for + :return: All the cryptokeys for the zone + :rtype: list(CryptoKey) + """ + code, resp = self._do_request('/zones/{}/cryptokeys'.format(_sanitize_dnsname(zone)), + 'GET') + + if code == 200: + cryptokeys = [] + for k in resp: + k.pop('type') + cryptokeys.append(CryptoKey(**k)) + return cryptokeys + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def get_cryptokey(self, zone, cryptokey): + """ + Gets a single CryptoKey + + :param zone: The zone name + :param cryptokey: The id of the key or a :class:`CryptoKey `, when the latter is provided, only the ``id`` field + is read + :return: a :class:`pdnsapi.cryptokey.CryptoKey` + """ + keyid = -1 + if isinstance(cryptokey, CryptoKey): + keyid = cryptokey.id + if isinstance(cryptokey, str) or isinstance(cryptokey, int): + keyid = cryptokey + if keyid == -1: + raise Exception("cryptokey is not a CryptoKey, nor a str or int") + + code, resp = self._do_request('/zones/{}/cryptokeys/{}'.format(_sanitize_dnsname(zone), keyid), + 'GET') + + if code == 200: + resp.pop('type') + return CryptoKey(**resp) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def set_cryptokey_active(self, zone, cryptokey, active=True): + """ + Sets the `active` field of a CryptoKey + + :param zone: The name of the zone + :param cryptokey: The :class:`pdnsapi.cryptokey.CryptoKey` or a string of the `id` field + Note: the `active`-field of this object is ignored! + :param active: A boolean for the `active` field + :return: the new :class:`pdnsapi.cryptokey.Cryptokey` + :raises: Exception on failure + """ + keyid = -1 + if isinstance(cryptokey, CryptoKey): + keyid = cryptokey.id + if isinstance(cryptokey, str) or isinstance(cryptokey, int): + keyid = int(cryptokey) + if keyid == -1: + raise Exception("cryptokey is not a CryptoKey, nor a str or int") + + code, resp = self._do_request('/zones/{}/cryptokeys/{}'.format(_sanitize_dnsname(zone), keyid), + 'PUT', + {'active': active}) + if code == 422: + raise Exception('Failed to set cryptokey {} in zone {} to {}: {}'.format( + keyid, zone, 'active' if active else 'inactive', resp)) + if code == 204: + return self.get_cryptokey(zone, cryptokey) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def set_cryptokey_published(self, zone, cryptokey, published=True): + """ + Sets the `published` field of a CryptoKey + + :param zone: The name of the zone + :param cryptokey: The :class:`pdnsapi.cryptokey.CryptoKey` or a string of the `id` field + :param published: A boolean for the `published` field + :return: the new :class:`pdnsapi.cryptokey.Cryptokey` + :raises: Exception on failure + """ + keyid = -1 + if isinstance(cryptokey, CryptoKey): + keyid = cryptokey.id + if isinstance(cryptokey, str) or isinstance(cryptokey, int): + keyid = int(cryptokey) + if keyid == -1: + raise Exception("cryptokey is not a CryptoKey, nor a str or int") + + code, resp = self._do_request('/zones/{}/cryptokeys/{}'.format(_sanitize_dnsname(zone), keyid), + 'PUT', + {'published': published, + 'active': True}) + if code == 422: + raise Exception('Failed to set cryptokey {} in zone {} to {}: {}'.format( + keyid, zone, 'published' if published else 'unpublished', resp)) + if code == 204: + return self.get_cryptokey(zone, cryptokey) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def publish_cryptokey(self, zone, cryptokey): + + return self.set_cryptokey_published(zone, cryptokey, published=True) + + def unpublish_cryptokey(self, zone, cryptokey): + + return self.set_cryptokey_published(zone, cryptokey, published=False) + + def delete_cryptokey(self, zone, cryptokey): + """ + Removes a cryptokey + + :param zone: The name of the zone + :param cryptokey: The :class:`pdnsapi.zone.CryptoKey` or a string of the `id` field + Note: the `active`-field of this object is ignored! + :return: On success + :raises: Exception on failure + """ + keyid = -1 + if isinstance(cryptokey, CryptoKey): + keyid = cryptokey.id + if isinstance(cryptokey, str) or isinstance(cryptokey, int): + keyid = cryptokey + if keyid == -1: + raise Exception("cryptokey is not a CryptoKey, nor a str or int") + code, resp = self._do_request('/zones/{}/cryptokeys/{}'.format(_sanitize_dnsname(zone), keyid), + 'DELETE') + if code == 422: + raise Exception('Failed to remove cryptokey {} in zone {}: {}'.format( + keyid, zone, resp)) + if code == 204: + return + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def add_cryptokey(self, zone, keytype='zsk', active=False, content=None, algo=None, bits=None, published=True): + """ + Adds a CryptoKey to zone. If content is None, a new key is generated by the server, using algorithm from `algo` + and a size of `bits` (if applicable). If `content` and `algo` are both None, the server default is used (in + 4.0.X, this is algorithm 13, ECDSAP256SHA256) + + :param zone: The zone for which to create the key + :param keytype: Either 'ksk' or 'zsk' + :param active: Bool whether or not the new key should be active + :param content: An ISC encoded private key. + :param algo: An integer or lowercase DNSSEC algorithm name + :param bits: The size of the key + :return: The created CryptoKey on success + :raises: an Exception on failure + """ + + data = {'active': active, + 'keytype': keytype, + 'published': published} + + if content is not None: + data.update({'content': content}) + + if algo is not None: + algo = pdnsapi.cryptokey.shorthand_to_algo.get(algo, algo) + data.update({'algorithm': algo}) + + if bits is not None: + data.update({'bits': bits}) + + code, resp = self._do_request('/zones/{}/cryptokeys'.format(_sanitize_dnsname(zone)), + 'POST', + data) + + if code == 422: + raise Exception('Unable to create CryptoKey in zone {}: {}'.format(zone, resp)) + if code == 201: + resp.pop('type') + return CryptoKey(**resp) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def get_zones(self): + """ + Get all zones + + :return: All zones ons the server + :rtype: list(:class:`pdnsapi.zone.Zone`) + """ + code, resp = self._do_request('/zones', + 'GET') + if code == 200: + return [Zone(**zone) for zone in resp] + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def get_zone(self, zone): + """ + Gets the full zone contents + + :param str zone: The zone we want the full contents for + :return: a :class:`pdnsapi.zone.Zone` + """ + code, resp = self._do_request('/zones/{}'.format(_sanitize_dnsname(zone)), + 'GET') + + if code == 200: + return Zone(**resp) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def bump_soa(self, zone, serial=None): + """ + Bump zone SOA serial number + + :param str zone: The zone we want to bump + :param str serial: The new serial otherwise will update to existing serial+1 + :return: a :class:`pdnsapi.zone.Zone` + """ + + soa = None + content = self.get_zone(zone) + for rrset in content.rrsets: + if rrset.rtype == "SOA" : + soa = rrset + break + + if soa is None: + raise Exception('No such SOA record') + + + newcontent = soa.records[0].content.split(" ") + if serial != None: + newcontent[2] = serial + else: + newcontent[2] = str(int(newcontent[2]) + 1) + code, resp = self._do_request('/zones/{}'.format(_sanitize_dnsname(zone)), + 'PATCH', + { + "rrsets": [{ + "name": soa.name, + "type": soa.rtype, + "ttl": soa.ttl, + "changetype": "REPLACE", + "records": [ + { + "content": " ".join(newcontent), + "disabled": soa.records[0].disabled + } + ] + }] + }) + + if code == 204: + return self.get_zone(zone) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def set_zone_param(self, zone, param, value): + """ + + :param zone: + :param param: + :param value: + :return: + """ + zonename = _sanitize_dnsname(zone) + code, resp = self._do_request('/zones/{}'.format(zonename), + 'PUT', {param: value}) + + if code == 204: + return self.get_zone(zonename) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def get_zone_metadata(self, zone, kind=''): + """ + Gets zone metadata + + :param zone: The zone for which to retrieve the meta data + :param kind: The zone metadata kind to retrieve. If this is an empty string, all zone metadata is retrieved + :return: A list of :class:`pdnsapi.metadata.ZoneMetadata` objects + """ + code, resp = self._do_request('/zones/{}/metadata{}'.format(_sanitize_dnsname(zone), '/' + kind if len(kind) else ''), + 'GET') + + if code == 200: + if kind == '': + return [ZoneMetadata(r['kind'], r['metadata']) for r in resp] + else: + return ZoneMetadata(resp['kind'], resp['metadata']) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def set_zone_metadata(self, zone, kind, metadata): + if not isinstance(metadata, list): + metadata = [metadata] + obj = {'metadata': metadata} + code, resp = self._do_request('/zones/{}/metadata/{}'.format(_sanitize_dnsname(zone), kind), + 'PUT', + obj) + + if code == 422: + raise Exception('Failed to set metadata {} in zone {} to {}: {}'.format(kind, zone, metadata, resp)) + if code == 200: + return ZoneMetadata(resp['kind'], resp['metadata']) + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) + + def delete_zone_metadata(self, zone, kind): + code, resp = self._do_request('/zones/{}/metadata/{}'.format(_sanitize_dnsname(zone), kind), + 'DELETE') + + if code == 422: + raise Exception('Failed to remove metadata {} in zone {}: {}'.format(kind, zone, resp)) + if code == 200: + return + + raise Exception('Unexpected response: {}: {}'.format(code, resp)) diff --git a/pdns/keyroller/pdnsapi/cryptokey.py b/pdns/keyroller/pdnsapi/cryptokey.py new file mode 100644 index 0000000000..5634d81bd6 --- /dev/null +++ b/pdns/keyroller/pdnsapi/cryptokey.py @@ -0,0 +1,94 @@ +algo_to_shorthand = { + 1: "RSAMD5", + 2: "DH", + 3: "DSA", + 5: "RSASHA1", + 6: "DSA-NSEC3-SHA1", + 7: "RSASHA1-NSEC3-SHA1", + 8: "RSASHA256", + 10: "RSASHA512", + 12: "ECC-GOST", + 13: "ECDSAP256", + 14: "ECDSAP384", + 15: "ED25519", + 16: "ED448", +} + +shorthand_to_algo = {v: k for k, v in algo_to_shorthand.items()} + +algo_to_bits = { + 13: 256, + 14: 512, + 15: 32, + 16: 57. +} + + +class CryptoKey: + """ + Represents a CryptoKey from the API + """ + _algo = None + + def __init__(self, id, active, keytype, flags=None, algo=None, dnskey=None, ds=None, privatekey=None, **kwargs): + """ + Construct a new CryptoKey + + :param int id: The id number of the key + :param bool active: Whether or not this key is active + :param string keytype: The type of key, KSK, ZSK or CSK + :param int flags: The flags of this key + :param algo: The algorithm of the key. Can be an integer or a string mnemonic + :param string dnskey: The DNSKEY zonefile content + :param list(string) ds: The DS records for this key + :param string privatekey: The private key content + :param dict kwargs: for compatibility with (future) API responses, ignored + """ + self.id = id + self.active = active + self.keytype = keytype + self.flags = flags + self.dnskey = dnskey + self.ds = ds + self.privatekey = privatekey + self.algo = algo or dnskey.split(' ')[2] + + def __repr__(self): + return 'CryptoKey({id}, {active}, {keytype}, {flags}, {algo}, {dnskey}, {ds}, "{privatekey})'.format( + id=self.id, active=self.active, keytype=self.keytype, flags=self.flags, algo=self.algo, dnskey=self.dnskey, + ds=self.ds, privatekey=self.privatekey) + + def __str__(self): + return str({ + 'id': self.id, + 'active': self.active, + 'keytype': self.keytype, + 'flags': self.flags, + 'dnskey': self.dnskey, + 'ds': self.ds, + 'privatekey': self.privatekey, + 'algo': self.algo, + }) + + @property + def algo(self): + """ + Returns the algorithm of this key + + :return: Either the mnemonic or the algorithm number is the mnemonic is unknown + """ + return algo_to_shorthand.get(self._algo, self._algo) + + @algo.setter + def algo(self, val): + if isinstance(val, int): + self._algo = val + return + if isinstance(val, str): + try: + self._algo = int(val) + except ValueError: + self.algo = shorthand_to_algo.get(val, val) + return + raise ValueError("Value is not a str or int, but a {}".format(type(val))) + diff --git a/pdns/keyroller/pdnsapi/metadata.py b/pdns/keyroller/pdnsapi/metadata.py new file mode 100644 index 0000000000..28b32ed5ca --- /dev/null +++ b/pdns/keyroller/pdnsapi/metadata.py @@ -0,0 +1,18 @@ +class ZoneMetadata: + def __init__(self, kind, metadata, type="Metadata"): + self.kind = kind + if not isinstance(metadata, list): + raise Exception('metadata must be a list, not a {}'.format(type(metadata))) + self.metadata = metadata + + def empty(self): + return not self.metadata + + def __repr__(self): + return 'ZoneMetadata({}, {})'.format(self.kind, self.metadata) + + def __str__(self): + return str({ + 'kind': self.kind, + 'metadata': self.metadata + }) diff --git a/pdns/keyroller/pdnsapi/zone.py b/pdns/keyroller/pdnsapi/zone.py new file mode 100644 index 0000000000..d7eef2e9bf --- /dev/null +++ b/pdns/keyroller/pdnsapi/zone.py @@ -0,0 +1,160 @@ +class RRSet: + def __init__(self, name, type, ttl, records, comments=[]): + """ + Represents and RRSet from the API, see https://doc.powerdns.com/md/httpapi/api_spec/#zone95collection + + :param str name: Name of the rrset + :param str type: Type of the rrset + :param int ttl: Time to Live of the rrset + :param list records: a list of :class:`Record` + :param list comments: a list of :class:`Comment` + """ + self._records = [] + self._comments = [] + self.name = name + self.rtype = type + self.ttl = ttl + self.records = records + self.comments = comments + + def __repr__(self): + return 'RRSet("{}", "{}", {}, {}, {})'.format(self.name, self.rtype, self.ttl, self.records, self.comments) + + def __str__(self): + ret = '\n'.join(['; {}'.format(c) for c in self.comments]) + ret += '\n'.join(['{}{}\tIN\t{}\t{}'.format(';' if rec.disabled else '', self.name, self.rtype, rec.content) + for rec in self.records]) + return ret + + @property + def records(self): + return self._records + + @records.setter + def records(self, val): + if not isinstance(val, list): + raise Exception('TODO') + if all(isinstance(v, dict) for v in val): + self._records = [] + for v in val: + self._records.append(Record(**v)) + return + if not all(isinstance(v, Record) for v in val): + raise Exception('Not all records are of type Record') + self._records = val + + @property + def comments(self): + return self._comments + + @comments.setter + def comments(self, val): + if not isinstance(val, list): + raise Exception('TODO') + if all(isinstance(v, dict) for v in val): + self._comments = [] + for v in val: + self._comments.append(Comment(**v)) + return + if not all(isinstance(v, Comment) for v in val): + raise Exception('Not all comments are of type Comment') + self._comments = val + + +class Record: + def __init__(self, content, disabled): + """ + Represents a Record from the API. Note that is does not contian the rrname nor ttl (these are held by the + encompassing :class:`RRSet` object). + + :param str content: The content of the record in zonefile-format + :param bool disabled: True if this record is disabled + """ + self.content = content + self.disabled = bool(disabled) + + def __repr__(self): + return 'Record("{}", "{}")'.format(self.content, self.disabled) + + +class Comment: + def __init__(self, content, modified_at, account): + """ + Constructor, see https://doc.powerdns.com/md/httpapi/api_spec/#zone95collection + + :param str content: The content of the comment + :param int modified_at: A timestamp when the comment was changed + :param account: The account that made this comment + """ + self.content = content + # TODO make modified_at a datetime.datetime + self.modified_at = modified_at + self.account = account + + def __repr__(self): + return 'Comment("{}", "{}", "{})'.format(self.content, self.modified_at, self.account) + + def __str__(self): + return '{} by {} on {}'.format(self.content, self.account, self.modified_at) + + +class Zone: + """ + This represents a Zone-object + """ + _keys = ["id", "name", "url", "kind", "serial", "notified_serial", "masters", "dnssec", "nsec3param", + "nsec3narrow", "presigned", "soa_edit", "soa_edit_api", "account", "nameservers", "servers", + "recursion_desired", "rrsets", "last_check"] + _rrsets = [] + _kind = '' + + def __init__(self, **kwargs): + """ + Constructor + :param kwargs: Any of the elements named in https://doc.powerdns.com/md/httpapi/api_spec/#zone95collection + """ + for k, v in kwargs.items(): + if k in Zone._keys: + setattr(self, k, v) + + def __str__(self): + ret = "{}".format('\n'.join(['; {} = {}'.format( + k, str(getattr(self, k))) for k in Zone._keys if getattr(self, k, None) and k != 'rrsets'])) + ret += "\n{}".format('\n'.join([str(v) for v in self.rrsets])) + return ret + + def __repr__(self): + return 'Zone({})'.format( + ', '.join(['{}="{}"'.format(k, getattr(self, k)) for k in Zone._keys if getattr(self, k, None)])) + + @property + def kind(self): + return self._kind + + @kind.setter + def kind(self, val): + if val not in ['Native', 'Master', 'Slave']: + raise Exception("TODO") + self._kind = val + + @property + def rrsets(self): + return self._rrsets + + @rrsets.setter + def rrsets(self, val): + """ + Sets the :class:`RRSet`s for this Zone + :param val: a list of :class:`RRSet`s or :class:`dict`s. The latter is converted to RRsets + :return: + """ + if not isinstance(val, list): + raise Exception('Please pass a list of RRSets') + if all(isinstance(v, dict) for v in val): + self._rrsets = [] + for v in val: + self._rrsets.append(RRSet(**v)) + return + if not all(isinstance(v, RRSet) for v in val): + raise Exception('Not all rrsets are actually RRSets') + self._rrsets = val diff --git a/pdns/keyroller/pdnskeyroller/__init__.py b/pdns/keyroller/pdnskeyroller/__init__.py new file mode 100644 index 0000000000..115fdb943a --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/__init__.py @@ -0,0 +1,4 @@ +__author__ = 'PowerDNS.COM BV' + +PDNSKEYROLLER_CONFIG_metadata_kind = 'X-PDNSKEYROLLER-CONFIG' +PDNSKEYROLLER_STATE_metadata_kind = 'X-PDNSKEYROLLER-STATE' diff --git a/pdns/keyroller/pdnskeyroller/config.py b/pdns/keyroller/pdnskeyroller/config.py new file mode 100644 index 0000000000..9cbb281837 --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/config.py @@ -0,0 +1,68 @@ +import yaml +import datetime +import logging + +from pdnsapi.api import PDNSApi +import pdnskeyroller.keyrollerdomain + +logger = logging.getLogger(__name__) + + +class KeyrollerConfig: + def __init__(self, configfile): + self._configfile = configfile + self._config = self._load_config() + + def _load_config(self): + # These are all the Defaults + tmp_conf = { + 'keyroller': { + 'loglevel': 'info', + }, + 'API': { + 'version': 1, + 'baseurl': 'http://localhost:8081', + 'server': 'localhost', + 'apikey': '', + 'timeout': '2', + }, + 'domain_defaults': { + 'ksk_frequency': 0, + 'ksk_algo': 13, + 'ksk_method': 'prepublish', + 'zsk_frequency': '6w', + 'zsk_algo': 13, + 'zsk_method': 'prepublish', + 'key_style': 'single', + 'ksk_keysize': 3069, + 'zsk_keysize': 3069, + }, + } + + logger.debug("Loading configuration from {}".format(self._configfile)) + try: + with open(self._configfile, 'r') as f: + a = yaml.safe_load(f) + if a: + for k, v in tmp_conf.items(): + if isinstance(v, dict) and isinstance(a.get(k), dict): + tmp_conf[k].update(a.get(k)) + if isinstance(v, list) and isinstance(a.get(k), list): + tmp_conf[k] = a.get(k) + + loglevel = getattr(logging, tmp_conf['keyroller']['loglevel'].upper()) + if not isinstance(loglevel, int): + loglevel = logging.INFO + logger.info("Setting loglevel to {}".format(loglevel)) + logging.basicConfig(level=loglevel) + + except FileNotFoundError as e: + logger.error('Unable to load configuration file: {}'.format(e)) + + return tmp_conf + + def api(self): + return self._config['API'] + + def defaults(self): + return self._config['domain_defaults'] diff --git a/pdns/keyroller/pdnskeyroller/daemon.py b/pdns/keyroller/pdnskeyroller/daemon.py new file mode 100644 index 0000000000..b63b6627de --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/daemon.py @@ -0,0 +1,119 @@ +import yaml +import datetime +import logging + +from pdnsapi.api import PDNSApi +from pdnskeyroller import domainstate +import pdnskeyroller.keyrollerdomain +from pdnskeyroller.prepublishkeyroll import PrePublishKeyRoll + +logger = logging.getLogger(__name__) + + +class Daemon: + def __init__(self, configfile): + self._configfile = configfile + self._config = self._load_config() + + # Initialize all domains + self._domains = {} + api = PDNSApi(**self._config['API']) + for zone in api.get_zones(): + try: + zoneconf = pdnskeyroller.keyrollerdomain.KeyrollerDomain(zone.id, api) + self._domains[zone.id] = zoneconf + except FileNotFoundError: + logger.debug("No config found for zone {}".format(zone.id)) + continue + except Exception as e: + logger.error("Unable to load informations for zone {}".format(zone.id)) + continue + + def _load_config(self): + # These are all the Defaults + tmp_conf = { + 'keyroller': { + 'loglevel': 'info', + }, + 'API': { + 'version': 1, + 'baseurl': 'http://localhost:8081', + 'server': 'localhost', + 'apikey': '', + 'timeout': '2', + }, + } + + logger.debug("Loading configuration from {}".format(self._configfile)) + try: + with open(self._configfile, 'r') as f: + a = yaml.safe_load(f) + if a: + for k, v in tmp_conf.items(): + if isinstance(v, dict) and isinstance(a.get(k), dict): + tmp_conf[k].update(a.get(k)) + if isinstance(v, list) and isinstance(a.get(k), list): + tmp_conf[k] = a.get(k) + + loglevel = getattr(logging, tmp_conf['keyroller']['loglevel'].upper()) + if not isinstance(loglevel, int): + loglevel = logging.INFO + logger.info("Setting loglevel to {}".format(loglevel)) + logging.basicConfig(level=loglevel) + + except FileNotFoundError as e: + logger.error('Unable to load configuration file: {}'.format(e)) + + return tmp_conf + + def _get_actionable_domains(self): + now = datetime.datetime.now() + return [zone for zone, domainconf in self._domains.items() if + domainconf.next_action_datetime and domainconf.next_action_datetime <= now] + + def update_config(self): + """ + Should be called when we want to update the config of a running instance (not implemented) + + :return: + """ + pass + + def run(self): + actionable_domains = self._get_actionable_domains() + now = datetime.datetime.now() + logger.debug("Found {} domain(s) ({} actionable)".format(len(self._domains), len(actionable_domains))) + + + if len(actionable_domains) > 0: + for domain in actionable_domains: + keyrollerdomain = self._domains[domain] + if keyrollerdomain.state.is_rolling: + try: + logger.info("Moving to step {} for {} roll".format(keyrollerdomain.current_step_name, keyrollerdomain.zone)) + keyrollerdomain.step() + except Exception as e: + logger.error("Unable to advance keyroll: {}".format(e)) + else: + next_ksk_roll = keyrollerdomain.next_ksk_roll() + next_zsk_roll = keyrollerdomain.next_zsk_roll() + if next_zsk_roll is not None and next_zsk_roll <= now: + try: + logger.info("Starting {} {} keyroll for {} ({} algo)".format("pre-publish", "ZSK", keyrollerdomain.zone, keyrollerdomain.config.zsk_algo)) + roll = PrePublishKeyRoll() + roll.initiate(keyrollerdomain.zone, keyrollerdomain.api, 'zsk', keyrollerdomain.config.zsk_algo) + keyrollerdomain.state.current_roll = roll + domainstate.to_api(keyrollerdomain.zone, keyrollerdomain.api, keyrollerdomain.state) + except Exception as e: + logger.error("Unable to start keyroll: {}".format(e)) + elif next_ksk_roll is not None and next_ksk_roll <= now: + try: + logger.info("Starting {} {} keyroll for {} ({} algo)".format("pre-publish", "KSK", keyrollerdomain.zone, keyrollerdomain.config.zsk_algo)) + roll = PrePublishKeyRoll() + roll.initiate(keyrollerdomain.zone, keyrollerdomain.api, 'ksk', keyrollerdomain.config.ksk_algo) + keyrollerdomain.state.current_roll = roll + domainstate.to_api(keyrollerdomain.zone, keyrollerdomain.api, keyrollerdomain.state) + except Exception as e: + logger.error("Unable to start keyroll: {}".format(e)) + else: + logger.info("No action taken") diff --git a/pdns/keyroller/pdnskeyroller/domainconfig.py b/pdns/keyroller/pdnskeyroller/domainconfig.py new file mode 100644 index 0000000000..c809fef207 --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/domainconfig.py @@ -0,0 +1,198 @@ +import pdnsapi.api +from pdnskeyroller import PDNSKEYROLLER_CONFIG_metadata_kind +from pytimeparse.timeparse import timeparse +import pdnsapi.metadata +import json_tricks.nonp as json_tricks +from pdnskeyroller.util import (parse_algo) + +DOMAINCONFIG_VERSION = 1 + +def from_api(zone, api): + """ + Retrieves a keyroller configuration for zone ``zone`` from the api ``api`` + + :param string zone: The zone to retrieve the Config for + :param pdnsapi.api.PDNSApi api: The API to use + :return: The configuration + :rtype: :class:`DomainConfig` + :raises: FileNotFoundError if ``zone`` does not have a roller config + """ + if not isinstance(api, pdnsapi.api.PDNSApi): + raise Exception('api is not a PDNSApi') + + metadata = api.get_zone_metadata(zone, PDNSKEYROLLER_CONFIG_metadata_kind) + + if metadata.empty(): + raise FileNotFoundError + + if len(metadata.metadata) > 1: + raise Exception("More than one {} Domain Metadata found for {}!".format(PDNSKEYROLLER_CONFIG_metadata_kind, + zone)) + try: + state = json_tricks.loads(metadata.metadata[0]) + except Exception as e: + raise ValueError(e) + + return DomainConfig(**state) + +def to_api(zone, api, config): + """ + + :param zone: + :param api: + :param config: + :return: + """ + if not isinstance(api, pdnsapi.api.PDNSApi): + raise Exception('api must be a PDNSApi instance, not a {}'.format(type(api))) + if not isinstance(config, DomainConfig): + raise Exception('config must be a DomainConfig instance, not a {}'.format(type(config))) + + api.set_zone_metadata(zone, PDNSKEYROLLER_CONFIG_metadata_kind, str(config)) + + +class DomainConfig: + __version = DOMAINCONFIG_VERSION + __ksk_frequency = 0 + __ksk_algo = 13 + __ksk_keysize = 3096 + __ksk_method = "prepublish" + __zsk_frequency = "6w" + __zsk_algo = 13 + __zsk_keysize = 3096 + __zsk_method = "prepublish" + __key_style = "split" + + def __init__(self, version=DOMAINCONFIG_VERSION, ksk_frequency=0, ksk_algo=13, ksk_keysize=3096, ksk_method="prepublish", + zsk_frequency="6w", zsk_algo=13, zsk_keysize=3096, zsk_method="prepublish", key_style="split", **kwargs): + + self.version = version + + self.ksk_frequency = ksk_frequency + self.ksk_algo = ksk_algo + self.ksk_keysize = ksk_keysize + self.ksk_method = ksk_method + + self.zsk_frequency = zsk_frequency + self.zsk_algo = zsk_algo + self.zsk_keysize = zsk_keysize + self.zsk_method = zsk_method + + self.key_style = key_style + if kwargs: + logger.warning('Unknown keys passed: {}'.format(', '.join( + [k for k, v in kwargs.items()]))) + + @property + def ksk_frequency(self): + + return self.__ksk_frequency + + @ksk_frequency.setter + def ksk_frequency(self, value): + if value != "never" and value != 0: + if timeparse(value) is None: + raise SyntaxError('Can not parse value "%s" to as timedelta' % value) + self.__ksk_frequency = value + else: + self.__ksk_frequency = 0 + + @property + def ksk_algo(self): + return self.__ksk_algo + + @ksk_algo.setter + def ksk_algo(self, value): + self.__ksk_algo = parse_algo(value) + + @property + def ksk_keysize(self): + return self.__ksk_keysize + + @ksk_keysize.setter + def ksk_keysize(self, value): + self.__ksk_keysize = value + + @property + def ksk_method(self): + return self.__ksk_method + + @ksk_method.setter + def ksk_method(self, value): + self.__ksk_method = value + + @property + def zsk_frequency(self): + return self.__zsk_frequency + + @zsk_frequency.setter + def zsk_frequency(self, value): + if value != "never" and value != 0: + if timeparse(value) is None: + raise SyntaxError('Can not parse value "%s" to as timedelta' % value) + self.__zsk_frequency = value + else: + self.__zsk_frequency = 0 + + @property + def zsk_algo(self): + return self.__zsk_algo + + @zsk_algo.setter + def zsk_algo(self, value): + self.__zsk_algo = parse_algo(value) + + @property + def zsk_keysize(self): + return self.__zsk_keysize + + @zsk_keysize.setter + def zsk_keysize(self, value): + self.__zsk_keysize = value + + @property + def zsk_method(self): + return self.__zsk_method + + @zsk_method.setter + def zsk_method(self, value): + self.__zsk_method = value + + @property + def key_style(self): + return self.__key_style + + @key_style.setter + def key_style(self, value): + if value not in ('single', 'split'): + raise Exception('Invalid key_style: {}'. format(value)) + self.__key_style = value + + @property + def version(self): + return self.__version + + @version.setter + def version(self, val): + if val != 1: + raise Exception('{} is not a valid version!') + self.__version = val + + def __repr__(self): + return 'DomainConfig({})'.format( + ', '.join(['{} = "{}"'.format(k, self.__getattribute__(k)) for k in + ["version", "ksk_frequency", "ksk_algo", "ksk_keysize", "ksk_method", "zsk_frequency", + "zsk_algo", "zsk_keysize", "zsk_method", "key_style"]])) + def __str__(self): + return(json_tricks.dumps({ + 'version': self.version, + 'ksk_frequency': self.ksk_frequency, + 'ksk_algo': self.ksk_algo, + 'ksk_keysize': self.ksk_keysize, + 'ksk_method': self.ksk_method, + 'zsk_frequency': self.zsk_frequency, + 'zsk_algo': self.zsk_algo, + 'zsk_keysize': self.zsk_keysize, + 'zsk_method': self.zsk_method, + 'key_style': self.key_style, + })) diff --git a/pdns/keyroller/pdnskeyroller/domainstate.py b/pdns/keyroller/pdnskeyroller/domainstate.py new file mode 100644 index 0000000000..c5034ff031 --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/domainstate.py @@ -0,0 +1,153 @@ +import logging +import pdnsapi.api +from datetime import datetime +import json_tricks.nonp as json_tricks +from pdnskeyroller import PDNSKEYROLLER_STATE_metadata_kind +from pdnskeyroller.keyroll import KeyRoll +from pdnskeyroller.prepublishkeyroll import PrePublishKeyRoll + +DOMAINSTATE_VERSION = 1 +logger = logging.getLogger(__name__) + + +def from_api(zone, api): + """ + Get the keyroller state from the API + + :param string zone: The zone to het the state for + :param pdnsapi.api.PDNSApi api: the API endpoint to use + :return: The state for ``zone`` + :rtype: DomainState + :raises: ValueError if the JSON from the domain metadata cannot be unpacked + """ + if not isinstance(api, pdnsapi.api.PDNSApi): + raise Exception('api must be a PDNSApi instance, not a {}'.format(type(api))) + tmp_state = api.get_zone_metadata(zone, PDNSKEYROLLER_STATE_metadata_kind).metadata + + if not tmp_state: + return DomainState() + + if len(tmp_state) > 1: + raise Exception('More than one {} metadata found!'.format(PDNSKEYROLLER_STATE_metadata_kind)) + + try: + state = json_tricks.loads(tmp_state[0]) + except Exception as e: + raise ValueError(e) + + return DomainState(**state) + + +def to_api(zone, api, state): + """ + + :param zone: + :param api: + :param state: + :return: + """ + if not isinstance(api, pdnsapi.api.PDNSApi): + raise Exception('api must be a PDNSApi instance, not a {}'.format(type(api))) + if not isinstance(state, DomainState): + raise Exception('state must be a DomainState instance, not a {}'.format(type(state))) + + if state.current_roll.complete: + state.set_last_roll_date(state.current_roll.keytype, state.current_roll.step_datetimes[-1]) + state.current_roll = KeyRoll() + + api.set_zone_metadata(zone, PDNSKEYROLLER_STATE_metadata_kind, str(state)) + + +class DomainState: + __last_zsk_roll_datetime = None + __last_ksk_roll_datetime = None + __current_roll = None + __version = DOMAINSTATE_VERSION + + def __init__(self, version=DOMAINSTATE_VERSION, last_ksk_roll_datetime=datetime.min, + last_zsk_roll_datetime=datetime.min, current_roll=KeyRoll(), **kwargs): + + self.version = version + self.last_ksk_roll_datetime = last_ksk_roll_datetime if isinstance(last_ksk_roll_datetime, datetime) else datetime.fromtimestamp(last_ksk_roll_datetime) + self.last_zsk_roll_datetime = last_zsk_roll_datetime if isinstance(last_zsk_roll_datetime, datetime) else datetime.fromtimestamp(last_zsk_roll_datetime) + self.current_roll = current_roll + if kwargs: + logger.warning('Unknown keys passed: {}'.format(', '.join( + [k for k, v in kwargs.items()]))) + + @property + def last_zsk_roll_datetime(self): + return self.__last_zsk_roll_datetime + + @last_zsk_roll_datetime.setter + def last_zsk_roll_datetime(self, val): + if not isinstance(val, datetime): + raise Exception('Can not set last_zsk_roll_datetime: not a datetime object') + self.__last_zsk_roll_datetime = val + + @property + def last_ksk_roll_datetime(self): + return self.__last_ksk_roll_datetime + + @last_ksk_roll_datetime.setter + def last_ksk_roll_datetime(self, val): + if not isinstance(val, datetime): + raise Exception('Can not set last_ksk_roll_datetime: not a datetime object') + self.__last_ksk_roll_datetime = val + + @property + def last_ksk_roll_str(self): + return "never" if self.last_ksk_roll_datetime == datetime.min else \ + str(self.last_ksk_roll_datetime) + @property + def last_zsk_roll_str(self): + return "never" if self.last_zsk_roll_datetime == datetime.min else \ + str(self.last_zsk_roll_datetime) + + @property + def current_roll(self): + return self.__current_roll + + @current_roll.setter + def current_roll(self, val): + if not isinstance(val, (KeyRoll, PrePublishKeyRoll)): + raise Exception('Roll is not a KeyRoll') + self.__current_roll = val + + @property + def version(self): + return self.__version + + @version.setter + def version(self, val): + if val != 1: + raise Exception('{} is not a valid version!') + self.__version = val + + def __repr__(self): + return 'DomainState({})'.format( + ', '.join(['{}={}'.format(k, v) for k, v in [ + ('version', self.version), + ('last_ksk_roll_datetime', self.last_ksk_roll_datetime.timestamp() if self.last_ksk_roll_datetime > datetime.fromtimestamp(0) else 0), + ('last_zsk_roll_datetime', self.last_zsk_roll_datetime.timestamp() if self.last_zsk_roll_datetime > datetime.fromtimestamp(0) else 0), + ('current_roll', self.current_roll), + ]]) + ) + + def __str__(self): + return(json_tricks.dumps({ + 'version': self.version, + 'last_ksk_roll_datetime': self.last_ksk_roll_datetime.timestamp() if self.last_ksk_roll_datetime > datetime.fromtimestamp(0) else 0, + 'last_zsk_roll_datetime': self.last_zsk_roll_datetime.timestamp() if self.last_zsk_roll_datetime > datetime.fromtimestamp(0) else 0, + 'current_roll': self.current_roll, + })) + + def set_last_roll_date(self, keytype, date): + self.__setattr__('last_{}_roll_datetime'.format(keytype), date) + + def last_roll_date(self, keytype): + return self.__getattribute__('last_{}_roll_datetime'.format(keytype)) + + @property + def is_rolling(self): + return bool(not self.current_roll.complete and self.current_roll.started) diff --git a/pdns/keyroller/pdnskeyroller/keyroll.py b/pdns/keyroller/pdnskeyroller/keyroll.py new file mode 100644 index 0000000000..59b205f11a --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/keyroll.py @@ -0,0 +1,27 @@ +class KeyRoll: + def __init__(self, **kwargs): + self.rolltype = kwargs.get('rolltype') + self.complete = False + + def initiate(self, zone, api, **kwargs): + raise NotImplementedError() + + def step(self, zone, api): + raise NotImplementedError() + + def validate(self, zone, api): + raise NotImplementedError() + + def __str__(self): + return '' + + def __repr__(self): + raise NotImplementedError() + + @property + def started(self): + return False + + @property + def current_step_name(self): + raise NotImplementedError() diff --git a/pdns/keyroller/pdnskeyroller/keyrollerdomain.py b/pdns/keyroller/pdnskeyroller/keyrollerdomain.py new file mode 100644 index 0000000000..f09d38d1d1 --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/keyrollerdomain.py @@ -0,0 +1,87 @@ +from pdnsapi.api import PDNSApi +import logging +import pdnskeyroller.domainconfig +import pdnskeyroller.domainstate +from pytimeparse.timeparse import timeparse +import datetime + +logger = logging.getLogger(__name__) + +class KeyrollerDomain: + def __init__(self, zone, api, config=None, state=None): + if not isinstance(api, PDNSApi): + raise Exception('api is not a PDNSApi') + + self.zone = zone + self.api = api + if not config: + config = pdnskeyroller.domainconfig.from_api(zone, api) + + if not isinstance(config, pdnskeyroller.domainconfig.DomainConfig): + raise Exception('config is not a DomainConfig') + + self.config = config + + if not state: + state = pdnskeyroller.domainstate.from_api(zone, api) + + if not isinstance(state, pdnskeyroller.domainstate.DomainState): + raise Exception('state is not a DomainState') + + self.state = state + + def next_ksk_roll(self): + if not self.state.is_rolling: + if self.config.ksk_frequency != 0 : + return self.state.last_roll_date('ksk') + datetime.timedelta(seconds=timeparse(self.config.ksk_frequency)) + return None + + def next_zsk_roll(self): + if not self.state.is_rolling: + if self.config.zsk_frequency != 0: + return self.state.last_roll_date('zsk') + datetime.timedelta(seconds=timeparse(self.config.zsk_frequency)) + return None + + @property + def current_step_name(self): + if not self.state.is_rolling: + return None + return self.state.current_roll.current_step_name + + def step(self, force=False, customttl=0): + if not self.state.is_rolling: + return + self.state.current_roll.step(self.zone, self.api, force, customttl) + pdnskeyroller.domainstate.to_api(self.zone, self.api, self.state) + + @property + def next_action_datetime(self): + """ + The datetime for the next roll or action + + :return: + """ + ret = [] + if self.state.is_rolling: + nextaction = self.state.current_roll.current_step_datetime + ret.append(nextaction) + logger.debug("{}: Next roll step {}".format(self.zone, nextaction)) + else: + if self.config.zsk_frequency != 0: + nextaction = self.next_zsk_roll() + ret.append(nextaction) + logger.debug("{}: Next ZSK roll {}".format(self.zone, nextaction)) + if self.config.ksk_frequency != 0: + nextaction = self.next_ksk_roll() + ret.append(nextaction) + logger.debug("{}: Next KSK roll {}".format(self.zone, nextaction)) + if ret: + ret.sort() + return ret[0] + return None + + + def __repr__(self): + return 'keyrollerDomain("{}", {}, {}, {})'.format( + self.zone, self.api, self.config, self.state + ) diff --git a/pdns/keyroller/pdnskeyroller/prepublishkeyroll.py b/pdns/keyroller/pdnskeyroller/prepublishkeyroll.py new file mode 100644 index 0000000000..d9ebbca941 --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/prepublishkeyroll.py @@ -0,0 +1,220 @@ +import pdnsapi.api +import json_tricks.nonp as json_tricks +from pdnskeyroller.util import (get_keys_of_type, DNSKEY_ALGO_TO_MNEMONIC, DNSKEY_MNEMONIC_TO_ALGO, validate_api) +from datetime import datetime, timedelta +from pdnskeyroller.keyroll import KeyRoll + +_step_to_name = { + 0: 'initial', + 1: 'new DNSKEY', + 2: 'new DS/new RRSIGs', + 3: 'DNSKEY removal', +} + + +class PrePublishKeyRoll(KeyRoll): + def __init__(self, **kwargs): + super().__init__(rolltype='prepublish') + self.current_step = kwargs.get('current_step', 0) + self.complete = kwargs.get('complete', False) + self.step_datetimes = list(map(lambda x: datetime.fromtimestamp(x), kwargs.get('step_datetimes', []))) + self.current_step_datetime = datetime.fromtimestamp(kwargs.get('current_step_datetime', datetime.now().timestamp())) + self.keytype = kwargs.get('keytype') + self.algo = kwargs.get('algo') + self.old_keyids = kwargs.get('old_keyids') + self.new_keyid = kwargs.get('new_keyid') + + def initiate(self, zone, api, keytype, algo, bits=None, published=True): + """ + Initiate a pre-publish rollover (:rfc:`RFC 6781 §4.1.1.1 <6781#section-4.1.1.1>`) for the ``keytype`` key of algorithm + ``algo`` for ``zone``. + + The roll will **only** be initiated if there exists a ``keytype`` key of algorithm ``algo`` for the domain ``zone``. + + :param string zone: The zone to roll for + :param pdnsapi.api.PDNSApi api: The API endpoint to use + :param string keytype: The keytype to roll, must be one of 'ksk', 'zsk' or 'csk' + :param string algo: The algorithm to roll the ``keytype`` for + :param int bits: If needed, use this many bits for the new key for ``algo`` + """ + if self.started: + raise Exception('Already rolling the {} for {}'.format( + self.keytype, zone)) + validate_api(api) + + keytype = keytype.lower() + if keytype not in ('ksk', 'zsk'): + raise Exception('Invalid key type: {}'.format(keytype)) + + current_keys = get_keys_of_type(zone, api, keytype) + algo = DNSKEY_ALGO_TO_MNEMONIC.get(algo, algo) + if not current_keys: + raise Exception('There are no keys of type {} in zone {}, cannot roll!'.format(keytype, zone)) + if not any([k.algo == algo and k.keytype == keytype for k in current_keys]): + raise Exception('No keys for algorithm {} in zone {}, cannot roll!'.format(algo, zone)) + + active = True + published = True + if keytype == "zsk": + active = False + new_key = api.add_cryptokey(zone, keytype, active=active, algo=algo, bits=bits, published=published) + self.current_step = 1 + self.complete = False + self.step_datetimes = [datetime.now()] + self.keytype = keytype + self.algo = algo + self.old_keyids = [k.id for k in current_keys if k.algo == algo and k.keytype == keytype] + self.new_keyid = new_key.id + httl = self._get_highest_ttl(zone, api) + self.current_step_datetime = datetime.now() + timedelta(seconds=httl) + + api.bump_soa(zone); + + def _get_highest_ttl(self, zone, api, zoneobject=None): + if zoneobject is None: + zoneobject = api.get_zone(zone) + httl = 0 + for rrset in zoneobject.rrsets: + httl = max(rrset.ttl, httl) + + return httl + + def is_waiting_ds(self): + return self.started and self.keytype == "ksk" and self.current_step == 1 + + def step(self, zone, api, force=False, customttl=0): + """ + Perform the next step in the keyroll + + :param string zone: The zone we are rolling for + :param pdnsapi.api.PDNSApi api: The API endpoint to use + :raises: Exception when a sanity check fails + """ + validate_api(api) + if not self.validate(zone, api): + raise Exception('Keys for zone {} do not match keys initially found. Refusing to continue'.format(zone)) + + if not self.started: + raise Exception('Can not go to the next step in phase "{}", did you mean to call initialize()?'.format( + self.current_step_name)) + + # make sure we are passed the expected datetime + if self.current_step_datetime > datetime.now(): + return + + if self.current_step == 1: + if self.keytype == "zsk": + # activate the new keys and deactivate the old ones + api.set_cryptokey_active(zone, self.new_keyid, active=True) + for keyid in self.old_keyids: + api.set_cryptokey_active(zone, keyid, active=False) + + api.bump_soa(zone); + + httl = self._get_highest_ttl(zone, api) + self.current_step_datetime = datetime.now() + timedelta(seconds=httl) + self.step_datetimes.append(datetime.now()) + self.current_step = 2 + + elif self.keytype == "ksk": + if force == True and isinstance(customttl, int): + self.current_step_datetime = datetime.now() + timedelta(seconds=customttl) + self.step_datetimes.append(datetime.now()) + self.current_step = 3 + + elif self.current_step == 2: + if self.keytype == "zsk": + # remove the old keys + for keyid in self.old_keyids: + api.delete_cryptokey(zone, keyid) + api.bump_soa(zone); + # rollover is finished + self.complete = True + self.step_datetimes.append(datetime.now()) + + + elif self.current_step == 3: + if self.keytype == "ksk": + # remove the old keys + for keyid in self.old_keyids: + api.delete_cryptokey(zone, keyid) + api.bump_soa(zone); + # rollover is finished + self.complete = True + self.step_datetimes.append(datetime.now()) + + else: + raise Exception("Unknown step number {}".format(self.current_step)) + + def validate(self, zone, api): + """ + Checks if the current keys in the zone matches what we have + + :param string zone: The zone to check in + :param pdnsapi.api.PDNSApi api: The API endpoint to use + :return: True if the keys in the zone indeed match, False otherwise + :rtype: bool + """ + validate_api(api) + to_match = self.old_keyids.copy() + to_match.append(self.new_keyid) + return all([k.id in to_match for k in api.get_cryptokeys(zone) + if k.algo == self.algo and k.keytype == self.keytype]) + + def __str__(self): + return json_tricks.dumps({ + 'rolltype': 'prepublish', + 'current_step': self.current_step, + 'complete': self.complete, + 'current_step_datetime': self.current_step_datetime.timestamp(), + 'step_datetimes': list(map(lambda d: d.timestamp(), self.step_datetimes)), + 'keytype': self.keytype, + 'algo': self.algo, + 'old_keyids': self.old_keyids, + 'new_keyid': self.new_keyid, + }) + def __json_encode__(self): + # should return primitive, serializable types like dict, list, int, string, float... + return { + 'rolltype': 'prepublish', + 'current_step': self.current_step, + 'complete': self.complete, + 'current_step_datetime': self.current_step_datetime.timestamp(), + 'step_datetimes': list(map(lambda d: d.timestamp(), self.step_datetimes)), + 'keytype': self.keytype, + 'algo': self.algo, + 'old_keyids': self.old_keyids, + 'new_keyid': self.new_keyid, + } + + def __json_decode__(self, **kwargs): + super().__init__(rolltype='prepublish') + self.current_step = kwargs.get('current_step', 0) + self.complete = kwargs.get('complete', False) + self.step_datetimes = list(map(lambda x: datetime.fromtimestamp(x), kwargs.get('step_datetimes', []))) + self.current_step_datetime = datetime.fromtimestamp(kwargs.get('current_step_datetime', datetime.now().timestamp())) + self.keytype = kwargs.get('keytype') + self.algo = kwargs.get('algo') + self.old_keyids = kwargs.get('old_keyids') + self.new_keyid = kwargs.get('new_keyid') + + def __repr__(self): + return 'PrePublishRoll({})'.format( + ', '.join(['{}={}'.format(k, v) for k, v in [ + ('current_step', self.current_step), + ('complete', self.complete), + ('current_step_datetime', self.current_step_datetime.timestamp()), + ('step_datetimes', list(map(lambda d: d.timestamp(), self.step_datetimes))), + ('keytype', self.keytype), + ('algo', self.algo), + ('old_keyids', self.old_keyids), + ('new_keyid', self.new_keyid), + ]])) + + @property + def started(self): + return self.current_step > 0 + + @property + def current_step_name(self): + return _step_to_name.get(self.current_step) diff --git a/pdns/keyroller/pdnskeyroller/util.py b/pdns/keyroller/pdnskeyroller/util.py new file mode 100644 index 0000000000..50bcd6667b --- /dev/null +++ b/pdns/keyroller/pdnskeyroller/util.py @@ -0,0 +1,98 @@ +import pdnsapi.api +import logging + +logger = logging.getLogger() + +""" +Helper functions for the keyrollers and the daemon +""" + +DNSKEY_ALGO_TO_MNEMONIC = { + 1: "RSAMD5", + 2: "DH", + 3: "DSA", + 5: "RSASHA1", + 6: "DSA-NSEC3-SHA1", + 7: "RSASHA1-NSEC3-SHA1", + 8: "RSASHA256", + 10: "RSASHA512", + 12: "ECC-GOST", + 13: "ECDSAP256", + 14: "ECDSAP384", + 15: "ED25519", + 16: "ED448", + } + +DNSKEY_MNEMONIC_TO_ALGO = {v: k for k, v in DNSKEY_ALGO_TO_MNEMONIC.items()} + +def parse_algo(algo): + res = 0 + try: + res = int(algo) + except: + res = DNSKEY_MNEMONIC_TO_ALGO.get(algo.upper()) + + if DNSKEY_ALGO_TO_MNEMONIC.get(res) is None: + raise Exception('Unknown key algorithm {}'.format(algo)) + + return res + + +def validate_api(api): + if not isinstance(api, pdnsapi.api.PDNSApi): + raise Exception('api is not a PDNSApi') + +def validate_keytype(keytype): + keytype = keytype.lower() + if keytype not in ('ksk', 'zsk', 'csk'): + raise Exception('{} is not a valid key type'.format(keytype)) + + +def get_keystyle(zone, api): + """ + Determines the current style of DNSSEC keying for ``zone``. + The style will be one of: + + * single (one or more CSKs) + * split (KSK(s) and ZSK(s) exist) + * mixed (There are CSK(s), KSK(s) and ZSK(s)) + + :param string zone: The zone to check + :param pdnsapi.api.PDNSApi api: The API endpoint to use + :return: The description of the current key-style + :rtype: string + """ + validate_api(api) + cryptokeys = api.get_cryptokeys(zone) + + if not len(cryptokeys): + raise Exception('No cryptokeys for zone {}'.format(zone)) + + got_ksk = any([cryptokey.keytype.lower() == 'ksk' for cryptokey in cryptokeys]) + got_zsk = any([cryptokey.keytype.lower() == 'zsk' for cryptokey in cryptokeys]) + got_csk = any([cryptokey.keytype.lower() == 'csk' for cryptokey in cryptokeys]) + + if got_csk and not any([got_ksk, got_zsk]): + return 'single' + if all([got_ksk, got_zsk]) and not got_csk: + return 'split' + if all([got_ksk, got_zsk, got_csk]): + return 'mixed' + + +def get_keys_of_type(zone, api, keytype): + """ + Returns all the keys of type ``keytype`` for ``zone`` + + :param string zone: The zone to get the keys from + :param pdnsapi.api.PDNSApi api: The API endpoint to use + :param string keytype: 'ksk', 'zsk' or 'csk' + :return: All the keys of the requested type + :rtype: list(pdnsapi.zone.CryptoKey) + """ + validate_api(api) + keytype = keytype.lower() + validate_keytype(keytype) + + cryptokeys = api.get_cryptokeys(zone) + return [k for k in cryptokeys if k.keytype == keytype] diff --git a/pdns/keyroller/requirements-test.txt b/pdns/keyroller/requirements-test.txt new file mode 100644 index 0000000000..145e520f8e --- /dev/null +++ b/pdns/keyroller/requirements-test.txt @@ -0,0 +1,2 @@ +nose +requests-mock diff --git a/pdns/keyroller/requirements.txt b/pdns/keyroller/requirements.txt new file mode 100644 index 0000000000..f09318e1c1 --- /dev/null +++ b/pdns/keyroller/requirements.txt @@ -0,0 +1,5 @@ +PyYAML +pytimeparse +requests +json_tricks +nose diff --git a/pdns/keyroller/setup.py b/pdns/keyroller/setup.py new file mode 100644 index 0000000000..d89c87953c --- /dev/null +++ b/pdns/keyroller/setup.py @@ -0,0 +1,56 @@ +import os +from setuptools import setup, find_packages + +install_reqs = list() + +# Use pipenv for dependencies, setuptools otherwise. +# This makes the installation for the packages easier (no pipenv needed) +try: + from pipenv.project import Project + from pipenv.utils import convert_deps_to_pip + pfile = Project(chdir=False).parsed_pipfile + install_reqs = convert_deps_to_pip(pfile['packages'], r=False) +except ImportError: + from pkg_resources import parse_requirements + import pathlib + with pathlib.Path('requirements.txt').open() as requirements_txt: + install_reqs = [ + str(r) + for r + in parse_requirements(requirements_txt)] + + +def exists(fname): + return os.path.exists(os.path.join(os.path.dirname(__file__), fname)) + + +# Utility function to read the README file. +# Used for the long_description. It's nice, because now 1) we have a top level +# README file and 2) it's easier to type in the README file than to put a raw +# string in below ... +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname), + 'r', encoding='utf-8').read() + + +version = os.environ.get('BUILDER_VERSION', '0.0.0') + +if exists('version.txt'): + version = read('version.txt').strip() + +setup( + name = "pdns-keyroller", + version = version, + author = "PowerDNS.COM BV", + author_email = "powerdns.support@powerdns.com", + description = ("PowerDNS keyroller"), + license = "GNU GPLv2", + keywords = "PowerDNS keyroller", + url = "https://www.powerdns.com/", + packages = find_packages(), + install_requires=install_reqs, + include_package_data = True, + scripts=['pdns-keyroller.py', 'pdns-keyroller-ctl.py'], + long_description=read('README.md'), + classifiers=[], +)