WIP acme dehydrated
parent
a53d0bc117
commit
5b312bfbad
@ -1,12 +0,0 @@
|
|||||||
# Accept service terms
|
|
||||||
agree-tos
|
|
||||||
|
|
||||||
# Default RSA key size
|
|
||||||
rsa-key-size = {{ acme_key_size }}
|
|
||||||
|
|
||||||
# E-Mail Address for registration
|
|
||||||
email = {{ acme_account_mail }}
|
|
||||||
|
|
||||||
# Use webroot per default
|
|
||||||
authenticator = webroot
|
|
||||||
webroot-path = {{ acme_validation_root_directory }}
|
|
@ -0,0 +1,329 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
# constants
|
||||||
|
|
||||||
|
ACME_DOMAIN_PREFIX = "_acme-challenge"
|
||||||
|
SUBPROCESS_DEFAULT_KWARGS = {"shell": False}
|
||||||
|
SUBPROCESS_ENV = ["/usr/bin/env"]
|
||||||
|
|
||||||
|
|
||||||
|
# subprocess helpers
|
||||||
|
|
||||||
|
def safe_run(args, **kwargs):
|
||||||
|
return subprocess.run(args, **SUBPROCESS_DEFAULT_KWARGS, check=True, **kwargs)
|
||||||
|
|
||||||
|
def safe_call(args, **kwargs):
|
||||||
|
return safe_run(SUBPROCESS_ENV + args, **kwargs)
|
||||||
|
|
||||||
|
def safe_popen(args, subprocess_input, **kwargs):
|
||||||
|
proc = subprocess.Popen(SUBPROCESS_ENV + args, **SUBPROCESS_DEFAULT_KWARGS, stdin=subprocess.PIPE, **kwargs)
|
||||||
|
ret = proc.communicate(input=subprocess_input)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(returncode=proc.returncode, cmd=proc.args)
|
||||||
|
return (proc, *ret)
|
||||||
|
|
||||||
|
|
||||||
|
# shell replacements
|
||||||
|
|
||||||
|
def chmod(mode, *paths):
|
||||||
|
safe_call(["chmod", mode] + [str(path) for path in paths])
|
||||||
|
|
||||||
|
def concat(out_path, *in_paths, mode=0o333):
|
||||||
|
with open(out_path, mode="wb", opener=functools.partial(os.open, mode=mode)) as out_file:
|
||||||
|
for in_path in in_paths:
|
||||||
|
with in_path.open(mode="rb") as in_file:
|
||||||
|
out_file.write(in_file.read())
|
||||||
|
|
||||||
|
def iterdir_recursive(dir_path, suffix=""):
|
||||||
|
ret = []
|
||||||
|
for entry in dir_path.iterdir():
|
||||||
|
if entry.is_dir():
|
||||||
|
ret.extend(iterdir_recursive(entry, suffix=suffix))
|
||||||
|
elif entry.name.endswith(suffix):
|
||||||
|
ret.append(entry)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def nsupdate(key_path, record_name, record_ttl=60, record_type=None, record_data=None, delete_record=False):
|
||||||
|
if delete_record:
|
||||||
|
record_type = record_type or ""
|
||||||
|
record_data = record_data or ""
|
||||||
|
action = f"update delete {record_name} {record_ttl} IN {record_type} {record_data}"
|
||||||
|
else:
|
||||||
|
if not (record_name and record_ttl and record_type and record_data):
|
||||||
|
raise Exception("args missing")
|
||||||
|
action = f"update add {record_name} {record_ttl} IN {record_type} {record_data}"
|
||||||
|
safe_popen(["nsupdate", "-k", str(key_path)], f"{action}\nsend\n")
|
||||||
|
|
||||||
|
|
||||||
|
# hooks code
|
||||||
|
|
||||||
|
|
||||||
|
class DomainData:
|
||||||
|
|
||||||
|
def __init__(self, domain):
|
||||||
|
self.__domain = domain
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain(self):
|
||||||
|
return self.__domain
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain_dir(self):
|
||||||
|
return config.domains_directory / self.__domain
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain_data_file(self):
|
||||||
|
return self.domain_dir / config.domain_data_name
|
||||||
|
|
||||||
|
def run_hooks(self, hook_name, **kwargs):
|
||||||
|
hook_dir = self.domain_dir / hook_name
|
||||||
|
if not hook_dir.exists():
|
||||||
|
return
|
||||||
|
for hook in iterdir_recursive(hook_dir):
|
||||||
|
if os.access(hook, os.X_OK):
|
||||||
|
safe_run([str(hook)]) # TODO args
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_challenge(domain, token_filename, token_value):
|
||||||
|
# token_filename only for http-01 challenges
|
||||||
|
challenge_name = f"{ACME_DOMAIN_PREFIX}.{domain}."
|
||||||
|
nsupdate(key_path=None, record_name=challenge_name, record_ttl=config.challenge_record_ttl, record_type="TXT", record_data=token_value)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_challenge(domain, token_filename, token_value):
|
||||||
|
# token_filename only for http-01 challenges
|
||||||
|
challenge_name = f"{ACME_DOMAIN_PREFIX}.{domain}."
|
||||||
|
nsupdate(key_path=None, record_name=challenge_name, delete_record=True)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_cert(domain, key_path, cert_path, fullchain_path, chain_path, request_path):
|
||||||
|
safe_call(["sync", key_path, cert_path, fullchain_path, chain_path, request_path])
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_cert(domain, key_path, cert_path, fullchain_path, chain_path, timestamp: int):
|
||||||
|
# make public key files readable for all
|
||||||
|
chmod("+r", cert_path, fullchain_path, chain_path)
|
||||||
|
# create legacy key+cert+chain file
|
||||||
|
keyfullchain_path = key_path / ".." / config.keyfullchain_name
|
||||||
|
concat(keyfullchain_path, key_path, fullchain_path, mode=config.key_file_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_ocsp(domain, ocsp_path, timestamp):
|
||||||
|
# make OCSP readable for all
|
||||||
|
chmod("+r", ocsp_path)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def unchanged_cert(domain, key_path, cert_path, fullchain_path, chain_path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_challenge(domain, response):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def request_failure(status_code, reason, req_type, headers):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def generate_csr(domain, cert_dir, alt_names):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def startup_hook():
|
||||||
|
# prepare domains list
|
||||||
|
concat(config.domains_main_file, *iterdir_recursive(config.domains_directory))
|
||||||
|
|
||||||
|
|
||||||
|
def exit_hook(error):
|
||||||
|
if error is None:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# hooks metadata
|
||||||
|
|
||||||
|
arg_infos = {
|
||||||
|
"alt_names": {
|
||||||
|
"help": "All domain names for the current certificate as specified in domains.txt.",
|
||||||
|
},
|
||||||
|
"cert_path": {
|
||||||
|
"help": "The path of the file containing the signed certificate.",
|
||||||
|
"type": Path,
|
||||||
|
},
|
||||||
|
"chain_path": {
|
||||||
|
"help": "The path of the file containing the intermediate certificate(s).",
|
||||||
|
"type": Path,
|
||||||
|
},
|
||||||
|
"cert_dir": {
|
||||||
|
"help": "Certificate output directory for this particular certificate.",
|
||||||
|
"type": Path,
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"help": "The domain name (CN or subject alternative name) being validated.",
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"help": "Contains error message if dehydrated exits with error.",
|
||||||
|
"nargs": "?",
|
||||||
|
},
|
||||||
|
"fullchain_path": {
|
||||||
|
"help": "The path of the file containing the signed full certificate chain.",
|
||||||
|
"type": Path,
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"help": "HTTP headers returned by the CA",
|
||||||
|
},
|
||||||
|
"key_path": {
|
||||||
|
"help": "The path of the file containing the private key.",
|
||||||
|
"type": Path,
|
||||||
|
},
|
||||||
|
"ocsp_path": {
|
||||||
|
"help": "The path of the ocsp stapling file.",
|
||||||
|
"type": Path,
|
||||||
|
},
|
||||||
|
"req_type": {
|
||||||
|
"help": "The kind of request that was made (GET, POST, …).",
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"help": "The specific reason for the error.",
|
||||||
|
},
|
||||||
|
"request_path": {
|
||||||
|
"help": "The path of the file containing the certificate signing request.",
|
||||||
|
"type": Path,
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"help": "The response that the verification server returned."
|
||||||
|
},
|
||||||
|
"status_code": {
|
||||||
|
"help": "The HTML status code that originated the error.",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"help": "Timestamp when the specified certificate was created.",
|
||||||
|
"type": int, # TODO to date
|
||||||
|
},
|
||||||
|
"token_filename": {
|
||||||
|
"help": "The name of the file containing the token to be served for HTTP validation.",
|
||||||
|
},
|
||||||
|
"token_value": {
|
||||||
|
"help": "The name of the file containing the token to be served for HTTP validation.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks = {
|
||||||
|
"deploy_challenge": (deploy_challenge, [
|
||||||
|
"domain",
|
||||||
|
"token_filename",
|
||||||
|
"token_value",
|
||||||
|
]),
|
||||||
|
"clean_challenge": (clean_challenge, [
|
||||||
|
"domain",
|
||||||
|
"token_filename",
|
||||||
|
"token_value",
|
||||||
|
]),
|
||||||
|
"sync_cert": (sync_cert, [
|
||||||
|
"domain",
|
||||||
|
"key_path",
|
||||||
|
"cert_path",
|
||||||
|
"fullchain_path",
|
||||||
|
"chain_path",
|
||||||
|
"request_path",
|
||||||
|
]),
|
||||||
|
"deploy_cert": (deploy_cert, [
|
||||||
|
"domain",
|
||||||
|
"key_path",
|
||||||
|
"cert_path",
|
||||||
|
"fullchain_path",
|
||||||
|
"chain_path",
|
||||||
|
"timestamp",
|
||||||
|
]),
|
||||||
|
"deploy_ocsp": (deploy_ocsp, [
|
||||||
|
"domain",
|
||||||
|
"ocsp_path",
|
||||||
|
"timestamp",
|
||||||
|
]),
|
||||||
|
"unchanged_cert": (unchanged_cert, [
|
||||||
|
"domain",
|
||||||
|
"key_path",
|
||||||
|
"cert_path",
|
||||||
|
"fullchain_path",
|
||||||
|
"chain_path",
|
||||||
|
]),
|
||||||
|
"invalid_challenge": (invalid_challenge, [
|
||||||
|
"domain",
|
||||||
|
"response",
|
||||||
|
]),
|
||||||
|
"request_failure": (request_failure, [
|
||||||
|
"status_code",
|
||||||
|
"reason",
|
||||||
|
"req_type",
|
||||||
|
"headers",
|
||||||
|
]),
|
||||||
|
"generate_csr": (generate_csr, [
|
||||||
|
"domain",
|
||||||
|
"cert_dir",
|
||||||
|
"alt_names",
|
||||||
|
]),
|
||||||
|
"startup_hook": (startup_hook, [
|
||||||
|
]),
|
||||||
|
"exit_hook": (exit_hook, [
|
||||||
|
"error",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# general
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(config_path):
|
||||||
|
return json.loads(config_path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-c", "--config", "--config-file", dest='_config', type=Path, help="Path to config file")
|
||||||
|
subparsers = parser.add_subparsers(dest='_hook', required=True)
|
||||||
|
for hook_name, hook_data in hooks.items():
|
||||||
|
hook_fun, hook_args = hook_data
|
||||||
|
hook_parser = subparsers.add_parser(
|
||||||
|
hook_name,
|
||||||
|
prefix_chars='+',
|
||||||
|
)
|
||||||
|
hook_parser.set_defaults(
|
||||||
|
_func=hook_fun,
|
||||||
|
_parser=hook_parser,
|
||||||
|
)
|
||||||
|
for arg_name in hook_args:
|
||||||
|
hook_parser.add_argument(
|
||||||
|
arg_name,
|
||||||
|
**arg_infos[arg_name],
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
return (args, {key: val for key, val in args.__dict__.items() if not key.startswith("_")})
|
||||||
|
|
||||||
|
|
||||||
|
def external_hook(hook_name, domain, **kwargs):
|
||||||
|
domain_dir = config.domains_directory / domain
|
||||||
|
print(domain)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
global config
|
||||||
|
args, fun_args = parse_args()
|
||||||
|
#config = read_config(args._config)
|
||||||
|
#args._fun(**fun_args)
|
||||||
|
external_hook(args._hook, **fun_args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
Loading…
Reference in New Issue