mirror of https://github.com/moepman/acertmgr.git
Compare commits
37 Commits
Author | SHA1 | Date |
---|---|---|
Markus | 9ca6dae048 | |
Kishi85 | 274351415d | |
Rudolf Mayerhofer | f78cb5c554 | |
Rudolf Mayerhofer | c3736c0838 | |
Rudolf Mayerhofer | 1a98f86aad | |
Rudolf Mayerhofer | ef81ea62d1 | |
Rudolf Mayerhofer | d1caaf80ef | |
Rudolf Mayerhofer | ba644d44f1 | |
Jan | c15b6ec441 | |
Kishi85 | 2d230e30d9 | |
Kishi85 | 6f0ccfdc91 | |
Kishi85 | 460b0119ac | |
David Klaftenegger | e2f7b09b18 | |
Markus | 93e28437ff | |
Kishi85 | 2e1f5cd894 | |
Kishi85 | ce157a5c8a | |
Kishi85 | 9953cb4527 | |
Markus | 7a5d35f29b | |
Markus | 62f01aeff9 | |
Markus | b48f4532b9 | |
Markus | bc2a7229ec | |
Markus | fd4fed9432 | |
Markus | 56743dcbb9 | |
Kishi85 | 0648cb7b38 | |
Kishi85 | b37d0cad94 | |
Kishi85 | c33a39a433 | |
Kishi85 | 882ddfd0b8 | |
Kishi | e5edc4e5aa | |
Markus | e48724b726 | |
Markus | 6314f468c1 | |
Kishi85 | 97e9be80cf | |
Kishi85 | f5f038d47b | |
Markus | a0a4b0bf07 | |
Markus | a63eabd0ee | |
Markus | 2911e05165 | |
Markus | 8dad549d68 | |
Markus | 11d43d4817 |
|
@ -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 }}
|
11
README.md
11
README.md
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
92
setup.py
92
setup.py
|
@ -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"])],
|
||||
)
|
||||
|
|
|
@ -1 +1 @@
|
|||
0.9.8
|
||||
1.0.5
|
||||
|
|
Loading…
Reference in New Issue