2019-01-22 09:05:41 +01:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
# config - acertmgr config parser
|
|
|
|
# Copyright (c) Markus Hauschild & David Klaftenegger, 2016.
|
|
|
|
# Copyright (c) Rudolf Mayerhofer, 2019.
|
|
|
|
# available under the ISC license, see LICENSE
|
|
|
|
|
2019-02-20 11:57:15 +01:00
|
|
|
import argparse
|
2019-01-22 09:05:41 +01:00
|
|
|
import copy
|
2019-02-20 11:57:15 +01:00
|
|
|
import hashlib
|
2019-03-07 13:51:08 +01:00
|
|
|
import io
|
2019-03-21 13:03:09 +01:00
|
|
|
import json
|
2019-03-24 17:15:30 +01:00
|
|
|
import os
|
2019-03-27 16:34:29 +01:00
|
|
|
import sys
|
|
|
|
|
2019-04-04 13:15:34 +02:00
|
|
|
from acertmgr.tools import log
|
|
|
|
|
2019-03-27 16:34:29 +01:00
|
|
|
try:
|
|
|
|
import idna
|
|
|
|
except ImportError:
|
|
|
|
pass
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-02-20 11:57:15 +01:00
|
|
|
# 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")
|
2019-03-28 12:31:31 +01:00
|
|
|
LEGACY_API = "v1"
|
|
|
|
LEGACY_AUTHORITY = "https://acme-v01.api.letsencrypt.org"
|
|
|
|
LEGACY_AUTHORITY_TOS_AGREEMENT = "true"
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-02-20 11:57:15 +01:00
|
|
|
# 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
|
2019-03-18 13:13:22 +01:00
|
|
|
DEFAULT_TTL = 30 # days
|
2019-03-19 02:13:29 +01:00
|
|
|
DEFAULT_API = "v2"
|
|
|
|
DEFAULT_AUTHORITY = "https://acme-v02.api.letsencrypt.org"
|
2019-01-22 09:05:41 +01:00
|
|
|
|
|
|
|
|
|
|
|
# @brief augment configuration with defaults
|
|
|
|
# @param domainconfig the domain configuration
|
|
|
|
# @param defaults the default configuration
|
|
|
|
# @return the augmented configuration
|
|
|
|
def complete_action_config(domainconfig, config):
|
|
|
|
defaults = config['defaults']
|
|
|
|
domainconfig['ca_file'] = config['ca_file']
|
|
|
|
domainconfig['cert_file'] = config['cert_file']
|
|
|
|
domainconfig['key_file'] = config['key_file']
|
|
|
|
for name, value in defaults.items():
|
|
|
|
if name not in domainconfig:
|
|
|
|
domainconfig[name] = value
|
|
|
|
if 'action' not in domainconfig:
|
|
|
|
domainconfig['action'] = None
|
|
|
|
return domainconfig
|
|
|
|
|
|
|
|
|
2019-03-07 13:51:08 +01:00
|
|
|
# @brief update config[name] with value from localconfig>globalconfig>default
|
|
|
|
def update_config_value(config, name, localconfig, globalconfig, default):
|
2019-03-28 12:31:31 +01:00
|
|
|
values = [x[name] for x in localconfig if name in x]
|
2019-03-07 13:51:08 +01:00
|
|
|
if len(values) > 0:
|
2019-03-28 12:31:31 +01:00
|
|
|
config[name] = values[0]
|
2019-03-07 13:51:08 +01:00
|
|
|
else:
|
|
|
|
config[name] = globalconfig.get(name, default)
|
|
|
|
|
|
|
|
|
2019-03-27 16:34:29 +01:00
|
|
|
# @brief convert domain list to idna representation (if applicable
|
|
|
|
def idna_convert(domainlist):
|
|
|
|
if 'idna' in sys.modules and any(ord(c) >= 128 for c in ''.join(domainlist)):
|
2019-03-31 22:50:16 +02:00
|
|
|
domaintranslation = list()
|
2019-03-27 16:34:29 +01:00
|
|
|
for domain in domainlist:
|
|
|
|
if any(ord(c) >= 128 for c in domain):
|
|
|
|
# Translate IDNA domain name from a unicode domain (handle wildcards separately)
|
|
|
|
if domain.startswith('*.'):
|
|
|
|
idna_domain = "*.{}".format(idna.encode(domain[2:]).decode('utf-8'))
|
|
|
|
else:
|
|
|
|
idna_domain = idna.encode(domain).decode('utf-8')
|
2019-03-31 22:50:16 +02:00
|
|
|
result = idna_domain, domain
|
|
|
|
else:
|
|
|
|
result = domain, domain
|
|
|
|
domaintranslation.append(result)
|
2019-03-27 16:34:29 +01:00
|
|
|
return domaintranslation
|
|
|
|
else:
|
|
|
|
if 'idna' not in sys.modules:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Unicode domain(s) found but IDNA names could not be translated due to missing idna module", error=True)
|
2019-03-31 22:50:16 +02:00
|
|
|
return list()
|
2019-03-27 16:34:29 +01:00
|
|
|
|
|
|
|
|
2019-01-22 09:05:41 +01:00
|
|
|
# @brief load the configuration from a file
|
2019-03-27 15:17:17 +01:00
|
|
|
def parse_config_entry(entry, globalconfig, runtimeconfig):
|
2019-01-22 09:05:41 +01:00
|
|
|
config = dict()
|
|
|
|
|
|
|
|
# Basic domain information
|
2019-03-28 13:59:44 +01:00
|
|
|
domains, localconfig = entry
|
|
|
|
config['domainlist'] = domains.split(' ')
|
|
|
|
config['id'] = hashlib.md5(domains.encode('utf-8')).hexdigest()
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-03-27 16:34:29 +01:00
|
|
|
# Convert unicode to IDNA domains
|
|
|
|
config['domaintranslation'] = idna_convert(config['domainlist'])
|
|
|
|
if len(config['domaintranslation']) > 0:
|
2019-03-31 22:50:16 +02:00
|
|
|
config['domainlist'] = [x for x, _ in config['domaintranslation']]
|
2019-03-21 13:03:09 +01:00
|
|
|
|
2019-02-20 11:57:15 +01:00
|
|
|
# Action config defaults
|
2019-01-22 09:05:41 +01:00
|
|
|
config['defaults'] = globalconfig.get('defaults', {})
|
|
|
|
|
2019-03-28 09:06:21 +01:00
|
|
|
# Authority related config options
|
|
|
|
config['authority'] = {}
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-03-28 09:06:21 +01:00
|
|
|
# - API version
|
|
|
|
update_config_value(config['authority'], 'api', localconfig, globalconfig, DEFAULT_API)
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-03-28 09:06:21 +01:00
|
|
|
# - Certificate authority
|
|
|
|
update_config_value(config['authority'], 'authority', localconfig, globalconfig, DEFAULT_AUTHORITY)
|
|
|
|
|
|
|
|
# - Certificate authority ToS agreement
|
|
|
|
update_config_value(config['authority'], 'authority_tos_agreement', localconfig, globalconfig,
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig['authority_tos_agreement'])
|
2019-02-23 17:52:07 +01:00
|
|
|
|
2019-03-28 09:06:21 +01:00
|
|
|
# - Certificate authority contact email addresses
|
|
|
|
update_config_value(config['authority'], 'authority_contact_email', localconfig, globalconfig, None)
|
2019-03-18 20:58:30 +01:00
|
|
|
|
2019-03-28 09:06:21 +01:00
|
|
|
# - Account key path
|
|
|
|
update_config_value(config['authority'], 'account_key', localconfig, globalconfig,
|
2019-03-27 15:17:17 +01:00
|
|
|
os.path.join(runtimeconfig['work_dir'], "account.key"))
|
2019-01-22 09:05:41 +01:00
|
|
|
|
|
|
|
# Certificate directory
|
2019-03-27 15:17:17 +01:00
|
|
|
update_config_value(config, 'cert_dir', localconfig, globalconfig, runtimeconfig['work_dir'])
|
2019-02-20 11:57:15 +01:00
|
|
|
|
|
|
|
# TTL days
|
2019-03-24 17:50:31 +01:00
|
|
|
update_config_value(config, 'ttl_days', localconfig, globalconfig, DEFAULT_TTL)
|
2019-03-24 17:15:30 +01:00
|
|
|
config['ttl_days'] = int(config['ttl_days'])
|
2019-03-07 13:51:08 +01:00
|
|
|
|
2019-03-28 00:34:53 +01:00
|
|
|
# Revoke old certificate with reason superseded after renewal
|
|
|
|
update_config_value(config, 'cert_revoke_superseded', localconfig, globalconfig, "false")
|
|
|
|
|
2019-03-29 08:37:50 +01:00
|
|
|
# Whether to include request for OCSP must-staple in the certificate
|
|
|
|
update_config_value(config, 'cert_must_staple', localconfig, globalconfig, "false")
|
|
|
|
|
2019-03-24 17:22:04 +01:00
|
|
|
# Use a static cert request
|
2019-03-24 17:50:31 +01:00
|
|
|
update_config_value(config, 'csr_static', localconfig, globalconfig, "false")
|
2019-03-24 17:22:04 +01:00
|
|
|
|
|
|
|
# SSL cert request location
|
2019-03-24 17:50:31 +01:00
|
|
|
update_config_value(config, 'csr_file', localconfig, globalconfig,
|
2019-03-24 17:22:04 +01:00
|
|
|
os.path.join(config['cert_dir'], "{}.csr".format(config['id'])))
|
|
|
|
|
2019-03-07 13:51:08 +01:00
|
|
|
# SSL cert location (with compatibility to older versions)
|
2019-03-23 08:39:17 +01:00
|
|
|
if 'server_cert' in globalconfig:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Legacy configuration directive 'server_cert' used. Support will be removed in 1.0", warning=True)
|
2019-03-24 17:50:31 +01:00
|
|
|
update_config_value(config, 'cert_file', localconfig, globalconfig,
|
2019-03-07 13:51:08 +01:00
|
|
|
globalconfig.get('server_cert',
|
|
|
|
os.path.join(config['cert_dir'], "{}.crt".format(config['id']))))
|
|
|
|
|
|
|
|
# SSL key location (with compatibility to older versions)
|
2019-03-23 08:39:17 +01:00
|
|
|
if 'server_key' in globalconfig:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Legacy configuration directive 'server_key' used. Support will be removed in 1.0", warning=True)
|
2019-03-24 17:50:31 +01:00
|
|
|
update_config_value(config, 'key_file', localconfig, globalconfig,
|
2019-03-07 13:51:08 +01:00
|
|
|
globalconfig.get('server_key',
|
|
|
|
os.path.join(config['cert_dir'], "{}.key".format(config['id']))))
|
|
|
|
|
|
|
|
# SSL key length (if key has to be (re-)generated, converted to int)
|
2019-03-24 17:50:31 +01:00
|
|
|
update_config_value(config, 'key_length', localconfig, globalconfig, DEFAULT_KEY_LENGTH)
|
2019-03-07 13:51:08 +01:00
|
|
|
config['key_length'] = int(config['key_length'])
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-03-28 12:33:59 +01:00
|
|
|
# SSL CA location / use static
|
|
|
|
update_config_value(config, 'ca_file', localconfig, globalconfig,
|
|
|
|
globalconfig.get('server_ca', config['defaults'].get('server_ca',
|
2019-04-04 13:15:34 +02:00
|
|
|
os.path.join(config['cert_dir'],
|
|
|
|
"{}.ca".format(
|
|
|
|
config['id'])))))
|
2019-03-28 12:33:59 +01:00
|
|
|
update_config_value(config, 'ca_static', localconfig, globalconfig, "false")
|
|
|
|
if 'server_ca' in globalconfig or 'server_ca' in config['defaults']:
|
|
|
|
config['ca_static'] = "true"
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Legacy configuration directive 'server_ca' used. Support removed in 1.0", warning=True)
|
2019-01-22 09:05:41 +01:00
|
|
|
|
|
|
|
# Domain action configuration
|
|
|
|
config['actions'] = list()
|
2019-03-24 17:50:31 +01:00
|
|
|
for actioncfg in [x for x in localconfig if 'path' in x]:
|
2019-01-22 09:05:41 +01:00
|
|
|
config['actions'].append(complete_action_config(actioncfg, config))
|
|
|
|
|
|
|
|
# Domain challenge handler configuration
|
|
|
|
config['handlers'] = dict()
|
2019-03-24 17:50:31 +01:00
|
|
|
handlerconfigs = [x for x in localconfig if 'mode' in x]
|
2019-03-31 22:50:16 +02:00
|
|
|
_domaintranslation_dict = {x: y for x, y in config.get('domaintranslation', [])}
|
2019-01-22 09:05:41 +01:00
|
|
|
for domain in config['domainlist']:
|
|
|
|
# Use global config as base handler config
|
|
|
|
cfg = copy.deepcopy(globalconfig)
|
|
|
|
|
|
|
|
# Determine generic domain handler config values
|
|
|
|
genericfgs = [x for x in handlerconfigs if 'domain' not in x]
|
|
|
|
if len(genericfgs) > 0:
|
|
|
|
cfg.update(genericfgs[0])
|
|
|
|
|
2019-03-21 13:03:09 +01:00
|
|
|
# Update handler config with more specific values (use original names for translated unicode domains)
|
2019-03-31 22:50:16 +02:00
|
|
|
_domain = _domaintranslation_dict.get(domain, domain)
|
2019-03-21 13:03:09 +01:00
|
|
|
specificcfgs = [x for x in handlerconfigs if 'domain' in x and x['domain'] == _domain]
|
2019-01-22 09:05:41 +01:00
|
|
|
if len(specificcfgs) > 0:
|
|
|
|
cfg.update(specificcfgs[0])
|
|
|
|
|
|
|
|
config['handlers'][domain] = cfg
|
|
|
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
# @brief load the configuration from a file
|
|
|
|
def load():
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig = dict()
|
2019-02-20 11:57:15 +01:00
|
|
|
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)")
|
2019-03-20 10:34:59 +01:00
|
|
|
parser.add_argument("--authority-tos-agreement", "--tos-agreement", "--tos", nargs="?",
|
|
|
|
help="Agree to the authorities Terms of Service (value required depends on authority)")
|
2019-03-27 15:31:15 +01:00
|
|
|
parser.add_argument("--force-renew", "--renew-now", nargs="?",
|
|
|
|
help="Renew all domain configurations matching the given value immediately")
|
2019-03-27 21:00:21 +01:00
|
|
|
parser.add_argument("--revoke", nargs="?",
|
|
|
|
help="Revoke a certificate file issued with the currently configured account key.")
|
|
|
|
parser.add_argument("--revoke-reason", nargs="?", type=int,
|
|
|
|
help="Provide a revoke reason, see https://tools.ietf.org/html/rfc5280#section-5.3.1")
|
2019-02-20 11:57:15 +01:00
|
|
|
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):
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Legacy config file '{}' used. Move to '{}' for 1.0".format(LEGACY_CONF_FILE, DEFAULT_CONF_FILE),
|
|
|
|
warning=True)
|
2019-02-20 11:57:15 +01:00
|
|
|
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):
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Legacy config dir '{}' used. Move to '{}' for 1.0".format(LEGACY_CONF_DIR, DEFAULT_CONF_DIR), warning=True)
|
2019-02-20 11:57:15 +01:00
|
|
|
domain_config_dir = LEGACY_CONF_DIR
|
|
|
|
else:
|
|
|
|
domain_config_dir = DEFAULT_CONF_DIR
|
|
|
|
|
2019-03-27 15:17:17 +01:00
|
|
|
# Runtime configuration: Get from command-line options
|
|
|
|
# - work_dir
|
2019-02-20 11:57:15 +01:00
|
|
|
if args.work_dir:
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig['work_dir'] = args.work_dir
|
2019-03-19 02:04:31 +01:00
|
|
|
elif os.path.isdir(LEGACY_WORK_DIR) and domain_config_dir == LEGACY_CONF_DIR:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Legacy work dir '{}' used. Move to config-dir for 1.0".format(LEGACY_WORK_DIR), warning=True)
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig['work_dir'] = LEGACY_WORK_DIR
|
2019-02-20 11:57:15 +01:00
|
|
|
else:
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig['work_dir'] = domain_config_dir
|
|
|
|
# create work_dir if it does not exist yet
|
|
|
|
if not os.path.isdir(runtimeconfig['work_dir']):
|
|
|
|
os.mkdir(runtimeconfig['work_dir'], int("0700", 8))
|
2019-02-20 11:57:15 +01:00
|
|
|
|
2019-03-27 15:17:17 +01:00
|
|
|
# - authority_tos_agreement
|
2019-03-20 10:34:59 +01:00
|
|
|
if args.authority_tos_agreement:
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig['authority_tos_agreement'] = args.authority_tos_agreement
|
2019-03-20 10:34:59 +01:00
|
|
|
elif global_config_file == LEGACY_CONF_FILE:
|
2019-03-27 15:17:17 +01:00
|
|
|
# Legacy global config file assumes ToS are agreed
|
|
|
|
runtimeconfig['authority_tos_agreement'] = LEGACY_AUTHORITY_TOS_AGREEMENT
|
2019-03-20 10:34:59 +01:00
|
|
|
else:
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig['authority_tos_agreement'] = None
|
2019-03-20 10:34:59 +01:00
|
|
|
|
2019-03-27 15:31:15 +01:00
|
|
|
# - force-rewew
|
|
|
|
if args.force_renew:
|
|
|
|
domaintranslation = idna_convert(args.force_renew.split(' '))
|
|
|
|
if len(domaintranslation) > 0:
|
2019-03-31 22:50:16 +02:00
|
|
|
runtimeconfig['force_renew'] = [x for x, _ in domaintranslation]
|
2019-03-27 15:31:15 +01:00
|
|
|
else:
|
2019-03-28 13:59:44 +01:00
|
|
|
runtimeconfig['force_renew'] = args.force_renew.split(' ')
|
2019-03-27 15:31:15 +01:00
|
|
|
|
2019-03-27 21:00:21 +01:00
|
|
|
# - revoke
|
|
|
|
if args.revoke:
|
|
|
|
runtimeconfig['mode'] = 'revoke'
|
|
|
|
runtimeconfig['revoke'] = args.revoke
|
|
|
|
runtimeconfig['revoke_reason'] = args.revoke_reason
|
|
|
|
|
2019-03-27 15:17:17 +01:00
|
|
|
# Global configuration: Load from file
|
2019-02-20 11:57:15 +01:00
|
|
|
globalconfig = dict()
|
|
|
|
if os.path.isfile(global_config_file):
|
|
|
|
with io.open(global_config_file) as config_fd:
|
2019-01-22 09:05:41 +01:00
|
|
|
try:
|
|
|
|
globalconfig = json.load(config_fd)
|
|
|
|
except ValueError:
|
|
|
|
import yaml
|
|
|
|
config_fd.seek(0)
|
2019-02-22 10:31:28 +01:00
|
|
|
globalconfig = yaml.safe_load(config_fd)
|
2019-03-23 08:39:17 +01:00
|
|
|
if global_config_file == LEGACY_CONF_FILE:
|
|
|
|
if 'api' not in globalconfig:
|
|
|
|
globalconfig['api'] = LEGACY_API
|
|
|
|
if 'authority' not in globalconfig:
|
|
|
|
globalconfig['authority'] = LEGACY_AUTHORITY
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-03-27 15:17:17 +01:00
|
|
|
# Domain configuration(s): Load from file(s)
|
|
|
|
domainconfigs = list()
|
2019-02-20 11:57:15 +01:00
|
|
|
if os.path.isdir(domain_config_dir):
|
|
|
|
for domain_config_file in os.listdir(domain_config_dir):
|
2019-03-19 02:07:17 +01:00
|
|
|
domain_config_file = os.path.join(domain_config_dir, domain_config_file)
|
2019-02-20 11:57:15 +01:00
|
|
|
# check file extension and skip if global config file
|
2019-03-19 02:07:17 +01:00
|
|
|
if domain_config_file.endswith(".conf") and \
|
|
|
|
os.path.abspath(domain_config_file) != os.path.abspath(global_config_file):
|
|
|
|
with io.open(domain_config_file) as config_fd:
|
2019-02-20 11:57:15 +01:00
|
|
|
try:
|
|
|
|
for entry in json.load(config_fd).items():
|
2019-03-27 15:17:17 +01:00
|
|
|
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
|
2019-02-20 11:57:15 +01:00
|
|
|
except ValueError:
|
|
|
|
import yaml
|
|
|
|
config_fd.seek(0)
|
2019-02-22 10:31:28 +01:00
|
|
|
for entry in yaml.safe_load(config_fd).items():
|
2019-03-27 15:17:17 +01:00
|
|
|
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
|
2019-01-22 09:05:41 +01:00
|
|
|
|
2019-03-27 15:17:17 +01:00
|
|
|
return runtimeconfig, domainconfigs
|