Compare commits

...

37 Commits

Author SHA1 Message Date
Markus 9ca6dae048 version: bump to 1.0.5 2023-07-13 15:36:44 +02:00
Kishi85 274351415d Update github action workflow to build debian packages with gzip format for older OS versions 2023-07-12 17:46:06 +02:00
Rudolf Mayerhofer f78cb5c554 Github Action: Update to newer github action syntax/standards, change image to ubuntu-latest, change pypi-publish to supported version and check if we have credentials to publish at all 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer c3736c0838 Allow multiple sets of the same domain to defined in a single config file (necessary for multiple certs using different key_algorithm) in a list style notation (lists of maps) 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer 1a98f86aad Fix idna conversion for force-renew (probably broken since the IDNA cleanup) 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer ef81ea62d1 Unify key_algorithm handling for elipic curves (change naming to ECC but stay backwards compatible) 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer d1caaf80ef Fix LOG_REPLACEMENTS determination when multiple domain sets exist and we are on a newer version of python 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer ba644d44f1 Update config id if we have a key algorithm set to allow for multiple certs with different algorithms for the same set of domains
This is a breaking change!
Changes the id for configurations with a key algorithm set, which by default results in changes to serveral dependent configuration values as well,
such as cert_file/key_file/csr_file. This will require existing ECC setups to append the ecc suffix to files in the acertmgr configuration directory
2023-07-12 16:10:21 +02:00
Jan c15b6ec441 Instantiate HashAlgorithm in OCSPRequestBuilder
Installations of more recent cryptography require parameter hash
algorithm to be an instance of hashes.HashAlgorithm, not the bare object
itself.

Fixes #63
2023-07-10 19:27:44 +02:00
Kishi85 2d230e30d9 Clarify expected authority format (at least for v2) and add an example 2021-10-31 09:57:31 +01:00
Kishi85 6f0ccfdc91 logging: Add real counterparts of IDNA-mapped domains in brackets 2021-09-20 09:26:47 +02:00
Kishi85 460b0119ac configuration: Simplify too complex IDNA conversion 2021-09-13 09:00:59 +02:00
David Klaftenegger e2f7b09b18 certs already contain idna domain names
The idna_convert call here does nothing: when reading a certificate, it
already contains idna domain names. Converting them to idna is
equivalent to the identity function, and can thus be removed.
2021-05-30 16:21:54 +02:00
Markus 93e28437ff version: bump to 1.0.4 2021-05-21 22:52:34 +02:00
Kishi85 2e1f5cd894 acertmgr/v2: Handle CA certificate chains properly 2021-05-21 22:50:44 +02:00
Kishi85 ce157a5c8a CI: Build on Ubuntu 18.04 while we are Python 2 compatbile and OS version is not EOL 2021-03-23 18:43:07 +01:00
Kishi85 9953cb4527 standalone: Fix multiple challange handlers on same port
If you define challenge handlers on a per-domain basis multiple will be
created. This would cause the standalone handler to potientially try
to bind the same port (when configured) multiple times, which would only
work on the first try. Subsequent tries would fail with "Address already
in use". To fix this only bind the server between start and stop of the
challenge and cleanup afterwards.
2021-03-23 18:43:07 +01:00
Markus 7a5d35f29b GitHub Actions: use current setuptools and wheel 2020-10-12 19:22:02 +02:00
Markus 62f01aeff9 GitHub Actions: twine upload via pypa/gh-action-pypi-publish 2020-10-12 19:01:09 +02:00
Markus b48f4532b9 reformat setup.py 2020-10-12 17:48:55 +02:00
Markus bc2a7229ec GitHub Actions: unify whitespace style 2020-10-12 17:22:52 +02:00
Markus fd4fed9432 version: bump to 1.0.3 2020-03-12 18:41:15 +01:00
Markus 56743dcbb9 GitHub Actions: fix fetching tags 2020-03-04 17:29:11 +01:00
Kishi85 0648cb7b38 tools: Fix IDNA handler (again) 2020-03-04 14:50:05 +01:00
Kishi85 b37d0cad94 acertmgr: Add a OCSP validation to certificate verification 2020-03-04 14:50:05 +01:00
Kishi85 c33a39a433 tools: make pem file writable by owner before tryting to write
A PEM file might not be writable by the owner when it should be written
(e.g. on Windows), so we have to ensure the file has write permissions
before doing so
2020-03-04 14:40:49 +01:00
Kishi85 882ddfd0b8 Generate proper dependencies on deb Packages 2020-02-20 18:40:22 +01:00
Kishi e5edc4e5aa Use Github Actions for automated building and release 2020-02-20 18:35:41 +01:00
Markus e48724b726 version: bump to 1.0.2 2019-11-23 15:37:07 +01:00
Markus 6314f468c1 setup.py: fix package name for yaml 2019-11-08 19:40:05 +01:00
Kishi85 97e9be80cf acertmgr: Fix module/function issues on windows 2019-10-28 10:50:09 +01:00
Kishi85 f5f038d47b configuration: global config is now relative to config_dir 2019-10-26 19:11:33 +02:00
Markus a0a4b0bf07 version: bump to 1.0.1 2019-10-01 13:08:45 +02:00
Markus a63eabd0ee .drone.yml: upload releases to PyPI 2019-10-01 13:08:10 +02:00
Markus 2911e05165 setup.py: use proper PyPI supported classifiers 2019-10-01 13:06:37 +02:00
Markus 8dad549d68 version: bump to 1.0.0 2019-09-23 14:57:29 +02:00
Markus 11d43d4817 build packages via drone.io 2019-09-23 14:57:12 +02:00
10 changed files with 399 additions and 145 deletions

114
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,114 @@
---
name: Build (and release)
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt update -qq -y
sudo apt install -qq -y build-essential fakeroot git python-all python3-cryptography python3-pip python3-stdeb python3-wheel twine dh-python
sudo pip3 install --upgrade setuptools wheel
- name: Prepare build process
id: buildprep
run: |
# Fetch tags and determine version
git fetch --tags -f
VER="$(python3 setup.py --version)"
echo "Version found: $VER"
echo "version=$VER" >> $GITHUB_OUTPUT
- name: Build python package using setuptools (source/wheel)
run: |
python3 setup.py sdist --formats=gztar
python3 setup.py bdist_wheel
- name: Prepare stdeb build process
id: stdebprep
run: |
# Patch setup.py to allow stdeb proper debian style builds
sed "s/=determine_version()/='$(python3 setup.py --version)'/gi" -i setup.py
sed "s@('readme'@('share/doc/python3-acertmgr'@" -i setup.py
# Determine recommended dependencies for deb package
echo "recommends3=$(echo "python3-pkg-resources")" >> $GITHUB_OUTPUT
# Find optional dependencies to suggest in deb package
echo "suggests3=$(python3 -c "from setup import extra_requirements; print('\n'.join(['\n'.join(x) for x in extra_requirements.values()]))" | grep -v cryptography | sed 's/PyYAML/yaml/gi' | awk '{ printf("python3-%s, ",$1)};' | awk '{$1=$1; print}')" >> $GITHUB_OUTPUT
- name: Build debian package using setuptools and stdeb
run: |
# Create debianized source directory
python3 setup.py --command-packages=stdeb.command sdist_dsc --with-python2=False --with-python3=True --recommends3="${{ steps.stdebprep.outputs.recommends3 }}" --suggests3="${{ steps.stdebprep.outputs.suggests3 }}"
# Enter debianzied source directory (first sub-directory under deb_dist)
cd "$(find deb_dist -maxdepth 1 -type d | grep -v 'deb_dist$\|tmp_sdist_dsc' | head -n1)"
# Enforce GZIP compressed debian package info for older OS versions
echo -e 'override_dh_builddeb:\n\tdh_builddeb -- -Zgzip\n' >> debian/rules
# Build deb package
DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -rfakeroot -uc -us
- name: Create a changelog from git log since last non-pre/rc tag
run: |
# Determine current tag and last non-rc/pre tag
CTAG="$(git describe --tags --abbrev=0)"
LTAG="$(git tag | grep -B1 ${CTAG} | head -n1)"
while echo $LTAG | grep -q 'rc\|pre'; do
LTAG="$(git tag | grep -B1 ${LTAG} | head -n1)"
done
# Write changelog
echo "Changes since ${LTAG}:" > changelog.txt
git log --format=' * %s' ${LTAG}..${CTAG} >> changelog.txt
cat changelog.txt
- name: Collect files for artifact upload
run: |
mkdir -v artifacts
cp -v changelog.txt artifacts/
cp -v dist/*.tar.gz artifacts/
cp -v dist/*.whl artifacts/
cp -v deb_dist/*.deb artifacts/
- name: Upload build artifact
uses: actions/upload-artifact@v1
with:
name: ${{ format('acertmgr_build_{0}', steps.buildprep.outputs.version) }}
path: artifacts
- name: Create new GitHub release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
name: ${{ steps.buildprep.outputs.version }}
draft: true
prerelease: ${{ contains(github.ref, 'rc') || contains(github.ref, 'pre') }}
body_path: changelog.txt
files: |
artifacts/*.tar.gz
artifacts/*.whl
artifacts/*.deb
- name: Check PyPI secrets
id: checksecrets
shell: bash
run: |
if [ "$USER" == "" -o "$PASSWORD" == "" ]; then
echo "secretspresent=NO" >> $GITHUB_OUTPUT
else
echo "secretspresent=YES" >> $GITHUB_OUTPUT
fi
env:
USER: ${{ secrets.PYPI_USERNAME }}
PASSWORD: ${{ secrets.PYPI_PASSWORD }}
- name: Create new PyPI release
if: steps.checksecrets.outputs.secretspresent == 'YES' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (!(contains(github.ref, 'rc') || contains(github.ref, 'pre')))
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: ${{ secrets.PYPI_USERNAME }}
password: ${{ secrets.PYPI_PASSWORD }}

View File

@ -12,7 +12,7 @@ Requirements
------------
* Python (2.7+ and 3.5+ should work)
* cryptography>=0.6 (usually includes the optional idna module)
* cryptography>=0.6
Optional requirements (to use specified features)
------------------------------------------------------
@ -56,7 +56,7 @@ By default the directory (work_dir) containing the working data (csr,certificate
4 configuration contexts are known (*domainconfig (d) > globalconfig (g) > commandline (c) > built-in defaults*) with the following directives (subject to change, usual usage context written bold):
| Directive | Context | Description | Built-in Default |
| --- | --- | --- | --- |
| --- | --- |----------------------------------------------------------------------------------------------------------------------------------------------| --- |
| -c/--config-file | **c** | global configuration file (optional) | /etc/acertmgr/acertmgr.conf |
| -d/--config-dir | **c** | directory containing domain configuration files (ending with .conf, globalconfig will be excluded automatically if in same directory) | /etc/acertmgr/*.conf |
| -w/--work-dir | **c** | working directory containing csr/certificates/keys/ca files | /etc/acertmgr |
@ -65,15 +65,16 @@ By default the directory (work_dir) containing the working data (csr,certificate
| --revoke-reason | **c** | Provide a reason code for the revocation (see https://tools.ietf.org/html/rfc5280#section-5.3.1 for valid values) | |
| domain (san-domain...): | **d** | (domainconfig section start) Domains to use in the cert request. This value will be MD5-hashed as cert_id. | |
| api | d,**g** | Determines the API version used | v2 |
| authority | d,**g** | URL to the certificate authorities API | https://acme-v02.api.letsencrypt.org |
| authority | d,**g** | URL to the certificate authorities ACME API root (without trailing /directory or similar) | https://acme-v02.api.letsencrypt.org |
| authority_tos_agreement | d,**g**,c | Indicates agreement to the ToS of the certificate authority (--authority-tos-agreement on command line) | |
| authority_contact_email | d,**g** | (v2 API only) Contact e-mail to be registered with your account key | |
| account_key | d,**g** | Path to the account key | {work_dir}/account.key |
| account_key_algorithm | d,**g** | Key-algorithm for newly generated account keys (RSA, EC, ED25519, ED448) | RSA |
| account_key_length | d,**g** | Key-length for newly generated RSA account keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on account_key_algorithm |
| ttl_days | d,**g** | Renew certificate if it has less than this value validity left | 30 |
| validate_ocsp | d,**g** | Renew certificate if it's OCSP status is REVOKED. Allowed values for this key are: false, sha1, sha224, sha256, sha384, sha512 | sha1 (as mandated by RFC5019) |
| cert_dir | d,**g** | Directory containing all certificate related data (crt,key,csr) | {work_dir} |
| key_algorithm | d,**g** | Key-algorithm for newly generated private keys (RSA, EC, ED25519, ED448) | RSA |
| key_algorithm | d,**g** | Key-algorithm for newly generated private keys (RSA, ECC, ED25519, ED448) | RSA |
| key_length | d,**g** | Key-length for newly generated RSA private keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on key_algorithm |
| csr_static | **d**,g | Whether to re-use a static CSR or generate a new dynamic CSR | false |
| csr_file | **d**,g | Path to store (and load) the certificate CSR file | {cert_dir}/{cert_id}.csr |
@ -120,4 +121,4 @@ Please keep the following in mind when using this software:
* Create a dedicated user for acertmgr (e.g. acertmgr)
* Run a acertmgr as that user (add acertmgr to that users cron!)
* Access rights to read/write all files configured with the created user
* Run any programs/scripts defined on cert update as the created user (might need work-arounds with sudo or wrapper scripts)
* Run any programs/scripts defined on cert update as the created user (might need work-arounds with sudo or wrapper scripts)

View File

@ -6,17 +6,23 @@
# Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE
import grp
import io
import os
import pwd
import stat
import subprocess
import sys
from acertmgr import configuration, tools
from acertmgr.authority import authority
from acertmgr.modes import challenge_handler
from acertmgr.tools import log
from acertmgr.tools import log, LOG_REPLACEMENTS
try:
import pwd
import grp
except ImportError:
# Warnings will be reported upon usage below
pass
# @brief fetch new certificate from letsencrypt
@ -90,18 +96,25 @@ def cert_put(settings):
# set owner and group
if 'user' in settings or 'group' in settings:
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)
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)
# set permissions
if 'perm' in settings:
try:
os.chmod(settings['path'], int(settings['perm'], 8))
except OSError as e:
log('Could not set certificate file permissions', e, warning=True)
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)
return settings['action']
@ -116,7 +129,8 @@ def cert_revoke(cert, configs, fallback_authority, reason=None):
if not acmeconfig:
acmeconfig = fallback_authority
log("No matching authority found to revoke {}: {}, using globalconfig/defaults".format(tools.get_cert_cn(cert),
tools.get_cert_domains(cert)), warning=True)
tools.get_cert_domains(
cert)), warning=True)
acme = authority(acmeconfig)
acme.register_account()
acme.revoke_crt(cert, reason)
@ -125,6 +139,10 @@ def cert_revoke(cert, configs, fallback_authority, reason=None):
def main():
# load config
runtimeconfig, domainconfigs = configuration.load()
# register idna-mapped domains as LOG_REPLACEMENTS for better readability of log output
for domainconfig in domainconfigs:
LOG_REPLACEMENTS.update({k: "{} [{}]".format(k, v) for k, v in domainconfig['domainlist_idna_mapped'].items()})
# Start processing
if runtimeconfig.get('mode') == 'revoke':
# Mode: revoke certificate
log("Revoking {}".format(runtimeconfig['revoke']))
@ -144,9 +162,21 @@ def main():
cert = None
if os.path.isfile(config['cert_file']):
cert = tools.read_pem_file(config['cert_file'])
if not cert or not tools.is_cert_valid(cert, config['ttl_days']) or (
'force_renew' in runtimeconfig and
all(d in config['domainlist'] for d in runtimeconfig['force_renew'])):
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'])):
cert_get(config)
if str(config.get('cert_revoke_superseded')).lower() == 'true' and cert:
superseded.add(cert)

View File

@ -260,8 +260,8 @@ class ACMEAuthority(AbstractACMEAuthority):
if code >= 400:
raise ValueError("Error downloading certificate chain: {0} {1}".format(code, certificate))
cert_dict = re.match((r'(?P<cert>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)\n\n'
r'(?P<ca>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)?'),
cert_dict = re.match((r'(?P<cert>^-----BEGIN CERTIFICATE-----\n[^\-]+\n-----END CERTIFICATE-----)\n*'
r'(?P<ca>-----BEGIN CERTIFICATE-----\n.+\n-----END CERTIFICATE-----)?$'),
certificate, re.DOTALL).groupdict()
cert = tools.convert_pem_str_to_cert(cert_dict['cert'])
if cert_dict['ca'] is None:

View File

@ -16,9 +16,10 @@ import os
from acertmgr.tools import idna_convert
# Configuration defaults to use if not specified otherwise
DEFAULT_CONF_FILE = "/etc/acertmgr/acertmgr.conf"
DEFAULT_CONF_DIR = "/etc/acertmgr"
DEFAULT_CONF_FILENAME = "acertmgr.conf"
DEFAULT_TTL = 30 # days
DEFAULT_VALIDATE_OCSP = "sha1" # mandated by RFC5019
DEFAULT_API = "v2"
DEFAULT_AUTHORITY = "https://acme-v02.api.letsencrypt.org"
@ -89,9 +90,14 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
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']]
config['domainlist_idna_mapped'] = {}
for idx in range(0, len(config['domainlist'])):
if any(ord(c) >= 128 for c in config['domainlist'][idx]):
domain_human = config['domainlist'][idx]
domain_idna = idna_convert(domain_human)
if domain_idna != domain_human:
config['domainlist'][idx] = domain_idna # Update domain with idna counterpart
config['domainlist_idna_mapped'][domain_idna] = domain_human # Store original domain for reference
# Action config defaults
config['defaults'] = globalconfig.get('defaults', {})
@ -106,6 +112,9 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
update_config_value(config, 'ttl_days', localconfig, globalconfig, DEFAULT_TTL)
config['ttl_days'] = int(config['ttl_days'])
# Validate OCSP on certificate verification
update_config_value(config, 'validate_ocsp', localconfig, globalconfig, DEFAULT_VALIDATE_OCSP)
# Revoke old certificate with reason superseded after renewal
update_config_value(config, 'cert_revoke_superseded', localconfig, globalconfig, "false")
@ -115,6 +124,17 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
# Use a static cert request
update_config_value(config, 'csr_static', localconfig, globalconfig, "false")
# SSL key algorithm (if key has to be (re-)generated)
update_config_value(config, 'key_algorithm', localconfig, globalconfig, None)
# Update config id if we have a key algorithm set to allow for
# multiple certs with different algorithms for the same set of domains
if config.get('key_algorithm', None):
config['id'] += "_" + config['key_algorithm'].lower()
# SSL key length (if key has to be (re-)generated, converted to int)
update_config_value(config, 'key_length', localconfig, globalconfig, None)
config['key_length'] = int(config['key_length']) if config['key_length'] else None
# SSL cert request location
update_config_value(config, 'csr_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.csr".format(config['id'])))
@ -127,13 +147,6 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
update_config_value(config, 'key_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.key".format(config['id'])))
# SSL key algorithm (if key has to be (re-)generated)
update_config_value(config, 'key_algorithm', localconfig, globalconfig, None)
# SSL key length (if key has to be (re-)generated, converted to int)
update_config_value(config, 'key_length', localconfig, globalconfig, None)
config['key_length'] = int(config['key_length']) if config['key_length'] else None
# SSL CA location / use static
update_config_value(config, 'ca_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.ca".format(config['id'])))
@ -158,8 +171,8 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
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]
specificcfgs = [x for x in handlerconfigs if
'domain' in x and x['domain'] == config['domainlist_idna_mapped'].get(domain, domain)]
if len(specificcfgs) > 0:
cfg.update(specificcfgs[0])
@ -173,11 +186,11 @@ 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))
help="global configuration file (default='$config_dir/{}')".format(DEFAULT_CONF_FILENAME))
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)")
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="?",
@ -188,18 +201,18 @@ def load():
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
else:
global_config_file = DEFAULT_CONF_FILE
# Determine domain configuration directory
if args.config_dir:
domain_config_dir = args.config_dir
else:
domain_config_dir = DEFAULT_CONF_DIR
# Determine global configuration file
if args.config_file:
global_config_file = args.config_file
else:
global_config_file = os.path.join(domain_config_dir, DEFAULT_CONF_FILENAME)
# Runtime configuration: Get from command-line options
# - work_dir
if args.work_dir:
@ -218,9 +231,9 @@ def load():
# - force-rewew
if args.force_renew:
domaintranslation = idna_convert(args.force_renew.split(' '))
domaintranslation = [idna_convert(d) for d in args.force_renew.split(' ')]
if len(domaintranslation) > 0:
runtimeconfig['force_renew'] = [x for x, _ in domaintranslation]
runtimeconfig['force_renew'] = domaintranslation
else:
runtimeconfig['force_renew'] = args.force_renew.split(' ')
@ -251,15 +264,29 @@ def load():
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))
data = json.load(config_fd)
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))
data = yaml.safe_load(config_fd)
if isinstance(data, list):
# Handle newer config in list format (allows for multiple entries with same domains)
entries = list()
for element in data:
entries += element.items()
else:
# Handle older config format with just one entry per same domain set
entries = data.items()
for entry in entries:
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
if __name__ == '__main__':
# Simple configuration load test and output
from pprint import pprint
pprint(load())

View File

@ -28,10 +28,20 @@ class HTTPServer6(HTTPServer):
class ChallengeHandler(HTTPChallengeHandler):
def __init__(self, config):
HTTPChallengeHandler.__init__(self, config)
bind_address = config.get("bind_address", "")
port = int(config.get("port", 80))
self.bind_address = config.get("bind_address", "")
self.port = int(config.get("port", 80))
self.challenges = {} # Initialize the challenge data dict
self.server_thread = None
self.server = None
def create_challenge(self, domain, thumbprint, token):
self.challenges[token] = "{0}.{1}".format(token, thumbprint)
def destroy_challenge(self, domain, thumbprint, token):
del self.challenges[token]
def start_challenge(self, domain, thumbprint, token):
_self = self
# Custom HTTP request handler
@ -54,19 +64,11 @@ class ChallengeHandler(HTTPChallengeHandler):
self.end_headers()
self.wfile.write(value)
self.server_thread = None
try:
self.server = HTTPServer6((bind_address, port), _HTTPRequestHandler)
self.server = HTTPServer6((self.bind_address, self.port), _HTTPRequestHandler)
except socket.gaierror:
self.server = HTTPServer((bind_address, port), _HTTPRequestHandler)
self.server = HTTPServer((self.bind_address, self.port), _HTTPRequestHandler)
def create_challenge(self, domain, thumbprint, token):
self.challenges[token] = "{0}.{1}".format(token, thumbprint)
def destroy_challenge(self, domain, thumbprint, token):
del self.challenges[token]
def start_challenge(self, domain, thumbprint, token):
def _serve():
self.server.serve_forever()
@ -78,3 +80,6 @@ class ChallengeHandler(HTTPChallengeHandler):
if self.server_thread.is_alive():
self.server.shutdown()
self.server_thread.join()
self.server.server_close()
self.server = None
self.server_thread = None

View File

@ -10,6 +10,8 @@ import base64
import datetime
import io
import os
import re
import stat
import sys
import traceback
@ -21,6 +23,11 @@ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.utils import int_to_bytes
from cryptography.x509.oid import NameOID, ExtensionOID
try:
from cryptography.x509 import ocsp
except ImportError:
pass
try:
from cryptography.hazmat.primitives.asymmetric import ed25519, ed448
except ImportError:
@ -31,6 +38,8 @@ try:
except ImportError:
from urllib2 import urlopen, Request # Python 2
LOG_REPLACEMENTS = {}
class InvalidCertificateError(Exception):
pass
@ -52,6 +61,9 @@ def log(msg, exc=None, error=False, warning=False):
prefix = ""
output = prefix + msg
for k, v in LOG_REPLACEMENTS.items():
output = output.replace(k, v)
if exc:
_, exc_value, _ = sys.exc_info()
if not getattr(exc, '__traceback__', None) and exc == exc_value:
@ -132,7 +144,7 @@ def new_ssl_key(path=None, key_algo=None, key_size=None):
key_size=key_size,
backend=default_backend()
)
elif key_algo.lower() == 'ec':
elif key_algo.lower() == 'ec' or key_algo.lower() == 'ecc':
if not key_size or key_size == 256:
key_curve = ec.SECP256R1
elif key_size == 384:
@ -159,10 +171,13 @@ def new_ssl_key(path=None, key_algo=None, key_size=None):
)
with io.open(path, 'wb') as pem_out:
pem_out.write(pem)
try:
os.chmod(path, int("0400", 8))
except OSError:
log('Could not set file permissions on {0}!'.format(path), warning=True)
if hasattr(os, 'chmod'):
try:
os.chmod(path, int("0400", 8))
except OSError:
log('Could not set file permissions on {0}!'.format(path), warning=True)
else:
log('Keyfile permission handling unavailable on this platform', warning=True)
return private_key
@ -183,13 +198,21 @@ def read_pem_file(path, key=False, csr=False):
# @brief write cert data to PEM formatted file
def write_pem_file(crt, path, perms=None):
if hasattr(os, 'chmod') and os.path.exists(path):
try:
os.chmod(path, os.stat(path).st_mode | stat.S_IWRITE)
except OSError:
log('Could not make file ({0}) writable'.format(path), warning=True)
with io.open(path, "w") as f:
f.write(convert_cert_to_pem_str(crt))
if perms:
try:
os.chmod(path, perms)
except OSError:
log('Could not set file permissions ({0}) on {1}!'.format(perms, path), warning=True)
if hasattr(os, 'chmod'):
try:
os.chmod(path, perms)
except OSError:
log('Could not set file permissions ({0}) on {1}!'.format(perms, path), warning=True)
else:
log('PEM-File permission handling unavailable on this platform', warning=True)
# @brief download the issuer ca for a given certificate
@ -225,8 +248,7 @@ def get_cert_domains(cert):
if san_cert:
for d in san_cert.value:
domains.add(d.value)
# Convert IDNA domain to correct representation and return the list
return [x for x, _ in idna_convert(domains)]
return domains
# @brief determine certificate cn
@ -240,15 +262,26 @@ def get_cert_valid_until(cert):
# @brief convert certificate to PEM format
# @param cert certificate object in pyopenssl format
# @param cert certificate object or a list thereof
# @return the certificate in PEM format
def convert_cert_to_pem_str(cert):
return cert.public_bytes(serialization.Encoding.PEM).decode('utf8')
if not isinstance(cert, list):
cert = [cert]
result = list()
for data in cert:
result.append(data.public_bytes(serialization.Encoding.PEM).decode('utf8'))
return '\n'.join(result)
# @brief load a PEM certificate from str
# @return a certificate object or a list of objects if multiple are in the string
def convert_pem_str_to_cert(certdata):
return x509.load_pem_x509_certificate(certdata.encode('utf8'), default_backend())
certs = re.findall(r'(-----BEGIN CERTIFICATE-----\n[^\-]+\n-----END CERTIFICATE-----)',
certdata, re.DOTALL)
result = list()
for data in certs:
result.append(x509.load_pem_x509_certificate(data.encode('utf8'), default_backend()))
return result[0] if len(result) == 1 else result
# @brief serialize cert/csr to DER bytes
@ -356,23 +389,64 @@ def target_is_current(target, file):
return target_date >= crt_date
# @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(domain[2:].encode('idna').decode('ascii'))
else:
idna_domain = domain.encode('idna').decode('ascii')
result = idna_domain, domain
# @brief convert domain to idna representation (if applicable
def idna_convert(domain):
try:
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(domain[2:].encode('idna').decode('ascii'))
else:
result = domain, domain
domaintranslation.append(result)
return domaintranslation
idna_domain = domain.encode('idna').decode('ascii')
return idna_domain
except Exception as e:
log("Unicode domain(s) found but IDNA names could not be translated due to error: {}".format(e), error=True)
return domain
# @brief validate the OCSP status for a given certificate by the given issuer
def is_ocsp_valid(cert, issuer, hash_algo):
if hash_algo == 'sha1':
algorithm = hashes.SHA1
elif hash_algo == 'sha224':
algorithm = hashes.SHA224
elif hash_algo == 'sha256':
algorithm = hashes.SHA256
elif hash_algo == 'sha385':
algorithm = hashes.SHA384
elif hash_algo == 'sha512':
algorithm = hashes.SHA512
else:
if any(ord(c) >= 128 for c in ''.join(domainlist)) and '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 [(x, x) for x in domainlist]
log("Invalid hash algorithm '{}' used for OCSP validation. Validation ignored.".format(hash_algo), warning=True)
return True
if isinstance(issuer, list):
issuer = issuer[0] # First certificate in the CA chain is the immediate issuer
try:
ocsp_urls = []
aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS)
for data in aia.value:
if data.access_method == x509.OID_OCSP:
ocsp_urls.append(data.access_location.value)
# This is a bit of a hack due to validation problems within cryptography (TODO: Check if this is still true)
# Correct replacement: ocsprequest = ocsp.OCSPRequestBuilder().add_certificate(cert, issuer, algorithm).build()
ocsprequest = ocsp.OCSPRequestBuilder((cert, issuer, (algorithm)())).build()
ocsprequestdata = ocsprequest.public_bytes(serialization.Encoding.DER)
for ocsp_url in ocsp_urls:
response = get_url(ocsp_url,
ocsprequestdata,
{
'Accept': 'application/ocsp-response',
'Content-Type': 'application/ocsp-request',
})
ocspresponsedata = response.read()
ocspresponse = ocsp.load_der_ocsp_response(ocspresponsedata)
if ocspresponse.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL \
and ocspresponse.certificate_status == ocsp.OCSPCertStatus.REVOKED:
return False
except Exception as e:
log("An exception occurred during OCSP validation (Validation will be ignored): {}".format(e), error=True)
return True

View File

@ -68,3 +68,12 @@ mail.example.com smtp.example.com webmail.example.net *.intra.example.com:
perm: '400'
format: crt,ca
action: '/etc/init.d/postfix reload'
# this will use a different authority for the following set of domains (buypass.com in this example)
buypass-example.com *.buypass-example.com:
- authority: 'https://api.buypass.com/acme' # Removed trailing /directory from buypass docs for API endpoint
mode: dns.nsupdate
nsupdate_keyname: buypass
nsupdate_keyvalue: Test1234512359==
nsupdate_keyalgorithm: HMAC-MD5.SIG-ALG.REG.INT

View File

@ -26,68 +26,62 @@ def determine_version():
return version
# Derive version from git
try:
output = subprocess.check_output(['git', 'describe', '--tags', '--dirty'], cwd=dir_path) \
.decode('utf-8').strip().split('-')
output = subprocess.check_output(["git", "describe", "--tags", "--dirty"], cwd=dir_path) \
.decode("utf-8").strip().split("-")
if len(output) == 1:
return output[0]
elif len(output) == 2:
return "{}.dev0".format(output[0])
else:
release = 'dev' if len(output) == 4 and output[3] == 'dirty' else ''
release = "dev" if len(output) == 4 and output[3] == "dirty" else ""
return "{}.{}{}+{}".format(output[0], release, output[1], output[2])
except subprocess.CalledProcessError:
try:
commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip()
status = subprocess.check_output(['git', 'status', '-s']).decode('utf-8').strip()
commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip()
status = subprocess.check_output(["git", "status", "-s"]).decode("utf-8").strip()
return "{}.dev0+{}".format(version, commit) if len(status) > 0 else "{}+{}".format(version, commit)
except subprocess.CalledProcessError:
# finding the git version has utterly failed, use version.txt
return version
setup(
name="acertmgr",
version=determine_version(),
author="Markus Hauschild",
author_email="moepman@binary-kitchen.de",
description="An automated certificate manager using ACME/letsencrypt",
license="ISC",
keywords="acme letsencrypt",
url="https://github.com/moepman/acertmgr",
packages=find_packages(),
long_description=read('README.md'),
long_description_content_type="text/markdown",
classifiers=[
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Environment :: Console",
"Topic :: Security :: Cryptography",
"License :: OSI Approved :: ISC License",
],
install_requires=[
"cryptography>=0.6",
],
extras_require={
"dns": [
"dnspython",
extra_requirements = {
"dns": ["dnspython"],
"yaml": ["PyYAML"],
"idna": ["idna"],
"ocsp-must-staple": ["cryptography>=2.1"],
"ocsp-validation": ["cryptography>=2.4"],
"ed25519": ["cryptography>=2.6"],
}
if __name__ == "__main__":
setup(
name="acertmgr",
version=determine_version(),
author="Markus Hauschild",
author_email="moepman@binary-kitchen.de",
description="An automated certificate manager using ACME/letsencrypt",
license="ISC",
keywords="acme letsencrypt",
url="https://github.com/moepman/acertmgr",
packages=find_packages(),
long_description=read("README.md"),
long_description_content_type="text/markdown",
classifiers=[
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
"Environment :: Console",
"Topic :: Security :: Cryptography",
"License :: OSI Approved :: ISC License (ISCL)",
],
"yaml": [
"yaml",
install_requires=[
"cryptography>=0.6",
],
"idna": [
"idna",
],
"ocsp-must-staple": [
"cryptography>=2.1",
],
"ed25519": [
"cryptography>=2.6",
],
},
entry_points={
'console_scripts': [
'acertmgr=acertmgr:main',
],
},
data_files=[('readme', ['README.md'])]
)
extras_require=extra_requirements,
entry_points={
"console_scripts": [
"acertmgr=acertmgr:main",
],
},
data_files=[("readme", ["README.md"])],
)

View File

@ -1 +1 @@
0.9.8
1.0.5