Files
ouranos/ansible/certbot/vault-certs.yml
Robert Helewka 0a053c1cd6 Refactor HAProxy configuration and certificate management
- Updated HAProxy configuration template to reflect changes for the Taurus Production Environment, including SSL settings and rate limiting for specific endpoints.
- Introduced new playbooks for certificate distribution and validation with OCI Vault, ensuring certificates are correctly managed and renewed.
- Added hooks for uploading renewed certificates to OCI Vault and validating their integrity.
- Enhanced the HAProxy configuration playbook to ensure proper service management and verification of the HAProxy service.
- Updated inventory variables for certificate management and ensured compatibility with the new structure.
2026-03-17 13:13:38 -04:00

348 lines
14 KiB
YAML

---
# -----------------------------------------------------------------------------
# Vault Certificate Management Playbook
# -----------------------------------------------------------------------------
# Checks certificate validity in OCI Vault and renews expired/expiring
# certificates via certbot on bootes. Designed for internal hosts that
# don't have public IPs — certs are stored in OCI Vault for distribution.
#
# Run from fornax:
# ansible-playbook certbot/vault-certs.yml
#
# Steps:
# 1. Validate vault secrets exist and are readable
# 2. Display public IP for Namecheap API whitelisting verification
# 3. Check certificate validity (PEM format + 30-day expiry window)
# 4. Request certificates for any that need renewal (conditional)
# 5. Post-validate renewed certificates in vault (conditional)
# 6. Verify renewal schedule is active
#
# Prerequisites:
# - certbot/deploy.yml has been run on bootes (certbot is installed)
# - certbot/vault-validate.yml has been run (vault R/W confirmed)
# - Namecheap API IP is whitelisted
# -----------------------------------------------------------------------------
- name: Manage Internal Host Certificates via OCI Vault
hosts: bootes.helu.ca
gather_facts: false
tags: [certbot, vault, certs]
tasks:
# -------------------------------------------------------------------------
# Derive Certificate List from Host Vars
# -------------------------------------------------------------------------
- name: Build certificate prefix list from host_vars
ansible.builtin.set_fact:
_cert_prefixes: "{{ certbot_certificates | map(attribute='cert_name') | map('replace', '.', '-') | list }}"
_secret_suffixes: [fullchain, privkey]
- name: Build list of all vault secret names
ansible.builtin.set_fact:
_all_secret_names: "{{ _cert_prefixes | product(_secret_suffixes) | map('join', '-') | list }}"
# -------------------------------------------------------------------------
# Step 1: Validate Vault Secrets Exist
# -------------------------------------------------------------------------
- name: "Step 1 — Read vault secrets"
ansible.builtin.set_fact:
"_vault_{{ item | replace('-', '_') }}": "{{ lookup('oci_secret', item, vault_id=oci_vault_id) }}"
loop: "{{ _all_secret_names }}"
loop_control:
label: "{{ item }}"
- name: "Step 1 — Verify all secrets are readable and non-empty"
ansible.builtin.assert:
that:
- lookup('vars', '_vault_' + item | replace('-', '_')) is defined
- lookup('vars', '_vault_' + item | replace('-', '_')) | length > 0
fail_msg: "Secret '{{ item }}' is missing or empty in OCI Vault"
success_msg: "{{ item }}"
loop: "{{ _all_secret_names }}"
loop_control:
label: "{{ item }}"
- name: "Step 1 — Summary"
ansible.builtin.debug:
msg: "All {{ _all_secret_names | length }} vault secrets exist and are readable."
# -------------------------------------------------------------------------
# Step 2: IP Whitelisting Check
# -------------------------------------------------------------------------
- name: "Step 2 — Get public IP for Namecheap API"
ansible.builtin.uri:
url: https://ifconfig.me/ip
return_content: true
register: _public_ip_result
- name: "Step 2 — Display public IP for verification"
ansible.builtin.debug:
msg: >-
Public IP: {{ _public_ip_result.content | trim }}
— Ensure whitelisted at https://ap.www.namecheap.com/settings/tools/apiaccess/
# -------------------------------------------------------------------------
# Step 3: Certificate Validity Check
# -------------------------------------------------------------------------
- name: Create temporary directory for certificate validation
ansible.builtin.tempfile:
state: directory
prefix: vault-certs-
register: _validate_tmpdir
- name: "Step 3 — Write fullchain PEMs to temp files"
ansible.builtin.copy:
content: "{{ lookup('vars', ('_vault_' + item | replace('.', '-') + '-fullchain') | replace('-', '_')) }}"
dest: "{{ _validate_tmpdir.path }}/{{ item }}.pem"
mode: '0600'
loop: "{{ certbot_certificates | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
- name: "Step 3 — Check PEM format"
ansible.builtin.command:
cmd: openssl x509 -noout -in {{ _validate_tmpdir.path }}/{{ item }}.pem
loop: "{{ certbot_certificates | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
register: _pem_check
changed_when: false
failed_when: false
- name: "Step 3 — Check certificate expiry (30-day window)"
ansible.builtin.command:
cmd: openssl x509 -checkend 2592000 -noout -in {{ _validate_tmpdir.path }}/{{ item }}.pem
loop: "{{ certbot_certificates | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
register: _expiry_check
changed_when: false
failed_when: false
- name: "Step 3 — Get certificate details"
ansible.builtin.command:
cmd: openssl x509 -noout -subject -enddate -in {{ _validate_tmpdir.path }}/{{ item }}.pem
loop: "{{ certbot_certificates | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
register: _cert_details
changed_when: false
failed_when: false
- name: "Step 3 — Build renewal status"
ansible.builtin.set_fact:
_certs_needing_renewal: >-
{{ _certs_needing_renewal | default([]) +
([certbot_certificates[idx]]
if _pem_check.results[idx].rc != 0 or _expiry_check.results[idx].rc != 0
else []) }}
loop: "{{ certbot_certificates | map(attribute='cert_name') | list }}"
loop_control:
index_var: idx
label: "{{ item }}"
- name: "Step 3 — Display certificate status"
ansible.builtin.debug:
msg: >-
{{ item.item }}:
{{ 'INVALID PEM' if item.rc != 0 else
(_cert_details.results[idx].stdout | default('unknown'))
+ (' — NEEDS RENEWAL' if _expiry_check.results[idx].rc != 0 else ' — valid') }}
loop: "{{ _pem_check.results }}"
loop_control:
index_var: idx
label: "{{ item.item }}"
- name: "Step 3 — Summary"
ansible.builtin.debug:
msg: >-
{{ (_certs_needing_renewal | default([]) | length == 0)
| ternary(
'All ' + (certbot_certificates | length | string) + ' certificates are valid. Skipping renewal.',
(_certs_needing_renewal | default([]) | length | string) + ' certificate(s) need renewal: '
+ (_certs_needing_renewal | default([]) | map(attribute='cert_name') | join(', '))
) }}
- name: Clean up validation temp directory
ansible.builtin.file:
path: "{{ _validate_tmpdir.path }}"
state: absent
# -------------------------------------------------------------------------
# Step 4: Request Certificates (conditional — only if needed)
# -------------------------------------------------------------------------
- name: "Step 4 — Request certificates that need renewal"
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 \
{{ item.domains | map('regex_replace', '^', '-d ') | join(' ') }}
args:
executable: /bin/bash
loop: "{{ _certs_needing_renewal | default([]) }}"
loop_control:
label: "{{ item.cert_name }}"
register: _certbot_renewals
when: _certs_needing_renewal | default([]) | length > 0
- name: "Step 4 — Upload renewed certificates to vault"
become: true
ansible.builtin.command: "{{ certbot_directory }}/hooks/renewal-hook.sh"
environment:
RENEWED_LINEAGE: "{{ certbot_directory }}/config/live/{{ item.item.cert_name }}"
loop: "{{ _certbot_renewals.results | default([]) }}"
when: item.changed | default(false)
loop_control:
label: "{{ item.item.cert_name }}"
- name: "Step 4 — Update certificate metrics"
become: true
ansible.builtin.command: "{{ certbot_directory }}/hooks/cert-metrics.sh"
changed_when: false
when: _certs_needing_renewal | default([]) | length > 0
# -------------------------------------------------------------------------
# Step 5: Post-Validation (conditional — only after renewal)
# -------------------------------------------------------------------------
- name: "Step 5 — Re-read vault secrets after renewal"
ansible.builtin.set_fact:
"_post_{{ item | replace('-', '_') }}": "{{ lookup('oci_secret', item, vault_id=oci_vault_id) }}"
loop: "{{ _all_secret_names }}"
loop_control:
label: "{{ item }}"
when: _certs_needing_renewal | default([]) | length > 0
- name: Create post-validation temp directory
ansible.builtin.tempfile:
state: directory
prefix: vault-post-
register: _post_tmpdir
when: _certs_needing_renewal | default([]) | length > 0
- name: "Step 5 — Write renewed fullchain PEMs"
ansible.builtin.copy:
content: "{{ lookup('vars', ('_post_' + item | replace('.', '-') + '-fullchain') | replace('-', '_')) }}"
dest: "{{ _post_tmpdir.path }}/{{ item }}-fullchain.pem"
mode: '0600'
loop: "{{ _certs_needing_renewal | default([]) | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
when: _certs_needing_renewal | default([]) | length > 0
- name: "Step 5 — Write renewed privkey PEMs"
ansible.builtin.copy:
content: "{{ lookup('vars', ('_post_' + item | replace('.', '-') + '-privkey') | replace('-', '_')) }}"
dest: "{{ _post_tmpdir.path }}/{{ item }}-privkey.pem"
mode: '0600'
loop: "{{ _certs_needing_renewal | default([]) | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
no_log: true
when: _certs_needing_renewal | default([]) | length > 0
- name: "Step 5 — Verify renewed certificates are valid and not expiring"
ansible.builtin.command:
cmd: openssl x509 -checkend 2592000 -noout -in {{ _post_tmpdir.path }}/{{ item }}-fullchain.pem
loop: "{{ _certs_needing_renewal | default([]) | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
changed_when: false
when: _certs_needing_renewal | default([]) | length > 0
- name: "Step 5 — Verify cert/key modulus match"
ansible.builtin.shell: |
set -euo pipefail
cert_mod=$(openssl x509 -noout -modulus -in "{{ _post_tmpdir.path }}/{{ item }}-fullchain.pem" | openssl md5)
key_mod=$(openssl rsa -noout -modulus -in "{{ _post_tmpdir.path }}/{{ item }}-privkey.pem" 2>/dev/null | openssl md5 || \
openssl ec -noout -text -in "{{ _post_tmpdir.path }}/{{ item }}-privkey.pem" 2>/dev/null | openssl md5)
if [[ "${cert_mod}" != "${key_mod}" ]]; then
echo "MISMATCH: cert=${cert_mod} key=${key_mod}" >&2
exit 1
fi
echo "OK: modulus match"
args:
executable: /bin/bash
loop: "{{ _certs_needing_renewal | default([]) | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
changed_when: false
when: _certs_needing_renewal | default([]) | length > 0
- name: "Step 5 — Get renewed certificate expiry dates"
ansible.builtin.command:
cmd: openssl x509 -noout -subject -enddate -in {{ _post_tmpdir.path }}/{{ item }}-fullchain.pem
loop: "{{ _certs_needing_renewal | default([]) | map(attribute='cert_name') | list }}"
loop_control:
label: "{{ item }}"
register: _post_cert_details
changed_when: false
when: _certs_needing_renewal | default([]) | length > 0
- name: "Step 5 — Display renewed certificate status"
ansible.builtin.debug:
msg: "{{ item.item }}: {{ item.stdout }}"
loop: "{{ _post_cert_details.results | default([]) }}"
loop_control:
label: "{{ item.item }}"
when: _certs_needing_renewal | default([]) | length > 0
- name: Clean up post-validation temp directory
ansible.builtin.file:
path: "{{ _post_tmpdir.path }}"
state: absent
when: _post_tmpdir.path is defined
# -------------------------------------------------------------------------
# Step 6: Schedule Verification (always runs)
# -------------------------------------------------------------------------
- name: "Step 6 — Verify certbot-renew.timer is enabled"
become: true
ansible.builtin.systemd:
name: certbot-renew.timer
enabled: true
state: started
register: _timer_status
- name: "Step 6 — Get timer status"
become: true
ansible.builtin.command:
cmd: systemctl show certbot-renew.timer --property=ActiveState,NextElapseUSecRealtime,LastTriggerUSec
register: _timer_details
changed_when: false
- name: "Step 6 — Display timer status"
ansible.builtin.debug:
msg: "certbot-renew.timer: {{ _timer_details.stdout_lines | join(', ') }}"
- name: "Step 6 — Update certificate metrics"
become: true
ansible.builtin.command: "{{ certbot_directory }}/hooks/cert-metrics.sh"
changed_when: false
- name: Final summary
ansible.builtin.debug:
msg: >-
Vault certificate check complete.
{{ (certbot_certificates | length) }} certificates checked.
{{ (_certs_needing_renewal | default([]) | length) }} renewed.
Renewal timer is {{ _timer_status.status.ActiveState | default('active') }}.