Commit 618b1b97 authored by Kourser's avatar Kourser
Browse files

L'installateur protégeait le fichier, pas l'état

Un installateur qui demande l'autorisation avant d'écraser un .env donne
l'impression d'être prudent. Il ne l'était qu'à moitié : ce qu'il faut
protéger n'est pas le fichier, c'est ce que la machine porte déjà. Quatre
défauts, dont deux qui cassent une instance en service.

Le volume, qu'il ne regardait pas
  POSTGRES_PASSWORD n'est lu qu'une fois dans la vie d'une base, par initdb,
  sur un répertoire vide. Ensuite Postgres garde le sien et ignore la
  variable. Tirer un secret neuf devant un volume déjà peuplé ne le change
  donc pas : l'application démarre, se présente avec un mot de passe que la
  base ne connaît pas, et tombe sur « password authentication failed ».

  Deux chemins y menaient, et aucun ne prévenait — la porte « écrire un .env
  neuf », et le cas sans .env du tout, où le script n'a jamais rien demandé.
  Le second est le pire : le mot de passe de la base n'est alors écrit nulle
  part sur la machine.

  Le volume est maintenant détecté avant toute écriture. S'il existe et qu'un
  ancien fichier est là, le script propose de reprendre utilisateur, base et
  mot de passe — les quatre autres secrets tournent normalement, et le
  message final le dit plutôt que d'annoncer cinq tirages. S'il n'y a pas
  d'ancien fichier, il s'arrête : retrouver le mot de passe, réaligner la
  base ou la supprimer sont trois décisions d'exploitation, et deviner n'en
  est pas une. Passer outre reste possible, avec la marche à suivre pour
  réaligner la base ensuite.

Le port, qu'il ne confrontait à rien
  La question « Port d'écoute » ne pose que celui de l'application. Ceux de
  LiveKit, Postgres, Redis et MinIO sont écrits dans la composition, et
  répondre 7881 les heurtait sans un mot — le conflit se découvrait à la
  levée, sous un « address already in use » qui ne nomme pas le coupable.

  Les réservations sont désormais relues dans docker/compose.yaml plutôt que
  recopiées dans le script : une ligne ajoutée là-bas serait sinon ignorée
  ici. Un port réservé est refusé sur place, plages comprises. Un port déjà
  tenu sur la machine n'est qu'un avertissement — et le script se tait si
  c'est un conteneur de ce projet, parce qu'une instance en service tient
  légitimement son port et que s'en alarmer ferait crier à chaque
  réinstallation. Le contrôle vaut aussi pour un .env conservé, qui n'était
  relu par personne.

Les sauvegardes, que git suivait
  .gitignore excluait .env, pas les .env.sauvegarde-* que ce script écrit.
  Elles portent les secrets vivants de l'instance précédente, en 600 mais
  parfaitement indexables : un git add -A les mettait en soute. L'en-tête du
  script promettait déjà que .gitignore les excluait — c'est vrai maintenant.

Les clés FCM, qu'il ne connaissait pas
  .env.example a gagné FCM_PROJET, FCM_COMPTE et FCM_CLE avec le relais
  Android ; le script était resté en arrière et écrivait un .env amputé. Son
  propre contrôle de dérive le signalait, ce qui est mieux que rien, mais
  laissait le travail à faire à la main.

Contrôlé
  Sur une copie isolée, avec un volume leurre créé puis supprimé : passe
  neuve, reprise des identifiants Postgres, refus devant une base sans .env,
  outrepassement, ports réservés simples et en plage, occupation par un
  projet étranger et par le sien, .env conservé portant un port réservé.
  sh -n valide.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 26cbb77f
Loading
Loading
Loading
Loading
Loading
+3 −0
Changes for .gitignore: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,9 @@ node_modules/
dist/
.env
.env.local
# Les sauvegardes qu'écrit outils/installer.sh portent les secrets
# vivants de l'instance précédente : elles ne doivent pas être suivies.
.env.sauvegarde-*
*.log
.DS_Store
coverage/
+246 −3
Changes for outils/installer.sh: 246 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -21,6 +21,16 @@
#   outils/preparer-env.mjs. Les deux outils suivent la même règle — aucune
#   valeur d'exemple ne survit, et rien n'est écrit en clair sur le terminal.
#
# Ce qu'il vérifie avant d'écrire
#   Que le port demandé ne heurte ni ceux que la composition réserve, ni ce qui
#   écoute déjà sur la machine. Le premier cas est refusé, le second demande
#   confirmation — une instance en service tient légitimement son port.
#
#   Qu'aucun volume Postgres ne traîne déjà. Le mot de passe d'une base n'est
#   lu qu'à sa création : en tirer un neuf devant un volume existant donne une
#   application qui ne se connecte plus. Le fichier était protégé, l'état ne
#   l'était pas.
#
# Ce que ce script ne fait pas
#   Il ne touche jamais un .env existant sans le dire, et il ne demande aucun
#   secret venu de l'extérieur. La clé APNS d'Apple, notamment, se colle à la
@@ -136,12 +146,102 @@ valeur_env() { # valeur_env <cle>
  sed -n "s/^$1=//p" .env | tail -n 1
}

valeur_fichier() {   # valeur_fichier <fichier> <cle> — idem, hors du .env courant
  sed -n "s/^$2=//p" "$1" | tail -n 1
}

# ── Ports ──────────────────────────────────────────────────────────────────
#
# La composition réserve une poignée de ports côté hôte — LiveKit en prend la
# plus grande part — et ceux-là ne se négocient pas depuis ce script. Ils sont
# relus dans le fichier plutôt que recopiés ici : une ligne ajoutée là-bas
# serait sinon ignorée, et le conflit ne se découvrirait qu'à la levée, sous un
# « address already in use » qui ne nomme pas le coupable.

ports_composition() {
  sed -n 's/^[[:space:]]*-[[:space:]]*"\([^"]*\)".*$/\1/p' "$COMPOSE" |
  awk -F: '
    /\$\{/ { next }
    {
      sub(/\/(tcp|udp)$/, "", $NF)
      if (NF == 3) hote = $2
      else if (NF == 2) hote = $1
      else next
      if (hote ~ /^[0-9]+(-[0-9]+)?$/) print hote
    }'
}

conflit_composition() {   # <port> — imprime le port ou la plage qui le heurte
  _p="$1"
  for _r in $(ports_composition); do
    case "$_r" in
      *-*)
        if [ "$_p" -ge "${_r%-*}" ] && [ "$_p" -le "${_r#*-}" ]; then
          echo "$_r"
          return
        fi
        ;;
      *)
        if [ "$_p" -eq "$_r" ]; then
          echo "$_r"
          return
        fi
        ;;
    esac
  done
}

# Ce que dit l'hôte, et non ce que dit la composition : un port peut être tenu
# par n'importe quoi d'autre sur cette machine.
port_occupe() {   # <port> — 0 si quelque chose écoute déjà
  _p="$1"
  if command -v ss > /dev/null 2>&1; then
    ss -ltn 2>/dev/null | grep -qE "[.:]$_p[[:space:]]"
  elif command -v netstat > /dev/null 2>&1; then
    netstat -an 2>/dev/null | grep -qE "[.:]$_p[[:space:]]+.*LISTEN"
  else
    return 1
  fi
}

# Une instance déjà en service tient légitimement son port : c'est le cas
# ordinaire d'une réinstallation, et s'en alarmer ferait crier le script à
# chaque passage. On demande donc à Docker qui publie ce port, et on se tait
# si c'est un conteneur de ce projet — la levée remplacera le sien.
port_tenu_par_projet() {   # <port> — 0 si ce projet le publie déjà
  docker ps --filter "publish=$1" \
            --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null |
    grep -qx "$projet"
}

echo
echo "── Kastell — installation par composition Docker ──────────────────────"

# ── La base a-t-elle déjà un mot de passe ? ────────────────────────────────
#
# POSTGRES_PASSWORD n'est lu qu'une fois dans la vie d'une base : par initdb,
# sur un répertoire vide. Ensuite Postgres garde celui qu'il a stocké et ignore
# la variable. Tirer un secret neuf devant un volume déjà peuplé ne le change
# donc pas — cela produit une application qui démarre, se présente avec un mot
# de passe que la base ne connaît pas, et tombe sur « password authentication
# failed ». Protéger le .env ne suffisait pas : l'état qui compte est dans le
# volume, pas dans le fichier.
#
# Le nom du volume suit le nom du projet, déclaré en tête de la composition.

projet="$(sed -n 's/^name:[[:space:]]*//p' "$COMPOSE" | head -n 1)"
[ -n "$projet" ] || projet="kastell"
volume_base="${projet}_base"

base_existe="non"
if docker volume inspect "$volume_base" > /dev/null 2>&1; then
  base_existe="oui"
fi

# ── Un .env existant n'est jamais écrasé en silence ────────────────────────

ecrire_env="oui"
ancien_env=""

if [ -f .env ]; then
  echo
@@ -158,6 +258,7 @@ if [ -f .env ]; then
    2)
      sauvegarde=".env.sauvegarde-$(date '+%Y%m%d-%H%M%S')"
      cp -p .env "$sauvegarde"
      ancien_env="$sauvegarde"
      echo "  ✓ Ancien fichier conservé sous $sauvegarde"
      ;;
    *)
@@ -167,6 +268,71 @@ if [ -f .env ]; then
  esac
fi

# ── Un volume déjà là interdit de tirer un mot de passe neuf en silence ────

reprendre_pg="non"

if [ "$ecrire_env" = "oui" ] && [ "$base_existe" = "oui" ]; then
  echo
  echo "  ⚠  Le volume « $volume_base » existe déjà : cette machine porte une"
  echo "     base Postgres initialisée, qui a son propre mot de passe."
  echo
  echo "     Un secret neuf ne l'atteindrait pas. Postgres ne relit ce mot de"
  echo "     passe qu'à la création de la base : l'application démarrerait et"
  echo "     échouerait sur « password authentication failed »."
  echo
  if [ -n "$ancien_env" ]; then
    echo "    1) Reprendre les identifiants Postgres de l'ancien fichier"
    echo "    2) Tirer un mot de passe neuf quand même"
    echo "    3) Ne rien faire"
    echo
    demander "Votre choix" "1"
    case "$REPONSE" in
      1) reprendre_pg="oui" ;;
      2) ;;
      *)
        rm -f "$ancien_env"
        echo "  Rien n'a été modifié — le .env d'origine est intact, et la"
        echo "  sauvegarde qui venait d'en être tirée a été retirée."
        exit 0
        ;;
    esac
  else
    echo "     Aucun .env ici : le mot de passe de cette base n'est écrit nulle"
    echo "     part sur cette machine. Le retrouver, réaligner la base ou la"
    echo "     supprimer sont trois décisions d'exploitation — ce script n'en"
    echo "     prend aucune à votre place."
    echo
    echo "    1) Ne rien faire"
    echo "    2) Tirer un mot de passe neuf quand même"
    echo
    demander "Votre choix" "1"
    case "$REPONSE" in
      2) ;;
      *) echo "  Rien n'a été modifié." ; exit 0 ;;
    esac
  fi

  if [ "$reprendre_pg" = "non" ]; then
    echo
    echo "  ⚠  Mot de passe neuf devant une base existante. L'application ne se"
    echo "     connectera pas tant que la base n'aura pas été réalignée dessus,"
    echo "     une fois la composition levée :"
    echo
    echo "       docker compose -f $COMPOSE --env-file .env up -d postgres"
    echo "       docker compose -f $COMPOSE --env-file .env exec postgres \\"
    echo "         psql -U UTILISATEUR -d postgres"
    echo
    echo "     puis, à l'invite de psql, avec le mot de passe qu'on vient"
    echo "     d'écrire dans le .env :"
    echo
    echo "       ALTER USER UTILISATEUR WITH PASSWORD 'LE_MOT_DE_PASSE';"
    echo
    echo "     Les connexions par socket local sont en « trust » dans l'image"
    echo "     officielle : l'ancien mot de passe n'est pas nécessaire."
  fi
fi

# ── Questionnaire ──────────────────────────────────────────────────────────

if [ "$ecrire_env" = "oui" ]; then
@@ -179,11 +345,33 @@ if [ "$ecrire_env" = "oui" ]; then
    case "$REPONSE" in
      '' | *[!0-9]*) echo "     Un nombre, entre 1 et 65535." ; continue ;;
    esac
    if [ "$REPONSE" -ge 1 ] && [ "$REPONSE" -le 65535 ]; then
    if [ "$REPONSE" -lt 1 ] || [ "$REPONSE" -gt 65535 ]; then
      echo "     Un nombre, entre 1 et 65535."
      continue
    fi

    # Ce port n'est que celui de l'application. Ceux de LiveKit, Postgres,
    # Redis et MinIO sont écrits dans la composition : les lui donner produit
    # un conflit que rien n'annonçait, plusieurs questions plus loin.
    reserve="$(conflit_composition "$REPONSE")"
    if [ -n "$reserve" ]; then
      echo "     La composition réserve déjà $reserve côté hôte. Ce port-ci"
      echo "     n'est que celui de l'application ; les autres services ont"
      echo "     les leurs, fixés dans $COMPOSE. Prenez-en un autre."
      continue
    fi

    if port_occupe "$REPONSE" && ! port_tenu_par_projet "$REPONSE"; then
      echo
      echo "     ⚠  Quelque chose écoute déjà sur $REPONSE, et ce n'est pas"
      echo "        un conteneur de ce projet. La levée échouera sur"
      echo "        « address already in use »."
      echo
      oui_non "Garder ce port quand même ?" "n" || continue
    fi

    port="$REPONSE"
    break
    fi
    echo "     Un nombre, entre 1 et 65535."
  done

  echo
@@ -232,10 +420,31 @@ if [ "$ecrire_env" = "oui" ]; then
  echo
  echo "── Base de données ────────────────────────────────────────────────────"
  echo
  pg_motdepasse_repris=""
  if [ "$reprendre_pg" = "oui" ]; then
    pg_user="$(valeur_fichier "$ancien_env" POSTGRES_USER)"
    pg_base="$(valeur_fichier "$ancien_env" POSTGRES_DB)"
    pg_motdepasse_repris="$(valeur_fichier "$ancien_env" POSTGRES_PASSWORD)"
  fi
  # Une valeur manquante dans l'ancien fichier ne se remplace pas par un
  # défaut en silence : ce serait reperdre la base qu'on cherche à garder.
  if [ "$reprendre_pg" = "oui" ] && [ -n "$pg_user" ] && [ -n "$pg_base" ] \
     && [ -n "$pg_motdepasse_repris" ]; then
    echo "  Repris de l'ancien fichier, pour que la base reste joignable :"
    echo "    utilisateur « $pg_user », base « $pg_base », mot de passe inchangé."
  else
    if [ "$reprendre_pg" = "oui" ]; then
      echo "  ⚠  L'ancien fichier ne porte pas les trois valeurs Postgres."
      echo "     Elles sont redemandées — et le mot de passe sera neuf."
      echo
      reprendre_pg="non"
      pg_motdepasse_repris=""
    fi
    demander "Utilisateur PostgreSQL" "kastell"
    pg_user="$REPONSE"
    demander "Nom de la base" "kastell"
    pg_base="$REPONSE"
  fi

  echo
  echo "── Visioconférence (LiveKit) ──────────────────────────────────────────"
@@ -280,7 +489,11 @@ if [ "$ecrire_env" = "oui" ]; then

  # ── Écriture ─────────────────────────────────────────────────────────────

  if [ -n "$pg_motdepasse_repris" ]; then
    pg_motdepasse="$pg_motdepasse_repris"
  else
    pg_motdepasse="$(secret 16)"
  fi
  s3_secret="$(secret 24)"
  livekit_secret="$(secret 24)"
  session_secret="$(secret 32)"
@@ -370,13 +583,33 @@ APNS_ENVIRONNEMENT=bac_a_sable
# d'habilitation d'alerte critique : sans elle, la notification ne franchit pas
# le mode silencieux.
APNS_ALERTE_CRITIQUE=false

# ── Android : le compte de service Firebase ────────────────────────────────
#
# Les trois champs viennent du fichier JSON que la console Firebase remet. Les
# trois ou aucun : un relais à moitié configuré refuserait la moitié des
# téléphones sans qu'on sache lesquels.
#
# La clé privée est en PEM, sur une seule ligne avec des \n, comme celle
# d'Apple, et se colle ici à la main pour la même raison. Sans elle, le relais
# démarre, sert iOS, et refuse chaque heurtoir Android avec un motif clair —
# le canal bascule alors sur le SMS.
FCM_PROJET=
FCM_COMPTE=
FCM_CLE=
FIN
  )

  echo
  echo "  ✓ .env écrit, en 600."
  if [ -n "$pg_motdepasse_repris" ]; then
    echo "    Secrets tirés au sort : S3_SECRET, LIVEKIT_SECRET,"
    echo "    SECRET_SESSION, KASTELL_RELAIS_CLE."
    echo "    POSTGRES_PASSWORD est celui de la base existante, à dessein."
  else
    echo "    Secrets tirés au sort : POSTGRES_PASSWORD, S3_SECRET,"
    echo "    LIVEKIT_SECRET, SECRET_SESSION, KASTELL_RELAIS_CLE."
  fi

  # .env.example est la référence : une clé qui y apparaît et manque ici est une
  # dérive à signaler, pas à laisser découvrir au démarrage.
@@ -399,6 +632,16 @@ fi
port="$(valeur_env KASTELL_PORT)"
[ -n "$port" ] || port="8080"

# Ce port peut venir d'un .env conservé, donc n'avoir jamais été confronté à la
# composition. Le dire ici laisse encore le temps de refuser la levée.
reserve="$(conflit_composition "$port")"
if [ -n "$reserve" ]; then
  echo
  echo "  ⚠  KASTELL_PORT vaut $port, que la composition réserve déjà"
  echo "     ($reserve) pour un autre service. La levée échouera sur"
  echo "     « address already in use ». Corrigez KASTELL_PORT dans .env."
fi

# Cette liste suit docker/compose.yaml : un service ajouté là doit l'être ici.
services="postgres redis minio app"
[ -n "$(valeur_env CLAMAV_HOTE)" ] && services="$services clamav"