20. The administrative API

pepsi-httpd serves an HTTP API under /api/v1 covering everything the operator command-line tools do: the queue, the health summary, the configuration, the key store and the logs. The browser console and an unattended script are clients of the same documented surface.

Warning

The API is not yet stable. The path carries v1 so the versioning mechanism exists and clients are written against it, but while Pepsi’s version starts with 0. a release may still change the shape of a request or a response; such changes are listed in NEWS. Unlike the database, which each release migrates forward (see Upgrading), a client may have to be adapted. Freezing the contract before the console, its first real client, has exercised it would lock in decisions taken blind.

20.1. Where it is served

Only on a listener flagged ADMIN = yes. On every other listener the /api/v1 routes answer a plain 404 that is byte-for-byte what any unknown path gets — so publishing the public HTTPS listener does not publish administration, and a public listener cannot even be probed for whether administration is enabled on this deployment.

The shipped configuration flags exactly one listener, a UNIX socket:

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

pepsi-httpd refuses to serve the administrative routes on a flagged listener that would carry them in cleartext off this host: plaintext TCP is accepted only on a loopback address, TLS and UNIX sockets always. A socket-activated listener (SERVE = systemd) qualifies only with TLS, because the address of the inherited descriptor is not visible to the server. A flagged listener that fails the test logs a warning at start-up and serves the public endpoints only; pepsi-setup reports the same thing when it validates the configuration, so the mistake is caught before it matters.

Note

One binary serves both audiences. There is no separate administrative daemon: one thing to build, package, supervise and keep in sync — and, in exchange, a bug in the shared server process is a bug in both surfaces. The isolation is therefore the operator’s to configure. Bind the administrative listener to a UNIX socket or to loopback, and put a reverse proxy in front of it if it must be reachable from elsewhere.

Note

What a front server is believed about. In that topology this process terminates no TLS and sees no client address, so it reads two headers from the front server: X-Forwarded-Proto decides whether the session cookie goes out Secure (and under its __Host- name), and the rightmost X-Forwarded-For element is what the rate limits key on and what the secure-link portal records as the peer of an access attempt. Both are read only on a connection that cannot have come from off this host — a UNIX socket, or a loopback TCP peer — so a client talking to a public listener directly can never assert either. The sites pepsi-setup writes set both. Without them a proxied deployment has cookies with no Secure attribute and one single rate-limit bucket shared by the whole internet.

20.2. Authentication

Three mechanisms, one identity model. An explicitly presented credential always wins: the Authorization header is tried first, then the session cookie, and SO_PEERCRED only when neither is present. That order matters — if peercred won, an administrator who deliberately presented a narrowly-scoped token over the local socket would silently get full authority back.

20.2.1. Local administrators (SO_PEERCRED)

A process connecting over a UNIX-socket listener is identified by the credentials the kernel recorded at connect(2), which the peer cannot forge. root, or a member of [pepsi-admin] ADMIN_GROUP (default pepsi-admin), is an administrator with no credential to configure, store or lose:

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

This is how first-run bootstrap works on a system that has no accounts yet, and it is why the socket may safely be mode 0660 with a group: the identity comes from the kernel, not from the file mode.

20.2.2. Bearer tokens

For automation. A token is minted through the API and shown exactly once:

# 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

The presented form is pepsi_<id>.<secret>. Only the SHA-256 of the secret half is stored, so a token cannot be re-displayed or recovered from a database backup; the id half is a public selector that indexes the table, which is what lets the comparison be a constant-time check against one row rather than a scan.

Present it as Authorization: Bearer pepsi_<id>.<secret> — the Bearer scheme of RFC 6750, carried in the Authorization field RFC 9110 §11.6.2 defines. Scheme names are case-insensitive (RFC 7235 §2.1), so bearer and BEARER are accepted too. Tokens may carry an expiry (expires_in_days) and are revoked by deleting them.

The token is not an OAuth 2.0 (RFC 6749) access token and there is no authorization server: it is a locally minted credential that happens to use the same wire form. Pepsi does speak OAuth 2.0 elsewhere — outbound to a smarthost, see pepsi-helper-token-refresh — and the two should not be confused.

20.2.3. Passwords and sessions

For a browser. Accounts live in pepsi.admin_account with Argon2id (RFC 9106) password hashes; POST /api/v1/auth/login exchanges a name and password for a session cookie (RFC 6265) plus a CSRF token, and every mutating request from a session must repeat that token in the X-Pepsi-Csrf header. A bearer token and a local peer need no CSRF token: neither is ever sent by a browser on somebody else’s behalf.

The cookie carries HttpOnly, SameSite=Strict and the __Host- name prefix. That prefix is the strongest of the three: the browser refuses the cookie unless it was set over HTTPS with no Domain attribute and Path=/, so a sibling host on the same registrable domain cannot plant a session cookie for this one.

The prefix and Secure are one decision, and it follows the client’s scheme rather than this process’s: a listener that terminates TLS itself, or a plaintext one behind a front server asserting X-Forwarded-Proto: https as described above. On the plaintext local bootstrap path — a loopback or UNIX listener with no front server — a browser would refuse a Secure cookie outright, so neither is used and the cookie is named pepsi_session. Both spellings are accepted on the way in, so adding or removing a TLS front server does not sign every administrator out.

Those requirements are also why the secure-link portal, sharing an origin, cannot use the same prefix: __Host- mandates Path=/, which would send the portal’s session cookie to /metrics, /resume and the Web Key Directory as well. It keeps its path scoping, uses __Secure- instead, and buys back what the prefix would have given by sealing the cookie’s value under the server pepper — see The secure-link fallback portal.

Sessions are rows, not process memory, so a restart does not log everyone out and two server processes agree about who is logged in. They expire twice over:

  • an idle timeout (SESSION_IDLE, default 30 minutes) pushed forward on every request, and

  • a hard lifetime (SESSION_LIFETIME, default 12 hours) that is never extended.

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. The policy is affordable because the people who administer a deployment most often — local operators — never type a password at all.

Note

PAM is not supported. It would put a privileged authentication path and system-account semantics inside a mail server’s web tier, and make the API’s security depend on host configuration Pepsi does not control. A local administrator does not need it; a remote one gets an account in the database with an explicit scope list.

20.3. Authorisation: scopes

Every endpoint declares the one scope it requires, in the same table the dispatch table and the OpenAPI document are generated from — so what this manual promises and what the server enforces cannot disagree.

Scope

Grants

config:read

Read the effective configuration and its provenance (secrets masked).

config:write

Change the configuration overlay.

keys:read

Read local identities, correspondent keys and trust anchors.

keys:write

Change local identities and remove trust anchors.

peers:write

Import a correspondent’s key, queue a discovery lookup, or forget a cached key.

queue:read

Read the health summary and the message queue.

queue:write

Requeue, reroute or delete a queued message.

logs:read

Read the audit log, the mail log, TLS outcomes and the DNS cache.

setup:write

Manage accounts and tokens, and drive the online setup.

secure:read

Read secure-link metadata: who a stored message is for, when it was read, how often the PIN was got wrong. Never its content.

secure:write

Revoke a secure-link message, destroying the only copy of it.

own:<address>

Authority over exactly one e-mail address.

A local administrator holds every scope except own:. A principal can never mint a credential more powerful than itself: creating an account or a token with a scope the caller does not hold is refused with scope_escalation.

20.3.1. The own:<address> scope

own:<address> narrows authority to a single address. No interface issues such a principal on its own; an administrator creates one as a token or an account carrying that scope. The scope is nevertheless defined and enforced on every endpoint that takes an address, because retrofitting an authorisation model onto endpoints written without one is the expensive half of the work.

A principal holding only own: scopes may read its own identities and correspondent keys (an unfiltered listing is narrowed to its address rather than refused) and its own rows of the mail log (it must pass ?address= naming its own address there). It may not read anybody else’s, may not touch the deployment-wide objects — the correspondent-key cache, the trust anchors — and may not read or write the configuration. An administrator may delegate one address by issuing such a token; a token that can only create credentials cannot conjure address authority it does not itself hold.

It may also manage its own keys (the pEp-style self-service; see Key management): ask for a server-managed key (POST /api/v1/identities), register the user’s own public key (POST /api/v1/identities/client), and change its own identity’s flags, including a key-server upload request and its revocation, with PATCH /api/v1/identities/{id}. Each of these re-checks the address, and the two POST routes and a revoking PATCH only enqueue a task that the applier re-checks again against the principal’s own: scope. Deleting an identity’s row stays with keys:write.

It may follow the tasks it queued to their end: GET /api/v1/setup/tasks/{id} answers for a task this principal queued itself, and GET /api/v1/setup/tasks lists exactly those (see Following a task). Everything else on the setup surface stays setup:write.

20.4. Conventions

  • JSON only. Requests and responses are application/json.

  • One error shape, on every failure:

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

    code is a stable token to switch on; hint is prose for a human and may change; detail carries structured extra information when there is any.

  • Uniform authentication failures. A wrong password, an unknown account and a disabled account all produce the same invalid_credentials, and an unknown account still pays for one password verification, so neither the message nor the response time enumerates accounts.

  • Internal failures never carry a cause. The full error goes to the process log; the client gets internal_error.

  • Listings answer {"items": [...], "total": N, "limit": L, "offset": O}, and every one of them means the same thing by it. See Pagination.

  • ISO-8601 timestamps, with an offset.

  • No trailing slashes.

20.5. Pagination

Eleven endpoints answer the listing envelope, and all eleven behave identically:

Endpoint

Filters that also apply to total

GET /api/v1/queue

?stage= ?status=

GET /api/v1/events

?kind= (prefix) ?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

— (narrowed to the caller’s own tasks without setup:write)

limit defaults to [pepsi-admin] PAGE_LIMIT (100) and is clamped to 1–1000; offset is clamped to be non-negative; a value that is not a number is a 400. The envelope echoes the window that was applied, so a request for limit=100000 comes back saying "limit": 1000. Both go into the SQL, and total is a COUNT(*) over the same WHERE clause as the page — never the length of items, and never the size of an over-fetched window. Every listing orders by something unique last (its row id), so an OFFSET can neither skip a row nor show one twice.

GET /api/v1/mail-log adds one member to the envelope, mode, because an empty page means something different depending on whether [pepsi] MAIL_LOG is recording anything at all.

20.5.1. total and the page are two queries

The count runs first, then the page, and nothing holds a snapshot across the two. A read-only transaction per listing would make them exactly consistent, at the cost of tying up a pooled database connection on a web tier for a guarantee no client needs. So, against a table being written concurrently:

  • rows deleted in between make total an over-estimate — and the queue is drained continuously, so this is the normal case there, not a curiosity. The visible effect is a last page that comes back empty.

  • rows inserted in between make it an under-estimate.

Page until items is shorter than limit; treat total as a good number to show an operator, not as a loop bound to trust to the row.

The count is a real COUNT(*), not an estimate, on every one of these tables. That is affordable because of what they are: pepsi.workqueue is the live backlog — a delivered message’s row is deleted by the stage that delivered it, so the table holds mail still in flight rather than a history of everything ever sent — and the key store, the accounts and the tokens are deployment-scale objects. event_log and mail_log are the two that do grow with traffic; both are bounded by the retention sweeps described under The audit log and The mail log (off by default).

20.5.2. Not this shape

GET /api/v1/status takes a limit — how many stuck messages the report lists — but no offset, and answers pepsi-status’s report rather than a listing. GET /api/v1/tls-sessions answers {"days": D, "items": [...]} — it echoes the window it applied, the way the listing envelope echoes limit/offset — bounded by the days parameter (1–365, default from pepsi-status). GET /api/v1/dns-cache answers a bare {"items": [...]}, bounded only by the size of the DNS failure cache.

20.6. Endpoints

The authoritative list for a given build is GET /api/v1/openapi.json, which is generated from the server’s own route table — including the limit and offset parameters, which it declares on exactly the endpoints listed under Pagination. The Notes column below gives each listing’s other parameters; every listing additionally takes ?limit= and ?offset=.

Method

Path

Scope

Notes

POST

/api/v1/auth/login

—

Returns a session cookie and a CSRF token.

POST

/api/v1/auth/logout

—

Idempotent.

GET

/api/v1/auth/whoami

—

The first thing to check when something answers 403.

GET POST

/api/v1/accounts

setup:write

Password hashes are never returned.

PATCH DELETE

/api/v1/accounts/{id}

setup:write

Deleting an account ends its sessions.

GET POST

/api/v1/tokens

setup:write

The secret is in the POST response and nowhere else.

DELETE

/api/v1/tokens/{id}

setup:write

Revocation.

GET

/api/v1/status

queue:read

Identical to pepsi-status --json. ?limit= caps the stuck-message list; not a listing, so no ?offset=.

GET

/api/v1/queue

queue:read

?stage= ?status=

GET

/api/v1/queue/{id}

queue:read

Envelope, stage, status and state. Never the message.

POST

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

queue:write

Pending again at its current stage.

POST

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

queue:write

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

POST

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

queue:write

Deletes the message.

GET

/api/v1/events

logs:read

The audit log. ?kind= (prefix) ?actor=

GET

/api/v1/mail-log

logs:read

?address= Empty unless [pepsi] MAIL_LOG is on; reports the mode.

GET

/api/v1/tls-sessions

logs:read

?days=

GET

/api/v1/dns-cache

logs:read

MX addresses currently failing.

GET

/api/v1/config

config:read

?scope= Effective values with provenance; secrets masked.

GET

/api/v1/config/{section}

config:read

One section.

PUT DELETE

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

config:write

Validated before it is stored. See below.

POST

/api/v1/config/validate

config:read

Dry run; answers 200 with a verdict either way.

GET

/api/v1/identities

keys:read

?address= ?protocol=

GET PATCH DELETE

/api/v1/identities/{id}

keys:read / keys:write

PATCH sets is_primary (true), published, vks_wanted; or, on its own, status (only revoked, with an optional revocation_reason), which queues a revoke-identity task. See below. GET and PATCH are open to own:<address> for that address; DELETE is not.

GET

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

keys:read

The public half, base64.

POST

/api/v1/identities

keys:write or own:<address>

Queue a generate-identity task: {"address": ..., "vks": true|false}. See below.

POST

/api/v1/identities/client

keys:write or own:<address>

Queue a register-client-key task: {"address": ..., "key": "<armoured public key>"}. See below.

DELETE

/api/v1/otp/{address}

keys:write

Queue a reset-otp task removing (and so unlocking) the address’s second factor. See below.

GET

/api/v1/peers

keys:read

?address=

POST

/api/v1/peers

peers:write

Import a correspondent’s key by hand. See below.

POST

/api/v1/peers/discover

peers:write

Queue a discovery lookup; it does not fetch. See below.

DELETE

/api/v1/peers/{id}

peers:write

Forget a cached key.

GET

/api/v1/peers/unclaimed

keys:read

?address= — served addresses we hold no identity for but have cached a key for anyway. A paginated listing.

GET

/api/v1/ca-trust

keys:read

The S/MIME trust anchors.

DELETE

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

keys:write

Changes which inbound signatures validate.

GET

/api/v1/secure-messages

secure:read

Outstanding secure-link messages, metadata only. ?include_expired=1.

GET

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

secure:read

One message with its access history. Never its content.

DELETE

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

secure:write

Revoke; this destroys the only copy of the message.

GET

/api/v1/dns-records

config:read

The records to publish and the last live verdict. See below.

GET

/api/v1/setup/questions

setup:write

The setup interview as data.

GET

/api/v1/setup/telemetry

setup:write

Whether switching feature telemetry on could take effect, and if not, what to do on the server. See below.

GET

/api/v1/setup/answers

setup:write

The staged answers; credentials masked.

PUT

/api/v1/setup/answers

setup:write

Merge answers into the staged (draft) set.

DELETE

/api/v1/setup/answers

setup:write

Abandon the staged interview.

GET

/api/v1/setup/tasks

setup:write, or any principal for its own tasks

Privileged setup tasks, newest first; without setup:write, only the caller’s own.

POST

/api/v1/setup/tasks

setup:write

Ask the applier for one of its closed set of actions.

GET

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

setup:write, or any principal for its own tasks

One task, with ?since= streaming its progress and ?wait= holding the answer until there is news.

POST

/api/v1/setup/preflight

setup:write

Check the environment (ports, resolver DNSSEC).

POST

/api/v1/setup/dns-check

setup:write

Compare live DNS against what setup would publish.

POST

/api/v1/setup/certificates

setup:write

Obtain the certificates the configuration needs.

GET

/api/v1/openapi.json

—

Generated from the route table.

The monitoring endpoints call pepsi-status’s and pepsi-queue’s own functions and serialise the same structs, so pepsi-status --json and GET /api/v1/status are the same bytes by construction. The configuration endpoints reuse pepsi-config’s provenance walk and its validation.

20.7. What this server cannot do

20.7.1. Generate or revoke key material – it asks the applier instead

Private key material lives in crypto_identity.private_wrapped, which pepsi-setup grants to the pepsi-crypto role alone (the schema owner aside) and revokes from every service account, pepsi-httpd included: a web tier that can mint signing keys mints them for whoever compromises it.

POST /api/v1/identities therefore does not generate anything. It enqueues a generate-identity task ({"address": ..., "vks": true|false}; vks is optional and defaults to [pepsi-keys] VKS_PUBLISH) and answers with the task ({"task": ..., "kind": "generate-identity", "applier_notified": ...}). The root applier re-validates it and generates an OpenPGP key as the pepsi-crypto role. Generation on request is always allowed, even next to the user’s own key.

POST /api/v1/identities/client enqueues a register-client-key task in the same way ({"address": ..., "key": ...}, the key an armoured OpenPGP public key of at most 64 KiB). It goes through the applier although no private material is involved, because a key registered as the user’s own is trusted by the MTA – it becomes the address’s public face, and signatures made with it verify as the user’s – so a compromised web tier must not be able to plant one. The applier requires one OpenPGP certificate whose User IDs name the address, and does not register a fingerprint the address already has, retired or not.

PATCH /api/v1/identities/{id} with {"status": "revoked"} (and an optional "revocation_reason", one line of at most 1024 bytes) is asynchronous for the same reason: revoking a signing-only key destroys its private half, and that UPDATE names private_wrapped, which pepsi-httpd may not. It enqueues a revoke-identity task ({"address": ..., "identity_id": ..., "reason": ...}) and answers with the task, not the changed row: {"identity_id": ..., "task": ..., "kind": "revoke-identity", "applier_notified": ...}. The identity’s status reads revoked once the applier has run; GET /api/v1/setup/tasks/{task} shows the task itself, to setup:write and to the principal that asked for it alike, whose result names the fingerprint and says whether the identity was revoked by this task or already had been. The applier checks that the identity belongs to the task’s address and then applies the same rule as pepsi-keys identity revoke: a signing-only key’s private half is destroyed, an encryption key’s is kept so mail already encrypted to it stays readable. Because the answer is a task, a revoking PATCH carries status alone; combined with is_primary, published or vks_wanted it is refused with 400, as is a revocation_reason without status.

For all three, the applier’s authorisation gate is keys:write, or own:<address> for exactly the address in the task’s parameters – setup:write alone is not enough, since managing one user’s key is not configuring the server – and the address must be at a [pepsi-ingress] ACCEPTED_DOMAINS domain. A task can end done, failed (the applier tried and could not, the reason in error) or refused (the applier would not accept the row at all), and the principal that asked can read which: a revocation request for a compromised key is followed with GET /api/v1/setup/tasks/{task}?wait=60 until status is one of the three, as Following a task describes. Without pepsi-httpd-admin all three answer 503 setup_applier_unavailable, like every applier path, and without a [pepsi-admin] CONFIG_DB connection 503 setup_write_unavailable (see Online setup: only the pepsi-config role may enqueue). Producing a CSR and importing an issued certificate are not offered here; use pepsi-keys.

The owner’s second factor. An address whose owner enrolled a second factor (Key management, “A second factor for key changes”) needs its current code on each of the three when the task is asked for under own:<address>: add "otp": "123456" to the body (the console’s forms have a field for it). pepsi-httpd cannot check the code – pepsi.otp_key is granted to pepsi-crypto alone – so it is carried in the task’s parameters and the applier checks it before acting; a missing, wrong, replayed or locked code fails the task with the reason in error. A task asked for with keys:write is not second-factor checked: the operator can reset the second factor anyway. DELETE /api/v1/otp/{address} (keys:write only, never own:) queues a reset-otp task that removes the address’s second factor, unlocking it; its owner then enrols again. The flag changes a PATCH performs directly (is_primary, published, vks_wanted) are not gated, since this process performs them itself.

20.7.2. Write the configuration, unless you ask for it

Writing pepsi.config_override belongs to the pepsi-config database role, which pepsi-setup grants and explicitly revokes from every service account — a component that processes mail must not be able to rewrite the pipeline it runs in. pepsi-httpd is such an account, and peer authentication keys off the effective uid, so its own connection cannot be that role.

PUT and DELETE on /api/v1/config therefore answer 503 config_write_unavailable until [pepsi-admin] CONFIG_DB names a connection that authenticates as pepsi-config — in practice a password in a secrets.d fragment readable only by pepsi-httpd, or a pg_ident map. The server checks with SELECT current_user at start-up and refuses anything else.

PUT takes {"value": "...", "scope": "..."}, where scope is global (the default), domain:<domain> or address:<address>; DELETE takes the same as ?scope=, and GET reads the effective configuration as seen from that scope. Both writes answer with a reload object saying how the change takes effect (hot, restart with the unit to restart, or ini-only).

Sections that are read from the configuration file only ([pepsi], [pepsi-postgres], [paths], [pepsi-admin], [pepsi-httpd], [pepsi-crypto], [pepsi-srs], [pepsi-origin], [pepsi-secure-link], [pepsi-wizard], and every [pepsi-httpd-listener-*], [pepsi-httpd-cert-*] and [pepsi-ingress-listener-*] section) are refused with 403 forbidden regardless. So is every credential-bearing option in any other section (one whose name contains PASSWORD, PASSPHRASE, SECRET, TOKEN, CREDENTIAL, CLIENT_ID or PEPPER – the same rule that masks it in GET, which also covers the file a credential is read from and the endpoint it is sent to): secrets are never stored in the database. A domain: or address: scope is accepted for [stage-*] sections only, the only ones read per correspondent; any other section there is refused with 403 too. POST /api/v1/config/validate reports the same refusals as "valid": false. The overlay’s reader applies the same rule, so a row that reached the table some other way is ignored with a warning.

20.7.3. Do anything privileged

Setup writes /etc/pepsi/pepsi.conf, hands each secrets.d fragment to the one account that reads it, runs certbot, creates database roles and generates key material — all as root. pepsi-httpd drops privileges before it accepts a connection and must never be able to regain them.

So the setup endpoints do not act. They write an intent row into pepsi.setup_task, and a separate root program, pepsi-setup apply, drains it. Root is reached through a database table, never through a socket that speaks a protocol. The full trust model — the closed task list, the two admission gates, what is deliberately absent, and what the design cannot promise — is in pepsi-setup(1), “The applier’s trust model”, and is required reading before deploying the browser setup.

20.8. Correspondent keys

POST /api/v1/peers stores a correspondent’s public key by hand — the sibling of pepsi-keys peer import. (The browser console has no import: its correspondent page can only forget a key, and points at pepsi-keys peer import for the rest.) The body names the address, the protocol (openpgp or smime), the material (base64, or armored/PEM text verbatim) and an optional pin.

Two refusals matter:

  • Material that is not a key is a 422 invalid_value. OpenPGP input goes through the hardened parser the discovery methods use, which walks the top level only and rejects a stream carrying a compressed or encrypted packet — a transferable public key never contains one, and a compression bomb stapled to a keyring is not a key worth having.

  • A key that names a different address is a 422 invalid_value naming whose key it really is. Filing one correspondent’s key under another’s address silently misdirects every future message to them. A key that names no address is stored — silence cannot contradict anything — and the response says so in its binding field (matched / no-identity).

The row is stored with source = api, which the conflict rule ranks beside a hand-entered key: it displaces a discovered one and does not displace a pinned one. A *@domain entry is refused here; the schema admits one only for source = manual, so widening a whole domain stays a decision taken at a terminal.

POST /api/v1/peers/discover queues a lookup and returns at once. It does not fetch: discovery speaks HTTPS, LDAP and DNS to hosts the correspondent’s domain chooses, and doing that inside a request handler would let a stranger hold this server’s connection open — the same reason the encrypt and decrypt stages park a message rather than looking a key up themselves. The endpoint writes the same pepsi.key_request row a stage’s park writes, through the same SQL function, and a pepsi-keydisc service does the work. The answer appears in GET /api/v1/peers when one has stored it. {"force": true} re-asks an address whose fresh negative cache entry says there is no key. It answers 501 no_discovery_method when [pepsi-keydiscovery] SOURCES is empty, since no service would ever answer, and 403 for an address that ALLOW_DOMAINS/DENY_DOMAINS exclude.

20.10. DNS records

GET /api/v1/dns-records answers with the records this deployment should publish, the last live-DNS verdict for each (ok / missing / mismatch / lookup-failed), the remedy for each that is wrong, and the zone text pepsi-setup run would print.

It serves a stored answer rather than computing one. Deriving what should be published means reading the validated configuration and the DKIM key directory, and comparing it means querying DNS — work that belongs to pepsi-setup, which this server cannot call (the dependency already runs the other way) and whose inputs a web tier should not be reading. So the privileged applier computes it as part of a run-preflight task and writes it back, and this endpoint reports it with the time it was computed, always: a verdict with no age on it invites trust in a stale one. POST /api/v1/setup/dns-check asks for a fresh one, and needs setup:write accordingly — looking at what was found is a read, asking a root process to go and look is not.

Before any check has run, the response is empty and names the endpoint that runs one, rather than an empty list that reads like “everything is fine”.

20.11. Online setup

The browser path through pepsi-setup. Two mechanisms:

Answers are staged, not applied. PUT /api/v1/setup/answers merges into a set of draft rows in pepsi.config_override, which every configuration reader filters out structurally (WHERE NOT draft). 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 resuming is reading the drafts back. The identifiers are the ones GET /api/v1/setup/questions publishes, which are also the keys pepsi-setup --answers reads and the [pepsi-wizard] section round-trips — so an interview can be started in a browser and finished at a terminal.

The telemetry switch is offered only where it can take effect. [pepsi] SHARE_TELEMETRY is in the configuration file, so it is set like any other answer and written by the applier’s write-config task, which then sends the telemetry_changed notification (in both directions) so that a running pepsi-telemetry-client re-reads the file. That daemon may run while telemetry is off — dormant, submitting nothing — and it keeps a liveness row in pepsi.telemetry_client saying whether it runs and whether a SYSTEM_ID is configured (a boolean; the console never sees the identifier). The console never generates an identifier, since that is what opt-in rules out, so unless the daemon is running and has one, yes cannot take effect: GET /api/v1/setup/questions then marks the question "disabled": true with a disabled_reason telling the operator what to do on the server (systemctl enable --now pepsi-telemetry-client.service; add SYSTEM_ID = and 64 hexadecimal characters to [pepsi]), the console renders it disabled, and a yes sent anyway through PUT /api/v1/setup/answers or a write-config task is refused with 409 telemetry_client_not_ready. Switching off, or keeping an answer that is already yes, is never refused. GET /api/v1/setup/telemetry answers with the whole picture:

{"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"}}

Actions are requested, not performed. POST /api/v1/setup/tasks enqueues one of eleven kinds (write-config, write-secret, obtain-certificate, install-schema, provision-roles, generate-keys, run-preflight, generate-identity, register-client-key, revoke-identity, reset-otp) with strictly validated parameters. The last four act on one address’s keys and are normally reached through POST /api/v1/identities, /identities/client, a revoking PATCH /api/v1/identities/{id} and DELETE /api/v1/otp/{address}; the applier authorises them by keys:write or – all but reset-otp – the address’s own own: scope rather than setup:write. There is no arbitrary-command kind, and there is no restart, reload or shutdown kind — a change that needs a component restarted ends with the operator restarting it, which the console says rather than hiding. The applier refuses anything else, and every refusal, success and failure is an audit record.

Enqueuing goes through the [pepsi-admin] CONFIG_DB connection, exactly as configuration writes do and for the same reason: only the pepsi-config role may INSERT into setup_task, and the applier refuses any row whose written_by says otherwise. Without that connection the setup endpoints answer 503 setup_write_unavailable; the server’s own account may only SELECT the queue, so the console can watch privileged work without being able to ask for it.

GET /api/v1/setup/tasks/{id}?since=<seq> returns the progress lines the applier has written so far, so a certbot run or a schema install can be watched line by line without the applier holding an HTTP connection open.

20.11.1. Following a task

A task answers {"id", "kind", "status", "requested_by", "requested_at", "result", "error", "log"}; status is pending or running until it is one of done, failed or refused, after which the applier writes nothing more to it. params is never returned, since one kind’s parameter is a credential.

Who may read it. A principal holding setup:write reads every task. Any other authenticated principal reads the tasks it queued itself – in practice the generate-identity, register-client-key and revoke-identity tasks it queued through the key endpoints, the only tasks it can queue. GET /api/v1/setup/tasks is narrowed to those rather than refused, and GET /api/v1/setup/tasks/{id} answers 404 not_found for a task somebody else queued, exactly as for one that does not exist. What such a task shows is the requester’s own business: the kind, the address and identity it named, the fingerprint of the key, and why it failed or was refused. The parameters – a registered public key, a revocation reason – are not returned, and the key endpoints hand over nothing private in the first place.

“Queued itself” is decided by a key recorded with the task, requester_key, not by requested_by. requested_by (token:<label>, session:<login>, peer:<login>) is what the task shows and what the audit log records, but it is a name, and names are reused: two tokens may carry one label, and an account deleted and created again under the same login is a new account. The key is never reused: token:<selector> for a bearer token (the public <id> half of pepsi_<id>.<secret>, unique among tokens), account:<n> for a password session (the account’s row number, which PostgreSQL never hands out twice) and peer:<login> for a local peer, where the system account itself is the principal. So a new token labelled like an old one, or a recreated account, reads none of its predecessor’s tasks. A task queued from the command line (pepsi-setup) records an empty key, which no principal matches.

Waiting instead of asking again. ?wait=<seconds> (at most 60; a larger value is taken as 60) holds the answer back until the task has ended or has a progress line after since (0 when absent), or until the time is up, whichever comes first, and then answers exactly as without it. A client following a task therefore loops on ?since=<last seq>&wait=60: each answer carries the lines it has not seen, and a status that is not pending or running ends the loop. Nothing polls the database meanwhile. The applier’s writes fire two notifications carrying the task id – setup_task_progress for each progress line, setup_task_done when the task ends (see pepsi-setup) – and pepsi-httpd holds one listener connection for them, which wakes every request waiting on that task. A waiting request subscribes before it reads the row, so a task that ends between the two is never missed. When the listener is not connected (PostgreSQL restarted, say) wait returns at once, never hangs; the listener reconnects on its own. A wait that is not a whole number of seconds is a 400.

The console’s task page does the same thing for a browser, without script: while a task is pending or running the page reloads at once into a request the server holds for up to 20 seconds until there is something new to show, so the result appears the moment the applier records it. The self-service key forms (generate, register, revoke) answer with that page for the task they queued.

20.11.2. When there is no applier: 503 setup_applier_unavailable

The privileged half is a separate Debian package, pepsi-httpd-admin, holding only pepsi-setup-apply.socket and pepsi-setup-apply.service — the whole path from an HTTP request to a change under /etc/pepsi. The pepsi package Recommends it, so it is installed by default and removable; a source install gets the same lever with make install INSTALL_ADMIN_UNITS=no. Removing it breaks the loop at the mechanism: nothing drains setup_task, so an INSERT is inert.

pepsi-httpd detects that and degrades to read-only rather than enqueuing requests nothing will ever perform. Every endpoint that would ask the applier answers 503 with code setup_applier_unavailable and a detail naming the package and the unit, so a deployment script need not parse 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"}}

Which endpoints are gated is the interesting part:

Endpoint

Gated on an applier?

PUT /api/v1/setup/answers

Yes. A draft row is harmless in itself, but the only thing an answer set is for is being applied; a wizard that quietly banks six steps of answers on a host where nothing can apply them is the silent no-op the package split exists to avoid.

DELETE /api/v1/setup/answers

No, deliberately. Discarding a draft cannot cause a privileged change, and a wedged interview must stay clearable.

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

No. They read. (A wait on a task nothing will ever drain simply runs out.)

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} with "status": "revoked", DELETE /api/v1/otp/{address}

Yes — every one of them reaches the single ask_applier, which calls require_applier before it writes anything. (A PATCH that only flips is_primary, published or vks_wanted does not.)

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

No. The configuration overlay writes pepsi.config_override, a database table, not /etc. They keep working with no applier.

503 rather than 501: the capability is a package away, so it is a facility that is not present here, now, not an operation the API does not have. It is also a distinct code from setup_write_unavailable above, because the remedies differ — “install pepsi-httpd-admin and start its socket” against “give this server a CONFIG_DB connection”.

Detection is two filesystem tests, both required and neither redundant: the unit file exists somewhere systemd searches (installed), and [pepsi-admin] APPLY_SOCKET (default /run/pepsi/setup-apply.sock) is a socket this account may write (armed). It never connects — connecting is the doorbell, and would start a root process per page render. The unit-file half exists because systemd’s RemoveOnStop= defaults to off, so a stale socket inode would otherwise read as a live doorbell and fail open in exactly the scenario the feature exists for; the shipped unit sets RemoveOnStop=yes as well. Every uncertainty reads as unavailable. [pepsi-admin] APPLIER = auto|yes|no overrides the probe for a cron-driven or non-systemd applier, and no switches privileged setup off outright.

20.12. Hardening

  • Rate limits, per source address (RATE_LIMIT, default 120/minute) and, on the login path, per account and per source address (LOGIN_RATE_LIMIT, default 10/minute) — many passwords against one account and one password against many accounts are different attacks. “Source address” is the client’s, taken from X-Forwarded-For when a trusted local front server asserts one; a UNIX-socket caller with no such assertion has no address, and the whole host shares one key.

  • Body caps (MAX_BODY, default 1 MiB).

  • Constant-time comparison of every token and CSRF digest.

  • No secret is ever returned by GET /api/v1/config: a value that could be one is replaced by "***" with "secret": true. The masking is deliberately generous — a value wrongly masked costs a look in the file, a value wrongly shown is published to everyone holding config:read.

  • No message content is reachable. The queue endpoints answer the envelope, the stage, the status and the state JSON, never headers or body. Reading mail is not an administrative function.

  • Responses carry Cache-Control: no-store, X-Content-Type-Options: nosniff and a frame-ancestors 'none' policy.

20.13. The audit log

Every configuration change, key operation, login, failed login, account and token change and administrative queue action is recorded in pepsi.event_log and readable through GET /api/v1/events:

event_id  at  actor  kind  subject  severity  detail

actor names the principal (peer:root, token:monitoring, session:alice) or, for a change made at a terminal, the invoking login (cli:alice). The operator command-line tools write to the same log, so it is complete regardless of which surface acted; a log that noticed only what happened over HTTP would invite the wrong conclusion from an absence.

The log is append-only for everything that processes mail: pepsi-setup grants those roles INSERT and SELECT and revokes UPDATE/DELETE, then verifies the revocation against the live server. Retention is bounded by [pepsi-admin] EVENT_RETENTION_DAYS (default 90) and pruned daily by pepsi-httpd prune, which pepsi-log-prune.timer runs as the pepsi-httpd account whether or not the web server is running.

Neither a configuration value nor any key material is written to a record: what changed is the auditable fact, not what it changed to.

20.14. The mail log (off by default)

Warning

Switching ``[pepsi] MAIL_LOG`` on makes this deployment keep a record of who corresponds with whom. Consider whether you need that evidence, and whether keeping it is lawful where you operate, before you turn it on.

Pepsi deletes a message’s row when the pipeline is finished with it. There is therefore no per-message success log: an ordinary deployment does not accumulate a record of its users’ correspondence, and cannot be compelled to produce one it does not have.

Some deployments genuinely need that evidence. [pepsi] MAIL_LOG provides it, as an administrative act with a stated consequence:

off

The default. Nothing is written; pepsi.mail_log stays empty.

summary

One row as the message leaves the pipeline: the envelope sender and recipients, the direction (outbound for locally-submitted mail, inbound otherwise), the stage it ended at, the outcome, and what the pipeline decided about it (the authentication verdict, the spam/paid decision, any next-hop failure detail).

full

The above plus the Subject: line.

Message content is never recorded at any setting. Rows are readable through GET /api/v1/mail-log by a principal holding logs:read (or, for one address, own:<address>), are append-only for the mail-processing accounts exactly as the audit log is, and are pruned after [pepsi-admin] MAIL_LOG_RETENTION_DAYS days (default 30).

A row is written when a stage finishes with a message (completed) and when a message fails permanently (failed) — the two ways it stops moving. The write rides on the same statement as the terminal that removes or fails the row, so enabling the log costs no extra database round-trip.

20.15. Bootstrapping

A fresh install has no accounts, and does not need one: the UNIX socket plus SO_PEERCRED always works for a local administrator. From there,

# 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

creates the first remote account. A lost password is not a lockout: the local socket is still there.

When the socket is not reachable — an operator working from another machine, or a container without a shell on the host — pepsi-setup bootstrap, run as root, prints a bearer token that holds the full administrator scope set, expires within the hour, and is accepted exactly once: enough for one POST /api/v1/accounts.

It has to hold that set rather than setup:write alone, because POST /api/v1/accounts refuses to issue a scope the caller does not itself hold — so a setup:write-only token could create no administrator at all, and an account it did create could not widen itself afterwards either. What bounds the credential is its lifetime, its single use, and that the next pepsi-setup bootstrap revokes it.

Single use because of where it is printed. A token on a terminal is in a scrollback buffer and a token in the journal is readable by whoever can read the journal, which is usually a wider set of people than “may administer the mail server”. It is consumed on presentation by an update only one racing request can win, so a token two people read admits one of them. Only its digest is stored, so it cannot be recovered — run the command again, which also revokes the previous one.

20.16. The browser console

The administration console is a client of this API, mounted under /ui on the same ADMIN = yes listeners, sharing its authentication, its scopes and its audit log. It adds no capability: everything it does is reachable with curl here. Two details of the session mechanism exist for it, and matter when writing another client:

  • POST /api/v1/auth/login returns the CSRF token in the response body and sets it as a pepsi_csrf cookie (HttpOnly, SameSite=Strict; named __Host-pepsi_csrf whenever the session cookie carries that prefix). A program reads the body; the console has no other way to get the token into a form, because it ships no script to read one out of the page. Neither placement is the check — the check is that the digest of what a mutating request presents equals the digest stored with the session.

  • A mutating request may present the token in the X-Pepsi-Csrf header (what a program does) or as a _csrf form field (what an HTML form does). A bearer token and a UNIX-socket peer are asked for neither, since a browser never sends them on somebody else’s behalf.