2016-02-13 00:56:47 +01:00
|
|
|
#!/usr/bin/env python
|
2016-01-10 15:00:43 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
# Automated Certificate Manager using ACME
|
2016-04-04 01:17:58 +02:00
|
|
|
# Copyright (c) Markus Hauschild & David Klaftenegger, 2016.
|
2019-01-08 08:12:20 +01:00
|
|
|
# Copyright (c) Rudolf Mayerhofer, 2019.
|
2016-04-04 01:17:58 +02:00
|
|
|
# available under the ISC license, see LICENSE
|
2016-01-10 15:00:43 +01:00
|
|
|
|
2019-02-18 20:45:28 +01:00
|
|
|
import io
|
2016-01-10 15:00:43 +01:00
|
|
|
import os
|
2016-04-10 02:45:29 +02:00
|
|
|
import stat
|
2019-01-08 08:12:20 +01:00
|
|
|
import subprocess
|
2019-10-25 18:03:50 +02:00
|
|
|
import sys
|
2019-01-08 08:12:20 +01:00
|
|
|
|
2019-02-20 11:49:30 +01:00
|
|
|
from acertmgr import configuration, tools
|
2019-03-28 09:18:28 +01:00
|
|
|
from acertmgr.authority import authority
|
|
|
|
from acertmgr.modes import challenge_handler
|
2021-09-15 14:38:01 +02:00
|
|
|
from acertmgr.tools import log, LOG_REPLACEMENTS
|
2019-01-08 08:19:50 +01:00
|
|
|
|
2019-10-25 18:03:50 +02:00
|
|
|
try:
|
|
|
|
import pwd
|
|
|
|
import grp
|
|
|
|
except ImportError:
|
|
|
|
# Warnings will be reported upon usage below
|
|
|
|
pass
|
|
|
|
|
2019-01-08 08:19:50 +01:00
|
|
|
|
2016-01-10 15:43:11 +01:00
|
|
|
# @brief fetch new certificate from letsencrypt
|
2019-01-22 09:05:41 +01:00
|
|
|
# @param settings the domain's configuration options
|
|
|
|
def cert_get(settings):
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Getting certificate for %s" % settings['domainlist'])
|
2019-01-08 08:12:20 +01:00
|
|
|
|
2019-03-28 09:18:28 +01:00
|
|
|
acme = authority(settings['authority'])
|
2019-03-24 17:15:30 +01:00
|
|
|
acme.register_account()
|
2019-01-08 08:12:20 +01:00
|
|
|
|
2019-03-24 17:15:30 +01:00
|
|
|
# create challenge handlers for this certificate
|
2019-01-08 08:19:50 +01:00
|
|
|
challenge_handlers = dict()
|
2019-02-18 20:43:13 +01:00
|
|
|
for domain in settings['domainlist']:
|
2019-01-08 08:19:50 +01:00
|
|
|
# Create the challenge handler
|
2019-03-28 09:18:28 +01:00
|
|
|
challenge_handlers[domain] = challenge_handler(settings['handlers'][domain])
|
2019-01-08 08:12:20 +01:00
|
|
|
|
2019-03-24 17:15:30 +01:00
|
|
|
# create ssl key
|
|
|
|
key_file = settings['key_file']
|
|
|
|
if os.path.isfile(key_file):
|
|
|
|
key = tools.read_pem_file(key_file, key=True)
|
|
|
|
else:
|
2019-04-19 15:16:06 +02:00
|
|
|
log("SSL key not found at '{0}'. Creating key.".format(key_file))
|
|
|
|
key = tools.new_ssl_key(key_file, settings['key_algorithm'], settings['key_length'])
|
2019-03-24 17:15:30 +01:00
|
|
|
|
|
|
|
# create ssl csr
|
2019-03-24 17:22:04 +01:00
|
|
|
csr_file = settings['csr_file']
|
|
|
|
if os.path.isfile(csr_file) and str(settings['csr_static']).lower() == 'true':
|
2019-04-04 13:15:34 +02:00
|
|
|
log('Loading CSR from {}'.format(csr_file))
|
2019-03-24 17:22:04 +01:00
|
|
|
cr = tools.read_pem_file(csr_file, csr=True)
|
|
|
|
else:
|
2019-04-04 13:15:34 +02:00
|
|
|
log('Generating CSR for {}'.format(settings['domainlist']))
|
2019-03-29 08:37:50 +01:00
|
|
|
must_staple = str(settings.get('cert_must_staple')).lower() == "true"
|
|
|
|
cr = tools.new_cert_request(settings['domainlist'], key, must_staple)
|
2019-03-24 17:22:04 +01:00
|
|
|
tools.write_pem_file(cr, csr_file)
|
2019-03-24 17:15:30 +01:00
|
|
|
|
|
|
|
# request cert with csr
|
|
|
|
crt, ca = acme.get_crt_from_csr(cr, settings['domainlist'], challenge_handlers)
|
|
|
|
|
|
|
|
# if resulting certificate is valid: store in final location
|
|
|
|
if tools.is_cert_valid(crt, settings['ttl_days']):
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Certificate '{}' renewed and valid until {}".format(tools.get_cert_cn(crt),
|
|
|
|
tools.get_cert_valid_until(crt)))
|
2019-03-24 17:15:30 +01:00
|
|
|
tools.write_pem_file(crt, settings['cert_file'], stat.S_IREAD)
|
2019-03-28 12:33:59 +01:00
|
|
|
if (not str(settings.get('ca_static')).lower() == 'true' or not os.path.exists(settings['ca_file'])) \
|
|
|
|
and ca is not None:
|
2019-03-24 17:15:30 +01:00
|
|
|
tools.write_pem_file(ca, settings['ca_file'])
|
2016-01-10 18:07:00 +01:00
|
|
|
|
2016-01-10 17:29:26 +01:00
|
|
|
|
2016-01-12 17:41:48 +01:00
|
|
|
# @brief put new certificate in place
|
2016-01-10 17:29:26 +01:00
|
|
|
# @param settings the domain's configuration options
|
2016-02-21 11:18:32 +01:00
|
|
|
# @return the action to be executed after the certificate update
|
2019-01-08 08:12:20 +01:00
|
|
|
def cert_put(settings):
|
2019-04-05 09:59:29 +02:00
|
|
|
if 'path' not in settings:
|
|
|
|
raise ValueError('Deployment settings are missing required element: path')
|
|
|
|
if 'format' not in settings:
|
|
|
|
raise ValueError('Deployment settings are missing required element: format')
|
2019-01-08 08:12:20 +01:00
|
|
|
|
2019-04-05 09:59:29 +02:00
|
|
|
with io.open(settings['path'], "w+") as crt_fd:
|
|
|
|
for fmt in [str.strip(x) for x in settings['format'].split(",")]:
|
2019-01-08 08:12:20 +01:00
|
|
|
if fmt == "crt":
|
2019-04-05 09:59:29 +02:00
|
|
|
with io.open(settings['cert_file'], "r") as src_fd:
|
|
|
|
crt_fd.write(src_fd.read())
|
|
|
|
elif fmt == "key":
|
|
|
|
with io.open(settings['key_file'], "r") as src_fd:
|
|
|
|
crt_fd.write(src_fd.read())
|
|
|
|
elif fmt == "ca":
|
|
|
|
with io.open(settings['ca_file'], "r") as src_fd:
|
|
|
|
crt_fd.write(src_fd.read())
|
2019-01-08 08:12:20 +01:00
|
|
|
else:
|
2019-04-05 09:59:29 +02:00
|
|
|
log("Ignored unknown deployment format key: {}".format(fmt), warning=True)
|
|
|
|
|
|
|
|
# set owner and group
|
|
|
|
if 'user' in settings or 'group' in settings:
|
2019-10-25 18:03:50 +02:00
|
|
|
if 'pwd' in sys.modules and 'grp' in sys.modules and hasattr(os, 'chown') and hasattr(os, 'geteuid') and \
|
|
|
|
hasattr(os, 'getegid'):
|
|
|
|
try:
|
|
|
|
uid = pwd.getpwnam(settings['user']).pw_uid if 'user' in settings else os.geteuid()
|
|
|
|
gid = grp.getgrnam(settings['group']).gr_gid if 'group' in settings else os.getegid()
|
|
|
|
os.chown(settings['path'], uid, gid)
|
|
|
|
except OSError as e:
|
|
|
|
log('Could not set certificate file ownership', e, warning=True)
|
|
|
|
else:
|
|
|
|
log('File user and group handling unavailable on this platform', warning=True)
|
2019-04-05 09:59:29 +02:00
|
|
|
# set permissions
|
|
|
|
if 'perm' in settings:
|
2019-10-25 18:03:50 +02:00
|
|
|
if hasattr(os, 'chmod'):
|
|
|
|
try:
|
|
|
|
os.chmod(settings['path'], int(settings['perm'], 8))
|
|
|
|
except OSError as e:
|
|
|
|
log('Could not set certificate file permissions', e, warning=True)
|
|
|
|
else:
|
|
|
|
log('File permission handling unavailable on this platform', warning=True)
|
2019-04-05 09:59:29 +02:00
|
|
|
|
|
|
|
return settings['action']
|
2016-01-10 15:00:43 +01:00
|
|
|
|
|
|
|
|
2019-04-03 12:34:25 +02:00
|
|
|
def cert_revoke(cert, configs, fallback_authority, reason=None):
|
2019-03-27 21:00:21 +01:00
|
|
|
domains = set(tools.get_cert_domains(cert))
|
2019-04-03 12:34:25 +02:00
|
|
|
acmeconfig = None
|
2019-03-27 21:00:21 +01:00
|
|
|
for config in configs:
|
|
|
|
if domains == set(config['domainlist']):
|
2019-04-03 12:34:25 +02:00
|
|
|
acmeconfig = config['authority']
|
|
|
|
break
|
|
|
|
if not acmeconfig:
|
|
|
|
acmeconfig = fallback_authority
|
|
|
|
log("No matching authority found to revoke {}: {}, using globalconfig/defaults".format(tools.get_cert_cn(cert),
|
2020-03-04 13:47:32 +01:00
|
|
|
tools.get_cert_domains(
|
|
|
|
cert)), warning=True)
|
2019-04-03 12:34:25 +02:00
|
|
|
acme = authority(acmeconfig)
|
|
|
|
acme.register_account()
|
|
|
|
acme.revoke_crt(cert, reason)
|
2019-03-27 21:00:21 +01:00
|
|
|
|
|
|
|
|
2019-02-20 11:49:30 +01:00
|
|
|
def main():
|
2019-01-22 09:05:41 +01:00
|
|
|
# load config
|
2019-03-27 15:17:17 +01:00
|
|
|
runtimeconfig, domainconfigs = configuration.load()
|
2021-09-15 14:38:01 +02:00
|
|
|
# register idna-mapped domains as LOG_REPLACEMENTS for better readability of log output
|
2023-03-28 21:48:58 +02:00
|
|
|
for domainconfig in domainconfigs:
|
|
|
|
LOG_REPLACEMENTS.update({k: "{} [{}]".format(k, v) for k, v in domainconfig['domainlist_idna_mapped'].items()})
|
2021-09-15 14:38:01 +02:00
|
|
|
# Start processing
|
2019-03-27 21:00:21 +01:00
|
|
|
if runtimeconfig.get('mode') == 'revoke':
|
|
|
|
# Mode: revoke certificate
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Revoking {}".format(runtimeconfig['revoke']))
|
2019-04-03 12:34:25 +02:00
|
|
|
cert_revoke(tools.read_pem_file(runtimeconfig['revoke']),
|
|
|
|
domainconfigs,
|
|
|
|
runtimeconfig['fallback_authority'],
|
|
|
|
runtimeconfig['revoke_reason'])
|
2019-03-27 21:00:21 +01:00
|
|
|
else:
|
|
|
|
# Mode: issue certificates (implicit)
|
|
|
|
# post-update actions (run only once)
|
|
|
|
actions = set()
|
2019-03-28 00:34:53 +01:00
|
|
|
superseded = set()
|
2019-03-28 14:34:06 +01:00
|
|
|
exceptions = list()
|
2019-03-27 21:00:21 +01:00
|
|
|
# check certificate validity and obtain/renew certificates if needed
|
|
|
|
for config in domainconfigs:
|
2019-03-28 14:34:06 +01:00
|
|
|
try:
|
|
|
|
cert = None
|
|
|
|
if os.path.isfile(config['cert_file']):
|
|
|
|
cert = tools.read_pem_file(config['cert_file'])
|
2020-03-04 13:47:32 +01:00
|
|
|
validate_ocsp = str(config.get('validate_ocsp')).lower() != 'false'
|
|
|
|
if validate_ocsp and cert and os.path.isfile(config['ca_file']):
|
|
|
|
try:
|
|
|
|
issuer = tools.read_pem_file(config['ca_file'])
|
|
|
|
except Exception as e1:
|
|
|
|
log("Failed to retrieve issuer from ca file: {}. Trying to download...".format(e1))
|
|
|
|
try:
|
|
|
|
issuer = tools.download_issuer_ca(cert)
|
|
|
|
except Exception as e2:
|
|
|
|
log("Failed to download issuer for cert file: {}. Cannot validate OCSP.".format(e2))
|
|
|
|
validate_ocsp = False
|
|
|
|
if not cert or ('force_renew' in runtimeconfig and all(
|
|
|
|
d in config['domainlist'] for d in runtimeconfig['force_renew'])) \
|
|
|
|
or not tools.is_cert_valid(cert, config['ttl_days']) \
|
|
|
|
or (validate_ocsp and not tools.is_ocsp_valid(cert, issuer, config['validate_ocsp'])):
|
2019-03-28 14:34:06 +01:00
|
|
|
cert_get(config)
|
|
|
|
if str(config.get('cert_revoke_superseded')).lower() == 'true' and cert:
|
|
|
|
superseded.add(cert)
|
|
|
|
except Exception as e:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Certificate issue/renew failed", e, error=True)
|
2019-03-28 14:34:06 +01:00
|
|
|
exceptions.append(e)
|
2019-03-27 21:00:21 +01:00
|
|
|
|
2019-03-28 00:20:55 +01:00
|
|
|
# deploy new certificates after all are renewed
|
2019-03-28 14:34:06 +01:00
|
|
|
deployment_success = True
|
2019-03-28 00:20:55 +01:00
|
|
|
for config in domainconfigs:
|
2019-04-05 09:59:29 +02:00
|
|
|
for cfg in config['actions']:
|
|
|
|
try:
|
2019-03-28 14:34:06 +01:00
|
|
|
if not tools.target_is_current(cfg['path'], config['cert_file']):
|
|
|
|
actions.add(cert_put(cfg))
|
2019-04-05 09:59:29 +02:00
|
|
|
log("Updated '{}' due to newer version".format(cfg['path']))
|
|
|
|
except Exception as e:
|
|
|
|
log("Certificate deployment to {} failed".format(cfg['path']), e, error=True)
|
|
|
|
exceptions.append(e)
|
|
|
|
deployment_success = False
|
2019-03-27 21:00:21 +01:00
|
|
|
|
|
|
|
# run post-update actions
|
|
|
|
for action in actions:
|
|
|
|
if action is not None:
|
|
|
|
try:
|
|
|
|
# Run actions in a shell environment (to allow shell syntax) as stated in the configuration
|
|
|
|
output = subprocess.check_output(action, shell=True, stderr=subprocess.STDOUT)
|
2019-04-02 10:57:57 +02:00
|
|
|
logmsg = "Action succeeded: {}".format(action)
|
|
|
|
if len(output) > 0:
|
|
|
|
if getattr(output, 'decode', None):
|
|
|
|
# Decode function available? Use it to get a proper str
|
|
|
|
output = output.decode('utf-8')
|
|
|
|
logmsg += os.linesep + tools.indent(output, 18) # 18 = len("Action succeeded: ")
|
|
|
|
log(logmsg)
|
2019-03-27 21:00:21 +01:00
|
|
|
except subprocess.CalledProcessError as e:
|
2019-04-02 10:57:57 +02:00
|
|
|
output = e.output
|
|
|
|
logmsg = "Action failed: ({}) {}".format(e.returncode, e.cmd)
|
|
|
|
if len(output) > 0:
|
|
|
|
if getattr(output, 'decode', None):
|
|
|
|
# Decode function available? Use it to get a proper str
|
|
|
|
output = output.decode('utf-8')
|
|
|
|
logmsg += os.linesep + tools.indent(output, 15) # 15 = len("Action failed: ")
|
|
|
|
log(logmsg, error=True)
|
2019-03-28 14:34:06 +01:00
|
|
|
exceptions.append(e)
|
|
|
|
deployment_success = False
|
2019-03-28 00:34:53 +01:00
|
|
|
|
|
|
|
# revoke old certificates as superseded
|
2019-03-28 14:34:06 +01:00
|
|
|
if deployment_success:
|
2019-03-28 00:34:53 +01:00
|
|
|
for superseded_cert in superseded:
|
2019-03-28 14:34:06 +01:00
|
|
|
try:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Revoking '{}' valid until {} as superseded".format(
|
|
|
|
tools.get_cert_cn(superseded_cert),
|
|
|
|
tools.get_cert_valid_until(superseded_cert)))
|
2019-04-03 12:34:25 +02:00
|
|
|
cert_revoke(superseded_cert, domainconfigs, runtimeconfig['fallback_authority'], reason=4)
|
2019-03-28 14:34:06 +01:00
|
|
|
except Exception as e:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Certificate supersede revoke failed", e, error=True)
|
2019-03-28 14:34:06 +01:00
|
|
|
exceptions.append(e)
|
|
|
|
|
|
|
|
# throw a RuntimeError with all exceptions caught while working if there were any
|
|
|
|
if len(exceptions) > 0:
|
2019-04-04 13:15:34 +02:00
|
|
|
raise RuntimeError("{} exception(s) occurred during processing".format(len(exceptions)))
|