mirror of
https://github.com/moepman/acertmgr.git
synced 2024-06-01 13:22:34 +02:00
79b625619a
If no configuration matching the domains in the given certificate exist use the globalconfig/default settings for an authority to revoke the certificate (which might still fail if things do not match up, but the authority will decide on that) Configuration parsing for the authority settings is therefore split into a seperate function which will be called for the 'fallback_authority' element in runtimeconfig.
322 lines
14 KiB
Python
322 lines
14 KiB
Python
#!/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
|
|
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
from acertmgr.tools import log
|
|
|
|
try:
|
|
import idna
|
|
except ImportError:
|
|
pass
|
|
|
|
# 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")
|
|
LEGACY_API = "v1"
|
|
LEGACY_AUTHORITY = "https://acme-v01.api.letsencrypt.org"
|
|
LEGACY_AUTHORITY_TOS_AGREEMENT = "true"
|
|
|
|
# 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 = 30 # days
|
|
DEFAULT_API = "v2"
|
|
DEFAULT_AUTHORITY = "https://acme-v02.api.letsencrypt.org"
|
|
|
|
|
|
# @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
|
|
|
|
|
|
# @brief update config[name] with value from localconfig>globalconfig>default
|
|
def update_config_value(config, name, localconfig, globalconfig, default):
|
|
values = [x[name] for x in localconfig if name in x]
|
|
if len(values) > 0:
|
|
config[name] = values[0]
|
|
else:
|
|
config[name] = globalconfig.get(name, default)
|
|
|
|
|
|
# @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)):
|
|
domaintranslation = list()
|
|
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')
|
|
result = idna_domain, domain
|
|
else:
|
|
result = domain, domain
|
|
domaintranslation.append(result)
|
|
return domaintranslation
|
|
else:
|
|
if 'idna' not in sys.modules:
|
|
log("Unicode domain(s) found but IDNA names could not be translated due to missing idna module", error=True)
|
|
return list()
|
|
|
|
|
|
# @brief parse authority from config
|
|
def parse_authority(localconfig, globalconfig, runtimeconfig):
|
|
authority = {}
|
|
# - API version
|
|
update_config_value(authority, 'api', localconfig, globalconfig, DEFAULT_API)
|
|
|
|
# - Certificate authority
|
|
update_config_value(authority, 'authority', localconfig, globalconfig, DEFAULT_AUTHORITY)
|
|
|
|
# - Certificate authority ToS agreement
|
|
update_config_value(authority, 'authority_tos_agreement', localconfig, globalconfig,
|
|
runtimeconfig['authority_tos_agreement'])
|
|
|
|
# - Certificate authority contact email addresses
|
|
update_config_value(authority, 'authority_contact_email', localconfig, globalconfig, None)
|
|
|
|
# - Account key path
|
|
update_config_value(authority, 'account_key', localconfig, globalconfig,
|
|
os.path.join(runtimeconfig['work_dir'], "account.key"))
|
|
return authority
|
|
|
|
|
|
# @brief load the configuration from a file
|
|
def parse_config_entry(entry, globalconfig, runtimeconfig):
|
|
config = dict()
|
|
|
|
# Basic domain information
|
|
domains, localconfig = entry
|
|
config['domainlist'] = domains.split(' ')
|
|
config['id'] = hashlib.md5(domains.encode('utf-8')).hexdigest()
|
|
|
|
# Convert unicode to IDNA domains
|
|
config['domaintranslation'] = idna_convert(config['domainlist'])
|
|
if len(config['domaintranslation']) > 0:
|
|
config['domainlist'] = [x for x, _ in config['domaintranslation']]
|
|
|
|
# Action config defaults
|
|
config['defaults'] = globalconfig.get('defaults', {})
|
|
|
|
# Authority related config options
|
|
config['authority'] = parse_authority(localconfig, globalconfig, runtimeconfig)
|
|
|
|
# Certificate directory
|
|
update_config_value(config, 'cert_dir', localconfig, globalconfig, runtimeconfig['work_dir'])
|
|
|
|
# TTL days
|
|
update_config_value(config, 'ttl_days', localconfig, globalconfig, DEFAULT_TTL)
|
|
config['ttl_days'] = int(config['ttl_days'])
|
|
|
|
# Revoke old certificate with reason superseded after renewal
|
|
update_config_value(config, 'cert_revoke_superseded', localconfig, globalconfig, "false")
|
|
|
|
# Whether to include request for OCSP must-staple in the certificate
|
|
update_config_value(config, 'cert_must_staple', localconfig, globalconfig, "false")
|
|
|
|
# Use a static cert request
|
|
update_config_value(config, 'csr_static', localconfig, globalconfig, "false")
|
|
|
|
# SSL cert request location
|
|
update_config_value(config, 'csr_file', localconfig, globalconfig,
|
|
os.path.join(config['cert_dir'], "{}.csr".format(config['id'])))
|
|
|
|
# SSL cert location (with compatibility to older versions)
|
|
if 'server_cert' in globalconfig:
|
|
log("Legacy configuration directive 'server_cert' used. Support will be removed in 1.0", warning=True)
|
|
update_config_value(config, 'cert_file', localconfig, globalconfig,
|
|
globalconfig.get('server_cert',
|
|
os.path.join(config['cert_dir'], "{}.crt".format(config['id']))))
|
|
|
|
# SSL key location (with compatibility to older versions)
|
|
if 'server_key' in globalconfig:
|
|
log("Legacy configuration directive 'server_key' used. Support will be removed in 1.0", warning=True)
|
|
update_config_value(config, 'key_file', localconfig, globalconfig,
|
|
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)
|
|
update_config_value(config, 'key_length', localconfig, globalconfig, DEFAULT_KEY_LENGTH)
|
|
config['key_length'] = int(config['key_length'])
|
|
|
|
# SSL CA location / use static
|
|
update_config_value(config, 'ca_file', localconfig, globalconfig,
|
|
globalconfig.get('server_ca', config['defaults'].get('server_ca',
|
|
os.path.join(config['cert_dir'],
|
|
"{}.ca".format(
|
|
config['id'])))))
|
|
update_config_value(config, 'ca_static', localconfig, globalconfig, "false")
|
|
if 'server_ca' in globalconfig or 'server_ca' in config['defaults']:
|
|
config['ca_static'] = "true"
|
|
log("Legacy configuration directive 'server_ca' used. Support removed in 1.0", warning=True)
|
|
|
|
# Domain action configuration
|
|
config['actions'] = list()
|
|
for actioncfg in [x for x in localconfig if 'path' in x]:
|
|
config['actions'].append(complete_action_config(actioncfg, config))
|
|
|
|
# Domain challenge handler configuration
|
|
config['handlers'] = dict()
|
|
handlerconfigs = [x for x in localconfig if 'mode' in x]
|
|
_domaintranslation_dict = {x: y for x, y in config.get('domaintranslation', [])}
|
|
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])
|
|
|
|
# Update handler config with more specific values (use original names for translated unicode domains)
|
|
_domain = _domaintranslation_dict.get(domain, domain)
|
|
specificcfgs = [x for x in handlerconfigs if 'domain' in x and x['domain'] == _domain]
|
|
if len(specificcfgs) > 0:
|
|
cfg.update(specificcfgs[0])
|
|
|
|
config['handlers'][domain] = cfg
|
|
|
|
return config
|
|
|
|
|
|
# @brief load the configuration from a file
|
|
def load():
|
|
runtimeconfig = 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)")
|
|
parser.add_argument("--authority-tos-agreement", "--tos-agreement", "--tos", nargs="?",
|
|
help="Agree to the authorities Terms of Service (value required depends on authority)")
|
|
parser.add_argument("--force-renew", "--renew-now", nargs="?",
|
|
help="Renew all domain configurations matching the given value immediately")
|
|
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")
|
|
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):
|
|
log("Legacy config file '{}' used. Move to '{}' for 1.0".format(LEGACY_CONF_FILE, DEFAULT_CONF_FILE),
|
|
warning=True)
|
|
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):
|
|
log("Legacy config dir '{}' used. Move to '{}' for 1.0".format(LEGACY_CONF_DIR, DEFAULT_CONF_DIR), warning=True)
|
|
domain_config_dir = LEGACY_CONF_DIR
|
|
else:
|
|
domain_config_dir = DEFAULT_CONF_DIR
|
|
|
|
# Runtime configuration: Get from command-line options
|
|
# - work_dir
|
|
if args.work_dir:
|
|
runtimeconfig['work_dir'] = args.work_dir
|
|
elif os.path.isdir(LEGACY_WORK_DIR) and domain_config_dir == LEGACY_CONF_DIR:
|
|
log("Legacy work dir '{}' used. Move to config-dir for 1.0".format(LEGACY_WORK_DIR), warning=True)
|
|
runtimeconfig['work_dir'] = LEGACY_WORK_DIR
|
|
else:
|
|
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))
|
|
|
|
# - authority_tos_agreement
|
|
if args.authority_tos_agreement:
|
|
runtimeconfig['authority_tos_agreement'] = args.authority_tos_agreement
|
|
elif global_config_file == LEGACY_CONF_FILE:
|
|
# Legacy global config file assumes ToS are agreed
|
|
runtimeconfig['authority_tos_agreement'] = LEGACY_AUTHORITY_TOS_AGREEMENT
|
|
else:
|
|
runtimeconfig['authority_tos_agreement'] = None
|
|
|
|
# - force-rewew
|
|
if args.force_renew:
|
|
domaintranslation = idna_convert(args.force_renew.split(' '))
|
|
if len(domaintranslation) > 0:
|
|
runtimeconfig['force_renew'] = [x for x, _ in domaintranslation]
|
|
else:
|
|
runtimeconfig['force_renew'] = args.force_renew.split(' ')
|
|
|
|
# - revoke
|
|
if args.revoke:
|
|
runtimeconfig['mode'] = 'revoke'
|
|
runtimeconfig['revoke'] = args.revoke
|
|
runtimeconfig['revoke_reason'] = args.revoke_reason
|
|
|
|
# Global configuration: Load from file
|
|
globalconfig = dict()
|
|
if os.path.isfile(global_config_file):
|
|
with io.open(global_config_file) as config_fd:
|
|
try:
|
|
globalconfig = json.load(config_fd)
|
|
except ValueError:
|
|
import yaml
|
|
config_fd.seek(0)
|
|
globalconfig = yaml.safe_load(config_fd)
|
|
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
|
|
|
|
# Domain configuration(s): Load from file(s)
|
|
domainconfigs = list()
|
|
if os.path.isdir(domain_config_dir):
|
|
for domain_config_file in os.listdir(domain_config_dir):
|
|
domain_config_file = os.path.join(domain_config_dir, domain_config_file)
|
|
# check file extension and skip if global config file
|
|
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:
|
|
try:
|
|
for entry in json.load(config_fd).items():
|
|
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
|
|
except ValueError:
|
|
import yaml
|
|
config_fd.seek(0)
|
|
for entry in yaml.safe_load(config_fd).items():
|
|
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
|
|
|
|
# Define a fallback authority from global configuration / defaults
|
|
runtimeconfig['fallback_authority'] = parse_authority([], globalconfig, runtimeconfig)
|
|
|
|
return runtimeconfig, domainconfigs
|