#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.

# SecuBox-Deb :: certsctl — deploiement des certificats vers HAProxy
# CyberMind — https://cybermind.fr
#
# LE MAILLON QUI MANQUAIT (#991).
#
# acme.sh renouvelle correctement, mais rien ne portait le resultat jusqu'a
# HAProxy : aucun Le_ReloadCmd, aucun chemin d'installation. Les pem de
# /data/haproxy/certs avaient ete fabriques UNE FOIS a la main le 7 mai 2026 et
# n'avaient plus bouge. Constat du 6 aout : 36 certificats expires servis en
# production, alors que le magasin acme.sh en detenait des valides jusqu'en
# novembre. Le moniteur criait juste ; il lit les pem deployes, pas le magasin.
#
# Ce ctl synchronise magasin -> pem, et ne recharge HAProxy que si quelque
# chose a change. Idempotent : le rejouer sans renouvellement ne fait rien.

set -euo pipefail

readonly VERSION="1.3.1"
readonly CERTS_DIR="${SECUBOX_CERTS_DIR:-/data/haproxy/certs}"
readonly ACME_HOME="${SECUBOX_ACME_HOME:-/root/.acme.sh}"
# Certbot gere une partie du parc (ganimed.fr, mail.gk2.secubox.in, …). La garantie
# doit etre UNIFORME : un certificat renouvele doit atteindre HAProxy quel que
# soit l outil qui l a emis. Sinon la moitie du parc reste sur le mecanisme qui
# avait laisse 36 certificats expirer.
readonly CERTBOT_LIVE="${SECUBOX_CERTBOT_LIVE:-/etc/letsencrypt/live}"
readonly ACME_WEBROOT="${SECUBOX_ACME_WEBROOT:-/usr/share/secubox/www}"
readonly ACME_BIN="${SECUBOX_ACME_BIN:-$ACME_HOME/acme.sh}"
readonly CERTBOT_BIN="${SECUBOX_CERTBOT_BIN:-certbot}"
readonly RELOAD_CMD="${SECUBOX_CERTS_RELOAD:-systemctl reload haproxy}"

log() { printf '[certsctl] %s\n' "$*" >&2; }

# Magasin pour $1, tous outils confondus. Emet "<chaine>\t<cle>" sur stdout.
#
# acme.sh suffixe _ecc les certificats a courbe elliptique, qui sont le defaut
# depuis des annees — les deux formes sont essayees. Certbot, lui, range sous
# live/<domaine>/ avec des noms fixes.
#
# ORDRE : acme.sh d abord. Il gere la grande majorite du parc ; un domaine
# present dans les deux magasins est presume gere par acme.sh, et prendre
# l autre risquerait de deployer un certificat qui ne sera plus renouvele.
_store_for() {
    local dom="$1" c
    for c in "${dom}_ecc" "$dom"; do
        if [ -f "$ACME_HOME/$c/fullchain.cer" ] && [ -f "$ACME_HOME/$c/$dom.key" ]; then
            printf '%s\t%s' "$ACME_HOME/$c/fullchain.cer" "$ACME_HOME/$c/$dom.key"
            return 0
        fi
    done
    if [ -f "$CERTBOT_LIVE/$dom/fullchain.pem" ] && [ -f "$CERTBOT_LIVE/$dom/privkey.pem" ]; then
        printf '%s\t%s' "$CERTBOT_LIVE/$dom/fullchain.pem" "$CERTBOT_LIVE/$dom/privkey.pem"
        return 0
    fi
    return 1
}

_end_epoch() {
    local end
    end=$(openssl x509 -noout -enddate -in "$1" 2>/dev/null | cut -d= -f2) || return 1
    [ -n "$end" ] || return 1
    date -d "$end" +%s 2>/dev/null
}

# deploy [--apply] — sans --apply, simule et n'ecrit rien.
cmd_deploy() {
    local apply=false
    [ "${1:-}" = "--apply" ] && apply=true

    local updated=0 uptodate=0 unmanaged=0 failed=0 changed=false
    local pem dom store chain keyf new_e cur_e tmp

    for pem in "$CERTS_DIR"/*.pem; do
        [ -f "$pem" ] || continue
        dom=$(basename "$pem" .pem)
        if ! store=$(_store_for "$dom"); then
            unmanaged=$((unmanaged + 1))
            continue
        fi
        chain=${store%%$'\t'*}
        keyf=${store#*$'\t'}
        new_e=$(_end_epoch "$chain" || true)
        cur_e=$(_end_epoch "$pem" || true)
        # Une date illisible n'autorise JAMAIS un remplacement : mieux vaut
        # laisser un certificat en place que d'en installer un qu'on n'a pas su
        # dater. Un balayage ecrit trop vite avait deja masque 35 certificats
        # expires en avalant silencieusement les dates non analysables.
        if [ -z "$new_e" ] || [ -z "$cur_e" ]; then
            failed=$((failed + 1))
            log "date illisible, ignore : $dom"
            continue
        fi
        if [ "$new_e" -le "$cur_e" ]; then
            uptodate=$((uptodate + 1))
            continue
        fi
        updated=$((updated + 1))
        changed=true
        if [ "$apply" = "true" ]; then
            # Ecriture atomique, droits et proprietaire du pem existant
            # preserves : HAProxy tourne sous son propre utilisateur et un
            # certificat devenu illisible pour lui est une panne totale.
            tmp=$(mktemp "$CERTS_DIR/.certsctl.XXXXXX")
            cat "$chain" > "$tmp"
            printf '\n' >> "$tmp"
            cat "$keyf" >> "$tmp"
            chown --reference="$pem" "$tmp" 2>/dev/null || true
            chmod --reference="$pem" "$tmp" 2>/dev/null || true
            mv -f "$tmp" "$pem"
        fi
    done

    if [ "$apply" = "true" ] && [ "$changed" = "true" ]; then
        # Rechargement UNIQUEMENT si quelque chose a change : un reload par
        # passage de minuteur, sans raison, coupe des connexions pour rien.
        if haproxy -c -f /etc/haproxy/haproxy.cfg >/dev/null 2>&1; then
            $RELOAD_CMD >/dev/null 2>&1 || log "rechargement HAProxy en echec"
        else
            log "haproxy -c en echec — rechargement NON effectue"
        fi
    fi

    cat <<EOF
{
  "applied": $apply,
  "updated": $updated,
  "up_to_date": $uptodate,
  "unmanaged": $unmanaged,
  "unreadable": $failed,
  "reloaded": $([ "$apply" = "true" ] && [ "$changed" = "true" ] && echo true || echo false)
}
EOF
}

# status — combien de certificats deployes sont expires ou proches de l'etre.
cmd_status() {
    local exp=0 soon=0 ok=0 pem
    for pem in "$CERTS_DIR"/*.pem; do
        [ -f "$pem" ] || continue
        if ! openssl x509 -noout -checkend 0 -in "$pem" >/dev/null 2>&1; then
            exp=$((exp + 1))
        elif ! openssl x509 -noout -checkend 1814400 -in "$pem" >/dev/null 2>&1; then
            soon=$((soon + 1))
        else
            ok=$((ok + 1))
        fi
    done
    printf '{"expired": %d, "expiring_21d": %d, "valid": %d}\n' "$exp" "$soon" "$ok"
}

# renew <domaine> — force le renouvellement certbot puis redeploie le pem (#2874).
#
# Le panneau tourne dans l agregateur sous l utilisateur secubox : certbot y
# echouait sur /var/log/letsencrypt/.certbot.lock. Ce verbe s execute en root
# (sudoers a la lettre) ; le nom est valide AVANT tout, il n atteint jamais un
# chemin ni une ligne de commande sans l avoir ete.
cmd_renew() {
    local dom="${1:-}"
    if ! printf '%s' "$dom" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$'; then
        log "nom de domaine invalide"
        printf '{"success": false, "error": "invalid domain"}\n'
        return 2
    fi
    if [ ! -f "$CERTS_DIR/$dom.pem" ]; then
        log "certificat inconnu : $dom"
        printf '{"success": false, "error": "unknown certificate"}\n'
        return 3
    fi
    # Le parc a DEUX emetteurs : acme.sh (la grande majorite) et certbot. On
    # renouvelle avec celui qui detient le certificat — l'autre repondrait
    # « No certificate found » (cas reel : apt.secubox.in).
    local outil="certbot" ecc=""
    if [ -f "$ACME_HOME/${dom}_ecc/fullchain.cer" ]; then
        outil="acme.sh"; ecc="--ecc"
    elif [ -f "$ACME_HOME/$dom/fullchain.cer" ]; then
        outil="acme.sh"
    fi
    if [ "$outil" = "acme.sh" ]; then
        # `--renew` ignore `-w` : il rejoue le webroot MEMORISE dans la conf du
        # domaine. On corrige donc la conf (la ou acme.sh l'a ecrit) pour que le
        # defi tombe dans la racine servie par :8880.
        local conf
        for conf in "$ACME_HOME/${dom}_ecc/$dom.conf" "$ACME_HOME/$dom/$dom.conf"; do
            if [ -f "$conf" ] && grep -q "^Le_Webroot=" "$conf"; then
                sed -i "s|^Le_Webroot=.*|Le_Webroot='$ACME_WEBROOT'|" "$conf"
            fi
        done
        # shellcheck disable=SC2086
        if ! "$ACME_BIN" --home "$ACME_HOME" --renew -d "$dom" --force $ecc >&2; then
            printf '{"success": false, "domain": "%s", "error": "acme.sh failed"}\n' "$dom"
            return 1
        fi
    elif ! "$CERTBOT_BIN" renew --cert-name "$dom" --force-renewal --non-interactive >&2; then
        printf '{"success": false, "domain": "%s", "error": "certbot failed"}\n' "$dom"
        return 1
    fi
    cmd_deploy --apply >&2 || true
    printf '{"success": true, "domain": "%s"}\n' "$dom"
}

case "${1:-help}" in
    deploy) cmd_deploy "${2:-}" ;;
    status) cmd_status ;;
    renew) cmd_renew "${2:-}" ;;
    *) cat <<EOF
certsctl v$VERSION — deploiement des certificats vers HAProxy

  deploy [--apply]   Synchronise magasin acme.sh -> $CERTS_DIR.
                     Sans --apply : simule. Recharge HAProxy uniquement si
                     quelque chose a change.
  renew <domaine>    Force le renouvellement certbot puis redeploie le pem.
  status             Compte les certificats deployes expires / proches / valides
EOF
    ;;
esac
