commit d75d1a21e1be77bb2fcb085d00c5c871210a7e39 Author: HgO Date: Thu May 21 20:25:55 2020 +0200 create acme role diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..d40b76e --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,9 @@ +acme_directory: https://acme-v02.api.letsencrypt.org/directory +acme_config_dir: /etc/ssl +acme_keys_dir: "{{ acme_config_dir }}/private" +acme_csr_dir: "{{ acme_config_dir }}/csr" +acme_certs_dir: "{{ acme_config_dir }}/certs" +acme_accounts_dir: "{{ acme_config_dir }}/accounts" +acme_account_key: "acme_account.key" +acme_ssl_group: ssl-cert +acme_challenge_dir: /var/www/acme \ No newline at end of file diff --git a/files/acme_renew_cert.py b/files/acme_renew_cert.py new file mode 100755 index 0000000..0a8a935 --- /dev/null +++ b/files/acme_renew_cert.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 + +import logging +import os +import sys +from datetime import datetime + +import josepy as jose +import acme +from OpenSSL import crypto +from acme import client, messages, challenges, crypto_util + +SSL_CONFIG_DIR="/etc/ssl" +OPENSSL_DATE_FORMAT = '%Y%m%d%H%M%SZ' +USER_AGENT="python-acme" + +def get_cert_expiration_days(cert_path: str): + """Calculate remaining number of days before certificate's expiration. + + :param str cert_path: Path to a certificate file. + + :returns: Number of remaining days before certificate's expiration. + :rtype: int + """ + now = datetime.now() + cert_expiration_date = now + + try: + with open(cert_path, 'rb') as pem_in: + fullchain_pem = crypto.load_certificate(crypto.FILETYPE_PEM, pem_in.read()) + cert_expiration_date = datetime.strptime(fullchain_pem.get_notAfter().decode(), OPENSSL_DATE_FORMAT) + except IOError as e: + logging.warning(f"Unable to load certificate: {e}") + + cert_expiration_days = (cert_expiration_date - now).days + return cert_expiration_days + +def load_or_create_csr(domain: str, csr_path: str, privkey_path: str): + """Load or create a CSR for a given domain. + + :param str domain: A domain name. + :param str csr_path: Path to a CSR file. + :param str privkey_path: Path to a private key. It is used as fallback when the CSR doesn't exist. + + :returns: The CSR in PEM file format. + :rtype: str + """ + logging.info(f"Loading {domain} CSR file from {csr_path}…") + try: + with open(csr_path, 'r') as pem_in: + csr_pem = pem_in.read() + except IOError as e: + logging.warning(f"Unable to load CSR file: {e}") + logging.info(f"Loading {domain} private key from {privkey_path}…") + with open(privkey_path, 'r') as pem_in: + privkey_pem = pem_in.read() + + logging.info(f"Generating a new CSR…") + csr_pem = crypto_util.make_csr(privkey_pem, [domain]) + return csr_pem + +def select_http01_challenge(order: messages.OrderResource): + """Select the HTTP-01 challenge from a given order + + :param messages.OrderResource order: ACME order containing the challenges. + + :returns: The HTTP-01 challenge. + :rtype: challenges.Challenge + """ + for auth in order.authorizations: + for challenge in auth.body.challenges: + if isinstance(challenge.chall, challenges.HTTP01): + return challenge + raise Exception("HTTP-01 challenge was not offered by the CA server.") + +def perform_http01_challenge( + client_acme: client.ClientV2, + challenge: challenges.Challenge, + order: messages.OrderResource, + challenge_dir: str): + """Perform the HTTP-01 challenge in a given directory + + :param client.ClientV2 client_acme: A ACME v2 client + :param challenges.Challenge challenge: A ACME challenge + :param messages.OrderResource order: An ACME order + :param str challenge_dir: The directory containing the challenge + + :returns: The fullchain certificate in PEM file format + :rtype: str + """ + response, validation = challenge.response_and_validation(client_acme.net.key) + validation_filename, validation_content = validation.split('.') + + challenge_path = os.path.join(challenge_dir, validation_filename) + with open(challenge_path, 'w') as f_out: + f_out.write(validation_content) + + client_acme.answer_challenge(challenge, response) + + fullchain_pem = client_acme.poll_and_finalize(order).fullchain_pem + + os.remove(challenge_path) + + return fullchain_pem + +def renew_cert(domain: str, + account_key_path: str, + privkey_path: str, + csr_path: str, + certs_dir: str, + challenge_dir: str, + directory_url: str, + days_before_renewal: int, + force: bool = False): + logging.info(f"Checking {domain} certificate's expiration date…") + fullchain_path = os.path.join(certs_dir, "fullchain.pem") + + cert_expiration_days = get_cert_expiration_days(fullchain_path) + if not force and (cert_expiration_days >= days_before_renewal): + logging.info(f"Certificate expires in {cert_expiration_days} days. Nothing to do.") + return + + logging.info(f"Certificate expires in {cert_expiration_days} days! Renewing certificate…") + + logging.info(f"Loading account key from {account_key_path}…") + with open(account_key_path, 'rb') as pem_in: + account_pem = crypto.load_privatekey(crypto.FILETYPE_PEM, pem_in.read()) + account_jwk = jose.JWKRSA(key=account_pem.to_cryptography_key()) + account_jwk_pub = account_jwk.public_key() + + csr_pem = load_or_create_csr(domain, csr_path, privkey_path) + + client_network = client.ClientNetwork(account_jwk, user_agent=USER_AGENT) + directory = messages.Directory.from_json(client_network.get(directory_url).json()) + client_acme = client.ClientV2(directory, net=client_network) + + logging.info("Registering with ACME account…") + # Here we assume we already have an account + registration_message = messages.NewRegistration( + key=account_jwk_pub, + only_return_existing=True) + registration_response = client_acme._post(directory["newAccount"], registration_message) + account = client_acme._regr_from_response(registration_response) + client_acme.net.account = account + + logging.info("Ordering ACME challenge…") + order = client_acme.new_order(csr_pem) + + logging.info("Selecting HTTP-01 ACME challenge…") + challenge = select_http01_challenge(order) + + logging.info("Performing HTTP-01 ACME challenge…") + fullchain_pem = perform_http01_challenge(client_acme, challenge, order, challenge_dir) + + logging.info(f"Writing {domain} certificates into {certs_dir}…") + certs_pem = [] + for line in fullchain_pem.split('\n'): + if 'BEGIN CERTIFICATE' in line: + cert_pem = '' + + cert_pem += line + '\n' + + if 'END CERTIFICATE' in line: + certs_pem.append(cert_pem) + + cert_path = os.path.join(certs_dir, "cert.pem") + with open(cert_path, 'w') as pem_out: + pem_out.write(certs_pem[0]) + + chain_path = os.path.join(certs_dir, "chain.pem") + with open(chain_path, 'w') as pem_out: + pem_out.write(''.join(certs_pem[1:])) + + with open(fullchain_path, 'w') as pem_out: + pem_out.write(fullchain_pem) + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser( + description="Renew ACME certificates for a list of domains. This script assumes you already have an account on the CA server and a private key for each certificate.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument("domains", nargs='+', + help="List of domain names for which to renew the certificate.") + parser.add_argument("--account_key", "-a", + default=os.path.join(SSL_CONFIG_DIR, "accounts", "acme_account.key"), + help="Path to the account key.") + parser.add_argument("--privkey", "-p", + default=os.path.join(SSL_CONFIG_DIR, "private", "{domain}.pem"), + help="Path to the private certificate.") + parser.add_argument("--csr", "-r", + default=os.path.join(SSL_CONFIG_DIR, "csr", "{domain}.pem"), + help="Path to the CSR file. If the file doesn't exist, it will be generated from the private key and the domain name.") + parser.add_argument("--certs", "-o", + default=os.path.join(SSL_CONFIG_DIR, "certs", "{domain}.d"), + help="Path to the certificates directory.") + parser.add_argument("--challenge", "-c", + default="/var/www/html/.well-known/acme-challenge", + help="Path to the challenge directory.") + parser.add_argument("--directory_url", "-u", + default="https://acme-v02.api.letsencrypt.org/directory", + help="Directory URL on which performing ACME challenges. Only ACME v2 is supported.") + parser.add_argument("--days", "-d", type=int, + default=30, + help="Days before attempting to renew the certificates.") + parser.add_argument("--quiet", "-q", + action="store_true", + help="Quiet mode.") + parser.add_argument("--verbose", "-v", + action="store_true", + help="Increase verbosity.") + parser.add_argument("--force", "-f", + action="store_true", + help="Force certificates renewal without checking their expiration date.") + args = parser.parse_args() + + if args.quiet: + log_level = logging.WARNING + elif args.verbose: + log_level = logging.DEBUG + else: + log_level = logging.INFO + + logging.basicConfig(stream=sys.stdout, level=log_level, format="%(levelname)s:%(message)s") + + for domain in args.domains: + account_key = args.account_key.format(domain=domain) + privkey = args.privkey.format(domain=domain) + csr = args.csr.format(domain=domain) + certs_dir = args.certs.format(domain=domain) + challenge_dir = args.challenge.format(domain=domain) + + renew_cert(domain, + account_key_path=account_key, + privkey_path=privkey, + csr_path=csr, + certs_dir=certs_dir, + challenge_dir=challenge_dir, + directory_url=args.directory_url, + days_before_renewal=args.days, + force=args.force) \ No newline at end of file diff --git a/tasks/acme_challenge.yml b/tasks/acme_challenge.yml new file mode 100644 index 0000000..afd03a0 --- /dev/null +++ b/tasks/acme_challenge.yml @@ -0,0 +1,74 @@ +- name: Create {{ domain_name }} certificates directory + file: + path: "{{ acme_certs_dir }}/{{ domain_name }}.d" + state: directory + owner: root + group: "{{ acme_ssl_group }}" + mode: "755" + tags: acme_install + +- name: Generate Let's Encrypt account key + openssl_privatekey: + path: "{{ acme_accounts_dir }}/{{ acme_account_key }}" + owner: root + group: root + mode: "600" + type: RSA + size: 4096 + tags: acme_account + +- name: Generate Let's Encrypt private key for {{ domain_name }} + openssl_privatekey: + path: "{{ acme_keys_dir }}/{{ domain_name }}.pem" + owner: root + group: "{{ acme_ssl_group }}" + mode: "640" + type: RSA + size: 4096 + +- name: Generate Let's Encrypt CSR for {{ domain_name }} + openssl_csr: + path: "{{ acme_csr_dir }}/{{ domain_name }}.csr" + owner: root + group: "{{ acme_ssl_group }}" + mode: "644" + privatekey_path: "{{ acme_keys_dir }}/{{ domain_name }}.pem" + common_name: "{{ domain_name }}" + +- name: Begin Let's Encrypt challenges for {{ domain_name }} + acme_certificate: + acme_directory: "{{ acme_directory }}" + acme_version: "{{ acme_version }}" + account_key_src: "{{ acme_accounts_dir }}/{{ acme_account_key }}" + account_email: "{{ acme_email }}" + terms_agreed: yes + challenge: http-01 + csr: "{{ acme_csr_dir }}/{{ domain_name }}.csr" + dest: "{{ acme_certs_dir }}/{{ domain_name }}.d/cert.pem" + fullchain_dest: "{{ acme_certs_dir }}/{{ domain_name }}.d/fullchain.pem" + remaining_days: 30 + register: _acme_challenge + +- name: Implement and complete Let's Encrypt challenge for {{ domain_name }} + when: _acme_challenge is changed + block: + - name: Implement http-01 challenge files for {{ domain_name }} + copy: + content: "{{ _acme_challenge.challenge_data[domain_name]['http-01'].resource_value }}" + dest: "{{ acme_challenge_dir }}/{{ _acme_challenge.challenge_data[domain_name]['http-01'].resource }}" + owner: root + group: root + mode: "644" + + - name: Complete Let's Encrypt challenges for {{ domain_name }} + acme_certificate: + acme_directory: "{{ acme_directory }}" + acme_version: "{{ acme_version }}" + account_key_src: "{{ acme_accounts_dir }}/{{ acme_account_key }}" + account_email: "{{ acme_email }}" + challenge: http-01 + csr: "{{ acme_csr_dir }}/{{ domain_name }}.csr" + dest: "{{ acme_certs_dir }}/{{ domain_name }}.d/cert.pem" + chain_dest: "{{ acme_certs_dir }}/{{ domain_name }}.d/chain.pem" + fullchain_dest: "{{ acme_certs_dir }}/{{ domain_name }}.d/fullchain.pem" + data: "{{ _acme_challenge }}" \ No newline at end of file diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..3f53678 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,76 @@ +- name: Install ACME dependencies + apt: + name: python3-acme + state: present + tags: acme_install + +- name: Create Let's Encrypt config directories + file: + path: "{{ config_dir }}" + state: directory + owner: root + group: "{{ acme_ssl_group }}" + mode: "711" + loop: + - "{{ acme_config_dir }}" + - "{{ acme_keys_dir }}" + - "{{ acme_accounts_dir }}" + - "{{ acme_csr_dir }}" + loop_control: + loop_var: config_dir + tags: acme_install + +- name: Create challenge directory + file: + path: "{{ acme_challenge_dir }}/.well-known/acme-challenge" + state: directory + owner: root + group: root + mode: "755" + tags: acme_install + +- name: Perform ACME challenge for each domain + include_tasks: + file: acme_challenge.yml + apply: + tags: acme_challenge + loop: "{{ acme_domains | unique }}" + loop_control: + loop_var: domain_name + tags: acme_challenge + +- name: Create directory for certificate renewal tool + file: + path: /opt/acme + owner: root + group: root + mode: "755" + state: directory + tags: acme_renew + +- name: Copy script to renew ACME certificates + copy: + src: acme_renew_cert.py + dest: /opt/acme/acme_renew_cert.py + owner: root + group: root + mode: "755" + tags: acme_renew + +- name: Setup cron job for ACME certificates renewal of {{ domain_name }} + cron: + name: acme renew {{ domain_name }} cert + job: >- + bash -c 'sleep $((RANDOM \% 3600))' && /opt/acme/acme_renew_cert.py {{ domain_name }} -q + -a {{ (acme_accounts_dir + '/' + acme_account_key) | quote }} + -p {{ acme_keys_dir | quote }}/{domain}.pem + -r {{ acme_csr_dir | quote }}/{domain}.csr + -o {{ acme_certs_dir | quote }}/{domain}.d + -c {{ acme_challenge_dir | quote }}/.well-known/acme-challenge + minute: "30" + hour: "2" + state: present + loop: "{{ acme_domains | unique }}" + loop_control: + loop_var: domain_name + tags: acme_renew \ No newline at end of file diff --git a/vars/main.yml b/vars/main.yml new file mode 100644 index 0000000..8fb9bcb --- /dev/null +++ b/vars/main.yml @@ -0,0 +1 @@ +acme_version: 2 \ No newline at end of file