Compare commits
3 Commits
0ea8d56473
...
efab0cadf5
Author | SHA1 | Date |
---|---|---|
HgO | efab0cadf5 | |
HgO | 334f609513 | |
HgO | 6c0b376459 |
|
@ -1,27 +1,36 @@
|
||||||
acme_config_dir: /etc/ssl
|
acme_ssl_dir: /etc/ssl
|
||||||
acme_config_file: "{{ acme_config_dir }}/acme.yml"
|
acme_config_dir: "{{ acme_ssl_dir }}/acme.d"
|
||||||
acme_keys_dir: "{{ acme_config_dir }}/private"
|
acme_config_file: "{{ acme_ssl_dir }}/acme.yml"
|
||||||
acme_csr_dir: "{{ acme_config_dir }}/csr"
|
acme_keys_dir: "{{ acme_ssl_dir }}/private"
|
||||||
acme_certs_dir: "{{ acme_config_dir }}/certs"
|
acme_csr_dir: "{{ acme_ssl_dir }}/csr"
|
||||||
acme_accounts_dir: "{{ acme_config_dir }}/accounts"
|
acme_certs_dir: "{{ acme_ssl_dir }}/certs"
|
||||||
|
acme_accounts_dir: "{{ acme_ssl_dir }}/accounts"
|
||||||
acme_script_dir: /opt/acme
|
acme_script_dir: /opt/acme
|
||||||
acme_script_bin: /usr/local/bin/acme-renew-cert
|
acme_script_bin: /usr/local/bin/acme-renew-cert
|
||||||
|
|
||||||
acme_ssl_group: ssl-cert
|
acme_ssl_group: ssl-cert
|
||||||
|
|
||||||
|
acme_account_private_key: "{{ acme_accounts_dir }}/acme_account.key"
|
||||||
|
acme_account_email: acme@example.com
|
||||||
|
acme_account_owner: root
|
||||||
|
acme_account_group: "{{ acme_account_group }}"
|
||||||
|
acme_directory_url: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
acme_root_dir: /var/www/acme
|
||||||
|
acme_challenge_dir: "{{ acme_challenge_root_dir }}/.well-known/acme-challenge"
|
||||||
|
acme_domains:
|
||||||
|
example.com:
|
||||||
|
alt_names:
|
||||||
|
- test.example.com
|
||||||
|
owner: root
|
||||||
|
group: "{{ acme_ssl_group }}"
|
||||||
|
remaining_days: 30
|
||||||
|
hooks:
|
||||||
|
- systemctl reload nginx
|
||||||
acme_config:
|
acme_config:
|
||||||
account:
|
account:
|
||||||
private_key: "{{ acme_accounts_dir }}/acme_account.key"
|
private_key: "{{ acme_account_private_key }}"
|
||||||
email: acme@example.com
|
email: "{{ acme_account_email }}"
|
||||||
owner: root
|
owner: "{{ acme_account_owner }}"
|
||||||
group: root
|
group: "{{ acme_account_group }}"
|
||||||
directory_url: https://acme-staging-v02.api.letsencrypt.org/directory
|
directory_url: "{{ acme_directory_url }}"
|
||||||
challenge_dir: /var/www/acme/.well-known/acme-challenge
|
challenge_dir: "{{ acme_challenge_dir }}"
|
||||||
domains:
|
|
||||||
example.com:
|
|
||||||
alt_names:
|
|
||||||
- test.example.com
|
|
||||||
owner: root
|
|
||||||
group: "{{ acme_ssl_group }}"
|
|
||||||
remaining_days: 30
|
|
||||||
hooks:
|
|
||||||
- systemctl reload nginx
|
|
|
@ -1,5 +1,6 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import glob
|
||||||
import grp
|
import grp
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
@ -28,6 +29,8 @@ try:
|
||||||
except KeyError:
|
except KeyError:
|
||||||
SSL_GROUP = "root"
|
SSL_GROUP = "root"
|
||||||
|
|
||||||
|
ACME_CONFIG_FILE = f"{SSL_CONFIG_DIR}/acme.yml"
|
||||||
|
ACME_CONFIG_DIR = f"{SSL_CONFIG_DIR}/acme.d"
|
||||||
ACME_ACCOUNT_KEY = os.path.join(SSL_ACCOUNTS_DIR, "acme_account.key")
|
ACME_ACCOUNT_KEY = os.path.join(SSL_ACCOUNTS_DIR, "acme_account.key")
|
||||||
ACME_CHALLENGE_DIR = "/var/www/acme/.well-known/acme-challenge"
|
ACME_CHALLENGE_DIR = "/var/www/acme/.well-known/acme-challenge"
|
||||||
LETSENCRYPT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
|
LETSENCRYPT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
@ -176,9 +179,12 @@ class Challenge:
|
||||||
class Domain(PrivateKey):
|
class Domain(PrivateKey):
|
||||||
def __init__(self, name: str, owner: str, group: str,
|
def __init__(self, name: str, owner: str, group: str,
|
||||||
alt_names: List[str] = None,
|
alt_names: List[str] = None,
|
||||||
remaining_days: int = CERT_REMAINING_DAYS,
|
cert: str = None, chain_cert: str = None,
|
||||||
|
fullchain_cert: str = None, private_key: str = None,
|
||||||
|
csr: str = None, remaining_days: int = CERT_REMAINING_DAYS,
|
||||||
hooks: List[str] = None):
|
hooks: List[str] = None):
|
||||||
private_key = os.path.join(SSL_KEYS_DIR, name + '.key')
|
if private_key is None:
|
||||||
|
private_key = os.path.join(SSL_KEYS_DIR, name + '.key')
|
||||||
super().__init__(private_key, owner, group)
|
super().__init__(private_key, owner, group)
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
|
@ -187,10 +193,23 @@ class Domain(PrivateKey):
|
||||||
self.hooks = [] if hooks is None else hooks
|
self.hooks = [] if hooks is None else hooks
|
||||||
|
|
||||||
certs_dir = os.path.join(SSL_CERTS_DIR, name + '.d')
|
certs_dir = os.path.join(SSL_CERTS_DIR, name + '.d')
|
||||||
self.csr = os.path.join(SSL_CSR_DIR, name + '.csr')
|
if cert is None:
|
||||||
self.cert = os.path.join(certs_dir, 'cert.pem')
|
self.cert = os.path.join(certs_dir, 'cert.pem')
|
||||||
self.chain_cert = os.path.join(certs_dir, 'chain.pem')
|
else:
|
||||||
self.fullchain_cert = os.path.join(certs_dir, 'fullchain.pem')
|
self.cert = cert
|
||||||
|
if chain_cert is None:
|
||||||
|
self.chain_cert = os.path.join(certs_dir, 'chain.pem')
|
||||||
|
else:
|
||||||
|
self.chain_cert = chain_cert
|
||||||
|
if fullchain_cert is None:
|
||||||
|
self.fullchain_cert = os.path.join(certs_dir, 'fullchain.pem')
|
||||||
|
else:
|
||||||
|
self.fullchain_cert = fullchain_cert
|
||||||
|
if csr is None:
|
||||||
|
self.csr = os.path.join(SSL_CSR_DIR, name + '.csr')
|
||||||
|
else:
|
||||||
|
self.csr = csr
|
||||||
|
|
||||||
self.cert_expiration_days = None
|
self.cert_expiration_days = None
|
||||||
|
|
||||||
def check_certificate_expiration_date(self) -> bool:
|
def check_certificate_expiration_date(self) -> bool:
|
||||||
|
@ -315,14 +334,95 @@ class Domain(PrivateKey):
|
||||||
logging.error(f"Unable to run hook: {hook_res.stderr}")
|
logging.error(f"Unable to run hook: {hook_res.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, filename, directory):
|
||||||
|
self.filename = filename
|
||||||
|
self.directory = directory
|
||||||
|
self.config = {}
|
||||||
|
self.domain_configs = {}
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
with open(self.filename, 'r') as ifd:
|
||||||
|
self._data = yaml.safe_load(ifd)
|
||||||
|
|
||||||
|
for pattern in ('*.yml', '*.yaml'):
|
||||||
|
config_glob = os.path.join(self.directory, pattern)
|
||||||
|
for config_file in glob.glob(config_glob):
|
||||||
|
with open(config_file, 'r') as ifd:
|
||||||
|
self.domain_configs[config_file] = yaml.safe_load(ifd)
|
||||||
|
|
||||||
|
def parse_domains(self) -> List[Domain]:
|
||||||
|
domains = self._data.get("domains", {})
|
||||||
|
|
||||||
|
domain_uniques = set()
|
||||||
|
domain_duplicates = set()
|
||||||
|
if isinstance(domains, list):
|
||||||
|
for domain in domains:
|
||||||
|
domain_name = domain["name"]
|
||||||
|
if domain_name not in domain_uniques:
|
||||||
|
domain_uniques.add(domain_name)
|
||||||
|
else:
|
||||||
|
domain_duplicates.add(domain_name)
|
||||||
|
domains = {domain["name"]: domain for domain in domains}
|
||||||
|
|
||||||
|
for config_file, domain_config in self.domain_configs.items():
|
||||||
|
domain_filename, _ = os.path.splitext(config_file)
|
||||||
|
domain_name = domain_config.get("name", domain_filename)
|
||||||
|
if domain_name not in domain_uniques:
|
||||||
|
domain_uniques.add(domain_name)
|
||||||
|
else:
|
||||||
|
domain_duplicates.add(domain_name)
|
||||||
|
domains[domain_name] = domain_config
|
||||||
|
|
||||||
|
if len(domain_duplicates) > 0:
|
||||||
|
raise RuntimeError(f"Duplicate domains found: {domain_duplicates}")
|
||||||
|
|
||||||
|
domains_parsed = []
|
||||||
|
for domain_name, domain_details in domains.items():
|
||||||
|
remaining_days = domain_details.get("remaining_days",
|
||||||
|
CERT_REMAINING_DAYS)
|
||||||
|
domain = Domain(
|
||||||
|
name=domain_name,
|
||||||
|
owner=domain_details.get("owner", "root"),
|
||||||
|
group=domain_details.get("group", SSL_GROUP),
|
||||||
|
alt_names=domain_details.get("alt_names"),
|
||||||
|
cert=domain_details.get("cert"),
|
||||||
|
chain_cert=domain_details.get("chain_cert"),
|
||||||
|
fullchain_cert=domain_details.get("fullchain_cert"),
|
||||||
|
private_key=domain_details.get("private_key"),
|
||||||
|
csr=domain_details.get("csr"),
|
||||||
|
remaining_days=remaining_days,
|
||||||
|
hooks=domain_details.get("hooks"))
|
||||||
|
domains_parsed.append(domain)
|
||||||
|
return domains_parsed
|
||||||
|
|
||||||
|
def parse_account(self) -> Account:
|
||||||
|
account_config = self._data["account"]
|
||||||
|
return Account(
|
||||||
|
private_key=account_config.get("private_key", ACME_ACCOUNT_KEY),
|
||||||
|
email=account_config["email"],
|
||||||
|
owner=account_config.get("owner", "root"),
|
||||||
|
group=account_config.get("group", "root"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def directory_url(self) -> str:
|
||||||
|
return self._data.get("directory_url", LETSENCRYPT_DIRECTORY_URL)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def challenge_dir(self) -> str:
|
||||||
|
return self._data.get("challenge_dir", ACME_CHALLENGE_DIR)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Renew ACME certificates for a list of domains. This "
|
description="Renew ACME certificates for a list of domains. This "
|
||||||
"script assumes you already have an account on the CA "
|
"script assumes you already have an account on the CA "
|
||||||
"server and a private key for each certificate.",
|
"server and a private key for each certificate.",
|
||||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||||
parser.add_argument("--config", "-c", default=SSL_CONFIG_FILE,
|
parser.add_argument("--config", "-c", default=ACME_CONFIG_FILE,
|
||||||
help="Path to the config file.")
|
help="Path to the config file.")
|
||||||
|
parser.add_argument("--config-dir", "-d", default=ACME_CONFIG_DIR,
|
||||||
|
help="Path to the config directory.")
|
||||||
parser.add_argument("--quiet", "-q", action="store_true",
|
parser.add_argument("--quiet", "-q", action="store_true",
|
||||||
help="Quiet mode.")
|
help="Quiet mode.")
|
||||||
parser.add_argument("--verbose", "-v", action="store_true",
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
|
@ -342,40 +442,22 @@ def main():
|
||||||
logging.basicConfig(stream=sys.stdout, level=log_level,
|
logging.basicConfig(stream=sys.stdout, level=log_level,
|
||||||
format="%(levelname)s:%(message)s")
|
format="%(levelname)s:%(message)s")
|
||||||
|
|
||||||
|
config = Config(args.config, args.config_dir)
|
||||||
try:
|
try:
|
||||||
with open(args.config, 'r') as ifd:
|
config.load()
|
||||||
config = yaml.safe_load(ifd)
|
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
logging.error(f"Unable to load config file: {e}")
|
logging.error(f"Unable to load config file: {e}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if isinstance(config["domains"], list):
|
try:
|
||||||
domains = {domain["name"]: domain for domain in config["domains"]}
|
domains = config.parse_domains()
|
||||||
if len(domains) != len(config["domains"]):
|
except RuntimeError as e:
|
||||||
domain_uniques = set()
|
logging.error(f"Unable to parse domains: {e}")
|
||||||
domain_duplicates = set()
|
return 2
|
||||||
for domain in config["domains"]:
|
|
||||||
domain_name = domain["name"]
|
|
||||||
if domain_name not in domain_uniques:
|
|
||||||
domain_uniques.add(domain_name)
|
|
||||||
else:
|
|
||||||
domain_duplicates.add(domain_name)
|
|
||||||
logging.error(f"Duplicate domain name(s) found: {domain_uniques}")
|
|
||||||
return 2
|
|
||||||
config["domains"] = domains
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
domains_to_renew = []
|
domains_to_renew = []
|
||||||
for domain_name, domain_details in config["domains"].items():
|
for domain in domains:
|
||||||
remaining_days = domain_details.get("remaining_days",
|
|
||||||
CERT_REMAINING_DAYS)
|
|
||||||
domain = Domain(domain_name,
|
|
||||||
owner=domain_details.get("owner", "root"),
|
|
||||||
group=domain_details.get("group", SSL_GROUP),
|
|
||||||
alt_names=domain_details.get("alt_names"),
|
|
||||||
remaining_days=remaining_days,
|
|
||||||
hooks=domain_details.get("hooks"))
|
|
||||||
|
|
||||||
if not (args.force or domain.check_certificate_expiration_date()):
|
if not (args.force or domain.check_certificate_expiration_date()):
|
||||||
logging.info(f"{domain.name} certificate will not be renewed.")
|
logging.info(f"{domain.name} certificate will not be renewed.")
|
||||||
continue
|
continue
|
||||||
|
@ -387,18 +469,9 @@ def main():
|
||||||
logging.info("No domain to renew. Aborting.")
|
logging.info("No domain to renew. Aborting.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
account_config = config["account"]
|
account = config.parse_account()
|
||||||
account = Account(
|
acme_client = account.register(config.directory_url)
|
||||||
private_key=account_config.get("private_key", ACME_ACCOUNT_KEY),
|
challenge = Challenge(config.challenge_dir, acme_client)
|
||||||
email=account_config["email"],
|
|
||||||
owner=account_config.get("owner", "root"),
|
|
||||||
group=account_config.get("group", "root"))
|
|
||||||
|
|
||||||
directory_url = config.get("directory_url", LETSENCRYPT_DIRECTORY_URL)
|
|
||||||
acme_client = account.register(directory_url)
|
|
||||||
|
|
||||||
challenge_dir = config.get("challenge_dir", ACME_CHALLENGE_DIR)
|
|
||||||
challenge = Challenge(challenge_dir, acme_client)
|
|
||||||
|
|
||||||
for domain in domains_to_renew:
|
for domain in domains_to_renew:
|
||||||
domain.renew_cert(challenge, force=args.force)
|
domain.renew_cert(challenge, force=args.force)
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "755"
|
mode: "755"
|
||||||
loop:
|
loop:
|
||||||
|
- "{{ acme_ssl_dir }}"
|
||||||
- "{{ acme_config_dir }}"
|
- "{{ acme_config_dir }}"
|
||||||
- "{{ acme_certs_dir }}"
|
- "{{ acme_certs_dir }}"
|
||||||
- "{{ acme_csr_dir }}"
|
- "{{ acme_csr_dir }}"
|
||||||
|
@ -52,7 +53,22 @@
|
||||||
dest: "{{ acme_config_file }}"
|
dest: "{{ acme_config_file }}"
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: root
|
||||||
mode: "600"
|
mode: "640"
|
||||||
|
tags: [acme_install, acme_config]
|
||||||
|
|
||||||
|
- name: Copy ACME domain config files
|
||||||
|
copy:
|
||||||
|
content: "{{ domain | to_nice_yaml(indent=2) }}"
|
||||||
|
dest: "{{ acme_config_dir }}/{{ domain_name }}.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "640"
|
||||||
|
loop: "{{ (acme_domains.keys() | list) if acme_domains is mapping else acme_domains }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ domain_name }}"
|
||||||
|
vars:
|
||||||
|
domain_name: "{{ item if item is string else item.name }}"
|
||||||
|
domain: "{{ acme_domains[item] if item is string else item }}"
|
||||||
tags: [acme_install, acme_config]
|
tags: [acme_install, acme_config]
|
||||||
|
|
||||||
- name: Create directory for certificate renewal tool
|
- name: Create directory for certificate renewal tool
|
||||||
|
@ -84,7 +100,7 @@
|
||||||
tags: acme_install
|
tags: acme_install
|
||||||
|
|
||||||
- name: Perform ACME challenge for each domain
|
- name: Perform ACME challenge for each domain
|
||||||
command: acme-renew-cert -c {{ acme_config_file | quote }}
|
command: acme-renew-cert -c {{ acme_config_file | quote }} -d {{ acme_config_dir | quote }}
|
||||||
changed_when: "'No domain to renew' not in _acme_challenge.stdout"
|
changed_when: "'No domain to renew' not in _acme_challenge.stdout"
|
||||||
register: _acme_challenge
|
register: _acme_challenge
|
||||||
tags: acme_challenge
|
tags: acme_challenge
|
||||||
|
@ -94,7 +110,7 @@
|
||||||
user: root
|
user: root
|
||||||
name: acme-renew-cert
|
name: acme-renew-cert
|
||||||
cron_file: acme-renew-cert
|
cron_file: acme-renew-cert
|
||||||
job: "{{ acme_script_bin }} -q -c {{ acme_config_file | quote }}"
|
job: "{{ acme_script_bin }} -q -c {{ acme_config_file | quote }} -d {{ acme_config_dir | quote }}"
|
||||||
minute: "30"
|
minute: "30"
|
||||||
hour: "2"
|
hour: "2"
|
||||||
state: present
|
state: present
|
||||||
|
|
Loading…
Reference in New Issue