Commit f72b9327 authored by Kourser's avatar Kourser
Browse files

Le courriel part enfin, et refuse de partir en clair

Sans transport, personne n'entre : ce sont les liens de vérification et de
connexion qui passent par là. L'instance le savait et refusait de démarrer en
production plutôt que de les écrire dans « docker logs » — un refus juste,
mais qui ne pouvait être satisfait par rien. Il peut l'être maintenant.

SMTP, et pas l'API d'un fournisseur
  C'est la dépendance à ne pas choisir pour la commodité de son intégration.
  SMTP est ce que toute organisation possède déjà, chez elle ou chez son
  hébergeur, et il ne fait de personne un point de passage obligé — ce qui est
  la raison d'être du produit. Les passerelles Brevo ou OVHcloud qu'une
  organisation branche restent à leur place : elles servent la diffusion de
  masse, pas l'authentification.

  nodemailer plutôt qu'un client écrit à la main. Négocier STARTTLS,
  s'authentifier et encoder du MIME sans faute est un travail dont le rater
  coûte des comptes ; ce n'est pas là qu'on gagne à improviser.

Le chiffrement n'est pas un réglage
  Hors boucle locale, STARTTLS est exigé et l'envoi échoue si le serveur ne
  l'offre pas. nodemailer se rabattrait volontiers en clair, et c'est ce repli
  silencieux qu'on refuse : un lien de connexion sur le fil est le même trou
  que dans un journal, avec un public plus large.

  Il n'existe pas d'interrupteur pour l'ôter — il n'aurait qu'un mauvais
  usage. La seule dispense est le relais en boucle locale, où rien ne quitte
  la machine : elle est déduite de l'hôte, pas offerte en option.

  Le mot de passe n'est pas demandé par l'installateur. Il vient de l'extérieur,
  comme la clé d'Apple, et se colle dans .env pour la même raison.

Ce que le contrôle éprouve
  Un serveur SMTP minimal est levé sur la boucle locale, le temps d'une
  transaction, et l'on relit ce qui est réellement passé sur le fil :
  expéditeur, destinataire, sujet, corps. Puis la décision qui ne se voit pas
  à l'usage — STARTTLS exigé d'un relais distant, toléré en local, et le port
  465 qui chiffre d'emblée. Dix garanties.

  Une décision qu'on ne peut pas relire est une décision qu'on croit prise :
  les options remises à nodemailer sont isolées dans une fonction, pour être
  contrôlables plutôt que supposées.

L'installateur suit
  Il propose le relais, avec « oui » par défaut en production, et dit dans le
  récapitulatif d'où partiront les courriels. Choisir la production sans
  relais est encore possible, et prévenu avant la levée plutôt que découvert
  dans une boucle de redémarrage.

Contrôlé
  Les dix garanties passent. La pile démarre en production avec SMTP_HOST
  renseigné, là où elle bouclait ; sans lui, elle refuse toujours, et le refus
  nomme désormais les deux variables à écrire.

Réserve : « pnpm verif » ne va pas jusqu'à ce contrôle, la suite s'arrêtant
plus tôt sur un import manquant introduit par cc663d8d —
verification/relais.ts appelle apps/relais/src/chargeJwt.ts, qui n'a jamais
existé ; le fichier livré s'appelle fcm.ts. Défaut antérieur et distinct,
laissé tel quel : le corriger demande de savoir ce que ce commit voulait.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 7773551d
Loading
Loading
Loading
Loading
Loading
+31 −0
Changes for .env.example: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -162,6 +162,37 @@ CLAMAV_PORT=3310
# EN — Application secrets — generate with: openssl rand -hex 32
SESSION_SECRET=CHANGEZ-MOI-secret-session-64-caracteres-hexadecimaux

# ── Courriel : par où partent les liens de connexion ───────────────────────
#
# Ce transport porte les liens de vérification et de connexion : sans lui,
# personne n'entre. SMTP_HOST vide laisse le transport « console », qui écrit
# les courriels dans les traces — commode pour dérouler tout le parcours sans
# serveur de messagerie, et refusé en production, où ces traces contiendraient
# des liens de connexion valides.
#
# Le chiffrement n'est pas une option. Hors boucle locale, STARTTLS est exigé
# et l'envoi échoue si le serveur ne l'offre pas : un lien de connexion en
# clair sur le réseau est le même trou que dans un journal, avec un public plus
# large. SMTP_SECURE=true bascule sur le TLS dès la connexion, port 465.
#
# EN — This transport carries the verification and sign-in links: without it,
# EN   nobody gets in. An empty SMTP_HOST leaves the "console" transport, which
# EN   writes emails to the logs — handy for walking the whole path without a
# EN   mail server, and refused in production, where those logs would contain
# EN   valid sign-in links.
# EN
# EN   Encryption is not optional. Outside the loopback, STARTTLS is required
# EN   and sending fails if the server does not offer it: a sign-in link in the
# EN   clear on the wire is the same hole as in a log, with a wider audience.
# EN   SMTP_SECURE=true switches to TLS from connection, port 465.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASSWORD=
# Requis dès que SMTP_HOST est renseigné. / Required as soon as SMTP_HOST is set.
SMTP_FROM=Kastell <crise@exemple.org>

# ── Notification mobile : le relais de heurtoir (EM-82 à EM-84) ─────────────
# ── Mobile notification: the knocker relay (EM-82 to EM-84) ────────────────
#
+2 −0
Changes for apps/api/package.json: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -20,11 +20,13 @@
    "fastify": "^5.2.0",
    "ioredis": "^6.0.0",
    "livekit-server-sdk": "^2.18.0",
    "nodemailer": "^9.0.6",
    "postgres": "^3.4.5",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@types/node": "^22.10.2",
    "@types/nodemailer": "^8.0.1",
    "@types/ws": "^8.18.1",
    "esbuild": "^0.24.2",
    "tsx": "^4.19.2",
+105 −0
Changes for apps/api/src/adaptateurs/smtp.ts: 105 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { createTransport } from "nodemailer";
import type { Message, Transport } from "./notify.js";

/**
 * Transport de courriel par SMTP.
 *
 * Pourquoi SMTP, et pas l'API d'un fournisseur
 *   Ce transport porte les liens de vérification et de connexion : sans lui,
 *   personne n'entre. C'est donc la dépendance à ne pas choisir sur la foi
 *   d'une intégration commode. SMTP est ce que toute organisation possède
 *   déjà, chez elle ou chez son hébergeur, et il ne fait de personne un point
 *   de passage obligé — ce qui est la raison d'être du produit.
 *
 *   Les passerelles d'un fournisseur (Brevo, OVHcloud) restent branchées par
 *   chaque organisation pour les envois de masse, où elles ont leur place :
 *   elles servent la diffusion, pas l'authentification.
 *
 * Ce qui n'est pas négociable
 *   Un lien de connexion en clair sur le réseau est le même trou que dans les
 *   traces, avec un public plus large. Le chiffrement est donc exigé, et la
 *   seule dispense est le relais en boucle locale, où rien ne quitte la
 *   machine. Il n'y a pas de réglage pour l'ôter : ce serait offrir un
 *   interrupteur qui n'a qu'un mauvais usage.
 */

export interface ConfigurationSmtp {
  hote: string;
  port: number;
  /** TLS dès la connexion (465) plutôt que négocié par STARTTLS (587). */
  implicite: boolean;
  utilisateur?: string | undefined;
  motDePasse?: string | undefined;
  /** L'expéditeur, tel qu'il apparaît. « Kastell <crise@exemple.org> ». */
  expediteur: string;
}

/** Une adresse qui ne quitte pas la machine, seul cas où le clair se défend. */
function boucleLocale(hote: string): boolean {
  return hote === "localhost" || hote === "127.0.0.1" || hote === "::1"
    || hote === "[::1]";
}

/**
 * Les options remises à nodemailer, isolées pour être contrôlables.
 *
 * La décision qui compte tient en un booléen — exiger STARTTLS, ou non — et
 * une décision qu'on ne peut pas relire est une décision qu'on croit prise.
 */
export function optionsSmtp(c: ConfigurationSmtp): {
  host: string; port: number; secure: boolean; requireTLS: boolean;
  auth?: { user: string; pass: string };
  connectionTimeout: number; greetingTimeout: number; socketTimeout: number;
} {
  const local = boucleLocale(c.hote);
  return {
    host: c.hote,
    port: c.port,
    secure: c.implicite,
    // STARTTLS obligatoire hors boucle locale : sans cela, nodemailer se
    // rabattrait en clair devant un serveur qui ne l'annonce pas, et le
    // repli silencieux est précisément ce qu'on refuse ici.
    requireTLS: !c.implicite && !local,
    ...(c.utilisateur
      ? { auth: { user: c.utilisateur, pass: c.motDePasse ?? "" } }
      : {}),
    // Un relais injoignable ne doit pas retenir la requête qui l'attend : en
    // crise, une page qui ne répond plus est lue comme une panne générale.
    connectionTimeout: 10_000,
    greetingTimeout: 10_000,
    socketTimeout: 20_000,
  };
}

export function transportSmtp(c: ConfigurationSmtp): Transport {
  const envoyeur = createTransport(optionsSmtp(c));

  return {
    nom: "smtp",
    async envoyer(message: Message): Promise<void> {
      await envoyeur.sendMail({
        from: c.expediteur,
        to: message.destinataire,
        subject: message.sujet,
        text: message.texte,
      });
    },
  };
}

/**
 * Interroge réellement le relais, sans rien envoyer.
 *
 * La sonde d'état mesure ce qui marche, pas ce qui est renseigné : une
 * adresse écrite dans le .env ne prouve rien, et « configuré » n'est pas
 * « joignable ». Le contrôle ouvre la connexion, négocie le chiffrement et
 * s'authentifie, puis raccroche.
 */
export async function verifierSmtp(c: ConfigurationSmtp): Promise<void> {
  const envoyeur = createTransport(optionsSmtp(c));
  try {
    await envoyeur.verify();
  } finally {
    envoyeur.close();
  }
}
+29 −1
Changes for apps/api/src/env.ts: 29 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -83,7 +83,35 @@ const schema = z.object({
   * confiance à tout le monde : à ne poser que si rien d'autre n'atteint le port.
   */
  KASTELL_TRUSTED_PROXY: z.string().optional(),
});

  /**
   * Le relais SMTP par lequel partent les liens de vérification et de
   * connexion (EX-26).
   *
   * SMTP_HOST vide laisse le transport « console », qui écrit les courriels
   * dans les traces : commode pour dérouler tout le parcours sans serveur de
   * messagerie, et refusé en production, où ces traces contiendraient des
   * liens de connexion valides.
   *
   * Le chiffrement n'est pas une option : hors boucle locale, STARTTLS est
   * exigé. Un lien de connexion en clair sur le réseau est le même trou que
   * dans un journal, avec un public plus large.
   */
  SMTP_HOST: z.string().optional(),
  SMTP_PORT: z.coerce.number().int().positive().default(587),
  /** TLS dès la connexion (465). Sinon, STARTTLS sur 587. */
  SMTP_SECURE: z.enum(["true", "false"]).default("false"),
  SMTP_USER: z.string().optional(),
  SMTP_PASSWORD: z.string().optional(),
  /** L'expéditeur tel qu'il apparaît : « Kastell <crise@exemple.org> ». */
  SMTP_FROM: z.string().optional(),
}).refine(
  // Un relais sans expéditeur part au refus du serveur distant, à la première
  // demande de connexion — c'est-à-dire au pire moment, et pour une valeur
  // qu'on pouvait exiger au démarrage.
  (e) => !e.SMTP_HOST || (e.SMTP_FROM !== undefined && e.SMTP_FROM !== ""),
  { message: "SMTP_FROM est requis dès que SMTP_HOST est renseigné", path: ["SMTP_FROM"] },
);

export type Env = z.infer<typeof schema>;

+18 −3
Changes for apps/api/src/index.ts: 18 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -17,7 +17,8 @@ import { antivirus, antivirusClamav, definirAntivirus } from "./adaptateurs/anti
import { definirStockage, stockageS3 } from "./adaptateurs/stockage.js";
import { definirMedia, media, mediaConfigure, mediaLiveKit } from "./adaptateurs/media.js";
import { definirPoussee, pousseeRelais } from "./adaptateurs/poussee.js";
import { transportActuel } from "./adaptateurs/notify.js";
import { definirTransport, transportActuel } from "./adaptateurs/notify.js";
import { transportSmtp } from "./adaptateurs/smtp.js";
import { autoriserReseauLocal } from "./adaptateurs/sortant.js";
import { demarrerEntretien } from "./entretien.js";
import { construireServeur } from "./serveur.js";
@@ -69,6 +70,20 @@ if (env.LIVEKIT_URL && env.LIVEKIT_KEY && env.LIVEKIT_SECRET) {
  }));
}

// Le relais SMTP, dès qu'il est désigné. Sans lui, le transport reste
// « console » — commode en développement, refusé en production quelques
// lignes plus bas.
if (env.SMTP_HOST) {
  definirTransport(transportSmtp({
    hote: env.SMTP_HOST,
    port: env.SMTP_PORT,
    implicite: env.SMTP_SECURE === "true",
    utilisateur: env.SMTP_USER,
    motDePasse: env.SMTP_PASSWORD,
    expediteur: env.SMTP_FROM ?? "",
  }));
}

// Le transport « console » écrit le corps des courriels dans les traces. C'est
// ce qui permet de dérouler tout le parcours sans serveur de messagerie — et
// c'est aussi, en production, un lien de connexion valide offert à quiconque
@@ -78,8 +93,8 @@ if (env.LIVEKIT_URL && env.LIVEKIT_KEY && env.LIVEKIT_SECRET) {
if (env.KASTELL_ENV === "production" && transportActuel().nom === "console") {
  throw new Error(
    "Aucun transport de courriel n'est configuré : le transport « console » écrirait "
    + "les liens de connexion en clair dans les traces. Configurez un transport avant "
    + "d'exposer cette instance.",
    + "les liens de connexion en clair dans les traces. Renseignez SMTP_HOST et "
    + "SMTP_FROM — voir .env.example — avant d'exposer cette instance.",
  );
}

Loading