diff --git a/acertmgr.py b/acertmgr.py index a8f304b..843b95e 100755 --- a/acertmgr.py +++ b/acertmgr.py @@ -6,10 +6,10 @@ # Copyright (c) Rudolf Mayerhofer, 2019. # available under the ISC license, see LICENSE -import acme -import tools + import grp import hashlib +import importlib import os import pwd import shutil @@ -17,9 +17,8 @@ import stat import subprocess import tempfile -import yaml - -import acertmgr_web +import acme +import tools ACME_DIR = "/etc/acme" ACME_CONF = os.path.join(ACME_DIR, "acme.conf") @@ -29,10 +28,6 @@ ACME_DEFAULT_SERVER_KEY = os.path.join(ACME_DIR, "server.key") ACME_DEFAULT_ACCOUNT_KEY = os.path.join(ACME_DIR, "account.key") -class FileNotFoundError(OSError): - pass - - # @brief check whether existing target file is still valid or source crt has been updated # @param target string containing the path to the target file # @param crt_file string containing the path to the certificate file @@ -45,18 +40,32 @@ def target_is_current(target, crt_file): return target_date >= crt_date -# @brief fetch new certificate from letsencrypt -# @param domain string containing the domain name -# @param settings the domain's configuration options -def cert_get(domains, settings): - print("Getting certificate for %s." % domains) +# @brief create a challenge handler for the given configuration +# @param config the domain's configuration options +def create_challenge_handler(config): + if "mode" in config: + mode = config["mode"] + else: + mode = "standalone" - key_file = settings['server_key'] + handler_module = importlib.import_module("modes.{0}".format(mode)) + handler_class = getattr(handler_module, "ChallengeHandler") + return handler_class(config) + + +# @brief fetch new certificate from letsencrypt +# @param domains string containing all domain names +# @param globalconfig the global configuration options +# @param handlerconfigs the domain's handler configuration options +def cert_get(domains, globalconfig, handlerconfigs): + print("Getting certificate for '%s'." % domains) + + key_file = globalconfig['server_key'] if not os.path.isfile(key_file): print("Server key not found at '{0}'. Creating RSA key.".format(key_file)) tools.new_rsa_key(key_file) - acc_file = settings['account_key'] + acc_file = globalconfig['account_key'] if not os.path.isfile(acc_file): print("Account key not found at '{0}'. Creating RSA key.".format(acc_file)) tools.new_rsa_key(acc_file) @@ -65,39 +74,43 @@ def cert_get(domains, settings): _, csr_file = tempfile.mkstemp(".csr", "%s." % filename) _, crt_file = tempfile.mkstemp(".crt", "%s." % filename) - challenge_dir = settings.get("webdir", "/var/www/acme-challenge/") - if not os.path.isdir(challenge_dir): - raise FileNotFoundError("Challenge directory (%s) does not exist!" % challenge_dir) + # find challenge handlers for this certificate + challenge_handlers = dict() + domainlist = domains.split(' ') + for domain in domainlist: + # Use global config as base handler config + cfg = globalconfig.deepcopy() - current_dir = '.' - server = None - if settings['mode'] == 'standalone': - port = settings.get('port', 80) + # Determine generic domain handler config values + genericfgs = [x for x in handlerconfigs if 'domain' not in x] + if len(genericfgs) > 0: + cfg = cfg.update(genericfgs[0]) + + # Update handler config with more specific values + specificcfgs = [x for x in handlerconfigs if ('domain' in x and x['domain'] == domain)] + if len(specificcfgs) > 0: + cfg = cfg.update(specificcfgs[0]) + + # Create the challenge handler + challenge_handlers[domain] = create_challenge_handler(cfg) - current_dir = os.getcwd() - os.chdir(challenge_dir) - server = acertmgr_web.ACMEHTTPServer(port) - server.start() try: key = tools.read_key(key_file) - cr = tools.new_cert_request(domains.split(), key) + cr = tools.new_cert_request(domainlist, key) print("Reading account key...") acc_key = tools.read_key(acc_file) - acme.register_account(acc_key, settings['authority']) - crt = acme.get_crt_from_csr(acc_key, cr, domains.split(), challenge_dir, settings['authority']) + acme.register_account(acc_key, config['authority']) + crt = acme.get_crt_from_csr(acc_key, cr, domainlist, challenge_handlers, config['authority']) with open(crt_file, "w") as crt_fd: crt_fd.write(tools.convert_cert_to_pem(crt)) # if resulting certificate is valid: store in final location if tools.is_cert_valid(crt_file, 60): - crt_final = os.path.join(ACME_DIR, ("%s.crt" % domains.split(" ")[0])) + crt_final = os.path.join(ACME_DIR, (hashlib.md5(domains).hexdigest() + ".crt")) shutil.copy2(crt_file, crt_final) os.chmod(crt_final, stat.S_IREAD) finally: - if settings['mode'] == 'standalone': - server.stop() - os.chdir(current_dir) os.remove(csr_file) os.remove(crt_file) @@ -170,11 +183,18 @@ def complete_config(domainconfig, globalconfig): if __name__ == "__main__": + config = dict() # load global configuration - config = {} if os.path.isfile(ACME_CONF): with open(ACME_CONF) as config_fd: - config = yaml.load(config_fd) + try: + import json + + config = json.load(config_fd) + except json.JSONDecodeError: + import yaml + + config = yaml.load(config_fd) if 'defaults' not in config: config['defaults'] = {} if 'server_key' not in config: @@ -187,8 +207,16 @@ if __name__ == "__main__": for config_file in os.listdir(ACME_CONFD): if config_file.endswith(".conf"): with open(os.path.join(ACME_CONFD, config_file)) as config_fd: - for entry in yaml.load(config_fd).items(): - config['domains'].append(entry) + try: + import json + + for entry in json.load(config_fd).items(): + config['domains'].append(entry) + except json.JSONDecodeError: + import yaml + + for entry in yaml.load(config_fd).items(): + config['domains'].append(entry) # post-update actions (run only once) actions = set() @@ -201,11 +229,13 @@ if __name__ == "__main__": crt_file = os.path.join(ACME_DIR, (hashlib.md5(domains).hexdigest() + ".crt")) ttl_days = int(config.get('ttl_days', 15)) if not tools.is_cert_valid(crt_file, ttl_days): - cert_get(domains, config) - for domaincfg in domaincfgs: - cfg = complete_config(domaincfg, config) - if not target_is_current(cfg['path'], crt_file): - actions.add(cert_put(cfg)) + # Get certificates using handler configs (contain element 'mode') + cert_get(domains, config, [x for x in domaincfgs if 'mode' in x]) + # Run actions from config (contain element 'path') + for actioncfg in [x for x in domaincfgs if 'path' in x]: + actioncfg = complete_config(actioncfg, config) + if not target_is_current(actioncfg['path'], crt_file): + actions.add(cert_put(actioncfg)) # run post-update actions for action in actions: diff --git a/acme.py b/acme.py index b070bb1..55d060b 100644 --- a/acme.py +++ b/acme.py @@ -1,16 +1,13 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# acertmgr - ssl management functions +# acertmgr - acme api functions # Copyright (c) Markus Hauschild & David Klaftenegger, 2016. # Copyright (c) Rudolf Mayerhofer, 2019. # available under the ISC license, see LICENSE -import tools -import base64 import copy import json -import os import re import time @@ -19,6 +16,9 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding +import tools +from tools import byte_string_format + try: from urllib.request import urlopen # Python 3 except ImportError: @@ -28,46 +28,26 @@ except ImportError: # @brief create the header information for ACME communication # @param key the account key # @return the header for ACME -def acme_header(key): +def _prepare_header(key): numbers = key.public_key().public_numbers() header = { "alg": "RS256", "jwk": { - "e": tools.to_json_base64(tools.byte_string_format(numbers.e)), + "e": tools.to_json_base64(byte_string_format(numbers.e)), "kty": "RSA", - "n": tools.to_json_base64(tools.byte_string_format(numbers.n)), + "n": tools.to_json_base64(byte_string_format(numbers.n)), }, } return header -# @brief register an account over ACME -# @param account_key the account key to register -# @param CA the certificate authority to register with -# @return True if new account was registered, False otherwise -def register_account(account_key, ca): - header = acme_header(account_key) - code, result = send_signed(account_key, ca, ca + "/acme/new-reg", header, { - "resource": "new-reg", - "agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf", - }) - if code == 201: - print("Registered!") - return True - elif code == 409: - print("Already registered!") - return False - else: - raise ValueError("Error registering: {0} {1}".format(code, result)) - - # @brief helper function to make signed requests # @param CA the certificate authority # @param url the request URL # @param header the message header # @param payload the message # @return tuple of return code and request answer -def send_signed(account_key, ca, url, header, payload): +def _send_signed(account_key, ca, url, header, payload): payload64 = tools.to_json_base64(json.dumps(payload).encode('utf8')) protected = copy.deepcopy(header) protected["nonce"] = urlopen(ca + "/directory").headers['Replay-Nonce'] @@ -87,16 +67,36 @@ def send_signed(account_key, ca, url, header, payload): return getattr(e, "code", None), getattr(e, "read", e.__str__)() +# @brief register an account over ACME +# @param account_key the account key to register +# @param CA the certificate authority to register with +# @return True if new account was registered, False otherwise +def register_account(account_key, ca): + header = _prepare_header(account_key) + code, result = _send_signed(account_key, ca, ca + "/acme/new-reg", header, { + "resource": "new-reg", + "agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf", + }) + if code == 201: + print("Registered!") + return True + elif code == 409: + print("Already registered!") + return False + else: + raise ValueError("Error registering: {0} {1}".format(code, result)) + + # @brief function to fetch certificate using ACME # @param account_key the account key in pyopenssl format # @param csr the certificate signing request in pyopenssl format # @param domains list of domains in the certificate, first is CN -# @param acme_dir directory for ACME challanges +# @param challenge_handlers a dict containing challenge for all given domains # @param CA which signing CA to use # @return the certificate in pyopenssl format # @note algorithm and parts of the code are from acme-tiny -def get_crt_from_csr(account_key, csr, domains, acme_dir, ca): - header = acme_header(account_key) +def get_crt_from_csr(account_key, csr, domains, challenge_handlers, ca): + header = _prepare_header(account_key) accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) account_hash = hashes.Hash(hashes.SHA256(), backend=default_backend()) account_hash.update(accountkey_json.encode('utf8')) @@ -107,34 +107,26 @@ def get_crt_from_csr(account_key, csr, domains, acme_dir, ca): print("Verifying {0}...".format(domain)) # get new challenge - code, result = send_signed(account_key, ca, ca + "/acme/new-authz", header, { + code, result = _send_signed(account_key, ca, ca + "/acme/new-authz", header, { "resource": "new-authz", "identifier": {"type": "dns", "value": domain}, }) if code != 201: raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) - # make the challenge file - challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0] + # create the challenge + challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if + c['type'] == challenge_handlers[domain].get_challenge_type()][0] token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) - keyauthorization = "{0}.{1}".format(token, account_thumbprint) - wellknown_path = os.path.join(acme_dir, token) - with open(wellknown_path, "w") as wellknown_file: - wellknown_file.write(keyauthorization) - # check that the file is in place - wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) - try: - resp = urlopen(wellknown_url) - resp_data = resp.read().decode('utf8').strip() - assert resp_data == keyauthorization - except (IOError, AssertionError): - os.remove(wellknown_path) - raise ValueError("Wrote file to {0}, but couldn't download {1}".format( - wellknown_path, wellknown_url)) + if domain not in challenge_handlers: + raise ValueError("No challenge handler given for domain: {0}".format(domain)) + + challenge_handlers[domain].create_challenge(domain, account_thumbprint, token) # notify challenge are met - code, result = send_signed(account_key, ca, challenge['uri'], header, { + keyauthorization = "{0}.{1}".format(token, account_thumbprint) + code, result = _send_signed(account_key, ca, challenge['uri'], header, { "resource": "challenge", "keyAuthorization": keyauthorization, }) @@ -153,7 +145,7 @@ def get_crt_from_csr(account_key, csr, domains, acme_dir, ca): time.sleep(2) elif challenge_status['status'] == "valid": print("{0} verified!".format(domain)) - os.remove(wellknown_path) + challenge_handlers[domain].destroy_challenge(domain, account_thumbprint, token) break else: raise ValueError("{0} challenge did not pass: {1}".format( @@ -162,7 +154,7 @@ def get_crt_from_csr(account_key, csr, domains, acme_dir, ca): # get the new certificate print("Signing certificate...") csr_der = csr.public_bytes(serialization.Encoding.DER) - code, result = send_signed(account_key, ca, ca + "/acme/new-cert", header, { + code, result = _send_signed(account_key, ca, ca + "/acme/new-cert", header, { "resource": "new-cert", "csr": tools.to_json_base64(csr_der), }) diff --git a/modes/__init__.py b/modes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modes/abstract.py b/modes/abstract.py new file mode 100644 index 0000000..1533802 --- /dev/null +++ b/modes/abstract.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# abstract - abstract base classes for challenge handlers +# Copyright (c) Rudolf Mayerhofer, 2019. +# available under the ISC license, see LICENSE + + +class AbstractChallengeHandler: + def __init__(self, config): + self.config = config + + @staticmethod + def get_challenge_type(): + raise NotImplemented + + def create_challenge(self, domain, thumbprint, token): + raise NotImplemented + + def destroy_challenge(self, domain, thumbprint, token): + raise NotImplemented diff --git a/acertmgr_web.py b/modes/standalone.py similarity index 69% rename from acertmgr_web.py rename to modes/standalone.py index db91906..11784b6 100644 --- a/acertmgr_web.py +++ b/modes/standalone.py @@ -1,8 +1,9 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# acertmgr - ACME challenge webserver +# standalone - standalone ACME challenge webserver # Copyright (c) Markus Hauschild & David Klaftenegger, 2016. +# Copyright (c) Rudolf Mayerhofer, 2019. # available under the ISC license, see LICENSE try: @@ -15,8 +16,11 @@ try: except ImportError: from http.server import HTTPServer +import os import threading +from modes.webdir import ChallengeHandler as WebChallengeHandler + # @brief custom request handler for ACME challenges # @note current working directory is temporarily changed by the script before @@ -51,20 +55,28 @@ def start_standalone(server): server.serve_forever() -# @brief a simple webserver for challanges -class ACMEHTTPServer: - # @brief create webserver instance - # @param port the port to listen on - def __init__(self, port=80): - HTTPServer.allow_reuse_address = True +HTTPServer.allow_reuse_address = True + + +class ChallengeHandler(WebChallengeHandler): + def __init__(self, config): + WebChallengeHandler.__init__(self, config) + self.current_directory = os.getcwd() + if "port" in config: + port = int(config["port"]) + else: + port = 80 + self.server_thread = None self.server = HTTPServer(("", port), ACMERequestHandler) - # @brief start the webserver - def start(self): + def create_challenge(self, domain, thumbprint, token): + WebChallengeHandler.create_challenge(self, domain, thumbprint, token) self.server_thread = threading.Thread(target=start_standalone, args=(self.server,)) + os.chdir(self.challenge_directory) self.server_thread.start() - # @brief stop the webserver - def stop(self): + def destroy_challenge(self, domain, thumbprint, token): self.server.shutdown() self.server_thread.join() + os.chdir(self.current_directory) + WebChallengeHandler.destroy_challenge(self, domain, thumbprint, token) diff --git a/modes/webdir.py b/modes/webdir.py new file mode 100644 index 0000000..c69a70f --- /dev/null +++ b/modes/webdir.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# web - http based challenge handler +# Copyright (c) Rudolf Mayerhofer, 2019. +# available under the ISC license, see LICENSE + +import os + +from modes.abstract import AbstractChallengeHandler + +try: + from urllib.request import urlopen # Python 3 +except ImportError: + from urllib2 import urlopen # Python 2 + + +class ChallengeHandler(AbstractChallengeHandler): + def __init__(self, config): + AbstractChallengeHandler.__init__(self, config) + self.challenge_directory = config.get("webdir", "/var/www/acme-challenge/") + if not os.path.isdir(self.challenge_directory): + raise FileNotFoundError("Challenge directory (%s) does not exist!" % self.challenge_directory) + + @staticmethod + def get_challenge_type(): + return "http-01" + + def create_challenge(self, domain, thumbprint, token): + keyauthorization = "{0}.{1}".format(token, thumbprint) + wellknown_path = os.path.join(self.challenge_directory, token) + with open(wellknown_path, "w") as wellknown_file: + wellknown_file.write(keyauthorization) + + # check that the file is in place + wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) + try: + resp = urlopen(wellknown_url) + resp_data = resp.read().decode('utf8').strip() + assert resp_data == keyauthorization + except (IOError, AssertionError): + os.remove(wellknown_path) + raise ValueError("Wrote file to {0}, but couldn't download {1}".format( + wellknown_path, wellknown_url)) + + def destroy_challenge(self, domain, thumbprint, token): + os.remove(os.path.join(self.challenge_directory, token))