diff --git a/docker-compose.yml b/docker-compose.yml index 3f6c03c..a0410d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,10 +18,12 @@ services: - ADMIN_PASSWORD=Itinova69! - GITEA_URL=https://git.recette.santinova-soft.org - GITEA_USERNAME=manus-admin - - GITEA_PASSWORD=ManusGitea2026! - - GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427 + - GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur} - GITEA_RECETTE_URL=http://gitea:3000 - - GITEA_PASSWORD_PROD=ManusGitea2026! + - GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-} + - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} + - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org} + - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} - APPS_BASE_PATH=/opt/manus-deploy/apps - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure # Contrôles bornés et dédoublonnés par healthcheck.js. diff --git a/ops/gitea-token-rotation-apply.path b/ops/gitea-token-rotation-apply.path new file mode 100644 index 0000000..7bc8e58 --- /dev/null +++ b/ops/gitea-token-rotation-apply.path @@ -0,0 +1,9 @@ +[Unit] +Description=Surveillance de la validation manuelle du jeton Gitea Santinova + +[Path] +PathExists=/opt/manus-deploy/secrets/gitea-token-rotation/approval.request +Unit=gitea-token-rotation-apply.service + +[Install] +WantedBy=multi-user.target diff --git a/ops/gitea-token-rotation-apply.service b/ops/gitea-token-rotation-apply.service new file mode 100644 index 0000000..f59472e --- /dev/null +++ b/ops/gitea-token-rotation-apply.service @@ -0,0 +1,11 @@ +[Unit] +Description=Application manuelle validée du jeton Gitea Santinova +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh apply +User=root +Group=root +UMask=0077 diff --git a/ops/gitea-token-rotation.service b/ops/gitea-token-rotation.service new file mode 100644 index 0000000..e20b21b --- /dev/null +++ b/ops/gitea-token-rotation.service @@ -0,0 +1,12 @@ +[Unit] +Description=Préparation contrôlée de la rotation du jeton Gitea Santinova +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh prepare +User=root +Group=root +UMask=0077 +Nice=10 diff --git a/ops/gitea-token-rotation.sh b/ops/gitea-token-rotation.sh new file mode 100644 index 0000000..d100925 --- /dev/null +++ b/ops/gitea-token-rotation.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly COMMAND="${1:-status}" +readonly FORCE="${2:-}" +readonly GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}" +readonly GITEA_URL="${GITEA_URL:-https://git.recette.santinova-soft.org}" +readonly GITEA_USER="${GITEA_USERNAME:-manus-admin}" +readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}" +readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}" +readonly STATE_DIR="${GITEA_ROTATION_STATE_DIR:-/opt/manus-deploy/secrets/gitea-token-rotation}" +readonly STATUS_FILE="${GITEA_ROTATION_STATUS_FILE:-/var/log/manus-deploy/gitea-token-rotation.json}" +readonly PENDING_TOKEN_FILE="${STATE_DIR}/pending.token" +readonly PENDING_NAME_FILE="${STATE_DIR}/pending.name" +readonly CURRENT_NAME_FILE="${STATE_DIR}/current.name" +readonly LAST_ROTATION_FILE="${STATE_DIR}/last-rotation" +readonly LOCK_FILE="${STATE_DIR}/rotation.lock" +readonly APPROVAL_FILE="${STATE_DIR}/approval.request" +readonly TOKEN_PREFIX="santinova-recette" +readonly MAX_AGE_DAYS="90" + +install -d -m 0700 "${STATE_DIR}" +install -d -m 0750 "$(dirname "${STATUS_FILE}")" +exec 9>"${LOCK_FILE}" +flock -n 9 || { echo "Une opération de rotation Gitea est déjà en cours." >&2; exit 1; } + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]/ /g' +} + +write_status() { + local state="$1" + local message="$2" + local token_name="${3:-}" + local temporary + temporary="$(mktemp "${STATUS_FILE}.XXXXXX")" + chmod 0640 "${temporary}" + printf '{"state":"%s","message":"%s","tokenName":"%s","updatedAt":"%s"}\n' \ + "$(json_escape "${state}")" \ + "$(json_escape "${message}")" \ + "$(json_escape "${token_name}")" \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "${temporary}" + mv "${temporary}" "${STATUS_FILE}" +} + +read_env_token() { + sed -n -E 's/^GITEA_TOKEN=(.*)$/\1/p' "${DASHBOARD_ENV_FILE}" | tail -n 1 +} + +validate_token() { + local token="$1" + local response + response="$(mktemp /tmp/gitea-token-validation.XXXXXX)" + trap 'rm -f "${response}"' RETURN + local status + status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/user")" + [[ "${status}" == "200" ]] || return 1 + grep -q '"login":"manus-admin"' "${response}" + status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/manus-dashboard")" + [[ "${status}" == "200" ]] || return 1 + status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/referentiel-ia")" + [[ "${status}" == "200" ]] +} + +rotation_due() { + [[ "${FORCE}" == "--force" ]] && return 0 + [[ -f "${LAST_ROTATION_FILE}" ]] || return 0 + local last_epoch now_epoch max_age_seconds + last_epoch="$(date -d "$(<"${LAST_ROTATION_FILE}")" +%s 2>/dev/null || echo 0)" + now_epoch="$(date +%s)" + max_age_seconds="$((MAX_AGE_DAYS * 86400))" + (( now_epoch - last_epoch >= max_age_seconds )) +} + +prepare_rotation() { + if [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]]; then + local existing_pending + existing_pending="$(<"${PENDING_TOKEN_FILE}")" + validate_token "${existing_pending}" + write_status "pending_approval" "Un jeton préparé et validé attend déjà une approbation manuelle." "$(<"${PENDING_NAME_FILE}")" + echo "GITEA_ROTATION_PENDING" + return 0 + fi + if ! rotation_due; then + write_status "current" "La rotation trimestrielle n’est pas encore requise." "$(cat "${CURRENT_NAME_FILE}" 2>/dev/null || true)" + echo "GITEA_ROTATION_NOT_DUE" + return 0 + fi + local current_token token_name pending_token + current_token="$(read_env_token)" + [[ -n "${current_token}" ]] || { write_status "failed" "Jeton actif absent du fichier .env serveur."; exit 1; } + validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe pas les contrôles Gitea."; exit 1; } + token_name="${TOKEN_PREFIX}-$(date -u +%Y%m%d-%H%M%S)" + pending_token="$(docker exec --user git "${GITEA_CONTAINER}" gitea admin user generate-access-token \ + --username "${GITEA_USER}" --token-name "${token_name}" --scopes all --raw)" + [[ -n "${pending_token}" ]] || { write_status "failed" "Gitea n’a pas généré de nouveau jeton." "${token_name}"; exit 1; } + printf '%s' "${pending_token}" > "${PENDING_TOKEN_FILE}" + printf '%s' "${token_name}" > "${PENDING_NAME_FILE}" + chmod 0600 "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}" + if ! validate_token "${pending_token}"; then + rm -f "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}" + write_status "failed" "Le nouveau jeton a été créé mais n’a pas passé les contrôles." "${token_name}" + exit 1 + fi + write_status "pending_approval" "Nouveau jeton créé et validé. Approbation manuelle requise avant basculement." "${token_name}" + echo "GITEA_ROTATION_PREPARED" +} + +apply_rotation() { + trap 'rm -f "${APPROVAL_FILE}"' EXIT + [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]] || { write_status "failed" "Aucun jeton préparé à appliquer."; exit 1; } + local pending_token pending_name current_token backup + pending_token="$(<"${PENDING_TOKEN_FILE}")" + pending_name="$(<"${PENDING_NAME_FILE}")" + current_token="$(read_env_token)" + validate_token "${pending_token}" || { write_status "failed" "Le jeton en attente n’est plus valide." "${pending_name}"; exit 1; } + validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe plus les contrôles préalables." "${pending_name}"; exit 1; } + backup="$(mktemp "${STATE_DIR}/dashboard.env.backup.XXXXXX")" + chmod 0600 "${backup}" + cp "${DASHBOARD_ENV_FILE}" "${backup}" + sed -i -E "s/^GITEA_TOKEN=.*/GITEA_TOKEN=${pending_token}/" "${DASHBOARD_ENV_FILE}" + if ! grep -q '^GITEA_TOKEN=' "${DASHBOARD_ENV_FILE}"; then + printf '\nGITEA_TOKEN=%s\n' "${pending_token}" >> "${DASHBOARD_ENV_FILE}" + fi + write_status "applying" "Basculement du dashboard vers le jeton validé." "${pending_name}" + if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null && sleep 8 && validate_token "${pending_token}"); then + cp "${backup}" "${DASHBOARD_ENV_FILE}" + (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true + rm -f "${backup}" + write_status "rollback" "Le basculement a échoué ; l’ancien jeton a été restauré." "${pending_name}" + exit 1 + fi + rm -f "${backup}" "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}" + printf '%s' "${pending_name}" > "${CURRENT_NAME_FILE}" + date -u +%Y-%m-%dT%H:%M:%SZ > "${LAST_ROTATION_FILE}" + chmod 0600 "${CURRENT_NAME_FILE}" "${LAST_ROTATION_FILE}" + write_status "active_revoke_previous" "Basculement validé. Révoquer manuellement l’ancien jeton dans Gitea après le contrôle final." "${pending_name}" + echo "GITEA_ROTATION_APPLIED" +} + +case "${COMMAND}" in + prepare) prepare_rotation ;; + apply) apply_rotation ;; + status) + if [[ -f "${STATUS_FILE}" ]]; then cat "${STATUS_FILE}"; else write_status "not_configured" "Aucune rotation n’a encore été préparée."; cat "${STATUS_FILE}"; fi + ;; + *) echo "Usage: $0 {prepare [--force]|apply|status}" >&2; exit 2 ;; +esac diff --git a/ops/gitea-token-rotation.timer b/ops/gitea-token-rotation.timer new file mode 100644 index 0000000..34e35b6 --- /dev/null +++ b/ops/gitea-token-rotation.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Préparation trimestrielle du jeton Gitea Santinova + +[Timer] +OnCalendar=*-01,04,07,10-01 07:00:00 +Persistent=true +RandomizedDelaySec=30m +Unit=gitea-token-rotation.service + +[Install] +WantedBy=timers.target diff --git a/ops/install-gitea-token-rotation.sh b/ops/install-gitea-token-rotation.sh new file mode 100644 index 0000000..571a59e --- /dev/null +++ b/ops/install-gitea-token-rotation.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +install -D -m 0700 "${SCRIPT_DIR}/gitea-token-rotation.sh" /opt/manus-deploy/scripts/gitea-token-rotation.sh +install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.service" /etc/systemd/system/gitea-token-rotation.service +install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.timer" /etc/systemd/system/gitea-token-rotation.timer +install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.service" /etc/systemd/system/gitea-token-rotation-apply.service +install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.path" /etc/systemd/system/gitea-token-rotation-apply.path +install -d -m 0700 /opt/manus-deploy/secrets/gitea-token-rotation +install -d -m 0750 /var/log/manus-deploy +systemctl daemon-reload +systemctl enable --now gitea-token-rotation.timer +systemctl enable --now gitea-token-rotation-apply.path +systemctl is-enabled gitea-token-rotation.timer +systemctl is-enabled gitea-token-rotation-apply.path +systemctl list-timers gitea-token-rotation.timer --no-pager diff --git a/ops/provision-dashboard-alert-token.sh b/ops/provision-dashboard-alert-token.sh new file mode 100644 index 0000000..0bbee60 --- /dev/null +++ b/ops/provision-dashboard-alert-token.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}" +readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}" +readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}" +readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}" + +backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)" +temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)" +chmod 0600 "${backup}" "${temporary}" +cp "${DASHBOARD_ENV_FILE}" "${backup}" + +cleanup() { + rm -f "${backup}" "${temporary}" + unset token result +} +trap cleanup EXIT + +result="$(docker exec "${REFERENTIEL_CONTAINER}" sh -lc 'cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts prepare')" +token="${result%%$'\t'*}" +token_id="${result##*$'\t'}" +[[ -n "${token}" && "${token_id}" =~ ^[0-9]+$ ]] || { echo "Le Référentiel IA n’a pas retourné un jeton exploitable." >&2; exit 1; } + +awk -v value="${token}" ' + BEGIN { replaced = 0 } + /^IA_REFERENTIEL_TOKEN=/ { print "IA_REFERENTIEL_TOKEN=" value; replaced = 1; next } + { print } + END { if (!replaced) print "IA_REFERENTIEL_TOKEN=" value } +' "${DASHBOARD_ENV_FILE}" > "${temporary}" +mv "${temporary}" "${DASHBOARD_ENV_FILE}" +chmod 0600 "${DASHBOARD_ENV_FILE}" + +if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null); then + cp "${backup}" "${DASHBOARD_ENV_FILE}" + exit 1 +fi +sleep 8 +health="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${token}" "${REFERENTIEL_URL}/api/integration/v1/alerts/ci/health")" +if [[ "${health}" != "204" ]]; then + cp "${backup}" "${DASHBOARD_ENV_FILE}" + (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true + echo "Le nouveau jeton d’alerte a échoué au contrôle HTTP (${health}); rollback effectué." >&2 + exit 1 +fi + +docker exec "${REFERENTIEL_CONTAINER}" sh -lc "cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts finalize ${token_id}" >/dev/null +echo "DASHBOARD_ALERT_TOKEN_PROVISIONED" diff --git a/ops/setup-gitea-webhooks.py b/ops/setup-gitea-webhooks.py new file mode 100644 index 0000000..b6ddbe1 --- /dev/null +++ b/ops/setup-gitea-webhooks.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +import json +import os +import secrets +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + + +GITEA_URL = os.environ.get("GITEA_URL", "https://git.recette.santinova-soft.org").rstrip("/") +GITEA_USER = os.environ.get("GITEA_USERNAME", "manus-admin") +DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "https://dashboard.recette.santinova-soft.org").rstrip("/") +DASHBOARD_APP_DIR = Path(os.environ.get("DASHBOARD_APP_DIR", "/opt/manus-deploy/apps/manus-dashboard")) +ENV_FILE = DASHBOARD_APP_DIR / ".env" +WEBHOOK_ENDPOINT = f"{DASHBOARD_URL}/api/webhook/gitea" + + +def read_env() -> dict[str, str]: + values: dict[str, str] = {} + if ENV_FILE.exists(): + for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): + if line and not line.lstrip().startswith("#") and "=" in line: + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def update_env(key: str, value: str) -> None: + lines = ENV_FILE.read_text(encoding="utf-8").splitlines() if ENV_FILE.exists() else [] + updated: list[str] = [] + found = False + for line in lines: + if line.startswith(f"{key}="): + updated.append(f"{key}={value}") + found = True + else: + updated.append(line) + if not found: + updated.append(f"{key}={value}") + temporary = ENV_FILE.with_suffix(".env.next") + temporary.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8") + temporary.chmod(0o600) + temporary.replace(ENV_FILE) + + +def api(token: str, method: str, path: str, payload: dict | None = None): + data = json.dumps(payload).encode() if payload is not None else None + request = urllib.request.Request( + f"{GITEA_URL}/api/v1{path}", + method=method, + data=data, + headers={"Authorization": f"token {token}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + body = response.read() + return json.loads(body) if body else None + except urllib.error.HTTPError as error: + detail = error.read().decode(errors="replace") + raise RuntimeError(f"Gitea {method} {path} : HTTP {error.code} {detail[:300]}") from error + + +def ensure_dashboard_secret(values: dict[str, str]) -> tuple[dict[str, str], bool]: + if values.get("WEBHOOK_SECRET"): + return values, False + update_env("WEBHOOK_SECRET", secrets.token_hex(32)) + return read_env(), True + + +def main() -> None: + values, generated = ensure_dashboard_secret(read_env()) + token = values.get("GITEA_TOKEN", "") + secret = values.get("WEBHOOK_SECRET", "") + if not token or not secret: + raise RuntimeError("GITEA_TOKEN et WEBHOOK_SECRET sont requis dans le .env serveur.") + if generated: + subprocess.run(["docker", "compose", "up", "-d", "--force-recreate", "dashboard"], cwd=DASHBOARD_APP_DIR, check=True) + time.sleep(8) + identity = api(token, "GET", "/user") + if identity.get("login") != GITEA_USER: + raise RuntimeError("Le jeton Gitea n’appartient pas au compte attendu.") + repositories = api(token, "GET", "/user/repos?limit=100&page=1") or [] + configured = 0 + for repository in repositories: + owner = repository.get("owner", {}).get("login") + name = repository.get("name") + if owner != GITEA_USER or not name: + continue + hooks = api(token, "GET", f"/repos/{owner}/{name}/hooks") or [] + existing = next((hook for hook in hooks if hook.get("config", {}).get("url") == WEBHOOK_ENDPOINT), None) + payload = { + "active": True, + "branch_filter": "main", + "config": {"url": WEBHOOK_ENDPOINT, "content_type": "json", "secret": secret}, + "events": ["push", "workflow_run"], + } + if existing: + api(token, "PATCH", f"/repos/{owner}/{name}/hooks/{existing['id']}", payload) + else: + api(token, "POST", f"/repos/{owner}/{name}/hooks", {"type": "gitea", **payload}) + configured += 1 + print(f"WEBHOOK_OK {owner}/{name}") + if configured == 0: + raise RuntimeError("Aucun dépôt Gitea n’a été configuré.") + print(f"GITEA_WEBHOOKS_CONFIGURED {configured}") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"GITEA_WEBHOOK_SETUP_FAILED: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/src/backend/package.json b/src/backend/package.json index fb8dfd6..1ead78e 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js" + "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js" }, "dependencies": { "express": "^4.21.0", diff --git a/src/backend/src/app-creator.js b/src/backend/src/app-creator.js index c388d66..06d44a5 100644 --- a/src/backend/src/app-creator.js +++ b/src/backend/src/app-creator.js @@ -1,11 +1,40 @@ const fs = require('fs'); const fsp = require('fs/promises'); +const os = require('os'); const path = require('path'); const { exec, execSync } = require('child_process'); const config = require('./config'); const { createRepo, giteaClient } = require('./gitea'); const { refreshApps, writeAppManifest } = require('./app-registry'); +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + +async function createGiteaGitAuth() { + if (!config.gitea.token) throw new Error('GITEA_TOKEN est requis pour les opérations Git.'); + const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'dashboard-gitea-auth-')); + const tokenPath = path.join(directory, 'token'); + const askpassPath = path.join(directory, 'askpass.sh'); + await fsp.writeFile(tokenPath, config.gitea.token, { mode: 0o600 }); + await fsp.writeFile(askpassPath, `#!/bin/sh\ncase "$1" in\n *Username*|*username*) printf '%s\\n' '${config.gitea.username}' ;;\n *) cat '${tokenPath}' ;;\nesac\n`, { mode: 0o700 }); + return { + env: { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: '0' }, + cleanup: () => fsp.rm(directory, { recursive: true, force: true }), + }; +} + +function normalizeGiteaRepoUrl(value) { + const url = new URL(value); + const configured = new URL(config.gitea.url); + if (url.protocol !== 'https:' || url.hostname !== configured.hostname) { + throw new Error('Le dépôt doit utiliser le serveur Gitea Santinova configuré en HTTPS.'); + } + url.username = ''; + url.password = ''; + return url.toString(); +} + // ============ TEMPLATES DOCKERFILE ============ const DOCKERFILE_TEMPLATES = { @@ -436,6 +465,7 @@ async function createApplication(params, logCallback) { const domain = `${subdomain}.recette.santinova-soft.org`; const appDir = path.join(config.appsBasePath, appId); const srcDir = path.join(appDir, 'src'); + let gitAuth = null; const log = (msg) => { const line = `[${new Date().toISOString()}] ${msg}`; @@ -444,6 +474,7 @@ async function createApplication(params, logCallback) { }; try { + gitAuth = await createGiteaGitAuth(); // 1. Créer le répertoire de l'application log(`Création du répertoire ${appDir}...`); await fsp.mkdir(appDir, { recursive: true }); @@ -455,11 +486,8 @@ async function createApplication(params, logCallback) { if (sourceType === 'gitea') { // 2a. Cloner depuis un dépôt Gitea existant log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`); - const cloneUrl = giteaRepoUrl.replace( - 'https://', - `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@` - ); - await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000); + const cloneUrl = normalizeGiteaRepoUrl(giteaRepoUrl); + await execCommand(`cd ${shellQuote(appDir)} && rm -rf src && git clone ${shellQuote(cloneUrl)} src`, 120000, gitAuth.env); log('Clonage terminé.'); // Extraire le nom du repo depuis l'URL @@ -503,10 +531,11 @@ async function createApplication(params, logCallback) { // Initialiser git et pousser le code log('Initialisation Git et push vers Gitea...'); - const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`; + const gitUrl = `${config.gitea.url.replace(/\/$/, '')}/${config.gitea.username}/${appId}.git`; await execCommand( - `cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`, - 120000 + `cd ${shellQuote(srcDir)} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m ${shellQuote(`Initial commit: ${name}`)} && git remote add origin ${shellQuote(gitUrl)} && git push -u origin main --force`, + 120000, + gitAuth.env ); log('Code poussé sur Gitea.'); } @@ -548,7 +577,8 @@ async function createApplication(params, logCallback) { if (sourceType === 'zip') { await execCommand( `cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`, - 60000 + 60000, + gitAuth.env ); log('Dockerfile commité et poussé sur Gitea.'); } @@ -642,12 +672,14 @@ async function createApplication(params, logCallback) { } catch (err) { log(`ERREUR: ${err.message}`); throw err; + } finally { + await gitAuth?.cleanup(); } } -function execCommand(cmd, timeout = 60000) { +function execCommand(cmd, timeout = 60000, env = process.env) { return new Promise((resolve, reject) => { - exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => { + exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout, env }, (error, stdout, stderr) => { if (error) { reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`)); } else { diff --git a/src/backend/src/ci-alerts.js b/src/backend/src/ci-alerts.js new file mode 100644 index 0000000..a48b153 --- /dev/null +++ b/src/backend/src/ci-alerts.js @@ -0,0 +1,82 @@ +const fs = require('fs'); +const path = require('path'); +const config = require('./config'); + +const LOG_DIR = '/var/log/manus-deploy'; +const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json'); +const MAX_ALERTS = 100; + +function normalizeWorkflowFailure(payload, deliveryId = null) { + if (payload?.action !== 'completed') return null; + const run = payload?.workflow_run || {}; + const conclusion = String(run.conclusion || '').toLowerCase(); + if (!conclusion || conclusion === 'success') return null; + const repository = payload?.repository?.full_name || payload?.repository?.name || 'dépôt inconnu'; + const commit = run.head_sha || run.head_commit?.id || 'commit-inconnu'; + return { + id: deliveryId || `${repository}-${run.id || commit}-${run.completed_at || Date.now()}`, + repository, + workflow: payload?.workflow?.name || run.name || run.workflow_id || 'Validation CI', + commit, + conclusion, + url: run.html_url || null, + occurredAt: run.completed_at || new Date().toISOString(), + receivedAt: new Date().toISOString(), + dsiNotificationSent: false, + }; +} + +function readCiAlerts() { + try { + const value = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8')); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +function saveCiAlert(alert) { + fs.mkdirSync(LOG_DIR, { recursive: true }); + const alerts = readCiAlerts().filter((candidate) => candidate.id !== alert.id); + alerts.unshift(alert); + const temporary = `${ALERTS_FILE}.${process.pid}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(alerts.slice(0, MAX_ALERTS), null, 2), { mode: 0o640 }); + fs.renameSync(temporary, ALERTS_FILE); + return alert; +} + +async function notifyDsi(alert) { + if (!config.dsiAlerts.token) { + console.error('[CI Alert] IA_REFERENTIEL_TOKEN absent : notification DSI non envoyée.'); + return false; + } + try { + const response = await fetch(`${config.dsiAlerts.url.replace(/\/$/, '')}/api/integration/v1/alerts/ci`, { + method: 'POST', + headers: { + authorization: `Bearer ${config.dsiAlerts.token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + repository: alert.repository, + workflow: alert.workflow, + commit: alert.commit, + conclusion: alert.conclusion, + url: alert.url, + occurredAt: alert.occurredAt, + deliveryId: alert.id, + }), + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + console.error(`[CI Alert] Notification DSI refusée (${response.status}).`); + return false; + } + return true; + } catch (error) { + console.error('[CI Alert] Notification DSI impossible:', error.message); + return false; + } +} + +module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi }; diff --git a/src/backend/src/config.js b/src/backend/src/config.js index 747d129..3725514 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -25,8 +25,14 @@ module.exports = { gitea: { url: process.env.GITEA_URL || 'http://gitea:3000', username: process.env.GITEA_USERNAME || 'manus-admin', - password: process.env.GITEA_PASSWORD || 'Itinova69!', token: process.env.GITEA_TOKEN || null, + productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org', + productionToken: process.env.GITEA_TOKEN_PROD || null, + }, + + dsiAlerts: { + url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org', + token: process.env.IA_REFERENTIEL_TOKEN || null, }, appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps', diff --git a/src/backend/src/gitea.js b/src/backend/src/gitea.js index 52b1d2f..2c2a0fc 100644 --- a/src/backend/src/gitea.js +++ b/src/backend/src/gitea.js @@ -2,18 +2,9 @@ const axios = require('axios'); const https = require('https'); const config = require('./config'); -// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire) -// Utiliser le token API si disponible, sinon auth basique -const giteaClientHeaders = config.gitea.token - ? { 'Authorization': `token ${config.gitea.token}` } - : {}; - const giteaClient = axios.create({ baseURL: `${config.gitea.url}/api/v1`, - ...(config.gitea.token - ? { headers: giteaClientHeaders } - : { auth: { username: config.gitea.username, password: config.gitea.password } } - ), + headers: config.gitea.token ? { Authorization: `token ${config.gitea.token}` } : {}, httpsAgent: new https.Agent({ rejectUnauthorized: false }), timeout: 10000, }); @@ -155,6 +146,35 @@ async function getWorkflowRunSummary(owner, repo) { return calculateCiSummary(await getWorkflowRuns(owner, repo, 30)); } +async function getRepoSnapshot(baseUrl, owner, repo, token, publicBaseUrl = baseUrl) { + if (!token) return { present: false, url: null, version: null }; + const client = axios.create({ + baseURL: `${baseUrl}/api/v1`, + headers: { Authorization: `token ${token}` }, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + timeout: 7000, + }); + try { + const [repoResponse, commitsResponse] = await Promise.all([ + client.get(`/repos/${owner}/${repo}`), + client.get(`/repos/${owner}/${repo}/commits`, { params: { limit: 1 } }), + ]); + const commit = commitsResponse.data?.[0]; + const sha = commit?.sha?.substring(0, 7) || null; + const date = commit?.commit?.author?.date + ? new Date(commit.commit.author.date).toLocaleDateString('fr-FR') + : null; + return { + present: Boolean(repoResponse.data?.id), + url: `${publicBaseUrl}/${owner}/${repo}`, + version: sha && date ? `${sha} (${date})` : sha, + }; + } catch (error) { + console.error(`Erreur Gitea getRepoSnapshot ${owner}/${repo}:`, error.message); + return { present: false, url: null, version: null }; + } +} + /** * Récupérer tous les dépôts (avec retry en cas d'erreur DNS) */ @@ -201,6 +221,7 @@ module.exports = { getWorkflowRuns, getWorkflowRunSummary, getWorkflowRunForCommit, + getRepoSnapshot, normalizeWorkflowRun, calculateCiSummary, listRepos, diff --git a/src/backend/src/index.js b/src/backend/src/index.js index b8c951e..2c30bef 100644 --- a/src/backend/src/index.js +++ b/src/backend/src/index.js @@ -99,6 +99,9 @@ async function runHealthChecks() { // Démarrage async function start() { + if (!config.gitea.token) { + throw new Error('GITEA_TOKEN est requis : aucun retour à une authentification par mot de passe n’est autorisé.'); + } await initAuth(); // Charger les applications dynamiques diff --git a/src/backend/src/routes.js b/src/backend/src/routes.js index 7cfd592..81ad828 100644 --- a/src/backend/src/routes.js +++ b/src/backend/src/routes.js @@ -2,11 +2,13 @@ const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); +const { execFile } = require('child_process'); const { authenticate, authMiddleware } = require('./auth'); const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker'); -const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea'); +const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, getRepoSnapshot, listRepos } = require('./gitea'); const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck'); const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator'); +const { readCiAlerts } = require('./ci-alerts'); const config = require('./config'); // Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent) @@ -541,17 +543,63 @@ router.get('/system/metrics', authMiddleware, async (req, res) => { } }); +router.get('/ci-alerts', authMiddleware, (req, res) => { + res.json(readCiAlerts()); +}); + +const GITEA_ROTATION_SCRIPT = '/opt/manus-deploy/scripts/gitea-token-rotation.sh'; +const GITEA_ROTATION_STATUS_FILE = '/var/log/manus-deploy/gitea-token-rotation.json'; +const GITEA_ROTATION_APPROVAL_FILE = '/opt/manus-deploy/secrets/gitea-token-rotation/approval.request'; + +function readGiteaRotationStatus() { + try { + return JSON.parse(fs.readFileSync(GITEA_ROTATION_STATUS_FILE, 'utf8')); + } catch { + return { state: 'not_configured', message: 'Aucune rotation n’a encore été préparée.', tokenName: '', updatedAt: null }; + } +} + +function runGiteaRotation(args) { + return new Promise((resolve, reject) => { + execFile(GITEA_ROTATION_SCRIPT, args, { timeout: 120000 }, (error, stdout, stderr) => { + if (error) return reject(new Error((stderr || stdout || error.message).trim())); + resolve(stdout.trim()); + }); + }); +} + +router.get('/security/gitea-token-rotation', authMiddleware, (req, res) => { + res.json(readGiteaRotationStatus()); +}); + +router.post('/security/gitea-token-rotation/prepare', authMiddleware, async (req, res) => { + try { + await runGiteaRotation(['prepare', '--force']); + res.json(readGiteaRotationStatus()); + } catch (error) { + res.status(500).json({ error: error.message, status: readGiteaRotationStatus() }); + } +}); + +router.post('/security/gitea-token-rotation/approve', authMiddleware, (req, res) => { + const status = readGiteaRotationStatus(); + if (status.state !== 'pending_approval') { + return res.status(409).json({ error: 'Aucun jeton validé n’attend une approbation.', status }); + } + fs.mkdirSync(path.dirname(GITEA_ROTATION_APPROVAL_FILE), { recursive: true, mode: 0o700 }); + fs.writeFileSync(GITEA_ROTATION_APPROVAL_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 }); + res.status(202).json({ ...status, state: 'approval_requested', message: 'Basculement demandé à l’hôte. Le dashboard sera brièvement redémarré après validation.' }); +}); + module.exports = router; // ===== INVENTAIRE DES APPLICATIONS ===== -const { exec } = require('child_process'); const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000'; const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org'; -const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org'; +const GITEA_PROD_EXTERNAL = config.gitea.productionUrl; const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin'; -const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!'; -const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!'; const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null; +const GITEA_TOKEN_PRD = config.gitea.productionToken; // INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json) function getInventoryApps() { @@ -562,41 +610,6 @@ function getInventoryApps() { })); } -function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) { - return new Promise((resolve) => { - const authHeader = token - ? `-H "Authorization: token ${token}"` - : `-u "${GITEA_USER_INV}:${pass}"`; - const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`; - exec(cmd, { timeout: 7000 }, (err, stdout) => { - if (err || !stdout) return resolve({ present: false, url: null, version: null }); - try { - const data = JSON.parse(stdout); - if (!data.id) return resolve({ present: false, url: null, version: null }); - // Récupérer le dernier commit - const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`; - exec(cmd2, { timeout: 7000 }, (err2, stdout2) => { - let version = null; - try { - const commits = JSON.parse(stdout2 || '[]'); - if (commits.length > 0) { - const c = commits[0]; - const sha = c.sha ? c.sha.substring(0, 7) : null; - const date = c.commit && c.commit.author && c.commit.author.date - ? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null; - version = sha && date ? `${sha} (${date})` : sha; - } - } catch(e) {} - const displayUrl = publicBaseUrl || baseUrl; - resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version }); - }); - } catch(e) { - resolve({ present: false, url: null, version: null }); - } - }); - }); -} - router.get('/inventory', authMiddleware, async (req, res) => { try { const owner = GITEA_USER_INV; @@ -604,8 +617,8 @@ router.get('/inventory', authMiddleware, async (req, res) => { const results = await Promise.all( INVENTORY_APPS.map(async (app) => { const [repoRecette, repoProd] = await Promise.all([ - curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC), - curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD), + getRepoSnapshot(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_TOKEN_REC, GITEA_RECETTE_PUBLIC), + getRepoSnapshot(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_TOKEN_PRD), ]); return { ...app, repoRecette, repoProd }; }) diff --git a/src/backend/src/webhook.js b/src/backend/src/webhook.js index 7559a06..b50b2ec 100644 --- a/src/backend/src/webhook.js +++ b/src/backend/src/webhook.js @@ -11,6 +11,7 @@ const fs = require('fs'); const config = require('./config'); const { findAppByRepo } = require('./app-registry'); const { getWorkflowRunForCommit } = require('./gitea'); +const { normalizeWorkflowFailure, notifyDsi, saveCiAlert } = require('./ci-alerts'); const router = express.Router(); @@ -160,6 +161,22 @@ router.post('/gitea', (req, res) => { return res.status(401).json({ error: 'Signature invalide' }); } const event = req.headers['x-gitea-event'] || 'unknown'; + if (event === 'workflow_run') { + const alert = normalizeWorkflowFailure(req.body, req.headers['x-gitea-delivery'] || null); + if (!alert) return res.status(200).json({ message: 'Workflow sans échec ignoré' }); + saveCiAlert(alert); + global.wsBroadcast?.({ type: 'ci_alert', data: alert }); + res.status(202).json({ message: 'Alerte CI enregistrée', id: alert.id }); + setImmediate(async () => { + const sent = await notifyDsi(alert); + if (sent) { + const notifiedAlert = { ...alert, dsiNotificationSent: true }; + saveCiAlert(notifiedAlert); + global.wsBroadcast?.({ type: 'ci_alert', data: notifiedAlert }); + } + }); + return; + } if (event !== 'push') { return res.status(200).json({ message: `Événement ${event} ignoré` }); } diff --git a/src/backend/test/ci-alerts.test.js b/src/backend/test/ci-alerts.test.js new file mode 100644 index 0000000..3c11bf7 --- /dev/null +++ b/src/backend/test/ci-alerts.test.js @@ -0,0 +1,22 @@ +const assert = require('assert'); +const { normalizeWorkflowFailure } = require('../src/ci-alerts'); + +const failure = normalizeWorkflowFailure({ + action: 'completed', + repository: { full_name: 'manus-admin/referentiel-ia' }, + workflow: { name: 'Validation CI' }, + workflow_run: { + id: 42, + conclusion: 'failure', + head_sha: '1234567890abcdef', + completed_at: '2026-08-21T12:00:00.000Z', + html_url: 'https://git.recette.santinova-soft.org/actions/runs/42', + }, +}, 'delivery-42'); + +assert.equal(failure.id, 'delivery-42'); +assert.equal(failure.repository, 'manus-admin/referentiel-ia'); +assert.equal(failure.conclusion, 'failure'); +assert.equal(normalizeWorkflowFailure({ action: 'completed', workflow_run: { conclusion: 'success' } }), null); +assert.equal(normalizeWorkflowFailure({ action: 'in_progress', workflow_run: { conclusion: 'failure' } }), null); +console.log('OK ci alerts'); diff --git a/src/backend/test/dashboard-alert-token.test.js b/src/backend/test/dashboard-alert-token.test.js new file mode 100644 index 0000000..0fd56af --- /dev/null +++ b/src/backend/test/dashboard-alert-token.test.js @@ -0,0 +1,13 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '../../..'); +const source = fs.readFileSync(path.join(root, 'ops/provision-dashboard-alert-token.sh'), 'utf8'); + +assert.match(source, /dashboard-alert-token\.ts prepare/); +assert.match(source, /alerts\/ci\/health/); +assert.match(source, /dashboard-alert-token\.ts finalize/); +assert.match(source, /rollback effectué/); +assert.doesNotMatch(source, /GITEA_PASSWORD/); +console.log('OK dashboard alert token'); diff --git a/src/backend/test/gitea-auth.test.js b/src/backend/test/gitea-auth.test.js new file mode 100644 index 0000000..d990992 --- /dev/null +++ b/src/backend/test/gitea-auth.test.js @@ -0,0 +1,23 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '../..'); +const files = [ + 'backend/src/config.js', + 'backend/src/gitea.js', + 'backend/src/app-creator.js', + 'backend/src/routes.js', + 'docker-compose.yml', +]; + +for (const relativePath of files) { + const content = fs.readFileSync(path.join(root, relativePath), 'utf8'); + assert.equal(content.includes('GITEA_PASSWORD'), false, `${relativePath} contient encore GITEA_PASSWORD`); + assert.equal(content.includes('config.gitea.password'), false, `${relativePath} utilise encore le mot de passe Gitea`); +} + +const compose = fs.readFileSync(path.resolve(root, '../docker-compose.yml'), 'utf8'); +assert.match(compose, /GITEA_TOKEN=\$\{GITEA_TOKEN:\?/); +assert.equal(/GITEA_TOKEN=[a-f0-9]{20,}/.test(compose), false); +console.log('OK gitea auth'); diff --git a/src/backend/test/token-rotation.test.js b/src/backend/test/token-rotation.test.js new file mode 100644 index 0000000..3f99f32 --- /dev/null +++ b/src/backend/test/token-rotation.test.js @@ -0,0 +1,19 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '../../..'); +const rotation = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.sh'), 'utf8'); +const timer = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.timer'), 'utf8'); +const approvalPath = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation-apply.path'), 'utf8'); + +assert.match(rotation, /generate-access-token/); +assert.match(rotation, /pending_approval/); +assert.match(rotation, /GITEA_ROTATION_APPLIED/); +assert.match(rotation, /chmod 0600/); +assert.match(rotation, /rollback/); +assert.doesNotMatch(rotation, /GITEA_PASSWORD/); +assert.match(timer, /OnCalendar=\*-01,04,07,10-01 07:00:00/); +assert.match(timer, /Persistent=true/); +assert.match(approvalPath, /PathExists=.*approval\.request/); +console.log('OK token rotation'); diff --git a/src/backend/test/webhook-setup.test.js b/src/backend/test/webhook-setup.test.js new file mode 100644 index 0000000..53c92f8 --- /dev/null +++ b/src/backend/test/webhook-setup.test.js @@ -0,0 +1,14 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '../../..'); +const setup = fs.readFileSync(path.join(root, 'ops/setup-gitea-webhooks.py'), 'utf8'); +const compose = fs.readFileSync(path.join(root, 'docker-compose.yml'), 'utf8'); + +assert.match(setup, /"events": \["push", "workflow_run"\]/); +assert.match(setup, /secrets\.token_hex\(32\)/); +assert.match(setup, /method=method/); +assert.doesNotMatch(setup, /GITEA_PASSWORD/); +assert.match(compose, /WEBHOOK_SECRET=\$\{WEBHOOK_SECRET:\?/); +console.log('OK webhook setup'); diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 9b5bd8a..3bb23f7 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -13,10 +13,12 @@ services: - ADMIN_PASSWORD=Itinova69! - GITEA_URL=https://git.recette.santinova-soft.org - GITEA_USERNAME=manus-admin - - GITEA_PASSWORD=ManusGitea2026! - - GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427 + - GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur} - GITEA_RECETTE_URL=http://gitea:3000 - - GITEA_PASSWORD_PROD=ManusGitea2026! + - GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-} + - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} + - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org} + - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} - APPS_BASE_PATH=/opt/manus-deploy/apps - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure - HEALTH_CHECK_INTERVAL=30000 diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx index dcbd001..d6b2a62 100644 --- a/src/frontend/src/App.jsx +++ b/src/frontend/src/App.jsx @@ -10,13 +10,15 @@ import DocumentationPage from './pages/DocumentationPage'; import InventairePage from './pages/InventairePage'; import TerminalPage from './pages/TerminalPage'; import Sidebar from './components/Sidebar'; +import CiAlertBanner from './components/CiAlertBanner'; import useWebSocket from './hooks/useWebSocket'; -import { getMe, getApps, logout as apiLogout } from './utils/api'; +import { getMe, getApps, getCiAlerts, logout as apiLogout } from './utils/api'; export default function App() { const [authenticated, setAuthenticated] = useState(false); const [loading, setLoading] = useState(true); const [currentPage, setCurrentPage] = useState('dashboard'); const [apps, setApps] = useState([]); + const [ciAlerts, setCiAlerts] = useState([]); // Vérifier l'authentification au chargement useEffect(() => { const token = localStorage.getItem('dashboard_token'); @@ -25,6 +27,7 @@ export default function App() { .then(() => { setAuthenticated(true); fetchApps(); + fetchCiAlerts(); }) .catch(() => { localStorage.removeItem('dashboard_token'); @@ -43,11 +46,22 @@ export default function App() { console.error('Erreur chargement apps:', err); } }; + const fetchCiAlerts = async () => { + try { + const response = await getCiAlerts(); + setCiAlerts(response.data); + } catch (error) { + console.error('Erreur chargement alertes CI:', error); + } + }; // WebSocket handler const handleWsMessage = useCallback((data) => { if (data.type === 'health_update') { setApps(data.data); } + if (data.type === 'ci_alert') { + setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]); + } }, []); const { connected: wsConnected } = useWebSocket( authenticated ? handleWsMessage : null @@ -55,6 +69,7 @@ export default function App() { const handleLogin = (data) => { setAuthenticated(true); fetchApps(); + fetchCiAlerts(); }; const handleLogout = async () => { try { @@ -65,6 +80,7 @@ export default function App() { localStorage.removeItem('dashboard_token'); setAuthenticated(false); setApps([]); + setCiAlerts([]); }; if (loading) { return ( @@ -126,7 +142,10 @@ export default function App() { onLogout={handleLogout} wsConnected={wsConnected} /> -
{renderPage()}
+
+ + {renderPage()} +
); } diff --git a/src/frontend/src/components/CiAlertBanner.jsx b/src/frontend/src/components/CiAlertBanner.jsx new file mode 100644 index 0000000..dcb9e32 --- /dev/null +++ b/src/frontend/src/components/CiAlertBanner.jsx @@ -0,0 +1,24 @@ +import { AlertTriangle, ExternalLink } from 'lucide-react'; + +export default function CiAlertBanner({ alert }) { + if (!alert) return null; + return ( +
+
+
+ +
+

Échec CI — {alert.repository}

+

{alert.workflow} · {alert.commit?.slice(0, 12)} · {alert.conclusion}

+

Notification DSI : {alert.dsiNotificationSent ? 'envoyée' : 'en attente ou indisponible'}

+
+
+ {alert.url && ( + + Voir le workflow + + )} +
+
+ ); +} diff --git a/src/frontend/src/pages/GiteaPage.jsx b/src/frontend/src/pages/GiteaPage.jsx index 6d69c57..162c29c 100644 --- a/src/frontend/src/pages/GiteaPage.jsx +++ b/src/frontend/src/pages/GiteaPage.jsx @@ -15,8 +15,10 @@ import { TrendingDown, TrendingUp, Minus, + KeyRound, + ShieldCheck, } from 'lucide-react'; -import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api'; +import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api'; function formatDate(dateStr) { if (!dateStr) return 'N/A'; @@ -54,6 +56,34 @@ export default function GiteaPage() { const [workflowRuns, setWorkflowRuns] = useState([]); const [workflowSummary, setWorkflowSummary] = useState(null); const [loadingCommits, setLoadingCommits] = useState(false); + const [rotation, setRotation] = useState(null); + const [rotationBusy, setRotationBusy] = useState(false); + const [rotationError, setRotationError] = useState(''); + + const fetchRotation = async () => { + try { + const response = await getGiteaTokenRotation(); + setRotation(response.data); + } catch (error) { + setRotationError(error.response?.data?.error || 'Statut de rotation indisponible.'); + } + }; + + const runRotationAction = async (action) => { + setRotationBusy(true); + setRotationError(''); + try { + const response = action === 'prepare' + ? await prepareGiteaTokenRotation() + : await approveGiteaTokenRotation(); + setRotation(response.data); + } catch (error) { + setRotationError(error.response?.data?.error || 'Opération de rotation impossible.'); + if (error.response?.data?.status) setRotation(error.response.data.status); + } finally { + setRotationBusy(false); + } + }; const fetchRepos = async () => { setLoading(true); @@ -92,6 +122,7 @@ export default function GiteaPage() { useEffect(() => { fetchRepos(); + fetchRotation(); }, []); return ( @@ -124,6 +155,31 @@ export default function GiteaPage() { +
+
+
+
+
+

Rotation contrôlée du jeton Gitea

+

{rotation?.message || 'Chargement du statut…'}

+ {rotation?.tokenName &&

{rotation.tokenName}

} + {rotation?.updatedAt &&

Mis à jour : {formatDate(rotation.updatedAt)}

} + {rotationError &&

{rotationError}

} +
+
+
+ + {rotation?.state === 'pending_approval' && ( + + )} +
+
+
+
{/* Repos List */}
diff --git a/src/frontend/src/utils/api.js b/src/frontend/src/utils/api.js index 482bf54..3af2dc0 100644 --- a/src/frontend/src/utils/api.js +++ b/src/frontend/src/utils/api.js @@ -54,6 +54,7 @@ export const createApp = (formData) => // Deployments export const getDeployments = (appId) => api.get('/deployments', { params: { appId } }); +export const getCiAlerts = () => api.get('/ci-alerts'); // Gitea export const getGiteaRepos = () => api.get('/gitea/repos'); @@ -63,6 +64,9 @@ export const getGiteaWorkflowRuns = (owner, repo) => api.get(`/gitea/repos/${owner}/${repo}/actions/runs`); export const getGiteaWorkflowSummary = (owner, repo) => api.get(`/gitea/repos/${owner}/${repo}/actions/summary`); +export const getGiteaTokenRotation = () => api.get('/security/gitea-token-rotation'); +export const prepareGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/prepare'); +export const approveGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/approve'); // Docker export const getContainers = () => api.get('/docker/containers');