#!/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 : détecte Nginx Proxy Manager, pose les questions, génère deploy.env, # docker-compose.override.yml, backend/.env, addin/.env, construit et # démarre la stack, initialise la base et lance une synchro annuaire complète. # TLS : terminé par Nginx Proxy Manager (certificat wildcard déjà présent), stack en HTTP interne. # 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" OVERRIDE="$ROOT/docker-compose.override.yml" ADMIN_ALLOW="$ROOT/docker/nginx/admin-allow.inc" 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 # Guillemets simples = valeur littérale pour Laravel (phpdotenv) comme pour Docker Compose (pas d'interpolation de $) if [[ "$val" =~ [[:space:]\#\"\$] && "$val" != *"'"* ]]; then line="$key='$val'" elif [[ "$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 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 »." # --- Nginx Proxy Manager --------------------------------------------------- NPM_CONTAINER="$(docker ps --format '{{.Names}} {{.Image}}' | awk 'tolower($2) ~ /nginx-proxy-manager/ {print $1; exit}')" NPM_NETWORK=""; HTTP_PORT="" if [[ -n "$NPM_CONTAINER" ]]; then NPM_NETWORK="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$NPM_CONTAINER" | tr ' ' '\n' | grep -vE '^(host|none)?$' | head -n1 || true)" c_ok "Nginx Proxy Manager détecté : conteneur « $NPM_CONTAINER », réseau « ${NPM_NETWORK:-aucun} »." else c_warn "Aucun conteneur Nginx Proxy Manager détecté sur ce serveur." fi if [[ -n "$NPM_NETWORK" ]]; then ask "Raccorder la stack au réseau Docker « $NPM_NETWORK » de NPM (recommandé, aucun port exposé) ? (o/n)" "o" [[ "$REPLY" =~ ^[oOyY] ]] || NPM_NETWORK="" fi if [[ -n "$NPM_NETWORK" ]]; then NGINX_REAL_IP_FROM="$(docker network inspect -f '{{range .IPAM.Config}}{{.Subnet}} {{end}}' "$NPM_NETWORK" | awk '{print $1}')" [[ -n "$NGINX_REAL_IP_FROM" ]] || die "Sous-réseau du réseau $NPM_NETWORK introuvable." # Seuls des conteneurs joignent la stack : confiance aux réseaux Docker privés pour X-Forwarded-*. TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" { echo "# Généré par deploy/deploy.sh — raccordement au réseau de Nginx Proxy Manager" echo "services:" echo " nginx:" echo " networks: [signature_net, npm]" echo "networks:" echo " npm:" echo " external: true" echo " name: $NPM_NETWORK" } > "$OVERRIDE" FORWARD_HOST="signature_nginx"; FORWARD_PORT=80 else c_warn "Mode port hôte : NPM joindra la stack par IP:port ; ce port est joignable par toute machine qui voit ce serveur." def_http="$(next_free_port 8085)" while :; do ask "Port HTTP interne de la stack" "$def_http"; port_used "$REPLY" && { c_warn "Port $REPLY occupé."; continue; }; HTTP_PORT="$REPLY"; break; done ask_required "IP ou réseau (CIDR) d'où NPM joint ce port (ex. 172.18.0.1/32 si NPM tourne sur ce serveur)" NGINX_REAL_IP_FROM="$REPLY"; TRUSTED_PROXIES="$REPLY,172.16.0.0/12" { echo "# Généré par deploy/deploy.sh — publication d'un port hôte pour Nginx Proxy Manager" echo "services:" echo " nginx:" echo " ports:" echo " - \"$HTTP_PORT:80\"" } > "$OVERRIDE" FORWARD_HOST="$(hostname -I | awk '{print $1}')"; FORWARD_PORT="$HTTP_PORT" fi DB_PASSWORD="$(openssl rand -hex 24)" umask 077 { echo "# Généré par deploy/deploy.sh le $(date '+%d/%m/%Y %H:%M') — ne pas versionner" echo "NGINX_REAL_IP_FROM=$NGINX_REAL_IP_FROM" echo "FORWARD_HOST=$FORWARD_HOST" echo "FORWARD_PORT=$FORWARD_PORT" echo "DB_DATABASE=signature" echo "DB_USERNAME=signature" echo "DB_PASSWORD=$DB_PASSWORD" } > "$DEPLOY_ENV" umask 022 # .env à la racine = fichier lu automatiquement par « docker compose » : les commandes manuelles fonctionnent sans --env-file ln -sfn deploy.env "$ROOT/.env" c_ok "deploy.env et docker-compose.override.yml créés (cible NPM : http://$FORWARD_HOST:$FORWARD_PORT)." # --- 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" [TRUSTED_PROXIES]="$TRUSTED_PROXIES" ) for k in "${!BE[@]}"; do set_env "$BACKEND_ENV" "$k" "${BE[$k]}"; done c_ok "backend/.env configuré (production)." { echo "# Généré par deploy/deploy.sh — réseaux autorisés pour /admin" IFS=',' read -ra cidrs <<< "$ADMIN_CIDRS" for c in "${cidrs[@]}"; do c="${c// /}"; if [[ -n "$c" ]]; then echo "allow $c;"; fi; done } > "$ADMIN_ALLOW" # --- addin/.env (lu au build de l'image) ----------------------------------- cat > "$ADDIN_ENV" < "$ADMIN_ALLOW" # ----------------------------------------------------------------------------- c_info "Construction de l'image (add-in + back-office)…" compose build --pull c_info "Démarrage…" compose up -d --remove-orphans compose exec -T nginx nginx -t >/dev/null 2>&1 || { compose exec -T nginx nginx -t || true; die "Configuration Nginx de la stack invalide."; } c_info "Attente de l'application…" ok=0 for _ in $(seq 1 30); do compose exec -T nginx wget -q -O /dev/null http://127.0.0.1/up 2>/dev/null && { ok=1; break; } sleep 3 done (( ok )) || { compose logs --tail=50 app nginx; die "L'application ne répond pas sur /up."; } c_ok "Application en ligne (contrôle interne /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 if compose exec -T nginx wget -q -O /dev/null http://127.0.0.1/addin/manifest.xml 2>/dev/null; then c_ok "Manifeste de l'add-in servi." else c_warn "manifest.xml non servi par la stack." fi xfo="$(compose exec -T nginx wget -S -q -O /dev/null http://127.0.0.1/addin/commands.html 2>&1 | grep -ci 'x-frame-options' || true)" if [[ "$xfo" == "0" ]]; then c_ok "Add-in intégrable par Outlook (pas de X-Frame-Options côté stack)." else c_warn "X-Frame-Options présent sur /addin : Outlook refusera de charger l'add-in."; fi FORWARD_HOST="$(get_env "$DEPLOY_ENV" FORWARD_HOST)"; FORWARD_PORT="$(get_env "$DEPLOY_ENV" FORWARD_PORT)" cat < Proxy Hosts > Add Proxy Host : Domain Names signature.beziers-mediterranee.fr Scheme / Hostname / Port http / $FORWARD_HOST / $FORWARD_PORT Block Common Exploits oui Websockets non SSL certificat wildcard existant, Force SSL, HTTP/2, HSTS Ne PAS ajouter d'en-tête X-Frame-Options (Outlook charge /addin en iframe) 2. DNS public : signature.beziers-mediterranee.fr -> IP publique de NPM 3. Test externe : https://signature.beziers-mediterranee.fr/up puis /addin/manifest.xml 4. Integrated Apps : lien https://signature.beziers-mediterranee.fr/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 mise à jour sudo ./deploy/deploy.sh ──────────────────────────────────────────────────────────────────── EOF