21. La console d’administration

Pepsi livre une petite console de navigateur, servie par pepsi-httpd sous /ui. Elle existe pour ce qui est pénible en ligne de commande : surveiller la file d’attente, parcourir les clés et les correspondants, lire le journal d’audit, et modifier la configuration avec la validation et la provenance sous les yeux.

Elle est cliente de l’API d’administration documentée (L’API d’administration) et rien de plus. Chaque page appelle les mêmes fonctions de bibliothèque qu’appellent les points de terminaison /api/v1, à travers la même authentification, les mêmes vérifications de portée et le même journal d’audit, de sorte que tout ce que la console sait faire peut aussi se faire avec curl — et que les deux ne peuvent pas être en désaccord sur ce que contient la file d’attente ni sur la validité d’une valeur de configuration.

Note

Il y a deux API HTTP dans ce serveur et elles ne sont pas la même. /api/v1 est celle de Pepsi (L’API d’administration), dont cette console est un client : comptes, portées, sessions et CSRF, conditionnée à ADMIN = yes. /3.0/ et /3.1/ sont l’API REST de GNU Mailman 3 (L’API REST de GNU Mailman 3), réimplémentée pour que les logiciels Mailman fonctionnent avec Pepsi : un mot de passe partagé, aucune portée, les formes de quelqu’un d’autre, conditionnée à LIST_API = yes. Un exploitant rencontrera les deux ; elles partagent le serveur et rien d’autre, et aucune n’est une version de l’autre.

21.1. Trois drapeaux de listener, et pour deux d’entre eux le conseil est inverse

pepsi-httpd sert quatre choses et en conditionne trois, chacune à son propre drapeau dans [pepsi-httpd-listener-*]. Les confondre est de loin la façon la plus simple de mal déployer la surface HTTP de Pepsi : ils sont donc réunis dans un tableau :

Drapeau

Ce qu’il sert

À qui il est destiné

Où le placer

ADMIN

/api/v1, /ui, /metrics

L’exploitant. Jetons porteurs, sessions par mot de passe, SO_PEERCRED, portées, CSRF.

Derrière un tunnel. Un socket UNIX, ou la boucle locale avec un proxy inverse. pepsi-httpd refuse de le servir en clair au-delà de cette machine.

LIST_API

/3.0/, /3.1/

Les logiciels de listes de diffusion (mailmanclient, Postorius, HyperKitty). Un mot de passe partagé, aucun compte, aucune portée.

Boucle locale ou TLS. Le même refus que pour ADMIN, et pour la même raison : un identifiant statique valable pour tout le serveur ne doit pas traverser un réseau en clair.

LISTS

/lists, /archives/list/…, /robots.txt

Le public anonyme. Aucun identifiant du tout.

Sur l’internet ouvert. C’est la seule surface destinée à être joignable : la restriction sur le clair ne s’y applique donc pas délibérément – mais un formulaire d’abonnement porte l’adresse de quelqu’un, utilisez donc le listener TLS.

(aucun)

MTA-STS, Web Key Directory, autoconfiguration du courrier, le portail des liens sécurisés, POST /resume et le complément Outlook (/addin/, activé par [pepsi-httpd] ADDIN plutôt que par un indicateur de listener)

Quiconque en a besoin, par conception.

Là où se trouve le listener public.

Chaque route conditionnée répond un simple 404 – octet pour octet ce que répond un chemin inconnu – sur un listener dépourvu de son drapeau. C’est pourquoi publier le listener HTTPS public ne publie pas l’administration, et pourquoi un listener public ne peut pas être sondé pour savoir ce que ce serveur fait tourner d’autre.

21.2. L’atteindre en toute sécurité

La console se monte exactement sur les listeners où se monte l’API : ceux marqués ADMIN = yes (voir pepsi-httpd(1)). Sur tout autre listener, les chemins /ui répondent un simple 404, octet pour octet ce que répond un chemin inconnu, de sorte que publier le listener public ne publie pas la console et qu’un listener public ne peut pas être sondé pour savoir si l’administration est activée ici.

pepsi-httpd refuse de la servir sur un listener marqué qui la transporterait en clair hors de cet hôte : le TCP en clair n’est accepté que sur une adresse de boucle locale, tandis que les listeners TLS et les sockets UNIX conviennent toujours. Un listener activé par socket (SERVE = systemd) sans TLS ne convient jamais, quoi que lie son unité, car le descripteur transmis pourrait être n’importe quoi et le traiter comme autorisé ferait passer le cas le plus strict par la vérification la plus lâche — donnez à l’administration un listener à elle. Il ne s’agit pas seulement d’écoute clandestine : le cookie de session ne porte le préfixe __Host- (RFC 6265bis §4.1.3.2) que lorsqu’il est posé Secure, c’est-à-dire seulement lorsque la connexion du client est en HTTPS, puisqu’un navigateur refuse un cookie __Host- qui ne l’est pas. Sur le chemin d’amorçage en clair par la boucle locale, ni l’un ni l’autre n’est employé : l’authentification propre de la console est donc structurellement plus faible sur une origine en clair. Un navigateur ne peut pas ouvrir une socket UNIX, aussi un déploiement typique

  • lie-t-il un listener d’administration à 127.0.0.1 et l’atteint par un tunnel SSH (ssh -L 8443:127.0.0.1:8443 mail.example.com), ou bien

  • donne-t-il au listener d’administration du TLS et un nom à lui, et le traite comme toute autre surface d’administration exposée à l’internet.

21.3. Se connecter

Deux des trois mécanismes de l’API sont utiles depuis un navigateur :

Un opérateur local sur la socket UNIX d’administration est identifié par SO_PEERCRED et n’a besoin d’aucun mot de passe. Les navigateurs ne savent pas parler à une socket UNIX : cette voie est donc pour curl et pour l’amorçage au premier démarrage ; c’est aussi ce qui rend acceptable la politique de session stricte ci-dessous.

Un administrateur distant se connecte à /ui/login avec un compte de pepsi.admin_account (créez-en un avec POST /api/v1/accounts). Les sessions ont un délai d’inactivité de 30 minutes et une durée de vie ferme de 12 heures quelle que soit l’activité, et il n’y a pas de « se souvenir de moi ». Cette console peut révoquer une clé, lire qui correspond avec qui et réécrire le pipeline ; un onglet de navigateur oublié ne devrait pas détenir cela demain, et un portable volé ne devrait pas être une session d’administration permanente.

Les jetons porteurs fonctionnent aussi, mais un navigateur n’a aucun moyen d’en attacher un ; ils sont destinés à l’automatisation contre /api/v1.

21.4. Ce que la console peut et ne peut pas faire

Les actions sont au niveau des messages et des clés uniquement :

  • remettre en file un message bloqué à son étape actuelle, l’acheminer vers une étape de rebond, ou le supprimer sans le remettre ;

  • publier ou retirer une identité locale, en rendre une principale, la supprimer, ou demander son téléversement vers le serveur de clés ;

  • demander à l’applicateur une clé gérée par le serveur, l’enregistrement de la propre clé publique d’un utilisateur, ou la révocation d’une identité (les trois passant par l’applicateur privilégié, ci-dessous) ;

  • oublier une clé de correspondant mise en cache, et retirer une ancre de confiance. Importer une clé à la main et mettre en file une recherche de découverte ne sont pas des actions de la console : ce sont POST /api/v1/peers et POST /api/v1/peers/discover sur l’API, ou pepsi-keys(1), que la page elle-même indique ;

  • révoquer un message à lien sécurisé ;

  • poser ou retirer une redéfinition de configuration ;

  • répondre au questionnaire de configuration initiale, et demander à l’applicateur privilégié d’exécuter certbot, d’installer le schéma, de provisionner des rôles ou de vérifier le DNS.

Il n’y a délibérément aucun bouton de redémarrage, d’arrêt ou de sauvegarde. Une console web capable d’arrêter le serveur de messagerie est une console que l’on peut tromper pour qu’elle arrête le serveur de messagerie, et systemd possède déjà ce rôle. Redémarrez un composant avec systemctl restart pepsi-ingress (les pages de configuration nomment l’unité lorsqu’un changement en exige un).

Tout ce qui est privilégié ici a cette forme : la console note ce qui devrait se produire et un programme root distinct le fait. La console ne peut pas écrire /etc, exécuter certbot ni créer un rôle de base de données, et ne doit pas le pouvoir. Voir « La configuration initiale dans le navigateur » ci-dessous.

Deux autres choses que la console ne peut pas faire, parce que le serveur ne le peut pas :

  • Générer ou révoquer le matériel de clé lui-même. Cela requiert le rôle de base de données pepsi-crypto, qui détient le droit sur la colonne privée et que pepsi-httpd ne détient délibérément pas — un niveau web capable de créer des clés de signature est un niveau web dont la compromission crée des clés de signature. Le formulaire Générer une clé gérée par le serveur de la page des identités ne fait donc que demander : il met en file une tâche generate-identity que pepsi-setup apply revalide et exécute en tant que pepsi-crypto. Enregistrer ma propre clé (coller une clé publique OpenPGP en armure) met en file register-client-key de la même manière, et le bouton Révoquer d’une identité, une fois confirmé, met en file revoke-identity — révoquer une clé de signature seule détruit sa moitié privée, ce qui est une écriture dans cette colonne. Chacun répond avec la page propre de la tâche mise en file, /ui/setup/tasks/<id>, que l’utilisateur qui l’a demandée peut ouvrir quelles que soient ses portées, et qui affiche l’issue – terminée, échouée avec la raison, ou refusée – au moment même où l’applicateur l’enregistre. Les trois répondent 503 lorsque le paquet de l’applicateur n’est pas installé. Chaque formulaire a aussi un champ Code du second facteur : un utilisateur qui a enrôlé un second facteur (Gestion des clés) y saisit le code courant de son application d’authentification, et l’applicateur le vérifie – la console ne le peut pas. Les CSR et les certificats émis restent du ressort de pepsi-keys(1).

  • Écrire la surcouche de configuration, à moins que [pepsi-admin] CONFIG_DB ne nomme une connexion s’authentifiant comme le rôle pepsi-config. Sans elle, les pages de configuration sont en lecture seule et disent pourquoi.

La console ne montre jamais non plus les en-têtes ou le corps d’un message. Les pages de file d’attente chargent l’enveloppe, l’étape, le statut et le JSON state, et rien d’autre — structurellement, car la requête derrière elles ne sélectionne pas ces colonnes. Lire du courrier n’est pas une fonction d’administration.

21.5. Les pages

Chemin

Portée

Ce qu’elle montre

/ui

queue:read

Tableau de bord : profondeur de file par (stage, status) avec le message le plus ancien de chaque compartiment, les messages qui se sont arrêtés et pourquoi, les compteurs cumulés, les compteurs par étape, les issues TLS sortantes récentes, les adresses MX en échec, et — avec keys:read — les identités locales avec avertissements d’expiration.

/ui/queue

queue:read

Listage filtrable (par étape et par statut), paginé, avec un rechargement toutes les 30 secondes que l’on active explicitement.

/ui/queue/<id>

queue:read

Un message : enveloppe, étape, statut et state mis en forme. Les formulaires de remise en file / de rebond / de suppression exigent queue:write.

/ui/identities

keys:read

Paires de clés locales, filtrables par adresse, avec les formulaires Générer une clé gérée par le serveur et Enregistrer ma propre clé (keys:write ou la portée own: propre à l’adresse ; ils envoient vers /ui/identities/request/{generate,register}).

/ui/identities/<id>

keys:read

Une identité, avec sa garde (clé gérée par le serveur ou propre clé de l’utilisateur) et son état sur le serveur de clés, et publier / retirer / Téléverser vers le serveur de clés / rendre principale / révoquer (keys:write ou la portée own: propre à l’adresse ; la révocation est mise en file pour l’applicateur) et supprimer (keys:write seulement).

/ui/peers

keys:read

Clés de correspondants mises en cache : source, indicateur DNSSEC, validité, état d’épinglage. En oublier une exige peers:write, et oublier est la seule action possible ici — la page nomme pepsi-keys peer import pour le reste.

/ui/ca-trust

keys:read

Les ancres de confiance S/MIME ; le retrait exige keys:write.

/ui/config

config:read

Chaque section de la configuration effective pour une portée, avec la façon dont un changement prend effet.

/ui/config/<section>

config:read

Une section : la valeur de chaque option, d’où elle vient (fichier, global, domaine, adresse), et les formulaires pour poser ou retirer une redéfinition (config:write).

/ui/logs

logs:read

Le journal d’audit, filtrable par nature et par acteur.

/ui/logs/mail

logs:read ou own:

Le journal par message, sur option, filtrable par adresse ; le dit clairement lorsque [pepsi] MAIL_LOG est désactivé. Un principal ne détenant que own:<address> voit les enregistrements de cette adresse et ceux de personne d’autre, comme avec GET /api/v1/mail-log.

/ui/logs/tls

logs:read

Issues des sessions TLS sortantes par domaine de politique.

/ui/logs/dns

logs:read

Adresses MX que le cache du résolveur marque actuellement comme en échec.

/ui/domains

config:read

Les domaines servis, le mode MTA-STS, combien d’identités sont publiées sous chacun, et les enregistrements DNS à publier avec le dernier verdict en direct pour chacun. Le bouton « vérifier le DNS maintenant » exige setup:write.

/ui/secure

secure:read

Les messages à lien sécurisé que le portail retient : correspondants, expiration, lectures, PIN erronés, verrouillages. La révocation exige secure:write.

/ui/secure/<token>

secure:read

Un message avec son historique d’accès.

/ui/setup

setup:write

Le point d’entrée de l’entretien de configuration initiale ; chaque étape est /ui/setup/<step> (GET la rend, POST enregistre les réponses de cette étape), en reprenant là où l’on s’était arrêté.

/ui/setup/tasks

setup:write, ou tout principal pour ses propres demandes

Ce qui a été demandé à l’applicateur privilégié, et un formulaire pour en demander davantage. Sans setup:write, uniquement les demandes que l’utilisateur a faites lui-même.

/ui/setup/tasks/<id>

setup:write, ou tout principal pour ses propres demandes

Une action avec la progression diffusée de l’applicateur, mise à jour dès que l’applicateur écrit quelque chose.

Aucune page ne charge un ensemble de résultats non borné : chaque listage passe par une requête portant son propre LIMIT/OFFSET, c’est donc une propriété du SQL plutôt que de ce que dessine le moteur de rendu. Les vues de file d’attente et de journal paginent avec ?limit=/?offset= (plafonnés à [pepsi-admin] PAGE_LIMIT, au plus 1000). Les listages du magasin de clés — identités, correspondants, ancres de confiance — récupèrent l’équivalent d’une page, plus une ligne pour savoir s’il y en a davantage, et disent clairement quand ils ont été tronqués ; restreignez-les avec le filtre d’adresse, ou utilisez pepsi-keys(1). La console ne demande un COUNT(*) que là où elle affiche un total (les deux vues de journal) ; l’API le fait toujours, car ses clients paginent par programme. Voir L’API d’administration.

Chaque action destructrice — supprimer un message en file d’attente, révoquer ou supprimer une identité, retirer une ancre de confiance, retirer une redéfinition de configuration — passe d’abord par une page de confirmation explicite. Chaque mutation est écrite au journal d’audit avec la même nature d’événement que celle qu’enregistre l’API, de sorte que /ui/logs montre indifféremment les changements faits depuis la console, depuis curl et depuis les outils en ligne de commande.

Les valeurs de configuration susceptibles de porter un secret sont affichées comme ***, jamais comme des valeurs, selon la règle même dont l’API se sert pour masquer. La liste est délibérément généreuse : une valeur masquée à tort coûte à un opérateur un coup d’œil dans le fichier, une valeur affichée à tort est publiée à tous ceux qui détiennent config:read.

21.5.1. La configuration initiale dans le navigateur

/ui/setup rend le même questionnaire que pepsi-setup --wizard pose à un terminal — littéralement le même, car les questions, leurs formes, leurs valeurs par défaut et les conditions dans lesquelles elles sont posées vivent en un seul endroit sous forme de données (pepsi-setup-model) et les deux frontaux les rendent. Une question ajoutée à l’un est une question ajoutée aux deux.

Deux mécanismes distincts la rendent sûre :

Les réponses sont des brouillons. Chaque étape écrit dans pepsi.config_override avec l’indicateur draft, que chaque lecteur de configuration filtre structurellement. Rien ne prend effet pendant que le questionnaire est en cours, de sorte qu’une session qui expire à mi-chemin n’a pas à moitié configuré un serveur de messagerie, et rouvrir la page reprend là où on s’était arrêté. Une étape avec une mauvaise réponse se réaffiche avec le message à côté du champ et ne stocke rien — pas même les bonnes réponses de la même page, car une étape à moitié stockée est une étape qu’un opérateur doit reconstruire de mémoire.

Les actions sont des intentions. /ui/setup/tasks montre ce qui a été demandé à l’applicateur privilégié et offre un formulaire pour en demander davantage, dans son ensemble fermé : run-preflight, obtain-certificate, install-schema, provision-roles. Un bouton écrit une ligne pepsi.setup_task et sonne à une sonnette ; pepsi-setup apply, tournant en root, fait le travail et rediffuse sa progression dans la ligne, qu’affiche /ui/setup/tasks/<id>. Pendant l’exécution de la tâche, la page se recharge d’elle-même (un <meta http-equiv="refresh">, pas un minuteur en script), et ce rechargement n’interroge pas en boucle : il redemande la page avec ce qu’elle affichait déjà, et le serveur retient cette requête jusqu’à 20 secondes, jusqu’à ce que la notification de l’applicateur signale une nouvelle ligne de progression ou une issue. Le résultat apparaît donc au moment même où il existe. Si le serveur a perdu sa connexion d’écoute à la base de données, il répond immédiatement et la page se rabat sur un rechargement toutes les cinq secondes. Il n’y a pas d’action de redémarrage ou d’arrêt ; un changement qui exige le redémarrage d’un composant se termine par vous le redémarrant.

Les deux exigent setup:write, lectures comprises : les réponses préparées sont la forme d’un déploiement en construction et la liste des tâches est un registre de travail privilégié. La seule exception concerne les propres demandes d’un utilisateur : les actions sur les clés des pages d’identités mettent elles aussi des tâches en file, et quiconque en a mis une en file peut l’ouvrir et la trouver dans /ui/setup/tasks, qui pour lui ne liste rien d’autre. La tâche de quelqu’un d’autre est « introuvable ».

Deux conditions indépendantes doivent être réunies avant que quoi que ce soit puisse être enregistré, et la page indique laquelle manque :

  • l”applicateur privilégié doit exister — le paquet Debian distinct pepsi-httpd-admin, qui contient pepsi-setup-apply.socket et son service. C’est un Recommends, il est donc installé par défaut et peut être retiré sans emporter Pepsi avec lui ; et

  • [pepsi-admin] CONFIG_DB doit nommer une connexion s’authentifiant comme le rôle pepsi-config, car l’applicateur refuse toute ligne qui n’a pas été écrite par lui.

L’applicateur est vérifié en premier, car sans applicateur, configurer CONFIG_DB ne servirait à rien. Lorsque l’un ou l’autre manque, l’entretien se dégrade en lecture seule plutôt que de mettre de côté des réponses que rien n’appliquera : chaque valeur est toujours rendue, les champs de saisie reviennent disabled/readonly, le bouton d’enregistrement disparaît, et un message éclair nomme la raison. Les appels d’API correspondants répondent 503 setup_applier_unavailable ou 503 setup_write_unavailable (voir L’API d’administration). La seule exception délibérée est le rejet d’un brouillon — cela ne peut causer aucune modification privilégiée, et un entretien coincé doit rester effaçable.

Notez que les pages /ui/config ne sont pas affectées par l’absence d’applicateur : elles écrivent pepsi.config_override, une table de base de données, non /etc. Elles n’ont besoin que de CONFIG_DB, et de rien d’autre.

Une réponse de forme password est rendue vide à chaque fois, jamais renvoyée dans un attribut value=, et soumettre l’étape en la laissant vide laisse tel quel ce qui est stocké. L’entrée d’audit d’une étape nomme les identifiants des questions et aucune valeur.

21.5.3. Le DNS sur la page des domaines

/ui/domains montre, à côté de chaque domaine servi, chaque enregistrement DNS que Pepsi attend qu’il publie : le nom, la valeur à publier, si le DNS en direct le porte, et quoi faire quand ce n’est pas le cas. Le texte de zone au bas de la page est le même bloc qu’imprime pepsi-setup run.

Le verdict vient de l’applicateur, non de ce serveur : établir ce qui devrait être publié suppose de lire les clés de signature, et le comparer suppose d’interroger le DNS. La page montre donc toujours quand la réponse a été calculée, et « vérifier le DNS maintenant » (setup:write) en demande une fraîche plutôt que de bloquer la requête sur un résolveur.

21.5.4. Pages pour l’utilisateur final

La portée own:<address> est définie et appliquée, et un principal ne détenant qu’elle atteint les pages d’identités et de correspondants restreintes à sa propre adresse — les pages d’une autre adresse répondent 404, et les objets partagés (le cache de clés de correspondants, les ancres de confiance) sont refusés d’emblée. Il atteint aussi le journal du courrier, restreint à sa propre adresse ; y demander une autre adresse est refusé avec 403. Sur ses propres identités, il peut aussi agir : demander une clé gérée par le serveur, enregistrer sa propre clé publique, demander un téléversement vers le serveur de clés, publier, retirer et demander une révocation – tout sauf supprimer une ligne (voir Gestion des clés). Il n’existe pas d”interface pour l’utilisateur final dédiée ; pepsi-stage-edit-settings(1) permet aux utilisateurs de modifier leurs propres paramètres par e-mail, et l’adresse de contrôle pepsi-keys@ de l’étape de chiffrement leur permet de gérer leurs clés par e-mail.

21.6. Deux moteurs de modèles, deux usages

Pepsi rend deux sortes de texte assez différentes et emploie un moteur différent pour chacune — n’essayez donc pas de redéfinir une page de la console comme on redéfinit un rebond.

Les corps de courrier sont des modèles Mustache, livrés comme fichiers sous [pepsi] TEMPLATE_DIR (bounce-<name>.<lang>.body, payment-request.<lang>.body, edit-settings.<lang>.body, wallet.<lang>.body). Ils sont éditables par l’opérateur et par langue : on attend d’un opérateur qu’il réécrive la prose qu’un correspondant lira, et qu’il ajoute une langue, sans rien reconstruire. Le modèle sans logique est exactement ce qu’il faut ici — il n’y a rien à calculer, et un modèle qui ne peut pas exécuter de code est un modèle qu’on peut confier sans risque à un opérateur.

Les pages de la console sont des modèles askama, compilés dans le binaire et vérifiés en types au moment du build contre les structures Rust qu’ils rendent. Il n’y a rien à installer à l’exécution, rien à maintenir en phase avec le binaire, et un champ renommé est une erreur de build plutôt qu’une case vide en production. La console est surtout faite de tableaux et de conditions, ce que le modèle sans logique rend pénible.

La conséquence pratique : il n’y a aucun moyen de redéfinir une page de la console, et ce n’est pas prévu. Si une page dit quelque chose de faux, c’est un bogue à signaler, non un fichier à éditer.

21.7. Pas de script, pas de ressources distantes

La console ne livre aucun JavaScript. Chaque action est un formulaire ou un lien : elle fonctionne donc avec les scripts désactivés — y compris le rafraîchissement automatique facultatif de la file d’attente, qui est un <meta http-equiv="refresh"> que le lecteur active et désactive par un lien plutôt qu’un minuteur. Les graphiques sont de petits SVG en ligne écrits à la main et générés sur le serveur : ils se rendent donc dans la même requête que le reste de la page.

Rien n’est chargé depuis un autre hôte : une feuille de style, compilée dans le binaire et servie depuis /ui/static/console.css. La Content-Security-Policy interdit donc le script d’emblée plutôt que d’autoriser celui de la console, et interdit l’encadrement (frame-ancestors 'none', avec X-Frame-Options: DENY à côté). Les pages sont envoyées avec Cache-Control: no-store, de sorte que les données d’administration ne restent pas dans un cache partagé ni dans le bouton retour après une déconnexion.

La falsification de requête inter-sites est refusée deux fois. Chaque formulaire porte le jeton CSRF de la session en champ caché, vérifié contre un condensat stocké avec la session ; et une requête modifiante dont l’en-tête Origin nomme un autre hôte est refusée avant même que son corps ne soit lu — ce qui couvre le formulaire de connexion, l’unique formulaire qui par définition n’a pas encore de session.

Les valeurs venues de l’extérieur — un sujet, le nom d’affichage d’un expéditeur, une valeur d’en-tête, le texte de réponse d’un rebond, l’identifiant utilisateur d’une clé — sont échappées par défaut au rendu, et les tests l’exercent avec des entrées hostiles plutôt que de s’y fier. Le seul balisage que la console émet sans échappement est le SVG de ses propres graphiques, qui par construction ne contient que des nombres.

21.8. Accessibilité

La console est du HTML simple : un lien d’évitement vers le contenu principal, un <h1> par page, des tableaux avec <caption> et <th scope=…>, une étiquette pour chaque champ de formulaire, et aria-current="page" sur l’entrée de navigation où vous vous trouvez. Chaque action est un véritable <button> à l’intérieur d’un formulaire : elle est donc atteignable et actionnable au clavier sans raccourci à apprendre, et l’anneau de focus est dessiné explicitement parce que celui par défaut disparaît sur certains fonds. Le clair et le sombre viennent tous deux de prefers-color-scheme ; il n’y a pas de sélecteur de thème, car un sélecteur exige du script ou un cookie et le système d’exploitation connaît déjà la réponse.

Les graphiques portent aria-hidden et ne sont jamais l’unique présentation de quoi que ce soit : les mêmes nombres figurent dans le tableau à côté de chaque figure.

La navigation n’offre que ce que vos identifiants permettent — un lien qui répond 403 est pire que pas de lien du tout.

21.9. Les pages publiques des listes de diffusion

Une seconde surface de navigation, distincte, conditionnée à LISTS = yes (voir Trois drapeaux de listener, et pour deux d’entre eux le conseil est inverse) et documentée ici parce qu’un exploitant rencontre les deux :

/lists

L’index des listes annoncées, groupées par domaine. Une liste dont l’attribut advertised est faux est absente de l’index et reste joignable par URL – c’est le sens qu’amont donne à cet attribut, et ce n’est pas une frontière de sécurité. « Non annoncée » se lit comme « cachée » ; elle ne l’est pas.

/lists/<list-id>

La description d’une liste, son texte info, son adresse d’envoi, les formulaires d’abonnement et de désabonnement, un lien vers les archives quand la politique en autorise un, et la liste des membres seulement si member_roster_visibility vaut public.

/lists/<list-id>/confirm/<token>

Ce vers quoi pointe un lien de confirmation. Un ``GET`` n’achève rien : il affiche à quoi sert le jeton et demande un unique POST. Les clients de messagerie, les générateurs d’aperçus de liens et les analyseurs de sécurité récupèrent les liens, plusieurs d’entre eux avant la personne. C’est une différence délibérée avec l’adresse -confirm+<token>, où l”arrivée est la confirmation parce que le jeton a voyagé dans l’enveloppe et que seul son propriétaire a pu l’y mettre.

/archives/list/<list@domain>/…

Les archives : vue d’ensemble, mois, fil, message, pièce jointe, recherche. Les formes d’URL sont celles de HyperKitty, si bien que chaque en-tête Archived-At: qu’un déploiement migré a émis continue de fonctionner. Voir Archives.

Trois choses au sujet de ces pages méritent d’être connues avant de les déployer.

Elles ne livrent aucun JavaScript, pas le moindre. La Content-Security-Policy n’a donc pas de script-src – pas même 'self' – et dit default-src 'none'. Le prix en est les parties interactives de HyperKitty : le repli des fils se fait par <details>, il n’y a pas de recherche en direct ni de réponse dans la page. Cela a été décidé délibérément, et une politique qui autorise ce qui n’est pas utilisé est une politique qui autorisera un jour ce qui est injecté.

Les corps de messages sont du texte échappé, jamais du balisage. L’attribut archive_rendering_mode d’amont propose text ou markdown ; Pepsi stocke et expose l’attribut – l’ensemble des attributs est le contrat de compatibilité – et rend text dans les deux cas. Rendre du markdown fourni par un utilisateur dans notre propre origine est précisément ce que la politique ci-dessus doit rendre impossible, et un moteur markdown est une surface d’injection HTML avec des étapes en plus.

Les adresses d’expéditeur sont obscurcies pour les visiteurs anonymes. Une page d’archives affiche alice@... plutôt que l’adresse entière. Les archives conservent la vraie – obscurcir à l’entrée serait irréversible, et le traitement des rejets en a besoin –, c’est donc une décision de rendu. Ce n’est pas un contrôle de sécurité et ce n’est pas censé l’être : qui est sur la liste a l’adresse dans sa propre copie du message. Ce que cela empêche, c’est la collecte en masse qui fait d’archives publiques une source de spam.

21.9.1. Les formulaires qui font envoyer du courrier par le serveur

S’abonner, c’est demander au serveur d’envoyer un message à une adresse dont personne n’a prouvé la maîtrise : sans limitation, c’est un amplificateur de harcèlement – et Pepsi ne livre aucun CAPTCHA, par la même décision qui exclut JavaScript. Donc :

  • chacun de ces formulaires est limité en débit sur l’adresse cible autant que sur l’adresse du client ;

  • une seconde soumission alors qu’un jeton est encore valide réutilise ce jeton au lieu d’en émettre un autre, de sorte que dix soumissions ne font pas dix messages ;

  • la réponse est identique selon que l’adresse est déjà membre ou non : le formulaire n’est donc pas un oracle d’appartenance.

La dernière est la raison pour laquelle la page dit « si cette adresse peut être abonnée, une confirmation lui a été envoyée » et rien de plus utile.

21.10. Deux systèmes de comptes, et aucun n’accorde rien à l’autre

C’est la chose la plus facile à mal comprendre dans la couche web : elle est donc énoncée aussi platement que possible.

Pepsi a deux sortes de comptes de navigateur, dans deux tables, avec deux jeux de cookies, et ils n’ont aucun rapport :

La console de l’exploitant

Le compte du membre de liste

À qui il est destiné

Celui qui fait tourner le serveur de courrier

Quiconque s’abonne à une liste, ainsi que les propriétaires et modérateurs de ces listes

Table des comptes

pepsi.admin_account

pepsi.list_user

Table des sessions

pepsi.admin_session

pepsi.list_session

Cookies

pepsi_session / pepsi_csrf

pepsi_list_session / pepsi_list_csrf

Drapeau de listener

ADMIN = yes

LISTS = yes

Où il devrait être joignable

La boucle locale, par un tunnel SSH

L’internet public

Créé par

Un opérateur, via POST /api/v1/accounts (le premier par la socket locale ou avec un jeton pepsi-setup bootstrap)

L’inscription via /lists/register sur le site public

Ce qu’il peut faire

La configuration, la file d’attente, les clés et les journaux. Pas les listes : celles-ci se gèrent avec pepsi-list(1) ou par l’API REST Mailman /3.x, dont les [pepsi-list] API_USER/API_PASS partagés constituent un identifiant distinct ; ni /api/v1 ni /ui ne les atteignent

Ses propres abonnements, plus les listes dont son adresse est propriétaire ou modératrice

Aucun n’est une version affaiblie de l’autre. Un compte d’exploitant n’est membre de rien, et un compte de membre – même celui d’un propriétaire de liste – ne peut lire ni la file, ni la configuration, ni les clés de qui que ce soit. Les deux sont séparés parce que leur conseil de déploiement est inverse : la console est ce que le manuel dit de garder hors de l’internet, et on ne peut évidemment pas demander à un propriétaire de liste d’ouvrir un tunnel SSH pour approuver un message retenu.

21.10.1. Comment fonctionne réellement l’autorité d’un propriétaire de liste

Il n’y a pas de compte « administrateur de liste » ni de table de permissions. L’autorité sur une liste est une interrogation de la liste des membres : un enregistrement list_member pour cette liste avec role = owner ou role = moderator. C’est le modèle de Postorius, conservé délibérément.

Trois conséquences en découlent, dont chacune ressemble à un oubli jusqu’à ce qu’on voie d’où elle vient :

  • Retirer quelqu’un de la liste des membres lui ôte ses pouvoirs immédiatement, sans session à invalider, parce que chaque page repose la question. Son compte survit – ce n’est pas son appartenance – ainsi que ses autres abonnements.

  • Un modérateur n’est pas un propriétaire. Un modérateur traite les messages retenus et les demandes d’abonnement ; un propriétaire fait cela et change en outre les réglages, la liste des membres, les interdictions et les règles d’en-tête. Cette séparation est celle d’amont.

  • Un propriétaire de serveur possède toutes les listes. list_user.is_server_owner est le drapeau d’amont pour cela, et c’est la seule autorité globale de ce palier. Ce n’est pas un compte d’exploitant non plus.

Un non-propriétaire qui demande /lists/<list-id>/admin obtient la page ordinaire « il n’y a rien à cette adresse » – la même que pour une liste inexistante. C’est voulu : sur une surface publique, une page qui dit « ceci existe mais vous ne pouvez pas le voir » a déjà appris à un inconnu que cela existe.

21.10.2. Si vous servez les deux surfaces depuis un seul nom d’hôte

L’assistant en avertit et ne peut pas l’empêcher. Les cookies sont liés à l’hôte et non au listener : un déploiement qui place ADMIN = yes et LISTS = yes derrière un même nom n’a exactement que deux choses pour séparer les surfaces, les noms de cookies et le contrôle, par le garde, de la table d’où vient une session.

C’est une ligne mince, et elle est mince par conception et non par accident. Elle est testée dans les deux sens – une session de membre présentée à une route d’exploitant est refusée exactement comme le serait l’absence de cookie, et une session d’exploitant présentée à une route de membre de même, sans que l’une ou l’autre produise une erreur distinguable. Préférez néanmoins deux noms, ou mieux deux listeners : ADMIN sur la boucle locale et LISTS en public est l’agencement que le reste de ce chapitre suppose.

21.11. Les pages du membre

/lists/register

Crée un compte et envoie un lien de vérification. Non oraculaire et limité en débit, comme le formulaire d’abonnement ci-dessus et pour la même raison.

/lists/verify/<token>

Confirme l’adresse et définit le mot de passe, en une seule étape. Il n’y a pas de page « choisir un mot de passe » séparée, car un lien de vérification qui ne fait que vérifier laisse un compte où personne ne peut se connecter.

/lists/sign-in, /lists/sign-out

Se connecter avec n’importe quelle adresse vérifiée du compte et l’unique mot de passe du compte. Un membre peut détenir plusieurs adresses – le modèle d’amont est un utilisateur à plusieurs adresses – et c’est ce qui fait fonctionner « je me suis abonné avec mon adresse professionnelle ». Une adresse non vérifiée ne peut pas se connecter : elle n’a rien prouvé.

/lists/reset

Envoie un lien de réinitialisation. Un lien, jamais un mot de passe : un mot de passe envoyé est un mot de passe qui reste pour toujours dans une boîte. pepsi-list owner reset-password demeure l’issue de secours de l’exploitant quand le courrier est en panne, et il l”affiche au lieu de l’envoyer, précisément pour cette raison.

/lists/me

Ses adresses, et ses abonnements sur toutes les listes, chacun avec son mode et son état de remise.

21.11.1. Pourquoi une préférence peut ne pas s’appliquer là où on l’attend

Les six préférences de remise sont cherchées le long d’une chaîne – cet abonnement, puis cette adresse, puis ce compte, puis le défaut de la liste – et tout niveau peut être non défini. Un membre qui pose une préférence sur son compte et constate qu’elle ne s’applique pas à une liste l’a donc généralement posée à un niveau qu’un niveau plus précis surcharge.

/lists/me montre donc, pour chaque abonnement, de quel niveau la valeur provient. Cette colonne est la réponse à la seule question que cette conception provoque à coup sûr.

21.12. La console du propriétaire et du modérateur

/lists/<list-id>/admin

La page d’où l’on part : les messages retenus, les demandes d’abonnement et (pour un propriétaire) des liens vers les écrans de réglages et la liste des membres.

/lists/<list-id>/admin/held/<request-id>

Un message retenu, ses octets exacts affichés en texte. Accepter libère ces octets, non un nouveau rendu de ceux-ci – un message retenu est conservé sous sa forme réseau précisément pour qu’une libération puisse être remise pour de vrai, signatures et pièces jointes intactes.

C’est la page la plus hostile du site : le courrier d’un inconnu, rendu là où le clic d’un modérateur peut agir. Le corps est donc du texte échappé dans un <pre>, la politique n’autorise aucun script, et chaque bouton porte un jeton CSRF.

/lists/<list-id>/admin/settings/<screen>

Les réglages de liste modifiables, sur onze écrans suivant le regroupement de Postorius lui-même – parce qu’un propriétaire qui migre cherchera un réglage là où il se trouvait. Le texte d’aide en regard de chaque champ est celui de Postorius, repris ; voir la note d’attribution dans Listes de diffusion.

Les écrans sont générés à partir de la même déclaration qui pilote l’API REST et la ligne de commande : une valeur refusée ici est refusée là-bas, dans les mêmes termes, et un réglage ne peut pas être présent à un endroit et absent à un autre.

Les six attributs de modèle *_uri figurent aussi sur ces écrans, de sorte qu’un propriétaire qui veut son propre message d’accueil ou son propre pied de page pose l’URI là où résident tous les autres réglages, et non sur une page à part.

/lists/<list-id>/admin/roster

Les membres, les propriétaires et les modérateurs ; les interdictions de la liste ; et ses règles d’en-tête, avec les interdictions et les règles du serveur affichées à côté en lecture seule – parce que celles du serveur priment, et qu’un propriétaire qui cherche pourquoi une règle ne se déclenche jamais doit voir celle qui s’est déclenchée d’abord.

Ajouter une adresse est la seule action de ce palier qui abonne quelqu’un sans son accord. C’est une action de propriétaire, elle est écrite au journal d’audit avec le propriétaire comme acteur, et l’adresse ajoutée n’est pas marquée vérifiée : la parole d’un propriétaire n’est pas la preuve qu’une boîte existe.

Tout ce qui se passe dans cette console est audité par le même journal que chaque action d’exploitant, avec le compte du membre comme acteur. detail ne porte jamais de contenu de message – accepter un message retenu consigne la demande et le condensé du message, non le corps – et le journal est élagué par la minuterie [pepsi-admin] EVENT_RETENTION_DAYS de l’exploitant, qu’il vaut la peine de comparer à la durée pendant laquelle vous voulez garder l’historique de modération.

21.13. La langue de l’interface, et pourquoi elles ne sont pas dix

Les pages publiques sont servies en anglais, allemand ou français, choisis d’après l’en-tête Accept-Language de la requête. Une étiquette régionale reçoit sa langue (de-CH obtient les pages allemandes, car une variante d’une langue en est plus proche que l’anglais), l’ordre des q de l’en-tête est respecté plutôt que son ordre d’écriture, et un q=0 est lu comme le refus qu’il est – de, en;q=0 signifie donc allemand et non « allemand, puis n’importe quoi ».

Trois langues ici et dix pour le courrier est une véritable couture, et elle est énoncée plutôt que cachée. Les modèles de notification – le message d’accueil, la demande de confirmation, les avertissements de rejet – existent en dix langues, parce qu’ils viennent de GNU Mailman, qui les a en trente-quatre (voir Listes de diffusion). Les libellés de l’interface sont les nôtres, et ils existent dans les trois langues où existe le manuel. Un membre qui lit un message d’accueil en allemand et suit son lien arrive sur une page allemande ; un membre suédois arrive sur une page anglaise. Ajouter une langue à l’interface revient à traduire environ 140 libellés courts : c’est un après-midi et un correctif, pas un projet.

Le choix se fait d’après l’en-tête seul, avant tout accès à la base. C’est délibéré : une page publique est joignable par n’importe qui, et choisir un mot en lisant un enregistrement signifierait qu’une requête non authentifiée touche la base, sur un serveur dont le pool de workers est d’une connexion. La préférence enregistrée d’un membre connecté ne surcharge donc pas l’en-tête.