initial commit
This commit is contained in:
9
roles/acme/defaults/main.yml
Normal file
9
roles/acme/defaults/main.yml
Normal file
@@ -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
|
||||
184
roles/acme/files/acme_renew_cert.py
Executable file
184
roles/acme/files/acme_renew_cert.py
Executable file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import josepy as jose
|
||||
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 select_http01_challenge(order):
|
||||
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, challenge, order, challenge_dir):
|
||||
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,
|
||||
account_key_path,
|
||||
privkey_path,
|
||||
csr_path,
|
||||
certs_dir,
|
||||
challenge_dir,
|
||||
directory_url,
|
||||
days_before_renewal,
|
||||
force = False):
|
||||
logging.info(f"Checking {domain} certificate's expiration date…")
|
||||
now = datetime.now()
|
||||
cert_expiration_date = now
|
||||
|
||||
fullchain_path = os.path.join(certs_dir, "fullchain.pem")
|
||||
try:
|
||||
with open(fullchain_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 {domain} certificate: {e}")
|
||||
|
||||
cert_expiration_days = (cert_expiration_date - now).days
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
csr_pem = crypto_util.make_csr(privkey_pem, [domain])
|
||||
|
||||
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("--force", "-f",
|
||||
action="store_true",
|
||||
help="Force certificates renewal without checking their expiration date.")
|
||||
args = parser.parse_args()
|
||||
|
||||
log_level = logging.WARN if args.quiet else 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)
|
||||
76
roles/acme/tasks/acme.yml
Normal file
76
roles/acme/tasks/acme.yml
Normal file
@@ -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: 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: >-
|
||||
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
|
||||
91
roles/acme/tasks/challenge.yml
Normal file
91
roles/acme/tasks/challenge.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
- 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: Check if Let's Encrypt certificate already exists for {{ domain_name }}
|
||||
# stat:
|
||||
# path: "{{ acme_certs_dir }}/{{ domain_name }}.d/cert.pem"
|
||||
# register: _acme_cert_file
|
||||
|
||||
# - name: Check Let's Encrypt certificate expiration date for {{ domain_name }}
|
||||
# openssl_certificate_info:
|
||||
# path: "{{ acme_certs_dir }}/{{ domain_name }}.d/cert.pem"
|
||||
# valid_at:
|
||||
# thirty_days: "+30d"
|
||||
# register: _acme_cert_validity
|
||||
# when: _acme_cert_file.stat.isreg is defined and _acme_cert_file.stat.isreg
|
||||
|
||||
- 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
|
||||
# when: _acme_cert_validity is skipped or not _acme_cert_validity.valid_at.thirty_days
|
||||
|
||||
- debug:
|
||||
var: _acme_challenge
|
||||
|
||||
# - name: Implement and complete Let's Encrypt challenge for {{ domain_name }}
|
||||
# when: _acme_challenge is not skipped
|
||||
# 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 }}"
|
||||
2
roles/acme/tasks/main.yml
Normal file
2
roles/acme/tasks/main.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
- import_tasks: acme.yml
|
||||
tags: acme
|
||||
1
roles/acme/vars/main.yml
Normal file
1
roles/acme/vars/main.yml
Normal file
@@ -0,0 +1 @@
|
||||
acme_version: 2
|
||||
Reference in New Issue
Block a user