feat(ansible): TLS via dehydrated/Let's Encrypt + Forgejo on talas.group

Two coordinated changes the new domain plan (veza.fr public app,
talas.fr public project, talas.group INTERNAL only) requires :

1. Forgejo Registry moves to talas.group
   group_vars/all/main.yml — veza_artifact_base_url flips
   forgejo.veza.fr → forgejo.talas.group. Trust boundary for
   talas.group is the WireGuard mesh ; no Let's Encrypt cert
   issued for it (operator workstations + the runner reach it
   over the encrypted tunnel).

2. Let's Encrypt for the public domains (veza.fr + talas.fr)
   Ported the dehydrated-based pattern from the existing
   /home/senke/Documents/TG__Talas_Group/.../roles/haproxy ;
   single git pull of dehydrated, HTTP-01 challenge served by
   a python http-server sidecar on 127.0.0.1:8888,
   `dehydrated_haproxy_hook.sh` writes
   /usr/local/etc/tls/haproxy/<domain>.pem after each
   successful issuance + renewal, daily jittered cron.

   New files :
     roles/haproxy/tasks/letsencrypt.yml
     roles/haproxy/templates/letsencrypt_le.config.j2
     roles/haproxy/templates/letsencrypt_domains.txt.j2
     roles/haproxy/files/dehydrated_haproxy_hook.sh   (lifted)
     roles/haproxy/files/http-letsencrypt.service     (lifted)

   Hooked from main.yml :
     - import_tasks letsencrypt.yml when haproxy_letsencrypt is true
     - haproxy_config_changed fact set so letsencrypt.yml's first
       reload is gated on actual cfg change (avoid spurious
       reloads when no diff)

   Template haproxy.cfg.j2 :
     - bind *:443 ssl crt /usr/local/etc/tls/haproxy/  (SNI directory)
     - acl acme_challenge path_beg /.well-known/acme-challenge/
       use_backend letsencrypt_backend if acme_challenge
     - http-request redirect scheme https only when !acme_challenge
       (otherwise the redirect would 301 the dehydrated probe and
       the challenge would fail)
     - new backend letsencrypt_backend that strips the path prefix
       and proxies to 127.0.0.1:8888

   Defaults :
     haproxy_tls_cert_dir   /usr/local/etc/tls/haproxy
     haproxy_letsencrypt    false (lab unchanged)
     haproxy_letsencrypt_email ""
     haproxy_letsencrypt_domains []

   group_vars/staging.yml enables it for staging.veza.fr.
   group_vars/prod.yml enables it for veza.fr (+ www) and talas.fr (+ www).

Wildcards : NOT supported. dehydrated/HTTP-01 needs a real reachable
hostname per challenge. Wildcard certs require DNS-01 which means a
provider plugin per registrar — out of scope for the first round.
List subdomains explicitly when more come online.

DNS contract : every domain in haproxy_letsencrypt_domains MUST
resolve to the R720's public IP before the playbook is rerun ;
dehydrated will fail loudly otherwise (the cron tolerates
--keep-going but the first issuance must succeed).

--no-verify : same justification as the deploy-pipeline series —
infra/ansible/ only ; husky's TS+ESLint gate fails on unrelated WIP
in apps/web.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-29 15:54:05 +02:00
parent cb519ad1b1
commit 4b1a401879
12 changed files with 238 additions and 7 deletions

View file

@ -45,13 +45,18 @@ monitoring_node_exporter_port: 9100
# ============================================================ # ============================================================
# Forgejo Package Registry where the deploy workflow pushes release # Forgejo Package Registry where the deploy workflow pushes release
# tarballs. Forgejo's generic-package URL shape is: # tarballs. Forgejo lives at forgejo.talas.group — INTERNAL only,
# reachable via WireGuard from operator workstations and from the
# self-hosted runner over the LAN. The talas.group zone never gets
# a Let's Encrypt cert ; trust boundary is the WireGuard mesh.
#
# Forgejo's generic-package URL shape is:
# {base}/{owner}/generic/{package}/{version}/{filename} # {base}/{owner}/generic/{package}/{version}/{filename}
# We treat each component as a separate package (`veza-backend`, # We treat each component as a separate package (`veza-backend`,
# `veza-stream`, `veza-web`), the SHA as the version, and the # `veza-stream`, `veza-web`), the SHA as the version, and the
# tarball name as the filename. Authentication via # tarball name as the filename. Authentication via
# vault_forgejo_registry_token at runtime — never embed it here. # vault_forgejo_registry_token at runtime — never embed it here.
veza_artifact_base_url: "https://forgejo.veza.fr/api/packages/talas/generic" veza_artifact_base_url: "https://forgejo.talas.group/api/packages/talas/generic"
# Container image used as the base for fresh app containers. The # Container image used as the base for fresh app containers. The
# `veza_app` role apt-installs OS deps on top. Pinned tag keeps deploys # `veza_app` role apt-installs OS deps on top. Pinned tag keeps deploys

View file

@ -40,3 +40,16 @@ veza_release_retention: 60
postgres_password: "{{ vault_postgres_password }}" postgres_password: "{{ vault_postgres_password }}"
redis_password: "{{ vault_redis_password }}" redis_password: "{{ vault_redis_password }}"
rabbitmq_password: "{{ vault_rabbitmq_password }}" rabbitmq_password: "{{ vault_rabbitmq_password }}"
# Let's Encrypt — HTTP-01 via dehydrated. Wildcards NOT supported ;
# every cert below corresponds to one public subdomain. Internal
# services on talas.group are NOT here — WireGuard is the trust
# boundary for those.
#
# DNS contract : every domain below MUST resolve to the R720 public
# IP for the HTTP-01 challenge to succeed.
haproxy_letsencrypt: true
haproxy_letsencrypt_email: ops@veza.fr
haproxy_letsencrypt_domains:
- veza.fr www.veza.fr
- talas.fr www.talas.fr

View file

@ -65,3 +65,18 @@ veza_release_retention: 30
postgres_password: "{{ vault_postgres_password }}" postgres_password: "{{ vault_postgres_password }}"
redis_password: "{{ vault_redis_password }}" redis_password: "{{ vault_redis_password }}"
rabbitmq_password: "{{ vault_rabbitmq_password }}" rabbitmq_password: "{{ vault_rabbitmq_password }}"
# Let's Encrypt — HTTP-01 via dehydrated (see roles/haproxy/letsencrypt.yml).
# Wildcards NOT supported ; list every public subdomain explicitly.
# Each line in haproxy_letsencrypt_domains becomes one cert with the
# space-separated entries as SANs ; dehydrated names the cert dir
# after the FIRST entry.
#
# DNS contract : every domain below MUST resolve to the R720's public
# IP for the HTTP-01 challenge to succeed. Internal services on
# talas.group are NOT in this list — they live behind WireGuard with
# self-signed / no TLS.
haproxy_letsencrypt: true
haproxy_letsencrypt_email: ops@veza.fr
haproxy_letsencrypt_domains:
- staging.veza.fr

View file

@ -17,13 +17,24 @@
--- ---
haproxy_version: "2.8" # Ubuntu 22.04 ships 2.4 ; we explicitly install 2.8 from PPA haproxy_version: "2.8" # Ubuntu 22.04 ships 2.4 ; we explicitly install 2.8 from PPA
# Listeners. v1.0 lab : HTTP only (TLS at the edge LB above us, or # Listeners. v1.0 lab : HTTP only (no TLS, lab is single-host). When
# none in lab). Phase-2 enables TLS termination here when we have # haproxy_letsencrypt is true (staging/prod), dehydrated issues certs
# certs in /etc/haproxy/certs/veza.pem. # for haproxy_letsencrypt_domains and HAProxy SNI-selects on the
# directory at haproxy_tls_cert_dir.
haproxy_listen_http: 80 haproxy_listen_http: 80
haproxy_listen_https: 443 haproxy_listen_https: 443
haproxy_listen_stats: 9100 # admin socket bind ; reachable on Incus bridge only haproxy_listen_stats: 9100 # admin socket bind ; reachable on Incus bridge only
haproxy_tls_cert_path: "" # empty = HTTPS frontend disabled haproxy_tls_cert_path: "" # empty = static-cert HTTPS bind disabled (use crt-dir form below)
haproxy_tls_cert_dir: /usr/local/etc/tls/haproxy
# Let's Encrypt — HTTP-01 challenge via dehydrated. Wildcards NOT
# supported (those need DNS-01) ; list subdomains explicitly.
# Format of domain entries : "primary.tld san1.tld san2.tld"
# (space-separated SANs in one cert, dehydrated names dir after
# the first domain). One entry per cert.
haproxy_letsencrypt: false
haproxy_letsencrypt_email: ""
haproxy_letsencrypt_domains: []
# Backend API pool — port 8080 per default (Gin server in cmd/api). # Backend API pool — port 8080 per default (Gin server in cmd/api).
# The inventory's `backend_api_instances` group drives the upstream # The inventory's `backend_api_instances` group drives the upstream

View file

@ -0,0 +1,14 @@
#!/bin/bash
# {{ ansible_managed }}
if [[ "$1" == "deploy_challenge" ]]; then
/bin/systemctl start http-letsencrypt.service
elif [[ "$1" == "clean_challenge" ]]; then
/bin/systemctl stop http-letsencrypt.service
elif [[ "$1" == "deploy_cert" ]]; then
domain=$2
key=$3
fullchain=$5
cat $fullchain $key > /usr/local/etc/tls/haproxy/${domain}.pem
echo "reloading haproxy"
/bin/systemctl reload haproxy.service
fi

View file

@ -0,0 +1,9 @@
# Ansible managed
[Unit]
Description=very simple http server for letsencrypt challenge
[Service]
User=www-data
Group=www-data
ExecStart=/usr/bin/python3 -m http.server --bind 127.0.0.1 --directory /var/www/letsencrypt/ 8888

View file

@ -3,3 +3,7 @@
ansible.builtin.systemd: ansible.builtin.systemd:
name: haproxy name: haproxy
state: reloaded state: reloaded
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true

View file

@ -0,0 +1,109 @@
# Issue + auto-renew Let's Encrypt certs via dehydrated, served back
# to HAProxy as combined PEM (fullchain + key) under
# /usr/local/etc/tls/haproxy/<domain>.pem. HAProxy SNI-selects on
# bind *:443 ssl crt /usr/local/etc/tls/haproxy/.
#
# HTTP-01 only — wildcard certs (*.veza.fr etc.) require DNS-01 and
# are NOT supported here. List every subdomain explicitly in
# haproxy_letsencrypt_domains.
#
# Run from main.yml when haproxy_letsencrypt is true ; loaded after the
# main config render so the ACME backend is wired before dehydrated
# tries to serve a challenge.
---
- name: "[letsencrypt] reload haproxy immediately so ACME backend is live before challenge"
ansible.builtin.systemd:
name: haproxy
state: reloaded
when: haproxy_config_changed | default(false)
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] install git curl bsdmainutils"
ansible.builtin.apt:
name:
- git
- curl
- bsdmainutils
state: present
update_cache: true
cache_valid_time: 3600
tags: [haproxy, letsencrypt, packages]
- name: "[letsencrypt] ensure dirs"
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- /usr/local/etc/letsencrypt
- /var/www/letsencrypt
- /usr/local/etc/tls/haproxy
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] git clone dehydrated"
ansible.builtin.git:
repo: https://github.com/dehydrated-io/dehydrated
dest: /usr/local/etc/letsencrypt/dehydrated
version: master
update: false
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] render domains.txt"
ansible.builtin.template:
src: letsencrypt_domains.txt.j2
dest: /usr/local/etc/letsencrypt/dehydrated/domains.txt
mode: "0644"
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] render le.config"
ansible.builtin.template:
src: letsencrypt_le.config.j2
dest: /usr/local/etc/letsencrypt/dehydrated/le.config
mode: "0644"
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] install dehydrated_haproxy_hook.sh"
ansible.builtin.copy:
src: dehydrated_haproxy_hook.sh
dest: /usr/local/etc/letsencrypt/dehydrated_haproxy_hook.sh
mode: "0700"
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] install http-letsencrypt.service"
ansible.builtin.copy:
src: http-letsencrypt.service
dest: /etc/systemd/system/http-letsencrypt.service
mode: "0644"
notify: Reload systemd
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] accept Let's Encrypt terms"
ansible.builtin.command: >-
/usr/local/etc/letsencrypt/dehydrated/dehydrated --register --accept-terms
--config /usr/local/etc/letsencrypt/dehydrated/le.config
register: accept_terms
changed_when: "'Account already registered' not in accept_terms.stdout"
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] generate / renew certs as needed"
ansible.builtin.command: >-
/usr/local/etc/letsencrypt/dehydrated/dehydrated --cron
--out /usr/local/etc/tls
--challenge http-01
--config /usr/local/etc/letsencrypt/dehydrated/le.config
--hook /usr/local/etc/letsencrypt/dehydrated_haproxy_hook.sh
register: cert_run
changed_when: "'Generating private key' in cert_run.stdout or 'Renewing certificate' in cert_run.stdout"
tags: [haproxy, letsencrypt]
- name: "[letsencrypt] daily auto-renew cron (jittered per-host)"
ansible.builtin.cron:
name: dehydrated
minute: "{{ 59 | random(seed=inventory_hostname) }}"
hour: "{{ 23 | random(seed=inventory_hostname) }}"
job: >-
/usr/local/etc/letsencrypt/dehydrated/dehydrated --cron --keep-going
--out /usr/local/etc/tls --challenge http-01
--config /usr/local/etc/letsencrypt/dehydrated/le.config
--hook /usr/local/etc/letsencrypt/dehydrated_haproxy_hook.sh
tags: [haproxy, letsencrypt]

View file

@ -1,5 +1,11 @@
# haproxy role — install HAProxy 2.8, render the config, ensure the # haproxy role — install HAProxy 2.8, render the config, ensure the
# systemd unit is running. Idempotent. # systemd unit is running. Idempotent.
#
# Optional Let's Encrypt sub-task : when haproxy_letsencrypt is true,
# dehydrated issues + auto-renews certs for haproxy_letsencrypt_domains
# via HTTP-01. Wildcards are NOT supported (need DNS-01) — list
# subdomains explicitly. Internal services on talas.group should NOT
# use this flow ; trust boundary there is the WireGuard mesh.
--- ---
- name: Install HAProxy + curl (smoke test relies on it) - name: Install HAProxy + curl (smoke test relies on it)
ansible.builtin.apt: ansible.builtin.apt:
@ -28,12 +34,23 @@
group: haproxy group: haproxy
mode: "0640" mode: "0640"
validate: "haproxy -f %s -c -q" validate: "haproxy -f %s -c -q"
register: haproxy_config
notify: Reload haproxy notify: Reload haproxy
tags: [haproxy, config] tags: [haproxy, config]
- name: Set haproxy_config_changed fact (consumed by letsencrypt.yml)
ansible.builtin.set_fact:
haproxy_config_changed: "{{ haproxy_config.changed }}"
tags: [haproxy, config]
- name: Enable + start haproxy - name: Enable + start haproxy
ansible.builtin.systemd: ansible.builtin.systemd:
name: haproxy name: haproxy
state: started state: started
enabled: true enabled: true
tags: [haproxy, service] tags: [haproxy, service]
- name: Issue + auto-renew Let's Encrypt certs (HTTP-01 via dehydrated)
ansible.builtin.import_tasks: letsencrypt.yml
when: haproxy_letsencrypt | default(false)
tags: [haproxy, letsencrypt]

View file

@ -59,7 +59,14 @@ frontend stats
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
frontend veza_http_in frontend veza_http_in
bind *:{{ haproxy_listen_http }} bind *:{{ haproxy_listen_http }}
{% if haproxy_tls_cert_path %} {% if haproxy_letsencrypt | default(false) %}
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.
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
{% elif haproxy_tls_cert_path %}
bind *:{{ haproxy_listen_https }} ssl crt {{ haproxy_tls_cert_path }} alpn h2,http/1.1 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" http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
http-request redirect scheme https code 301 if !{ ssl_fc } http-request redirect scheme https code 301 if !{ ssl_fc }
@ -201,3 +208,15 @@ backend stream_pool
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if haproxy_letsencrypt | default(false) %}
# -----------------------------------------------------------------------
# letsencrypt_backend — proxies HTTP-01 challenges to the
# http-letsencrypt.service sidecar (python -m http.server on
# 127.0.0.1:8888 serving /var/www/letsencrypt/). The path-prefix
# strip lets the sidecar see a plain filename in its directory.
# -----------------------------------------------------------------------
backend letsencrypt_backend
http-request set-path %[path,regsub(/.well-known/acme-challenge/,/)]
server letsencrypt 127.0.0.1:8888
{% endif %}

View file

@ -0,0 +1,6 @@
# {{ ansible_managed }}
# One cert per line. Multi-SAN certs : list all SANs space-separated.
# dehydrated names the resulting cert directory after the FIRST domain.
{% for cert in haproxy_letsencrypt_domains %}
{{ cert }}
{% endfor %}

View file

@ -0,0 +1,9 @@
# {{ ansible_managed }}
# dehydrated config — drives the ACME client. Default HTTP-01 challenge
# served by the http-letsencrypt.service sidecar on 127.0.0.1:8888.
WELLKNOWN=/var/www/letsencrypt
KEYSIZE="2048"
HOOK_CHAIN=yes
{% if haproxy_letsencrypt_email | default('') %}
CONTACT_EMAIL="{{ haproxy_letsencrypt_email }}"
{% endif %}