Commit 7d3d9608 authored by Kourser's avatar Kourser
Browse files

Un serveur média qui n'annonce que sa boucle locale ne reçoit rien

Aucun appel ne tenait sur une instance de développement, et le fichier livré
prétendait le contraire : « le navigateur et le conteneur sont sur la même
machine, 127.0.0.1 suffit ». C'est faux. Un navigateur ne collecte jamais de
candidat sur sa boucle locale ; il n'a donc aucune adresse à apparier avec
celle-là, et la paire est écartée avant d'être essayée. Mesuré : zéro paquet
entrant sur le port média, pendant que le serveur visait en vain l'adresse
publique du poste. Avec l'adresse de l'interface : dix mille paquets entrants,
et plus une session sans connexion.

Le relais TURN était annoncé sous « kastell.local », un nom qui ne résout
nulle part. Il se présentait aux navigateurs comme le dernier recours
disponible et n'a jamais répondu — la pire des promesses, puisqu'elle ne se
découvre qu'en crise. Sans domaine résolvable, il reste éteint.

L'installateur écrit maintenant l'adresse : une adresse de réseau privé
s'annonce telle quelle, jamais par « use_external_ip », qui ferait annoncer
l'adresse publique du site — que les navigateurs du même réseau viseraient en
vain, la plupart des routeurs ne renvoyant pas vers l'intérieur un paquet
destiné à leur propre adresse publique.

La garantie qui couvrait ce cas passait pour la mauvaise raison : elle
acceptait « use_external_ip », c'est-à-dire le comportement défaillant.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 7c40860b
Loading
Loading
Loading
Loading
+38 −6
Changes for apps/api/src/verification/installateur.ts: 38 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -36,15 +36,21 @@ export async function verifierInstallateur(r: Rapport): Promise<void> {
  const script = await readFile(`${racine}outils/installer.sh`, "utf8");

  /** Un bloc du script, de sa ligne d'ouverture jusqu'au « fi » de son niveau. */
  const bloc = (depart: string): string => {
  // « combien » : la décision enchaîne deux « if » — l'hôte joignable, puis
  // l'adresse d'interface en local. S'arrêter au premier laisserait la moitié
  // du bloc dehors, et le contrôle éprouverait un shell qui ne fait pas ce que
  // l'installateur fait.
  const bloc = (depart: string, combien = 1): 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");
    let reste = combien;
    const fin = lignes.findIndex((l, i) => i > debut && l === "  fi" && --reste === 0);
    if (fin < 0) throw new Error(`Fin introuvable : ${depart}`);
    return lignes.slice(debut, fin + 1).join("\n");
  };

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

  /** Joue les deux blocs sur un jeu de variables, et rend ce qui a été écrit. */
@@ -74,10 +80,36 @@ export async function verifierInstallateur(r: Rapport): Promise<void> {
    livekit_mode: "livre", livekit_turn_tls: "non", livekit_turn_domaine: "",
  };

  // ── Un poste de développement garde le fichier livré ─────────────────────
  /**
   * Un poste de développement reçoit sa configuration, lui aussi.
   *
   * Le contrôle affirmait l'inverse — « le fichier livré suffit » — et il
   * encodait un défaut. Le fichier livré annonce la boucle locale, et aucun
   * appel ne tient avec : un navigateur ne collecte jamais de candidat sur sa
   * boucle locale, n'a donc aucune adresse à apparier avec « 127.0.0.1 », et
   * la paire est écartée avant d'être essayée. Mesuré sur une instance de
   * développement : zéro paquet entrant sur le port média, tandis que le
   * serveur visait en vain l'adresse publique du poste.
   */
  const local = await jouer({ ...commun, hote_public: "localhost" });
  r.verifie("EF-803", "sur une machine locale, le fichier livré suffit",
    local.contenu === "" && local.config === "../docker/livekit.yaml", local.config);
  r.verifie("EF-803", "même en local, une configuration de machine est écrite",
    local.config === "../docker/livekit.local.yaml", local.config);
  // Une adresse de réseau local, et pas « use_external_ip » : celui-ci ferait
  // annoncer l'adresse publique du site, que les navigateurs du même réseau
  // viseraient en vain — la plupart des routeurs ne renvoient pas vers
  // l'intérieur un paquet destiné à leur propre adresse publique.
  r.verifie("EF-803", "et elle annonce l'adresse de l'interface, jamais la boucle locale",
    /^\s*node_ip:\s*(?!127\.0\.0\.1)\d+\.\d+\.\d+\.\d+\s*$/m.test(local.contenu)
      && !/use_external_ip/.test(local.contenu),
    (local.contenu.match(/node_ip:.*|use_external_ip:.*/) ?? ["aucune adresse"])[0]!);
  // Et le relais reste éteint : LiveKit veut un domaine résolvable, une adresse
  // de réseau local n'en est pas un. Un relais allumé sous « kastell.local »
  // — un nom qui ne résout nulle part — a coûté une matinée : il se présentait
  // aux navigateurs comme le dernier recours disponible, et n'a jamais répondu.
  r.verifie("EF-804", "et en local le relais reste éteint plutôt qu'annoncé sans domaine",
    /^\s*enabled:\s*false\s*$/m.test(local.contenu)
      && !/^\s*domain:\s*$/m.test(local.contenu),
    (local.contenu.match(/turn:[\s\S]*?(?=\n\n)/) ?? ["aucun bloc turn"])[0]!);

  // ── Un hôte joignable a le sien, ports déplacés ou non ───────────────────
  const serveur = await jouer({ ...commun, hote_public: "kastell.exemple.fr" });
+26 −12
Changes for docker/livekit.yaml: 26 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -22,17 +22,24 @@ rtc:
  # que la signalisation ou le TURN — c'est-à-dire de la façon qui marche déjà
  # partout. Il épargne aussi cent processus « docker-proxy » sur l'hôte.
  udp_port: 50000
  # Adresse annoncée aux navigateurs. La boucle locale, parce que ce fichier
  # est celui d'un poste de développement : le navigateur et le conteneur y
  # sont sur la même machine, et Docker publie ce port sur elle.
  # Adresse annoncée aux navigateurs.
  #
  # Ne le déployez pas tel quel. Un conteneur qui annonce sa propre adresse de
  # pont — ce que fait « use_external_ip: false » — n'est joignable par aucun
  # navigateur extérieur, et l'appel échoue sur « ICE failed » sans rien dire
  # d'utile. La boucle locale échoue au moins franchement, et tout de suite.
  # AUCUN APPEL NE TIENDRA AVEC CETTE VALEUR, pas même sur le poste où tourne
  # la composition. Le commentaire qui figurait ici prétendait le contraire —
  # « le navigateur et le conteneur sont sur la même machine » — et c'était
  # faux, mesures à l'appui : zéro paquet entrant sur le port média.
  #
  # L'installateur écrit docker/livekit.local.yaml dès que l'adresse publique
  # n'est pas locale, avec « use_external_ip: true » et le vrai domaine TURN.
  # Un navigateur ne collecte jamais de candidat sur sa boucle locale. Il n'a
  # donc aucune adresse à apparier avec un « 127.0.0.1 » annoncé par le
  # serveur, et la paire est écartée avant d'être essayée. Pendant ce temps le
  # serveur vise l'adresse publique du poste — la seule que le navigateur lui
  # offre, ses adresses locales étant masquées en noms mDNS que le conteneur ne
  # sait pas résoudre — et ces paquets-là ne reviennent jamais.
  #
  # L'installateur écrit donc docker/livekit.local.yaml dans TOUS les cas où le
  # serveur média est celui de la composition, avec l'adresse de l'interface.
  # Ce fichier-ci ne sert que si l'on démarre la composition à la main, et il
  # échoue alors franchement plutôt que de laisser croire à un réseau en cause.
  node_ip: 127.0.0.1

# Relais TURN.
@@ -51,9 +58,16 @@ rtc:
# L'installateur écrit tout cela pour vous dès que l'adresse publique n'est pas
# locale ; docs/exploitation.md donne la moitié qui vous revient, côté nginx.
turn:
  enabled: true
  domain: kastell.local
  udp_port: 3478
  # Éteint tant qu'un domaine résolvable n'est pas posé.
  #
  # Il était annoncé sous « kastell.local », un nom qui ne résout nulle part :
  # le navigateur recevait une adresse de relais inutilisable, et le message
  # d'erreur du produit conseillait de réessayer un chemin qui ne pouvait pas
  # exister. Mieux vaut pas de relais qu'un relais fantôme.
  #
  # L'installateur l'allume avec le vrai domaine dès que l'adresse publique
  # n'est pas locale ; docs/exploitation.md donne la moitié côté nginx.
  enabled: false

redis:
  address: redis:6379
+69 −13
Changes for outils/installer.sh: 69 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -1722,6 +1722,43 @@ if [ "$ecrire_env" = "oui" ]; then
    livekit_config="../docker/livekit.local.yaml"
  fi

  # ── Même en local, la boucle locale ne convient pas ──────────────────────
  #
  # Elle a l'air de convenir : le navigateur et le conteneur sont sur la même
  # machine, et Docker publie le port sur elle. C'est vrai de la signalisation,
  # que le navigateur ouvre lui-même par son URL. C'est faux d'ICE.
  #
  # Un navigateur ne collecte jamais de candidat sur sa boucle locale. Il n'a
  # donc aucune adresse à apparier avec un « 127.0.0.1 » annoncé par le
  # serveur, et la paire est écartée avant d'être essayée. Mesuré : zéro paquet
  # entrant sur le port média, tandis que le serveur s'épuisait à viser
  # l'adresse publique du poste — la seule que le navigateur lui offrait, ses
  # adresses locales étant masquées en noms mDNS que le conteneur ne résout pas.
  #
  # 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
    if [ -n "$adresse_interface" ]; then
      ecrire_config_livekit="oui"
      livekit_config="../docker/livekit.local.yaml"
      hote_public="$adresse_interface"
    else
      dire "  ⚠  Aucune adresse d'interface trouvée : le serveur média annoncera" \
           "  ⚠  No interface address found: the media server will announce the"
      dire "     la boucle locale, et l'appel échouera sur « ICE failed »." \
           "     loopback, and calls will fail on \"ICE failed\"."
    fi
  fi

  # umask dans un sous-shell : le fichier naît en 600, il n'est jamais lisible
  # par les autres — pas même le temps d'un chmod.
  (
@@ -1871,19 +1908,29 @@ FIN
  # Les ports déplacés doivent l'être des deux côtés. Le fichier livré reste
  # la référence commentée : on en écrit un second plutôt que de le réécrire.
  if [ "$ecrire_config_livekit" = "oui" ]; then
    if [ "$hote_public" = "localhost" ] || [ "$hote_public" = "127.0.0.1" ]; then
      # Le conteneur annoncerait sinon son adresse de pont, que le navigateur
      # de la machine hôte ne sait pas joindre — l'appel se monte et le média
      # ne passe jamais.
    livekit_adresse=""
    case "$hote_public" in
      localhost|127.0.0.1)
        # Ce cas ne devrait plus se produire : la décision plus haut remplace
        # une adresse locale par celle de l'interface. S'il survient, c'est
        # qu'aucune interface n'a été trouvée, et l'appel ne tiendra pas —
        # dit franchement plutôt que découvert en réunion.
        livekit_adresse="  node_ip: 127.0.0.1"
      # LiveKit refuse « localhost » comme domaine TURN : il en veut un vrai,
      # même s'il ne sert à rien tant que le TLS n'est pas configuré.
      [ -n "$livekit_turn_domaine" ] || livekit_turn_domaine="kastell.local"
    else
      # Sur un hôte joignable, les candidats doivent porter son adresse
      # publique : sinon ils annoncent celle du conteneur, et aucun navigateur
      # extérieur n'y accède. LiveKit refuse par ailleurs « localhost » comme
      # domaine TURN.
        ;;
      10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*)
        # Une adresse de réseau local s'annonce telle quelle. « use_external_ip »
        # ferait interroger un STUN public et annoncer l'adresse publique du
        # site : les navigateurs du même réseau la viseraient, et la plupart des
        # routeurs ne renvoient pas vers l'intérieur un paquet destiné à leur
        # propre adresse publique. L'appel échoue alors sans rien dire.
        livekit_adresse="  node_ip: $hote_public"
        ;;
    esac
    if [ -z "$livekit_adresse" ]; then
      # Reste le nom d'hôte joignable de l'extérieur. Là, les candidats doivent
      # porter l'adresse publique du site : sinon ils annoncent celle du
      # conteneur, que personne n'atteint. Et ce nom fait un domaine TURN
      # valable, ce qu'une adresse de réseau local n'est pas.
      livekit_adresse="  use_external_ip: true"
      [ -n "$livekit_turn_domaine" ] || livekit_turn_domaine="$hote_public"
    fi
@@ -1901,7 +1948,16 @@ FIN
    # terminaison devant lui serait annoncé aux navigateurs et n'y répondrait
    # jamais — une promesse de plus, et la pire, puisqu'elle ne se découvre
    # qu'en crise.
    if [ "$livekit_turn_tls" = "oui" ]; then
    if [ -z "$livekit_turn_domaine" ]; then
      # Aucun domaine : le relais reste éteint. LiveKit veut un nom résolvable,
      # et l'annoncer sous un nom qui ne résout nulle part ferait échouer le
      # dernier recours tout en le faisant croire disponible — c'est ainsi que
      # « kastell.local » a fait perdre une matinée. Sur un réseau local le
      # relais ne sert de toute façon à rien : les navigateurs joignent
      # directement l'adresse annoncée juste au-dessus.
      livekit_turn_bloc="turn:
  enabled: false"
    elif [ "$livekit_turn_tls" = "oui" ]; then
      livekit_turn_bloc="turn:
  enabled: true
  domain: $livekit_turn_domaine