15. 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 (The administrative 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.

15.1. Reaching it safely

The console mounts on exactly the listeners the API does: those flagged ADMIN = yes (see 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. This is not only about eavesdropping — the session cookie carries the __Host- prefix (RFC 6265), which a browser honours only for a cookie set over HTTPS, 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.

15.2. 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.

15.3. 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, revoke it, delete it;

  • import, forget or queue a lookup for a correspondent key; remove a trust anchor;

  • 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).

That last one is the shape of everything privileged here: 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 key material. 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. Use 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.

15.4. The pages

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/<id>

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.

/ui/identities/<id>

keys:read

One identity, with publish / withdraw / make-primary / revoke / delete (keys:write).

/ui/peers

keys:read

Cached correspondent keys: source, DNSSEC flag, validity, pin state. Forgetting one needs peers:write.

/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/<section>

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

The opt-in per-message log; says so plainly when [pepsi] MAIL_LOG is off.

/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/<token>

secure:read

One message with its access history.

/ui/setup

setup:write

The setup interview, one page per step, resuming where it left off.

/ui/setup/tasks

setup:write

What has been asked of the privileged applier, and a form to ask for more.

/ui/setup/tasks/<id>

setup:write

One action with the applier’s streamed progress; reloads itself while it runs.

No page loads an unbounded result set — and since every listing now goes through a query that carries its own LIMIT/OFFSET, that is a property of the SQL rather than of what the renderer chose to draw. 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 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 The administrative 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.

15.4.1. 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 mechanisms keep it safe, and they are worth understanding separately:

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/<id> shows (reloading itself while the task runs, with a <meta http-equiv="refresh"> rather than a timer in script). There is no restart or shutdown action and there will not be one; 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. They also need [pepsi-admin] CONFIG_DB, because the applier refuses any row not written by the configuration role — without it the pages are readable and say why nothing can be saved.

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.

15.4.3. 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.

15.4.4. End-user pages

The own:<address> scope is defined and enforced from the first release, 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. A dedicated end-user interface is not built yet. Users are not without recourse meanwhile: pepsi-stage-edit-settings(1) lets them change their own settings by e-mail.

15.5. Two templating engines, two purposes

Pepsi renders two quite different kinds of text, and uses a different engine for each. It is worth stating plainly, so nobody tries to override a page the way they override a bounce.

Mail bodies are Mustache templates, shipped as files under [pepsi] TEMPLATE_DIR (bounce-<name>.<lang>.body, payment-request.<lang>.body, edit-settings.<lang>.body, wallet.<lang>.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.

15.6. 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 <meta http-equiv="refresh"> 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.

15.7. Accessibility

The console is plain HTML: a skip link to the main content, one <h1> per page, tables with <caption> and <th scope=…>, a label for every form field, and aria-current="page" on the navigation entry you are on. Every action is a real <button> inside a form, so it is reachable and operable from the keyboard with no key bindings to learn, and the focus ring is drawn explicitly because the default one disappears against some backgrounds. Light and dark both come from prefers-color-scheme; there is no theme switch, because a switch needs script or a cookie and the operating system already knows the answer.

The charts carry aria-hidden and are never the only presentation of anything: the same numbers are in the table beside every figure.

Navigation offers only what your credentials allow — a link that answers 403 is worse than no link.