diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..500196c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +Thumbs.db +.vscode \ No newline at end of file diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..76fa94b --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,48 @@ +--- +# Certbot +# +# Ansible Role +# Linux-Server-Admin.com + +# +# Default Variables for Certbot +# + +certbot_admin_email: root@localhost +certbot_python: python3-certbot-nginx # python3-certbot-apache +certbot_debug: false + +certbot_vhosts: [] +# Example: +# certbot_vhosts: +# name: "domain.com" +# documentroot: "/var/www/" +# alias: +# - "" + +# Install certbot packages +# certbot_install: true + +# certbot_temp_dir: "/tmp/letsencrypt/" + +# +# Optional vars +# + +# Webserver +# certbot_webserver: not defined by default # apache2 (or httpd for CentOS) or nginx +# certbot_webserver_plugin_install: true + +# FreeIPA Integration +# certbot_freeipa: false +# FreeIPA Domain for WebGUI, default is inventory_hostname +# certbot_ipa_domain: "{{inventory_hostname}}" + + +# Readme & Host Documentation +# certbot_readme: false +# certbot_readme_mode: "0640" +# certbot_readme_path: "/etc/ansible/readme/" + +# Certbot set facts +# certbot_facts: false \ No newline at end of file diff --git a/tasks/facts.yml b/tasks/facts.yml new file mode 100644 index 0000000..cb8f1c4 --- /dev/null +++ b/tasks/facts.yml @@ -0,0 +1,54 @@ +--- +# Certbot +# +# Linux-Server-Admin.com Ansible Role for cert management with Certbot +# +# Fact Tasks +# + +- name: Install python3 and pip (inkl. venv) + ansible.builtin.package: + name: + - python3 + - python3-pip + - python3-psutil + - python3-venv + state: latest + update_cache: true + become: true + +- name: Create python venv for facts + ansible.builtin.command: + cmd: python3 -m venv /opt/ansible-facts-venv + become: true + args: + creates: /opt/ansible-facts-venv + +- name: Install pyyaml in venv + ansible.builtin.command: + cmd: /opt/ansible-facts-venv/bin/pip install pyyaml + become: true + +- name: "Create certbot parse facts script" + ansible.builtin.template: + src: "certbot-certificates.py.j2" + dest: "/usr/local/bin/ansible_certbot_parse_facts.py" + mode: +x + become: true + +- name: "Create directory for ansible system facts" + ansible.builtin.file: + state: directory + recurse: true + path: /etc/ansible/facts.d + become: true + +- name: "Set certbot fact file" + ansible.builtin.template: + src: "certbot.fact.j2" + dest: "/etc/ansible/facts.d/certbot.json" + become: true + +- name: Run certbot parse script in venv + ansible.builtin.shell: certbot certificates | /opt/ansible-facts-venv/bin/python3 /usr/local/bin/ansible_certbot_parse_facts.py + become: true \ No newline at end of file diff --git a/tasks/install.yml b/tasks/install.yml new file mode 100644 index 0000000..6cfc166 --- /dev/null +++ b/tasks/install.yml @@ -0,0 +1,32 @@ +--- +# Certbot +# +# Linux-Server-Admin.com Ansible Role for cert management with Certbot +# +# Install Tasks +# + +- name: Install EPEL Release + ansible.builtin.package: + name: "epel-release" + state: latest + update_cache: true + when: ansible_facts["os_family"] == "RedHat" + become: true + +- name: Install Certbot + ansible.builtin.package: + name: "certbot" + state: latest + update_cache: true + become: true + +- name: Install Certbot's Nginx/Apache package + ansible.builtin.package: + name: "{{ certbot_python }}" + state: latest + when: + - not certbot_freeipa | default(false) | bool + - certbot_webserver is defined + - certbot_webserver_plugin_install | default(true) | bool + become: true diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..400908c --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,80 @@ +--- +# Certbot +# +# Linux-Server-Admin.com Ansible Role for cert management with Certbot +# +# Main Tasks +# + +- name: "Check if certbot_debug is defined and true and if set debug_nolog to false for all sensitive tasks" + set_fact: + debug_nolog: false + when: certbot_debug is defined and certbot_debug is true + +- name: "Install Certbot" + include_tasks: install.yml + when: certbot_install | default(true) | bool + +- shell: "certbot --version" + register: __certbot_version + +- debug: + var: __certbot_version + when: certbot_debug is defined and certbot_debug is true + +- name: Check Webserver + debug: + msg: "Selected Webserver: {{ certbot_webserver }}" + when: certbot_webserver is defined and certbot_debug is defined and certbot_debug is true + +- name: "Check if certificate already exists" + ansible.builtin.stat: + path: /etc/letsencrypt/live/{{ item.name }}/cert.pem + register: certbot_vhosts_host + with_items: "{{ certbot_vhosts }}" + become: true + +- name: "Generate certificate scripts" + ansible.builtin.template: + src: "generate-cert.sh.j2" + dest: "/usr/local/bin/certbot-{{ item.item.name }}.sh" + mode: +x + with_items: "{{ certbot_vhosts_host.results }}" + become: true +# no_log: debug_nolog | default(true) | bool + +- name: "Exec cert script" + ansible.builtin.shell: '/usr/local/bin/certbot-{{ item.item.name }}.sh' + with_items: "{{ certbot_vhosts_host.results }}" + become: true +# no_log: debug_nolog | default(true) | bool + +# list all installed certificates +- name: "List all installed certificates" + ansible.builtin.command: + cmd: "certbot certificates" + register: __certbot_certificates + failed_when: false + changed_when: false + become: true +# when: certbot_debug is defined and certbot_debug is true + +- debug: + var: __certbot_certificates.stdout_lines + when: certbot_debug is defined and certbot_debug is true + +- name: "Generate LetsEncrypt FreeIPA Integration script" + ansible.builtin.template: + src: "letsencrypt-freeipa.sh.j2" + dest: "/usr/local/bin/letsencrypt-freeipa.sh" + mode: +x + when: certbot_freeipa | default(false) | bool + become: true + +- name: "Setup Certbot facts" + include_tasks: facts.yml + when: certbot_facts | default(false) | bool + +- name: "Setup Certbot readme" + include_tasks: readme.yml + when: certbot_readme | default(false) | bool \ No newline at end of file diff --git a/tasks/readme.yml b/tasks/readme.yml new file mode 100644 index 0000000..2b04650 --- /dev/null +++ b/tasks/readme.yml @@ -0,0 +1,21 @@ +--- +# Certbot +# +# Linux-Server-Admin.com Ansible Role for cert management with Certbot +# +# Readme Tasks +# + +- name: "Create Readme Directory" + ansible.builtin.file: + path: "{{ certbot_readme_path | default('/etc/ansible/readme/') }}" + state: directory + mode: "{{ certbot_readme_mode | default('0640') }}" + become: true + +- name: "Update Readme" + ansible.builtin.template: + src: "certbot.md.j2" + dest: "{{ certbot_readme_path | default('/etc/ansible/readme/') }}certbot.md" + mode: "{{ certbot_readme_mode | default('0640') }}" + become: true \ No newline at end of file diff --git a/templates/certbot-certificates.py.j2 b/templates/certbot-certificates.py.j2 new file mode 100644 index 0000000..66d125b --- /dev/null +++ b/templates/certbot-certificates.py.j2 @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# filepath: /usr/local/bin/certbot_parse_facts.py + +import sys +import yaml + +def parse_certbot_output(lines): + certs = [] + cert = {} + for line in lines: + if line.startswith(" Certificate Name:"): + if cert: + certs.append(cert) + cert = {} + cert["name"] = line.split(":", 1)[1].strip() + elif line.strip().startswith("Serial Number:"): + cert["serial"] = line.split(":", 1)[1].strip() + elif line.strip().startswith("Key Type:"): + cert["key_type"] = line.split(":", 1)[1].strip() + elif line.strip().startswith("Domains:"): + cert["domains"] = line.split(":", 1)[1].strip() + elif line.strip().startswith("Expiry Date:"): + cert["expiry"] = line.split(":", 1)[1].strip() + elif line.strip().startswith("Certificate Path:"): + cert["cert_path"] = line.split(":", 1)[1].strip() + elif line.strip().startswith("Private Key Path:"): + cert["key_path"] = line.split(":", 1)[1].strip() + if cert: + certs.append(cert) + return {"certificates": certs} + +def sort_certificates_by_name(facts): + facts["certificates"].sort(key=lambda c: c.get("name", "").lower()) + +if __name__ == "__main__": + # Read lines from stdin + lines = [line.rstrip("\n") for line in sys.stdin] + facts = parse_certbot_output(lines) + # Write facts but sorted by certificate name + sort_certificates_by_name(facts) + # Output to YAML file for Ansible facts + with open("/etc/ansible/facts.d/certbot.certificates.yml", "w") as f: + yaml.dump(facts, f, default_flow_style=False) \ No newline at end of file diff --git a/templates/certbot.certificates.fact.j2 b/templates/certbot.certificates.fact.j2 new file mode 100644 index 0000000..45263ee --- /dev/null +++ b/templates/certbot.certificates.fact.j2 @@ -0,0 +1,35 @@ +{ + "certificates": [ + {% set cert = {} %} + {% for line in certbot_certificates %} + {% if line.startswith(' Certificate Name:') %} + {% if cert %} + {{ cert | to_nice_json }},{% set cert = {} %} + {% endif %} + {% set cert = cert.copy() %} + {% set cert['name'] = line.split(':', 1)[1].strip() %} + {% elif line.startswith(' Serial Number:') %} + {% set cert = cert.copy() %} + {% set cert['serial'] = line.split(':', 1)[1].strip() %} + {% elif line.startswith(' Key Type:') %} + {% set cert = cert.copy() %} + {% set cert['key_type'] = line.split(':', 1)[1].strip() %} + {% elif line.startswith(' Domains:') %} + {% set cert = cert.copy() %} + {% set cert['domains'] = line.split(':', 1)[1].strip() %} + {% elif line.startswith(' Expiry Date:') %} + {% set cert = cert.copy() %} + {% set cert['expiry'] = line.split(':', 1)[1].strip() %} + {% elif line.startswith(' Certificate Path:') %} + {% set cert = cert.copy() %} + {% set cert['cert_path'] = line.split(':', 1)[1].strip() %} + {% elif line.startswith(' Private Key Path:') %} + {% set cert = cert.copy() %} + {% set cert['key_path'] = line.split(':', 1)[1].strip() %} + {% endif %} + {% if loop.last and cert %} + {{ cert | to_nice_json }} + {% endif %} + {% endfor %} + ] +} \ No newline at end of file diff --git a/templates/certbot.fact.j2 b/templates/certbot.fact.j2 new file mode 100644 index 0000000..99b4084 --- /dev/null +++ b/templates/certbot.fact.j2 @@ -0,0 +1,4 @@ +{ + "installed":"true", + "version":"{{ __certbot_version | regex_replace('^.*certbot ([\\w\\.\\-]+).*?$', '\\1') }}" +} \ No newline at end of file diff --git a/templates/certbot.md.j2 b/templates/certbot.md.j2 new file mode 100644 index 0000000..568fc92 --- /dev/null +++ b/templates/certbot.md.j2 @@ -0,0 +1,43 @@ +{{ certbot_message | default (admin_message ) | comment }} + +# Certbot + +Certbot {{ __certbot_version | regex_replace('^.*certbot ([\\w\\.\\-]+).*?$', '\\1') }} is installed on host {{ inventory_hostname }} + +For all certs helper scripts (/usr/local/bin/certbot-[domainname].sh) and a cronjob (to update the certs) are installed. + +{% if certbot_webserver is defined %} +The Webserver "{{ certbot_webserver }}" is defined as the default one which interacts with the certbot plugin. +{% endif %} + + +## Certs + +{{ __certbot_certificates.stdout }} + + +## Facts + +{% if certbot_facts | default(false) %} + +Facts are saved on the host in these files: + + - /etc/ansible/facts.d/certbot.json + - /etc/ansible/facts.d/certbot.certificates.yml + + +### Update facts without Ansible + +To update the facts run: +certbot certificates | /opt/ansible-facts-venv/bin/python3 /usr/local/bin/ansible_certbot_parse_facts.py + +{% else %} +Facts are not saved on the host. To enable this set certbot_facts to true in your inventory. +{% endif %} + +--- + +Any Questions? + +**Linux-Server-Admin.com** + diff --git a/templates/generate-cert.sh.j2 b/templates/generate-cert.sh.j2 new file mode 100644 index 0000000..a3f2b1e --- /dev/null +++ b/templates/generate-cert.sh.j2 @@ -0,0 +1,56 @@ +#!/bin/bash +# +# Create Lets Encrypt Cert for {{ item.item.name }} +# If the Cert is already created, it will just perform a quiet "certbot renew". +# +# Linux-Server-Admin.com +# +{{ certbot_message | default (admin_message ) | comment }} +# +# +# /usr/local/bin/certbot-{{ item.item.name }}.sh +# + +CERT="/etc/letsencrypt/live/{{ item.item.name }}/" + +if [ ! -d "$CERT" ]; then +{% if certbot_webserver is defined and certbot_webserver_plugin_install | default(true) | bool %} + + echo "### Start Creating Cert {{ item.item.name }} or renew it"; + + certbot certonly --{{ certbot_webserver }} --noninteractive --agree-tos --expand \ + --email {{ certbot_admin_email | default('root@localhost') }} \ + -d {{ item.item.name }} {% if item.item.alias is defined %}\ + {% for altname in item.item.alias %} -d {{ altname }} {% endfor %} + + {% endif %} + +{% else %} + +{% if certbot_freeipa %} + systemctl stop httpd +{% endif %} + + echo "### Start Creating Cert {{ item.item.name }} or renew it"; + + certbot certonly --standalone --noninteractive --agree-tos --expand \ + --email {{ certbot_admin_email | default('root@localhost') }} \ + -d {{ item.item.name }} {% if item.item.alias is defined %}\ + {% for altname in item.item.alias %} -d {{ altname }} {% endfor %} + + {% endif %} + +{% if certbot_freeipa %} + systemctl start httpd +{% endif %} +{% endif %} + +else +{% if certbot_webserver is defined %} + certbot renew --quiet --{{ certbot_webserver }} +{% else %} + certbot renew --quiet +{% endif %} +fi + +exit \ No newline at end of file diff --git a/templates/letsencrypt-freeipa.sh.j2 b/templates/letsencrypt-freeipa.sh.j2 new file mode 100644 index 0000000..2c5f949 --- /dev/null +++ b/templates/letsencrypt-freeipa.sh.j2 @@ -0,0 +1,120 @@ +#!/bin/bash +# +# LetsEncrypt Integration for FreeIPA with Backup and Recovery +# +# Linux-Server-Admin.com +# +{{ certbot_message | default (admin_message ) | comment }} +# +# This script obtains a Let’s Encrypt certificate for FreeIPA and integrates it. +# It also creates backups and provides instructions for recovery. +# +# USAGE: +# sudo ./letsencrypt-freeipa.sh +# +# REQUIREMENTS: +# - FreeIPA server with ipa-cacert-manage, ipa-certupdate, ipa-server-certinstall, and ipactl available. +# - Certbot certificates must already be present in /etc/letsencrypt/live/$IPADOMAIN/. +# + +set -euo pipefail + +### VARIABLES ### +IPADOMAIN="{{ certbot_ipa_domain | default(inventory_hostname) }}" +TEMP_DIR="{{ certbot_temp_dir | default('/tmp/letsencrypt/') }}" +ADMIN_EMAIL="{{ certbot_admin_email | default('root@localhost') }}" + +CERTS_URL="https://letsencrypt.org/certs/" +CERTS_BASE=("isrgrootx1.pem" "isrg-root-x2.pem") + +CERTS_EXTRA_URL="https://letsencrypt.org/certs/2024/" +CERTS_EXTRA=("e5.pem" "e6.pem" "r10.pem" "r11.pem") + +CERTDIR="/etc/letsencrypt/live/$IPADOMAIN/" + +TIMESTAMP=$(date +%Y%m%d%H%M%S) +BACKUP_DIR="/var/lib/ipa-backups/${TIMESTAMP}" + +### FUNCTIONS ### +# If something fails after we start modifying certs, you may need to restore. +restore_instructions() { + echo "### ERROR DETECTED ###" + echo "To restore from backups:" + echo "- If only certs and private directories changed, restore from backups:" + echo " cp -r /var/lib/ipa/certs.bak.${TIMESTAMP} /var/lib/ipa/certs" + echo " cp -r /var/lib/ipa/private.bak.${TIMESTAMP} /var/lib/ipa/private" + echo " ipa-certupdate" + echo " ipactl restart" + echo "" + echo "- If the FreeIPA state is more severely disrupted, use ipa-restore:" + echo " ipa-restore --from-backup /var/lib/ipa/backup/${TIMESTAMP}" + echo "You will need to confirm the restore when prompted." + exit 1 +} + +trap restore_instructions ERR + +### MAIN SCRIPT ### + +echo "### Creating working directory" +mkdir -p "${TEMP_DIR}" +cd "${TEMP_DIR}" + +echo "### Creating timestamped backups for IPA certs and private keys" +mkdir -p "${BACKUP_DIR}" + +# Back up existing certs and keys +cp -r /var/lib/ipa/certs "/var/lib/ipa/certs.bak.${TIMESTAMP}" +cp -r /var/lib/ipa/private "/var/lib/ipa/private.bak.${TIMESTAMP}" + +# Optional: Create a full FreeIPA backup for complete rollback if needed. +# Note: This can be commented out if you do not want a full backup. +echo "### Performing a full FreeIPA backup" +ipa-backup + +echo "### Downloading Let’s Encrypt root certificates" +for CERT_BASE in "${CERTS_BASE[@]}"; do + curl -fSLo "${TEMP_DIR}${CERT_BASE}" "${CERTS_URL}${CERT_BASE}" +done + +echo "### Downloading additional Let’s Encrypt certificates" +for CERT_EXTRA in "${CERTS_EXTRA[@]}"; do + curl -fSLo "${TEMP_DIR}${CERT_EXTRA}" "${CERTS_EXTRA_URL}${CERT_EXTRA}" +done + +echo "### Installing Root Certificates into IPA CA Store" +for CERT_BASE in "${CERTS_BASE[@]}"; do + ipa-cacert-manage install "${TEMP_DIR}${CERT_BASE}" +done + +echo "### Installing Additional Certificates into IPA CA Store" +for CERT_EXTRA in "${CERTS_EXTRA[@]}"; do + ipa-cacert-manage install "${TEMP_DIR}${CERT_EXTRA}" +done + +echo "### Updating CA certificates in IPA" +ipa-certupdate + +echo "### Installing Let’s Encrypt server certificates" +# Ensure that fullchain.pem and privkey.pem exist +if [[ ! -f "${CERTDIR}fullchain.pem" || ! -f "${CERTDIR}privkey.pem" ]]; then + echo "ERROR: The Let's Encrypt certificates are not present in ${CERTDIR}" + exit 1 +fi + +ipa-server-certinstall -w -d \ + "${CERTDIR}privkey.pem" \ + "${CERTDIR}fullchain.pem" \ + --pin='' + +echo "### Restarting IPA services" +ipactl restart + +echo "### Cleanup" +rm -rf "${TEMP_DIR}" + +echo "### Done!" +echo "The Let’s Encrypt certificates have been installed successfully." +echo "If you need to restore at any point, follow the instructions in the error handler above." + +