diff --git a/.gitignore b/.gitignore index af6ad96183e7aea0d454b4004df65f22ba4285df..36b9f0571cf6a4f336326e320765d28f07616145 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .*.sw[op] +wile_coyote-*.tar.gz # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/Description.txt b/Description.txt index 8da220a81fb455c351b06fc6ad96721c3e63bea6..555c396514df97c4372a580cc3e678129b674555 100644 --- a/Description.txt +++ b/Description.txt @@ -1,3 +1,3 @@ == A tool to manage certificate lifecycle on Vault, Redis, Consul -this tool is used in conjunction with certbot to upload certificates to Vault, Redis and Consul +this tool is used in conjunction with certbot to upload certificates to the key stores diff --git a/README.md b/README.md index 6c80339537f4633fa09ff3ed2ca474a5e48edc3e..74db8ad2257e88fc375fa3b37efa1396b71745f3 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,21 @@ The version number is defined in `./version/__init__.py`\ In this README we assume that we are using version `0.5.0` -### Use pip +### Build pip ```sh python3 setup.py bdist_wheel pip install dist wile_coyote-0.5.0-py3-none-any.whl ``` -### Use RPM +### UBuildse RPM ```sh python3 setup.py bdist_rpm sudo rpm -Uvh dist/wile_coyote-0.5.0-1.noarch.rpm ``` -### Use DEB +### Build DEB `DEB_BUILD_OPTIONS=nocheck` is required because `dh_auto_test` resets `$HOME` variable and the unit test fails because it won't find `acme.ini` diff --git a/acme_kit/nomad_uploader.py b/acme_kit/nomad_uploader.py index df3b37695b4e4a73268ca7d16a46281c9b6a9bf1..98f62da55a8fb3fce4fe4d6a04703a995bfb1c01 100644 --- a/acme_kit/nomad_uploader.py +++ b/acme_kit/nomad_uploader.py @@ -3,11 +3,11 @@ """Nomad Uploader Options: - provider = ACME Provider (sectigo_ev, sectigo_ov, letsencrypt) - domain = Certificate name - project = Nomad Project - environment = staging environment - wildcard = Wildcard (Bool) + provider = ACME Provider (sectigo_ev, sectigo_ov, letsencrypt) + domain = Certificate name + project = Nomad Project + nomad_env = staging nomad_env + wildcard = Wildcard (Bool) """ import os import wile_coyote.tools @@ -15,7 +15,7 @@ import wile_coyote.common_kit from wile_coyote.common_kit import log -def uploader(provider, project, domain, environment, wildcard=None): +def uploader(provider, project, domain, nomad_env, wildcard=None): """ Upload wildcard certificate to Consul and Vault """ if type(provider) is str: @@ -23,10 +23,10 @@ def uploader(provider, project, domain, environment, wildcard=None): else: project_list = project - if type(environment) is str: - environment_list = [environment] + if type(nomad_env) is str: + nomad_env_list = [nomad_env] else: - environment_list = environment + nomad_env_list = nomad_env if wildcard: wcard_flag = 'wildcard_' @@ -52,7 +52,7 @@ def uploader(provider, project, domain, environment, wildcard=None): wile_coyote.common_kit.private_key.check(keypath, log_file) - for env in environment_list: + for env in nomad_env_list: for proj in project_list: # upload certificates to Consul for suffix in suffixes_list: diff --git a/bin/anvil.py b/bin/anvil.py new file mode 100755 index 0000000000000000000000000000000000000000..e394e9931fadfcd29ce34f5d2350175281b9ba60 --- /dev/null +++ b/bin/anvil.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Anvil + wile_coyote test tool, erases, uploads and check certificates + validity onto test instances of Vault, Redis and Consul + +Usage: + anvil.py [--prune] + anvil.py (-h | --help) + +Options: + -h --help Show this screen. + -p --prune Client +""" +import os +import re +from glob import glob +import subprocess as sp +from docopt import docopt +import redis +from consul import Consul +import hvac +import wile_coyote.tools +import wile_coyote.common_kit +from wile_coyote.common_kit import log + + +def certificates_delete(provider: str, certificates: list): + """ delete list of certificates from a given provider """ + for cert in certificates: + cbot_cmd = '/usr/local/bin/certbot delete -non-interactive' \ + + f' -c /etc/${provider}/cli.ini --cert-name ${cert}' + cbot_child = sp.Popen(cbot_cmd.split(), stdout=sp.PIPE, stderr=sp.PIPE) + cbot_out, cbot_err = cbot_child.communicate() + if cbot_child.returncode != 0: + err = cbot_err.decode("utf-8") + log.handler(f"error executing certbot: {err}", LOGFILE, True) + os.sys.exit(1) + else: + decoded_msg = cbot_out.decode("utf-8") + msg = decoded_msg[:decoded_msg.rfind('\n')] + log.handler(msg, LOGFILE) + + +def redis_keys(server, token): + """ download keys from Redis """ + log.handler('fetching keys from Redis...', LOGFILE) + r_client = redis.StrictRedis( + host=server, password=token, port=6379, db=0) + + try: + r_keys = [n.decode("utf-8") for n in r_client.keys('*')] + except Exception as err: # pylint: disable=w0703 + r_keys = err + + return sorted(r_keys) + + +def redis_prune(server, token): + """ prune keys from Redis """ + log.handler('purging keys from Redis...', LOGFILE) + r_client = redis.StrictRedis(host=server, password=token, port=6379, db=0) + r_keys = [n.decode("utf-8") for n in r_client.keys('*')] + for key in r_keys: + r_client.delete(key) + + +def vault_keys(server, token, mount_points): + """ download key from vault """ + log.handler('fetching keys from Vault...', LOGFILE) + all_vault_keys = [] + for mount in mount_points: + vault_cmd = f'/usr/local/bin/rvault --token {token}' \ + + f' --insecure --address https://{server} list {mount}/' + vault_child = sp.Popen( + vault_cmd.split(), stdout=sp.PIPE, stderr=sp.PIPE) + vault_out, vault_err = vault_child.communicate() + if vault_child.returncode != 0: + err = vault_err.decode("utf-8") + log.handler(f'error listing keys for the mount point {mount}: {err}', LOGFILE) + os.sys.exit(1) + else: + decoded_msg = vault_out.decode("utf-8") + _msg = [x for x in decoded_msg.split('\n') if x != ''] + msg = [f'/{mount}{x}' for x in _msg] + all_vault_keys.extend(msg) + + return sorted(all_vault_keys) + + +def vault_prune(server, token, mount_points, ver): + """ prune key to vault """ + log.handler('purging keys from Vault...') + v_client = hvac.Client(url=f'https://{server}', token=token) + for mount in mount_points: + v_client.sys.disable_secrets_engine(mount) + v_client.sys.enable_secrets_engine( + 'kv', path=mount, options={'version': ver}) + + +def consul_keys(server, token): + """ download keys from consul """ + log.handler('fetching keys from Consul...', LOGFILE) + c_client = Consul(host=server, port='443', token=token, scheme='https') + try: + c_keys = sorted(c_client.kv.get('nomad', recurse=True, keys=True)[1]) + except Exception as err: # pylint: disable=W0703 + return err + + return sorted(c_keys) + + +def consul_prune(server, token): + """ prune keys from consul """ + log.handler('purging keys from Consul...', LOGFILE) + c_client = Consul(host=server, port='443', token=token, scheme='https') + for nomad_env in ['test', 'uat', 'prod']: + c_client.kv.delete(f'nomad/{nomad_env}', recurse=True) + + +# Here we Go. +if __name__ == "__main__": + + ARGS = docopt(__doc__) + PRUNE = ARGS['--prune'] + LOGFILE = '/dev/stdout' + + # Anvil should not be used in production + VAULT_ROOT_TOKEN = wile_coyote.tools.VAULT_ROOT_TOKEN + if not VAULT_ROOT_TOKEN: + log.handler('you can use this tool ONLY in test', LOGFILE, True) + log.handler('exiting ...', LOGFILE, True) + os.sys.exit() + + REDIS_HOST = wile_coyote.tools.REDIS_HOST + REDIS_TOKEN = wile_coyote.tools.REDIS_TOKEN + VAULT_HOST = wile_coyote.tools.VAULT_HOST + CONSUL_SERVERS = wile_coyote.tools.CONSUL_SERVERS + CONSUL_TOKEN = wile_coyote.tools.CONSUL_TOKEN + CONSUL_LEADER, _, __ = wile_coyote.tools.consul_leader.get(LOGFILE) + MOUNT_POINTS_V1 = wile_coyote.tools.MOUNT_POINTS_V1 + MOUNT_POINTS_V2 = wile_coyote.tools.MOUNT_POINTS_V2 + + # keys define in .acme.ini + REDIS_KEYS = wile_coyote.tools.REDIS_KEYS + VAULT_KEYS = wile_coyote.tools.VAULT_KEYS + CONSUL_KEYS = wile_coyote.tools.CONSUL_KEYS + + # delete and upload keys again + consul_prune(CONSUL_LEADER, CONSUL_TOKEN) + redis_prune(REDIS_HOST, REDIS_TOKEN) + vault_prune(VAULT_HOST, VAULT_ROOT_TOKEN, MOUNT_POINTS_V1, 1) + vault_prune(VAULT_HOST, VAULT_ROOT_TOKEN, MOUNT_POINTS_V2, 2) + + # prune certificates locally + ACME_PROVIDERS = ['sectigo_ev', 'sectigo_ev', 'letsencrypt'] + for acme_provider in ACME_PROVIDERS: + acme_certificates = glob(f'/etc/{acme_provider}/live/*') + try: + acme_certificates.remove(f'/etc/{acme_provider}/live/README') + except ValueError: + pass + certificates_delete(acme_provider, acme_certificates) + + # run all scripts under /opt/acme/bin + for script in glob('/opt/acme/bin/*'): + log.handler(f'running script {script}', LOGFILE) + os.system(script) + + # upstream keys + C_KEYS = consul_keys(CONSUL_LEADER, CONSUL_TOKEN) + R_KEYS = redis_keys(REDIS_HOST, REDIS_TOKEN) + V_KEYS_V1 = vault_keys(VAULT_HOST, VAULT_ROOT_TOKEN, MOUNT_POINTS_V1) + V_KEYS_V2 = vault_keys(VAULT_HOST, VAULT_ROOT_TOKEN, MOUNT_POINTS_V2) + V_KEYS_ALL = sorted(V_KEYS_V1 + V_KEYS_V2) + + # differences + C_DIFFS = [item for item in CONSUL_KEYS if item not in C_KEYS] + R_DIFFS = [item for item in REDIS_KEYS if item not in R_KEYS] + V_DIFFS = [item for item in VAULT_KEYS if item not in V_KEYS_ALL] + for key_store in ['Consul', 'Redis', 'Vault']: + if key_store == 'Consul': + key_list = C_DIFFS + elif key_store == 'Redis': + key_list = R_DIFFS + elif key_store == 'Vault': + key_list = V_DIFFS + if key_list: + log.handler(f'the following keys are not available on {key_store}:', LOGFILE, True) + log.handler(' '.join(key_list), LOGFILE, True) + os.sys.exit(1) + else: + log.handler(f'{key_store} keys names have been successfully checked') + + if REDIS_KEYS != R_KEYS: + log.handler('downstream and upstream Redis keys are different', LOGFILE, True) + os.sys.exit(1) + elif CONSUL_KEYS != C_KEYS: + log.handler('downstream and upstream Consul keys are different', LOGFILE, True) + os.sys.exit(1) + elif VAULT_KEYS != V_KEYS_ALL: + log.handler('downstream and upstream Vault keys are different', LOGFILE, True) + os.sys.exit(1) + + # validate public keys + for r_key in [item for item in REDIS_KEYS if '_chain.pem' not in R_KEYS]: + log.handler(f'checking certificate {r_key} on Redis', LOGFILE) + pubkey = wile_coyote.tools.redis_get.get(r_key) + wile_coyote.common_kit.public_key.check(pubkey, LOGFILE, r_key, False) + + for c_key in [item for item in CONSUL_KEYS if '_chain.pem' not in C_KEYS]: + log.handler(f'checking certificate {c_key} on Consul', LOGFILE) + pubkey = wile_coyote.tools.consul_get.get(c_key) + wile_coyote.common_kit.public_key.check(pubkey, LOGFILE, c_key, False) + + # validate private keys: we check only if it's not malformed + for v_key in V_KEYS_V1: + log.handler(f'checking private key {v_key} on Vault', LOGFILE) + privkey = wile_coyote.tools.vault_get.get(v_key, 'root') + wile_coyote.common_kit.public_key.check(privkey, LOGFILE, v_key, False) + + for v_key in V_KEYS_V2: + log.handler(f'checking private key {v_key} on Vault v2', LOGFILE) + fixed_v_key = re.sub(r'^/nomad/', '/', v_key) + privkey = wile_coyote.tools.vault_get_v2.get(fixed_v_key, 'root') + wile_coyote.common_kit.public_key.check(privkey, LOGFILE, v_key, False) diff --git a/bin/wile_coyote.py b/bin/wile_coyote.py index b6395e993fb8fa145ca862f3f480d85f10dcc3f4..cff709aefddccf6d97827e759eee4e3b86d60552 100644 --- a/bin/wile_coyote.py +++ b/bin/wile_coyote.py @@ -1,25 +1,25 @@ #!/usr/bin/env python3 # -"""Geant Acme +"""Wile Coyote Usage: - acme.py (--domain <DOMAIN>...) (--provider <PROVIDER>) --days <DAYS> \ -[--project <PROJECT>...] [--environment <ENVIRONMENT>...] [--tld] \ -[--unit <UNIT>] [--client <CLIENT>...] [--wildcard] [--extra <EXTRA>...] - acme.py (-h | --help) + wile_coyote.py (--domain <DOMAIN>...) (--provider <PROVIDER>) --days <DAYS> \ +[--project <PROJECT>...] [--nomad-env <ENV>...] [--tld] [--unit <UNIT>] \ +[--client <CLIENT>...] [--wildcard] [--extra <EXTRA>...] + wile_coyote.py (-h | --help) Options: - -h --help Show this screen. - -c CLIENT --client=CLIENT Client - -d DOMAIN --domain=DOMAIN Domain - -p PROVIDER --provider=PROVIDER Provider - -u UNIT --unit=UNIT Unit, entity or team - --days DAYS CRIT days before expiration - --project=PROJECT Project (Nomad only) - -e ENVIRONMENT --environment=ENVIRONMENT Environment (Nomad only) - -t --tld Top Level Domain - -w --wildcard Use wildcard - -x --extra=EXTRA Supply extra parameters (check certbot documentation) + -h --help Show this screen. + -c CLIENT --client=CLIENT Client + -d DOMAIN --domain=DOMAIN Domain + -p PROVIDER --provider=PROVIDER Provider + -u UNIT --unit=UNIT Unit, entity or team + --days DAYS CRIT days before expiration + --project=PROJECT Project (Nomad only) + --nomad-env=ENV Environment (Nomad only) + -t --tld Top Level Domain + -w --wildcard Use wildcard + -x --extra=EXTRA Supply extra parameters (check certbot documentation) """ import os import time @@ -124,7 +124,7 @@ if __name__ == "__main__": DOMAIN = ARGS['--domain'] UNIT = ARGS['--unit'] PROJECT = ARGS['--project'] - ENVIRONMENT = ARGS['--environment'] + ENV = ARGS['--nomad-env'] FIRST_NAME = DOMAIN[0] WILDCARD = ARGS['--wildcard'] # True or None EXTRA = ARGS['--extra'] @@ -192,7 +192,7 @@ if __name__ == "__main__": log.handler( f'uploading {FIRST_NAME} {PROVIDER}{wilcard_string} for Nomad', LOG_FILE) wile_coyote.acme_kit.nomad_uploader.uploader( - PROVIDER, PROJECT, FIRST_NAME, ENVIRONMENT, WILDCARD) + PROVIDER, PROJECT, FIRST_NAME, ENV, WILDCARD) else: log.handler( f'uploading {FIRST_NAME} {PROVIDER}{wilcard_string} for {UNIT}', LOG_FILE) diff --git a/common_kit/private_key.py b/common_kit/private_key.py index 026f632e0fc3cc7e0f5918e012150ec2e8e2d444..029dcad592293609a7dfd1f7c7d7000ae79abd6b 100644 --- a/common_kit/private_key.py +++ b/common_kit/private_key.py @@ -7,14 +7,19 @@ from wile_coyote.common_kit import log import wile_coyote.common_kit.constants -def check(privkey, log_file): +def check(privkey, log_file, key_name='unknown', from_file=True): """ check private key validity """ giveup = wile_coyote.common_kit.constants.GIVEUP is_broken = False - try: - st_key = open(privkey, 'rt', encoding="utf8").read() - except Exception: # pylint: disable=w0703 - is_broken = True + if from_file: + key_path = privkey + try: + st_key = open(privkey, 'rt', encoding="utf8").read() + except Exception: # pylint: disable=w0703 + is_broken = True + else: + st_key = privkey + key_path = key_name ssl_crypto = OpenSSL.crypto try: @@ -28,7 +33,7 @@ def check(privkey, log_file): is_broken = True if is_broken: - log.handler(f'{privkey} is not a valid key: {giveup}', log_file, True) + log.handler(f'{key_path} is not a valid key: {giveup}', log_file, True) os.sys.exit(1) return True diff --git a/common_kit/public_key.py b/common_kit/public_key.py index c2574dce763ea13844b69a1dd118004ec3924732..c0e8a1261ffdb3708060d6a269c32b215a6acfa1 100644 --- a/common_kit/public_key.py +++ b/common_kit/public_key.py @@ -7,14 +7,25 @@ from wile_coyote.common_kit import log import wile_coyote.common_kit.constants -def check(certificate, log_file): +def check(certificate, log_file, key_name='unknown', from_file=True): """ check certificate expiration """ - st_cert = open(certificate, 'rt', encoding="utf8").read() + if from_file: + st_cert = open(certificate, 'rt', encoding="utf8").read() + key_path = certificate + else: + st_cert = certificate + key_path = key_name ssl_crypto = OpenSSL.crypto - cert = ssl_crypto.load_certificate(ssl_crypto.FILETYPE_PEM, st_cert) + + try: + cert = ssl_crypto.load_certificate(ssl_crypto.FILETYPE_PEM, st_cert) + except Exception as err: # pylint: disable=w0703 + log.handler(f'Error. The certificate {key_path} is malformed: {err}') + os.sys.exit(1) + if cert.has_expired(): log.handler( - f'{certificate} expired and it will not be uploaded', log_file, True) + f'{key_path} expired and it will not be uploaded', log_file, True) log.handler(wile_coyote.common_kit.constants.giveup, log_file) os.sys.exit(1) diff --git a/tools/__init__.py b/tools/__init__.py index 1f9615ea7edbe52ab1654f02e80d7d4ed4dff205..bbb7fde200097c9a5980ce3f72bea197acdc8f86 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -1,3 +1,4 @@ +from ast import literal_eval as l_eval import pkgutil import configparser import wile_coyote.common_kit.constants @@ -15,3 +16,35 @@ for loader, module_name, is_pkg in pkgutil.walk_packages(__path__): REDIS_TOKEN = config.get('acme', 'redis_token') CONSUL_LIST = config.get('acme', 'consul_servers') CONSUL_TOKEN = config.get('acme', 'consul_token') + + # these parameters only work in test + # + try: + VAULT_ROOT_TOKEN = config.get('unit-test', 'vault_token_root') + except (configparser.NoSectionError, configparser.NoOptionError): + VAULT_ROOT_TOKEN = None + + try: + MOUNT_POINTS_V1 = l_eval(config.get('unit-test', 'mount_points_v1')) + except (configparser.NoSectionError, configparser.NoOptionError): + MOUNT_POINTS_V1 = None + + try: + MOUNT_POINTS_V2 = l_eval(config.get('unit-test', 'mount_points_v2')) + except (configparser.NoSectionError, configparser.NoOptionError): + MOUNT_POINTS_V2 = None + + try: + REDIS_KEYS = sorted(l_eval(config.get('unit-test', 'redis_keys'))) + except (configparser.NoSectionError, configparser.NoOptionError): + REDIS_KEYS = None + + try: + VAULT_KEYS = sorted(l_eval(config.get('unit-test', 'vault_keys'))) + except (configparser.NoSectionError, configparser.NoOptionError): + VAULT_KEYS = None + + try: + CONSUL_KEYS = sorted(l_eval(config.get('unit-test', 'consul_keys'))) + except (configparser.NoSectionError, configparser.NoOptionError): + CONSUL_KEYS = None