Commit 34f17e58 authored by Kourser's avatar Kourser
Browse files

L'installateur crée le premier compte, et l'amorce entre dans l'image

La dernière marche de l'installation demandait la chaîne Node sur l'hôte —
« pnpm amorcer-root » — à un script qui s'adresse précisément à l'exploitant
qui n'a que Docker. On l'envoyait chercher pnpm pour finir ce qu'on venait de
lui installer sans lui.

Le compte naît du parcours normal
  L'installateur appelle /auth/inscription, l'adresse que composerait un
  navigateur : mêmes contrôles, même journal, aucune porte de service. Ce
  n'est pas un détail de commodité — une inscription qui court-circuiterait la
  validation créerait un premier compte différent de tous les autres, et c'est
  celui-là qui devient root.

  Le mot de passe se saisit sans écho, et voyage par un fichier temporaire en
  600 plutôt que par la ligne de commande : « ps » est lisible par tous les
  utilisateurs de la machine. Le corps JSON est échappé par une fonction
  dédiée — jq n'est pas un prérequis, et un mot de passe porte légitimement
  une contre-oblique.

L'amorce voyage dans l'image
  esbuild produit désormais dist/racine.js à côté de dist/index.js, et
  l'octroi se fait par « docker compose exec app node dist/racine.js ». Plus
  rien à installer sur l'hôte. La règle ne bouge pas : l'amorce ne crée pas de
  compte, et refuse dès qu'un root existe.

Ce que le script ne promet pas
  Que le droit root suive dans la foulée. L'amorce refuse un compte sans
  second facteur actif : la console exige une présentation récente (EA-03), et
  accorder root à un compte qui ne pourrait jamais y entrer ne servirait
  personne. Le script tente l'amorçage, puis relaie SON message plutôt que
  d'en inventer un — le jour où la règle changera, il n'aura pas à mentir en
  attendant qu'on le corrige.

Deux défauts de portée, trouvés en éprouvant
  « pret » n'existe que si curl était là pour interroger la sonde, et
  « hote_public » que si un .env neuf a été écrit. Les lire ailleurs faisait
  tomber le script sous « set -u », juste après une levée réussie — au moment
  précis où tout venait de marcher. Le premier a un défaut, le second se
  déduit maintenant du .env, qui est toujours là.

Contrôlé
  L'inscription répond 201 sur une instance vierge montée à côté, et l'amorce
  y donne le motif attendu. Le mot de passe ne s'affiche pas : la frappe reste
  invisible et la valeur arrive intacte. L'échappement JSON tient les
  guillemets, les contre-obliques et l'UTF-8. 920 garanties, tsc, image
  reconstruite avec racine.js.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 9262f70d
Loading
Loading
Loading
Loading
Loading
+1 −1
Changes for apps/api/package.json: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -8,7 +8,7 @@
    "dev": "tsx --env-file=../../.env watch src/index.ts",
    "migrate": "tsx --env-file=../../.env src/db/migrate.ts",
    "verif": "tsx --env-file=../../.env src/verif.ts",
    "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\""
    "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\" && esbuild src/racine.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/racine.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\""
  },
  "dependencies": {
    "@aws-sdk/client-s3": "^3.1116.0",
+165 −8
Changes for outils/installer.sh: 165 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -57,6 +57,23 @@ case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
  *) LANGUE="en" ;;
esac

# Échappe une chaîne pour l'insérer dans du JSON, guillemets compris. jq
# n'est pas un prérequis de ce script, et un mot de passe peut légitimement
# porter un guillemet ou une contre-oblique.
json_texte() {
  printf '%s' "$1" | awk '
    BEGIN { RS = "\0"; printf "\"" }
    {
      gsub(/\\/, "\\\\")
      gsub(/"/, "\\\"")
      gsub(/\n/, "\\n")
      gsub(/\r/, "\\r")
      gsub(/\t/, "\\t")
      printf "%s", $0
    }
    END { printf "\"" }'
}

# dire <fr> <en> — une ligne, dans la langue retenue.
dire() {
  if [ "$LANGUE" = "en" ]; then echo "$2"; else echo "$1"; fi
@@ -163,6 +180,26 @@ demander() { # demander <question> <defaut>
  [ -n "$REPONSE" ] || REPONSE="${2:-}"
}

# Un mot de passe ne se lit pas par-dessus l'épaule. « stty » est le seul
# moyen portable d'éteindre l'écho en shell ; le piège de sortie le rallume,
# faute de quoi un Ctrl-C laisserait le terminal muet.
demander_secret() {   # demander_secret <question fr> <question en>
  if [ "$LANGUE" = "en" ]; then printf '  %s : ' "$2"; else printf '  %s : ' "$1"; fi
  _echo_avant="$(stty -g 2>/dev/null || echo '')"
  [ -n "$_echo_avant" ] && trap 'stty "$_echo_avant" 2>/dev/null; exit 130' INT
  [ -n "$_echo_avant" ] && stty -echo 2>/dev/null
  if ! read -r REPONSE; then
    [ -n "$_echo_avant" ] && stty "$_echo_avant" 2>/dev/null
    echo
    dire_err "  Interrompu : fin d'entrée. Rien de plus n'a été écrit." \
             "  Interrupted: end of input. Nothing further has been written."
    exit 1
  fi
  [ -n "$_echo_avant" ] && stty "$_echo_avant" 2>/dev/null
  [ -n "$_echo_avant" ] && trap - INT
  echo
}

oui_non() {   # oui_non <question> <o|n par défaut> — 0 pour oui
  while :; do
    if [ "$2" = "o" ]; then
@@ -1822,22 +1859,142 @@ if [ "$mode_media" = "mutualise" ] && [ -z "$(valeur_env LIVEKIT_SECRET)" ]; the
fi
# L'adresse publique, et non « localhost » : c'est celle que les postes de la
# cellule composent, et la seule qui vaille si l'instance n'est pas sur ce poste.
# ── Le premier compte ──────────────────────────────────────────────────────
#
# C'est la marche qui restait. Le compte se créait au navigateur, et le droit
# root s'accordait avec « pnpm amorcer-root » — donc avec la chaîne Node sur
# l'hôte, que l'exploitant visé par ce script n'a précisément pas.
#
# Rien n'est contourné pour autant. Le compte naît du parcours normal :
# l'installateur appelle /auth/inscription, l'adresse que composerait un
# navigateur, avec les mêmes contrôles et le même journal. Puis il accorde le
# droit root en exécutant l'amorce DANS le conteneur, qui porte Node — et cette
# amorce refuse toujours de s'exécuter sur une instance qui a déjà un root.

compte_cree="non"
# « pret » n'existe que si curl était là pour interroger la sonde. Sans lui, on
# ne sait pas si l'instance répond, et on ne propose donc pas de lui parler.
if [ "${pret:-non}" = "oui" ] && command -v curl > /dev/null 2>&1; then
  echo
  dire "  Une instance sans compte ne sert à personne, et le premier ne peut" \
       "  An instance with no account serves nobody, and the first one cannot"
  dire "  pas se voir accorder le droit root par quelqu'un d'autre : il n'y a" \
       "  be granted root by anyone else: there is nobody there yet."
  dire "  encore personne." \
       "  "
  echo
  if oui_non_b "Créer ce premier compte maintenant ?" "Create that first account now?" "o"; then
    while :; do
      demander_b "Adresse électronique" "Email address" ""
      compte_email="$REPONSE"
      case "$compte_email" in
        ?*@?*.?*) break ;;
        *) dire "     Une adresse électronique complète." \
                "     A complete email address." ;;
      esac
    done
    demander_b "Votre nom" "Your name" ""
    compte_nom="$REPONSE"
    # Tiré du .env et non de la saisie : cette section s'exécute après toute
    # levée, y compris celles qui ont gardé ou complété un fichier existant, où
    # rien n'a été demandé et où « hote_public » n'existe pas.
    orga_defaut="$(printf '%s' "$(valeur_env KASTELL_PUBLIC_URL)" \
      | sed -e 's,^[a-zA-Z][a-zA-Z0-9+.-]*://,,' -e 's,[:/].*$,,')"
    demander_b "Nom de l'organisation" "Organisation name" "${orga_defaut:-Kastell}"
    compte_orga="$REPONSE"

    while :; do
      demander_secret "Mot de passe" "Password"
      compte_mdp="$REPONSE"
      demander_secret "Répétez-le" "Repeat it"
      if [ "$compte_mdp" = "$REPONSE" ] && [ -n "$compte_mdp" ]; then break; fi
      dire "     Les deux saisies diffèrent, ou sont vides." \
           "     The two entries differ, or are empty."
    done

    # Le corps est écrit par un fichier temporaire en 600 plutôt que sur la
    # ligne de commande : « ps » est lisible par tout le monde sur cette
    # machine, et un mot de passe n'a rien à y faire.
    corps_compte="$(mktemp)"
    chmod 600 "$corps_compte"
    printf '{"email":%s,"nom":%s,"motDePasse":%s,"nomOrganisation":%s}' \
      "$(json_texte "$compte_email")" "$(json_texte "$compte_nom")" \
      "$(json_texte "$compte_mdp")" "$(json_texte "$compte_orga")" > "$corps_compte"

    reponse_compte="$(curl -s -o /dev/null -w '%{http_code}' \
      -X POST "http://localhost:$port/api/auth/inscription" \
      -H 'Content-Type: application/json' --data-binary "@$corps_compte" || echo 000)"
    rm -f "$corps_compte"

    if [ "$reponse_compte" = "201" ]; then
      compte_cree="oui"
      echo
      dire "  ✓ Compte créé pour $compte_email." \
           "  ✓ Account created for $compte_email."

      # L'amorce vit désormais dans l'image : pas de chaîne Node sur l'hôte.
      #
      # Elle refusera très probablement, et pour une bonne raison : un compte
      # neuf n'a pas de second facteur, et la console d'administration en exige
      # une présentation récente (EA-03) — accorder root à un compte qui ne
      # pourrait jamais y entrer ne servirait personne. On la tente quand même,
      # et on relaie SON message plutôt que d'en inventer un : le jour où la
      # règle change, ce script n'aura pas à mentir en attendant qu'on le
      # corrige.
      sortie_amorce="$(docker compose -f "$COMPOSE" --env-file .env exec -T app \
        node dist/racine.js "$compte_email" "Amorçage de l'instance." 2>&1)" && amorce_ok="oui" || amorce_ok="non"

      if [ "$amorce_ok" = "oui" ]; then
        dire "  ✓ Droit root accordé, et consigné au journal d'administration." \
             "  ✓ Root granted, and recorded in the administration log."
      else
        echo
        dire "  Il reste une marche, et l'amorce dit laquelle :" \
             "  One step remains, and the bootstrap says which:"
        echo
        echo "$sortie_amorce" | sed 's/^/     /'
        echo
        dire "  Une fois cela fait, accordez le droit root — depuis le" \
             "  Once that is done, grant root — from inside the container, so"
        dire "  conteneur, donc sans rien installer sur cette machine :" \
             "  that nothing needs installing on this machine:"
        echo
        echo "       docker compose -f $COMPOSE --env-file .env exec app \\"
        echo "         node dist/racine.js $compte_email \"Amorçage de l'instance.\""
      fi
    else
      echo
      dire "  ⚠  L'inscription a été refusée (HTTP $reponse_compte)." \
           "  ⚠  Sign-up was refused (HTTP $reponse_compte)."
      dire "     Rien n'est perdu : le parcours reste ouvert au navigateur." \
           "     Nothing is lost: the normal path is still open in a browser."
    fi
    echo
  fi
fi

if [ "$compte_cree" = "non" ]; then
  dire "  1) Sur $(valeur_env KASTELL_PUBLIC_URL), « Créer un espace » : votre" \
       "  1) At $(valeur_env KASTELL_PUBLIC_URL), \"Create a space\": your"
  dire "     adresse, votre mot de passe. Personne d'autre ne les voit." \
       "     address, your password. Nobody else sees them."
  echo
dire "  2) Puis accordez le droit root à ce compte. Ce geste n'est pas dans" \
     "  2) Then grant root to that account. This step is not in the image:"
dire "     l'image : il demande la chaîne Node sur l'hôte —" \
     "     it needs the Node toolchain on the host —"
dire "       pnpm amorcer-root vous@exemple.org \"Amorçage de l'instance.\"" \
     "       pnpm amorcer-root you@example.org \"Bootstrapping the instance.\""
dire "     amorcer-root ne crée pas de compte : il accorde un droit à un compte" \
     "     amorcer-root creates no account: it grants a right to an account"
  dire "  2) Puis accordez le droit root à ce compte, depuis le conteneur :" \
       "  2) Then grant root to that account, from inside the container:"
  echo "       docker compose -f $COMPOSE --env-file .env exec app \\"
  echo "         node dist/racine.js vous@exemple.org \"Amorçage de l'instance.\""
  dire "     L'amorce ne crée pas de compte : elle accorde un droit à un compte" \
       "     The bootstrap creates no account: it grants a right to an account"
  dire "     né du parcours normal, et ne fonctionne que sur instance vierge." \
       "     born of the normal path, and works only on a pristine instance."
  echo
else
  dire "  Connectez-vous sur $(valeur_env KASTELL_PUBLIC_URL) avec l'adresse" \
       "  Sign in at $(valeur_env KASTELL_PUBLIC_URL) with the address you"
  dire "  que vous venez de donner." \
       "  just gave."
  echo
fi
if [ "$(valeur_env KASTELL_ENV)" = "developpement" ]; then
  dire "  En développement, les courriels ne partent pas : les liens de" \
       "  In development, emails are not sent: verification and sign-in links"
+25 −0
Changes for site/docs/configuration.md: 25 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -154,6 +154,31 @@ Les dépôts restent possibles pendant ce temps.
`KASTELL_PUSH_RELAY`, `KASTELL_RELAY_KEY`. Sans relais, l'instance reste
entière : elle ne réveille simplement personne, et le dit.

## Le premier compte, et le droit root

`outils/installer.sh` propose de créer ce compte à la fin de l'installation.
Rien n'est contourné : il appelle `/auth/inscription`, l'adresse que
composerait un navigateur, avec les mêmes contrôles et le même journal. Le mot
de passe est saisi sans écho et passe par un fichier temporaire en 600 plutôt
que par la ligne de commande, que `ps` expose à toute la machine.

**Le droit root demande une marche de plus, et c'est voulu.** L'amorce refuse
un compte sans second facteur actif : la console d'administration en exige une
présentation récente (EA-03), et accorder root à un compte qui ne pourrait
jamais y entrer ne servirait personne. Activez donc le second facteur depuis
l'application, puis :

```bash
docker compose -f docker/compose.yaml --env-file .env exec app \
  node dist/racine.js vous@exemple.org "Amorçage de l'instance."
```

L'amorce voyage désormais **dans l'image** : elle ne demande plus la chaîne
Node sur l'hôte, ce qui la remet à portée de l'exploitant qui n'a que Docker.
Elle ne crée pas de compte — elle accorde un droit à un compte né du parcours
normal — et ne fonctionne que tant qu'aucun root n'existe. Ensuite, l'octroi
passe par la console et laisse une trace nominative.

## Surcharger depuis la console root

L'onglet **Configuration** de la console root recouvre le `.env` sans toucher
+25 −0
Changes for site/i18n/en/docusaurus-plugin-content-docs/current/configuration.md: 25 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -103,6 +103,31 @@ possible meanwhile.
`KASTELL_PUSH_RELAY`, `KASTELL_RELAY_KEY`. Without a relay the instance is
whole: it simply wakes nobody, and says so.

## The first account, and root

`outils/installer.sh` offers to create that account at the end of the install.
Nothing is bypassed: it calls `/auth/inscription`, the address a browser would
dial, with the same checks and the same log. The password is typed without
echo and goes through a temporary file in mode 600 rather than the command
line, which `ps` exposes to the whole machine.

**Granting root takes one more step, by design.** The bootstrap refuses an
account with no active second factor: the administration console requires a
recent presentation of one (EA-03), and granting root to an account that could
never enter it would serve nobody. So activate the second factor from the
application, then:

```bash
docker compose -f docker/compose.yaml --env-file .env exec app \
  node dist/racine.js you@example.org "Bootstrapping the instance."
```

The bootstrap now travels **inside the image**: it no longer needs the Node
toolchain on the host, which puts it back within reach of an operator who only
has Docker. It creates no account — it grants a right to an account born of the
normal path — and works only while no root exists. After that, granting goes
through the console and leaves a named trace.

## Overriding from the root console

The **Configuration** tab of the root console covers the `.env` without