.. This file is part of PEPSI. Copyright (C) 2026 GNUnet e.V. PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. PEPSI is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. ========================== The administration console ========================== Pepsi ships a small browser console, served by **pepsi-httpd** under ``/ui``. It exists for the things that are painful on the command line: watching the queue, browsing keys and correspondents, reading the audit log, and changing configuration with validation and provenance visible. It is a **client of the documented administrative API** (:doc:`admin-api`) and nothing more. Every page calls the same library functions the ``/api/v1`` endpoints call, through the same authentication, the same scope checks and the same audit log, so anything the console can do can also be done with ``curl`` — and the two cannot disagree about what the queue contains or about whether a configuration value is valid. .. note:: **There are two HTTP APIs in this server and they are not the same.** ``/api/v1`` is Pepsi's own (:doc:`admin-api`), which this console is a client of: accounts, scopes, sessions and CSRF, gated on ``ADMIN = yes``. ``/3.0/`` and ``/3.1/`` are **the GNU Mailman 3 REST API** (:doc:`mailman-api`), reimplemented so that Mailman software works against Pepsi: one shared password, no scopes, somebody else's shapes, gated on ``LIST_API = yes``. An operator will meet both; they share the server and nothing else, and neither is a version of the other. .. _three-listener-flags: Three listener flags, and two of them have opposite advice ========================================================== ``pepsi-httpd`` serves four things and gates three of them, each on its own ``[pepsi-httpd-listener-*]`` flag. Getting these the wrong way round is the single easiest way to mis-deploy Pepsi's HTTP surface, so they are in one table: .. list-table:: :header-rows: 1 :widths: 14 30 28 28 * - Flag - What it serves - Who it is for - Where to put it * - ``ADMIN`` - ``/api/v1``, ``/ui``, ``/metrics`` - The operator. Bearer tokens, password sessions, ``SO_PEERCRED``, scopes, CSRF. - **Behind a tunnel.** A UNIX socket, or loopback with a reverse proxy. ``pepsi-httpd`` refuses to serve it in cleartext off this host. * - ``LIST_API`` - ``/3.0/``, ``/3.1/`` - Mailing-list software (``mailmanclient``, Postorius, HyperKitty). **One shared password**, no accounts, no scopes. - **Loopback or TLS.** Same refusal as ``ADMIN``, for the same reason: a static site-wide credential must not cross a cleartext network. * - ``LISTS`` - ``/lists``, ``/archives/list/…``, ``/robots.txt`` - **The anonymous public.** No credential at all. - **On the open internet.** This is the one surface meant to be reachable, so the cleartext restriction is deliberately not applied — but a subscribe form carries somebody's address, so use the TLS listener. * - *(none)* - MTA-STS, Web Key Directory, mail autoconfiguration, the secure-link portal, ``POST /resume``, and the Outlook add-in (``/addin/``, switched on by ``[pepsi-httpd] ADDIN`` rather than by a listener flag) - Anybody who needs them, by design. - Wherever the public listener is. Every gated route answers a plain ``404`` — byte for byte what an unknown path answers — on a listener without its flag. That is why publishing the public HTTPS listener does not publish administration, and why a public listener cannot be probed for what else this server runs. Reaching it safely ================== The console mounts on exactly the listeners the API does: those flagged ``ADMIN = yes`` (see :manpage:`pepsi-httpd(1)`). On every other listener the ``/ui`` paths answer a plain ``404``, byte for byte what an unknown path answers, so publishing the public listener does not publish the console and a public listener cannot be probed for whether administration is enabled here. **pepsi-httpd** refuses to serve it on a flagged listener that would carry it in cleartext off this host: plaintext TCP is accepted only on a loopback address, while TLS listeners and UNIX sockets always qualify. A socket-activated listener (``SERVE = systemd``) without TLS never qualifies, whatever its unit binds, since the passed descriptor could be anything and treating it as permitted would let the strictest case through the loosest check — give administration a listener of its own. This is not only about eavesdropping — the session cookie carries the ``__Host-`` prefix (RFC 6265bis §4.1.3.2) only when it is set ``Secure``, i.e. only when the client's connection is HTTPS, since a browser refuses a ``__Host-`` cookie that is not. On the plaintext loopback bootstrap path neither is used, so the console's own authentication is weaker by construction on a cleartext origin. A browser cannot open a UNIX socket, so a typical deployment either * binds an administrative listener to ``127.0.0.1`` and reaches it over an SSH tunnel (``ssh -L 8443:127.0.0.1:8443 mail.example.com``), or * gives the administrative listener TLS and a name of its own, and treats it like any other internet-facing administrative surface. Signing in ========== Two of the API's three mechanisms are useful from a browser: **A local operator over the administrative UNIX socket** is identified by ``SO_PEERCRED`` and needs no password at all. Browsers cannot speak to a UNIX socket, so this path is for ``curl`` and for the first-run bootstrap; it is also why the strict session policy below is affordable. **A remote administrator** signs in at ``/ui/login`` with an account from ``pepsi.admin_account`` (create one with ``POST /api/v1/accounts``). Sessions have a **30-minute idle timeout** and a **12-hour hard lifetime** regardless of activity, and there is no "remember me". This console can revoke a key, read who corresponds with whom and rewrite the pipeline; a forgotten browser tab should not still hold that tomorrow, and a stolen laptop should not be a standing administrative session. Bearer tokens work too, but a browser has no way to attach one; they are for automation against ``/api/v1``. What the console can and cannot do ================================== The actions are **message-level and key-level only**: * requeue a stuck message at its current stage, route it to a bounce stage, or delete it without delivering; * publish or withdraw a local identity, make one primary, delete it, or request its upload to the key server; * **ask** the applier for a server-managed key, to register a user's own public key, or to revoke an identity (all three through the privileged applier, below); * **forget** a cached correspondent key, and remove a trust anchor. Importing a key by hand and queueing a discovery lookup are **not** console actions: they are ``POST /api/v1/peers`` and ``POST /api/v1/peers/discover`` on the API, or :manpage:`pepsi-keys(1)`, which the page itself points at; * revoke a secure-link message; * set or remove one configuration override; * answer the setup interview, and **ask** the privileged applier to run certbot, install the schema, provision roles or check DNS. There is deliberately **no restart, no shutdown and no backup button**. A web console that can stop the mail server is one that can be *tricked* into stopping the mail server, and systemd already owns that job. Restart a component with ``systemctl restart pepsi-ingress`` (the configuration pages name the unit when a change needs one). Everything privileged here has that shape: the console records what should happen and a separate root program does it. The console cannot write ``/etc``, run certbot or create a database role, and must not be able to. See "Setup in the browser" below. Two further things the console cannot do, because the server cannot: * **Generate or revoke key material itself.** That needs the ``pepsi-crypto`` database role, which holds the grant on the private column and which **pepsi-httpd** deliberately does not hold — a web tier that can mint signing keys is a web tier whose compromise mints signing keys. The identities page's *Generate a server-managed key* form therefore only **asks**: it enqueues a ``generate-identity`` task that ``pepsi-setup apply`` re-validates and performs as ``pepsi-crypto``. *Register my own key* (paste an armoured OpenPGP public key) enqueues ``register-client-key`` the same way, and an identity's *Revoke* button, once confirmed, enqueues ``revoke-identity`` — revoking a signing-only key destroys its private half, which is a write to that column. Each answers with the queued task's own page, ``/ui/setup/tasks/``, which the user who asked may open whatever their scopes, and which shows the outcome -- done, failed with the reason, or refused -- the moment the applier records it. All three answer ``503`` when the applier package is not installed. Each form also has a *Second-factor code* field: a user who enrolled a second factor (:doc:`key-management`) types the current code from their authenticator app there, and the applier checks it -- the console cannot. CSRs and issued certificates stay with :manpage:`pepsi-keys(1)`. * **Write the configuration overlay**, unless ``[pepsi-admin] CONFIG_DB`` names a connection that authenticates as the ``pepsi-config`` role. Without it the configuration pages are read-only and say why. The console also never shows a message's headers or body. The queue pages load the envelope, the stage, the status and the ``state`` JSON, and nothing else — structurally, because the query behind them does not select those columns. Reading mail is not an administrative function. The pages ========= .. list-table:: :header-rows: 1 :widths: 26 16 58 * - Path - Scope - What it shows * - ``/ui`` - ``queue:read`` - Dashboard: queue depth by ``(stage, status)`` with the oldest message in each bucket, the messages that stopped and why, lifetime counters, per-stage counters, recent outbound TLS outcomes, failing MX addresses, and — with ``keys:read`` — the local identities with expiry warnings. * - ``/ui/queue`` - ``queue:read`` - Filterable listing (by stage and status), paginated, with an opt-in 30-second reload. * - ``/ui/queue/`` - ``queue:read`` - One message: envelope, stage, status and pretty-printed ``state``. The requeue / bounce / delete forms need ``queue:write``. * - ``/ui/identities`` - ``keys:read`` - Local key pairs, filterable by address, with the *Generate a server-managed key* and *Register my own key* forms (``keys:write`` or the address's own ``own:`` scope; they post to ``/ui/identities/request/{generate,register}``). * - ``/ui/identities/`` - ``keys:read`` - One identity, with its custody (server-managed or the user's own key) and key-server state, and publish / withdraw / *Upload to the key server* / make-primary / revoke (``keys:write`` or the address's own ``own:`` scope; revoking is queued for the applier) and delete (``keys:write`` only). * - ``/ui/peers`` - ``keys:read`` - Cached correspondent keys: source, DNSSEC flag, validity, pin state. Forgetting one needs ``peers:write``, and forgetting is the **only** action here — the page names ``pepsi-keys peer import`` for the rest. * - ``/ui/ca-trust`` - ``keys:read`` - The S/MIME trust anchors; removal needs ``keys:write``. * - ``/ui/config`` - ``config:read`` - Every section of the effective configuration for a scope, with how a change takes effect. * - ``/ui/config/
`` - ``config:read`` - One section: each option's value, where it came from (file, global, domain, address), and the forms to set or remove an override (``config:write``). * - ``/ui/logs`` - ``logs:read`` - The audit log, filterable by kind and actor. * - ``/ui/logs/mail`` - ``logs:read`` or ``own:`` - The opt-in per-message log, filterable by address; says so plainly when ``[pepsi] MAIL_LOG`` is off. A principal holding only ``own:
`` sees that address's records and nobody else's, as with ``GET /api/v1/mail-log``. * - ``/ui/logs/tls`` - ``logs:read`` - Outbound TLS session outcomes per policy domain. * - ``/ui/logs/dns`` - ``logs:read`` - MX addresses the resolver cache currently marks as failing. * - ``/ui/domains`` - ``config:read`` - The served domains, the MTA-STS mode, how many identities are published under each, and the DNS records to publish with the last live verdict for each. The "check DNS now" button needs ``setup:write``. * - ``/ui/secure`` - ``secure:read`` - Secure-link messages the portal is holding: correspondents, expiry, reads, wrong PINs, lockouts. Revoking needs ``secure:write``. * - ``/ui/secure/`` - ``secure:read`` - One message with its access history. * - ``/ui/setup`` - ``setup:write`` - The setup interview's entry point; each step is ``/ui/setup/`` (``GET`` renders it, ``POST`` stores that step's answers), resuming where it left off. * - ``/ui/setup/tasks`` - ``setup:write``, or any principal for its own requests - What has been asked of the privileged applier, and a form to ask for more. Without ``setup:write``, only the requests the user made itself. * - ``/ui/setup/tasks/`` - ``setup:write``, or any principal for its own requests - One action with the applier's streamed progress, updated as soon as the applier writes something. No page loads an unbounded result set: every listing goes through a query carrying its own ``LIMIT``/``OFFSET``, so that is a property of the SQL rather than of what the renderer draws. The queue and the log views paginate with ``?limit=``/``?offset=`` (clamped to ``[pepsi-admin] PAGE_LIMIT``, at most 1000). The key-store listings — identities, correspondents, trust anchors — fetch one page's worth, plus one row to learn whether there is more, and say plainly when they were cut short; narrow them with the address filter, or use :manpage:`pepsi-keys(1)`. The console asks for a ``COUNT(*)`` only where it shows a total (the two log views); the API always does, because its clients page programmatically. See :doc:`admin-api`. Every destructive action — deleting a queued message, revoking or deleting an identity, removing a trust anchor, removing a configuration override — goes through an explicit confirmation page first. Every mutation is written to the audit log with the same event kind the API records, so ``/ui/logs`` shows changes made from the console, from ``curl`` and from the command-line tools alike. Configuration values that could carry a secret are shown as ``***``, never as values, by the same rule the API masks with. The list is deliberately generous: a value wrongly masked costs an operator a look in the file, a value wrongly shown is published to everyone holding ``config:read``. Setup in the browser -------------------- ``/ui/setup`` renders the same interview ``pepsi-setup --wizard`` asks at a terminal — literally the same, because the questions, their shapes, their defaults and the conditions under which they are asked live in one place as data (``pepsi-setup-model``) and both front-ends render it. A question added to one is a question added to both. Two separate mechanisms keep it safe: **Answers are drafts.** Each step writes into ``pepsi.config_override`` with the ``draft`` flag, which every configuration reader filters out structurally. Nothing takes effect while the interview is in progress, so a session that times out half way through has not half-configured a mail server, and reopening the page resumes where it left off. A step with a bad answer re-renders with the message beside the field and stores **nothing** — not even the good answers on the same page, because a half-stored step is one an operator has to reconstruct from memory. **Actions are intents.** ``/ui/setup/tasks`` shows what has been asked of the privileged applier and offers a form to ask for more, from its closed set: ``run-preflight``, ``obtain-certificate``, ``install-schema``, ``provision-roles``. A button writes a ``pepsi.setup_task`` row and rings a doorbell; ``pepsi-setup apply``, running as root, does the work and streams its progress back into the row, which ``/ui/setup/tasks/`` shows. While the task runs the page reloads itself (a ````, not a timer in script), and the reload does not poll: it asks for the page again with what it already showed, and the server holds that request for up to 20 seconds until the applier's notification says there is a new progress line or an outcome. The result therefore appears the moment it exists. If the server has lost its database listener connection it answers at once and the page falls back to reloading every five seconds. There is no restart or shutdown action; a change that needs a component restarted ends with you restarting it. Both need ``setup:write``, the reads included: the staged answers are the shape of a deployment being built and the task list is a record of privileged work. The one exception is a user's own requests: the key actions on the identities pages queue tasks too, and whoever queued one may open it and find it in ``/ui/setup/tasks``, which for them lists nothing else. Somebody else's task is "not found". **Two independent things must hold before anything can be saved**, and the page tells you which one is missing: * the **privileged applier** must exist — the separate ``pepsi-httpd-admin`` Debian package, holding ``pepsi-setup-apply.socket`` and its service. It is a ``Recommends``, so it is installed by default and can be removed without taking Pepsi with it; and * ``[pepsi-admin] CONFIG_DB`` must name a connection authenticating as the ``pepsi-config`` role, because the applier refuses any row not written by it. The applier is checked first, because with no applier configuring ``CONFIG_DB`` would not help. When either is missing the interview **degrades to read-only** rather than banking answers nothing will apply: every value still renders, the inputs come back ``disabled``/``readonly``, the save button is gone, and a flash names the reason. The corresponding API calls answer ``503 setup_applier_unavailable`` or ``503 setup_write_unavailable`` (see :doc:`admin-api`). The one deliberate exception is *discarding* a draft — that cannot cause a privileged change, and a wedged interview must stay clearable. Note that the ``/ui/config`` pages are **not** affected by a missing applier: they write ``pepsi.config_override``, a database table, not ``/etc``. They need ``CONFIG_DB`` and nothing else. A ``password``-shaped answer is rendered blank every time, never echoed back into a ``value=`` attribute, and submitting the step with it blank leaves whatever is stored alone. The audit entry for a step names the question identifiers and no values at all. Secure links ------------ ``/ui/secure`` lists the messages the secure-link portal is holding for recipients who had no key: who they are from and for, when they expire, how often the PIN was entered correctly and wrongly, and whether a lockout is in force. ``/ui/secure/`` adds the access log. **The message itself is not on either page, and cannot be.** ``pepsi-httpd`` *is* the portal, so it is the one database role granted the ciphertext column — which is exactly why the operator pages do not read it. The queries behind them name every column except that one and report only its length, so the discipline lives in the SQL rather than in a struct field somebody remembered to leave out. Revoking destroys the only copy of the message and therefore needs ``secure:write``, a separate capability from ``secure:read``. DNS on the domains page ----------------------- ``/ui/domains`` shows, beside each served domain, every DNS record Pepsi expects it to publish: the name, the value to publish, whether live DNS carries it, and what to do when it does not. The zone text at the foot of the page is the same block ``pepsi-setup run`` prints. The verdict comes from the applier, not from this server: working out what should be published means reading the signing keys, and comparing it means querying DNS. The page therefore always shows **when** the answer was computed, and "check DNS now" (``setup:write``) asks for a fresh one rather than blocking the request on a resolver. End-user pages -------------- The ``own:
`` scope is defined and enforced, and a principal holding only it reaches the identity and correspondent pages narrowed to its own address — another address's pages answer ``404``, and the shared objects (the correspondent-key cache, the trust anchors) are refused outright. It also reaches the mail log, narrowed to its own address; asking for another address there is refused with ``403``. On its own identities it may also act: ask for a server-managed key, register its own public key, request a key-server upload, publish, withdraw and ask for a revocation -- everything but deleting a row (see :doc:`key-management`). There is no dedicated *end-user interface*; :manpage:`pepsi-stage-edit-settings(1)` lets users change their own settings by e-mail, and the encrypt stage's ``pepsi-keys@`` control address lets them manage their keys by e-mail. Two templating engines, two purposes ==================================== Pepsi renders two quite different kinds of text, and uses a different engine for each — so do not try to override a console page the way you override a bounce. **Mail bodies are Mustache templates**, shipped as files under ``[pepsi] TEMPLATE_DIR`` (``bounce-..body``, ``payment-request..body``, ``edit-settings..body``, ``wallet..body``). They are **operator-editable and per-language**: an operator is expected to rewrite the prose a correspondent will read, and to add a language, without rebuilding anything. Logic-less templating is right there — there is nothing to compute, and a template that cannot run code is a template an operator can safely be handed. **Console pages are askama templates**, compiled into the binary and type-checked at build time against the Rust structs they render. There is nothing to install at runtime, nothing to keep in sync with the binary, and a renamed field is a build error rather than a blank cell in production. The console is mostly tables and conditionals, which logic-less templating makes painful. The practical consequence: **there is no way to override a console page**, and there is not meant to be. If a page says the wrong thing, that is a bug to report, not a file to edit. No scripting, no remote assets ============================== The console ships **no JavaScript at all**. Every action is a form or a link, so it works with scripting disabled — including the queue's optional auto-refresh, which is a ```` the reader turns on and off with a link rather than a timer. The charts are small hand-written inline SVG generated on the server, so they render in the same request as the rest of the page. Nothing is loaded from another host: one stylesheet, compiled into the binary and served from ``/ui/static/console.css``. The Content-Security-Policy therefore forbids script outright rather than allowlisting the console's own, and forbids framing (``frame-ancestors 'none'``, with ``X-Frame-Options: DENY`` beside it). Pages are sent ``Cache-Control: no-store``, so administrative data does not sit in a shared cache or in the back button after a sign-out. Cross-site request forgery is refused twice over. Every form carries the session's CSRF token as a hidden field, checked against a digest stored with the session; and a mutating request whose ``Origin`` header names another host is refused before its body is even read — which is what covers the login form, the one form that by definition has no session yet. Values that arrive from outside — a subject, a sender's display name, a header value, a bounce reply text, a key's user id — are escaped by default when they are rendered, and the tests exercise that with hostile input rather than trusting it. The only markup the console emits unescaped is the SVG of its own charts, which contains numbers and nothing else by construction. Accessibility ============= The console is plain HTML: a skip link to the main content, one ``

`` per page, tables with ```` and ````, a label for every form field, and ``aria-current="page"`` on the navigation entry you are on. Every action is a real ``