Commit b9c0320c authored by Kourser's avatar Kourser
Browse files

Le dernier chemin existe enfin

La composition publiait un port TURN TLS depuis toujours, aucune
configuration ne posait « tls_port », et la documentation le promettait
quand même comme « le dernier chemin quand le pare-feu bloque tout le
reste ». Le port était ouvert, personne n'écoutait derrière, et cela ne se
découvrait qu'au pire moment : depuis un réseau d'entreprise qui ne laisse
sortir que 443, ni l'UDP direct, ni le RTC en TCP, ni le TURN en UDP ne
passent — la cellule repliée à l'hôtel n'entrait dans aucun appel.

L'installateur le configure désormais, et le propose dès que l'adresse
publique n'est pas locale.

J'ai vérifié plutôt que supposé ce que fait « external_tls », en sondant
les serveurs ICE qu'un vrai client reçoit de la version 1.13.5 :

  turn:127.0.0.1:3478?transport=udp
  turns:turn.kastell.local:443?transport=tcp

« tls_port » n'est donc pas le port annoncé : c'est celui où LiveKit attend
en clair ce que la terminaison TLS lui transmet, et c'est 443 qu'il
annonce. Trois commentaires du dépôt disaient le contraire — « activez
tls_port: 443 », « publiez ce port sur 443 » — et envoyaient configurer
une chose impossible. Ils sont corrigés.

La moitié qui revient à l'exploitant est documentée, avec son piège. Le
TURN sur TLS n'est pas du HTTP : un bloc « server » de nginx ne sait pas le
porter, il faut le module « stream » et un aiguillage par SNI. Et comme
« stream » et « http » ne peuvent pas écouter tous deux sur 443, les vhosts
descendent d'un port — après quoi ils ne voient plus l'adresse du client
mais 127.0.0.1. Dans ce produit, cette adresse nourrit la limitation de
débit, l'ouverture des partages et le journal d'administration : un journal
append-only qui consigne la mauvaise adresse est pire qu'un journal qui
n'en consigne aucune. La documentation le dit, et donne l'alternative — une
seconde IP, quelques euros, nginx intact.

Ce que je n'ai pas fait : LiveKit terminant lui-même le TLS avec ses
propres certificats. Cela demande de monter des fichiers dans le conteneur
et je n'avais pas de quoi l'éprouver ici — un certificat valide et un
domaine résolvable. Je préfère ne pas livrer un chemin que je n'ai pas vu
fonctionner.

Une suite neuve exécute le morceau de l'installateur qui décide et qui
écrit, puis lit ce qui en sort — plutôt que d'y chercher des expressions
rationnelles, qui seraient vertes le jour où le shell change d'avis. Huit
garanties : le poste local garde le fichier livré, l'hôte joignable reçoit
le sien avec son adresse publique et ses ports, aucun « tls_port » n'est
écrit tant qu'on ne l'a pas demandé, et demandé il vient avec son
« external_tls » et le nom distinct que l'exploitant a donné.

984 garanties.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 7383ab06
Loading
Loading
Loading
Loading
Loading
+7 −0
Changes for .env.example: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -132,6 +132,13 @@ LIVEKIT_EGRESS=oui
LIVEKIT_PORT=7880
LIVEKIT_PORT_RTC_TCP=7881
LIVEKIT_PORT_TURN=3478
# Le port en clair du relais TURN sur TLS, et non le 443 : LiveKit ne termine
# pas ce TLS, votre terminaison le fait et lui transmet le trafic ici.
# L'installateur le configure ; sans cela rien n'écoute derrière.
#
# EN — The clear-text port of the TLS TURN relay, not 443: LiveKit does not
# EN   terminate that TLS, your terminator does and forwards the traffic here.
# EN   The installer configures it; without that, nothing listens behind it.
LIVEKIT_PORT_TURN_TLS=5349
LIVEKIT_PORT_MEDIA=50000
LIVEKIT_CONFIG=../docker/livekit.yaml
+2 −0
Changes for apps/api/src/verif.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import { verifierRelais } from "./verification/relais.js";
import { verifierCourriel } from "./verification/courriel.js";
import { verifierComposition } from "./verification/composition.js";
import { verifierRenforts } from "./verification/renforts.js";
import { verifierInstallateur } from "./verification/installateur.js";
import { verifierCanaux } from "./verification/canaux.js";
import { verifierContinuite } from "./verification/continuite.js";
import { verifierVeille } from "./verification/veille.js";
@@ -353,6 +354,7 @@ await verifierAmorceParLien(sql, env, rapport);

// ── L'image et la composition (EX-27) ─────────────────────────────────────
await verifierComposition(rapport);
await verifierInstallateur(rapport);

// ── Canaux de diffusion et envois de masse (§6.11) ────────────────────────
await verifierCanaux(sql, app, rapport);
+107 −0
Changes for apps/api/src/verification/installateur.ts: 107 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import type { Rapport } from "./rapport.js";

const executer = promisify(execFile);
const racine = fileURLToPath(new URL("../../../../", import.meta.url));

/**
 * Ce que l'installateur écrit pour le serveur média.
 *
 * Deux défauts sont passés par là, et aucun ne se voyait en relisant le script.
 *
 *   Le fichier de configuration n'était écrit que sous la voie « cette machine
 *   porte déjà un LiveKit, on déplace les ports ». Prendre la voie ordinaire
 *   sur un vrai serveur y laissait la configuration du poste de développement,
 *   qui annonce une adresse qu'aucun navigateur extérieur ne joint. L'appel
 *   échouait sur « ICE failed », sans rien qui désigne la cause.
 *
 *   Et le relais TURN sur 443 — le dernier chemin depuis un réseau qui ne
 *   laisse sortir que ce port — n'était configurable nulle part. La
 *   composition publiait son port, aucune configuration ne posait « tls_port »,
 *   et la documentation le promettait quand même.
 *
 * On n'éprouve donc pas le texte du script : on exécute le morceau qui décide
 * et qui écrit, et on lit ce qui en sort. Un contrôle qui relirait des
 * expressions rationnelles dans du shell serait vert le jour où le shell
 * change d'avis.
 */
export async function verifierInstallateur(r: Rapport): Promise<void> {
  r.titre("Ce que l'installateur écrit pour le média");

  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 => {
    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 decision = bloc("# ── Toute machine joignable autrement");
  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. */
  async function jouer(v: Record<string, string>): Promise<{
    config: string; contenu: string;
  }> {
    const dossier = await mkdtemp(join(tmpdir(), "kastell-installateur-"));
    await mkdir(join(dossier, "docker"), { recursive: true });
    const prelude = [
      "set -eu",
      "LANGUE=fr; dire() { :; }",
      ...Object.entries(v).map(([c, x]) => `${c}='${x}'`),
    ].join("\n");
    await writeFile(join(dossier, "essai.sh"),
      `${prelude}\n${decision}\n${ecriture}\nprintf '%s' "$livekit_config" > .config\n`);
    await executer("sh", ["essai.sh"], { cwd: dossier });
    const config = await readFile(join(dossier, ".config"), "utf8");
    const contenu = await readFile(join(dossier, "docker/livekit.local.yaml"), "utf8")
      .catch(() => "");
    return { config, contenu };
  }

  const commun = {
    livekit_port: "7890", livekit_port_rtc_tcp: "7891", livekit_port_turn: "3479",
    livekit_port_turn_tls: "5350", livekit_port_media: "50200",
    livekit_config: "../docker/livekit.yaml", ecrire_config_livekit: "non",
    livekit_mode: "livre", livekit_turn_tls: "non", livekit_turn_domaine: "",
  };

  // ── Un poste de développement garde le fichier livré ─────────────────────
  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);

  // ── 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",
    serveur.config === "../docker/livekit.local.yaml", serveur.config);
  r.verifie("EF-803", "qui annonce son adresse publique et non celle du conteneur",
    /^\s*use_external_ip:\s*true\s*$/m.test(serveur.contenu)
      && !/node_ip:\s*127\.0\.0\.1/.test(serveur.contenu));
  r.verifie("EF-803", "et reprend les ports retenus",
    /^\s*udp_port:\s*50200\s*$/m.test(serveur.contenu)
      && /^\s*tcp_port:\s*7891\s*$/m.test(serveur.contenu));

  // ── Le relais TURN sur 443, quand il est demandé ─────────────────────────
  r.verifie("EF-804", "sans relais TLS demandé, aucun tls_port n'est écrit",
    !/tls_port:/.test(serveur.contenu));

  const avecTls = await jouer({
    ...commun, hote_public: "kastell.exemple.fr",
    livekit_turn_tls: "oui", livekit_turn_domaine: "turn.kastell.exemple.fr",
  });
  r.verifie("EF-804", "demandé, le relais écoute en clair sur le port retenu",
    /^\s*tls_port:\s*5350\s*$/m.test(avecTls.contenu));
  r.verifie("EF-804", "et annonce « turns://…:443 » via external_tls",
    /^\s*external_tls:\s*true\s*$/m.test(avecTls.contenu));
  r.verifie("EF-804", "sous le nom distinct que l'exploitant a donné",
    /^\s*domain:\s*turn\.kastell\.exemple\.fr\s*$/m.test(avecTls.contenu));
}
+9 −2
Changes for docker/compose.yaml: 9 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -135,8 +135,15 @@ services:
      - "${LIVEKIT_PORT:-7880}:${LIVEKIT_PORT:-7880}"
      - "${LIVEKIT_PORT_RTC_TCP:-7881}:${LIVEKIT_PORT_RTC_TCP:-7881}"
      - "${LIVEKIT_PORT_TURN:-3478}:${LIVEKIT_PORT_TURN:-3478}/udp"
      # En production, publiez ce port sur 443 : c'est le dernier chemin quand
      # le pare-feu du client bloque tout le reste (EF-804).
      # Le relais TURN sur TLS (EF-804), dernier chemin quand le pare-feu du
      # client ne laisse sortir que 443. Ce port-ci n'est PAS le 443 : LiveKit
      # ne termine pas le TLS, il attend en clair ici ce que votre terminaison
      # lui transmet, et annonce « turns://<nom>:443 » aux navigateurs.
      #
      # Tant que « tls_port » n'est pas posé dans la configuration de LiveKit,
      # rien n'écoute derrière ce port. L'installateur le propose dès que
      # l'adresse publique n'est pas locale ; docs/exploitation.md donne la
      # moitié qui vous revient, côté nginx.
      - "${LIVEKIT_PORT_TURN_TLS:-5349}:${LIVEKIT_PORT_TURN_TLS:-5349}"
      # Un seul port UDP, multiplexé. L'ancien nom est encore lu : un .env
      # qui n'a pas été migré porte une plage, dont le premier numéro est
+9 −3
Changes for docker/livekit.yaml: 9 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -41,9 +41,15 @@ rtc:
# résolvable, que la machine d'un développeur n'a pas. LiveKit refuse d'ailleurs
# de démarrer avec « localhost » — le domaine doit en être un.
#
# En production, activez « tls_port: 443 » avec vos certificats. C'est le seul
# port que tous les pare-feux laissent passer, et souvent le seul chemin depuis
# l'hôtel où la cellule s'est repliée.
# En production, il faut le relais sur 443 : c'est le seul port que tous les
# pare-feux laissent passer, et souvent le seul chemin depuis l'hôtel où la
# cellule s'est repliée. Mais « tls_port » n'est PAS 443 — LiveKit ne termine
# pas ce TLS. Il attend en clair sur ce port ce que votre terminaison lui
# transmet, et annonce « turns://<nom>:443 » aux navigateurs, à condition
# qu'« external_tls » soit posé.
#
# 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
Loading