275 lines
14 KiB
Bash
275 lines
14 KiB
Bash
#!/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
|
|
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" <<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
|
|
fi
|
|
[[ -f "$ADMIN_ALLOW" ]] || printf 'allow 10.0.0.0/8;\nallow 172.16.0.0/12;\nallow 192.168.0.0/16;\n' > "$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 <<EOF
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
Stack « signature » opérationnelle — cible NPM : http://$FORWARD_HOST:$FORWARD_PORT
|
|
────────────────────────────────────────────────────────────────────
|
|
Restant à faire (1re installation) :
|
|
1. Nginx Proxy Manager > 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 <commande>
|
|
mise à jour sudo ./deploy/deploy.sh
|
|
────────────────────────────────────────────────────────────────────
|
|
EOF
|