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) <noreply@anthropic.com>
262 lines
12 KiB
Django/Jinja
262 lines
12 KiB
Django/Jinja
# Managed by Ansible — do not edit by hand.
|
|
# v1.0.9 W4 Day 19 (multi-instance) → W5+ extended to blue/green.
|
|
# `haproxy_topology` (set in group_vars/<env>.yml) selects between:
|
|
#
|
|
# multi-instance (default, lab) — server list comes from inventory
|
|
# groups backend_api_instances, stream_server_instances ; sticky
|
|
# cookie load-balances across N peers.
|
|
# blue-green (staging, prod) — server list is exactly two:
|
|
# <prefix>backend-blue + <prefix>backend-green. veza_active_color
|
|
# picks which one is primary ; the other is `backup` (HAProxy
|
|
# routes to a backup server only when ALL primaries are down).
|
|
# The veza_haproxy_switch role re-renders this template with a
|
|
# new active_color, validates, atomic-swaps, and HUPs.
|
|
|
|
global
|
|
log /dev/log local0
|
|
log /dev/log local1 notice
|
|
chroot /var/lib/haproxy
|
|
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
|
|
stats timeout 30s
|
|
user haproxy
|
|
group haproxy
|
|
daemon
|
|
server-state-file /var/lib/haproxy/server-state
|
|
ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11
|
|
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
|
|
|
|
defaults
|
|
log global
|
|
mode http
|
|
option httplog
|
|
option dontlognull
|
|
option forwardfor
|
|
option http-server-close
|
|
timeout connect 5s
|
|
timeout client 60s
|
|
timeout server 60s
|
|
timeout tunnel 1h
|
|
timeout client-fin 5s
|
|
timeout http-keep-alive 15s
|
|
timeout http-request 10s
|
|
load-server-state-from-file global
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Stats endpoint — bound to loopback only ; the Prometheus haproxy
|
|
# exporter sidecar scrapes it.
|
|
# -----------------------------------------------------------------------
|
|
frontend stats
|
|
bind 127.0.0.1:{{ haproxy_listen_stats }}
|
|
stats enable
|
|
stats uri /stats
|
|
stats refresh 5s
|
|
stats show-node
|
|
stats show-legends
|
|
no log
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Frontend — HTTP + (optionally) HTTPS. ACL-driven path routing.
|
|
# -----------------------------------------------------------------------
|
|
frontend veza_http_in
|
|
bind *:{{ haproxy_listen_http }}
|
|
{% 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.
|
|
# 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/
|
|
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"
|
|
http-request redirect scheme https code 301 if !{ ssl_fc }
|
|
{% endif %}
|
|
|
|
{% if haproxy_topology | default('multi-instance') == 'blue-green' %}
|
|
# ===================================================================
|
|
# Host-based routing — single edge HAProxy serves all envs + Forgejo
|
|
# ===================================================================
|
|
{% for env, hosts in haproxy_env_public_hosts.items() %}
|
|
acl host_{{ env }} hdr(host),lower -i {{ hosts | join(' ') }}
|
|
{% endfor %}
|
|
{% if haproxy_forgejo_host %}
|
|
acl host_forgejo hdr(host),lower -i {{ haproxy_forgejo_host }}
|
|
{% endif %}
|
|
{% if haproxy_talas_hosts %}
|
|
acl host_talas hdr(host),lower -i {{ haproxy_talas_hosts | join(' ') }}
|
|
{% endif %}
|
|
|
|
# Path ACLs (apply within each env's traffic)
|
|
acl is_api path_beg /api/v1
|
|
acl is_stream_seg path_beg /tracks/ path_end .m3u8
|
|
acl is_stream_seg path_beg /tracks/ path_end .ts
|
|
acl is_stream_seg path_beg /tracks/ path_end .m4s
|
|
acl is_stream_path path_beg /stream
|
|
acl is_stream_path path_beg /hls
|
|
|
|
# ===================================================================
|
|
# Routing — per env: API → backend, /tracks/* /stream /hls → stream,
|
|
# everything else → web. Forgejo and Talas bypass the path logic.
|
|
# ===================================================================
|
|
{% if haproxy_forgejo_host %}
|
|
use_backend forgejo_backend if host_forgejo
|
|
{% endif %}
|
|
{% if haproxy_talas_hosts %}
|
|
use_backend talas_vitrine_backend if host_talas
|
|
{% endif %}
|
|
{% for env in haproxy_env_public_hosts.keys() %}
|
|
use_backend {{ env }}_backend_api if host_{{ env }} is_api
|
|
use_backend {{ env }}_stream_pool if host_{{ env }} is_stream_seg
|
|
use_backend {{ env }}_stream_pool if host_{{ env }} is_stream_path
|
|
use_backend {{ env }}_web_pool if host_{{ env }}
|
|
{% endfor %}
|
|
|
|
# Default backend — request didn't match any known host. Returns the
|
|
# talas vitrine if configured, otherwise a hard 503.
|
|
{% if haproxy_talas_hosts %}
|
|
default_backend talas_vitrine_backend
|
|
{% else %}
|
|
default_backend default_503
|
|
{% endif %}
|
|
{% else %}
|
|
acl is_api path_beg /api/v1
|
|
acl is_stream path_beg /tracks/ path_end .m3u8
|
|
acl is_stream path_beg /tracks/ path_end .ts
|
|
acl is_stream path_beg /tracks/ path_end .m4s
|
|
use_backend stream_pool if is_stream
|
|
default_backend api_pool
|
|
{% endif %}
|
|
|
|
{% if haproxy_topology | default('multi-instance') == 'blue-green' %}
|
|
# =======================================================================
|
|
# BLUE / GREEN backends, per env (staging + prod)
|
|
#
|
|
# haproxy_active_colors comes from the veza_haproxy_switch role's
|
|
# set_fact in tasks/main.yml — it always carries BOTH envs' current
|
|
# colors so a staging deploy doesn't drop the prod backend (and v.v.).
|
|
# =======================================================================
|
|
{% set active_colors = haproxy_active_colors | default({'staging': 'blue', 'prod': 'blue'}) %}
|
|
|
|
{% for env, prefix in haproxy_env_prefixes.items() %}
|
|
{% set _active = active_colors[env] | default('blue') %}
|
|
|
|
# --- {{ env }} : backend API (Go) -------------------------------------
|
|
backend {{ env }}_backend_api
|
|
balance roundrobin
|
|
option httpchk GET {{ veza_healthcheck_paths.backend | default('/api/v1/health') }}
|
|
http-check expect status 200
|
|
cookie {{ haproxy_sticky_cookie_name }}_{{ env }} insert indirect nocache httponly secure
|
|
default-server check inter {{ haproxy_health_check_interval_ms }} fall {{ haproxy_health_check_fall }} rise {{ haproxy_health_check_rise }} on-marked-down shutdown-sessions slowstart {{ haproxy_graceful_drain_seconds }}s
|
|
server {{ env }}_backend_blue {{ prefix }}backend-blue.{{ veza_incus_dns_suffix }}:{{ veza_backend_port }} cookie {{ env }}_backend_blue {{ '' if _active == 'blue' else 'backup' }}
|
|
server {{ env }}_backend_green {{ prefix }}backend-green.{{ veza_incus_dns_suffix }}:{{ veza_backend_port }} cookie {{ env }}_backend_green {{ '' if _active == 'green' else 'backup' }}
|
|
|
|
# --- {{ env }} : stream pool (Rust) -----------------------------------
|
|
backend {{ env }}_stream_pool
|
|
balance uri whole
|
|
hash-type consistent
|
|
option httpchk GET {{ veza_healthcheck_paths.stream | default('/health') }}
|
|
http-check expect status 200
|
|
timeout tunnel 1h
|
|
default-server check inter {{ haproxy_health_check_interval_ms }} fall {{ haproxy_health_check_fall }} rise {{ haproxy_health_check_rise }} on-marked-down shutdown-sessions slowstart {{ haproxy_graceful_drain_seconds }}s
|
|
server {{ env }}_stream_blue {{ prefix }}stream-blue.{{ veza_incus_dns_suffix }}:{{ veza_stream_port }} {{ '' if _active == 'blue' else 'backup' }}
|
|
server {{ env }}_stream_green {{ prefix }}stream-green.{{ veza_incus_dns_suffix }}:{{ veza_stream_port }} {{ '' if _active == 'green' else 'backup' }}
|
|
|
|
# --- {{ env }} : web pool (nginx) -------------------------------------
|
|
backend {{ env }}_web_pool
|
|
balance roundrobin
|
|
option httpchk GET {{ veza_healthcheck_paths.web | default('/') }}
|
|
http-check expect status 200
|
|
default-server check inter {{ haproxy_health_check_interval_ms }} fall {{ haproxy_health_check_fall }} rise {{ haproxy_health_check_rise }} on-marked-down shutdown-sessions slowstart {{ haproxy_graceful_drain_seconds }}s
|
|
server {{ env }}_web_blue {{ prefix }}web-blue.{{ veza_incus_dns_suffix }}:{{ veza_web_port }} {{ '' if _active == 'blue' else 'backup' }}
|
|
server {{ env }}_web_green {{ prefix }}web-green.{{ veza_incus_dns_suffix }}:{{ veza_web_port }} {{ '' if _active == 'green' else 'backup' }}
|
|
|
|
{% endfor %}
|
|
|
|
{% if haproxy_forgejo_host %}
|
|
# --- Forgejo (managed outside the deploy pipeline) --------------------
|
|
backend forgejo_backend
|
|
option httpchk GET /
|
|
http-check expect status 200
|
|
default-server check inter 10s fall 3 rise 2
|
|
server forgejo {{ haproxy_forgejo_backend }}
|
|
{% endif %}
|
|
|
|
{% if haproxy_talas_hosts %}
|
|
# --- Talas vitrine (placeholder until the site lands) -----------------
|
|
backend talas_vitrine_backend
|
|
{% if haproxy_talas_vitrine_backend %}
|
|
default-server check inter 5s
|
|
server talas {{ haproxy_talas_vitrine_backend }}
|
|
{% else %}
|
|
# No backend configured yet — return 503 with a small body.
|
|
http-request return status 503 content-type text/plain string "Talas vitrine — coming soon."
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
# --- 503 catch-all ----------------------------------------------------
|
|
backend default_503
|
|
http-request return status 503 content-type text/plain string "Unknown host"
|
|
|
|
{% else %}
|
|
# =======================================================================
|
|
# MULTI-INSTANCE topology (lab, default)
|
|
# Server list comes from inventory groups ; sticky cookie load-balances.
|
|
# =======================================================================
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Backend api_pool — Gin REST API. Sticky cookie + active health check.
|
|
# -----------------------------------------------------------------------
|
|
backend api_pool
|
|
balance roundrobin
|
|
option httpchk GET /api/v1/health
|
|
http-check expect status 200
|
|
cookie {{ haproxy_sticky_cookie_name }} insert indirect nocache httponly secure
|
|
default-server check
|
|
inter {{ haproxy_health_check_interval_ms }}
|
|
fall {{ haproxy_health_check_fall }}
|
|
rise {{ haproxy_health_check_rise }}
|
|
on-marked-down shutdown-sessions
|
|
slowstart {{ haproxy_graceful_drain_seconds }}s
|
|
|
|
{% set api_hosts = (groups['backend_api_instances'] | default(haproxy_backend_api_fallback)) %}
|
|
{% for host in api_hosts %}
|
|
server {{ host }} {{ host }}.lxd:{{ haproxy_backend_api_port }} cookie {{ host }}
|
|
{% endfor %}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Backend stream_pool — Rust Axum HLS. URI hash so the same track_id
|
|
# consistently lands on the same node.
|
|
# -----------------------------------------------------------------------
|
|
backend stream_pool
|
|
balance uri whole
|
|
hash-type consistent
|
|
option httpchk GET /health
|
|
http-check expect status 200
|
|
default-server check
|
|
inter {{ haproxy_health_check_interval_ms }}
|
|
fall {{ haproxy_health_check_fall }}
|
|
rise {{ haproxy_health_check_rise }}
|
|
on-marked-down shutdown-sessions
|
|
slowstart {{ haproxy_graceful_drain_seconds }}s
|
|
|
|
{% set stream_hosts = (groups['stream_server_instances'] | default(haproxy_stream_server_fallback)) %}
|
|
{% for host in stream_hosts %}
|
|
server {{ host }} {{ host }}.lxd:{{ haproxy_stream_server_port }}
|
|
{% 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 %}
|