From 4bd60c6a4119b0191bcf7065aedd60c705c95cf2 Mon Sep 17 00:00:00 2001 From: Tom Cammann Date: Mon, 2 Mar 2015 14:11:09 +0000 Subject: [PATCH] inital commit for github --- .gitignore | 17 +++ .test.conf | 4 + LICENSE.txt | 202 +++++++++++++++++++++++++++++++++++ README.rst | 108 +++++++++++++++++++ cathead/__init__.py | 0 cathead/cadriver.py | 23 ++++ cathead/cathead.py | 126 ++++++++++++++++++++++ cathead/certwatch.py | 100 +++++++++++++++++ cathead/drivers/__init__.py | 0 cathead/drivers/eca.py | 58 ++++++++++ cathead/drivers/selfsign.py | 30 ++++++ cathead/scheduler.py | 82 ++++++++++++++ cathead/x509.py | 61 +++++++++++ example_config.py | 58 ++++++++++ requirements.txt | 3 + setup.cfg | 36 +++++++ setup.py | 30 ++++++ test-requirements.txt | 6 ++ tests/__init__.py | 0 tests/test_cathead.py | 9 ++ tests/test_certwatcher.py | 50 +++++++++ tests/test_ecadriver.py | 11 ++ tests/test_selfsigndriver.py | 23 ++++ tests/test_x509.py | 48 +++++++++ tests/util.py | 0 tox.ini | 28 +++++ 26 files changed, 1113 insertions(+) create mode 100644 .gitignore create mode 100644 .test.conf create mode 100644 LICENSE.txt create mode 100644 README.rst create mode 100644 cathead/__init__.py create mode 100644 cathead/cadriver.py create mode 100644 cathead/cathead.py create mode 100644 cathead/certwatch.py create mode 100644 cathead/drivers/__init__.py create mode 100644 cathead/drivers/eca.py create mode 100644 cathead/drivers/selfsign.py create mode 100644 cathead/scheduler.py create mode 100644 cathead/x509.py create mode 100644 example_config.py create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_cathead.py create mode 100644 tests/test_certwatcher.py create mode 100644 tests/test_ecadriver.py create mode 100644 tests/test_selfsigndriver.py create mode 100644 tests/test_x509.py create mode 100644 tests/util.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0998c16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +AUTHORS +ChangeLog +*~ +*.swp +*.pyc +*.log +.tox +.coverage +cathead.egg-info/ +build/ +doc/build/ +doc/source/api/ +dist/ +.testrepository/ +.project +.pydevproject +.venv diff --git a/.test.conf b/.test.conf new file mode 100644 index 0000000..1641f86 --- /dev/null +++ b/.test.conf @@ -0,0 +1,4 @@ +[DEFAULT] +test_command=OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 OS_TEST_TIMEOUT=60 ${PYTHON:-python} -m subunit.run discover -t ./ . $LISTOPT $IDOPTION +test_id_option=--load-list $IDFILE +test_list_option=--list diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8f71f43 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..10c68ad --- /dev/null +++ b/README.rst @@ -0,0 +1,108 @@ +======= +Cathead +======= + +Cathead is a utility to monitor SSL certificates for expiry and retrieve new +certificates when expiry is near. + +This project is borne out of frustration with using cron and certmonger and +various other bits of bash to monitor and renew certificates. + +.. WARNING:: +This project is under active development so expect changes to APIs and +configurations. + +Running +""""""" +1. Clone repo :: + + git clone https://github.com/takac/cathead + +2. Install requirements and cathead into a virtual env. :: + + virtualenv .venv + pip install -r requirements.txt + pip install . + +3. Construct a config file specifying which certs to track and how to refresh + them. See the `example_config.py `_ file. + + The *certs* section contains the details of the certificates to monitor and + which driver should be used. The common name and other cert details should + also be specified here. + + driver + Name of the driver to use. Use the name value from the driver. + + key + Path to the key. This key will be regenerated at every refresh. + + cert + Path to the cert. + + common_name + Common name of the certificate. + + on_refresh_success + Callback action to execute on successful refresh of cert. Use the name + value of an action defined in the actions section. + + on_refresh_failure + Callback action to execute on failure to refresh the cert. Use the name + value of an action defined in the actions section. + + The *drivers* section specifies how new certs are obtained, the only 2 + drivers currently supported are Anchor (currently named ECA, due to be + changed), and self signed certs. + + name + Name of the driver used to associate with certificates. + + driver + Python class of the driver. e.g. ``cathead.drivers.selfsign.SelfSignDriver``. + + All other keys in the driver are passed into the driver class at + construction. e.g. ``SelfSignDriver(**drivers['selfsign'])`` + + The *actions* section contains actions to perform on different events. So + far the possible events are: + + - Successful refresh of a certificate + - Failure to refresh a certificate + + Actions can either by system calls or python calls. + + name + Name of the action, used to associate with a certificate event callback. + + type + The type of action, either ``'system'`` for a system call (e.g. ``reboot``) + or ``'python'`` which allows executing a python callable. + + module + Use this when using type of ``python`` to select which module the + callable is in. + + command + Specify the command or callable to be run. + + args + Specify the arguments to the command or callable. This should be a list. + +4. Run cathead with your requirements file. :: + + cathead example_config.py + +.. NOTE:: +For the self signing driver you will need to generate a key to sign the certs +with. This can be done using :: + + openssl genrsa 2048 > ca.key + +Naming +"""""" + +The name comes from the `anchor support +`_ as this project can be used in +conjunction with `Anchor `_ an an +ephemeral PKI service. diff --git a/cathead/__init__.py b/cathead/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cathead/cadriver.py b/cathead/cadriver.py new file mode 100644 index 0000000..578ff04 --- /dev/null +++ b/cathead/cadriver.py @@ -0,0 +1,23 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc + + +class CaDriver(object): + + @abc.abstractmethod + def sign(self, csr): + pass diff --git a/cathead/cathead.py b/cathead/cathead.py new file mode 100644 index 0000000..c521278 --- /dev/null +++ b/cathead/cathead.py @@ -0,0 +1,126 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import imp +import importlib +import logging +import sys + +from oslo_concurrency import processutils + +import scheduler + + +class Cathead(object): + + def __init__(self, config): + self.config = config + + def start(self): + self.setup_logging() + self.parse_config() + self.wait() + + def setup_logging(self): + ch = logging.StreamHandler() + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + logger.addHandler(ch) + + def extract_drivers(self): + drivers = {} + for driver in self.config['drivers']: + split = driver.pop('driver').split('.') + module = importlib.import_module('.'.join(split[:-1])) + name = driver.pop('name') + drivers[name] = getattr(module, split[-1])(**driver) + return drivers + + def parse_config(self): + + drivers = self.extract_drivers() + actions = self.extract_actions() + + self._scheduler = scheduler.Scheduler() + + for cert in self.config['certs']: + callback = self.create_cert_callback(cert['on_refresh_success'], + actions) + + scheduler_conf = { + 'driver': drivers[cert['driver']], + 'key_path': cert['key'], + 'cert_path': cert['cert'], + 'refresh_window': cert['refresh_window'], + 'common_name': cert['common_name'], + 'on_refresh_success': callback, + 'jitter': 0, + } + + self._scheduler.add_cert_watch(**scheduler_conf) + + return self._scheduler + + def extract_actions(self): + actions = {} + for action in self.config['actions']: + if action['type'] == 'python': + + def create_closure(): + closure = action + + def callback(): + module = importlib.import_module(closure['module']) + getattr(module, closure['command'])(*closure['args']) + return callback + actions[action['name']] = create_closure() + elif action['type'] == 'system': + # closure = action.copy() + + def create_closure(): + closure = action + + def callback(): + command = [closure['command']] + command.extend(closure['args'] + if closure['args'] else []) + processutils.execute(*command) + return callback + actions[action['name']] = create_closure() + return actions + + def create_cert_callback(self, action, actions): + def callback(): + on_success = action + if isinstance(on_success, str): + actions[on_success]() + else: + for func in on_success: + actions[func]() + return callback + + def wait(self): + self._scheduler.wait() + +def main(): + if len(sys.argv) == 2: + # sys.path.append(os.path.abspath(sys.argv[1])) + # conf_module = importlib.import_module(sys.argv[1].split(".py")[0]) + # conf = __import__(sys.argv[1].split(".py")[0]) + (file, path, desc) = imp.find_module(sys.argv[1].split(".py")[0], ["."]) + conf_module = imp.load_module('', file, path, desc) + Cathead(conf_module.CONF).start() + else: + print("Usage: cathead path/to/configy.py") diff --git a/cathead/certwatch.py b/cathead/certwatch.py new file mode 100644 index 0000000..bd44b29 --- /dev/null +++ b/cathead/certwatch.py @@ -0,0 +1,100 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import logging +import os.path + +import x509 + +LOG = logging.getLogger(__name__) + + +class CertWatcher(object): + + def __init__(self, key_path, cert_path, common_name, ca_driver, + on_refresh_success=None, on_refresh_failure=None, + refresh_window=None): + if not os.path.isfile(key_path): + raise Exception("key needs to exist") + self.key_path = key_path + self.cert_path = cert_path + self.ca_driver = ca_driver + self.on_refresh_success = on_refresh_success + self.on_refresh_failure = on_refresh_failure + self.common_name = common_name + self.refresh_window = refresh_window + + @property + def key(self): + return open(self.key_path).read() + + @property + def cert(self): + return open(self.cert_path).read() + + def get_expire_date(self): + return x509.get_expire_date(self.cert) + + def seconds_until_expiry(self): + diff = self.get_expire_date() - datetime.datetime.now() + return diff.total_seconds() + + def _replace_cert(self, cert_contents): + LOG.info("Replacing certificate at %s" % self.cert_path) + cert = open(self.cert_path, "w") + cert.write(cert_contents) + cert.close() + + def _will_be_expired(self, date): + return date > self.get_expire_date() + + def _expires_in_window(self): + now = datetime.datetime.now() + if not self.refresh_window: + LOG.debug("No refresh window set, assuming expired") + return True + window = now + datetime.timedelta(0, self.refresh_window) + if self._will_be_expired(window): + LOG.info("%s is expired inside window of %s" + % (self.cert_path, self.refresh_window)) + return True + LOG.info("Certificate valid within window of %s seconds" + % self.refresh_window) + return False + + def _cert_exists(self): + if not os.path.isfile(self.cert_path): + LOG.info("No cert found at %s" % self.cert_path) + return False + return True + + def is_invalid_cert(self): + return not self._cert_exists() or self._expires_in_window() + + def check_and_update(self): + LOG.info('Checking validity of certificate %s' % self.cert_path) + if self.is_invalid_cert(): + csr = x509.generate_csr(self.key, self.common_name) + cert = None + try: + cert = self.ca_driver.sign(csr) + except Exception as e: + LOG.exception("Could not retrieve cert\n%s", e) + if cert: + self._replace_cert(cert) + self.on_refresh_success() + else: + self.on_refresh_failure() diff --git a/cathead/drivers/__init__.py b/cathead/drivers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cathead/drivers/eca.py b/cathead/drivers/eca.py new file mode 100644 index 0000000..710f661 --- /dev/null +++ b/cathead/drivers/eca.py @@ -0,0 +1,58 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import logging + +import requests + +from cathead import cadriver +from cathead import x509 + +LOG = logging.getLogger(__name__) + + +class EcaDriver(cadriver.CaDriver): + + def __init__(self, host, port, + user, secret, scheme='http'): + self.host = host + self.port = port + self.user = user + self.secret = secret + self.scheme = scheme + + def sign(self, csr): + url = "{scheme}://{host}:{port}/sign".format(**self.__dict__) + LOG.info("Sending CSR to %s" % url) + params = {"user": self.user, + "secret": self.secret, + "encoding": "pem", + "csr": csr} + r = requests.post(url, data=params) + cert = r.text + LOG.debug("Received from ECA server:\n%s" % cert) + if self._is_valid_cert(cert): + return cert + else: + LOG.info("Received invalid certificate from ECA") + + def _is_valid_cert(self, cert): + try: + expire = x509.get_expire_date(cert) + return expire > datetime.datetime.now() + except Exception as e: + LOG.info("invalid cert, failed check date with:\n%s", e) + return False diff --git a/cathead/drivers/selfsign.py b/cathead/drivers/selfsign.py new file mode 100644 index 0000000..50c60f1 --- /dev/null +++ b/cathead/drivers/selfsign.py @@ -0,0 +1,30 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os.path + +from cathead import cadriver +from cathead import x509 + + +class SelfSignDriver(cadriver.CaDriver): + + def __init__(self, ca_key_file, check_key_file=True): + if check_key_file and not os.path.isfile(ca_key_file): + raise Exception("Key %s not found" % ca_key_file) + self.ca_key_file = ca_key_file + + def sign(self, csr): + return x509.generate_cert(open(self.ca_key_file).read(), csr) diff --git a/cathead/scheduler.py b/cathead/scheduler.py new file mode 100644 index 0000000..fcffd44 --- /dev/null +++ b/cathead/scheduler.py @@ -0,0 +1,82 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from apscheduler.jobstores.base import JobLookupError +from apscheduler.schedulers import background + +import certwatch + +LOG = logging.getLogger(__name__) + + +class Scheduler(object): + + def __init__(self): + self._scheduler = background.BackgroundScheduler() + self._scheduler.configure(daemon=True) + self.job_dict = {} + self._scheduler.start() + + def is_tracked(self, key_path): + return key_path in self.job_dict + + def _remove_job(self, key_path): + try: + self._scheduler.remove_job(key_path) + except JobLookupError: + LOG.info("No scheduled job for %s, creating new job", key_path) + + def _create_success_callback(self, key_path, callback): + def success_callback(): + job_info = self.job_dict[key_path] + self._remove_job(key_path) + watcher = job_info['watcher'] + seconds = watcher.seconds_until_expiry() + new_interval = seconds - seconds / 5 + self._scheduler.add_job(watcher.check_and_update, 'interval', + seconds=new_interval, id=key_path) + if callback: + callback() + return success_callback + + def _create_failure_callback(self, key_path): + def failure_callback(): + job_info = self.job_dict[key_path] + self._remove_job(key_path) + watcher = job_info['watcher'] + self._scheduler.add_job(watcher.check_and_update, + 'interval', seconds=10, id=key_path) + return failure_callback + + def add_cert_watch(self, driver, key_path, cert_path, + common_name, on_refresh_success=None, + jitter=0, refresh_window=None): + if self.is_tracked(key_path): + raise Exception("Already tracking certificate") + + on_success = self._create_success_callback(key_path, + on_refresh_success) + on_failure = on_failure = self._create_failure_callback(key_path) + watcher = certwatch.CertWatcher(key_path, cert_path, common_name, + driver, on_refresh_success=on_success, + on_refresh_failure=on_failure) + + self.job_dict[key_path] = {'watcher': watcher} + watcher.check_and_update() + + def wait(self): + self._scheduler._thread.join() diff --git a/cathead/x509.py b/cathead/x509.py new file mode 100644 index 0000000..2315ca2 --- /dev/null +++ b/cathead/x509.py @@ -0,0 +1,61 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import logging +import tempfile + +from oslo_concurrency import processutils + +LOG = logging.getLogger(__name__) + + +def generate_key(): + return processutils.execute("openssl", "genrsa", "2048")[0] + + +def generate_cert(key, csr): + key_file = _create_temp_file(key) + csr_file = _create_temp_file(csr) + return processutils.execute("openssl", "x509", "-req", "-days", + "365", "-in", csr_file.name, "-signkey", + key_file.name)[0] + + +def get_expire_date(cert): + # open cert with openssl and parse + cert_file = _create_temp_file(cert) + out = processutils.execute("openssl", "x509", "-in", cert_file.name, + "-dates", "-issuer", "-noout", "-subject") + strdate = out[0].split("\n")[1].split("=")[1] + return datetime.datetime.strptime(strdate, "%b %d %H:%M:%S %Y %Z") + + +def generate_csr(key, common_name, + country="", organisation=""): + LOG.debug("Generating CSR") + key_file = _create_temp_file(key) + out = processutils.execute("openssl", "req", "-new", "-key", key_file.name, + "-subj", + "/C=%s/O=%s/CN=%s" % + (country, organisation, common_name)) + return out[0] + + +def _create_temp_file(contents): + temp_file = tempfile.NamedTemporaryFile() + temp_file.file.write(contents) + temp_file.flush() + return temp_file diff --git a/example_config.py b/example_config.py new file mode 100644 index 0000000..4e123bc --- /dev/null +++ b/example_config.py @@ -0,0 +1,58 @@ +# Copyright 2015 Tom Cammann +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +CONF = { + 'failure_refresh_timeout': 10, + 'drivers': [ + { + 'name': 'selfsign', + 'driver': 'cathead.drivers.selfsign.SelfSignDriver', + 'ca_key': 'ca.p.key', + }, + { + 'name': 'eca', + 'driver': 'cathead.drivers.eca.EcaDriver', + 'host': '127.0.0.1', + 'port': 5000, + 'user': 'woot', + 'secret': 'woot', + } + ], + 'certs': [ + { + 'driver': 'eca', + 'key': 'ca.p.key', + 'cert': 'newcrt.crt', + 'refresh_window': None, + 'common_name': '127.0.0.1', + 'on_refresh_success': 'hello_system', + } + ], + 'actions': [ + { + 'name': 'hello_python', + 'type': 'python', + 'module': 'os', + 'command': 'write', + 'args': [2, 'hello world'], + }, + { + 'name': 'hello_system', + 'type': 'system', + 'command': 'echo', + 'args': ['hello echo world'], + }, + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b242a77 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +apscheduler +oslo.concurrency +requests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..20a773d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,36 @@ +[metadata] +name = cathead +author = Tom Cammann +author-email = tom.cammann@hp.com +summary = Certificate monitoring service +description-file = + README.rst +home-page = None +classifier = + Development Status :: 4 - Beta + Environment :: OpenStack + Intended Audience :: Developers + Intended Audience :: Information Technology + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.6 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.3 + +[files] +packages = + cathead + +[global] +setup-hooks = + pbr.hooks.setup_hook + +[wheel] +universal = 1 + +[entry_points] +console_scripts = + cathead = cathead.cathead:main diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7363757 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT +import setuptools + +# In python < 2.7.4, a lazy loading of package `pbr` will break +# setuptools if some other modules registered functions in `atexit`. +# solution from: http://bugs.python.org/issue15881#msg170215 +try: + import multiprocessing # noqa +except ImportError: + pass + +setuptools.setup( + setup_requires=['pbr'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..5d34800 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,6 @@ +hacking +freezegun +python-subunit>=0.0.18 +testrepository>=0.0.18 +testtools>=0.9.34 +mock>=1.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cathead.py b/tests/test_cathead.py new file mode 100644 index 0000000..3d81d96 --- /dev/null +++ b/tests/test_cathead.py @@ -0,0 +1,9 @@ +import unittest + +from cathead import cathead + + +class CatheadTestCase(unittest.TestCase): + + def test_cathead(self): + cathead.Cathead(None) diff --git a/tests/test_certwatcher.py b/tests/test_certwatcher.py new file mode 100644 index 0000000..71abf2c --- /dev/null +++ b/tests/test_certwatcher.py @@ -0,0 +1,50 @@ +import datetime +import tempfile +import unittest + +import freezegun +import mock + +from cathead import certwatch +import cathead.x509 + + +class CertWatcherTestCase(unittest.TestCase): + + @mock.patch.object(cathead.x509, 'get_expire_date') + def test_expires_in_window(self, mock_get_expire_date): + key = tempfile.NamedTemporaryFile() + cert = tempfile.NamedTemporaryFile() + watcher = certwatch.CertWatcher(key.name, cert.name, + "common name", None, + refresh_window=40) + mock_get_expire_date.return_value = datetime.datetime(2014, 12, 19, + 15, 18, 53) + with freezegun.freeze_time("2014-12-19 15:18:10"): + self.assertFalse(watcher._expires_in_window()) + + with freezegun.freeze_time("2014-12-19 15:18:14"): + self.assertTrue(watcher._expires_in_window()) + + @mock.patch.object(cathead.x509, 'get_expire_date') + @mock.patch.object(cathead.x509, 'generate_csr') + def test_check_and_update(self, mock_generate_csr, mock_get_expire_date): + mock_get_expire_date.return_value = datetime.datetime(2014, 12, 19, + 15, 18, 53) + mock_generate_csr.return_value = "hello csr" + + callback = mock.Mock() + key = tempfile.NamedTemporaryFile() + cert = tempfile.NamedTemporaryFile() + watcher = certwatch.CertWatcher(key.name, cert.name, 'common_name', + None, on_refresh_success=callback, + refresh_window=40) + + watcher.ca_driver = mock.Mock() + watcher.ca_driver.sign.return_value = "hello cert" + + watcher.check_and_update() + + self.assertEqual("hello cert", cert.file.read()) + watcher.ca_driver.called_once_with("hello csr") + callback.assert_called_once_with() diff --git a/tests/test_ecadriver.py b/tests/test_ecadriver.py new file mode 100644 index 0000000..5c1d747 --- /dev/null +++ b/tests/test_ecadriver.py @@ -0,0 +1,11 @@ +import unittest + +from cathead import cadriver +from cathead.drivers import eca + + +class EcaDriverTestCase(unittest.TestCase): + + def test_sign(self): + driver = eca.EcaDriver("host", "port", "user", "password") + self.assertTrue(isinstance(driver, cadriver.CaDriver)) diff --git a/tests/test_selfsigndriver.py b/tests/test_selfsigndriver.py new file mode 100644 index 0000000..d82d539 --- /dev/null +++ b/tests/test_selfsigndriver.py @@ -0,0 +1,23 @@ +import re +import unittest +import tempfile + +from cathead.drivers import selfsign +from cathead import x509 + + +class SelfSignDriverTestCase(unittest.TestCase): + + def test_sign(self): + key = x509.generate_key() + keyfile = tempfile.NamedTemporaryFile() + keyfile.write(key) + keyfile.flush() + driver = selfsign.SelfSignDriver(keyfile.name) + csr = x509.generate_csr(key, '192') + cert = driver.sign(csr) + match = re.search("^-----BEGIN CERTIFICATE-----" + ".*" + "-----END CERTIFICATE-----", + cert, re.MULTILINE | re.DOTALL) + self.assertTrue(match) diff --git a/tests/test_x509.py b/tests/test_x509.py new file mode 100644 index 0000000..b8b8879 --- /dev/null +++ b/tests/test_x509.py @@ -0,0 +1,48 @@ +import datetime +import re +import unittest + +import mock +import oslo_concurrency.processutils + +from cathead import x509 + + +class X509TestCase(unittest.TestCase): + + @mock.patch.object(oslo_concurrency.processutils, 'execute') + def test_get_expire_date(self, mock_execute): + mock_execute.return_value = (("notBefore=Dec 19 03:18:43 2014 GMT\n" + "notAfter=Dec 19 15:18:43 2014 GMT\n" + "issuer= /C=UK/O=hp/CN=CertAuthority\n" + "subject= /CN=192.0.2.26\n"),) + expected_date = datetime.datetime(2014, 12, 19, 15, 18, 43) + + self.assertEqual(x509.get_expire_date('-'), expected_date) + + def test_generate_csr(self): + key = x509.generate_key() + csr = x509.generate_csr(key, "secert cert auth") + match = re.search("^-----BEGIN CERTIFICATE REQUEST-----" + ".*" + "-----END CERTIFICATE REQUEST-----", + csr, re.MULTILINE | re.DOTALL) + self.assertTrue(match) + + def test_generate_key(self): + key = x509.generate_key() + match = re.search("^-----BEGIN RSA PRIVATE KEY-----" + ".*" + "-----END RSA PRIVATE KEY-----", + key, re.MULTILINE | re.DOTALL) + self.assertTrue(match) + + def test_generate_cert(self): + key = x509.generate_key() + csr = x509.generate_csr(key, "unknown") + cert = x509.generate_cert(key, csr) + match = re.search("^-----BEGIN CERTIFICATE-----" + ".*" + "-----END CERTIFICATE-----", + cert, re.MULTILINE | re.DOTALL) + self.assertTrue(match) diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..e69de29 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f02792f --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +[tox] +distribute = False +envlist = py33,py34,py26,py27,pep8 + +[testenv] +setenv = VIRTUAL_ENV={envdir} +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = python setup.py testr --slowest --testr-args='{posargs}' + +[testenv:pep8] +commands = flake8 + +[testenv:cover] +setenv = VIRTUAL_ENV={envdir} +commands = + python setup.py testr --coverage + +[testenv:venv] +commands = {posargs} + +[testenv:docs] +commands = python setup.py build_sphinx + +[flake8] +show-source = True +exclude = .tox,dist,doc,*.egg,build +builtins = _