diff --git a/README.md b/README.md index fa61584..80a46da 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,12 @@ While testing, you can use the acme-staging authority instead, in order to avoid Configuration ------------- -The main configuration is read from `/etc/acme/acme.conf`, domains for which certificates should be obtained/renewed should be configured in `/etc/acme/domains.d/*.conf`. +Unless specified with a commandline parameter (see acertmgr.py --help) the optional global configuration is read from '/etc/acertmgr/acertmgr.conf'. +Domains for which certificates should be obtained/renewed should be configured in `/etc/acertmgr/*.conf` (the global configuration is automatically excluded if it is in the same directory). All configuration files can use yaml (requires PyYAML) or json syntax. - * Example global configuration file (YAML syntax): + * Example optional global configuration file (YAML syntax): ```yaml --- # Required: Authority API endpoint to use @@ -138,7 +139,7 @@ mail.example.com smtp.example.com webmail.example.net: ``` - * Example global configuration file (JSON syntax): + * Example optional global configuration file (JSON syntax): ```json --- { @@ -150,11 +151,6 @@ mail.example.com smtp.example.com webmail.example.net: "webdir": "/var/www/acme-challenge/", "authority": "https://acme-v01.api.letsencrypt.org", - -"defaults": - { - "cafile": "/etc/acme/lets-encrypt-x3-cross-signed.pem" - } } ``` diff --git a/acertmgr/__init__.py b/acertmgr/__init__.py index b18829e..f471843 100755 --- a/acertmgr/__init__.py +++ b/acertmgr/__init__.py @@ -34,20 +34,15 @@ def target_is_current(target, file): # @brief create a authority for the given configuration # @param settings the authority configuration options def create_authority(settings): - if "api" in settings: - api = settings["api"] - else: - api = "v1" - acc_file = settings['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) acc_key = tools.read_key(acc_file) - authority_module = importlib.import_module("acertmgr.authority.{0}".format(api)) + authority_module = importlib.import_module("acertmgr.authority.{0}".format(settings["api"])) authority_class = getattr(authority_module, "ACMEAuthority") - return authority_class(settings.get('authority'), acc_key) + return authority_class(settings['authority'], acc_key) # @brief create a challenge handler for the given configuration @@ -171,7 +166,7 @@ def main(): # check certificate validity and obtain/renew certificates if needed for config in configs: cert_file = config['cert_file'] - ttl_days = int(config.get('ttl_days', configuration.ACME_DEFAULT_TTL)) + ttl_days = int(config['ttl_days']) if not tools.is_cert_valid(cert_file, ttl_days): cert_get(config) for cfg in config['actions']: diff --git a/acertmgr/configuration.py b/acertmgr/configuration.py index 343fdc2..b09eb7c 100644 --- a/acertmgr/configuration.py +++ b/acertmgr/configuration.py @@ -6,19 +6,24 @@ # Copyright (c) Rudolf Mayerhofer, 2019. # available under the ISC license, see LICENSE +import argparse import copy import io +import hashlib import os -from acertmgr import tools +# Backward compatiblity for older versions/installations of acertmgr +LEGACY_WORK_DIR = "/etc/acme" +LEGACY_CONF_FILE = os.path.join(LEGACY_WORK_DIR, "acme.conf") +LEGACY_CONF_DIR = os.path.join(LEGACY_WORK_DIR, "domains.d") -ACME_DIR = "/etc/acme" -ACME_CONF = os.path.join(ACME_DIR, "acme.conf") -ACME_CONFD = os.path.join(ACME_DIR, "domains.d") - -ACME_DEFAULT_ACCOUNT_KEY = os.path.join(ACME_DIR, "account.key") -ACME_DEFAULT_KEY_LENGTH = 4096 # bits -ACME_DEFAULT_TTL = 15 # days +# Configuration defaults to use if not specified otherwise +DEFAULT_CONF_FILE = "/etc/acertmgr/acertmgr.conf" +DEFAULT_CONF_DIR = "/etc/acertmgr" +DEFAULT_KEY_LENGTH = 4096 # bits +DEFAULT_TTL = 15 # days +DEFAULT_API = "v1" +DEFAULT_AUTHORITY = "https://acme-v01.api.letsencrypt.org" # @brief augment configuration with defaults @@ -39,42 +44,51 @@ def complete_action_config(domainconfig, config): # @brief load the configuration from a file -def parse_config_entry(entry, globalconfig): +def parse_config_entry(entry, globalconfig, work_dir): config = dict() # Basic domain information config['domains'], data = entry config['domainlist'] = config['domains'].split(' ') - config['id'] = tools.to_unique_id(config['domains']) + config['id'] = hashlib.md5(config['domains'].encode('utf-8')).hexdigest() - # Defaults + # Action config defaults config['defaults'] = globalconfig.get('defaults', {}) # API version apis = [x for x in entry if 'api' in x] if len(apis) > 0: config['api'] = apis[0] + else: + config['api'] = globalconfig.get('api', DEFAULT_API) # Certificate authority authorities = [x for x in entry if 'authority' in x] if len(authorities) > 0: config['authority'] = authorities[0] else: - config['authority'] = globalconfig.get('authority') + config['authority'] = globalconfig.get('authority', DEFAULT_AUTHORITY) # Account key acc_keys = [x for x in entry if 'account_key' in x] if len(acc_keys) > 0: config['account_key'] = acc_keys[0] else: - config['account_key'] = globalconfig.get('account_key', ACME_DEFAULT_ACCOUNT_KEY) + config['account_key'] = globalconfig.get('account_key', os.path.join(work_dir, "account.key")) # Certificate directory cert_dirs = [x for x in entry if 'cert_dir' in x] if len(cert_dirs) > 0: config['cert_dir'] = cert_dirs[0] else: - config['cert_dir'] = globalconfig.get('cert_dir', ACME_DIR) + config['cert_dir'] = globalconfig.get('cert_dir', work_dir) + + # TTL days + cert_dirs = [x for x in entry if 'ttl_days' in x] + if len(cert_dirs) > 0: + config['ttl_days'] = cert_dirs[0] + else: + config['ttl_days'] = globalconfig.get('ttl_days', DEFAULT_TTL) # SSL CA location ca_files = [x for x in entry if 'ca_file' in x] @@ -109,7 +123,7 @@ def parse_config_entry(entry, globalconfig): if len(key_lengths) > 0: config['key_length'] = int(key_lengths[0]) else: - config['key_length'] = ACME_DEFAULT_KEY_LENGTH + config['key_length'] = DEFAULT_KEY_LENGTH # Domain action configuration config['actions'] = list() @@ -140,10 +154,44 @@ def parse_config_entry(entry, globalconfig): # @brief load the configuration from a file def load(): - globalconfig = dict() + parser = argparse.ArgumentParser(description="acertmgr - Automated Certificate Manager using ACME/Let's Encrypt") + parser.add_argument("-c", "--config-file", nargs="?", + help="global configuration file (default='{}')".format(DEFAULT_CONF_FILE)) + parser.add_argument("-d", "--config-dir", nargs="?", + help="domain configuration directory (default='{}')".format(DEFAULT_CONF_DIR)) + parser.add_argument("-w", "--work-dir", nargs="?", + help="persistent work data directory (default=config_dir)") + args = parser.parse_args() + + # Determine global configuration file + if args.config_file: + global_config_file = args.config_file + elif os.path.isfile(LEGACY_CONF_FILE): + global_config_file = LEGACY_CONF_FILE + else: + global_config_file = DEFAULT_CONF_FILE + + # Determine domain configuration directory + if args.config_dir: + domain_config_dir = args.config_dir + elif os.path.isdir(LEGACY_CONF_DIR): + domain_config_dir = LEGACY_CONF_DIR + else: + domain_config_dir = DEFAULT_CONF_DIR + + # Determine work directory... + if args.work_dir: + work_dir = args.work_dir + elif os.path.isdir(LEGACY_WORK_DIR): + work_dir = LEGACY_WORK_DIR + else: + # .. or use the domain configuration directory otherwise + work_dir = domain_config_dir + # load global configuration - if os.path.isfile(ACME_CONF): - with io.open(ACME_CONF) as config_fd: + globalconfig = dict() + if os.path.isfile(global_config_file): + with io.open(global_config_file) as config_fd: try: import json globalconfig = json.load(config_fd) @@ -152,19 +200,25 @@ def load(): config_fd.seek(0) globalconfig = yaml.load(config_fd) - config = list() + # create work directory if it does not exist + if not os.path.isdir(work_dir): + os.mkdir(work_dir, int("0700", 8)) + # load domain configuration - for config_file in os.listdir(ACME_CONFD): - if config_file.endswith(".conf"): - with io.open(os.path.join(ACME_CONFD, config_file)) as config_fd: - try: - import json - for entry in json.load(config_fd).items(): - config.append(parse_config_entry(entry, globalconfig)) - except ValueError: - import yaml - config_fd.seek(0) - for entry in yaml.load(config_fd).items(): - config.append(parse_config_entry(entry, globalconfig)) + config = list() + if os.path.isdir(domain_config_dir): + for domain_config_file in os.listdir(domain_config_dir): + # check file extension and skip if global config file + if domain_config_file.endswith(".conf") and domain_config_file != global_config_file: + with io.open(os.path.join(domain_config_dir, domain_config_file)) as config_fd: + try: + import json + for entry in json.load(config_fd).items(): + config.append(parse_config_entry(entry, globalconfig, work_dir)) + except ValueError: + import yaml + config_fd.seek(0) + for entry in yaml.load(config_fd).items(): + config.append(parse_config_entry(entry, globalconfig, work_dir)) return config diff --git a/acertmgr/tools.py b/acertmgr/tools.py index 156aa2c..411aa92 100644 --- a/acertmgr/tools.py +++ b/acertmgr/tools.py @@ -158,10 +158,3 @@ def byte_string_format(num): n = format(num, 'x') n = "0{0}".format(n) if len(n) % 2 else n return binascii.unhexlify(n) - - -# @brief convert a string to an ID -# @param data data to convert to id -# @return unique id string -def to_unique_id(data): - return hashlib.md5(data.encode('utf-8')).hexdigest()