modes: Use classes to easily allow different types of challenge handling

This commit is contained in:
Kishi85 2019-01-08 08:19:50 +01:00
parent 622c4866da
commit cc3bfb55dd
6 changed files with 207 additions and 105 deletions

View File

@ -6,10 +6,10 @@
# Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE
import acme
import tools
import grp
import hashlib
import importlib
import os
import pwd
import shutil
@ -17,9 +17,8 @@ import stat
import subprocess
import tempfile
import yaml
import acertmgr_web
import acme
import tools
ACME_DIR = "/etc/acme"
ACME_CONF = os.path.join(ACME_DIR, "acme.conf")
@ -29,10 +28,6 @@ ACME_DEFAULT_SERVER_KEY = os.path.join(ACME_DIR, "server.key")
ACME_DEFAULT_ACCOUNT_KEY = os.path.join(ACME_DIR, "account.key")
class FileNotFoundError(OSError):
pass
# @brief check whether existing target file is still valid or source crt has been updated
# @param target string containing the path to the target file
# @param crt_file string containing the path to the certificate file
@ -45,18 +40,32 @@ def target_is_current(target, crt_file):
return target_date >= crt_date
# @brief fetch new certificate from letsencrypt
# @param domain string containing the domain name
# @param settings the domain's configuration options
def cert_get(domains, settings):
print("Getting certificate for %s." % domains)
# @brief create a challenge handler for the given configuration
# @param config the domain's configuration options
def create_challenge_handler(config):
if "mode" in config:
mode = config["mode"]
else:
mode = "standalone"
key_file = settings['server_key']
handler_module = importlib.import_module("modes.{0}".format(mode))
handler_class = getattr(handler_module, "ChallengeHandler")
return handler_class(config)
# @brief fetch new certificate from letsencrypt
# @param domains string containing all domain names
# @param globalconfig the global configuration options
# @param handlerconfigs the domain's handler configuration options
def cert_get(domains, globalconfig, handlerconfigs):
print("Getting certificate for '%s'." % domains)
key_file = globalconfig['server_key']
if not os.path.isfile(key_file):
print("Server key not found at '{0}'. Creating RSA key.".format(key_file))
tools.new_rsa_key(key_file)
acc_file = settings['account_key']
acc_file = globalconfig['account_key']
if not os.path.isfile(acc_file):
print("Account key not found at '{0}'. Creating RSA key.".format(acc_file))
tools.new_rsa_key(acc_file)
@ -65,39 +74,43 @@ def cert_get(domains, settings):
_, csr_file = tempfile.mkstemp(".csr", "%s." % filename)
_, crt_file = tempfile.mkstemp(".crt", "%s." % filename)
challenge_dir = settings.get("webdir", "/var/www/acme-challenge/")
if not os.path.isdir(challenge_dir):
raise FileNotFoundError("Challenge directory (%s) does not exist!" % challenge_dir)
# find challenge handlers for this certificate
challenge_handlers = dict()
domainlist = domains.split(' ')
for domain in domainlist:
# Use global config as base handler config
cfg = globalconfig.deepcopy()
current_dir = '.'
server = None
if settings['mode'] == 'standalone':
port = settings.get('port', 80)
# Determine generic domain handler config values
genericfgs = [x for x in handlerconfigs if 'domain' not in x]
if len(genericfgs) > 0:
cfg = cfg.update(genericfgs[0])
# Update handler config with more specific values
specificcfgs = [x for x in handlerconfigs if ('domain' in x and x['domain'] == domain)]
if len(specificcfgs) > 0:
cfg = cfg.update(specificcfgs[0])
# Create the challenge handler
challenge_handlers[domain] = create_challenge_handler(cfg)
current_dir = os.getcwd()
os.chdir(challenge_dir)
server = acertmgr_web.ACMEHTTPServer(port)
server.start()
try:
key = tools.read_key(key_file)
cr = tools.new_cert_request(domains.split(), key)
cr = tools.new_cert_request(domainlist, key)
print("Reading account key...")
acc_key = tools.read_key(acc_file)
acme.register_account(acc_key, settings['authority'])
crt = acme.get_crt_from_csr(acc_key, cr, domains.split(), challenge_dir, settings['authority'])
acme.register_account(acc_key, config['authority'])
crt = acme.get_crt_from_csr(acc_key, cr, domainlist, challenge_handlers, config['authority'])
with open(crt_file, "w") as crt_fd:
crt_fd.write(tools.convert_cert_to_pem(crt))
# if resulting certificate is valid: store in final location
if tools.is_cert_valid(crt_file, 60):
crt_final = os.path.join(ACME_DIR, ("%s.crt" % domains.split(" ")[0]))
crt_final = os.path.join(ACME_DIR, (hashlib.md5(domains).hexdigest() + ".crt"))
shutil.copy2(crt_file, crt_final)
os.chmod(crt_final, stat.S_IREAD)
finally:
if settings['mode'] == 'standalone':
server.stop()
os.chdir(current_dir)
os.remove(csr_file)
os.remove(crt_file)
@ -170,11 +183,18 @@ def complete_config(domainconfig, globalconfig):
if __name__ == "__main__":
config = dict()
# load global configuration
config = {}
if os.path.isfile(ACME_CONF):
with open(ACME_CONF) as config_fd:
config = yaml.load(config_fd)
try:
import json
config = json.load(config_fd)
except json.JSONDecodeError:
import yaml
config = yaml.load(config_fd)
if 'defaults' not in config:
config['defaults'] = {}
if 'server_key' not in config:
@ -187,8 +207,16 @@ if __name__ == "__main__":
for config_file in os.listdir(ACME_CONFD):
if config_file.endswith(".conf"):
with open(os.path.join(ACME_CONFD, config_file)) as config_fd:
for entry in yaml.load(config_fd).items():
config['domains'].append(entry)
try:
import json
for entry in json.load(config_fd).items():
config['domains'].append(entry)
except json.JSONDecodeError:
import yaml
for entry in yaml.load(config_fd).items():
config['domains'].append(entry)
# post-update actions (run only once)
actions = set()
@ -201,11 +229,13 @@ if __name__ == "__main__":
crt_file = os.path.join(ACME_DIR, (hashlib.md5(domains).hexdigest() + ".crt"))
ttl_days = int(config.get('ttl_days', 15))
if not tools.is_cert_valid(crt_file, ttl_days):
cert_get(domains, config)
for domaincfg in domaincfgs:
cfg = complete_config(domaincfg, config)
if not target_is_current(cfg['path'], crt_file):
actions.add(cert_put(cfg))
# Get certificates using handler configs (contain element 'mode')
cert_get(domains, config, [x for x in domaincfgs if 'mode' in x])
# Run actions from config (contain element 'path')
for actioncfg in [x for x in domaincfgs if 'path' in x]:
actioncfg = complete_config(actioncfg, config)
if not target_is_current(actioncfg['path'], crt_file):
actions.add(cert_put(actioncfg))
# run post-update actions
for action in actions:

94
acme.py
View File

@ -1,16 +1,13 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# acertmgr - ssl management functions
# acertmgr - acme api functions
# Copyright (c) Markus Hauschild & David Klaftenegger, 2016.
# Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE
import tools
import base64
import copy
import json
import os
import re
import time
@ -19,6 +16,9 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
import tools
from tools import byte_string_format
try:
from urllib.request import urlopen # Python 3
except ImportError:
@ -28,46 +28,26 @@ except ImportError:
# @brief create the header information for ACME communication
# @param key the account key
# @return the header for ACME
def acme_header(key):
def _prepare_header(key):
numbers = key.public_key().public_numbers()
header = {
"alg": "RS256",
"jwk": {
"e": tools.to_json_base64(tools.byte_string_format(numbers.e)),
"e": tools.to_json_base64(byte_string_format(numbers.e)),
"kty": "RSA",
"n": tools.to_json_base64(tools.byte_string_format(numbers.n)),
"n": tools.to_json_base64(byte_string_format(numbers.n)),
},
}
return header
# @brief register an account over ACME
# @param account_key the account key to register
# @param CA the certificate authority to register with
# @return True if new account was registered, False otherwise
def register_account(account_key, ca):
header = acme_header(account_key)
code, result = send_signed(account_key, ca, ca + "/acme/new-reg", header, {
"resource": "new-reg",
"agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
})
if code == 201:
print("Registered!")
return True
elif code == 409:
print("Already registered!")
return False
else:
raise ValueError("Error registering: {0} {1}".format(code, result))
# @brief helper function to make signed requests
# @param CA the certificate authority
# @param url the request URL
# @param header the message header
# @param payload the message
# @return tuple of return code and request answer
def send_signed(account_key, ca, url, header, payload):
def _send_signed(account_key, ca, url, header, payload):
payload64 = tools.to_json_base64(json.dumps(payload).encode('utf8'))
protected = copy.deepcopy(header)
protected["nonce"] = urlopen(ca + "/directory").headers['Replay-Nonce']
@ -87,16 +67,36 @@ def send_signed(account_key, ca, url, header, payload):
return getattr(e, "code", None), getattr(e, "read", e.__str__)()
# @brief register an account over ACME
# @param account_key the account key to register
# @param CA the certificate authority to register with
# @return True if new account was registered, False otherwise
def register_account(account_key, ca):
header = _prepare_header(account_key)
code, result = _send_signed(account_key, ca, ca + "/acme/new-reg", header, {
"resource": "new-reg",
"agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
})
if code == 201:
print("Registered!")
return True
elif code == 409:
print("Already registered!")
return False
else:
raise ValueError("Error registering: {0} {1}".format(code, result))
# @brief function to fetch certificate using ACME
# @param account_key the account key in pyopenssl format
# @param csr the certificate signing request in pyopenssl format
# @param domains list of domains in the certificate, first is CN
# @param acme_dir directory for ACME challanges
# @param challenge_handlers a dict containing challenge for all given domains
# @param CA which signing CA to use
# @return the certificate in pyopenssl format
# @note algorithm and parts of the code are from acme-tiny
def get_crt_from_csr(account_key, csr, domains, acme_dir, ca):
header = acme_header(account_key)
def get_crt_from_csr(account_key, csr, domains, challenge_handlers, ca):
header = _prepare_header(account_key)
accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':'))
account_hash = hashes.Hash(hashes.SHA256(), backend=default_backend())
account_hash.update(accountkey_json.encode('utf8'))
@ -107,34 +107,26 @@ def get_crt_from_csr(account_key, csr, domains, acme_dir, ca):
print("Verifying {0}...".format(domain))
# get new challenge
code, result = send_signed(account_key, ca, ca + "/acme/new-authz", header, {
code, result = _send_signed(account_key, ca, ca + "/acme/new-authz", header, {
"resource": "new-authz",
"identifier": {"type": "dns", "value": domain},
})
if code != 201:
raise ValueError("Error requesting challenges: {0} {1}".format(code, result))
# make the challenge file
challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0]
# create the challenge
challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if
c['type'] == challenge_handlers[domain].get_challenge_type()][0]
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
keyauthorization = "{0}.{1}".format(token, account_thumbprint)
wellknown_path = os.path.join(acme_dir, token)
with open(wellknown_path, "w") as wellknown_file:
wellknown_file.write(keyauthorization)
# check that the file is in place
wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token)
try:
resp = urlopen(wellknown_url)
resp_data = resp.read().decode('utf8').strip()
assert resp_data == keyauthorization
except (IOError, AssertionError):
os.remove(wellknown_path)
raise ValueError("Wrote file to {0}, but couldn't download {1}".format(
wellknown_path, wellknown_url))
if domain not in challenge_handlers:
raise ValueError("No challenge handler given for domain: {0}".format(domain))
challenge_handlers[domain].create_challenge(domain, account_thumbprint, token)
# notify challenge are met
code, result = send_signed(account_key, ca, challenge['uri'], header, {
keyauthorization = "{0}.{1}".format(token, account_thumbprint)
code, result = _send_signed(account_key, ca, challenge['uri'], header, {
"resource": "challenge",
"keyAuthorization": keyauthorization,
})
@ -153,7 +145,7 @@ def get_crt_from_csr(account_key, csr, domains, acme_dir, ca):
time.sleep(2)
elif challenge_status['status'] == "valid":
print("{0} verified!".format(domain))
os.remove(wellknown_path)
challenge_handlers[domain].destroy_challenge(domain, account_thumbprint, token)
break
else:
raise ValueError("{0} challenge did not pass: {1}".format(
@ -162,7 +154,7 @@ def get_crt_from_csr(account_key, csr, domains, acme_dir, ca):
# get the new certificate
print("Signing certificate...")
csr_der = csr.public_bytes(serialization.Encoding.DER)
code, result = send_signed(account_key, ca, ca + "/acme/new-cert", header, {
code, result = _send_signed(account_key, ca, ca + "/acme/new-cert", header, {
"resource": "new-cert",
"csr": tools.to_json_base64(csr_der),
})

0
modes/__init__.py Normal file
View File

21
modes/abstract.py Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# abstract - abstract base classes for challenge handlers
# Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE
class AbstractChallengeHandler:
def __init__(self, config):
self.config = config
@staticmethod
def get_challenge_type():
raise NotImplemented
def create_challenge(self, domain, thumbprint, token):
raise NotImplemented
def destroy_challenge(self, domain, thumbprint, token):
raise NotImplemented

View File

@ -1,8 +1,9 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# acertmgr - ACME challenge webserver
# standalone - standalone ACME challenge webserver
# Copyright (c) Markus Hauschild & David Klaftenegger, 2016.
# Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE
try:
@ -15,8 +16,11 @@ try:
except ImportError:
from http.server import HTTPServer
import os
import threading
from modes.webdir import ChallengeHandler as WebChallengeHandler
# @brief custom request handler for ACME challenges
# @note current working directory is temporarily changed by the script before
@ -51,20 +55,28 @@ def start_standalone(server):
server.serve_forever()
# @brief a simple webserver for challanges
class ACMEHTTPServer:
# @brief create webserver instance
# @param port the port to listen on
def __init__(self, port=80):
HTTPServer.allow_reuse_address = True
HTTPServer.allow_reuse_address = True
class ChallengeHandler(WebChallengeHandler):
def __init__(self, config):
WebChallengeHandler.__init__(self, config)
self.current_directory = os.getcwd()
if "port" in config:
port = int(config["port"])
else:
port = 80
self.server_thread = None
self.server = HTTPServer(("", port), ACMERequestHandler)
# @brief start the webserver
def start(self):
def create_challenge(self, domain, thumbprint, token):
WebChallengeHandler.create_challenge(self, domain, thumbprint, token)
self.server_thread = threading.Thread(target=start_standalone, args=(self.server,))
os.chdir(self.challenge_directory)
self.server_thread.start()
# @brief stop the webserver
def stop(self):
def destroy_challenge(self, domain, thumbprint, token):
self.server.shutdown()
self.server_thread.join()
os.chdir(self.current_directory)
WebChallengeHandler.destroy_challenge(self, domain, thumbprint, token)

47
modes/webdir.py Normal file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# web - http based challenge handler
# Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE
import os
from modes.abstract import AbstractChallengeHandler
try:
from urllib.request import urlopen # Python 3
except ImportError:
from urllib2 import urlopen # Python 2
class ChallengeHandler(AbstractChallengeHandler):
def __init__(self, config):
AbstractChallengeHandler.__init__(self, config)
self.challenge_directory = config.get("webdir", "/var/www/acme-challenge/")
if not os.path.isdir(self.challenge_directory):
raise FileNotFoundError("Challenge directory (%s) does not exist!" % self.challenge_directory)
@staticmethod
def get_challenge_type():
return "http-01"
def create_challenge(self, domain, thumbprint, token):
keyauthorization = "{0}.{1}".format(token, thumbprint)
wellknown_path = os.path.join(self.challenge_directory, token)
with open(wellknown_path, "w") as wellknown_file:
wellknown_file.write(keyauthorization)
# check that the file is in place
wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token)
try:
resp = urlopen(wellknown_url)
resp_data = resp.read().decode('utf8').strip()
assert resp_data == keyauthorization
except (IOError, AssertionError):
os.remove(wellknown_path)
raise ValueError("Wrote file to {0}, but couldn't download {1}".format(
wellknown_path, wellknown_url))
def destroy_challenge(self, domain, thumbprint, token):
os.remove(os.path.join(self.challenge_directory, token))