]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
keyroller first import
authorCharles-Henri Bruyand <charles-henri.bruyand@open-xchange.com>
Tue, 20 Sep 2022 12:56:34 +0000 (14:56 +0200)
committerCharles-Henri Bruyand <charles-henri.bruyand@open-xchange.com>
Tue, 20 Sep 2022 12:56:34 +0000 (14:56 +0200)
23 files changed:
pdns/keyroller/Pipfile [new file with mode: 0644]
pdns/keyroller/Pipfile.lock [new file with mode: 0644]
pdns/keyroller/README.md [new file with mode: 0644]
pdns/keyroller/pdns-keyroller-ctl.py [new file with mode: 0755]
pdns/keyroller/pdns-keyroller.conf.example [new file with mode: 0644]
pdns/keyroller/pdns-keyroller.py [new file with mode: 0755]
pdns/keyroller/pdnsapi/__init__.py [new file with mode: 0644]
pdns/keyroller/pdnsapi/api.py [new file with mode: 0644]
pdns/keyroller/pdnsapi/cryptokey.py [new file with mode: 0644]
pdns/keyroller/pdnsapi/metadata.py [new file with mode: 0644]
pdns/keyroller/pdnsapi/zone.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/__init__.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/config.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/daemon.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/domainconfig.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/domainstate.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/keyroll.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/keyrollerdomain.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/prepublishkeyroll.py [new file with mode: 0644]
pdns/keyroller/pdnskeyroller/util.py [new file with mode: 0644]
pdns/keyroller/requirements-test.txt [new file with mode: 0644]
pdns/keyroller/requirements.txt [new file with mode: 0644]
pdns/keyroller/setup.py [new file with mode: 0644]

diff --git a/pdns/keyroller/Pipfile b/pdns/keyroller/Pipfile
new file mode 100644 (file)
index 0000000..75bc29a
--- /dev/null
@@ -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 (file)
index 0000000..ee903c7
--- /dev/null
@@ -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 (file)
index 0000000..2c08edb
--- /dev/null
@@ -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 <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
diff --git a/pdns/keyroller/pdns-keyroller-ctl.py b/pdns/keyroller/pdns-keyroller-ctl.py
new file mode 100755 (executable)
index 0000000..1e43cc8
--- /dev/null
@@ -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 (file)
index 0000000..117925c
--- /dev/null
@@ -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 (executable)
index 0000000..8554433
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/pdns/keyroller/pdnsapi/api.py b/pdns/keyroller/pdnsapi/api.py
new file mode 100644 (file)
index 0000000..2bc4acd
--- /dev/null
@@ -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 <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))
diff --git a/pdns/keyroller/pdnsapi/cryptokey.py b/pdns/keyroller/pdnsapi/cryptokey.py
new file mode 100644 (file)
index 0000000..5634d81
--- /dev/null
@@ -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 (file)
index 0000000..28b32ed
--- /dev/null
@@ -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 (file)
index 0000000..d7eef2e
--- /dev/null
@@ -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 (file)
index 0000000..115fdb9
--- /dev/null
@@ -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 (file)
index 0000000..9cbb281
--- /dev/null
@@ -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 (file)
index 0000000..b63b662
--- /dev/null
@@ -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 (file)
index 0000000..c809fef
--- /dev/null
@@ -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 (file)
index 0000000..c5034ff
--- /dev/null
@@ -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 (file)
index 0000000..59b205f
--- /dev/null
@@ -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 (file)
index 0000000..f09d38d
--- /dev/null
@@ -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 (file)
index 0000000..d9ebbca
--- /dev/null
@@ -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 (file)
index 0000000..50bcd66
--- /dev/null
@@ -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 (file)
index 0000000..145e520
--- /dev/null
@@ -0,0 +1,2 @@
+nose
+requests-mock
diff --git a/pdns/keyroller/requirements.txt b/pdns/keyroller/requirements.txt
new file mode 100644 (file)
index 0000000..f09318e
--- /dev/null
@@ -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 (file)
index 0000000..d89c879
--- /dev/null
@@ -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=[],
+)