--- /dev/null
+[[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"
--- /dev/null
+{
+ "_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": {}
+}
--- /dev/null
+# 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 <ZONE> <TTL>
+
+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` `<target>` is supported
+
+ $ git submodule update --init --recursive
+ $ bash builder/build.sh <target>
+
+## 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
--- /dev/null
+#!/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
+ )
+ )
--- /dev/null
+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
--- /dev/null
+#!/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))
--- /dev/null
+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 <pdnsapi.cryptokey.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))
--- /dev/null
+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)))
+
--- /dev/null
+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
+ })
--- /dev/null
+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
--- /dev/null
+__author__ = 'PowerDNS.COM BV'
+
+PDNSKEYROLLER_CONFIG_metadata_kind = 'X-PDNSKEYROLLER-CONFIG'
+PDNSKEYROLLER_STATE_metadata_kind = 'X-PDNSKEYROLLER-STATE'
--- /dev/null
+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']
--- /dev/null
+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")
--- /dev/null
+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,
+ }))
--- /dev/null
+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)
--- /dev/null
+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()
--- /dev/null
+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
+ )
--- /dev/null
+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)
--- /dev/null
+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]
--- /dev/null
+nose
+requests-mock
--- /dev/null
+PyYAML
+pytimeparse
+requests
+json_tricks
+nose
--- /dev/null
+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=[],
+)