#!/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