1
0
mirror of https://github.com/moepman/acertmgr.git synced 2024-06-02 07:32:34 +02:00

Compare commits

...

21 Commits

Author SHA1 Message Date
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
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
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
7a5d35f29b GitHub Actions: use current setuptools and wheel 2020-10-12 19:22:02 +02:00
62f01aeff9 GitHub Actions: twine upload via pypa/gh-action-pypi-publish 2020-10-12 19:01:09 +02:00
b48f4532b9 reformat setup.py 2020-10-12 17:48:55 +02:00
bc2a7229ec GitHub Actions: unify whitespace style 2020-10-12 17:22:52 +02:00
10 changed files with 163 additions and 89 deletions

View File

@ -5,13 +5,15 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install dependencies - name: Install dependencies
run: | run: |
sudo apt update -qq -y sudo apt update -qq -y
sudo apt install -qq -y build-essential fakeroot git python-all python3-cryptography python3-stdeb python3-wheel twine 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 - name: Prepare build process
id: buildprep id: buildprep
@ -20,7 +22,7 @@ jobs:
git fetch --tags -f git fetch --tags -f
VER="$(python3 setup.py --version)" VER="$(python3 setup.py --version)"
echo "Version found: $VER" echo "Version found: $VER"
echo "::set-output name=version::$VER" echo "version=$VER" >> $GITHUB_OUTPUT
- name: Build python package using setuptools (source/wheel) - name: Build python package using setuptools (source/wheel)
run: | run: |
@ -34,12 +36,20 @@ jobs:
sed "s/=determine_version()/='$(python3 setup.py --version)'/gi" -i setup.py sed "s/=determine_version()/='$(python3 setup.py --version)'/gi" -i setup.py
sed "s@('readme'@('share/doc/python3-acertmgr'@" -i setup.py sed "s@('readme'@('share/doc/python3-acertmgr'@" -i setup.py
# Determine recommended dependencies for deb package # Determine recommended dependencies for deb package
echo "::set-output name=recommends3::$(echo "python3-pkg-resources")" echo "recommends3=$(echo "python3-pkg-resources")" >> $GITHUB_OUTPUT
# Find optional dependencies to suggest in deb package # Find optional dependencies to suggest in deb package
echo "::set-output name=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}')" 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 - name: Build debian package using setuptools and stdeb
run: 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 }}" bdist_deb 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 - name: Create a changelog from git log since last non-pre/rc tag
run: | run: |
@ -61,6 +71,7 @@ jobs:
cp -v dist/*.tar.gz artifacts/ cp -v dist/*.tar.gz artifacts/
cp -v dist/*.whl artifacts/ cp -v dist/*.whl artifacts/
cp -v deb_dist/*.deb artifacts/ cp -v deb_dist/*.deb artifacts/
- name: Upload build artifact - name: Upload build artifact
uses: actions/upload-artifact@v1 uses: actions/upload-artifact@v1
with: with:
@ -82,12 +93,22 @@ jobs:
artifacts/*.whl artifacts/*.whl
artifacts/*.deb artifacts/*.deb
- name: Create new PyPI release - name: Check PyPI secrets
env: id: checksecrets
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} shell: bash
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
if: "startsWith(github.ref, 'refs/tags/') && (!(contains(github.ref, 'rc') || contains(github.ref, 'pre')))"
run: | run: |
if [ "$TWINE_USERNAME" != '' -a "$TWINE_PASSWORD" != '' ]; then if [ "$USER" == "" -o "$PASSWORD" == "" ]; then
twine upload dist/* echo "secretspresent=NO" >> $GITHUB_OUTPUT
else
echo "secretspresent=YES" >> $GITHUB_OUTPUT
fi 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) * 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) 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): 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 | | Directive | Context | Description | Built-in Default |
| --- | --- | --- | --- | | --- | --- |----------------------------------------------------------------------------------------------------------------------------------------------| --- |
| -c/--config-file | **c** | global configuration file (optional) | /etc/acertmgr/acertmgr.conf | | -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 | | -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 | | -w/--work-dir | **c** | working directory containing csr/certificates/keys/ca files | /etc/acertmgr |
@ -65,7 +65,7 @@ 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) | | | --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. | | | 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 | | 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_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 | | | 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 | d,**g** | Path to the account key | {work_dir}/account.key |
@ -74,7 +74,7 @@ By default the directory (work_dir) containing the working data (csr,certificate
| ttl_days | d,**g** | Renew certificate if it has less than this value validity left | 30 | | 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) | | 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} | | 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 | | 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_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 | | csr_file | **d**,g | Path to store (and load) the certificate CSR file | {cert_dir}/{cert_id}.csr |
@ -121,4 +121,4 @@ Please keep the following in mind when using this software:
* Create a dedicated user for acertmgr (e.g. acertmgr) * Create a dedicated user for acertmgr (e.g. acertmgr)
* Run a acertmgr as that user (add acertmgr to that users cron!) * Run a acertmgr as that user (add acertmgr to that users cron!)
* Access rights to read/write all files configured with the created user * 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

@ -15,7 +15,7 @@ import sys
from acertmgr import configuration, tools from acertmgr import configuration, tools
from acertmgr.authority import authority from acertmgr.authority import authority
from acertmgr.modes import challenge_handler from acertmgr.modes import challenge_handler
from acertmgr.tools import log from acertmgr.tools import log, LOG_REPLACEMENTS
try: try:
import pwd import pwd
@ -139,6 +139,10 @@ def cert_revoke(cert, configs, fallback_authority, reason=None):
def main(): def main():
# load config # load config
runtimeconfig, domainconfigs = configuration.load() 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': if runtimeconfig.get('mode') == 'revoke':
# Mode: revoke certificate # Mode: revoke certificate
log("Revoking {}".format(runtimeconfig['revoke'])) log("Revoking {}".format(runtimeconfig['revoke']))

View File

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

View File

@ -90,9 +90,14 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
config['id'] = hashlib.md5(domains.encode('utf-8')).hexdigest() config['id'] = hashlib.md5(domains.encode('utf-8')).hexdigest()
# Convert unicode to IDNA domains # Convert unicode to IDNA domains
config['domaintranslation'] = idna_convert(config['domainlist']) config['domainlist_idna_mapped'] = {}
if len(config['domaintranslation']) > 0: for idx in range(0, len(config['domainlist'])):
config['domainlist'] = [x for x, _ in config['domaintranslation']] 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 # Action config defaults
config['defaults'] = globalconfig.get('defaults', {}) config['defaults'] = globalconfig.get('defaults', {})
@ -119,6 +124,17 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
# Use a static cert request # Use a static cert request
update_config_value(config, 'csr_static', localconfig, globalconfig, "false") 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 # SSL cert request location
update_config_value(config, 'csr_file', localconfig, globalconfig, update_config_value(config, 'csr_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.csr".format(config['id']))) os.path.join(config['cert_dir'], "{}.csr".format(config['id'])))
@ -131,13 +147,6 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
update_config_value(config, 'key_file', localconfig, globalconfig, update_config_value(config, 'key_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.key".format(config['id']))) 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 # SSL CA location / use static
update_config_value(config, 'ca_file', localconfig, globalconfig, update_config_value(config, 'ca_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.ca".format(config['id']))) os.path.join(config['cert_dir'], "{}.ca".format(config['id'])))
@ -162,8 +171,8 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
cfg.update(genericfgs[0]) cfg.update(genericfgs[0])
# Update handler config with more specific values (use original names for translated unicode domains) # 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
specificcfgs = [x for x in handlerconfigs if 'domain' in x and x['domain'] == _domain] 'domain' in x and x['domain'] == config['domainlist_idna_mapped'].get(domain, domain)]
if len(specificcfgs) > 0: if len(specificcfgs) > 0:
cfg.update(specificcfgs[0]) cfg.update(specificcfgs[0])
@ -222,9 +231,9 @@ def load():
# - force-rewew # - force-rewew
if args.force_renew: 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: if len(domaintranslation) > 0:
runtimeconfig['force_renew'] = [x for x, _ in domaintranslation] runtimeconfig['force_renew'] = domaintranslation
else: else:
runtimeconfig['force_renew'] = args.force_renew.split(' ') runtimeconfig['force_renew'] = args.force_renew.split(' ')
@ -255,15 +264,29 @@ def load():
os.path.abspath(domain_config_file) != os.path.abspath(global_config_file): os.path.abspath(domain_config_file) != os.path.abspath(global_config_file):
with io.open(domain_config_file) as config_fd: with io.open(domain_config_file) as config_fd:
try: try:
for entry in json.load(config_fd).items(): data = json.load(config_fd)
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
except ValueError: except ValueError:
import yaml import yaml
config_fd.seek(0) config_fd.seek(0)
for entry in yaml.safe_load(config_fd).items(): data = yaml.safe_load(config_fd)
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig)) 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 # Define a fallback authority from global configuration / defaults
runtimeconfig['fallback_authority'] = parse_authority([], globalconfig, runtimeconfig) runtimeconfig['fallback_authority'] = parse_authority([], globalconfig, runtimeconfig)
return runtimeconfig, domainconfigs 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): class ChallengeHandler(HTTPChallengeHandler):
def __init__(self, config): def __init__(self, config):
HTTPChallengeHandler.__init__(self, config) HTTPChallengeHandler.__init__(self, config)
bind_address = config.get("bind_address", "") self.bind_address = config.get("bind_address", "")
port = int(config.get("port", 80)) self.port = int(config.get("port", 80))
self.challenges = {} # Initialize the challenge data dict 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 _self = self
# Custom HTTP request handler # Custom HTTP request handler
@ -54,19 +64,11 @@ class ChallengeHandler(HTTPChallengeHandler):
self.end_headers() self.end_headers()
self.wfile.write(value) self.wfile.write(value)
self.server_thread = None
try: try:
self.server = HTTPServer6((bind_address, port), _HTTPRequestHandler) self.server = HTTPServer6((self.bind_address, self.port), _HTTPRequestHandler)
except socket.gaierror: 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(): def _serve():
self.server.serve_forever() self.server.serve_forever()
@ -78,3 +80,6 @@ class ChallengeHandler(HTTPChallengeHandler):
if self.server_thread.is_alive(): if self.server_thread.is_alive():
self.server.shutdown() self.server.shutdown()
self.server_thread.join() self.server_thread.join()
self.server.server_close()
self.server = None
self.server_thread = None

View File

@ -10,6 +10,7 @@ import base64
import datetime import datetime
import io import io
import os import os
import re
import stat import stat
import sys import sys
import traceback import traceback
@ -37,6 +38,8 @@ try:
except ImportError: except ImportError:
from urllib2 import urlopen, Request # Python 2 from urllib2 import urlopen, Request # Python 2
LOG_REPLACEMENTS = {}
class InvalidCertificateError(Exception): class InvalidCertificateError(Exception):
pass pass
@ -58,6 +61,9 @@ def log(msg, exc=None, error=False, warning=False):
prefix = "" prefix = ""
output = prefix + msg output = prefix + msg
for k, v in LOG_REPLACEMENTS.items():
output = output.replace(k, v)
if exc: if exc:
_, exc_value, _ = sys.exc_info() _, exc_value, _ = sys.exc_info()
if not getattr(exc, '__traceback__', None) and exc == exc_value: if not getattr(exc, '__traceback__', None) and exc == exc_value:
@ -138,7 +144,7 @@ def new_ssl_key(path=None, key_algo=None, key_size=None):
key_size=key_size, key_size=key_size,
backend=default_backend() 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: if not key_size or key_size == 256:
key_curve = ec.SECP256R1 key_curve = ec.SECP256R1
elif key_size == 384: elif key_size == 384:
@ -242,8 +248,7 @@ def get_cert_domains(cert):
if san_cert: if san_cert:
for d in san_cert.value: for d in san_cert.value:
domains.add(d.value) domains.add(d.value)
# Convert IDNA domain to correct representation and return the list return domains
return [x for x, _ in idna_convert(domains)]
# @brief determine certificate cn # @brief determine certificate cn
@ -257,15 +262,26 @@ def get_cert_valid_until(cert):
# @brief convert certificate to PEM format # @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 # @return the certificate in PEM format
def convert_cert_to_pem_str(cert): 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 # @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): 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 # @brief serialize cert/csr to DER bytes
@ -373,26 +389,19 @@ def target_is_current(target, file):
return target_date >= crt_date return target_date >= crt_date
# @brief convert domain list to idna representation (if applicable # @brief convert domain to idna representation (if applicable
def idna_convert(domainlist): def idna_convert(domain):
if any(ord(c) >= 128 for c in ''.join(domainlist)): try:
try: if any(ord(c) >= 128 for c in domain):
domaintranslation = list() # Translate IDNA domain name from a unicode domain (handle wildcards separately)
for domain in domainlist: if domain.startswith('*.'):
if any(ord(c) >= 128 for c in domain): idna_domain = "*.{}".format(domain[2:].encode('idna').decode('ascii'))
# Translate IDNA domain name from a unicode domain (handle wildcards separately) else:
if domain.startswith('*.'): idna_domain = domain.encode('idna').decode('ascii')
idna_domain = "*.{}".format(domain[2:].encode('idna').decode('ascii')) return idna_domain
else: except Exception as e:
idna_domain = domain.encode('idna').decode('ascii') log("Unicode domain(s) found but IDNA names could not be translated due to error: {}".format(e), error=True)
result = idna_domain, domain return domain
else:
result = domain, domain
domaintranslation.append(result)
return domaintranslation
except Exception as e:
log("Unicode domain(s) found but IDNA names could not be translated due to error: {}".format(e), error=True)
return [(x, x) for x in domainlist]
# @brief validate the OCSP status for a given certificate by the given issuer # @brief validate the OCSP status for a given certificate by the given issuer
@ -411,6 +420,9 @@ def is_ocsp_valid(cert, issuer, hash_algo):
log("Invalid hash algorithm '{}' used for OCSP validation. Validation ignored.".format(hash_algo), warning=True) log("Invalid hash algorithm '{}' used for OCSP validation. Validation ignored.".format(hash_algo), warning=True)
return True return True
if isinstance(issuer, list):
issuer = issuer[0] # First certificate in the CA chain is the immediate issuer
try: try:
ocsp_urls = [] ocsp_urls = []
aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS) aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS)
@ -420,7 +432,7 @@ def is_ocsp_valid(cert, issuer, hash_algo):
# This is a bit of a hack due to validation problems within cryptography (TODO: Check if this is still true) # 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() # Correct replacement: ocsprequest = ocsp.OCSPRequestBuilder().add_certificate(cert, issuer, algorithm).build()
ocsprequest = ocsp.OCSPRequestBuilder((cert, issuer, algorithm)).build() ocsprequest = ocsp.OCSPRequestBuilder((cert, issuer, (algorithm)())).build()
ocsprequestdata = ocsprequest.public_bytes(serialization.Encoding.DER) ocsprequestdata = ocsprequest.public_bytes(serialization.Encoding.DER)
for ocsp_url in ocsp_urls: for ocsp_url in ocsp_urls:
response = get_url(ocsp_url, response = get_url(ocsp_url,

View File

@ -68,3 +68,12 @@ mail.example.com smtp.example.com webmail.example.net *.intra.example.com:
perm: '400' perm: '400'
format: crt,ca format: crt,ca
action: '/etc/init.d/postfix reload' 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,19 +26,19 @@ def determine_version():
return version return version
# Derive version from git # Derive version from git
try: try:
output = subprocess.check_output(['git', 'describe', '--tags', '--dirty'], cwd=dir_path) \ output = subprocess.check_output(["git", "describe", "--tags", "--dirty"], cwd=dir_path) \
.decode('utf-8').strip().split('-') .decode("utf-8").strip().split("-")
if len(output) == 1: if len(output) == 1:
return output[0] return output[0]
elif len(output) == 2: elif len(output) == 2:
return "{}.dev0".format(output[0]) return "{}.dev0".format(output[0])
else: 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]) return "{}.{}{}+{}".format(output[0], release, output[1], output[2])
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
try: try:
commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).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() status = subprocess.check_output(["git", "status", "-s"]).decode("utf-8").strip()
return "{}.dev0+{}".format(version, commit) if len(status) > 0 else "{}+{}".format(version, commit) return "{}.dev0+{}".format(version, commit) if len(status) > 0 else "{}+{}".format(version, commit)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
# finding the git version has utterly failed, use version.txt # finding the git version has utterly failed, use version.txt
@ -65,7 +65,7 @@ if __name__ == "__main__":
keywords="acme letsencrypt", keywords="acme letsencrypt",
url="https://github.com/moepman/acertmgr", url="https://github.com/moepman/acertmgr",
packages=find_packages(), packages=find_packages(),
long_description=read('README.md'), long_description=read("README.md"),
long_description_content_type="text/markdown", long_description_content_type="text/markdown",
classifiers=[ classifiers=[
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@ -79,9 +79,9 @@ if __name__ == "__main__":
], ],
extras_require=extra_requirements, extras_require=extra_requirements,
entry_points={ entry_points={
'console_scripts': [ "console_scripts": [
'acertmgr=acertmgr:main', "acertmgr=acertmgr:main",
], ],
}, },
data_files=[('readme', ['README.md'])] data_files=[("readme", ["README.md"])],
) )

View File

@ -1 +1 @@
1.0.3 1.0.5