20. L’API d’administration

pepsi-httpd sert sous /api/v1 une API HTTP couvrant tout ce que font les outils en ligne de commande de l’opérateur : la file d’attente, le résumé de santé, la configuration, le magasin de clés et les journaux. La console de navigateur et un script sans surveillance sont clients de la même surface documentée.

Avertissement

L’API n’est pas encore stable. Le chemin porte v1 afin que le mécanisme de versionnement existe et que les clients soient écrits en conséquence, mais tant que la version de Pepsi commence par 0., une version peut encore changer la forme d’une demande ou d’une réponse ; de tels changements sont listés dans NEWS. Contrairement à la base de données, que chaque version fait avancer par migration (voir Mise à niveau), un client peut devoir être adapté. Figer le contrat avant que la console, son premier vrai client, ne l’ait exercé graverait des décisions prises à l’aveugle.

20.1. Où elle est servie

Uniquement sur un listener marqué ADMIN = yes. Sur tout autre listener, les routes /api/v1 répondent un simple 404 identique octet pour octet à ce que reçoit n’importe quel chemin inconnu — de sorte que publier le listener HTTPS public ne publie pas l’administration, et qu’un listener public ne puisse même pas être sondé pour savoir si l’administration est activée sur ce déploiement.

La configuration livrée marque exactement un listener, une socket UNIX :

[pepsi-httpd-listener-admin]
SERVE = unix
UNIXPATH = /run/pepsi/admin.sock
UNIXPATH_MODE = 660
UNIXPATH_GROUP = pepsi-admin
MODE = plain
ADMIN = yes

pepsi-httpd refuse de servir les routes d’administration sur un listener marqué qui les emporterait en clair hors de cet hôte : le TCP en clair n’est accepté que sur une adresse de boucle locale, le TLS et les sockets UNIX toujours. Un listener activé par socket (SERVE = systemd) n’est admis qu’avec TLS, car l’adresse du descripteur hérité n’est pas visible du serveur. Un listener marqué qui échoue au test journalise un avertissement au démarrage et ne sert que les points de terminaison publics ; pepsi-setup rapporte la même chose lorsqu’il valide la configuration, de sorte que l’erreur soit attrapée avant d’avoir de l’importance.

Note

Un seul binaire sert les deux publics. Il n’y a pas de démon d’administration distinct : une seule chose à construire, empaqueter, superviser et tenir synchronisée — et, en contrepartie, un bogue dans le processus serveur partagé est un bogue dans les deux surfaces. L’isolation est donc à la charge de l’opérateur. Liez le listener d’administration à une socket UNIX ou au loopback, et placez un proxy inverse devant lui s’il doit être joignable d’ailleurs.

Note

Ce que l’on croit d’un serveur frontal. Dans cette topologie, ce processus ne termine aucun TLS et ne voit aucune adresse cliente : il lit donc deux en-têtes posés par le serveur frontal. X-Forwarded-Proto décide si le cookie de session part avec l’attribut Secure (et sous son nom __Host-), et l’élément le plus à droite de X-Forwarded-For est ce sur quoi s’indexent les limitations de débit et ce que le portail à lien sécurisé note comme pair d’une tentative d’accès. Les deux ne sont lus que sur une connexion qui ne peut pas venir de l’extérieur de cet hôte — une socket UNIX, ou un pair TCP en loopback — de sorte qu’un client parlant directement à un listener public ne puisse jamais affirmer ni l’un ni l’autre. Les sites qu’écrit pepsi-setup posent les deux. Sans eux, un déploiement derrière un proxy a des cookies sans attribut Secure et un unique seau de limitation de débit partagé par l’internet entier.

20.2. Authentification

Trois mécanismes, un seul modèle d’identité. Un identifiant explicitement présenté l’emporte toujours : l’en-tête Authorization est essayé en premier, puis le cookie de session, et SO_PEERCRED seulement lorsque ni l’un ni l’autre n’est présent. Cet ordre importe — si peercred l’emportait, un administrateur présentant délibérément un jeton à portée étroite sur la socket locale retrouverait silencieusement toute l’autorité.

20.2.1. Administrateurs locaux (SO_PEERCRED)

Un processus qui se connecte sur un listener à socket UNIX est identifié par les identifiants que le noyau a enregistrés à l’appel connect(2), et que le pair ne peut pas falsifier. root, ou un membre de [pepsi-admin] ADMIN_GROUP (pepsi-admin par défaut), est un administrateur sans identifiant à configurer, stocker ou perdre :

# curl --unix-socket /run/pepsi/admin.sock http://localhost/api/v1/status

C’est ainsi que fonctionne l’amorçage initial sur un système qui n’a encore aucun compte, et c’est pourquoi la socket peut être en mode 0660 avec un groupe sans danger : l’identité vient du noyau, non du mode du fichier.

20.2.2. Jetons porteurs

Pour l’automatisation. Un jeton est frappé via l’API et affiché exactement une fois :

# curl --unix-socket /run/pepsi/admin.sock -X POST \
    -H 'Content-Type: application/json' \
    -d '{"label":"monitoring","scopes":["queue:read","logs:read"]}' \
    http://localhost/api/v1/tokens

La forme présentée est pepsi_<id>.<secret>. Seul le SHA-256 de la moitié secrète est stocké, de sorte qu’un jeton ne puisse être réaffiché ni récupéré depuis une sauvegarde de base ; la moitié identifiante est un sélecteur public qui indexe la table, ce qui permet à la comparaison d’être une vérification à temps constant sur une seule ligne plutôt qu’un parcours.

Présentez-le sous la forme Authorization: Bearer pepsi_<id>.<secret> — le schéma Bearer de la RFC 6750, porté dans le champ Authorization que définit la RFC 9110 §11.6.2. Les noms de schéma ne distinguent pas la casse (RFC 7235 §2.1), de sorte que bearer et BEARER soient également acceptés. Les jetons peuvent porter une expiration (expires_in_days) et sont révoqués en les supprimant.

Le jeton n’est pas un jeton d’accès OAuth 2.0 (RFC 6749) et il n’y a pas de serveur d’autorisation : c’est un identifiant frappé localement qui se trouve employer la même forme sur le fil. Pepsi parle bel et bien OAuth 2.0 ailleurs — vers un smarthost, voir pepsi-helper-token-refresh — et il ne faut pas confondre les deux.

20.2.3. Mots de passe et sessions

Pour un navigateur. Les comptes vivent dans pepsi.admin_account avec des condensats de mot de passe Argon2id (RFC 9106) ; POST /api/v1/auth/login échange un nom et un mot de passe contre un cookie de session (RFC 6265) plus un jeton CSRF, et chaque requête mutante venue d’une session doit répéter ce jeton dans l’en-tête X-Pepsi-Csrf. Un jeton porteur et un pair local n’ont besoin d’aucun jeton CSRF : ni l’un ni l’autre n’est jamais envoyé par un navigateur pour le compte de quelqu’un d’autre.

Le cookie porte HttpOnly, SameSite=Strict et le préfixe de nom __Host-. Ce préfixe est le plus fort des trois : le navigateur refuse le cookie sauf s’il a été posé en HTTPS sans attribut Domain et avec Path=/, de sorte qu’un hôte frère du même domaine enregistrable ne puisse pas planter un cookie de session pour celui-ci.

Le préfixe et Secure sont une seule décision, et elle suit le schéma du client plutôt que celui de ce processus : un listener qui termine lui-même le TLS, ou un listener en clair derrière un serveur frontal qui affirme X-Forwarded-Proto: https comme décrit ci-dessus. Sur le chemin d’amorçage local en clair — un listener loopback ou UNIX sans serveur frontal — un navigateur refuserait purement et simplement un cookie Secure : aucun des deux n’est donc employé et le cookie s’appelle pepsi_session. Les deux graphies sont acceptées à l’entrée, de sorte qu’ajouter ou retirer un serveur frontal TLS ne déconnecte pas tous les administrateurs.

Ces exigences sont aussi la raison pour laquelle le portail à lien sécurisé, qui partage une origine, ne peut pas employer le même préfixe : __Host- impose Path=/, ce qui enverrait le cookie de session du portail à /metrics, à /resume et au Web Key Directory également. Il conserve son cantonnement par chemin, emploie __Secure- à la place, et rachète ce que le préfixe lui aurait donné en scellant la valeur du cookie sous le poivre du serveur — voir Le portail de repli par lien sécurisé.

Les sessions sont des lignes, non de la mémoire de processus : un redémarrage ne déconnecte donc personne et deux processus serveur s’accordent sur qui est connecté. Elles expirent de deux façons :

  • un délai d’inactivité (SESSION_IDLE, 30 minutes par défaut) repoussé à chaque requête, et

  • une durée de vie dure (SESSION_LIFETIME, 12 heures par défaut) qui n’est jamais prolongée.

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 encore demain, et un portable volé ne devrait pas être une session d’administration permanente. La politique est abordable parce que les gens qui administrent un déploiement le plus souvent — les opérateurs locaux — ne tapent jamais de mot de passe.

Note

PAM n’est pas pris en charge. Il placerait un chemin d’authentification privilégié et la sémantique des comptes système à l’intérieur de la couche web d’un serveur de courrier, et ferait dépendre la sécurité de l’API d’une configuration d’hôte que Pepsi ne contrôle pas. Un administrateur local n’en a pas besoin ; un administrateur distant reçoit un compte en base de données avec une liste de portées explicite.

20.3. Autorisation : les portées

Chaque point de terminaison déclare l’unique portée qu’il exige, dans la même table dont sont engendrés la table d’aiguillage et le document OpenAPI — de sorte que ce que ce manuel promet et ce que le serveur applique ne puissent pas diverger.

Portée

Accorde

config:read

Lire la configuration effective et sa provenance (secrets masqués).

config:write

Modifier la surcouche de configuration.

keys:read

Lire les identités locales, les clés de correspondants et les ancres de confiance.

keys:write

Modifier les identités locales et retirer des ancres de confiance.

peers:write

Importer la clé d’un correspondant, mettre en file une consultation de découverte, ou oublier une clé en cache.

queue:read

Lire le résumé de santé et la file des messages.

queue:write

Remettre en file, réacheminer ou supprimer un message en file.

logs:read

Lire le journal d’audit, le journal de courrier, les issues TLS et le cache DNS.

setup:write

Gérer les comptes et les jetons, et piloter la configuration en ligne.

secure:read

Lire les métadonnées de lien sécurisé : à qui un message stocké est destiné, quand il a été lu, combien de fois le code PIN a été mal saisi. Jamais son contenu.

secure:write

Révoquer un message à lien sécurisé, ce qui détruit l’unique copie qui en existe.

own:<address>

Autorité sur exactement une adresse électronique.

Un administrateur local détient toutes les portées sauf own:. Un principal ne peut jamais frapper un identifiant plus puissant que lui-même : créer un compte ou un jeton avec une portée que l’appelant ne détient pas est refusé avec scope_escalation.

20.3.1. La portée own:<address>

own:<address> restreint l’autorité à une seule adresse. Aucune interface n’émet d’elle-même un tel principal ; un administrateur en crée un sous forme de jeton ou de compte portant cette portée. La portée est néanmoins définie et appliquée sur chaque point de terminaison qui prend une adresse, car greffer après coup un modèle d’autorisation sur des points de terminaison écrits sans lui est la moitié coûteuse du travail.

Un principal ne détenant que des portées own: peut lire ses propres identités et clés de correspondants (un listage non filtré est restreint à son adresse plutôt que refusé) et ses propres lignes du journal de courrier (il doit y passer ?address= nommant sa propre adresse). Il ne peut lire celles de personne d’autre, ne peut toucher aux objets valant pour tout le déploiement — le cache de clés de correspondants, les ancres de confiance — et ne peut ni lire ni écrire la configuration. Un administrateur peut déléguer une adresse en émettant un tel jeton ; un jeton qui ne peut que créer des identifiants ne peut pas faire apparaître une autorité d’adresse qu’il ne détient pas lui-même.

Il peut aussi gérer ses propres clés (le libre-service à la manière de pEp ; voir Gestion des clés) : demander une clé gérée par le serveur (POST /api/v1/identities), enregistrer la propre clé publique de l’utilisateur (POST /api/v1/identities/client), et changer les drapeaux de sa propre identité, y compris une demande de téléversement vers un serveur de clés et sa révocation, avec PATCH /api/v1/identities/{id}. Chacune de ces opérations revérifie l’adresse, et les deux routes POST ainsi qu’un PATCH de révocation ne font que mettre en file une tâche que l’applicateur revérifie à son tour par rapport à la portée own: du principal. Supprimer la ligne d’une identité reste réservé à keys:write.

Il peut suivre les tâches qu’il a mises en file jusqu’à leur terme : GET /api/v1/setup/tasks/{id} répond pour une tâche que ce principal a lui-même mise en file, et GET /api/v1/setup/tasks liste exactement celles-là (voir Suivre une tâche). Tout le reste de la surface de configuration reste setup:write.

20.4. Conventions

  • JSON uniquement. Les requêtes et les réponses sont en application/json.

  • Une seule forme d’erreur, à chaque échec

    {"code": "scope_required", "hint": "this endpoint requires the 'queue:write' scope",
     "detail": {"scope": "queue:write"}}
    

    code est un jeton stable sur lequel brancher ; hint est de la prose pour un humain et peut changer ; detail porte des informations supplémentaires structurées lorsqu’il y en a.

  • Des échecs d’authentification uniformes. Un mauvais mot de passe, un compte inconnu et un compte désactivé produisent tous le même invalid_credentials, et un compte inconnu paie tout de même une vérification de mot de passe : ni le message ni le temps de réponse n’énumèrent donc les comptes.

  • Les défaillances internes ne portent jamais de cause. L’erreur complète va dans le journal du processus ; le client reçoit internal_error.

  • Les listages répondent {"items": [...], "total": N, "limit": L, "offset": O}, et toutes entendent la même chose par là. Voir Pagination.

  • Horodatages ISO-8601, avec un décalage.

  • Pas de barre oblique finale.

20.5. Pagination

Onze points de terminaison répondent l’enveloppe de listage, et tous les onze se comportent à l’identique :

Point d’accès

Filtres qui s’appliquent aussi à total

GET /api/v1/queue

?stage= ?status=

GET /api/v1/events

?kind= (préfixe) ?actor=

GET /api/v1/mail-log

?address=

GET /api/v1/secure-messages

?include_expired=

GET /api/v1/identities

?address= ?protocol=

GET /api/v1/peers

?address=

GET /api/v1/peers/unclaimed

?address=

GET /api/v1/ca-trust

—

GET /api/v1/accounts

—

GET /api/v1/tokens

—

GET /api/v1/setup/tasks

— (restreint aux propres tâches de l’appelant sans setup:write)

limit vaut par défaut [pepsi-admin] PAGE_LIMIT (100) et est borné à 1–1000 ; offset est borné à une valeur non négative ; une valeur qui n’est pas un nombre donne un 400. L’enveloppe renvoie la fenêtre appliquée, de sorte qu’une requête à limit=100000 revienne en disant "limit": 1000. Les deux entrent dans le SQL, et total est un COUNT(*) sur la même clause WHERE que la page — jamais la longueur d”items, et jamais la taille d’une fenêtre surdimensionnée. Chaque listage trie en dernier ressort sur quelque chose d’unique (son identifiant de ligne), de sorte qu’un OFFSET ne puisse ni sauter une ligne ni en montrer une deux fois.

GET /api/v1/mail-log ajoute un membre à l’enveloppe, mode, parce qu’une page vide ne signifie pas la même chose selon que [pepsi] MAIL_LOG enregistre quoi que ce soit ou non.

20.5.1. total et la page sont deux requêtes

Le compte s’exécute d’abord, puis la page, et rien ne maintient d’instantané entre les deux. Une transaction en lecture seule par listage les rendrait exactement cohérents, au prix d’immobiliser une connexion de base de données mutualisée dans une couche web pour une garantie dont aucun client n’a besoin. Donc, face à une table écrite en parallèle :

  • les lignes supprimées entre-temps font de total une surestimation — et la file est vidée en continu, c’est donc là le cas normal, non une curiosité. L’effet visible est une dernière page qui revient vide.

  • les lignes insérées entre-temps en font une sous-estimation.

Paginez jusqu’à ce qu”items soit plus court que limit ; traitez total comme un bon nombre à montrer à un opérateur, non comme une borne de boucle à croire à la ligne près.

Le compte est un véritable COUNT(*), non une estimation, sur chacune de ces tables. C’est abordable en raison de ce qu’elles sont : pepsi.workqueue est l”arriéré vivant — la ligne d’un message remis est supprimée par l’étape qui l’a remis, de sorte que la table contienne le courrier encore en vol plutôt qu’un historique de tout ce qui a jamais été envoyé — et le magasin de clés, les comptes et les jetons sont des objets à l’échelle du déploiement. event_log et mail_log sont les deux qui grandissent avec le trafic ; toutes deux sont bornées par les balayages de rétention décrits sous Le journal d’audit et Le journal de courrier (désactivé par défaut).

20.5.2. Pas cette forme

GET /api/v1/status prend un limit — combien de messages bloqués le rapport liste — mais pas d”offset, et répond le rapport de pepsi-status plutôt qu’un listage. GET /api/v1/tls-sessions répond {"days": D, "items": [...]} — il renvoie la fenêtre qu’il a appliquée, comme l’enveloppe de listage renvoie limit/offset — bornée par le paramètre days (1–365, valeur par défaut venue de pepsi-status). GET /api/v1/dns-cache répond un simple {"items": [...]}, borné seulement par la taille du cache d’échecs DNS.

20.6. Points de terminaison

La liste faisant autorité pour une compilation donnée est GET /api/v1/openapi.json, engendré depuis la table de routes propre au serveur — y compris les paramètres limit et offset, qu’il déclare exactement sur les points de terminaison listés sous Pagination. La colonne Notes ci-dessous donne les autres paramètres de chaque listage ; tout listage prend en outre ?limit= et ?offset=.

Méthode

Chemin

Portée

Notes

POST

/api/v1/auth/login

—

Renvoie un cookie de session et un jeton CSRF.

POST

/api/v1/auth/logout

—

Idempotent.

GET

/api/v1/auth/whoami

—

La première chose à vérifier quand quelque chose répond 403.

GET POST

/api/v1/accounts

setup:write

Les condensats de mot de passe ne sont jamais renvoyés.

PATCH DELETE

/api/v1/accounts/{id}

setup:write

Supprimer un compte met fin à ses sessions.

GET POST

/api/v1/tokens

setup:write

Le secret est dans la réponse au POST et nulle part ailleurs.

DELETE

/api/v1/tokens/{id}

setup:write

Révocation.

GET

/api/v1/status

queue:read

Identique à pepsi-status --json. ?limit= plafonne la liste des messages bloqués ; ce n’est pas un listage, donc pas d”?offset=.

GET

/api/v1/queue

queue:read

?stage= ?status=

GET

/api/v1/queue/{id}

queue:read

Enveloppe, étape, statut et state. Jamais le message.

POST

/api/v1/queue/{id}/requeue

queue:write

De nouveau en attente à son étape courante.

POST

/api/v1/queue/{id}/bounce

queue:write

Corps {"stage": "<bounce stage>"}.

POST

/api/v1/queue/{id}/cancel

queue:write

Supprime le message.

GET

/api/v1/events

logs:read

Le journal d’audit. ?kind= (préfixe) ?actor=

GET

/api/v1/mail-log

logs:read

?address= Vide sauf si [pepsi] MAIL_LOG est activé ; rapporte le mode.

GET

/api/v1/tls-sessions

logs:read

?days=

GET

/api/v1/dns-cache

logs:read

Adresses MX actuellement en échec.

GET

/api/v1/config

config:read

?scope= Valeurs effectives avec provenance ; secrets masqués.

GET

/api/v1/config/{section}

config:read

Une section.

PUT DELETE

/api/v1/config/{section}/{option}

config:write

Validé avant d’être stocké. Voir ci-dessous.

POST

/api/v1/config/validate

config:read

Essai à blanc ; répond 200 avec un verdict dans les deux cas.

GET

/api/v1/identities

keys:read

?address= ?protocol=

GET PATCH DELETE

/api/v1/identities/{id}

keys:read / keys:write

PATCH pose is_primary (true), published, vks_wanted ; ou, seul, status (uniquement revoked, avec un revocation_reason facultatif), ce qui met en file une tâche revoke-identity. Voir plus bas. GET et PATCH sont ouverts à own:<address> pour cette adresse ; DELETE ne l’est pas.

GET

/api/v1/identities/{id}/public

keys:read

La moitié publique, en base64.

POST

/api/v1/identities

keys:write ou own:<address>

Met en file une tâche generate-identity : {"address": ..., "vks": true|false}. Voir plus bas.

POST

/api/v1/identities/client

keys:write ou own:<address>

Met en file une tâche register-client-key : {"address": ..., "key": "<armoured public key>"}. Voir plus bas.

DELETE

/api/v1/otp/{address}

keys:write

Met en file une tâche reset-otp qui supprime (et donc déverrouille) le second facteur de l’adresse. Voir plus bas.

GET

/api/v1/peers

keys:read

?address=

POST

/api/v1/peers

peers:write

Importer à la main la clé d’un correspondant. Voir ci-dessous.

POST

/api/v1/peers/discover

peers:write

Met en file une consultation de découverte ; il ne récupère rien. Voir ci-dessous.

DELETE

/api/v1/peers/{id}

peers:write

Oublier une clé en cache.

GET

/api/v1/peers/unclaimed

keys:read

?address= — les adresses servies pour lesquelles nous ne détenons aucune identité mais dont nous avons tout de même mis une clé en cache. Un listage paginé.

GET

/api/v1/ca-trust

keys:read

Les ancres de confiance S/MIME.

DELETE

/api/v1/ca-trust/{id}

keys:write

Change quelles signatures entrantes se valident.

GET

/api/v1/secure-messages

secure:read

Messages à lien sécurisé en attente, métadonnées seulement. ?include_expired=1.

GET

/api/v1/secure-messages/{token}

secure:read

Un message avec son historique d’accès. Jamais son contenu.

DELETE

/api/v1/secure-messages/{token}

secure:write

Révoquer ; ceci détruit l’unique copie du message.

GET

/api/v1/dns-records

config:read

Les enregistrements à publier et le dernier verdict en service. Voir ci-dessous.

GET

/api/v1/setup/questions

setup:write

Le questionnaire de configuration initiale sous forme de données.

GET

/api/v1/setup/telemetry

setup:write

Indique si l’activation de la télémétrie de fonctionnalités pourrait prendre effet et, sinon, ce qu’il faut faire sur le serveur. Voir plus bas.

GET

/api/v1/setup/answers

setup:write

Les réponses en attente ; identifiants masqués.

PUT

/api/v1/setup/answers

setup:write

Fusionner des réponses dans le jeu en attente (brouillon).

DELETE

/api/v1/setup/answers

setup:write

Abandonner le questionnaire en attente.

GET

/api/v1/setup/tasks

setup:write, ou tout principal pour ses propres tâches

Tâches de configuration privilégiées, les plus récentes d’abord ; sans setup:write, seulement celles de l’appelant.

POST

/api/v1/setup/tasks

setup:write

Demander à l’applicateur l’une de ses actions issues d’un ensemble fermé.

GET

/api/v1/setup/tasks/{id}

setup:write, ou tout principal pour ses propres tâches

Une tâche, avec ?since= qui diffuse sa progression et ?wait= qui retient la réponse jusqu’à ce qu’il y ait du nouveau.

POST

/api/v1/setup/preflight

setup:write

Vérifier l’environnement (ports, DNSSEC du résolveur).

POST

/api/v1/setup/dns-check

setup:write

Comparer le DNS en service à ce que la configuration publierait.

POST

/api/v1/setup/certificates

setup:write

Obtenir les certificats dont la configuration a besoin.

GET

/api/v1/openapi.json

—

Engendré depuis la table de routes.

Les points de terminaison de supervision appellent les fonctions propres de pepsi-status et de pepsi-queue et sérialisent les mêmes structures, de sorte que pepsi-status --json et GET /api/v1/status soient les mêmes octets par construction. Les points de terminaison de configuration réemploient le parcours de provenance de pepsi-config et sa validation.

20.7. Ce que ce serveur ne peut pas faire

20.7.1. Engendrer ou révoquer du matériel de clé – il le demande à l’applicateur

Le matériel de clé privée vit dans crypto_identity.private_wrapped, que pepsi-setup accorde au rôle pepsi-crypto seul (le propriétaire du schéma mis à part) et révoque à tout compte de service, pepsi-httpd compris : une couche web capable de frapper des clés de signature les frappe pour quiconque la compromet.

POST /api/v1/identities n’engendre donc rien. Il met en file une tâche generate-identity ({"address": ..., "vks": true|false} ; vks est facultatif et vaut par défaut [pepsi-keys] VKS_PUBLISH) et répond avec la tâche ({"task": ..., "kind": "generate-identity", "applier_notified": ...}). L’applicateur root la revalide et engendre une clé OpenPGP en tant que rôle pepsi-crypto. La génération sur demande est toujours permise, même à côté de la propre clé de l’utilisateur.

POST /api/v1/identities/client met en file une tâche register-client-key de la même façon ({"address": ..., "key": ...}, la clé étant une clé publique OpenPGP en armure d’au plus 64 Kio). Elle passe par l’applicateur bien qu’aucun matériel privé ne soit en jeu, car une clé enregistrée comme étant celle de l’utilisateur reçoit la confiance du MTA – elle devient la face publique de l’adresse, et les signatures faites avec elle se vérifient comme étant celles de l’utilisateur – de sorte qu’une couche web compromise ne doit pas pouvoir en planter une. L’applicateur exige un certificat OpenPGP dont les User IDs nomment l’adresse, et n’enregistre pas une empreinte que l’adresse possède déjà, retirée ou non.

PATCH /api/v1/identities/{id} avec {"status": "revoked"} (et un "revocation_reason" facultatif, une ligne d’au plus 1024 octets) est asynchrone pour la même raison : révoquer une clé de signature seule détruit sa moitié privée, et cet UPDATE nomme private_wrapped, ce que pepsi-httpd n’a pas le droit de faire. Il met en file une tâche revoke-identity ({"address": ..., "identity_id": ..., "reason": ...}) et répond avec la tâche, non avec la ligne modifiée : {"identity_id": ..., "task": ..., "kind": "revoke-identity", "applier_notified": ...}. Le status de l’identité se lit revoked une fois que l’applicateur s’est exécuté ; GET /api/v1/setup/tasks/{task} montre la tâche elle-même, à setup:write comme au principal qui l’a demandée, et son result nomme l’empreinte et dit si l’identité a été révoquée par cette tâche ou l’était déjà. L’applicateur vérifie que l’identité appartient à l’adresse de la tâche puis applique la même règle que pepsi-keys identity revoke : la moitié privée d’une clé de signature seule est détruite, celle d’une clé de chiffrement est conservée afin que le courrier déjà chiffré pour elle reste lisible. Comme la réponse est une tâche, un PATCH de révocation porte status seul ; combiné à is_primary, published ou vks_wanted, il est refusé avec 400, de même qu’un revocation_reason sans status.

Pour les trois, la barrière d’autorisation de l’applicateur est keys:write, ou own:<address> pour exactement l’adresse des paramètres de la tâche – setup:write seul ne suffit pas, puisque gérer la clé d’un utilisateur n’est pas configurer le serveur – et l’adresse doit appartenir à un domaine de [pepsi-ingress] ACCEPTED_DOMAINS. Une tâche peut se terminer done, failed (l’applicateur a essayé et n’a pas pu, la raison dans error) ou refused (l’applicateur n’a pas du tout accepté la ligne), et le principal qui l’a demandée peut lire laquelle : une demande de révocation pour une clé compromise se suit avec GET /api/v1/setup/tasks/{task}?wait=60 jusqu’à ce que status soit l’un des trois, comme le décrit Suivre une tâche. Sans pepsi-httpd-admin, les trois répondent 503 setup_applier_unavailable, comme tout chemin passant par l’applicateur, et sans connexion [pepsi-admin] CONFIG_DB, 503 setup_write_unavailable (voir Configuration en ligne : seul le rôle pepsi-config peut mettre en file). Produire une CSR et importer un certificat émis ne sont pas proposés ici ; employez pepsi-keys.

Le second facteur du propriétaire. Une adresse dont le propriétaire a enrôlé un second facteur (Gestion des clés, « Un second facteur pour les modifications de clés ») exige son code courant pour chacune des trois lorsque la tâche est demandée sous own:<address> : ajoutez "otp": "123456" au corps (les formulaires de la console ont un champ pour cela). pepsi-httpd ne peut pas vérifier le code – pepsi.otp_key est accordée à pepsi-crypto seul –, il est donc transporté dans les paramètres de la tâche et l’applicateur le vérifie avant d’agir ; un code manquant, erroné, rejoué ou verrouillé fait échouer la tâche avec la raison dans error. Une tâche demandée avec keys:write n’est pas soumise au contrôle du second facteur : l’opérateur peut de toute façon réinitialiser le second facteur. DELETE /api/v1/otp/{address} (keys:write uniquement, jamais own:) met en file une tâche reset-otp qui supprime le second facteur de l’adresse, la déverrouillant ; son propriétaire s’enrôle alors de nouveau. Les changements d’indicateurs qu’un PATCH effectue directement (is_primary, published, vks_wanted) ne sont pas soumis à ce contrôle, puisque ce processus les effectue lui-même.

20.7.2. Écrire la configuration, sauf si vous le demandez

Écrire pepsi.config_override appartient au rôle de base de données pepsi-config, que pepsi-setup accorde et révoque explicitement à chaque compte de service — un composant qui traite du courrier ne doit pas pouvoir réécrire le pipeline dans lequel il s’exécute. pepsi-httpd est un tel compte, et l’authentification par pair se fonde sur l’uid effectif : sa propre connexion ne peut donc pas être ce rôle.

PUT et DELETE sur /api/v1/config répondent donc 503 config_write_unavailable tant que [pepsi-admin] CONFIG_DB ne nomme pas une connexion qui s’authentifie comme pepsi-config — en pratique un mot de passe dans un fragment secrets.d lisible par pepsi-httpd seul, ou une correspondance pg_ident. Le serveur vérifie avec SELECT current_user au démarrage et refuse toute autre chose.

PUT prend {"value": "...", "scope": "..."}, où scope vaut global (par défaut), domain:<domain> ou address:<address> ; DELETE prend la même chose sous forme de ?scope=, et GET lit la configuration effective telle que vue depuis cette portée. Les deux écritures répondent avec un objet reload indiquant comment le changement prend effet (hot, restart avec l’unité à redémarrer, ou ini-only).

Les sections qui ne sont lues que depuis le fichier de configuration ([pepsi], [pepsi-postgres], [paths], [pepsi-admin], [pepsi-httpd], [pepsi-crypto], [pepsi-srs], [pepsi-origin], [pepsi-secure-link], [pepsi-wizard], et chaque section [pepsi-httpd-listener-*], [pepsi-httpd-cert-*] et [pepsi-ingress-listener-*]) sont refusées avec 403 forbidden quoi qu’il arrive. De même pour toute option porteuse d’identifiants dans n’importe quelle autre section (une option dont le nom contient PASSWORD, PASSPHRASE, SECRET, TOKEN, CREDENTIAL, CLIENT_ID ou PEPPER – la même règle qui la masque dans GET, et qui couvre aussi le fichier d’où un identifiant est lu et le point de terminaison auquel il est envoyé) : les secrets ne sont jamais stockés dans la base de données. Une portée domain: ou address: n’est acceptée que pour les sections [stage-*], les seules lues par correspondant ; toute autre section y est également refusée avec 403. POST /api/v1/config/validate rapporte les mêmes refus sous forme de "valid": false. Le lecteur de la surcouche applique la même règle, de sorte qu’une ligne parvenue dans la table par un autre chemin est ignorée avec un avertissement.

20.7.3. Faire quoi que ce soit de privilégié

La configuration initiale écrit /etc/pepsi/pepsi.conf, remet chaque fragment secrets.d au seul compte qui le lit, exécute certbot, crée des rôles de base de données et engendre du matériel de clé — le tout en root. pepsi-httpd abandonne ses privilèges avant d’accepter une connexion et ne doit jamais pouvoir les regagner.

Les points de terminaison de configuration initiale n’agissent donc pas. Ils écrivent une ligne d”intention dans pepsi.setup_task, et un programme root distinct, pepsi-setup apply, la vide. On atteint root par une table de base de données, jamais par une socket parlant un protocole. Le modèle de confiance complet — la liste fermée de tâches, les deux barrières d’admission, ce qui est délibérément absent, et ce que la conception ne peut pas promettre — se trouve dans pepsi-setup(1), « Le modèle de confiance de l’applicateur », et est une lecture obligatoire avant de déployer la configuration par navigateur.

20.8. Clés de correspondants

POST /api/v1/peers stocke à la main la clé publique d’un correspondant — le frère de pepsi-keys peer import. (La console de navigateur n’a aucun import : sa page correspondant ne peut qu’oublier une clé, et renvoie à pepsi-keys peer import pour le reste.) Le corps nomme l”address, le protocol (openpgp ou smime), le material (en base64, ou du texte blindé/PEM verbatim) et un pin facultatif.

Deux refus importent :

  • Un matériel qui n’est pas une clé donne un 422 invalid_value. L’entrée OpenPGP passe par l’analyseur durci qu’emploient les méthodes de découverte, qui ne parcourt que le niveau supérieur et rejette un flux portant un paquet compressé ou chiffré — une clé publique transférable n’en contient jamais, et une bombe de compression agrafée à un trousseau n’est pas une clé qui vaille la peine d’être détenue.

  • Une clé qui nomme une adresse différente donne un 422 invalid_value nommant à qui la clé appartient réellement. Classer la clé d’un correspondant sous l’adresse d’un autre égare silencieusement chaque message futur qui lui est destiné. Une clé qui ne nomme aucune adresse est stockée — le silence ne peut rien contredire — et la réponse le dit dans son champ binding (matched / no-identity).

La ligne est stockée avec source = api, que la règle de conflit classe à côté d’une clé saisie à la main : elle déloge une clé découverte et ne déloge pas une clé épinglée. Une entrée *@domain est refusée ici ; le schéma n’en admet une que pour source = manual, de sorte qu’élargir tout un domaine reste une décision prise à un terminal.

POST /api/v1/peers/discover met en file une consultation et rend la main aussitôt. Il ne récupère rien : la découverte parle HTTPS, LDAP et DNS à des hôtes que choisit le domaine du correspondant, et le faire à l’intérieur d’un gestionnaire de demande laisserait un inconnu maintenir ouverte la connexion de ce serveur — la même raison pour laquelle les étapes de chiffrement et de déchiffrement garent un message au lieu de consulter une clé elles-mêmes. Le point de terminaison écrit la même ligne pepsi.key_request qu’écrit une étape lorsqu’elle gare un message, par la même fonction SQL, et un service pepsi-keydisc fait le travail. La réponse apparaît dans GET /api/v1/peers lorsque l’un d’eux l’a stockée. {"force": true} redemande une adresse dont l’entrée négative fraîche du cache dit qu’il n’y a pas de clé. Il répond 501 no_discovery_method lorsque [pepsi-keydiscovery] SOURCES est vide, puisqu’aucun service ne répondrait jamais, et 403 pour une adresse qu’excluent ALLOW_DOMAINS/DENY_DOMAINS.

20.10. Enregistrements DNS

GET /api/v1/dns-records répond avec les enregistrements que ce déploiement devrait publier, le dernier verdict du DNS en service pour chacun (ok / missing / mismatch / lookup-failed), le remède pour chacun qui est erroné, et le texte de zone qu’afficherait pepsi-setup run.

Il sert une réponse stockée plutôt que d’en calculer une. Dériver ce qui devrait être publié suppose de lire la configuration validée et le répertoire de clés DKIM, et le comparer suppose d’interroger le DNS — un travail qui revient à pepsi-setup, que ce serveur ne peut pas appeler (la dépendance va déjà dans l’autre sens) et dont une couche web ne devrait pas lire les entrées. L’applicateur privilégié le calcule donc dans le cadre d’une tâche run-preflight et le réécrit, et ce point de terminaison le rapporte avec l’heure à laquelle il a été calculé, toujours : un verdict sans âge invite à faire confiance à un verdict périmé. POST /api/v1/setup/dns-check en demande un frais, et exige donc setup:write — regarder ce qui a été trouvé est une lecture, demander à un processus root d’aller voir ne l’est pas.

Avant qu’une vérification n’ait été exécutée, la réponse est vide et nomme le point de terminaison qui en exécute une, plutôt qu’une liste vide qui se lirait comme « tout va bien ».

20.11. Configuration en ligne

Le chemin par navigateur à travers pepsi-setup. Deux mécanismes :

Les réponses sont mises en attente, non appliquées. PUT /api/v1/setup/answers fusionne dans un jeu de lignes brouillons de pepsi.config_override, que chaque lecteur de configuration écarte structurellement (WHERE NOT draft). Rien ne prend effet pendant que le questionnaire est en cours, de sorte qu’une session expirant à mi-parcours n’ait pas à moitié configuré un serveur de courrier, et reprendre consiste à relire les brouillons. Les identifiants sont ceux que publie GET /api/v1/setup/questions, qui sont aussi les clés que lit pepsi-setup --answers et que la section [pepsi-wizard] fait circuler dans les deux sens — de sorte qu’un questionnaire puisse être commencé dans un navigateur et achevé à un terminal.

L’interrupteur de télémétrie n’est proposé que là où il peut prendre effet. [pepsi] SHARE_TELEMETRY se trouve dans le fichier de configuration : il se règle donc comme n’importe quelle autre réponse et est écrit par la tâche write-config de l’applicateur, qui envoie ensuite la notification telemetry_changed (dans les deux sens) afin qu’un pepsi-telemetry-client en cours d’exécution relise le fichier. Ce démon peut tourner alors que la télémétrie est désactivée — dormant, il ne soumet rien — et il tient une ligne de vivacité dans pepsi.telemetry_client indiquant s’il tourne et si un SYSTEM_ID est configuré (un booléen ; la console ne voit jamais l’identifiant). La console ne génère jamais d’identifiant, puisque c’est précisément ce que le consentement explicite exclut ; à moins que le démon ne tourne et n’en possède un, oui ne peut donc pas prendre effet : GET /api/v1/setup/questions marque alors la question "disabled": true avec un disabled_reason qui indique à l’opérateur quoi faire sur le serveur (systemctl enable --now pepsi-telemetry-client.service ; ajouter à [pepsi] SYSTEM_ID = suivi de 64 caractères hexadécimaux), la console l’affiche désactivée, et un oui envoyé malgré tout via PUT /api/v1/setup/answers ou une tâche write-config est refusé avec 409 telemetry_client_not_ready. Désactiver, ou conserver une réponse qui vaut déjà oui, n’est jamais refusé. GET /api/v1/setup/telemetry répond avec le tableau complet :

{"question": "SHARE_TELEMETRY", "sharing": false, "can_enable": true,
 "reason": null,
 "client": {"running": true, "enabled": false, "submitting": false,
            "system_id_configured": true, "detail": null,
            "version": "0.1.0", "last_seen": "2026-09-27T10:00:00+00"}}

Les actions sont demandées, non effectuées. POST /api/v1/setup/tasks met en file l’une de onze sortes (write-config, write-secret, obtain-certificate, install-schema, provision-roles, generate-keys, run-preflight, generate-identity, register-client-key, revoke-identity, reset-otp) avec des paramètres strictement validés. Les quatre dernières agissent sur les clés d’une adresse et s’atteignent normalement par POST /api/v1/identities, /identities/client, un PATCH /api/v1/identities/{id} de révocation et DELETE /api/v1/otp/{address} ; l’applicateur les autorise par keys:write ou – toutes sauf reset-otp – par la portée own: propre à l’adresse plutôt que par setup:write. Il n’y a pas de sorte « commande arbitraire », et il n’y a aucune sorte redémarrage, rechargement ou arrêt — un changement qui exige le redémarrage d’un composant se termine par l’opérateur le redémarrant, ce que la console dit plutôt que de le cacher. L’applicateur refuse toute autre chose, et chaque refus, succès et échec est un enregistrement d’audit.

La mise en file passe par la connexion [pepsi-admin] CONFIG_DB, exactement comme les écritures de configuration et pour la même raison : seul le rôle pepsi-config peut faire un INSERT dans setup_task, et l’applicateur refuse toute ligne dont le written_by dit autre chose. Sans cette connexion, les points de terminaison de configuration initiale répondent 503 setup_write_unavailable ; le compte propre du serveur ne peut que faire un SELECT sur la file, de sorte que la console puisse observer le travail privilégié sans pouvoir le demander.

GET /api/v1/setup/tasks/{id}?since=<seq> renvoie les lignes de progression que l’applicateur a écrites jusque-là, de sorte qu’une exécution de certbot ou une installation de schéma puisse être observée ligne à ligne sans que l’applicateur ne retienne une connexion HTTP ouverte.

20.11.1. Suivre une tâche

Une tâche répond {"id", "kind", "status", "requested_by", "requested_at", "result", "error", "log"} ; status vaut pending ou running jusqu’à ce qu’il prenne l’une des valeurs done, failed ou refused, après quoi l’applicateur n’y écrit plus rien. params n’est jamais renvoyé, puisque le paramètre de l’un des types de tâche est un secret d’authentification.

Qui peut la lire. Un principal détenant setup:write lit toutes les tâches. Tout autre principal authentifié lit les tâches qu’il a lui-même mises en file – en pratique les tâches generate-identity, register-client-key et revoke-identity qu’il a mises en file par les points de terminaison de clés, les seules tâches qu’il puisse mettre en file. GET /api/v1/setup/tasks est restreint à celles-ci plutôt que refusé, et GET /api/v1/setup/tasks/{id} répond 404 not_found pour une tâche mise en file par quelqu’un d’autre, exactement comme pour une tâche qui n’existe pas. Ce qu’une telle tâche montre ne regarde que le demandeur : le type, l’adresse et l’identité qu’elle nommait, l’empreinte de la clé, et la raison de son échec ou de son refus. Les paramètres – une clé publique enregistrée, un motif de révocation – ne sont pas renvoyés, et les points de terminaison de clés ne livrent de toute façon rien de privé.

« Mise en file par lui-même » est déterminé par une clé enregistrée avec la tâche, requester_key, et non par requested_by. requested_by (token:<label>, session:<login>, peer:<login>) est ce que la tâche affiche et ce que le journal d’audit enregistre, mais c’est un nom, et les noms sont réutilisés : deux jetons peuvent porter la même étiquette, et un compte supprimé puis recréé sous le même login est un nouveau compte. La clé, elle, n’est jamais réutilisée : token:<selector> pour un jeton porteur (la moitié publique <id> de pepsi_<id>.<secret>, unique parmi les jetons), account:<n> pour une session par mot de passe (le numéro de ligne du compte, que PostgreSQL ne distribue jamais deux fois) et peer:<login> pour un pair local, où le compte système lui-même est le principal. Ainsi, un nouveau jeton étiqueté comme un ancien, ou un compte recréé, ne lit aucune des tâches de son prédécesseur. Une tâche mise en file depuis la ligne de commande (pepsi-setup) enregistre une clé vide, à laquelle aucun principal ne correspond.

Attendre au lieu de redemander. ?wait=<seconds> (au plus 60 ; une valeur supérieure est prise pour 60) retient la réponse jusqu’à ce que la tâche soit terminée ou ait une ligne de progression postérieure à since (0 en son absence), ou jusqu’à l’expiration du délai, selon ce qui survient en premier, puis répond exactement comme sans ce paramètre. Un client qui suit une tâche boucle donc sur ?since=<last seq>&wait=60 : chaque réponse porte les lignes qu’il n’a pas encore vues, et un status qui n’est ni pending ni running termine la boucle. Rien n’interroge la base de données entre-temps. Les écritures de l’applicateur déclenchent deux notifications portant l’identifiant de la tâche – setup_task_progress pour chaque ligne de progression, setup_task_done lorsque la tâche se termine (voir pepsi-setup) – et pepsi-httpd maintient une seule connexion d’écoute pour elles, qui réveille chaque requête en attente sur cette tâche. Une requête en attente s’abonne avant de lire la ligne, de sorte qu’une tâche qui se termine entre les deux n’est jamais manquée. Lorsque l’écouteur n’est pas connecté (PostgreSQL redémarré, par exemple), wait rend la main immédiatement, sans jamais bloquer ; l’écouteur se reconnecte de lui-même. Un wait qui n’est pas un nombre entier de secondes donne un 400.

La page de tâche de la console fait la même chose pour un navigateur, sans script : tant qu’une tâche est en attente ou en cours, la page se recharge aussitôt en une requête que le serveur retient jusqu’à 20 secondes, jusqu’à ce qu’il y ait quelque chose de nouveau à afficher, de sorte que le résultat apparaît au moment même où l’applicateur l’enregistre. Les formulaires de clés en libre-service (générer, enregistrer, révoquer) répondent avec cette page pour la tâche qu’ils ont mise en file.

20.11.2. Lorsqu’il n’y a pas d’applicateur : 503 setup_applier_unavailable

La moitié privilégiée est un paquet Debian distinct, pepsi-httpd-admin, qui ne contient que pepsi-setup-apply.socket et pepsi-setup-apply.service — tout le chemin d’une requête HTTP jusqu’à une modification sous /etc/pepsi. Le paquet pepsi le recommande, il est donc installé par défaut et reste désinstallable ; une installation depuis les sources dispose du même levier avec make install INSTALL_ADMIN_UNITS=no. Le retirer rompt la boucle au niveau du mécanisme : plus rien ne vide setup_task, de sorte qu’un INSERT est inerte.

pepsi-httpd le détecte et se dégrade en lecture seule plutôt que de mettre en file des requêtes que rien n’exécutera jamais. Chaque point de terminaison qui solliciterait l’applicateur répond 503 avec le code setup_applier_unavailable et un detail nommant le paquet et l’unité, de sorte qu’un script de déploiement n’ait pas à analyser de la prose

{"code": "setup_applier_unavailable",
 "hint": "the administrative package 'pepsi-httpd-admin' is not installed …",
 "detail": {"package": "pepsi-httpd-admin", "unit": "pepsi-setup-apply.socket"}}

Quels points de terminaison sont conditionnés est la partie intéressante :

Point d’accès

Conditionné à un applicateur ?

PUT /api/v1/setup/answers

Oui. Une ligne de brouillon est inoffensive en soi, mais la seule raison d’être d’un jeu de réponses est d’être appliqué ; un assistant qui met discrètement six étapes de réponses de côté sur un hôte où rien ne peut les appliquer est précisément l’inaction silencieuse que la séparation en paquets vise à éviter.

DELETE /api/v1/setup/answers

Non, délibérément. Jeter un brouillon ne peut causer aucune modification privilégiée, et un entretien coincé doit rester effaçable.

GET /api/v1/setup/questions, GET /api/v1/setup/answers, GET /api/v1/setup/tasks, GET /api/v1/setup/tasks/{id}, GET /api/v1/dns-records

Non. Ils lisent. (Un wait sur une tâche que rien ne traitera jamais expire simplement.)

POST /api/v1/setup/tasks, POST /api/v1/setup/preflight, POST /api/v1/setup/dns-check, POST /api/v1/setup/certificates, POST /api/v1/identities, POST /api/v1/identities/client, PATCH /api/v1/identities/{id} avec "status": "revoked", DELETE /api/v1/otp/{address}

Oui — chacun d’eux atteint l’unique ask_applier, qui appelle require_applier avant d’écrire quoi que ce soit. (Un PATCH qui ne fait que basculer is_primary, published ou vks_wanted ne l’atteint pas.)

PUT/DELETE /api/v1/config/{section}/{option}

Non. La surcouche de configuration écrit pepsi.config_override, une table de base de données, non /etc. Ils continuent de fonctionner sans applicateur.

503 plutôt que 501 : la capacité est à un paquet de distance, il s’agit donc d’une facilité qui n’est pas présente ici et maintenant, non d’une opération que l’API ne possède pas. C’est aussi un code distinct de setup_write_unavailable ci-dessus, car les remèdes diffèrent — « installer pepsi-httpd-admin et démarrer sa socket » contre « donner à ce serveur une connexion CONFIG_DB ».

La détection consiste en deux tests sur le système de fichiers, tous deux requis et aucun redondant : le fichier d’unité existe quelque part où systemd cherche (installé), et [pepsi-admin] APPLY_SOCKET (par défaut /run/pepsi/setup-apply.sock) est une socket accessible en écriture à ce compte (armée). Elle ne se connecte jamais — se connecter est le coup de sonnette, et démarrerait un processus root à chaque rendu de page. La moitié « fichier d’unité » existe parce que le RemoveOnStop= de systemd est désactivé par défaut, de sorte qu’un inode de socket périmé se lirait autrement comme une sonnette vivante et échouerait en ouverture dans exactement le scénario pour lequel cette fonctionnalité existe ; l’unité livrée positionne également RemoveOnStop=yes. Toute incertitude se lit comme indisponible. [pepsi-admin] APPLIER = auto|yes|no outrepasse la sonde pour un applicateur piloté par cron ou hors systemd, et no désactive purement et simplement la configuration initiale privilégiée.

20.12. Durcissement

  • Limitations de débit, par adresse source (RATE_LIMIT, 120/minute par défaut) et, sur le chemin de connexion, par compte et par adresse source (LOGIN_RATE_LIMIT, 10/minute par défaut) — beaucoup de mots de passe contre un compte et un mot de passe contre beaucoup de comptes sont des attaques différentes. L”« adresse source » est celle du client, prise dans X-Forwarded-For lorsqu’un serveur frontal local de confiance en affirme une ; un appelant sur socket UNIX sans une telle affirmation n’a pas d’adresse, et tout l’hôte partage une seule clé.

  • Plafonds de corps (MAX_BODY, 1 Mio par défaut).

  • Comparaison à temps constant de chaque jeton et de chaque condensat CSRF.

  • Aucun secret n’est jamais renvoyé par GET /api/v1/config : une valeur qui pourrait en être un est remplacée par "***" avec "secret": true. Le masquage est délibérément généreux — une valeur masquée à tort coûte un coup d’œil dans le fichier, une valeur montrée à tort est publiée à quiconque détient config:read.

  • Aucun contenu de message n’est atteignable. Les points de terminaison de la file répondent l’enveloppe, l’étape, le statut et le JSON state, jamais headers ni body. Lire du courrier n’est pas une fonction d’administration.

  • Les réponses portent Cache-Control: no-store, X-Content-Type-Options: nosniff et une politique frame-ancestors 'none'.

20.13. Le journal d’audit

Chaque changement de configuration, opération de clé, connexion, connexion en échec, modification de compte et de jeton, et action d’administration sur la file est consignée dans pepsi.event_log et lisible par GET /api/v1/events :

event_id  at  actor  kind  subject  severity  detail

actor nomme le principal (peer:root, token:monitoring, session:alice) ou, pour un changement fait à un terminal, le login appelant (cli:alice). Les outils en ligne de commande de l’opérateur écrivent dans le même journal, de sorte qu’il soit complet quelle que soit la surface qui a agi ; un journal qui ne remarquerait que ce qui s’est passé en HTTP inviterait à tirer la mauvaise conclusion d’une absence.

Le journal est en ajout seul pour tout ce qui traite du courrier : pepsi-setup accorde à ces rôles INSERT et SELECT et révoque UPDATE/DELETE, puis vérifie la révocation face au serveur en service. La rétention est bornée par [pepsi-admin] EVENT_RETENTION_DAYS (90 par défaut) et élaguée chaque jour par pepsi-httpd prune, que pepsi-log-prune.timer exécute sous le compte pepsi-httpd, que le serveur web tourne ou non.

Ni une valeur de configuration ni le moindre matériel de clé n’est écrit dans un enregistrement : ce qui a changé est le fait auditable, non ce en quoi cela a changé.

20.14. Le journal de courrier (désactivé par défaut)

Avertissement

Activer ``[pepsi] MAIL_LOG`` fait que ce déploiement conserve la trace de qui correspond avec qui. Demandez-vous si vous avez besoin de cette preuve, et si la conserver est licite là où vous opérez, avant de l’activer.

Pepsi supprime la ligne d’un message lorsque le pipeline en a fini avec lui. Il n’y a donc aucun journal de succès par message : un déploiement ordinaire n’accumule pas de trace de la correspondance de ses utilisateurs, et ne peut pas être contraint d’en produire une qu’il n’a pas.

Certains déploiements ont réellement besoin de cette preuve. [pepsi] MAIL_LOG la fournit, comme un acte d’administration aux conséquences énoncées :

off

La valeur par défaut. Rien n’est écrit ; pepsi.mail_log reste vide.

summary

Une ligne au moment où le message quitte le pipeline : l’expéditeur et les destinataires d’enveloppe, la direction (outbound pour le courrier soumis localement, inbound sinon), l’étape où il s’est arrêté, l’issue, et ce que le pipeline a décidé à son sujet (le verdict d’authentification, la décision spam/payé, tout détail d’échec au saut suivant).

full

Ce qui précède plus la ligne Subject:.

Le contenu des messages n’est jamais enregistré, quel que soit le réglage. Les lignes sont lisibles par GET /api/v1/mail-log pour un principal détenant logs:read (ou, pour une adresse, own:<address>), sont en ajout seul pour les comptes traitant du courrier exactement comme le journal d’audit, et sont élaguées après [pepsi-admin] MAIL_LOG_RETENTION_DAYS jours (30 par défaut).

Une ligne est écrite lorsqu’une étape en finit avec un message (completed) et lorsqu’un message échoue définitivement (failed) — les deux façons dont il cesse d’avancer. L’écriture voyage sur la même instruction que le terminal qui retire ou fait échouer la ligne, de sorte qu’activer le journal ne coûte aucun aller-retour supplémentaire à la base.

20.15. Amorçage

Une installation fraîche n’a aucun compte, et n’en a pas besoin : la socket UNIX plus SO_PEERCRED fonctionne toujours pour un administrateur local. À partir de là,

# curl --unix-socket /run/pepsi/admin.sock -X POST \
    -H 'Content-Type: application/json' \
    -d '{"login":"alice","password":"…","scopes":["queue:read","queue:write"]}' \
    http://localhost/api/v1/accounts

crée le premier compte distant. Un mot de passe perdu n’est pas un verrouillage : la socket locale est toujours là.

Lorsque la socket n’est pas joignable — un opérateur travaillant depuis une autre machine, ou un conteneur sans shell sur l’hôte — pepsi-setup bootstrap, exécuté en root, affiche un jeton porteur qui détient l’ensemble complet des portées d’administrateur, expire dans l’heure, et est accepté exactement une fois : de quoi faire un unique POST /api/v1/accounts.

Il doit détenir cet ensemble plutôt que setup:write seul, car POST /api/v1/accounts refuse d’émettre une portée que l’appelant ne détient pas lui-même — un jeton limité à setup:write ne pourrait donc créer aucun administrateur, et un compte qu’il aurait créé ne pourrait pas non plus s’élargir ensuite. Ce qui borne cet identifiant, c’est sa durée de vie, son usage unique, et le fait que le prochain pepsi-setup bootstrap le révoque.

À usage unique à cause de l’endroit où il est affiché. Un jeton sur un terminal est dans un tampon de défilement et un jeton dans le journal est lisible par quiconque peut lire le journal, ce qui est d’ordinaire un ensemble de gens plus large que « peut administrer le serveur de courrier ». Il est consommé à la présentation par une mise à jour qu’une seule demande concurrente peut gagner, de sorte qu’un jeton lu par deux personnes n’en admette qu’une. Seul son condensat est stocké : il ne peut donc pas être récupéré — relancez la commande, ce qui révoque aussi le précédent.

20.16. La console de navigateur

La console d’administration est un client de cette API, monté sous /ui sur les mêmes listeners ADMIN = yes, partageant son authentification, ses portées et son journal d’audit. Elle n’ajoute aucune capacité : tout ce qu’elle fait est atteignable ici avec curl. Deux détails du mécanisme de session existent pour elle, et comptent lorsqu’on écrit un autre client :

  • POST /api/v1/auth/login renvoie le jeton CSRF dans le corps de la réponse et le pose comme cookie pepsi_csrf (HttpOnly, SameSite=Strict ; nommé __Host-pepsi_csrf chaque fois que le cookie de session porte ce préfixe). Un programme lit le corps ; la console n’a pas d’autre moyen de faire entrer le jeton dans un formulaire, puisqu’elle ne livre aucun script capable d’en lire un dans la page. Aucun des deux emplacements n’est la vérification — la vérification, c’est que le condensat de ce que présente une requête mutante égale celui stocké avec la session.

  • Une requête mutante peut présenter le jeton dans l’en-tête X-Pepsi-Csrf (ce que fait un programme) ou comme champ de formulaire _csrf (ce que fait un formulaire HTML). Un jeton porteur et un pair de socket UNIX ne se voient demander ni l’un ni l’autre, puisqu’un navigateur ne les envoie jamais pour le compte de quelqu’un d’autre.