#!/bin/bash
# haproxyctl - HAProxy Manager for SecuBox Debian
# Control script for HAProxy (native or LXC — never docker)
# Three-fold architecture: Components, Status, Access
set -e

VERSION="1.1.1"

LXC_NAME="haproxy"
LXC_PATH="/var/lib/lxc/$LXC_NAME"
# Canonical SecuBox data root is /data (charter §Storage). Older boards used
# /srv/haproxy; we honour both via auto-detect, with HAPROXY_DATA_PATH as the
# explicit override. Closes the recurring cert-path bug in #286 where
# cmd_generate wrote `bind *:443 ssl crt /srv/haproxy/certs/` on boards that
# actually keep certs in /data/haproxy/certs/.
DATA_PATH="${HAPROXY_DATA_PATH:-/data/haproxy}"
[ -d "$DATA_PATH" ] || DATA_PATH="/srv/haproxy"
# Surchargeables : indispensable pour verifier une configuration hors de la
# board (tests, mise au point) sans jamais toucher au frontal en production.
CONF_PATH="${SECUBOX_HAPROXY_CONF:-/etc/secubox/haproxy.toml}"

# Valeur d'une cle de haproxy.toml, ou la valeur par defaut ($2) quand la cle est ABSENTE ou VIDE (#2526).
# L'ancienne forme `$(grep … | cut … | tr -d ' ' || echo "80")` ne l'appliquait jamais : le `||` porte sur
# le dernier maillon du tube (tr), qui reussit toujours. Sur une box neuve (haproxy.toml sans ports) le cfg
# sortait avec `bind *:` et HAProxy le refusait (« missing port specification »).
cle_conf() {
    local v
    v=$(grep -E "^$1[[:space:]]*=" "$CONF_PATH" 2>/dev/null | head -1 | cut -d'=' -f2 | tr -d ' ')
    echo "${v:-$2}"
}
# Qui a le droit d'encadrer un vhost marque `cadrable = true` (#1257).
# EXPLICITE et surchargeable : `*` accepterait n'importe quel site, ce qui est
# l'attaque meme que frame-ancestors existe pour empecher.
# Le Hall et billets de CETTE box (#1723) — hall.gk2 figé laissait gk3 refuser
# d'être encadrée par son propre Hall. gk2 garde son alias public hall.gk2.net.
_D="$(secubox-domaine 2>/dev/null || true)"
_CADRE="'self'"
[ -n "$_D" ] && _CADRE+=" https://hall.$_D"
[ "$_D" = "gk2.secubox.in" ] && _CADRE+=" https://hall.gk2.net"
[ -n "$_D" ] && _CADRE+=" https://billets.$_D"
# ytsas encadre le lecteur PeerTube de ses titres miroir (#2750).
[ -n "$_D" ] && _CADRE+=" https://ytsas.$_D"
CADRE_ANCETRES="${SECUBOX_FRAME_ANCESTORS:-$_CADRE}"
# + les Halls du parc relayés par cette box (#1672, secubox-halls-parc) :
# origines https strictes seulement — un espace ou un `;` casserait la CSP.
if [ -r /var/lib/secubox/halls-parc/origines.txt ]; then
    while IFS= read -r _o; do
        [[ "$_o" =~ ^https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] && CADRE_ANCETRES+=" $_o"
    done < /var/lib/secubox/halls-parc/origines.txt
fi
CONFIG_DIR="${SECUBOX_HAPROXY_CONFIG_DIR:-/etc/haproxy}"
CERTS_DIR="$DATA_PATH/certs"
# Operator-managed extra frontends/backends (webui-lan, gitea-ssh, custom
# metablog_* backends, etc.) live as separate files in /etc/haproxy/cfg.d/.
# cmd_generate appends them verbatim at the end of the generated cfg so they
# survive every vhost-add regen (#286).
EXTRA_CFG_DIR="${HAPROXY_EXTRA_CFG_DIR:-/etc/haproxy/cfg.d}"
STATS_SOCKET="/run/haproxy/admin.sock"
LOG_FILE="/var/log/secubox/haproxy.log"

# Mode: native or lxc (docker retiré — LXC uniquement, #1751)
MODE="native"

# Logging
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" 2>/dev/null || echo "$*"; }
error() { log "ERROR: $*" >&2; }

# Load config
# Valeur d'une clé de [main] (ou d'avant toute section), premiere occurrence.
# Un grep sur tout le fichier ramassait aussi les `mode = "http"` des
# sections de vhosts : MODE valait « native\nhttp\nhttp », aucune branche
# `case` ne correspondait (seul le `*)` sauvait la mise) et `status` rendait
# un JSON invalide (#1751).
toml_main() {
    awk -v k="$1" '
        /^[[:space:]]*\[/ { sect = $0; gsub(/[[:space:]]/, "", sect); next }
        (sect == "" || sect == "[main]") && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" {
            v = $0; sub(/^[^=]*=[[:space:]]*/, "", v); sub(/[[:space:]]+#.*$/, "", v)
            gsub(/"/, "", v); print v; exit
        }' "$CONF_PATH"
}

load_config() {
    if [ -f "$CONF_PATH" ]; then
        MODE=$(toml_main mode); [ -n "$MODE" ] || MODE="native"
        local dp; dp=$(toml_main data_path); [ -n "$dp" ] && DATA_PATH="$dp"
    fi
    case "$MODE" in
        native|lxc) ;;
        *) echo "haproxyctl: mode « $MODE » non pris en charge (native ou lxc ; docker retiré, #1751) — native" >&2
           MODE="native" ;;
    esac
}

# CORPS D'UNE TABLE TOML, SANS L'EN-TETE DE LA TABLE SUIVANTE.
#
# L'extraction historique etait `sed '/^\[x\]/,/^\[/p' | head -n -1` : la plage
# sed inclut la ligne d'arret, donc l'en-tete de la table SUIVANTE, et le
# `head -n -1` servait a la retrancher. Quand la table visee est la DERNIERE du
# fichier, il n'y a pas d'en-tete suivant a retrancher — c'est une vraie ligne
# de configuration qui etait mangee, en silence.
#
# CE QUE CELA CASSAIT. Le dernier `[backends.X]` du haproxy.toml perdait son
# `servers = [...]` et sortait dans le cfg genere SANS UN SEUL SERVEUR : un
# backend sans serveur repond 503 sur tout son trafic. Un dernier `[vhosts.X]`
# perdait de meme son dernier drapeau — `ssl` absent = plus de redirection
# HTTPS ni de regle dans frontend https-in pour ce vhost.
#
# On s'arrete donc AVANT l'en-tete suivant plutot que de couper apres coup.
_toml_section() {
    awk -v want="[$1]" '
        { ligne = $0; sub(/[[:space:]]+$/, "", ligne) }
        dedans && ligne ~ /^\[/ { exit }
        dedans { print }
        ligne == want { dedans = 1 }
    ' "$2"
}

# LES CHAMPS DE TOUS LES VHOSTS, EN UNE LECTURE (#1685).
#
# `generate` extrayait chaque champ de chaque vhost par
# `echo "$section" | grep | cut` : une vingtaine de sous-processus par vhost et
# par boucle. Sur gk2 (119 vhosts, carte ARM), 27 a 33 s par generation — et
# `vhost add` en fait une, d'ou ~45 s a l'etape « Service » d'une publication.
#
# Ici, UNE passe rend, pour chaque ligne `[vhosts.*]` et dans l'ordre du
# fichier, les memes valeurs que l'ancienne extraction, bizarreries comprises :
# nom = la ligne sans son premier « [vhosts. » ni son premier « ] » ; section =
# l'algorithme de _toml_section (premiere table de ce nom, jusqu'a la prochaine
# ligne qui commence par « [ ») ; valeur = `grep -E '^cle\s*=' | cut -d'"' -f2`
# (plusieurs lignes jointes) ; booleen = « true »/« false » cherche dans les
# lignes de la cle. Critere de la reecriture : haproxy.cfg identique a l'octet.
#
# Sortie : un enregistrement par vhost, termine par \x1e, champs separes par
# \x1f — nom, domain, backend, enabled, waf_bypass, ssl, ssl_redirect,
# cadrable, cookies_tiers.
_vhosts_champs() {
    python3 - "$1" <<'PYCHAMPS'
import re, sys
try:
    brut = open(sys.argv[1], "rb").read().decode("utf-8", "surrogateescape")
except OSError:
    sys.exit(0)
lignes = brut.split("\n")
if brut.endswith("\n"):
    lignes.pop()                       # awk ne voit pas d'enregistrement vide final
ESP = " \t\n\r\x0b\x0c"

def section(want):
    out, dedans = [], False
    for l in lignes:
        ligne = l.rstrip(ESP)
        if dedans and ligne.startswith("["):
            break
        if dedans:
            out.append(l)
        if ligne == want:
            dedans = True
    while out and out[-1] == "":       # $(...) retire les sauts de ligne finaux
        out.pop()
    return out

def lignes_de(sec, cle):
    r = re.compile("^" + cle + "[ \t\n\r\x0b\x0c]*=")
    return [l for l in sec if r.search(l)]

def valeur(sec, cle):
    vals = []
    for l in lignes_de(sec, cle):
        morceaux = l.split('"')
        vals.append(morceaux[1] if len(morceaux) > 1 else l)
    return "\n".join(vals).rstrip("\n")

def contient(sec, cle, mot):
    return any(mot in l for l in lignes_de(sec, cle))

sortie = []
for l in lignes:
    if not l.startswith("[vhosts."):
        continue
    # `while read -r line` rognait les blancs de tete et de fin (IFS par
    # defaut) avant le sed : un « [vhosts.x]   » donnait bien le nom « x ».
    nom = l.strip(" \t").replace("[vhosts.", "", 1).replace("]", "", 1)
    sec = section("[vhosts." + nom + "]")
    champs = [
        nom,
        valeur(sec, "domain"),
        valeur(sec, "backend"),
        "0" if contient(sec, "enabled", "false") else "1",
        "1" if contient(sec, "waf_bypass", "true") else "0",
        "1" if contient(sec, "ssl", "true") else "0",
        "1" if contient(sec, "ssl_redirect", "true") else "0",
        "1" if contient(sec, "cadrable", "true") else "0",
        "1" if contient(sec, "cookies_tiers", "true") else "0",
    ]
    sortie.append("\x1f".join(champs) + "\x1e")
sys.stdout.buffer.write("".join(sortie).encode("utf-8", "surrogateescape"))
PYCHAMPS
}

# Fetch the strict-regex pattern from the secubox-haproxy API.
# Fallback: read /etc/default/secubox directly if the API socket is unreachable.
_fetch_webui_regex() {
    local sock=/run/secubox/haproxy.sock
    local regex
    if [[ -S "$sock" ]] && \
       regex=$(curl -sf --max-time 2 --unix-socket "$sock" \
                    http://localhost/webui/admin-domain 2>/dev/null \
                | jq -r '.regex // empty' 2>/dev/null) && \
       [[ -n "$regex" ]]; then
        echo "$regex"; return 0
    fi
    if [[ -f /etc/default/secubox ]]; then
        # shellcheck source=/dev/null
        . /etc/default/secubox
        if [[ -n "${SECUBOX_HOSTNAME:-}" ]]; then
            local suffix="${SECUBOX_DOMAIN_SUFFIX:-secubox.in}"
            local admin="admin.${SECUBOX_HOSTNAME}.${suffix}"
            # Escape literal dots for HAProxy regex
            echo "^${admin//./\\.}\$"
            return 0
        fi
    fi
    return 1
}

# Check container status
lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; }
lxc_exists() { [ -d "$LXC_PATH/rootfs" ]; }

# HAProxy native running check
haproxy_running() {
    case "$MODE" in
        lxc) lxc_running ;;
        *) pgrep -x haproxy >/dev/null 2>&1 ;;
    esac
}

# ─────────────────────────────────────────────────────────────────────
# CONTAINER MANAGEMENT
# ─────────────────────────────────────────────────────────────────────

cmd_install() {
    log "Installing HAProxy ($MODE mode)..."
    mkdir -p "$DATA_PATH" "$CERTS_DIR" "$CONFIG_DIR" "$(dirname $LOG_FILE)" /run/haproxy

    case "$MODE" in
        lxc)
            if lxc_exists; then
                log "LXC container already exists"
                return 0
            fi

            lxc-create -n "$LXC_NAME" -t download -- \
                -d alpine -r 3.19 -a arm64 || {
                error "Failed to create LXC container"
                return 1
            }

            cat >> "$LXC_PATH/config" << EOF

# HAProxy bind mounts
lxc.mount.entry = $CONFIG_DIR etc/haproxy none bind,create=dir 0 0
lxc.mount.entry = $CERTS_DIR srv/haproxy/certs none bind,create=dir 0 0
lxc.mount.entry = /run/haproxy run/haproxy none bind,create=dir 0 0

# Network - use macvlan for public access
lxc.net.0.type = veth
lxc.net.0.link = br-lan
lxc.net.0.flags = up
lxc.net.0.name = eth0

# Resource limits
lxc.cgroup2.memory.max = 512M
EOF

            lxc-start -n "$LXC_NAME"
            sleep 3

            lxc-attach -n "$LXC_NAME" -- sh -c '
                apk update
                apk add haproxy openssl curl
                mkdir -p /run/haproxy
            '
            ;;

        *)
            # Native install - assume haproxy package is installed
            if ! command -v haproxy >/dev/null; then
                error "HAProxy not found. Install with: apt install haproxy"
                return 1
            fi
            ;;
    esac

    # Generate default config
    cmd_generate

    log "HAProxy installation complete ($MODE mode)"
}

cmd_uninstall() {
    log "Uninstalling HAProxy container..."

    case "$MODE" in
        lxc)
            lxc_running && lxc-stop -n "$LXC_NAME"
            lxc_exists && lxc-destroy -n "$LXC_NAME"
            ;;
    esac

    log "Container removed (config preserved in $CONFIG_DIR)"
}

cmd_start() {
    log "Starting HAProxy ($MODE mode)..."

    case "$MODE" in
        lxc)
            lxc_exists || { error "Container not installed"; return 1; }
            lxc_running || lxc-start -n "$LXC_NAME"
            sleep 2
            lxc-attach -n "$LXC_NAME" -- haproxy -f /etc/haproxy/haproxy.cfg -D
            ;;
        *)
            systemctl start haproxy || \
                haproxy -f /etc/haproxy/haproxy.cfg -D
            ;;
    esac

    log "HAProxy started"
}

cmd_stop() {
    log "Stopping HAProxy..."

    case "$MODE" in
        lxc)
            lxc_running && lxc-attach -n "$LXC_NAME" -- killall haproxy 2>/dev/null || true
            ;;
        *)
            systemctl stop haproxy 2>/dev/null || killall haproxy 2>/dev/null || true
            ;;
    esac

    log "HAProxy stopped"
}

cmd_reload() {
    log "Reloading HAProxy configuration..."

    case "$MODE" in
        lxc)
            lxc-attach -n "$LXC_NAME" -- sh -c 'kill -USR2 $(cat /run/haproxy/haproxy.pid 2>/dev/null) 2>/dev/null' || \
            lxc-attach -n "$LXC_NAME" -- haproxy -f /etc/haproxy/haproxy.cfg -sf $(pidof haproxy)
            ;;
        *)
            systemctl reload haproxy 2>/dev/null || \
                haproxy -f /etc/haproxy/haproxy.cfg -sf $(pidof haproxy)
            ;;
    esac

    log "HAProxy reloaded"
}

cmd_status() {
    local running="false"
    local mode="$MODE"

    haproxy_running && running="true"

    local vhost_count=0
    local backend_count=0
    local cert_count=0

    [ -f "$CONF_PATH" ] && {
        vhost_count=$(grep -c '^\[vhosts\.' "$CONF_PATH" 2>/dev/null || echo "0")
        backend_count=$(grep -c '^\[backends\.' "$CONF_PATH" 2>/dev/null || echo "0")
    }
    [ -d "$CERTS_DIR" ] && cert_count=$(ls -1 "$CERTS_DIR"/*.pem 2>/dev/null | wc -l || echo "0")

    cat << EOF
{
  "running": $running,
  "mode": "$mode",
  "config_dir": "$CONFIG_DIR",
  "data_path": "$DATA_PATH",
  "vhost_count": $vhost_count,
  "backend_count": $backend_count,
  "cert_count": $cert_count
}
EOF
}

# ─────────────────────────────────────────────────────────────────────
# THREE-FOLD ARCHITECTURE: Components (What)
# ─────────────────────────────────────────────────────────────────────

cmd_components() {
    local running=false
    haproxy_running && running=true

    local acme_installed=false
    command -v acme.sh >/dev/null 2>&1 && acme_installed=true

    local stats_socket_ok=false
    [ -S "$STATS_SOCKET" ] && stats_socket_ok=true

    local waf_enabled=false
    grep -q '^waf_enabled\s*=\s*true' "$CONF_PATH" 2>/dev/null && waf_enabled=true

    local vhost_count=0
    local backend_count=0
    local cert_count=0
    [ -f "$CONF_PATH" ] && vhost_count=$(grep -c '^\[vhosts\.' "$CONF_PATH" 2>/dev/null || echo "0")
    [ -f "$CONF_PATH" ] && backend_count=$(grep -c '^\[backends\.' "$CONF_PATH" 2>/dev/null || echo "0")
    [ -d "$CERTS_DIR" ] && cert_count=$(ls -1 "$CERTS_DIR"/*.pem 2>/dev/null | wc -l || echo "0")

    cat <<EOF
{
  "components": [
    {
      "name": "HAProxy",
      "type": "service",
      "description": "High-performance load balancer",
      "installed": true,
      "running": $running,
      "mode": "$MODE"
    },
    {
      "name": "Stats Socket",
      "type": "socket",
      "path": "$STATS_SOCKET",
      "description": "HAProxy admin socket",
      "running": $stats_socket_ok
    },
    {
      "name": "ACME Client",
      "type": "tool",
      "description": "Let's Encrypt certificate automation",
      "installed": $acme_installed
    },
    {
      "name": "WAF Integration",
      "type": "module",
      "description": "Web Application Firewall MITM",
      "enabled": $waf_enabled
    },
    {
      "name": "VHosts",
      "type": "config",
      "count": $vhost_count
    },
    {
      "name": "Backends",
      "type": "config",
      "count": $backend_count
    },
    {
      "name": "SSL Certificates",
      "type": "certs",
      "path": "$CERTS_DIR",
      "count": $cert_count
    }
  ]
}
EOF
}

# ─────────────────────────────────────────────────────────────────────
# THREE-FOLD ARCHITECTURE: Access (How to connect)
# ─────────────────────────────────────────────────────────────────────

cmd_access() {
    local http_port=$(cle_conf http_port 80)
    local https_port=$(cle_conf https_port 443)
    local stats_port=$(cle_conf stats_port 8404)

    # Get vhosts as JSON array
    local vhosts_json="["
    local first=true
    if [ -f "$CONF_PATH" ]; then
        grep '^\[vhosts\.' "$CONF_PATH" | while read -r line; do
            local name=$(echo "$line" | sed 's/\[vhosts\.//;s/\]//')
            local section=$(_toml_section "vhosts.$name" "$CONF_PATH")
            local domain=$(echo "$section" | grep -E '^domain\s*=' | cut -d'"' -f2)
            local ssl=$(echo "$section" | grep -E '^ssl\s*=' | grep -q 'true' && echo "true" || echo "false")
            local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'false' && echo "false" || echo "true")

            [ -n "$domain" ] && {
                $first || echo -n ","
                first=false
                echo -n "{\"domain\":\"$domain\",\"ssl\":$ssl,\"enabled\":$enabled}"
            }
        done
    fi
    vhosts_json+="]"

    cat <<EOF
{
  "ports": {
    "http": $http_port,
    "https": $https_port,
    "stats": $stats_port
  },
  "stats_url": "http://localhost:${stats_port}/stats",
  "admin_panel": "/haproxy/",
  "config_dir": "$CONFIG_DIR",
  "certs_dir": "$CERTS_DIR"
}
EOF
}

# ─────────────────────────────────────────────────────────────────────
# STATS
# ─────────────────────────────────────────────────────────────────────

cmd_stats() {
    if [ -S "$STATS_SOCKET" ]; then
        echo "show stat" | socat stdio "$STATS_SOCKET" 2>/dev/null
    else
        echo "Stats socket not available"
        return 1
    fi
}

cmd_info() {
    if [ -S "$STATS_SOCKET" ]; then
        echo "show info" | socat stdio "$STATS_SOCKET" 2>/dev/null
    else
        echo "Stats socket not available"
        return 1
    fi
}

# ─────────────────────────────────────────────────────────────────────
# VHOST MANAGEMENT
# ─────────────────────────────────────────────────────────────────────

cmd_vhost_list() {
    echo '{"vhosts":['
    local first=true

    if [ -f "$CONF_PATH" ]; then
        grep '^\[vhosts\.' "$CONF_PATH" | while read -r line; do
            local name=$(echo "$line" | sed 's/\[vhosts\.//;s/\]//')
            local section=$(_toml_section "vhosts.$name" "$CONF_PATH")
            local domain=$(echo "$section" | grep -E '^domain\s*=' | cut -d'"' -f2)
            local backend=$(echo "$section" | grep -E '^backend\s*=' | cut -d'"' -f2)
            local ssl=$(echo "$section" | grep -E '^ssl\s*=' | grep -q 'true' && echo "true" || echo "false")
            local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'false' && echo "false" || echo "true")

            $first || echo ","
            first=false

            echo "  {\"name\": \"$name\", \"domain\": \"$domain\", \"backend\": \"$backend\", \"ssl\": $ssl, \"enabled\": $enabled}"
        done
    fi

    echo ']}'
}

# ─────────────────────────────────────────────────────────────────────
# EDITION DU TOML — bornes de section (#1015)
#
# Une section TOML va de son en-tete jusqu'a l'en-tete SUIVANT, EXCLU. C'est
# exactement ce que l'ancienne suppression rate : `sed '/^\[a\]/,/^\[/d'`
# supprime jusqu'a la ligne suivante commencant par `[` INCLUSE, donc decapite
# la section d'apres et laisse ses cles orphelines. Silencieusement.
# ─────────────────────────────────────────────────────────────────────

# vhost_table_delete <nom> — retire UNE table [vhosts.<nom>], la premiere.
# Rend 0 si quelque chose a ete retire.
vhost_table_delete() {
    local name="$1" tmp
    tmp=$(mktemp) || return 1
    awk -v cible="[vhosts.$name]" '
        # On copie tout, sauf les lignes de la table ciblee. `dans` retombe a 0
        # des le prochain en-tete, qui est donc CONSERVE.
        /^\[/ { dans = ($0 == cible) ? 1 : 0 }
        !dans  { print }
    ' "$CONF_PATH" > "$tmp" || { rm -f "$tmp"; return 1; }
    # Ecriture par renommage : un fichier de configuration tronque par une
    # interruption rendrait HAProxy non generable.
    cat "$tmp" > "$CONF_PATH" && rm -f "$tmp"
}

# vhost_table_replace <nom> <domaine> <backend> <ssl> — remplace la table en
# place, en PRESERVANT les cles que le paquet ne gere pas (waf_bypass, etc.).
# Les perdre a la mise a jour d'un vhost desactiverait en silence une exception
# WAF explicitement declaree par l'operateur.
vhost_table_replace() {
    local name="$1" domain="$2" backend="$3" ssl="$4" tmp
    tmp=$(mktemp) || return 1
    awk -v cible="[vhosts.$name]" -v dom="$domain" -v bk="$backend" -v ssl="$ssl" '
        /^\[/ {
            if (dans && !ecrit) { ecrit = 1 }
            dans = ($0 == cible) ? 1 : 0
            if (dans) {
                print cible
                print "domain = \"" dom "\""
                print "backend = \"" bk "\""
                print "ssl = " ssl
                next
            }
        }
        dans {
            # Dans la table ciblee : on jette ce qu on vient de reecrire et on
            # garde tout le reste tel quel.
            if ($0 ~ /^(domain|backend|ssl)[[:space:]]*=/) next
            print
            next
        }
        { print }
    ' "$CONF_PATH" > "$tmp" || { rm -f "$tmp"; return 1; }
    cat "$tmp" > "$CONF_PATH" && rm -f "$tmp"
}

cmd_vhost_add() {
    local domain="$1"
    local backend="$2"
    local ssl="${3:-false}"

    [ -z "$domain" ] || [ -z "$backend" ] && {
        error "Usage: haproxyctl vhost add <domain> <backend> [ssl]"
        return 1
    }

    local name=$(echo "$domain" | tr '.-' '_')

    # `ssl` EST NORMALISE EN BOOLEEN (#1015). L'argument etait insere verbatim :
    # `haproxyctl vhost add <d> <b> ssl` produisait `ssl = ssl`, qui n'est pas du
    # TOML. C'est arrive en production — l'API du module charge ce fichier avec
    # tomllib et echouait donc, pendant que le generateur (grep/sed) continuait
    # sans rien signaler.
    case "$(echo "$ssl" | tr '[:upper:]' '[:lower:]')" in
        true|yes|on|1|ssl)  ssl=true ;;
        false|no|off|0|"")  ssl=false ;;
        *) error "vhost add: ssl doit etre true ou false (recu: '$ssl')"; return 1 ;;
    esac

    # IDEMPOTENT (#1015). L'ancienne version appendait sans regarder l'existant :
    # deux appels pour le meme domaine laissaient deux tables, donc deux ACL
    # identiques dans le cfg genere et un TOML invalide au sens strict.
    if grep -q "^\[vhosts\.$name\]" "$CONF_PATH" 2>/dev/null; then
        vhost_table_replace "$name" "$domain" "$backend" "$ssl" \
            || { error "vhost add: mise a jour de $domain impossible"; return 1; }
        log "Updated vhost: $domain -> $backend"
        cmd_generate
        return 0
    fi

    cat >> "$CONF_PATH" << EOF

[vhosts.$name]
domain = "$domain"
backend = "$backend"
ssl = $ssl
ssl_redirect = true
enabled = true
EOF

    log "Added vhost: $domain -> $backend"
    cmd_generate
}

cmd_config_repair() {
    # Redresse un haproxy.toml deja abime par les anciens `vhost add/remove`
    # (#1015). EN LECTURE SEULE PAR DEFAUT : on ne reecrit pas la configuration
    # d'un frontal en production sans demande explicite.
    local ecrire=0
    [ "${1:-}" = "--write" ] && ecrire=1

    [ -f "$CONF_PATH" ] || { error "config absente: $CONF_PATH"; return 1; }

    python3 - "$CONF_PATH" "$ecrire" <<'PYEOF'
import re, shutil, sys, time

chemin, ecrire = sys.argv[1], sys.argv[2] == "1"
lignes = open(chemin).read().splitlines(keepends=True)

# 1. BOOLEENS INVALIDES. `vhost add <d> <b> ssl` ecrivait `ssl = ssl`, qui n'est
#    pas du TOML. Le generateur lit ce champ par `grep -q 'true'` : tout ce qui
#    n'est pas litteralement `true` vaut deja false pour lui. Normaliser vers
#    `false` preserve donc EXACTEMENT le comportement, sans toucher au trafic.
bools, sortie = [], []
for i, l in enumerate(lignes, 1):
    m = re.match(r'^(ssl|ssl_redirect|enabled|waf_bypass)\s*=\s*(.+?)\s*$', l)
    if m and m.group(2) not in ("true", "false"):
        bools.append((i, m.group(1), m.group(2)))
        l = "%s = false\n" % m.group(1)
    sortie.append(l)

# 2. TABLES EN DOUBLE. On garde la PREMIERE : c'est celle que le generateur
#    rencontre d'abord, donc celle dont le comportement est deja observe.
vus, doublons, final, jeter = set(), [], [], False
for i, l in enumerate(sortie, 1):
    m = re.match(r'^\[([^\]]+)\]', l)
    if m:
        nom = m.group(1)
        jeter = nom in vus
        if jeter:
            doublons.append((i, nom))
        vus.add(nom)
    if not jeter:
        final.append(l)

for i, cle, val in bools:
    print("  ligne %d: %s = %s  -> booleen invalide" % (i, cle, val))
for i, nom in doublons:
    print("  ligne %d: [%s]  -> table en double" % (i, nom))

if not bools and not doublons:
    print("  configuration saine")
    sys.exit(0)

if not ecrire:
    print("  %d anomalie(s) — relancer avec --write pour corriger"
          % (len(bools) + len(doublons)))
    sys.exit(0)

# Sauvegarde horodatee AVANT reecriture : la configuration d'un frontal en
# production ne se remplace pas sans filet.
shutil.copy2(chemin, "%s.avant-reparation-%s" % (chemin, time.strftime("%Y%m%d-%H%M%S")))
open(chemin, "w").write("".join(final))
print("  %d anomalie(s) corrigee(s)" % (len(bools) + len(doublons)))
PYEOF
}

cmd_vhost_remove() {
    local name="$1"
    [ -z "$name" ] && { error "Usage: haproxyctl vhost remove <name>"; return 1; }

    # NE TOUCHE PLUS A LA SECTION SUIVANTE (#1015). L'ancien
    # `sed '/^\[vhosts\.X\]/,/^\[/d'` supprimait jusqu'a l'en-tete d'apres
    # INCLUS : retirer un vhost decapitait le suivant et laissait ses cles
    # orphelines, absorbees par la section precedente.
    vhost_table_delete "$name" || { error "vhost remove: echec sur $name"; return 1; }

    log "Removed vhost: $name"
    cmd_generate
}

# ─────────────────────────────────────────────────────────────────────
# BACKEND MANAGEMENT
# ─────────────────────────────────────────────────────────────────────

cmd_backend_list() {
    echo '{"backends":['
    local first=true

    if [ -f "$CONF_PATH" ]; then
        grep '^\[backends\.' "$CONF_PATH" | while read -r line; do
            local name=$(echo "$line" | sed 's/\[backends\.//;s/\]//')
            local section=$(_toml_section "backends.$name" "$CONF_PATH")
            local mode=$(echo "$section" | grep -E '^mode\s*=' | cut -d'"' -f2 || echo "http")
            local balance=$(echo "$section" | grep -E '^balance\s*=' | cut -d'"' -f2 || echo "roundrobin")

            $first || echo ","
            first=false

            echo "  {\"name\": \"$name\", \"mode\": \"$mode\", \"balance\": \"$balance\"}"
        done
    fi

    echo ']}'
}

# ─────────────────────────────────────────────────────────────────────
# CERTIFICATE MANAGEMENT
# ─────────────────────────────────────────────────────────────────────

cmd_cert_list() {
    echo '{"certificates":['
    local first=true

    if [ -d "$CERTS_DIR" ]; then
        for cert in "$CERTS_DIR"/*.pem; do
            [ -f "$cert" ] || continue
            local name=$(basename "$cert" .pem)
            local expiry=""

            # Get expiry date
            expiry=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | sed 's/notAfter=//')

            $first || echo ","
            first=false

            echo "  {\"name\": \"$name\", \"path\": \"$cert\", \"expiry\": \"$expiry\"}"
        done
    fi

    echo ']}'
}

cmd_cert_add() {
    local domain="$1"
    local staging="${2:-}"

    [ -z "$domain" ] && { error "Usage: haproxyctl cert add <domain> [--staging]"; return 1; }

    log "Requesting certificate for $domain..."
    mkdir -p "$CERTS_DIR"

    # Use acme.sh if available
    if command -v acme.sh >/dev/null; then
        local acme_opts=""
        [ "$staging" = "--staging" ] && acme_opts="--staging"

        acme.sh --issue -d "$domain" --standalone $acme_opts && \
        acme.sh --install-cert -d "$domain" \
            --key-file "$CERTS_DIR/${domain}.key" \
            --fullchain-file "$CERTS_DIR/${domain}.crt"

        # Combine for HAProxy
        cat "$CERTS_DIR/${domain}.crt" "$CERTS_DIR/${domain}.key" > "$CERTS_DIR/${domain}.pem"
        chmod 640 "$CERTS_DIR/${domain}.pem"
        chown haproxy:secubox "$CERTS_DIR/${domain}.pem" 2>/dev/null || true   # lisible par le module certs (#2383)

        log "Certificate installed: $CERTS_DIR/${domain}.pem"
    else
        error "acme.sh not found. Install with: curl https://get.acme.sh | sh"
        return 1
    fi
}

cmd_cert_renew() {
    log "Renewing certificates..."
    if command -v acme.sh >/dev/null; then
        acme.sh --renew-all
        # Rebuild combined certs
        for crt in "$CERTS_DIR"/*.crt; do
            [ -f "$crt" ] || continue
            local name=$(basename "$crt" .crt)
            [ -f "$CERTS_DIR/${name}.key" ] && \
                cat "$crt" "$CERTS_DIR/${name}.key" > "$CERTS_DIR/${name}.pem"
        done
        cmd_reload
    else
        error "acme.sh not found"
        return 1
    fi
}

cmd_cert_sync() {
    # Reconstruit les .pem servis par HAProxy depuis /etc/letsencrypt/live (#1019).
    #
    # POURQUOI CE VERBE EXISTE. certbot renouvelle dans /etc/letsencrypt/live ;
    # HAProxy sert un fichier CONCATENE (chaine + cle) dans $CERTS_DIR. Rien ne
    # recopiait l'un vers l'autre : aucun hook de renouvellement n'existait, et
    # `cert renew` ci-dessus est ecrit pour acme.sh, absent de cette board.
    #
    # Le defaut est SILENCIEUX. Le renouvellement reussit, les journaux sont
    # propres, le site repond — jusqu'au jour ou la copie servie expire, des
    # semaines plus tard. Le lien de cause a effet est alors indechiffrable.
    #
    # Constate sur gk2 : trois certificats sur six divergeaient, dont un qui
    # serait tombe le 14 octobre alors qu'une version valide jusqu'au 2 novembre
    # dormait deja sur le disque.
    local ecrire=1
    [ "${1:-}" = "--dry-run" ] && ecrire=0

    local live_root="/etc/letsencrypt/live"
    [ -d "$live_root" ] || { log "cert-sync: pas de certbot sur cette board"; return 0; }
    mkdir -p "$CERTS_DIR"

    local change=0 vus=0
    for d in "$live_root"/*/; do
        [ -d "$d" ] || continue
        local nom chaine cle cible
        nom=$(basename "$d")
        chaine="$d/fullchain.pem"
        cle="$d/privkey.pem"
        [ -f "$chaine" ] && [ -f "$cle" ] || continue
        vus=$((vus + 1))
        cible="$CERTS_DIR/$nom.pem"

        # ON COMPARE LE CONTENU, PAS LES DATES. Un mtime plus recent ne prouve
        # pas que le contenu a change — et une copie touchee a la main aurait un
        # mtime plus recent tout en servant un certificat perime.
        local somme_src somme_dst
        somme_src=$(cat "$chaine" "$cle" | sha256sum | cut -d' ' -f1)
        somme_dst=$([ -f "$cible" ] && sha256sum "$cible" | cut -d' ' -f1 || echo "")
        [ "$somme_src" = "$somme_dst" ] && continue

        local fin_src fin_dst
        fin_src=$(openssl x509 -in "$chaine" -noout -enddate 2>/dev/null | cut -d= -f2)
        fin_dst=$([ -f "$cible" ] && openssl x509 -in "$cible" -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")

        # ON NE RACCOURCIT JAMAIS LA VIE D'UN CERTIFICAT SERVI.
        #
        # Le premier jet ecrasait des que le contenu differait. L'essai a blanc
        # sur gk2 a montre le piege : deux certificats servis expiraient PLUS
        # TARD que la copie de /etc/letsencrypt (live.gk2.secubox.in, 4 novembre
        # contre 14 octobre). Les synchroniser aurait raccourci leur duree de
        # vie — l'inverse exact du but.
        #
        # Un .pem servi peut venir d'ailleurs qu'ACME : joker, autre outil,
        # depot manuel. La source n'est donc pas autoritaire par nature ; seule
        # la date d'expiration l'est.
        if [ -n "$fin_dst" ]; then
            local ts_src ts_dst
            ts_src=$(date -d "$fin_src" +%s 2>/dev/null || echo 0)
            ts_dst=$(date -d "$fin_dst" +%s 2>/dev/null || echo 0)
            if [ "$ts_src" -le "$ts_dst" ]; then
                log "cert-sync: $nom — IGNORE, le certificat servi expire plus tard ($fin_dst) que la source ($fin_src)"
                continue
            fi
        fi

        change=$((change + 1))
        log "cert-sync: $nom — servi jusqu'au ${fin_dst:-absent}, remplace par $fin_src"

        [ "$ecrire" = "1" ] || continue

        # Ecriture par fichier temporaire puis renommage : HAProxy peut relire le
        # repertoire a tout instant, et un .pem tronque le ferait echouer au
        # rechargement — donc tomber pour TOUS les vhosts, pas seulement celui-ci.
        local tmp="$cible.nouveau"
        cat "$chaine" "$cle" > "$tmp" || { error "cert-sync: ecriture $nom impossible"; rm -f "$tmp"; continue; }
        # 0640 haproxy:secubox, comme `certsctl deploy` : en 0600 root, le module certs (utilisateur secubox, servi par l'agregateur)
        # ne pouvait pas lire le certificat et le montrait « expired » alors qu'il etait valide (#2383).
        chmod 0640 "$tmp"
        chown haproxy:secubox "$tmp" 2>/dev/null || true
        mv "$tmp" "$cible"
    done

    if [ "$ecrire" = "0" ]; then
        log "cert-sync: $vus certificat(s), $change a mettre a jour (lecture seule)"
        return 0
    fi
    log "cert-sync: $vus certificat(s), $change mis a jour"

    # ON NE RECHARGE QUE SI QUELQUE CHOSE A CHANGE. Recharger a chaque passage
    # coupe les connexions en cours sans raison, et ce verbe est appele apres
    # CHAQUE renouvellement — donc souvent, pour rien la plupart du temps.
    [ "$change" -gt 0 ] && cmd_reload
    return 0
}

# ─────────────────────────────────────────────────────────────────────
# CONFIG GENERATION
# ─────────────────────────────────────────────────────────────────────

cmd_generate() {
    log "Generating HAProxy configuration..."

    local http_port=$(cle_conf http_port 80)
    local https_port=$(cle_conf https_port 443)
    local stats_port=$(cle_conf stats_port 8404)
    local waf_enabled=$(grep -E '^waf_enabled\s*=' "$CONF_PATH" 2>/dev/null | grep -q 'true' && echo "1" || echo "0")
    local waf_port=$(cle_conf waf_backend_port 8080)
    local waf_ip=$(grep -E '^waf_backend_ip\s*=' "$CONF_PATH" 2>/dev/null | head -1 | cut -d'"' -f2)
    waf_ip="${waf_ip:-10.100.0.60}"
    # Port du serveur nginx dédié aux défis ACME HTTP-01 (#986). Surchargeable
    # par `acme_port` dans haproxy.toml ; 8880 est la valeur qui était câblée à
    # la main sur la board avant que ce routage ne soit généré.
    local acme_port=$(grep -E '^acme_port\s*=' "$CONF_PATH" 2>/dev/null | cut -d'=' -f2 | tr -d ' ')
    [ -n "$acme_port" ] || acme_port=8880

    mkdir -p "$CONFIG_DIR" /run/haproxy "$EXTRA_CFG_DIR"

    # Build to a tempfile, validate, then atomically promote. #286 — the old
    # behaviour overwrote $CONFIG_DIR/haproxy.cfg block-by-block, so any
    # validation failure left the live cfg in a half-written state and a
    # subsequent reload picked up the broken version.
    local out
    out=$(mktemp /tmp/haproxy-gen.XXXXXX.cfg) || { error "mktemp failed"; return 1; }

    cat > "$out" << EOF
# SecuBox HAProxy Configuration (Auto-generated)
# Generated: $(date)

global
    daemon
    maxconn 4096
    stats socket /run/haproxy/admin.sock mode 660 level admin
    log /dev/log local0

defaults
    mode http
    timeout connect 5s
    timeout client 30s
    timeout server 30s
    retries 3
    option redispatch
    option httplog
    option dontlognull
    option forwardfor
    # Smart self-healing error pages (#626) — shipped by secubox-haproxy.
    errorfile 400 /etc/haproxy/secubox-errors/400.http
    errorfile 403 /etc/haproxy/secubox-errors/403.http
    errorfile 408 /etc/haproxy/secubox-errors/408.http
    errorfile 500 /etc/haproxy/secubox-errors/500.http
    errorfile 502 /etc/haproxy/secubox-errors/502.http
    errorfile 503 /etc/haproxy/secubox-errors/503.http
    errorfile 504 /etc/haproxy/secubox-errors/504.http

frontend stats
    bind *:${stats_port}
    stats enable
    stats uri /stats
    stats refresh 10s

frontend http-in
    bind *:${http_port}
    mode http
    # Pendant du frontend TLS : ici la connexion EST en clair, et le dire
    # explicitement empeche un client de pretendre le contraire.
    http-request set-header X-Forwarded-Proto http

    # ACME HTTP-01 (#986) — les défis vont au serveur nginx dédié (:${acme_port}).
    #
    # ÉMIS EN PREMIER, avant toute règle de vhost : un défi arrive sur le
    # domaine du certificat demandé, donc une règle de vhost placée avant
    # l'attraperait et l'enverrait au service applicatif, qui répondrait 404.
    # Le renouvellement échouerait alors sans bruit jusqu'à expiration.
    #
    # Émis par le générateur depuis #986. Auparavant ce câblage n'existait
    # QUE dans le haproxy.cfg vivant, ajouté à la main : toute régénération
    # réussie l'aurait supprimé en silence. C'est la raison pour laquelle la
    # garde anti-dérive refusait de régénérer — elle protégeait exactement ça.
    acl is_acme_challenge path_beg /.well-known/acme-challenge/
    use_backend acme_challenge if is_acme_challenge

EOF

    if webui_regex=$(_fetch_webui_regex); then
        cat >> "$out" << EOF
    # WebUI Obfuscation (issue #44) — strict regex from /etc/default/secubox
    acl is_webui_admin hdr(host) -m reg $webui_regex
    use_backend webui_direct if is_webui_admin
EOF
    fi

    # ACCES PAR ADRESSE IP DEPUIS LE LAN (#1845). La premiere configuration d'une
    # box neuve se fait par http(s)://<IP>/ ; derriere HAProxy un hote IP n'a
    # pas de route. Source PRIVEE seulement : depuis le WAN, rien ne change.
    # En clair, on renvoie vers https (le defi ACME garde sa route).
    cat >> "$out" << 'EOF'
    acl hote_ip hdr(host) -m reg ^[0-9]{1,3}(\.[0-9]{1,3}){3}(:[0-9]+)?$
    acl hote_local hdr(host) -i secubox.local
    acl src_privee src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8
    redirect scheme https code 301 if src_privee hote_ip !is_acme_challenge or src_privee hote_local !is_acme_challenge
EOF

    # Add ACLs and use_backend rules for vhosts
    if [ -f "$CONF_PATH" ]; then
        # Hoisted: source /etc/default/secubox once before the http-in loop
        _admin_skip_domain=""
        if [ -f /etc/default/secubox ]; then
            # shellcheck source=/dev/null
            . /etc/default/secubox
            if [ -n "${SECUBOX_HOSTNAME:-}" ]; then
                _admin_skip_domain="admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX:-secubox.in}"
            fi
        fi
        _vhosts_champs "$CONF_PATH" | while IFS=$'\x1f' read -r -d $'\x1e' name domain backend enabled waf_bypass ssl ssl_redirect cadrable cookies_tiers; do
            # Champs lus en une passe par _vhosts_champs (#1685).
            # Opt-in, per-vhost WAF bypass. Sanctioned exception for services the
            # inspection chain breaks (large uploads / WebDAV / media streaming /
            # federation — nc, photoprism, matrix, torrent). Requires an explicit
            # `waf_bypass = true` AND a real `backend` (nginx_vhosts) in the vhost
            # block, so it is declarative and auditable — never a silent default.
            # #988 : ssl_redirect etait ecrit par `vhost add` dans chaque bloc et
            # n'etait LU nulle part — 18 vhosts le declaraient, zero redirection
            # etait produite. Un operateur relisant haproxy.toml croyait
            # legitimement que ses vhosts redirigeaient.
            # WebUI strict-regex already covers admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}
            if [ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ]; then continue; fi

            # NB: use if/then/fi, not `[ ] && [ ] && { }` — under `set -e` a
            # short-circuited &&-chain (e.g. a non-SSL vhost) aborts generation.
            if [ "$enabled" = "1" ] && [ -n "$domain" ]; then
                # DOMAINE PREFIXE (#1217, POC Surf). Un `domain` finissant par
                # `*` — « surf-* » — matche par DEBUT d'hote plutot qu'exact.
                # Une seule entree route ainsi toutes les origines par-site du
                # relais (surf-www-lemonde-fr, surf-img-lemde-fr, ...) sans les
                # enumerer. Reserve a un vhost BYPASS : jamais vers l'inspection,
                # un prefixe large avalerait des hotes non prevus.
                case "$domain" in
                  *\*) echo "    acl host_$name hdr_beg(host) -i ${domain%\*}" ;;
                  *)   echo "    acl host_$name hdr(host) -i $domain" ;;
                esac
                # HTTPS PARTOUT, PAR DEFAUT (#1370). Tout vhost qui a un
                # certificat (ssl = true) redirige le clair vers TLS : c'est la
                # posture attendue (ANSSI/CSPN), plus une exception a declarer
                # vhost par vhost. On ne conditionne donc PLUS a `ssl_redirect`
                # (qui restait false partout et ne redirigeait rien) : la seule
                # condition est d'avoir un certificat. Un vhost sans TLS
                # (ssl = false) ne redirige pas — il renverrait vers une page
                # injoignable. `ssl_redirect = false` devient un simple no-op,
                # garde pour compat : forcer le clair sur un service a cert n'a
                # pas de sens defendable.
                #
                # !is_acme_challenge n'est PAS optionnel. HAProxy evalue les
                # regles `redirect` AVANT `use_backend` : sans cette exclusion,
                # la redirection prendrait le pas sur le routage ACME emis en
                # tete de frontend, les defis Let's Encrypt seraient renvoyes
                # en HTTPS et le renouvellement echouerait — c'est-a-dire
                # exactement la panne que #986 vient de reparer.
                if [ "$ssl" = "1" ]; then
                    echo "    redirect scheme https code 301 if host_$name !is_acme_challenge"
                fi
                if [ "$waf_enabled" = "1" ] && [ "$waf_bypass" != "1" ]; then
                    echo "    use_backend sbxwaf_inspector if host_$name"
                else
                    echo "    use_backend ${backend:-nginx_vhosts} if host_$name"
                fi
            fi
        done >> "$out"
    fi

    cat >> "$out" << EOF
    default_backend sbxwaf_inspector

frontend https-in
    bind *:${https_port} ssl crt $CERTS_DIR/
    mode http
    # X-FORWARDED-PROTO, ET IL N'ETAIT POSE NULLE PART (#1269).
    #
    # HAProxy termine le TLS ; nginx ecoute en clair sur 9080 et transmettait
    # \`\$scheme\`, c'est-a-dire « http ». TOUS les modules croyaient donc servir
    # en clair. Consequences constatees : les permaliens de Billets sortaient
    # en http:// — le Hall refusait alors de suivre le lien, origine differente
    # — et \`_secure()\` rendait faux, ce qui faisait retomber les cookies sur
    # SameSite=Lax au lieu de None. Le correctif du cadrage etait donc annule
    # en silence par ce seul en-tete manquant.
    #
    # \`set-header\` et non \`add-header\` : la valeur du client est ECRASEE. Un
    # visiteur qui annoncerait « https » sur le port 80 ferait poser des
    # cookies Secure sur une connexion en clair.
    http-request set-header X-Forwarded-Proto https
EOF

    if webui_regex=$(_fetch_webui_regex); then
        cat >> "$out" << EOF
    # WebUI Obfuscation (issue #44) — strict regex from /etc/default/secubox
    acl is_webui_admin hdr(host) -m reg $webui_regex
    use_backend webui_direct if is_webui_admin
EOF
    fi

    # ACCES PAR ADRESSE IP DEPUIS LE LAN (#1845) — vers l'administration, comme
    # le frontal :9443 de gk2, mais sur 443 : aucun port de plus a ouvrir.
    cat >> "$out" << 'EOF'
    acl hote_ip hdr(host) -m reg ^[0-9]{1,3}(\.[0-9]{1,3}){3}(:[0-9]+)?$
    acl hote_local hdr(host) -i secubox.local
    acl src_privee src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8
    use_backend webui_direct if src_privee hote_ip or src_privee hote_local
EOF

    # HTTPS vhost rules (same pattern)
    if [ -f "$CONF_PATH" ]; then
        # Hoisted: source /etc/default/secubox once before the https-in loop
        _admin_skip_domain=""
        if [ -f /etc/default/secubox ]; then
            # shellcheck source=/dev/null
            . /etc/default/secubox
            if [ -n "${SECUBOX_HOSTNAME:-}" ]; then
                _admin_skip_domain="admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX:-secubox.in}"
            fi
        fi
        _vhosts_champs "$CONF_PATH" | while IFS=$'\x1f' read -r -d $'\x1e' name domain backend enabled waf_bypass ssl ssl_redirect cadrable cookies_tiers; do
            # Champs lus en une passe par _vhosts_champs (#1685), dont le
            # WAF bypass par vhost — voir la boucle http-in.
            # WebUI strict-regex already covers admin.${SECUBOX_HOSTNAME}.${SECUBOX_DOMAIN_SUFFIX}
            if [ -n "$_admin_skip_domain" ] && [ "$domain" = "$_admin_skip_domain" ]; then continue; fi

            # CADRAGE PAR LE HALL (#1257), opt-in par vhost.
            #
            # POURQUOI AU PROXY. Les services TIERS — PeerTube, Nextcloud,
            # PhotoPrism, Mastodon, le webmail — refusent d'etre encadres, et ils
            # ont raison de le faire par defaut : c'est la protection contre le
            # detournement de clic. Ils ne suivent pas nos conventions et n'ont
            # pas a les suivre. La reecriture se fait donc ici, une fois, plutot
            # que dans cinq amonts qu'on ne maitrise pas.
            #
            # CE QUE CELA COUTE, DIT FRANCHEMENT. Autoriser le Hall a encadrer une
            # session authentifiee, c'est lui accorder ce que X-Frame-Options
            # existe pour refuser. La garantie tient tant que le Hall lui-meme est
            # sur : c'est exactement le meme pari que le BBS et Billets font deja.
            # D'ou l'opt-in : rien n'est ouvert sans `cadrable = true` ecrit noir
            # sur blanc dans haproxy.toml.
            #
            # ON NOMME, ON N'OUVRE PAS. La liste est explicite : `frame-ancestors
            # *` accepterait n'importe quel site, ce qui est l'attaque meme que la
            # directive existe pour empecher. (`cadrable` : lu par _vhosts_champs.)

            # if/then/fi (not `&& { }`) — set -e safe for non-SSL vhosts.
            if [ "$enabled" = "1" ] && [ "$ssl" = "1" ] && [ -n "$domain" ]; then
                # DOMAINE PREFIXE (#1217, POC Surf). Un `domain` finissant par
                # `*` — « surf-* » — matche par DEBUT d'hote plutot qu'exact.
                # Une seule entree route ainsi toutes les origines par-site du
                # relais (surf-www-lemonde-fr, surf-img-lemde-fr, ...) sans les
                # enumerer. Reserve a un vhost BYPASS : jamais vers l'inspection,
                # un prefixe large avalerait des hotes non prevus.
                case "$domain" in
                  *\*) echo "    acl host_$name hdr_beg(host) -i ${domain%\*}" ;;
                  *)   echo "    acl host_$name hdr(host) -i $domain" ;;
                esac
                if [ "$cadrable" = "1" ]; then
                    # ACL SEPAREE, ET C'EST INDISPENSABLE. Dans une regle
                    # `http-response`, `hdr(host)` echantillonne l'en-tete de
                    # la REPONSE, ou aucun `host` n'existe : la condition etait
                    # toujours fausse et rien ne s'appliquait, en silence.
                    # `req.hdr()` nomme la requete, seul endroit ou le vhost
                    # est connu.
                    #
                    # ON NE POSE QUE LA MARQUE : les regles qui la lisent sont
                    # ecrites une seule fois, apres la boucle (#1316).
                    echo "    acl cadre_$name req.hdr(host) -i $domain"
                    echo "    http-request set-var(txn.cadrable) int(1) if cadre_$name"
                fi
                # COOKIES DU SERVICE TIERS, opt-in separe (#1259).
                #
                # Nextcloud pose ses cookies en SameSite=Lax/Strict et le
                # navigateur les REJETTE dans un cadre tiers : la session ne
                # s'etablit jamais et la page boucle sur /login. Autoriser le
                # cadrage ne suffit donc pas pour un service qui s'appuie sur
                # SameSite — il faut aussi reecrire ce qu'il pose.
                #
                # DEUX ETAPES, PAS UNE. On RETIRE d'abord l'attribut existant :
                # ajouter le notre sans cela laisserait deux SameSite dans le
                # meme en-tete, et le navigateur garde le premier.
                #
                # PAS DE `Partitioned` ICI, ET C'EST UNE CORRECTION (#1270).
                # Il cloisonne le cookie au couple (site encadrant, service) :
                # le webmail ouvert dans le Hall et ouvert directement se
                # retrouvaient avec DEUX sessions distinctes, et Roundcube
                # rendait « non concordance de temoin ». L'authentification
                # semblait se perdre — elle etait simplement rangee ailleurs.
                # Firefox annonce le rejet futur des cookies tiers sans cet
                # attribut ; on prefere une session qui marche aujourd'hui a
                # une session cloisonnee qui n'en est plus une.
                #
                # CE QUE CELA COUTE : SameSite etait une des protections CSRF de
                # Nextcloud. Il lui reste son jeton `requesttoken`, verifie sur
                # chaque ecriture — la protection n'est donc pas levee, elle
                # repose desormais sur ce seul jeton. D'ou l'opt-in SEPARE de
                # `cadrable` : on peut encadrer un service sans toucher a ses
                # cookies, et c'est le defaut. (`cookies_tiers` : _vhosts_champs.)
                if [ "$cookies_tiers" = "1" ]; then
                    # ON NE POSE QUE LA MARQUE (#1316). Les regles de reponse
                    # sont ecrites UNE fois, apres la boucle : les repeter par
                    # vhost les faisait s'empiler sur une meme reponse, et le
                    # temoin du webmail sortait avec cinq `Secure` a la suite.
                    echo "    http-request set-var(txn.cookies_tiers) int(1) if host_$name"
                fi
                if [ "$waf_enabled" = "1" ] && [ "$waf_bypass" != "1" ]; then
                    echo "    use_backend sbxwaf_inspector if host_$name"
                else
                    echo "    use_backend ${backend:-nginx_vhosts} if host_$name"
                fi
            fi
        done >> "$out"
    fi

    # LES REGLES DE REPONSE, UNE SEULE FOIS (#1316).
    #
    # Elles etaient emises A CHAQUE vhost marque, toutes conditionnees sur la
    # MEME variable de transaction : une reponse les traversait donc autant de
    # fois qu'il y a de vhosts marques. Le temoin du webmail sortait avec CINQ
    # `Secure` a la suite, le navigateur le rejetait, et la session etait
    # perdue — d'ou une demande de connexion a chaque clic depuis une carte.
    #
    # La decision reste propre au vhost (la marque, posee dans la boucle) ; les
    # regles qui la lisent sont ecrites ici, une fois.
    cat >> "$out" << EOF
    # X-Frame-Options ne sait dire que DENY ou SAMEORIGIN, jamais nommer une
    # origine : on le retire et l'on s'en remet a la CSP, qui sait.
    http-response del-header X-Frame-Options if { var(txn.cadrable) -m int 1 }
    # Politique deja presente : on REECRIT la directive. Ajouter un second
    # en-tete ne servirait a rien — deux politiques s'INTERSECTENT, et un
    # 'none' deja pose continuerait de tout bloquer.
    http-response replace-header Content-Security-Policy "frame-ancestors[^;]*" "frame-ancestors $CADRE_ANCETRES" if { var(txn.cadrable) -m int 1 }
    # Politique SANS frame-ancestors : une directive absente ne restreint rien,
    # un second en-tete qui ne porte qu'elle ajoute donc la contrainte.
    http-response add-header Content-Security-Policy "frame-ancestors $CADRE_ANCETRES" if { var(txn.cadrable) -m int 1 } !{ res.hdr(Content-Security-Policy) -m sub frame-ancestors }
    # Temoins : on RETIRE l'attribut existant avant de poser le notre, sinon
    # l'en-tete en porterait deux et le navigateur garderait le premier.
    http-response replace-header Set-Cookie "(.*);[ ]*[Ss]ame[Ss]ite=[A-Za-z]+(.*)" "\\1\\2" if { var(txn.cookies_tiers) -m int 1 }
    http-response replace-header Set-Cookie "(.*)" "\\1; SameSite=None; Secure" if { var(txn.cookies_tiers) -m int 1 } !{ res.hdr(Set-Cookie) -m sub SameSite=None }

EOF


    cat >> "$out" << EOF
    default_backend sbxwaf_inspector

EOF

    # WAF inspector backend (sbxwaf host daemon)
    # if/then/fi (not `&& cat`) — under set -e a false test here aborts generation.
    if [ "$waf_enabled" = "1" ]; then cat >> "$out" << EOF
# WAF Inspector Backend (sbxwaf at $waf_ip:$waf_port)
backend sbxwaf_inspector
    mode http
    # TELEVERSEMENTS LONGS : DELAI SERVEUR RELEVE PAR REQUETE (#1030).
    #
    # La section \`defaults\` porte \`timeout server 30s\`, et c'est bien : elle
    # protege TOUS les vhosts d'un amont qui trainerait. Mais un depot de 22 Mio
    # a mis 116 s a s'ecrire sur cette board — HAProxy abandonnait a 30, le
    # deposant voyait un 504, ET LE SERVEUR TERMINAIT LE TRAVAIL. Chaque essai
    # deposait a nouveau : trois depots pour une seule intention.
    #
    # DEUX LECONS PAYEES A L'ESSAI. \`set-timeout\` ne vit QUE dans un backend —
    # HAProxy refuse « proxy has no backend capability » cote frontend — et il
    # ne connait que \`server\` et \`tunnel\` : \`set-timeout client\` n'existe pas.
    # \`timeout client\` reste donc a 30 s, ce qui convient : c'est un delai
    # d'INACTIVITE, et un televersement regulier ne l'atteint jamais.
    #
    # Relever \`defaults\` aurait couvert le cas en retirant la protection a la
    # centaine de vhosts qui n'en ont pas besoin — payer pour tous ce dont un
    # seul a besoin.
    acl est_depot_long path_beg /api/v1/droplet/depot
    http-request set-timeout server 1h if est_depot_long
    # VOIX ET STUDIO (VoiceStudio, #1917) : meme probleme, meme remede, et pour les memes raisons. Une synthese vocale
    # sur CPU charge un modele de plus de 2 Go puis calcule : bien plus de 30 s a froid. HAProxy coupait, l'utilisateur
    # voyait « HTTP 504 » pendant que le moteur continuait de travailler. 10 minutes pour dire et dicter ; 1 heure pour
    # le domaine du studio natif (doublage, clonage, bibliotheque). Chemins et prefixe de domaine EXACTS : le reste du
    # parc garde les 30 s.
    acl est_voix_longue path_beg /api/v1/voicestudio/essai/ /api/v1/voicestudio/usager/dire /api/v1/voicestudio/usager/transcrire
    acl est_studio_natif hdr_beg(host) -i voicestudio.
    http-request set-timeout server 10m if est_voix_longue
    http-request set-timeout server 1h if est_studio_natif
    option forwardfor
    http-request set-header X-Real-IP %[src]
    # %[query] rend la chaine de requete SANS le « ? » qui l'introduit : une
    # URL « /a.css?v=3 » etait reecrite « /a.cssv=3 » et le backend repondait
    # 404. La panne touchait TOUT le trafic inspecte portant une chaine de
    # requete — donc chaque ressource versionnee du parc. %[url] rend le
    # chemin ET la requete, separateur compris — en HTTP/1.1 seulement : en
    # HTTP/2 (negocie par defaut depuis HAProxy 3.0, Debian 13) %[url] est
    # l'adresse ABSOLUE https://hote/chemin, d'ou « http://hotehttps://hote/ »
    # et un 421 du WAF sur tous les domaines publics. %[pathq] rend le chemin
    # et la requete (« ? » compris), quel que soit le protocole du client.
    http-request set-uri http://%[req.hdr(Host)]%[pathq]
    server waf ${waf_ip}:${waf_port} check

EOF
    fi

    # WebUI direct backend (issue #44 — for the strict-regex ACL above)
    # Only emit if SECUBOX_HOSTNAME is set (i.e., the strict ACL was injected).
    if _fetch_webui_regex >/dev/null 2>&1; then
        cat >> "$out" << 'EOF'
backend webui_direct
    mode http
    server srv0 127.0.0.1:9080 check
EOF
    fi

    # User backends
    if [ -f "$CONF_PATH" ]; then
        grep '^\[backends\.' "$CONF_PATH" | while read -r line; do
            local name=$(echo "$line" | sed 's/\[backends\.//;s/\]//')
            # Skip backends already emitted (sbxwaf_inspector/webui_direct are
            # auto-generated above) to avoid duplicate-backend fatal errors.
            if grep -q "^backend $name\$" "$out"; then continue; fi
            local section=$(_toml_section "backends.$name" "$CONF_PATH")
            local mode=$(echo "$section" | grep -E '^mode\s*=' | cut -d'"' -f2 || echo "http")
            local balance=$(echo "$section" | grep -E '^balance\s*=' | cut -d'"' -f2 || echo "roundrobin")
            local servers=$(echo "$section" | grep -E '^servers\s*=' | cut -d'[' -f2 | cut -d']' -f1 | tr -d '"' | tr ',' '\n')

            cat >> "$out" << EOF
backend $name
    mode $mode
    balance $balance
EOF
            local i=0
            echo "$servers" | while read -r srv; do
                if [ -n "$srv" ]; then
                    srv=$(echo "$srv" | tr -d ' ')
                    echo "    server srv$i $srv check" >> "$out"
                    i=$((i + 1))
                fi
            done
            echo "" >> "$out"
        done
    fi

    # Fallback backend
    cat >> "$out" << EOF
backend fallback
    mode http
    http-request deny deny_status 503
EOF

    # Make absolutely sure the canonical backends exist regardless of the
    # waf_enabled gate or user TOML — #286: vhosts can reference these even
    # when waf_enabled=0 (e.g. nginx_vhosts for non-WAF passthrough,
    # webui_direct for the SECUBOX_HOSTNAME obfuscation regex).
    # Cible du routage ACME émis dans frontend http-in (#986). Émis ici, avec
    # les autres backends canoniques, pour la même raison qu'eux : une règle
    # `use_backend` sans backend correspondant rend la configuration invalide
    # et `haproxy -c` la rejette — la génération échouerait entièrement.
    grep -q '^backend acme_challenge$' "$out" || cat >> "$out" << EOF

backend acme_challenge
    mode http
    server acme1 127.0.0.1:${acme_port}
EOF
    grep -q '^backend nginx_vhosts$' "$out" || cat >> "$out" << EOF

backend nginx_vhosts
    mode http
    balance roundrobin
    # STUDIO NATIF de VoiceStudio (#1917), servi SANS passer par le WAF (acces LAN direct) : memes delais longs que
    # dans sbxwaf_inspector (30 s par defaut coupent une synthese qui dure 2 minutes). Prefixe de domaine exact.
    acl est_studio_natif hdr_beg(host) -i voicestudio.
    http-request set-timeout server 1h if est_studio_natif
    server srv0 127.0.0.1:9080 check
EOF
    grep -q '^backend webui_direct$' "$out" || cat >> "$out" << EOF

backend webui_direct
    mode http
    server srv0 127.0.0.1:9080 check
EOF
    grep -q '^backend sbxwaf_inspector$' "$out" || cat >> "$out" << EOF

# Emitted unconditionally so use_backend sbxwaf_inspector references stay
# resolvable even with waf_enabled=0 (defense in depth — #286).
backend sbxwaf_inspector
    mode http
    balance roundrobin
    server srv0 ${waf_ip:-10.100.0.60}:${waf_port:-8080} check
EOF

    # Operator-managed extras (#286). Files in $EXTRA_CFG_DIR are appended
    # verbatim so things like the LAN webui frontend, gitea-ssh, custom
    # metablog backends, etc. survive every vhost-add regeneration.
    if [ -d "$EXTRA_CFG_DIR" ]; then
        for extra in "$EXTRA_CFG_DIR"/*.cfg; do
            [ -f "$extra" ] || continue
            printf '\n# === %s ===\n' "$(basename "$extra")" >> "$out"
            cat "$extra" >> "$out"
        done
    fi

    # Validate THEN atomic-promote (#286). The old code wrote block-by-block
    # to the live cfg and only validated at the end, leaving the file in a
    # broken state on failure (recurring "broken-by-vhost-add" backups).
    if haproxy -c -f "$out" 2>/dev/null; then
        # Drift guard (#626): refuse to overwrite a live config that has MORE
        # vhosts/backends than we just generated — that means the live cfg holds
        # entries absent from our inputs (haproxy.toml / cfg.d). Without this,
        # a successful regen would silently drop hand-maintained vhosts (kbin,
        # gitea, …). `local x=$(...)` masks grep's exit so set -e stays happy.
        if [ -f "$CONFIG_DIR/haproxy.cfg" ]; then
            local _nh=$(grep -c '^[[:space:]]*acl host_' "$out")
            local _oh=$(grep -c '^[[:space:]]*acl host_' "$CONFIG_DIR/haproxy.cfg")
            local _nb=$(grep -c '^backend ' "$out")
            local _ob=$(grep -c '^backend ' "$CONFIG_DIR/haproxy.cfg")
            if [ "${_nh:-0}" -lt "${_oh:-0}" ] || [ "${_nb:-0}" -lt "${_ob:-0}" ]; then
                # UNE REDUCTION PEUT ETRE VOULUE (#1015). Le garde-fou ne sait
                # pas distinguer une perte accidentelle d'un retrait delibere :
                # retirer un vhost, ou replier une table en double, fait
                # legitimement baisser le compte. `--allow-shrink` est l'aveu
                # explicite de l'operateur — jamais un defaut, sans quoi le
                # garde-fou ne protegerait plus de rien.
                if [ "${ALLOW_SHRINK:-0}" != "1" ]; then
                    error "Drift guard: generated cfg has fewer vhosts/backends than live (acl ${_nh}<${_oh} or backend ${_nb}<${_ob}) — refusing to clobber. Migrate the missing entries into haproxy.toml/cfg.d first, or pass --allow-shrink if the reduction is intended."
                    rm -f "$out"
                    return 1
                fi
                log "Drift guard: reduction acceptee sur demande explicite (acl ${_oh} -> ${_nh}, backend ${_ob} -> ${_nb})"
            fi
        fi
        install -m 0644 -o root -g root "$out" "$CONFIG_DIR/haproxy.cfg"
        rm -f "$out"
        log "Configuration generated, validated and installed: $CONFIG_DIR/haproxy.cfg"
        return 0
    else
        error "Configuration validation failed — live cfg LEFT UNTOUCHED"
        haproxy -c -f "$out" 2>&1 | tail -20 >&2
        rm -f "$out"
        return 1
    fi
}

# ─────────────────────────────────────────────────────────────────────
# FRONTAL (#1841)
# ─────────────────────────────────────────────────────────────────────

# METTRE LA BOX DERRIERE HAPROXY + SBXWAF, EN UNE COMMANDE IDEMPOTENTE.
#
# Une box neuve sortait de l'image avec nginx seul sur 80/443 (site
# « secubox » de secubox-core, attrape-tout) : un acces direct par le LAN
# contournait sbxwaf et ne tombait pas sur les vhosts du Hall. gk2 avait ete
# basculee a la main au fil du temps ; gk3 jamais. Cette commande fait la
# bascule complete, et revient en arriere si la box ne repond pas apres :
#   1. coherence : /etc/default/secubox doit nommer la box que nomme
#      secubox-domaine (sinon generate poserait la regle d'admin d'une autre) ;
#   2. sbxwaf doit ecouter (sinon tout le trafic tomberait en 503) ;
#   3. [main] seme dans haproxy.toml s'il manque ;
#   4. certificat par defaut tire du certificat autosigne de la box si le
#      repertoire des certificats est vide ;
#   5. frontaux poses a la main dans haproxy.cfg (gitea-ssh) deplaces dans
#      cfg.d, sans quoi generate les effacerait ;
#   6. generate (valide avant d'installer) ;
#   7. bascule : le site autonome de nginx rend 80/443 — le lien
#      sites-enabled/secubox pointe sur un site vide, pas supprime : le
#      postinst de secubox-core le recreerait sinon — puis HAProxy demarre ;
#   8. verification sur 127.0.0.1:443 ; retour arriere complet sinon.
#
# `--admin-lan IP[,IP]` : l'administration PAR ADRESSE IP. Derriere HAProxy, un
# hote qui est une IP n'a pas de route (421, comme gk2 sur 443) ; gk2 servait
# l'admin par IP sur :9443 via un frontal pose a la main. On le genere, sur les
# SEULES adresses donnees (privees) — jamais *:9443, qui l'exposerait au WAN.
NGINX_DIR="${SECUBOX_NGINX_DIR:-/etc/nginx}"
TLS_DIR="${SECUBOX_TLS_DIR:-/etc/secubox/tls}"
DEFAULTS_FILE="${SECUBOX_DEFAULTS_FILE:-/etc/default/secubox}"
SBXWAF_ECOUTE="${SECUBOX_SBXWAF_ECOUTE:-127.0.0.1:8085}"

_frontal_actif() {
    grep -q '^frontend https-in' "$CONFIG_DIR/haproxy.cfg" 2>/dev/null \
        && [ "$(readlink "$NGINX_DIR/sites-enabled/secubox" 2>/dev/null)" != "$NGINX_DIR/sites-available/secubox" ]
}

# Deplace un bloc `frontend <nom>` (et son `backend <backend>`) de haproxy.cfg
# vers cfg.d/<fichier>, s'il n'y est pas deja.
_frontal_vers_cfgd() {
    local fe="$1" be="$2" fichier="$EXTRA_CFG_DIR/$3"
    [ -f "$CONFIG_DIR/haproxy.cfg" ] || return 0
    grep -q "^frontend $fe\$" "$CONFIG_DIR/haproxy.cfg" || return 0
    grep -qs "^frontend $fe\$" "$EXTRA_CFG_DIR"/*.cfg && return 0
    mkdir -p "$EXTRA_CFG_DIR"
    awk -v fe="frontend $fe" -v be="backend $be" '
        /^(frontend|backend|listen|global|defaults|resolvers|userlist|peers)[ \t]/ || /^(global|defaults)$/ {
            garde = ($0 == fe || $0 == be)
        }
        garde { print }
    ' "$CONFIG_DIR/haproxy.cfg" > "$fichier.tmp"
    if [ -s "$fichier.tmp" ]; then
        mv "$fichier.tmp" "$fichier"
        log "frontal : $fe deplace dans $(basename "$fichier")"
    else
        rm -f "$fichier.tmp"
    fi
}

cmd_frontal() {
    local dom hote suffixe h_def s_def admin_lan="" ip
    case "${1:-}" in
        --etat)
            if _frontal_actif; then echo "frontal : actif"; return 0; fi
            echo "frontal : inactif"; return 1 ;;
        --admin-lan)
            admin_lan="${2:-}"
            for ip in ${admin_lan//,/ }; do
                if ! [[ "$ip" =~ ^(10\.([0-9]{1,3}\.){2}[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3})$ ]]; then
                    error "frontal : --admin-lan attend des IPv4 privees, pas « $ip »"; return 1
                fi
            done
            [ -n "$admin_lan" ] || { error "frontal : --admin-lan sans adresse"; return 1; } ;;
        "") ;;
        *) error "frontal : option inconnue « $1 » (--etat | --admin-lan IP[,IP])"; return 1 ;;
    esac

    # 1. Coherence de l'identite.
    dom=$(secubox-domaine 2>/dev/null || true)
    if ! [[ "$dom" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]]; then
        error "frontal : domaine de la box inconnu (secubox-domaine)"; return 1
    fi
    hote=${dom%%.*}; suffixe=${dom#*.}
    h_def=$(sed -n 's/^SECUBOX_HOSTNAME="\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$DEFAULTS_FILE" 2>/dev/null | tail -1)
    s_def=$(sed -n 's/^SECUBOX_DOMAIN_SUFFIX="\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$DEFAULTS_FILE" 2>/dev/null | tail -1)
    if [ "$h_def" != "$hote" ] || [ "${s_def:-secubox.in}" != "$suffixe" ]; then
        error "frontal : $DEFAULTS_FILE nomme ${h_def:-<vide>}.${s_def:-secubox.in}, la box est $dom — mettre secubox-defaults a jour d'abord"
        return 1
    fi

    # 2. sbxwaf a l'ecoute.
    if ! ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qx "$SBXWAF_ECOUTE"; then
        error "frontal : sbxwaf n'ecoute pas sur $SBXWAF_ECOUTE — rien ne serait inspecte"; return 1
    fi

    if _frontal_actif; then
        log "frontal : deja actif — rien a faire"; return 0
    fi

    # 3. Reglages generaux.
    mkdir -p "$(dirname "$CONF_PATH")"
    if ! grep -qE '^https_port[[:space:]]*=' "$CONF_PATH" 2>/dev/null; then
        local t; t=$(mktemp)
        {
            printf '[main]\nmode = "native"\nhttp_port = 80\nhttps_port = 443\nstats_port = 8404\n'
            printf 'waf_enabled = true\nwaf_backend_ip = "%s"\nwaf_backend_port = %s\n\n' \
                "${SBXWAF_ECOUTE%:*}" "${SBXWAF_ECOUTE##*:}"
            cat "$CONF_PATH" 2>/dev/null || true
        } > "$t"
        install -m 0644 "$t" "$CONF_PATH"; rm -f "$t"
        log "frontal : reglages [main] semes dans $CONF_PATH"
    fi

    # 4. Certificat par defaut.
    mkdir -p "$CERTS_DIR"
    if ! compgen -G "$CERTS_DIR/*.pem" >/dev/null; then
        if [ ! -r "$TLS_DIR/cert.pem" ] || [ ! -r "$TLS_DIR/key.pem" ]; then
            error "frontal : aucun certificat dans $CERTS_DIR ni dans $TLS_DIR"; return 1
        fi
        (umask 077; cat "$TLS_DIR/cert.pem" "$TLS_DIR/key.pem" > "$CERTS_DIR/000-defaut.pem")
        log "frontal : certificat par defaut tire de $TLS_DIR"
    fi

    # 5. Frontaux poses a la main.
    _frontal_vers_cfgd gitea-ssh gitea_ssh 20-gitea-ssh.cfg
    if [ -n "$admin_lan" ] && ! grep -qs '^frontend webui-lan' "$EXTRA_CFG_DIR"/*.cfg; then
        mkdir -p "$EXTRA_CFG_DIR"
        {
            printf '# Administration par IP, sur le LAN seulement (#1841, genere par haproxyctl frontal)\n'
            printf 'frontend webui-lan\n'
            for ip in ${admin_lan//,/ }; do printf '    bind %s:9443 ssl crt %s/\n' "$ip" "$CERTS_DIR"; done
            printf '    mode http\n    default_backend webui_direct\n'
        } > "$EXTRA_CFG_DIR/10-webui-lan.cfg"
        log "frontal : administration par IP sur ${admin_lan//,/, } (:9443)"
    fi

    # 6. Generation (validee avant installation). Sauvegarde d'abord.
    local sauve; sauve="$CONFIG_DIR/haproxy.cfg.avant-frontal.$(date +%s)"
    [ -f "$CONFIG_DIR/haproxy.cfg" ] && cp -p "$CONFIG_DIR/haproxy.cfg" "$sauve"
    cmd_generate || { error "frontal : generation refusee — rien n'a bascule"; return 1; }

    # 7. Bascule. Le site autonome de nginx rend 80/443.
    local lien="$NGINX_DIR/sites-enabled/secubox" ancien vide="$NGINX_DIR/sites-available/secubox-frontal"
    ancien=$(readlink "$lien" 2>/dev/null || true)
    printf '# Vide (#1841) : HAProxy tient 80/443 ; les vhosts ecoutent sur 9080.\n' > "$vide"
    ln -sfn "$vide" "$lien"
    _frontal_retour() {
        error "frontal : $1 — retour arriere"
        if [ -n "$ancien" ]; then ln -sfn "$ancien" "$lien"; else rm -f "$lien"; fi
        if [ -f "$sauve" ]; then cp -p "$sauve" "$CONFIG_DIR/haproxy.cfg"; fi
        systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null || true
        systemctl reload haproxy 2>/dev/null || true
        return 1
    }
    nginx -t >/dev/null 2>&1 || { _frontal_retour "nginx -t refuse"; return 1; }
    systemctl reload nginx || { _frontal_retour "rechargement de nginx"; return 1; }
    sleep 1
    systemctl enable haproxy >/dev/null 2>&1 || true
    systemctl restart haproxy || { _frontal_retour "HAProxy ne demarre pas"; return 1; }

    # 8. Verification : la box repond en TLS par le frontal.
    local code
    code=$(curl -sk -m 10 -o /dev/null -w '%{http_code}' --resolve "admin.$dom:443:127.0.0.1" "https://admin.$dom/" 2>/dev/null || true)
    case "$code" in
        2??|3??|401|403) log "frontal : actif — admin.$dom repond $code par HAProxy + sbxwaf" ;;
        *) _frontal_retour "admin.$dom repond « ${code:-rien} » apres bascule"; return 1 ;;
    esac
    return 0
}

# ─────────────────────────────────────────────────────────────────────
# MIGRATION
# ─────────────────────────────────────────────────────────────────────

cmd_migrate() {
    local source="${1:-192.168.255.1}"

    log "Migrating HAProxy config from $source..."
    mkdir -p "$DATA_PATH" "$CERTS_DIR" "$CONFIG_DIR"

    # Sync certificates
    log "Syncing certificates..."
    rsync -avz --progress "root@$source:/srv/haproxy/certs/" "$CERTS_DIR/" 2>/dev/null || \
    rsync -avz --progress "root@$source:/etc/haproxy/certs/" "$CERTS_DIR/" 2>/dev/null || true

    # Convert UCI config to TOML
    log "Converting UCI configuration to TOML..."

    cat > "$CONF_PATH" << EOF
# HAProxy Configuration (Migrated from OpenWrt)
# Generated: $(date)

[main]
mode = "native"
http_port = 80
https_port = 443
stats_port = 8404
waf_enabled = true
waf_backend_port = 8890

EOF

    # Convert vhosts
    ssh "root@$source" "uci show haproxy 2>/dev/null" | grep '=vhost$' | while read -r line; do
        local section=$(echo "$line" | cut -d'.' -f2 | cut -d'=' -f1)
        local domain=$(ssh "root@$source" "uci -q get haproxy.$section.domain" || echo "")
        local backend=$(ssh "root@$source" "uci -q get haproxy.$section.backend" || echo "")
        local ssl=$(ssh "root@$source" "uci -q get haproxy.$section.ssl" || echo "0")
        local enabled=$(ssh "root@$source" "uci -q get haproxy.$section.enabled" || echo "1")

        [ -n "$domain" ] && cat >> "$CONF_PATH" << EOF
[vhosts.$section]
domain = "$domain"
backend = "$backend"
ssl = $([ "$ssl" = "1" ] && echo "true" || echo "false")
ssl_redirect = true
enabled = $([ "$enabled" = "1" ] && echo "true" || echo "false")

EOF
    done

    # Convert backends
    ssh "root@$source" "uci show haproxy 2>/dev/null" | grep '=backend$' | while read -r line; do
        local section=$(echo "$line" | cut -d'.' -f2 | cut -d'=' -f1)
        local mode=$(ssh "root@$source" "uci -q get haproxy.$section.mode" || echo "http")
        local balance=$(ssh "root@$source" "uci -q get haproxy.$section.balance" || echo "roundrobin")
        local server=$(ssh "root@$source" "uci -q get haproxy.$section.server" || echo "")

        cat >> "$CONF_PATH" << EOF
[backends.$section]
mode = "$mode"
balance = "$balance"
servers = ["$server"]

EOF
    done

    # Count migrated items
    local vhost_count=$(grep -c '^\[vhosts\.' "$CONF_PATH" 2>/dev/null || echo "0")
    local backend_count=$(grep -c '^\[backends\.' "$CONF_PATH" 2>/dev/null || echo "0")
    local cert_count=$(ls -1 "$CERTS_DIR"/*.pem 2>/dev/null | wc -l || echo "0")

    # Generate new config
    cmd_generate

    log "Migration complete: $vhost_count vhosts, $backend_count backends, $cert_count certificates"
    echo "{\"success\": true, \"vhosts\": $vhost_count, \"backends\": $backend_count, \"certs\": $cert_count}"
}

# ─────────────────────────────────────────────────────────────────────
# WAF INTEGRATION
# ─────────────────────────────────────────────────────────────────────

cmd_waf_status() {
    local waf_enabled=$(grep -E '^waf_enabled\s*=' "$CONF_PATH" 2>/dev/null | grep -q 'true' && echo "true" || echo "false")
    local waf_ip=$(grep -E '^waf_backend_ip\s*=' "$CONF_PATH" 2>/dev/null | head -1 | cut -d'"' -f2)
    waf_ip="${waf_ip:-10.100.0.60}"
    local waf_port=$(cle_conf waf_backend_port 8080)
    local waf_running="false"
    local waf_container="false"

    # Legacy check retained for the historical mitmproxy WAF LXC (no-op on
    # boxes where sbxwaf is the host daemon and no such container exists).
    if lxc-info -n mitmproxy -s 2>/dev/null | grep -q "RUNNING"; then
        waf_container="true"
        # Check if the legacy in-LXC WAF service is active
        if lxc-attach -n mitmproxy -- systemctl is-active mitmproxy >/dev/null 2>&1; then
            waf_running="true"
        fi
    fi

    # Check connectivity
    local waf_reachable="false"
    if curl -s --connect-timeout 2 "http://${waf_ip}:${waf_port}/" >/dev/null 2>&1; then
        waf_reachable="true"
    fi

    cat <<EOF
{
  "enabled": $waf_enabled,
  "container_running": $waf_container,
  "service_running": $waf_running,
  "reachable": $waf_reachable,
  "ip": "$waf_ip",
  "port": $waf_port,
  "backend": "sbxwaf_inspector"
}
EOF
}

cmd_waf_enable() {
    if [ -f "$CONF_PATH" ]; then
        if grep -q '^waf_enabled' "$CONF_PATH"; then
            sed -i 's/^waf_enabled.*/waf_enabled = true/' "$CONF_PATH"
        else
            echo 'waf_enabled = true' >> "$CONF_PATH"
        fi
        log "WAF enabled in config"
        cmd_generate
        cmd_reload
    else
        error "Config file not found: $CONF_PATH"
        return 1
    fi
}

cmd_waf_disable() {
    if [ -f "$CONF_PATH" ]; then
        sed -i 's/^waf_enabled.*/waf_enabled = false/' "$CONF_PATH"
        log "WAF disabled in config"
        cmd_generate
        cmd_reload
    else
        error "Config file not found: $CONF_PATH"
        return 1
    fi
}

# ─────────────────────────────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────────────────────────────

load_config

case "${1:-}" in
    # Three-fold architecture
    components) cmd_components ;;
    access)     cmd_access ;;
    status)     cmd_status ;;
    # Service control
    install)    cmd_install ;;
    uninstall)  cmd_uninstall ;;
    start)      cmd_start ;;
    stop)       cmd_stop ;;
    restart)    cmd_stop; sleep 1; cmd_start ;;
    reload)     cmd_reload ;;
    stats)      cmd_stats ;;
    info)       cmd_info ;;
    generate)   [ "${2:-}" = "--allow-shrink" ] && ALLOW_SHRINK=1; cmd_generate ;;
    # Box derriere HAProxy + sbxwaf, en une commande idempotente (#1841).
    frontal)    cmd_frontal "${2:-}" "${3:-}" ;;
    # Verifie (et, avec --write, redresse) un haproxy.toml abime par les
    # anciens `vhost add/remove` — doublons de tables, booleens invalides.
    config-repair) cmd_config_repair "${2:-}" ;;

    vhost)
        case "${2:-}" in
            list)   cmd_vhost_list ;;
            add)    cmd_vhost_add "$3" "$4" "$5" ;;
            remove) cmd_vhost_remove "$3" ;;
            *)      echo "Usage: haproxyctl vhost {list|add|remove} [args]" ;;
        esac
        ;;

    backend)
        case "${2:-}" in
            list)   cmd_backend_list ;;
            *)      echo "Usage: haproxyctl backend {list}" ;;
        esac
        ;;

    cert)
        case "${2:-}" in
            list)   cmd_cert_list ;;
            sync)   cmd_cert_sync "${3:-}" ;;
            add)    cmd_cert_add "$3" "$4" ;;
            renew)  cmd_cert_renew ;;
            *)      echo "Usage: haproxyctl cert {list|add|renew} [domain]" ;;
        esac
        ;;

    migrate)    cmd_migrate "$2" ;;

    waf)
        case "${2:-}" in
            status)  cmd_waf_status ;;
            enable)  cmd_waf_enable ;;
            disable) cmd_waf_disable ;;
            *)       echo "Usage: haproxyctl waf {status|enable|disable}" ;;
        esac
        ;;

    *)
        cat << EOF
haproxyctl v$VERSION - HAProxy Manager
Three-fold architecture: Components, Status, Access

Information (Three-fold):
  components    List system components (JSON)
  status        Show status (JSON)
  access        Show ports and access info (JSON)

Service:
  install       Install HAProxy (native/lxc based on config)
  uninstall     Remove HAProxy container
  start         Start HAProxy
  stop          Stop HAProxy
  restart       Restart HAProxy
  reload        Reload configuration
  stats         Show live stats
  info          Show HAProxy info

Config:
  generate      Generate HAProxy config from TOML
  frontal       Put the box behind HAProxy + sbxwaf (idempotent; --etat to check,
                --admin-lan IP[,IP] to keep admin by IP on :9443)

VHosts:
  vhost list              List vhosts
  vhost add <domain> <backend> [ssl]   Add vhost
  vhost remove <name>     Remove vhost

Backends:
  backend list            List backends

Certificates:
  cert list               List certificates
  cert add <domain> [--staging]   Request certificate
  cert renew              Renew all certificates

Migration:
  migrate [source]        Migrate from OpenWrt (default: 192.168.255.1)

WAF (sbxwaf):
  waf status              Show WAF status (JSON)
  waf enable              Enable WAF inspection
  waf disable             Disable WAF inspection

Examples:
  haproxyctl components             # JSON list of components
  haproxyctl access                 # JSON port and URL info
  haproxyctl vhost add app.ex.com backend_app true

EOF
        ;;
esac
