Commit 3e5c722a authored by Kourser's avatar Kourser
Browse files

LiveKit et ClamAV démarrent avec la composition

Plus de profil « complet » à connaître : « pnpm demarrer » lève PostgreSQL,
Redis, MinIO, LiveKit, egress et ClamAV. Le script prépare aussi .env — il le
crée s'il manque, ajoute les clés apparues depuis, et tire au sort les secrets
sans jamais toucher à une valeur déjà renseignée.

  Les livrer en clair dans le dépôt donnerait à chaque instance auto-hébergée
  les mêmes clés que toutes les autres. Un secret publié n'est pas un secret,
  et celui de LiveKit ouvre les salles de crise.

Trois défauts que seules les vraies briques pouvaient révéler

  L'octet nul. ClamAV termine sa réponse INSTREAM par un octet nul, que
  PostgreSQL refuse dans du texte, JSON compris. L'insertion de l'événement
  échouait, donc le dépôt entier : avec un analyseur réel branché, plus aucun
  document ne se déposait. Le nettoyage se fait au point de passage unique
  plutôt que chez l'appelant — l'octet nul ne peut de toute façon exister
  nulle part dans cette base, et le laisser passer ne ferait que déplacer
  l'échec. Trois garanties de plus, dont la chaîne d'empreintes après nettoyage.

  Deux adresses pour un serveur média. « LIVEKIT_URL » est celle que compose le
  navigateur ; le serveur, lui, doit joindre « livekit:7880 » — « localhost »
  vu du conteneur applicatif le désigne lui-même. L'API média répondait
  ECONNREFUSED, que l'interface traduisait en diagnostic réseau, envoyant
  chercher une panne de pare-feu inexistante. D'où « LIVEKIT_URL_INTERNE ».

  Le micro faisait échouer l'appel. Il était activé dans le même « try » que la
  connexion : un poste sans micro, ou un navigateur où l'utilisateur a refusé
  l'accès, perdait l'appel entier et s'entendait répondre que le serveur média
  était injoignable. Connecté d'abord, micro ensuite : refus et absence sont
  distingués, et l'appel tient. Quelqu'un qui ne peut pas parler peut encore
  écouter, ce qui pendant une crise vaut beaucoup mieux que rien.

Perdre un appel n'est pas ne jamais l'avoir joint
  Le premier cas met en cause le chemin média — UDP filtré, réseau qui
  bascule ; le second, l'accès au serveur. Le panneau les distingue désormais,
  parce que les confondre fait chercher la panne au mauvais endroit.

Deux contraintes assumées
  ClamAV n'est publié qu'en amd64 : sur Apple Silicon, Docker l'émule. C'est
  délibérément l'image officielle — confier l'analyse antivirale d'un outil de
  crise à un portage qu'on ne sait pas qui construit déplacerait le problème.

  LiveKit refuse de démarrer avec « localhost » comme domaine TURN, et le TLS
  réclame un certificat. Le relais est donc en UDP seul en local ; la
  configuration de production, sur 443, est documentée dans livekit.yaml.

483 → 486 garanties. Éprouvé contre les vraies briques : ClamAV distingue une
pièce saine d'une pièce vérolée dans un salon, LiveKit crée la salle, émet le
jeton et enregistre le participant.

Le flux audio et vidéo lui-même n'est pas validé : le navigateur de contrôle
bloque le micro et filtre l'UDP. La signalisation va de bout en bout, le média
reste à confirmer dans un vrai navigateur.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent d34fff76
Loading
Loading
Loading
Loading
+14 −7
Original line number Diff line number Diff line
@@ -34,17 +34,24 @@ S3_BUCKET=kastell
S3_ACCES=kastell
S3_SECRET=CHANGEZ-MOI-secret-stockage

# Visioconférence (profil « complet »)
# Laisser vide désactive la visioconférence ; le produit l'annonce au démarrage.
# La clé doit faire au moins 32 caractères : openssl rand -hex 24
# Visioconférence — démarrée par défaut avec la composition.
# L'adresse est celle que le NAVIGATEUR compose, pas le serveur : elle doit
# rester joignable depuis les postes de la cellule.
# Laisser LIVEKIT_URL vide désactive la visioconférence ; le produit l'annonce.
LIVEKIT_URL=ws://localhost:7880
# Adresse de l'API média vue depuis le serveur. Dans la composition Docker,
# l'hôte est le nom du service : « localhost » y désigne le conteneur
# applicatif lui-même. Laissez vide hors conteneur.
LIVEKIT_URL_INTERNE=http://livekit:7880
LIVEKIT_CLE=kastell
LIVEKIT_SECRET=CHANGEZ-MOI-secret-media-au-moins-32-caracteres

# Analyse antivirale des dépôts (profil « complet »)
# Laisser vide désactive l'analyse : les documents sont alors marqués
# « non analysée », jamais « saine ».
CLAMAV_HOTE=
# Analyse antivirale des dépôts — démarrée par défaut avec la composition.
# Laisser CLAMAV_HOTE vide désactive l'analyse : les documents sont alors
# marqués « non analysée », jamais « saine ».
# Au premier démarrage, ClamAV télécharge ses signatures — quelques minutes
# pendant lesquelles les dépôts restent possibles, mais non analysés.
CLAMAV_HOTE=clamav
CLAMAV_PORT=3310

# Secrets applicatifs — générer avec: openssl rand -hex 32
+30 −0
Original line number Diff line number Diff line
@@ -380,6 +380,36 @@ La consultation hors ligne (EF-212) attend l'application installable en L10.
D'ici là, l'export papier tient ce rôle — et le tient mieux, puisqu'il survit à
une panne d'appareil.

## La composition démarre tout

`pnpm demarrer` lève **PostgreSQL, Redis, MinIO, LiveKit, egress et ClamAV**
plus de profil `complet` à connaître. Le script prépare aussi `.env` : il le
crée s'il manque, y ajoute les clés apparues depuis, et **tire au sort** les
secrets — sans jamais toucher à une valeur déjà renseignée.

Les secrets ne sont pas livrés en clair dans le dépôt : ils donneraient à
chaque instance auto-hébergée les mêmes clés que toutes les autres, et celle de
LiveKit ouvre les salles de crise.

Deux points valent d'être connus :

- **ClamAV n'existe qu'en amd64.** Sur un Mac Apple Silicon, Docker l'émule —
  plus lent à l'analyse, mais fidèle. C'est délibérément l'image officielle :
  confier l'analyse antivirale d'un outil de crise à un portage qu'on ne sait
  pas qui construit déplacerait le problème au lieu de le résoudre. Au premier
  démarrage il télécharge ses signatures pendant quelques minutes, durant
  lesquelles les dépôts restent possibles mais marqués « non analysée ».
- **Deux adresses pour le serveur média.** `LIVEKIT_URL` est celle que compose
  le navigateur, `LIVEKIT_URL_INTERNE` celle que compose le serveur — dans la
  composition, l'hôte est le nom du service, et « localhost » y désigne le
  conteneur applicatif lui-même.

Le relais TURN est en UDP seul en local : le TLS réclame un certificat et un
domaine résolvable. En production, activez `tls_port: 443` dans
[`docker/livekit.yaml`](docker/livekit.yaml) — 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.

## Diagnostic et supervision

L'onglet **État** de la console root dit ce que l'instance *contient*. L'onglet
+4 −1
Original line number Diff line number Diff line
@@ -68,7 +68,10 @@ export function antivirusClamav(hote: string, port = 3310, delaiMs = 60_000): An

        prise.on("close", () => {
          if (clos) return;
          const texte = reponse.trim();
          // clamd termine sa réponse par un octet nul. Il n'a rien à faire
          // dans un détail qu'on journalise : PostgreSQL le refuse dans du
          // texte, et « trim » ne l'enlève pas — ce n'est pas un blanc.
          const texte = reponse.split(String.fromCharCode(0)).join("").trim();
          if (texte.endsWith("OK")) finir({ verdict: "saine", detail: null });
          else if (texte.includes("FOUND")) {
            finir({ verdict: "infectee", detail: texte.replace(/^stream:\s*/, "") });
+17 −2
Original line number Diff line number Diff line
@@ -61,7 +61,21 @@ export interface Media {
}

export interface ConfigurationMedia {
  /**
   * L'adresse que compose le NAVIGATEUR pour rejoindre l'appel. Elle doit être
   * joignable depuis les postes de la cellule, pas depuis le serveur.
   */
  url: string;
  /**
   * L'adresse que compose le SERVEUR pour l'API d'administration des salles.
   *
   * Les deux diffèrent dès que l'application tourne en conteneur : le
   * navigateur voit « localhost:7880 », le serveur voit « livekit:7880 » sur
   * le réseau de la composition. Confondre les deux donne un ECONNREFUSED
   * côté serveur — que l'interface traduisait en diagnostic réseau, envoyant
   * chercher une panne chez l'utilisateur.
   */
  urlInterne?: string | undefined;
  cle: string;
  secret: string;
  /** Seau et identifiants du stockage où l'enregistrement composite atterrit. */
@@ -75,8 +89,9 @@ export interface ConfigurationMedia {
}

export function mediaLiveKit(c: ConfigurationMedia): Media {
  const salles = new RoomServiceClient(c.url, c.cle, c.secret);
  const egress = new EgressClient(c.url, c.cle, c.secret);
  const administration = c.urlInterne ?? c.url;
  const salles = new RoomServiceClient(administration, c.cle, c.secret);
  const egress = new EgressClient(administration, c.cle, c.secret);

  return {
    nom: "livekit",
+13 −5
Original line number Diff line number Diff line
@@ -88,10 +88,16 @@ async function sonder(env: Env, sql: Sql): Promise<Sonde[]> {
      const a = antivirus();
      if (a.nom === "inactif") throw new Error("non configuré");
      const r = await a.analyser(Readable.from([eicar()]));
      if (r.verdict !== "infectee") {
        throw new Error(`n'a pas reconnu le fichier d'essai (verdict « ${r.verdict} »)`);
      }
      return a.nom;
      if (r.verdict === "infectee") return a.nom;
      // Deux échecs très différents. Au premier démarrage, ClamAV télécharge
      // ses signatures pendant plusieurs minutes : il est injoignable, pas
      // défaillant. Confondre les deux ferait chercher une panne pendant que
      // le service se prépare tout seul.
      const injoignable = /injoignable|hors délai/i.test(r.detail ?? "");
      throw new Error(injoignable
        ? "ne répond pas encore — au premier démarrage, le téléchargement des "
          + "signatures prend plusieurs minutes"
        : `répond mais n'a pas reconnu le fichier d'essai (verdict « ${r.verdict} »)`);
    }),
    borner(async () => {
      await diffusion().presents("00000000-0000-0000-0000-000000000000");
@@ -122,7 +128,9 @@ async function sonder(env: Env, sql: Sql): Promise<Sonde[]> {
      + "« complet », ou renseignez LIVEKIT_URL, LIVEKIT_CLE et LIVEKIT_SECRET."),
    faire("antivirus", "Analyse antivirale", antivirus().nom !== "inactif", scan,
      "Les pièces déposées seront marquées « non analysée » plutôt que « saine » — "
      + "ce qui est honnête, mais laisse passer. Renseignez CLAMAV_HOTE."),
      + "ce qui est honnête, mais laisse passer. La composition démarre ClamAV par "
      + "défaut ; s'il vient de démarrer, laissez-lui le temps de télécharger ses "
      + "signatures. Sinon, vérifiez CLAMAV_HOTE."),
    faire("diffusion", "Temps réel", true, temps,
      "Le flux temps réel est dégradé : chaque écran se remet à jour à l'action, "
      + "sans propagation immédiate. Vérifiez REDIS_URL."),
Loading