Commit 1ec6c163 authored by Kourser's avatar Kourser
Browse files

Relancer l'installateur répare l'adresse du serveur média

La configuration du serveur média fige l'adresse de la machine. Un portable
change de réseau : l'appel se tient sans un son, alors que la signalisation
répond toujours, et la sonde de visio avec elle. Relancer l'installateur en
gardant son .env ne corrigeait rien — l'adresse ne s'écrivait que sur le
chemin d'un .env neuf — et il ne cherchait que en0 et en1, quand un Ethernet
de station d'accueil s'appelle en5 et qu'un VPN prend la route par défaut.

Sur tous les chemins, l'adresse annoncée est désormais confrontée à celle de
la machine, corrigée si elle a changé, et le serveur média relancé. Une
adresse que l'exploitant a posée dans son URL reste la sienne (EF-803).

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 939e2a69
Loading
Loading
Loading
Loading
+61 −1
Changes for apps/api/src/verification/installateur.ts: 61 added lines, 1 removed line.
Original line number Diff line number Diff line
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -50,6 +50,24 @@ export async function verifierInstallateur(r: Rapport): Promise<void> {
    return lignes.slice(debut, fin + 1).join("\n");
  };

  /** Une fonction du script, de sa déclaration à l'accolade qui la ferme en colonne zéro. */
  const fonction = (nom: string): string => {
    const lignes = script.split("\n");
    const debut = lignes.findIndex((l) => l.startsWith(`${nom}() {`));
    if (debut < 0) throw new Error(`Fonction introuvable : ${nom}`);
    const fin = lignes.findIndex((l, i) => i > debut && l === "}");
    return lignes.slice(debut, fin + 1).join("\n");
  };
  /** Un bloc de premier niveau, d'un repère jusqu'au « fi » en colonne zéro. */
  const blocRacine = (depart: string): string => {
    const lignes = script.split("\n");
    const debut = lignes.findIndex((l) => l.includes(depart));
    if (debut < 0) throw new Error(`Bloc introuvable : ${depart}`);
    const fin = lignes.findIndex((l, i) => i > debut && l === "fi");
    return lignes.slice(debut, fin + 1).join("\n");
  };
  const detection = fonction("adresse_interface_locale");

  const decision = bloc("# ── Toute machine joignable autrement", 2);
  const ecriture = bloc('if [ "$ecrire_config_livekit" = "oui" ]; then');

@@ -62,6 +80,7 @@ export async function verifierInstallateur(r: Rapport): Promise<void> {
    const prelude = [
      "set -eu",
      "LANGUE=fr; dire() { :; }",
      detection,
      ...Object.entries(v).map(([c, x]) => `${c}='${x}'`),
    ].join("\n");
    await writeFile(join(dossier, "essai.sh"),
@@ -111,6 +130,47 @@ export async function verifierInstallateur(r: Rapport): Promise<void> {
      && !/^\s*domain:\s*$/m.test(local.contenu),
    (local.contenu.match(/turn:[\s\S]*?(?=\n\n)/) ?? ["aucun bloc turn"])[0]!);

  // ── Une adresse qui a changé depuis l'installation ───────────────────────
  //
  // Relancer l'installateur en gardant son .env laissait la configuration du
  // serveur média annoncer l'adresse d'un réseau quitté : appel sans son, et
  // rien pour le dire. Le bloc tourne sur tous les chemins ; on le joue ici
  // sur un .env conservé.
  const derive = blocRacine("# ── Une adresse de réseau local qui a changé");
  const valeurEnv = fonction("valeur_env");
  const ici = (await executer("sh", ["-c", `set -eu\n${detection}\nadresse_interface_locale`])).stdout.trim();
  async function deriver(url: string, annoncee: string) {
    const dossier = await mkdtemp(join(tmpdir(), "kastell-installateur-derive-"));
    await mkdir(join(dossier, "docker"), { recursive: true });
    await writeFile(join(dossier, ".env"), [`KASTELL_PUBLIC_URL=${url}`, "LIVEKIT_MODE=livre",
      "LIVEKIT_CONFIG=../docker/livekit.local.yaml"].join("\n"));
    await writeFile(join(dossier, "docker/livekit.local.yaml"),
      `port: 7880\nrtc:\n  tcp_port: 7881\n  node_ip: ${annoncee}\nkeys:\n  cle-essai: secret-essai\n`, { mode: 0o600 });
    await writeFile(join(dossier, "essai.sh"), ["set -eu", "LANGUE=fr; dire() { :; }", valeurEnv, detection,
      "mode_media=livre", derive, "printf '%s' \"$relancer_media\" > .relance"].join("\n"));
    await executer("sh", ["essai.sh"], { cwd: dossier });
    return {
      contenu: await readFile(join(dossier, "docker/livekit.local.yaml"), "utf8"),
      relance: await readFile(join(dossier, ".relance"), "utf8"),
      droits: (await stat(join(dossier, "docker/livekit.local.yaml"))).mode & 0o777,
    };
  }
  r.verifie("EF-803", "cette machine a une adresse de réseau local, que l'installateur sait trouver",
    /^\d+\.\d+\.\d+\.\d+$/.test(ici), ici || "aucune");
  const partie = await deriver("http://localhost:8080", "10.254.254.254");
  r.verifie("EF-803", "une adresse quittée est remplacée, même en gardant son .env",
    partie.contenu.includes(`  node_ip: ${ici}\n`) && partie.relance === "oui",
    (partie.contenu.match(/node_ip:.*/) ?? ["aucune"])[0]!);
  r.verifie("EF-803", "sans rien perdre du reste du fichier, ni ses droits",
    partie.contenu.includes("  tcp_port: 7881") && partie.contenu.includes("cle-essai: secret-essai")
      && partie.droits === 0o600, `droits ${partie.droits.toString(8)}`);
  const inchangee = await deriver("http://localhost:8080", ici);
  r.verifie("EF-803", "une adresse toujours juste n'est pas touchée, et le média pas relancé",
    inchangee.contenu.includes(`  node_ip: ${ici}\n`) && inchangee.relance === "non");
  const posee = await deriver("http://192.168.77.5:8080", "192.168.77.5");
  r.verifie("EF-803", "une adresse que l'exploitant a posée dans son URL reste la sienne",
    posee.contenu.includes("  node_ip: 192.168.77.5\n") && posee.relance === "non");

  // ── Un hôte joignable a le sien, ports déplacés ou non ───────────────────
  const serveur = await jouer({ ...commun, hote_public: "kastell.exemple.fr" });
  r.verifie("EF-803", "un hôte joignable reçoit sa propre configuration",
+80 −9
Changes for outils/installer.sh: 80 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -267,6 +267,42 @@ valeur_fichier() { # valeur_fichier <fichier> <cle> — idem, hors du .env cou
  sed -n "s/^$2=//p" "$1" | tail -n 1
}

# L'adresse de cette machine sur son réseau local, ou rien.
#
# macOS. « en0 » puis « en1 » ne suffisaient pas : un Ethernet de station
# d'accueil s'appelle « en5 » ou « en7 », et un VPN prend la route par défaut
# sur un « utun » que personne ne joint depuis le réseau local. Le poste
# changeait de réseau, l'installateur ne trouvait plus rien, et le serveur
# média annonçait la boucle locale. On prend l'interface de la route par
# défaut si elle est physique, sinon la première « en » adressée.
#
# Linux. La source de la route vers l'extérieur, sauf quand elle passe par un
# tunnel ou un pont de conteneurs ; à défaut, la première adresse globale d'une
# interface qui n'est ni l'un ni l'autre.
adresse_interface_locale() {
  _adresse=""
  if command -v ipconfig > /dev/null 2>&1; then
    for _interface in $(route -n get default 2>/dev/null | awk '/interface:/ { print $2 }') \
                      $(ifconfig -l 2>/dev/null); do
      case "$_interface" in
        en*) _adresse="$(ipconfig getifaddr "$_interface" 2>/dev/null || true)" ;;
      esac
      if [ -n "$_adresse" ]; then break; fi
    done
  fi
  if [ -z "$_adresse" ] && command -v ip > /dev/null 2>&1; then
    _adresse="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '
      { for (i = 1; i < NF; i++) { if ($i == "dev") d = $(i + 1); if ($i == "src") s = $(i + 1) } }
      END { if (d !~ /^(tun|wg|tailscale|zt|ppp|docker|br-|veth|virbr)/) print s }')"
    if [ -z "$_adresse" ]; then
      _adresse="$(ip -4 -o addr show scope global 2>/dev/null | awk '
        $2 !~ /^(tun|wg|tailscale|zt|ppp|docker|br-|veth|virbr|lxc|cni|flannel)/ {
          sub(/\/.*/, "", $4); print $4; exit }')"
    fi
  fi
  printf '%s' "$_adresse"
}

# ── Migration d'un .env existant ───────────────────────────────────────────
#
# Un .env en service porte des choses que ce script n'a pas écrites et ne sait
@@ -1745,15 +1781,7 @@ if [ "$ecrire_env" = "oui" ]; then
  # On annonce donc l'adresse de l'interface, y compris en local. Le paquet
  # sort par l'interface et reboucle vers la même machine.
  if [ "$livekit_mode" = "livre" ] && [ "$ecrire_config_livekit" != "oui" ]; then
    adresse_interface=""
    if command -v ipconfig > /dev/null 2>&1; then
      adresse_interface="$(ipconfig getifaddr en0 2>/dev/null \
        || ipconfig getifaddr en1 2>/dev/null || true)"
    fi
    if [ -z "$adresse_interface" ] && command -v ip > /dev/null 2>&1; then
      adresse_interface="$(ip -4 route get 1.1.1.1 2>/dev/null \
        | sed -n 's/.*src \([0-9.]*\).*/\1/p')"
    fi
    adresse_interface="$(adresse_interface_locale)"
    if [ -n "$adresse_interface" ]; then
      ecrire_config_livekit="oui"
      livekit_config="../docker/livekit.local.yaml"
@@ -2092,6 +2120,43 @@ if [ -z "$mode_media" ] && [ -n "$(valeur_env LIVEKIT_URL)" ]; then
  mode_media="livre"
fi
[ "$mode_media" = "livre" ] && services="$services livekit"

# ── Une adresse de réseau local qui a changé depuis l'installation ─────────
#
# La configuration du serveur média fige l'adresse de la machine au moment où
# elle est écrite. Un portable change de réseau, un bail DHCP expire : le
# serveur continue d'annoncer une adresse que la machine n'a plus, la
# signalisation répond toujours, et l'appel se tient sans un son. Relancer
# l'installateur en gardant son .env ne corrigeait rien — la configuration ne
# s'écrivait que sur le chemin d'un .env neuf. Vécu : .149, puis .42, puis .105.
#
# On ne touche qu'à ce que l'installateur a lui-même déduit : une adresse de
# réseau local, écrite pour une instance dont l'adresse publique est la boucle
# locale. Une adresse que l'exploitant a posée dans son URL reste la sienne.
relancer_media="non"
hote_env="$(valeur_env KASTELL_PUBLIC_URL | sed -e 's#^[a-zA-Z]*://##' -e 's#[:/].*$##')"
if [ "$mode_media" = "livre" ] && [ "$(valeur_env LIVEKIT_CONFIG)" = "../docker/livekit.local.yaml" ] \
   && [ -f docker/livekit.local.yaml ] \
   && { [ "$hote_env" = "localhost" ] || [ "$hote_env" = "127.0.0.1" ]; }; then
  annoncee="$(sed -n 's/^  node_ip: *\([0-9.]*\) *$/\1/p' docker/livekit.local.yaml | head -n 1)"
  actuelle="$(adresse_interface_locale)"
  case "$annoncee" in
    10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*)
      if [ -n "$actuelle" ] && [ "$actuelle" != "$annoncee" ]; then
        # Réécrit en place : le fichier garde ses droits, et ses clés.
        sed "s/^  node_ip: *$annoncee *$/  node_ip: $actuelle/" docker/livekit.local.yaml > docker/livekit.local.yaml.neuf
        cat docker/livekit.local.yaml.neuf > docker/livekit.local.yaml
        rm -f docker/livekit.local.yaml.neuf
        relancer_media="oui"
        echo
        dire "  ✓ Le serveur média annonçait $annoncee, que cette machine n'a plus." \
             "  ✓ The media server announced $annoncee, which this machine no longer has."
        dire "    Il annoncera $actuelle ; il sera relancé avec la composition." \
             "    It will announce $actuelle; it will restart with the composition."
      fi
      ;;
  esac
fi
case "$(valeur_env LIVEKIT_EGRESS)" in
  oui) services="$services egress" ;;
  non) ;;
@@ -2246,6 +2311,12 @@ for _p in $profils; do set -- "$@" --profile "$_p"; done
# shellcheck disable=SC2086
KASTELL_REVISION="$revision" docker compose "$@" up -d --build $services

# « up » ne relance pas un conteneur dont seul un fichier monté a changé : le
# serveur média garderait l'ancienne adresse jusqu'à son prochain redémarrage.
if [ "$relancer_media" = "oui" ]; then
  docker compose "$@" restart livekit
fi

echo
dire "── État ───────────────────────────────────────────────────────────────" \
     "── Status ────────────────────────────────────────────────────────────"