From 4b1a4018796448d0cd4f0d0cdabd7553d440a548 Mon Sep 17 00:00:00 2001 From: senke Date: Wed, 29 Apr 2026 15:54:05 +0200 Subject: [PATCH] feat(ansible): TLS via dehydrated/Let's Encrypt + Forgejo on talas.group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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) --- infra/ansible/group_vars/all/main.yml | 9 +- infra/ansible/group_vars/prod.yml | 13 +++ infra/ansible/group_vars/staging.yml | 15 +++ infra/ansible/roles/haproxy/defaults/main.yml | 19 ++- .../haproxy/files/dehydrated_haproxy_hook.sh | 14 +++ .../haproxy/files/http-letsencrypt.service | 9 ++ infra/ansible/roles/haproxy/handlers/main.yml | 4 + .../roles/haproxy/tasks/letsencrypt.yml | 109 ++++++++++++++++++ infra/ansible/roles/haproxy/tasks/main.yml | 17 +++ .../roles/haproxy/templates/haproxy.cfg.j2 | 21 +++- .../templates/letsencrypt_domains.txt.j2 | 6 + .../templates/letsencrypt_le.config.j2 | 9 ++ 12 files changed, 238 insertions(+), 7 deletions(-) create mode 100755 infra/ansible/roles/haproxy/files/dehydrated_haproxy_hook.sh create mode 100755 infra/ansible/roles/haproxy/files/http-letsencrypt.service create mode 100644 infra/ansible/roles/haproxy/tasks/letsencrypt.yml create mode 100644 infra/ansible/roles/haproxy/templates/letsencrypt_domains.txt.j2 create mode 100644 infra/ansible/roles/haproxy/templates/letsencrypt_le.config.j2 diff --git a/infra/ansible/group_vars/all/main.yml b/infra/ansible/group_vars/all/main.yml index 5c44efe04..80d8d1c63 100644 --- a/infra/ansible/group_vars/all/main.yml +++ b/infra/ansible/group_vars/all/main.yml @@ -45,13 +45,18 @@ monitoring_node_exporter_port: 9100 # ============================================================ # 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} # We treat each component as a separate package (`veza-backend`, # `veza-stream`, `veza-web`), the SHA as the version, and the # tarball name as the filename. Authentication via # 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 # `veza_app` role apt-installs OS deps on top. Pinned tag keeps deploys diff --git a/infra/ansible/group_vars/prod.yml b/infra/ansible/group_vars/prod.yml index 15ff3b1f4..752478c7d 100644 --- a/infra/ansible/group_vars/prod.yml +++ b/infra/ansible/group_vars/prod.yml @@ -40,3 +40,16 @@ veza_release_retention: 60 postgres_password: "{{ vault_postgres_password }}" redis_password: "{{ vault_redis_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 diff --git a/infra/ansible/group_vars/staging.yml b/infra/ansible/group_vars/staging.yml index 43841b0ab..1d2466a88 100644 --- a/infra/ansible/group_vars/staging.yml +++ b/infra/ansible/group_vars/staging.yml @@ -65,3 +65,18 @@ veza_release_retention: 30 postgres_password: "{{ vault_postgres_password }}" redis_password: "{{ vault_redis_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 diff --git a/infra/ansible/roles/haproxy/defaults/main.yml b/infra/ansible/roles/haproxy/defaults/main.yml index a580de6cf..9b7fb05c3 100644 --- a/infra/ansible/roles/haproxy/defaults/main.yml +++ b/infra/ansible/roles/haproxy/defaults/main.yml @@ -17,13 +17,24 @@ --- 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 -# none in lab). Phase-2 enables TLS termination here when we have -# certs in /etc/haproxy/certs/veza.pem. +# Listeners. v1.0 lab : HTTP only (no TLS, lab is single-host). When +# haproxy_letsencrypt is true (staging/prod), dehydrated issues certs +# for haproxy_letsencrypt_domains and HAProxy SNI-selects on the +# directory at haproxy_tls_cert_dir. haproxy_listen_http: 80 haproxy_listen_https: 443 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). # The inventory's `backend_api_instances` group drives the upstream diff --git a/infra/ansible/roles/haproxy/files/dehydrated_haproxy_hook.sh b/infra/ansible/roles/haproxy/files/dehydrated_haproxy_hook.sh new file mode 100755 index 000000000..7840a20c1 --- /dev/null +++ b/infra/ansible/roles/haproxy/files/dehydrated_haproxy_hook.sh @@ -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 diff --git a/infra/ansible/roles/haproxy/files/http-letsencrypt.service b/infra/ansible/roles/haproxy/files/http-letsencrypt.service new file mode 100755 index 000000000..a26ecb3f7 --- /dev/null +++ b/infra/ansible/roles/haproxy/files/http-letsencrypt.service @@ -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 diff --git a/infra/ansible/roles/haproxy/handlers/main.yml b/infra/ansible/roles/haproxy/handlers/main.yml index 3e7ad5c7d..f908818c6 100644 --- a/infra/ansible/roles/haproxy/handlers/main.yml +++ b/infra/ansible/roles/haproxy/handlers/main.yml @@ -3,3 +3,7 @@ ansible.builtin.systemd: name: haproxy state: reloaded + +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/infra/ansible/roles/haproxy/tasks/letsencrypt.yml b/infra/ansible/roles/haproxy/tasks/letsencrypt.yml new file mode 100644 index 000000000..f07f1fa27 --- /dev/null +++ b/infra/ansible/roles/haproxy/tasks/letsencrypt.yml @@ -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/.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] diff --git a/infra/ansible/roles/haproxy/tasks/main.yml b/infra/ansible/roles/haproxy/tasks/main.yml index 9a510ce5e..3e72a2700 100644 --- a/infra/ansible/roles/haproxy/tasks/main.yml +++ b/infra/ansible/roles/haproxy/tasks/main.yml @@ -1,5 +1,11 @@ # haproxy role — install HAProxy 2.8, render the config, ensure the # 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) ansible.builtin.apt: @@ -28,12 +34,23 @@ group: haproxy mode: "0640" validate: "haproxy -f %s -c -q" + register: haproxy_config notify: Reload haproxy 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 ansible.builtin.systemd: name: haproxy state: started enabled: true 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] diff --git a/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 b/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 index 0fc206fd3..424ddbb86 100644 --- a/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 +++ b/infra/ansible/roles/haproxy/templates/haproxy.cfg.j2 @@ -59,7 +59,14 @@ frontend stats # ----------------------------------------------------------------------- frontend veza_http_in 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 http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" http-request redirect scheme https code 301 if !{ ssl_fc } @@ -201,3 +208,15 @@ backend stream_pool {% endfor %} {% 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 %} diff --git a/infra/ansible/roles/haproxy/templates/letsencrypt_domains.txt.j2 b/infra/ansible/roles/haproxy/templates/letsencrypt_domains.txt.j2 new file mode 100644 index 000000000..965d0fa5c --- /dev/null +++ b/infra/ansible/roles/haproxy/templates/letsencrypt_domains.txt.j2 @@ -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 %} diff --git a/infra/ansible/roles/haproxy/templates/letsencrypt_le.config.j2 b/infra/ansible/roles/haproxy/templates/letsencrypt_le.config.j2 new file mode 100644 index 000000000..759ffdecf --- /dev/null +++ b/infra/ansible/roles/haproxy/templates/letsencrypt_le.config.j2 @@ -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 %}