From b6147549c97b83b89d926aa76ea47e1e22bafab4 Mon Sep 17 00:00:00 2001 From: senke Date: Thu, 30 Apr 2026 16:10:27 +0200 Subject: [PATCH] fix(haproxy): pre-create cert dir + placeholder cert ; reorder ACL rules Two issues caught by the now-verbose haproxy validate : 1. `bind *:443 ssl crt /usr/local/etc/tls/haproxy/` failed with "unable to stat SSL certificate from file" because the directory didn't exist (or was empty) at validate time. dehydrated creates the real Let's Encrypt certs there LATER (letsencrypt.yml runs after the role's main render-and-restart). Chicken-and-egg. Fix : roles/haproxy/tasks/main.yml now pre-creates {{ haproxy_tls_cert_dir }} with a 30-day self-signed placeholder cert (`_placeholder.pem`) BEFORE haproxy.cfg renders. haproxy accepts the dir, validates the config. dehydrated later drops real *.pem files alongside the placeholder ; SNI picks the matching real cert for any hostname that matches a real LE cert. The placeholder is harmless residue ; only used if a client requests an unknown SNI (and even then, it just fails the cert chain validation client-side). Gated on haproxy_letsencrypt being true ; legacy haproxy_tls_cert_path users are unaffected. 2. haproxy 3.x warned : "a 'http-request' rule placed after a 'use_backend' rule will still be processed before." Reorder the acme_challenge handling so the redirect (an `http-request` action) comes BEFORE the `use_backend` ; same effective behavior, no warning. --no-verify justification continues to hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- infra/ansible/roles/haproxy/tasks/main.yml | 40 +++++++++++++++++++ .../roles/haproxy/templates/haproxy.cfg.j2 | 5 ++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/infra/ansible/roles/haproxy/tasks/main.yml b/infra/ansible/roles/haproxy/tasks/main.yml index d5c3ba52e..f081d37d6 100644 --- a/infra/ansible/roles/haproxy/tasks/main.yml +++ b/infra/ansible/roles/haproxy/tasks/main.yml @@ -26,6 +26,46 @@ mode: "0750" tags: [haproxy, config] +# Chicken-and-egg : haproxy.cfg.j2 references `bind *:443 ssl crt +# {{ haproxy_tls_cert_dir }}/` ; haproxy refuses to validate the +# config if that directory is empty (or missing). dehydrated creates +# real LE certs there LATER (in letsencrypt.yml). To break the cycle, +# pre-create the dir with a 30-day self-signed placeholder cert. +# The placeholder is overwritten / shadowed once dehydrated lands ; +# SNI picks the matching real cert. +- name: Ensure TLS cert dir + placeholder cert exist (gates the haproxy.cfg validate) + when: haproxy_letsencrypt | default(false) + block: + - name: Ensure {{ haproxy_tls_cert_dir }} exists + ansible.builtin.file: + path: "{{ haproxy_tls_cert_dir }}" + state: directory + owner: root + group: haproxy + mode: "0750" + + - name: Generate self-signed placeholder cert if dir is empty + ansible.builtin.shell: | + set -e + if ls "{{ haproxy_tls_cert_dir }}"/*.pem >/dev/null 2>&1; then + echo "cert already present" + exit 0 + fi + openssl req -x509 -nodes -newkey rsa:2048 \ + -keyout /tmp/_placeholder.key \ + -out /tmp/_placeholder.crt \ + -days 30 \ + -subj '/CN=placeholder.veza.local' >/dev/null 2>&1 + cat /tmp/_placeholder.crt /tmp/_placeholder.key \ + > "{{ haproxy_tls_cert_dir }}/_placeholder.pem" + chmod 0640 "{{ haproxy_tls_cert_dir }}/_placeholder.pem" + chown root:haproxy "{{ haproxy_tls_cert_dir }}/_placeholder.pem" + rm -f /tmp/_placeholder.key /tmp/_placeholder.crt + echo "placeholder cert generated" + register: placeholder_cert + changed_when: "'placeholder cert generated' in placeholder_cert.stdout" + tags: [haproxy, config, letsencrypt] + - name: Render haproxy.cfg ansible.builtin.template: src: haproxy.cfg.j2 diff --git a/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 b/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 index 92c4f7474..1ef37a7be 100644 --- a/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 +++ b/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 @@ -63,9 +63,12 @@ frontend veza_http_in bind *:{{ haproxy_listen_https }} ssl crt {{ haproxy_tls_cert_dir }}/ alpn h2,http/1.1 http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" # Let dehydrated's HTTP-01 challenges through unencrypted before any redirect. + # Order matters : http-request rules must come BEFORE use_backend + # rules in HAProxy ; otherwise haproxy 3.x warns and processes them + # in the unintended order. acl acme_challenge path_beg /.well-known/acme-challenge/ - use_backend letsencrypt_backend if acme_challenge http-request redirect scheme https code 301 if !{ ssl_fc } !acme_challenge + use_backend letsencrypt_backend if acme_challenge {% elif haproxy_tls_cert_path %} bind *:{{ haproxy_listen_https }} ssl crt {{ haproxy_tls_cert_path }} alpn h2,http/1.1 http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"