Commit d34fff76 authored by Kourser's avatar Kourser
Browse files

Diagnostic d'instance et pièces jointes de salon

Deux demandes. La console root gagne un onglet qui dit si l'instance marche,
et les salons acceptent des fichiers.

Diagnostic : sonder, pas relire la configuration
  L'onglet « État » disait ce que l'instance contient. Celui-ci dit si elle
  marche, ce qui n'est pas la même question — et se répond en interrogeant
  chaque brique. Une variable d'environnement renseignée ne prouve rien : ce
  qu'on veut savoir la veille d'une crise, c'est si le stockage répond.

  Cinq sondes bornées à quatre secondes — un diagnostic qui pend parce qu'une
  brique est morte ne sert à rien. Base, stockage objet, média, antivirus,
  temps réel, chacune avec sa latence.

  La sonde antivirale présente le fichier d'essai EICAR et exige le verdict
  « infectée ». Un fichier sain dirait seulement que le service répond ; un
  analyseur qui répond « saine » à tout est plus dangereux qu'un analyseur
  absent, puisqu'il rassure. La chaîne est reconstituée à l'exécution, jamais
  écrite d'un seul tenant : un dépôt qui la contient littéralement finit en
  quarantaine sur le poste de qui le clone.

  Une brique absente est distinguée d'une brique en panne, et chacune porte ce
  qu'on perd et le geste qui la remet en service. S'y ajoutent la volumétrie
  détaillée, le poids des douze plus grosses tables — ce qui coûtera à
  sauvegarder et à restaurer —, l'activité sur 24 h et l'état du processus.

  EX-34 : les mêmes chiffres au format Prometheus sur /api/administration/
  metriques, réservés au root comme le reste de la console — la volumétrie
  d'une instance dit combien de crises y sont ouvertes. Une page de diagnostic
  se regarde ; une métrique se surveille, et alerte avant que quiconque ait
  pensé à ouvrir la console.

Pièces jointes : le serveur savait déjà, l'interface ne le montrait pas
  EF-703 existait côté serveur depuis le lot documentaire. Manquaient le
  trombone, le glisser-déposer, et l'affichage sous le message.

  Les pièces sont lues avec les messages, en une requête : une conversation de
  crise en porte beaucoup, et un aller-retour par message coûterait plus que la
  lecture elle-même.

  Le verdict d'analyse est affiché tel quel — « non analysée » n'est pas
  « saine », et le taire reviendrait à rassurer sans avoir rien vérifié.

  Un message sans texte mais avec une pièce reçoit pour texte le nom du
  fichier. Le journal se relit trois mois plus tard : « (sans texte) » n'y
  apprendrait rien à personne.

Un piège d'ordonnancement supprimé au passage
  Mes premiers contrôles de pièces jointes échouaient en « erreur interne ».
  Ce n'était pas la fonctionnalité : le stockage objet était branché au début
  de la suite « documents », laissant sans stockage toutes celles qui passent
  avant — dont le chat. Un adaptateur d'instance se configure au démarrage de
  l'instance, pas au milieu d'une suite, sinon l'ordre des vérifications
  devient une dépendance invisible. Il l'était devenu.

467 → 483 garanties. Éprouvé dans le navigateur : deux fichiers glissés,
envoyés, rendus sous le message avec leur taille et leur verdict, retrouvés au
dossier documentaire, retéléchargés avec le bon type et le bon contenu.

Le diagnostic, lui, n'est vérifié qu'au niveau des données — dix contrôles — et
non à l'écran : la console est réservée au root, et le seul root de cette
instance est son propriétaire.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 19b939a7
Loading
Loading
Loading
Loading
+47 −0
Original line number Diff line number Diff line
@@ -308,6 +308,22 @@ par la base, indexée par séquence : un client qui revient redemande « tout ce
qui suit N ». Une dépêche perdue ne fait jamais de trou dans la conversation —
c'est ce qui permet de travailler en 3G depuis un parking.

## Pièces jointes de salon

Le trombone, ou un fichier glissé sur la zone de saisie. La pièce **entre au
dossier documentaire de la cellule** (`Pièces jointes`) et le message la
référence : une conversation garde le fil, c'est la GED qui garde les pièces —
sinon on ne les retrouve plus trois mois après.

Elle traverse le même chemin que tout dépôt : quota, analyse antivirale,
empreinte, versionnement. Le verdict est affiché tel quel sous le message —
**« non analysée » n'est pas « saine »**, et le taire reviendrait à rassurer
sans avoir rien vérifié.

Un message sans texte mais avec une pièce reçoit pour texte le nom du fichier :
le journal se relit trois mois plus tard, et « (sans texte) » n'y apprendrait
rien à personne.

## La boucle décisionnelle

Ce qui sépare Kastell d'une messagerie. L'opérationnelle publie un **point de
@@ -364,6 +380,37 @@ 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.

## Diagnostic et supervision

L'onglet **État** de la console root dit ce que l'instance *contient*. L'onglet
**Diagnostic** dit si elle *marche* — et le dit en interrogeant réellement
chaque brique, parce qu'une variable d'environnement renseignée ne prouve rien :

- la base répond, et en combien de millisecondes ;
- le stockage objet répond à une lecture qui traverse tout le chemin réseau et
  d'authentification, sans rien écrire ;
- le serveur média est joignable ;
- **l'analyse antivirale détecte encore** — la sonde lui présente le fichier
  d'essai EICAR. Un analyseur qui répond « saine » à tout est plus dangereux
  qu'un analyseur absent, puisqu'il rassure ;
- le canal temps réel répond.

Une brique **absente** est distinguée d'une brique **en panne**, et chacune
porte ce qu'on perd et le geste qui la remet en service.

S'y ajoutent la volumétrie détaillée, le poids des douze plus grosses tables —
ce qui coûtera à sauvegarder et à restaurer —, l'activité des dernières 24 h et
l'état du processus.

```
GET /api/administration/metriques
```

Les mêmes chiffres au format Prometheus (EX-34), réservés au root comme le
reste de la console : la volumétrie d'une instance dit combien de crises y sont
ouvertes. Une page de diagnostic se regarde ; une métrique se surveille — et
alerte avant que quiconque ait pensé à ouvrir la console.

## Administration de l'instance

Un compte **root** administre l'instance : organisations, comptes, quotas,
+283 −0
Original line number Diff line number Diff line
import { Readable } from "node:stream";
import type { Sql } from "../db/client.js";
import type { Env } from "../env.js";
import { VERSION, LICENCE, revision } from "../version.js";
import { media, mediaConfigure } from "../adaptateurs/media.js";
import { stockage, stockageConfigure } from "../adaptateurs/stockage.js";
import { antivirus } from "../adaptateurs/antivirus.js";
import { diffusion } from "../adaptateurs/diffusion.js";

/**
 * Diagnostic de l'instance (EA-07, EX-34).
 *
 * L'écran d'état dit ce que l'instance contient. Celui-ci dit si elle marche —
 * ce qui n'est pas la même question, et se répond en interrogeant réellement
 * chaque brique plutôt qu'en relisant sa configuration. Une variable
 * d'environnement renseignée ne prouve rien : ce qu'on veut savoir, la veille
 * d'une crise, c'est si le stockage répond et si l'analyse antivirale détecte
 * encore quelque chose.
 *
 * Chaque sonde est bornée dans le temps. Un diagnostic qui pend parce qu'une
 * brique est morte est un diagnostic qui ne sert à rien.
 */

const DELAI_SONDE = 4000;

export interface Sonde {
  cle: string;
  libelle: string;
  configure: boolean;
  joignable: boolean | null;
  latence_ms: number | null;
  detail: string;
  /** Ce qu'il faut faire, quand il y a quelque chose à faire. */
  remede: string | null;
}

async function borner<T>(operation: () => Promise<T>): Promise<
  { ok: true; valeur: T; ms: number } | { ok: false; erreur: string; ms: number }
> {
  const debut = Date.now();
  try {
    const valeur = await Promise.race([
      operation(),
      new Promise<never>((_, rejeter) =>
        setTimeout(() => rejeter(new Error(`sans réponse après ${DELAI_SONDE} ms`)), DELAI_SONDE)),
    ]);
    return { ok: true, valeur, ms: Date.now() - debut };
  } catch (e) {
    return { ok: false, erreur: e instanceof Error ? e.message : String(e), ms: Date.now() - debut };
  }
}

/**
 * Fichier d'essai EICAR — la chaîne que tout antivirus s'engage à signaler.
 *
 * Reconstituée à l'exécution, et jamais écrite d'un seul tenant dans le
 * fichier : un dépôt qui la contient littéralement finit en quarantaine sur le
 * poste de travail de celui qui le clone.
 *
 * Sonder avec un fichier sain dirait seulement que le service répond. Ce qu'on
 * veut savoir, c'est s'il détecte encore — un analyseur qui répond « saine » à
 * tout est plus dangereux qu'un analyseur absent, puisqu'il rassure.
 */
function eicar(): Buffer {
  const parts = ["X5O!P%@AP[4\\PZX54(P^)7CC)7}", "$EICAR-STANDARD-ANTIVIRUS-",
    "TEST-FILE!$H+H*"];
  return Buffer.from(parts.join(""), "ascii");
}

async function sonder(env: Env, sql: Sql): Promise<Sonde[]> {
  const [base, objet, visio, scan, temps] = await Promise.all([
    borner(async () => {
      const [v] = await sql<{ v: string }[]>`select version() as v`;
      return v?.v.split(" ").slice(0, 2).join(" ") ?? "PostgreSQL";
    }),
    borner(async () => {
      if (!stockageConfigure()) throw new Error("non configuré");
      // Lecture d'une clé absente : elle traverse tout le chemin réseau et
      // d'authentification sans rien écrire.
      await stockage().taille("_sonde-de-diagnostic");
      return stockage().nom;
    }),
    borner(async () => {
      if (!mediaConfigure()) throw new Error("non configuré");
      return (await media().disponible()) ? media().nom : Promise.reject(new Error("injoignable"));
    }),
    borner(async () => {
      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;
    }),
    borner(async () => {
      await diffusion().presents("00000000-0000-0000-0000-000000000000");
      return diffusion().nom;
    }),
  ]);

  const faire = (
    cle: string, libelle: string, configure: boolean,
    r: Awaited<ReturnType<typeof borner<string>>>, remedeSiAbsent: string,
  ): Sonde => ({
    cle, libelle, configure,
    joignable: configure ? r.ok : null,
    latence_ms: configure ? r.ms : null,
    detail: !configure ? "Absent de cette instance."
      : r.ok ? r.valeur : r.erreur,
    remede: configure ? (r.ok ? null : remedeSiAbsent) : remedeSiAbsent,
  });

  return [
    faire("base", "Base de données", true, base,
      "Sans base, rien ne fonctionne : vérifiez DATABASE_URL et le service postgres."),
    faire("stockage", "Stockage objet", stockageConfigure(), objet,
      "Les dépôts de documents et les enregistrements sont impossibles. "
      + "Renseignez S3_ENDPOINT, S3_BUCKET, S3_ACCES et S3_SECRET."),
    faire("media", "Visioconférence", mediaConfigure(), visio,
      "Aucun appel ne peut s'établir. Démarrez la composition sous le profil "
      + "« 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."),
    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."),
  ];
}

export interface Diagnostic {
  instance: {
    version: string; revision: string | null; licence: string;
    environnement: string; url_publique: string;
    demarre_depuis_s: number; node: string; plateforme: string;
    memoire_rss_octets: number; memoire_tas_octets: number;
  };
  sondes: Sonde[];
  volumetrie: Record<string, number>;
  stockage: { documents_octets: string; enregistrements_octets: string; base_octets: string };
  tables: { nom: string; octets: string; lignes: number }[];
  activite: { evenements_24h: number; evenements_7j: number; messages_24h: number;
    sessions_actives: number; connexions_24h: number; echecs_24h: number };
  journal: { crises: number; evenements: number; dernier_at: Date | null };
}

export async function diagnostic(sql: Sql, env: Env): Promise<Diagnostic> {
  const [sondes, v, poids, tables, act, dernier] = await Promise.all([
    sonder(env, sql),
    sql<Record<string, string>[]>`
      select
        (select count(*) from message where supprime_at is null) as messages,
        (select count(*) from message_direct)                    as messages_directs,
        (select count(*) from document where supprime_at is null) as documents,
        (select count(*) from document_version)                  as versions_documents,
        (select count(*) from enregistrement)                    as enregistrements,
        (select count(*) from tableau)                           as tableaux,
        (select count(*) from tableau_instantane)                as instantanes_tableaux,
        (select count(*) from decision)                          as decisions,
        (select count(*) from action)                            as actions,
        (select count(*) from point_situation)                   as points_situation,
        (select count(*) from mobilisation_envoi)                as envois_mobilisation,
        (select count(*) from personne)                          as fiches_annuaire,
        (select count(*) from tiers)                             as tiers,
        (select count(*) from salon)                             as salons,
        (select count(*) from cellule)                           as cellules,
        (select count(*) from crise)                             as crises,
        (select count(*) from evenement)                         as evenements`,
    sql<{ documents: string; enregistrements: string; base: string }[]>`
      select
        (select coalesce(sum(taille), 0)::text from document_version)      as documents,
        (select coalesce(sum(taille), 0)::text from enregistrement)        as enregistrements,
        pg_database_size(current_database())::text                          as base`,
    // Ce qui pèse, et donc ce qui coûtera à sauvegarder et à restaurer.
    sql<{ nom: string; octets: string; lignes: number }[]>`
      select c.relname as nom,
             pg_total_relation_size(c.oid)::text as octets,
             greatest(c.reltuples, 0)::bigint as lignes
        from pg_class c join pg_namespace n on n.oid = c.relnamespace
       where n.nspname = 'public' and c.relkind = 'r'
       order by pg_total_relation_size(c.oid) desc
       limit 12`,
    sql<Record<string, string>[]>`
      select
        (select count(*) from evenement where occurred_at > now() - interval '24 hours') as evenements_24h,
        (select count(*) from evenement where occurred_at > now() - interval '7 days')   as evenements_7j,
        (select count(*) from message where envoye_at > now() - interval '24 hours')     as messages_24h,
        (select count(*) from session where expire_at > now() and revoque_at is null)    as sessions_actives,
        (select count(*) from tentative where reussie and at > now() - interval '24 hours')      as connexions_24h,
        (select count(*) from tentative where not reussie and at > now() - interval '24 hours')  as echecs_24h`,
    sql<{ at: Date | null }[]>`select max(occurred_at) as at from evenement`,
  ]);

  const memoire = process.memoryUsage();
  const nombre = (o: Record<string, string> | undefined, k: string) => Number(o?.[k] ?? 0);
  const compteurs = v[0] ?? {};
  const a = act[0] ?? {};

  return {
    instance: {
      version: VERSION,
      revision: revision(env),
      licence: LICENCE,
      environnement: env.KASTELL_ENV,
      url_publique: env.KASTELL_URL_PUBLIQUE,
      demarre_depuis_s: Math.round(process.uptime()),
      node: process.version,
      plateforme: `${process.platform}/${process.arch}`,
      memoire_rss_octets: memoire.rss,
      memoire_tas_octets: memoire.heapUsed,
    },
    sondes,
    volumetrie: Object.fromEntries(
      Object.keys(compteurs).map((k) => [k, nombre(compteurs, k)])),
    stockage: {
      documents_octets: poids[0]?.documents ?? "0",
      enregistrements_octets: poids[0]?.enregistrements ?? "0",
      base_octets: poids[0]?.base ?? "0",
    },
    tables,
    activite: {
      evenements_24h: nombre(a, "evenements_24h"),
      evenements_7j: nombre(a, "evenements_7j"),
      messages_24h: nombre(a, "messages_24h"),
      sessions_actives: nombre(a, "sessions_actives"),
      connexions_24h: nombre(a, "connexions_24h"),
      echecs_24h: nombre(a, "echecs_24h"),
    },
    journal: {
      crises: nombre(compteurs, "crises"),
      evenements: nombre(compteurs, "evenements"),
      dernier_at: dernier[0]?.at ?? null,
    },
  };
}

/**
 * Métriques au format texte de Prometheus (EX-34).
 *
 * Une page de diagnostic se regarde ; une métrique se surveille. Un outil de
 * crise doit pouvoir être supervisé depuis l'extérieur, par un système qui n'a
 * rien à voir avec lui — et qui alerte avant que quiconque ait pensé à ouvrir
 * la console.
 */
export function metriques(d: Diagnostic): string {
  const l: string[] = [];
  const metrique = (nom: string, aide: string, type: string,
    valeurs: [string, number][]) => {
    l.push(`# HELP ${nom} ${aide}`, `# TYPE ${nom} ${type}`);
    for (const [etiquettes, valeur] of valeurs) {
      l.push(`${nom}${etiquettes}${valeur}`);
    }
  };

  metrique("kastell_composant_joignable",
    "1 si la brique repond a sa sonde, 0 sinon. Absente si non configuree.",
    "gauge", d.sondes.filter((s) => s.configure)
      .map((s) => [`{composant="${s.cle}"} `, s.joignable ? 1 : 0] as [string, number]));
  metrique("kastell_composant_latence_ms", "Duree de la sonde du composant.", "gauge",
    d.sondes.filter((s) => s.configure && s.latence_ms !== null)
      .map((s) => [`{composant="${s.cle}"} `, s.latence_ms!] as [string, number]));
  metrique("kastell_objets_total", "Volumetrie par nature d'objet.", "gauge",
    Object.entries(d.volumetrie).map(([k, n]) => [`{nature="${k}"} `, n] as [string, number]));
  metrique("kastell_base_octets", "Taille de la base de donnees.", "gauge",
    [[" ", Number(d.stockage.base_octets)]]);
  metrique("kastell_documents_octets", "Octets stockes pour les documents.", "gauge",
    [[" ", Number(d.stockage.documents_octets)]]);
  metrique("kastell_evenements_24h", "Evenements journalises sur 24 heures.", "gauge",
    [[" ", d.activite.evenements_24h]]);
  metrique("kastell_sessions_actives", "Sessions ouvertes et non expirees.", "gauge",
    [[" ", d.activite.sessions_actives]]);
  metrique("kastell_authentification_24h", "Tentatives d'authentification sur 24 heures.",
    "gauge", [['{issue="reussie"} ', d.activite.connexions_24h],
      ['{issue="echec"} ', d.activite.echecs_24h]]);
  metrique("kastell_demarre_depuis_secondes", "Duree depuis le demarrage du processus.",
    "gauge", [[" ", d.instance.demarre_depuis_s]]);
  metrique("kastell_memoire_octets", "Memoire du processus.", "gauge",
    [['{genre="rss"} ', d.instance.memoire_rss_octets],
      ['{genre="tas"} ', d.instance.memoire_tas_octets]]);

  return l.join("\n") + "\n";
}
+25 −1
Original line number Diff line number Diff line
@@ -43,6 +43,18 @@ export interface Message {
  epingle_par: string | null;
  promu_seq: string | null;
  reactions?: { emoji: string; comptes: string[] }[];
  /** Pièces jointes (EF-703). Elles vivent à la GED de la cellule, pas dans
   *  le message : ce qui est joint à une conversation se retrouve trois mois
   *  plus tard dans le dossier, pas en remontant un fil. */
  pieces?: PieceJointe[];
}

export interface PieceJointe {
  id: string;
  titre: string;
  type_mime: string;
  taille: string;
  verdict: string | null;
}

// ── Salons (EF-701) ────────────────────────────────────────────────────────
@@ -297,7 +309,19 @@ export async function messages(
                        from message_reaction
                       where crise_id = m.crise_id and message_id = m.id
                       group by emoji) r),
             '[]'::jsonb) as reactions
             '[]'::jsonb) as reactions,
           coalesce(
             (select jsonb_agg(jsonb_build_object(
                       'id', d.id, 'titre', d.titre, 'type_mime', v.type_mime,
                       'taille', v.taille::text, 'verdict', v.verdict)
                       order by d.cree_at)
                from document d
                join document_version v
                  on v.crise_id = d.crise_id and v.document_id = d.id
                 and v.version = d.version_courante
               where d.crise_id = m.crise_id and d.message_id = m.id
                 and d.supprime_at is null),
             '[]'::jsonb) as pieces
      from message m
      left join compte c on c.id = m.auteur_id
     where m.crise_id = ${criseId} and m.salon_id = ${salonId}
+20 −0
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { Sql } from "../db/client.js";
import * as console_ from "../administration/console.js";
import * as etat from "../administration/etat.js";
import * as diagnostic from "../administration/diagnostic.js";
import { roleDans } from "../auth/comptes.js";
import * as sessions from "../auth/sessions.js";
import { ouvrirAccesExceptionnel } from "../noyau/administration.js";
@@ -59,6 +60,25 @@ export function enregistrerRoutesAdministration(app: FastifyInstance, sql: Sql,
  app.post("/administration/integrite", console_root, async (req) =>
    etat.verifierTousLesJournaux(sql, moi(req)));

  // ── Diagnostic et supervision (EA-07, EX-34) ─────────────────────────────
  app.get("/administration/diagnostic", console_root, async (_req, reply) => {
    reply.header("cache-control", "no-store");
    return diagnostic.diagnostic(sql, env);
  });

  /**
   * Métriques de supervision. Réservées au root comme le reste de la console :
   * la volumétrie d'une instance dit combien de crises y sont ouvertes, ce qui
   * n'a pas à être public. Un collecteur s'y authentifie avec un compte dédié.
   */
  app.get("/administration/metriques", console_root, async (_req, reply) => {
    const d = await diagnostic.diagnostic(sql, env);
    return reply
      .header("content-type", "text/plain; version=0.0.4; charset=utf-8")
      .header("cache-control", "no-store")
      .send(diagnostic.metriques(d));
  });

  // ── Le droit root (EA-01, EA-02, EA-14) ──────────────────────────────────
  app.get("/administration/roots", console_root, async () => ({
    roots: await console_.listerRoots(sql),
+26 −0
Original line number Diff line number Diff line
import { randomUUID } from "node:crypto";
import { chargerEnv } from "./env.js";
import { definirStockage, stockageS3 } from "./adaptateurs/stockage.js";
import { definirAntivirus } from "./adaptateurs/antivirus.js";
import { antivirusEssai } from "./verification/antivirus_essai.js";
import { ouvrirBase } from "./db/client.js";
import { migrer } from "./db/migrate.js";
import {
@@ -94,6 +97,29 @@ await sql.unsafe("drop schema public cascade; create schema public;");

interceptet();

/**
 * Le stockage objet est branché ici, avant toute suite.
 *
 * Il l'était au début de la suite « documents », ce qui laissait sans stockage
 * toutes celles qui passent avant — le chat notamment, dont les pièces jointes
 * échouaient alors par « erreur interne ». Un adaptateur d'instance se
 * configure au démarrage de l'instance, pas au milieu d'une suite : sinon
 * l'ordre des vérifications devient une dépendance invisible.
 */
const s3 = stockageS3({
  endpoint: process.env["S3_ENDPOINT"], region: process.env["S3_REGION"] ?? "eu-west-1",
  seau: process.env["S3_BUCKET"] ?? "kastell",
  acces: process.env["S3_ACCES"] ?? "", secret: process.env["S3_SECRET"] ?? "",
});
export let stockageJoignable = true;
try {
  await s3.preparer();
  definirStockage(s3);
  definirAntivirus(antivirusEssai);
} catch {
  stockageJoignable = false;
}

console.log("\nMigrations :");
const faites = await migrer(sql);
if (faites.length === 0) console.log("  (aucune, base à jour)");
Loading