--- # ----------------------------------------------------------------------------- # Certbot Deployment Playbook # ----------------------------------------------------------------------------- # Deploys certbot with Namecheap DNS-01 validation and requests certificates. # Reusable across all certbot hosts (horkos, bootes). # # Supports two host_vars patterns: # Single-cert: certbot_cert_name + certbot_domains (horkos) # Multi-cert: certbot_certificates list (bootes) # # Secrets are fetched automatically from OCI Vault via group_vars/all/secrets.yml # # Usage: # ansible-playbook certbot/deploy.yml --limit horkos.helu.ca # ansible-playbook certbot/deploy.yml --limit bootes.helu.ca # ----------------------------------------------------------------------------- - name: Deploy Certbot with Namecheap DNS-01 Validation hosts: ubuntu gather_facts: false vars: ansible_common_remote_group: "{{ certbot_group | default(omit) }}" allow_world_readable_tmpfiles: true tags: [certbot, ssl, deploy] handlers: - name: restart certbot-renew timer become: true ansible.builtin.systemd: name: certbot-renew.timer state: restarted daemon_reload: true tasks: - name: Check if host has certbot service ansible.builtin.set_fact: has_certbot_service: "{{ 'certbot' in services | default([]) }}" - name: Skip hosts without certbot service ansible.builtin.meta: end_host when: not has_certbot_service # ------------------------------------------------------------------------- # Build Unified Certificate List # ------------------------------------------------------------------------- - name: Build unified certificate list from host_vars ansible.builtin.set_fact: _certbot_certs: >- {{ certbot_certificates | default([{'cert_name': certbot_cert_name, 'domains': certbot_domains}]) }} # ------------------------------------------------------------------------- # System Setup # ------------------------------------------------------------------------- - name: Create certbot group become: true ansible.builtin.group: name: "{{ certbot_group }}" system: true - name: Create certbot user become: true ansible.builtin.user: name: "{{ certbot_user }}" comment: "Certbot SSL Certificate Management" group: "{{ certbot_group }}" system: true shell: /usr/sbin/nologin home: "{{ certbot_directory }}" create_home: false - name: Add certbot user to ponos group become: true ansible.builtin.user: name: "{{ certbot_user }}" groups: ponos append: true - name: Add ponos user to certbot group become: true ansible.builtin.user: name: ponos groups: "{{ certbot_group }}" append: true # ------------------------------------------------------------------------- # Directory Structure # ------------------------------------------------------------------------- - name: Create certbot directories become: true ansible.builtin.file: path: "{{ item }}" owner: "{{ certbot_user }}" group: "{{ certbot_group }}" state: directory mode: '0750' loop: - "{{ certbot_directory }}" - "{{ certbot_directory }}/config" - "{{ certbot_directory }}/work" - "{{ certbot_directory }}/logs" - "{{ certbot_directory }}/credentials" - "{{ certbot_directory }}/hooks" # ------------------------------------------------------------------------- # Python Virtual Environment # ------------------------------------------------------------------------- - name: Install Python venv package become: true ansible.builtin.apt: name: - python3-venv - python3-pip - acl state: present update_cache: true - name: Create virtual environment become: true become_user: "{{ certbot_user }}" ansible.builtin.command: python3 -m venv {{ certbot_directory }}/.venv args: creates: "{{ certbot_directory }}/.venv/bin/activate" - name: Install certbot and Namecheap DNS plugin become: true become_user: "{{ certbot_user }}" ansible.builtin.pip: name: - pip - certbot - certbot-dns-namecheap state: present virtualenv: "{{ certbot_directory }}/.venv" - name: Install OCI CLI in certbot venv (vault upload hosts) become: true become_user: "{{ certbot_user }}" ansible.builtin.pip: name: - oci-cli state: present virtualenv: "{{ certbot_directory }}/.venv" when: certbot_vault_upload | default(false) # ------------------------------------------------------------------------- # Namecheap Credentials # ------------------------------------------------------------------------- - name: Get public IP for Namecheap API whitelisting ansible.builtin.uri: url: https://ifconfig.me/ip return_content: true register: public_ip_result - name: Set client IP fact ansible.builtin.set_fact: namecheap_client_ip: "{{ public_ip_result.content | trim }}" - name: Display public IP for Namecheap API whitelisting ansible.builtin.debug: msg: "Public IP: {{ namecheap_client_ip }} — ensure whitelisted at https://ap.www.namecheap.com/settings/tools/apiaccess/" - name: Template Namecheap credentials become: true ansible.builtin.template: src: namecheap.ini.j2 dest: "{{ certbot_directory }}/credentials/namecheap.ini" owner: "{{ certbot_user }}" group: "{{ certbot_group }}" mode: '0600' # ------------------------------------------------------------------------- # Renewal Hooks # ------------------------------------------------------------------------- - name: Template renewal hook script (HAProxy reload) become: true ansible.builtin.template: src: renewal-hook.sh.j2 dest: "{{ certbot_directory }}/hooks/renewal-hook.sh" owner: "{{ certbot_user }}" group: "{{ certbot_group }}" mode: '0750' when: not (certbot_vault_upload | default(false)) - name: Template vault upload hook script become: true ansible.builtin.template: src: vault-upload-hook.sh.j2 dest: "{{ certbot_directory }}/hooks/renewal-hook.sh" owner: "{{ certbot_user }}" group: "{{ certbot_group }}" mode: '0750' when: certbot_vault_upload | default(false) - name: Create Prometheus textfile directory become: true ansible.builtin.file: path: "{{ prometheus_node_exporter_text_directory }}" state: directory owner: root group: root mode: '0755' - name: Template certificate metrics script become: true ansible.builtin.template: src: cert-metrics.sh.j2 dest: "{{ certbot_directory }}/hooks/cert-metrics.sh" owner: "{{ certbot_user }}" group: "{{ certbot_group }}" mode: '0750' # ------------------------------------------------------------------------- # Certificate Requests # ------------------------------------------------------------------------- - name: Check if certificates already exist become: true ansible.builtin.stat: path: "{{ certbot_directory }}/config/live/{{ item.cert_name }}/fullchain.pem" register: cert_check loop: "{{ _certbot_certs }}" loop_control: label: "{{ item.cert_name }}" - name: Get current certificate domains become: true ansible.builtin.shell: | set -euo pipefail openssl x509 -in {{ certbot_directory }}/config/live/{{ item.item.cert_name }}/fullchain.pem \ -noout -ext subjectAltName | \ grep -oP 'DNS:\K[^,\s]+' | sort args: executable: /bin/bash register: cert_domains loop: "{{ cert_check.results }}" when: item.stat.exists changed_when: false loop_control: label: "{{ item.item.cert_name }}" - name: Determine which certificates need requesting ansible.builtin.set_fact: _certs_to_request: >- {{ _certs_to_request | default([]) + [ item.0.item | combine({ 'needs_request': not item.0.stat.exists, 'domains_changed': item.0.stat.exists and (item.1.stdout_lines | default([]) | sort) != (item.0.item.domains | sort) }) ] }} loop: "{{ cert_check.results | zip_longest(cert_domains.results | default([]), fillvalue={}) | list }}" loop_control: label: "{{ item.0.item.cert_name }}" - name: Request certificates become: true become_user: "{{ certbot_user }}" ansible.builtin.shell: | source {{ certbot_directory }}/.venv/bin/activate certbot certonly \ --non-interactive \ --agree-tos \ --email {{ certbot_email }} \ --authenticator dns-namecheap \ --dns-namecheap-credentials {{ certbot_directory }}/credentials/namecheap.ini \ --dns-namecheap-propagation-seconds 120 \ --config-dir {{ certbot_directory }}/config \ --work-dir {{ certbot_directory }}/work \ --logs-dir {{ certbot_directory }}/logs \ --cert-name {{ item.cert_name }} \ {{ '--force-renewal' if item.domains_changed | default(false) else '' }} \ {{ item.domains | map('regex_replace', '^', '-d ') | join(' ') }} args: executable: /bin/bash loop: "{{ _certs_to_request | selectattr('needs_request') | list + _certs_to_request | selectattr('domains_changed') | list }}" loop_control: label: "{{ item.cert_name }}" register: certbot_requests - name: Run renewal hook after certificate requests become: true ansible.builtin.command: "{{ certbot_directory }}/hooks/renewal-hook.sh" environment: >- {{ {'RENEWED_LINEAGE': certbot_directory + '/config/live/' + item.item.cert_name} if certbot_vault_upload | default(false) else {} }} loop: "{{ certbot_requests.results | default([]) }}" when: item.changed | default(false) loop_control: label: "{{ item.item.cert_name }}" - name: Ensure vault is populated with current certificates become: true ansible.builtin.command: "{{ certbot_directory }}/hooks/renewal-hook.sh" environment: RENEWED_LINEAGE: "{{ certbot_directory }}/config/live/{{ item.cert_name }}" loop: "{{ _certbot_certs }}" when: certbot_vault_upload | default(false) loop_control: label: "{{ item.cert_name }}" # ------------------------------------------------------------------------- # Systemd Timer for Auto-Renewal # ------------------------------------------------------------------------- - name: Create certbot renewal service become: true ansible.builtin.copy: content: | [Unit] Description=Certbot Renewal After=network-online.target Wants=network-online.target [Service] Type=oneshot User={{ certbot_user }} Group={{ certbot_group }} ExecStart=/bin/bash -c 'source {{ certbot_directory }}/.venv/bin/activate && certbot renew --config-dir {{ certbot_directory }}/config --work-dir {{ certbot_directory }}/work --logs-dir {{ certbot_directory }}/logs --deploy-hook {{ certbot_directory }}/hooks/renewal-hook.sh' PrivateTmp=true dest: /etc/systemd/system/certbot-renew.service mode: '0644' notify: restart certbot-renew timer - name: Create certbot renewal timer become: true ansible.builtin.copy: content: | [Unit] Description=Check certbot certificates and renew if expiring [Timer] OnCalendar=*-*-* 00,12:00:00 RandomizedDelaySec=3600 Persistent=true [Install] WantedBy=timers.target dest: /etc/systemd/system/certbot-renew.timer mode: '0644' notify: restart certbot-renew timer - name: Enable and start certbot renewal timer become: true ansible.builtin.systemd: name: certbot-renew.timer enabled: true state: started daemon_reload: true # ------------------------------------------------------------------------- # Initial Metrics Update # ------------------------------------------------------------------------- - name: Run certificate metrics script become: true ansible.builtin.command: "{{ certbot_directory }}/hooks/cert-metrics.sh" changed_when: false # ------------------------------------------------------------------------- # Verification # ------------------------------------------------------------------------- - name: Verify certificates exist become: true ansible.builtin.stat: path: "{{ certbot_directory }}/config/live/{{ item.cert_name }}/fullchain.pem" register: final_certs loop: "{{ _certbot_certs }}" loop_control: label: "{{ item.cert_name }}" - name: Certificate deployment status ansible.builtin.debug: msg: "{{ item.item.cert_name }}: {{ 'deployed' if item.stat.exists else 'MISSING' }}" loop: "{{ final_certs.results }}" loop_control: label: "{{ item.item.cert_name }}" - name: Verify HAProxy combined PEM exists become: true ansible.builtin.stat: path: "{{ haproxy_cert_path }}" register: _haproxy_pem when: haproxy_cert_path is defined - name: HAProxy PEM status ansible.builtin.debug: msg: "HAProxy PEM {{ haproxy_cert_path }}: {{ 'present' if _haproxy_pem.stat.exists else 'MISSING — renewal hook may have failed' }}" when: haproxy_cert_path is defined and _haproxy_pem is defined