Files
homelab-infra/infrastructure/proxmox/ansible/roles/pve_acme/tasks/main.yml
T
panxiao81 88a02ababa
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled
Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
2026-09-09 16:47:20 +00:00

116 lines
5.0 KiB
YAML

---
# Point PVE's built-in ACME client at OpenBao's internal PKI and get a real cert
# onto pveproxy. See defaults/main.yml for WHY the internal CA over Let's Encrypt.
#
# Everything here is idempotent: a second run reports changed=0. Renewal is NOT
# our job -- pve-daily-update.timer runs `pvenode acme cert renew` once a day and
# PVE reissues when the cert is inside 30 days of expiry.
# --- Preconditions -----------------------------------------------------------
# The standalone plugin binds :80 for the challenge. If something else holds it,
# the order fails deep inside pvenode with a confusing error, so check up front.
- name: Check that port 80 is free for the http-01 challenge
ansible.builtin.command: ss -lnt 'sport = :80'
register: _port80
changed_when: false
# Read-only, and every later condition depends on it -- so it must still run
# under --check, or the whole role errors out on an undefined register.
check_mode: false
- name: Fail early if port 80 is occupied
ansible.builtin.assert:
that:
- "':80' not in _port80.stdout"
fail_msg: >-
Something is already listening on port 80; the ACME standalone plugin cannot
bind it and the order would fail. Free the port or switch to a dns-01 plugin.
quiet: true
# --- ACME account (cluster-wide, registered once) ----------------------------
# /etc/pve/priv/acme/ is on the pmxcfs, so the account is shared by all three
# nodes. Guarding on the file -- rather than run_once -- is deliberate: this play
# uses serial: 1, where each host is its own batch and run_once would therefore
# fire on EVERY host, re-registering the account three times.
- name: Check whether the ACME account already exists
ansible.builtin.stat:
path: "/etc/pve/priv/acme/{{ pve_acme_account }}"
register: _acme_account
- name: Register the ACME account against OpenBao
# Non-interactive only because bao's directory advertises no termsOfService
# (verified 2026-07-26: meta contains just externalAccountRequired=false).
# pvenode prompts for ToS acceptance when a CA does publish one, and there is
# no --accept-tos flag to suppress it -- so a CA change here can hang the play.
ansible.builtin.command:
argv:
- pvenode
- acme
- account
- register
- "{{ pve_acme_account }}"
- "{{ pve_acme_email }}"
- --directory
- "{{ pve_acme_directory }}"
when: not _acme_account.stat.exists
changed_when: true
# --- Per-node domain config --------------------------------------------------
# Written to /etc/pve/nodes/<node>/config as an `acmedomain0:` line. That file
# does not exist until the first `pvenode config set`, hence the default('').
- name: Read the node config
ansible.builtin.slurp:
src: "/etc/pve/nodes/{{ inventory_hostname }}/config"
register: _node_cfg
failed_when: false
- name: Configure the ACME domain for this node
ansible.builtin.command:
argv:
- pvenode
- config
- set
- "--acmedomain0"
- "{{ pve_acme_domain }},plugin={{ pve_acme_plugin }}"
vars:
_want: "acmedomain0: {{ pve_acme_domain }},plugin={{ pve_acme_plugin }}"
when: _want not in (_node_cfg.content | default('') | b64decode)
changed_when: true
# --- Certificate -------------------------------------------------------------
# Order only when there is no usable cert already. Checking the ISSUER rather
# than mere existence means a leftover self-signed or previously-Let's-Encrypted
# pveproxy-ssl.pem gets replaced, while our own cert is left alone for PVE's
# renewal timer to manage.
- name: Inspect the current pveproxy certificate
ansible.builtin.command: "openssl x509 -noout -issuer -subject -in {{ pve_acme_cert_file }}"
register: _current_cert
changed_when: false
failed_when: false
check_mode: false # read-only; the order task's condition depends on it
- name: Order the certificate from OpenBao
# --force overwrites an existing pveproxy-ssl.pem, which is what we want once
# the check above has decided the current one is wrong or missing. It does NOT
# touch pve-ssl.pem, so the cluster's internal trust is unaffected.
#
# KNOWN FAILURE MODE: the bao-server PKI role pins key_type=rsa/key_bits=2048.
# If a future PVE generates an EC CSR, finalize is rejected by OpenBao with a
# key-type error (the same trap documented in samba-ad's samba_ad_acme role).
# The fix is on the bao side -- widen the role -- not here.
ansible.builtin.command: pvenode acme cert order --force
when: >-
_current_cert.rc != 0
or pve_acme_issuer_cn not in _current_cert.stdout
or pve_acme_domain not in _current_cert.stdout
changed_when: true
# --- Renewal -----------------------------------------------------------------
# This timer is what keeps the cert alive; without it the cert simply expires in
# place. Enabled by default on PVE, asserted here so the guarantee is explicit
# rather than assumed.
- name: Ensure the daily renewal timer is enabled
ansible.builtin.systemd:
name: pve-daily-update.timer
state: started
enabled: true