This commit is contained in:
mrKamoo
2026-09-30 16:27:32 +02:00
commit 310724708f
147 changed files with 21327 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
# =============================================================================
# Signatures CABM — installation et mise à jour sur le serveur Debian DMZ
#
# Usage (depuis la racine du dépôt cloné, ex. /opt/signature) :
# sudo ./deploy/deploy.sh installation (1re fois) ou mise à jour
#
# 1re exécution : pose les questions, génère deploy.env, backend/.env, addin/.env,
# installe le certificat, construit et démarre la stack, initialise
# la base et lance une synchro annuaire complète.
# Exécutions suivantes : git pull, rebuild, redémarrage, contrôles.
# Le serveur héberge d'autres applications : les ports sont détectés et jamais écrasés.
# =============================================================================
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
DEPLOY_ENV="$ROOT/deploy.env"
BACKEND_ENV="$ROOT/backend/.env"
ADDIN_ENV="$ROOT/addin/.env"
CERT_DIR="$ROOT/docker/certs"
CERT_CRT="$CERT_DIR/wildcard.beziers-mediterranee.fr.crt"
CERT_KEY="$CERT_DIR/wildcard.beziers-mediterranee.fr.key"
HOSTNAME_FQDN="signature.beziers-mediterranee.fr"
c_info() { printf '\033[1;34m[i]\033[0m %s\n' "$*"; }
c_ok() { printf '\033[1;32m[ok]\033[0m %s\n' "$*"; }
c_warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*"; }
c_err() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; }
die() { c_err "$*"; exit 1; }
trap 'c_err "Échec ligne $LINENO : $BASH_COMMAND"' ERR
ask() { # ask "Question" "défaut" -> REPLY
local q="$1" def="${2:-}"
if [[ -n "$def" ]]; then read -r -p "$q [$def] : " REPLY; REPLY="${REPLY:-$def}"
else read -r -p "$q : " REPLY; fi
}
ask_secret() { read -r -s -p "$1 : " REPLY; echo; }
ask_required() { # boucle tant que vide
while :; do ask "$1" "${2:-}"; [[ -n "$REPLY" ]] && break; c_warn "Valeur obligatoire."; done
}
is_guid() { [[ "$1" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; }
ask_guid() {
while :; do ask "$1" "${2:-}"; is_guid "$REPLY" && break; c_warn "Format attendu : GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)."; done
}
# Remplace ou ajoute KEY=VALUE dans un fichier .env (valeur entre guillemets si nécessaire)
set_env() {
local file="$1" key="$2" val="$3" line tmp
if [[ "$val" =~ [[:space:]\#\"\'\$] ]]; then line="$key=\"${val//\"/\\\"}\""; else line="$key=$val"; fi
tmp="$(mktemp)"
KEY="$key" LINE="$line" awk 'BEGIN{k=ENVIRON["KEY"]; l=ENVIRON["LINE"]; done=0}
$0 ~ "^"k"=" { if(!done){print l; done=1}; next } {print}
END{ if(!done) print l }' "$file" > "$tmp"
cat "$tmp" > "$file"; rm -f "$tmp"
}
get_env() { # get_env fichier CLE
[[ -f "$1" ]] || return 0
grep -E "^$2=" "$1" | tail -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
}
port_used() { # port occupé par le système ou publié par un conteneur Docker
local p="$1"
ss -Hltn "sport = :$p" 2>/dev/null | grep -q . && return 0
docker ps --format '{{.Ports}}' | grep -Eq "(:|\])$p->" && return 0
return 1
}
port_owned_by_us() { docker port signature_nginx 2>/dev/null | grep -Eq ":$1$"; }
next_free_port() { local p="$1"; while port_used "$p" && ! port_owned_by_us "$p"; do p=$((p + 1)); done; echo "$p"; }
compose() { docker compose --env-file "$DEPLOY_ENV" "$@"; }
# -----------------------------------------------------------------------------
[[ $EUID -eq 0 ]] || die "Lancer avec sudo (accès Docker et ports)."
for bin in docker git openssl ss curl awk; do command -v "$bin" >/dev/null || die "Commande manquante : $bin"; done
docker compose version >/dev/null 2>&1 || die "Plugin « docker compose » absent."
[[ -f backend/composer.json && -f backend/composer.lock ]] \
|| die "backend/composer.json ou composer.lock absent : le dépôt doit contenir le back-office complet (voir docs/02-deploiement.md, étape Git)."
FIRST_INSTALL=0
[[ -f "$DEPLOY_ENV" ]] || FIRST_INSTALL=1
# -----------------------------------------------------------------------------
if (( FIRST_INSTALL )); then
c_info "Première installation de la stack « signature »."
# --- Ports ------------------------------------------------------------------
def_https=443; port_used 443 && def_https="$(next_free_port 8443)"
def_http=80; port_used 80 && def_http="$(next_free_port 8080)"
(( def_https != 443 )) && c_warn "Le port 443 est déjà utilisé sur ce serveur : proposition $def_https (NAT Stormshield 443 public -> $def_https)."
while :; do ask "Port HTTPS de la stack sur ce serveur" "$def_https"; port_used "$REPLY" && { c_warn "Port $REPLY occupé."; continue; }; HTTPS_PORT="$REPLY"; break; done
while :; do ask "Port HTTP (redirection vers HTTPS)" "$def_http"; port_used "$REPLY" && { c_warn "Port $REPLY occupé."; continue; }; HTTP_PORT="$REPLY"; break; done
DB_PASSWORD="$(openssl rand -hex 24)"
umask 077
cat > "$DEPLOY_ENV" <<EOF
# Généré par deploy/deploy.sh le $(date '+%d/%m/%Y %H:%M') — ne pas versionner
HTTP_PORT=$HTTP_PORT
HTTPS_PORT=$HTTPS_PORT
DB_DATABASE=signature
DB_USERNAME=signature
DB_PASSWORD=$DB_PASSWORD
EOF
umask 022
c_ok "deploy.env créé (ports $HTTP_PORT / $HTTPS_PORT)."
# --- Certificat -------------------------------------------------------------
mkdir -p "$CERT_DIR"
if [[ ! -f "$CERT_CRT" || ! -f "$CERT_KEY" ]]; then
c_info "Certificat wildcard *.beziers-mediterranee.fr (PEM, chaîne complète : certificat + intermédiaires)."
ask_required "Chemin du fichier certificat (.crt/.pem)"; src_crt="$REPLY"
ask_required "Chemin de la clé privée (.key)"; src_key="$REPLY"
[[ -f "$src_crt" && -f "$src_key" ]] || die "Fichier introuvable."
cp "$src_crt" "$CERT_CRT"; cp "$src_key" "$CERT_KEY"; chmod 644 "$CERT_CRT"; chmod 600 "$CERT_KEY"
fi
crt_mod="$(openssl x509 -noout -modulus -in "$CERT_CRT" | openssl md5)"
key_mod="$(openssl rsa -noout -modulus -in "$CERT_KEY" 2>/dev/null | openssl md5 || true)"
[[ -z "$key_mod" || "$crt_mod" == "$key_mod" ]] || die "Le certificat et la clé ne correspondent pas."
c_ok "Certificat : $(openssl x509 -noout -subject -enddate -in "$CERT_CRT" | tr '\n' ' ')"
# --- Identifiants Entra -----------------------------------------------------
c_info "Identifiants Entra (docs/01-entra-app-registrations.md)."
ask_guid "ID du tenant (ENTRA_TENANT_ID)" "$(get_env "$BACKEND_ENV" ENTRA_TENANT_ID)"; TENANT_ID="$REPLY"
ask_guid "ID client « Back-office » (GRAPH_CLIENT_ID)" "$(get_env "$BACKEND_ENV" GRAPH_CLIENT_ID)"; GRAPH_ID="$REPLY"
ask_secret "Valeur du secret « Back-office » (GRAPH_CLIENT_SECRET, saisie masquée)"; GRAPH_SECRET="$REPLY"
[[ -n "$GRAPH_SECRET" ]] || die "Secret vide."
ask_guid "ID client « Add-in » (ADDIN_CLIENT_ID)" "$(get_env "$BACKEND_ENV" ADDIN_CLIENT_ID)"; ADDIN_ID="$REPLY"
ask "Réseaux autorisés pour /admin (CIDR, séparés par des virgules)" "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"; ADMIN_CIDRS="$REPLY"
# --- backend/.env -----------------------------------------------------------
[[ -f "$BACKEND_ENV" ]] || cp backend/.env.example "$BACKEND_ENV"
chmod 600 "$BACKEND_ENV"
APP_KEY="base64:$(openssl rand -base64 32)"
[[ -n "$(get_env "$BACKEND_ENV" APP_KEY)" ]] && APP_KEY="$(get_env "$BACKEND_ENV" APP_KEY)"
declare -A BE=(
[APP_NAME]="Signatures CABM" [APP_ENV]=production [APP_DEBUG]=false [APP_KEY]="$APP_KEY"
[APP_URL]="https://$HOSTNAME_FQDN" [APP_LOCALE]=fr [APP_TIMEZONE]=Europe/Paris
[LOG_CHANNEL]=daily [LOG_LEVEL]=notice
[DB_CONNECTION]=mariadb [DB_HOST]=db [DB_PORT]=3306
[DB_DATABASE]=signature [DB_USERNAME]=signature [DB_PASSWORD]="$DB_PASSWORD"
[SESSION_DRIVER]=database [SESSION_SECURE_COOKIE]=true [CACHE_STORE]=database
[ENTRA_TENANT_ID]="$TENANT_ID" [GRAPH_CLIENT_ID]="$GRAPH_ID" [GRAPH_CLIENT_SECRET]="$GRAPH_SECRET"
[SIGNATURE_GROUP_PREFIX]=signature_
[ADDIN_CLIENT_ID]="$ADDIN_ID"
[ADDIN_API_AUDIENCES]="$ADDIN_ID,api://$HOSTNAME_FQDN/$ADDIN_ID"
[ADDIN_API_SCOPE_NAME]=access_as_user
[SIGNATURE_ROLE_SUPERADMIN_GROUP]=signature_role_superadmin
[SIGNATURE_ROLE_ADMIN_GROUP]=signature_role_admin
[SIGNATURE_ROLE_EDITOR_GROUP]=signature_role_editeur
[SIGNATURE_DEFAULT_REPLY_VARIANT]=short
[SIGNATURE_ADMIN_ALLOWED_CIDRS]="$ADMIN_CIDRS"
)
for k in "${!BE[@]}"; do set_env "$BACKEND_ENV" "$k" "${BE[$k]}"; done
c_ok "backend/.env configuré (production)."
# --- addin/.env (lu au build de l'image) -----------------------------------
cat > "$ADDIN_ENV" <<EOF
ADDIN_BASE_URL=https://$HOSTNAME_FQDN/addin
API_BASE_URL=https://$HOSTNAME_FQDN
PORTAL_URL=https://$HOSTNAME_FQDN/me
SUPPORT_URL=https://$HOSTNAME_FQDN/me
ENTRA_TENANT_ID=$TENANT_ID
ADDIN_CLIENT_ID=$ADDIN_ID
API_SCOPE=api://$HOSTNAME_FQDN/$ADDIN_ID/access_as_user
EOF
c_ok "addin/.env configuré (production)."
else
c_info "Mise à jour de la stack existante."
if [[ -d .git ]]; then
if git diff --quiet && git diff --cached --quiet; then
git pull --ff-only
else
c_warn "Modifications locales dans le dépôt : git pull ignoré."
fi
fi
HTTPS_PORT="$(get_env "$DEPLOY_ENV" HTTPS_PORT)"
fi
# -----------------------------------------------------------------------------
c_info "Construction de l'image (add-in + back-office)…"
compose build --pull
c_info "Démarrage…"
compose up -d --remove-orphans
c_info "Attente de l'application…"
ok=0
for _ in $(seq 1 30); do
code="$(curl -sk -o /dev/null -w '%{http_code}' --resolve "$HOSTNAME_FQDN:$HTTPS_PORT:127.0.0.1" "https://$HOSTNAME_FQDN:$HTTPS_PORT/up" || true)"
[[ "$code" == "200" ]] && { ok=1; break; }
sleep 3
done
(( ok )) || { compose logs --tail=50 app nginx; die "L'application ne répond pas sur /up (dernier code : ${code:-aucun})."; }
c_ok "Application en ligne (https://$HOSTNAME_FQDN:$HTTPS_PORT/up)."
if (( FIRST_INSTALL )); then
c_info "Données initiales (tenant, template par défaut)…"
compose exec -T app php artisan db:seed --force
c_info "Synchro annuaire complète…"
compose exec -T -u www-data app php artisan signature:sync --full || c_warn "Synchro en échec : vérifier les autorisations Graph (docs/01) puis relancer depuis le tableau de bord."
fi
# Contrôles
addin_code="$(curl -sk -o /dev/null -w '%{http_code}' --resolve "$HOSTNAME_FQDN:$HTTPS_PORT:127.0.0.1" "https://$HOSTNAME_FQDN:$HTTPS_PORT/addin/manifest.xml")"
[[ "$addin_code" == "200" ]] && c_ok "Manifeste de l'add-in servi." || c_warn "manifest.xml : HTTP $addin_code"
xfo="$(curl -skI --resolve "$HOSTNAME_FQDN:$HTTPS_PORT:127.0.0.1" "https://$HOSTNAME_FQDN:$HTTPS_PORT/addin/commands.html" | grep -ci '^x-frame-options' || true)"
[[ "$xfo" == "0" ]] && c_ok "Add-in intégrable par Outlook (pas de X-Frame-Options)." || c_warn "X-Frame-Options présent sur /addin : Outlook refusera de charger l'add-in."
cat <<EOF
────────────────────────────────────────────────────────────────────
Stack « signature » opérationnelle sur ce serveur : HTTPS $HTTPS_PORT
────────────────────────────────────────────────────────────────────
Restant à faire côté réseau (1re installation) :
1. DNS public : $HOSTNAME_FQDN A <IP publique dédiée>
2. Stormshield : NAT <IP publique>:443 -> $(hostname -I | awk '{print $1}'):$HTTPS_PORT
(+ 80 -> $(get_env "$DEPLOY_ENV" HTTP_PORT) pour la redirection, facultatif)
3. Test externe : https://$HOSTNAME_FQDN/up puis /addin/manifest.xml
4. Integrated Apps : téléverser https://$HOSTNAME_FQDN/addin/manifest.xml
ciblé sur le groupe pilote (docs/01, §4)
Exploitation :
logs docker compose --env-file deploy.env logs -f app scheduler nginx
artisan docker compose --env-file deploy.env exec -u www-data app php artisan <commande>
mise à jour sudo ./deploy/deploy.sh
────────────────────────────────────────────────────────────────────
EOF