85.1.3. pepsi-httpd¶
serve MTA-STS policy, Web Key Directory, the Outlook add-in and metrics
- Manual section:
1
85.1.3.1.1. Name¶
pepsi-httpd - HTTP/HTTPS server for MTA-STS, the Web Key Directory, the Outlook add-in and metrics.
85.1.3.1.2. Synopsis¶
pepsi-httpd [GLOBAL-OPTIONS] serve
pepsi-httpd [GLOBAL-OPTIONS] prune
85.1.3.1.3. Description¶
pepsi-httpd is the Pepsi HTTP/HTTPS server. It binds one or more listeners and routes each request through a generic dispatch table, matching by HTTP method and URL shape: a route’s path is a list of segments, each of which is a literal, a named capture of one segment, or a capture of everything that remains. Routes are tried in registration order and the first whose method and path match wins. Further endpoints are added as single table entries.
Each [pepsi-httpd-listener-<name>] section binds one socket — SERVE = tcp
(BIND_TO/PORT, default port 443), unix (UNIXPATH) or systemd
(socket activation) — with MODE = plain or tls. A TLS listener selects its
certificate per connection from the client’s SNI host name: every
[pepsi-httpd-cert-<name>] section lists one or more SNI host names with a
TLS_CERT/TLS_KEY pair, and the listener’s own TLS_CERT/TLS_KEY (if
any) is the fallback for connections that present no matching SNI.
When another web server already owns ports 80/443, pepsi-setup(1) instead
configures a reverse-proxy deployment. The listener stays socket-activated
(SERVE = systemd) but plain-HTTP (MODE = plain); the switch happens in the
pepsi-httpd.socket unit instead: pepsi-setup drops a drop-in override
(/etc/systemd/system/pepsi-httpd.socket.d/10-reverse-proxy.conf) that resets
the shipped ListenStream=443 and rebinds it to a UNIX socket at
/run/pepsi/httpd.sock (mode 0660, owned by the front server’s own group
— www-data where that group exists, else nginx/apache/httpd), so
systemd passes that socket’s fd to pepsi-httpd exactly as it would the
port-443 socket. The front server terminates TLS and forwards the
mta-sts.<domain> requests to it; in this mode pepsi-httpd holds no
certificates of its own. Deleting the drop-in and running systemctl
daemon-reload restores the direct :443 bind. See
pepsi-setup(1) and --no-reverse-proxy.
85.1.3.1.4. Endpoints¶
- GET /.well-known/mta-sts.txt
Return the MTA-STS policy (RFC 8461), built from the
[pepsi]MTA_STS_*options and the[pepsi-ingress]HOSTNAME(themx). The policy is served only when the requestHostismta-sts.<domain>for a domain inACCEPTED_DOMAINS; any other host (orMTA_STS_MODE = none) yields404. Serving it here saves hosting the policy file by hand; pepsi-setup(1) prints the_mta-stsTXT record to publish in DNS.- GET /mail/config-v1.1.xml
- GET /.well-known/autoconfig/mail/config-v1.1.xml
Return the mail account autoconfiguration document (draft-ietf-mailmaint-autoconfig): the XML a mail client fetches, knowing only the user’s address, to discover which servers to use, on which ports, under which transport security and with which authentication.
These are the first two rungs of the fallback chain the draft defines, and the
Hostselects between them: the first is served when the requestHostisautoconfig.<domain>(the required rung, and the one clients try first), the second when it is<domain>itself. In both cases DOMAIN must be inACCEPTED_DOMAINS; any other host yields404. The draft’s optional?emailaddress=parameter is accepted and ignored — the document names%EMAILADDRESS%, the placeholder the client substitutes, so no request-controlled text is ever interpolated into the response.The response is
text/xml; charset=utf-8, cacheable for an hour, and — as the draft requires — public: it carries no authentication, because a client has to read it before it can know how to authenticate.The document is built from
[pepsi-autoconfig], and the endpoint answers404until that section names at least one incoming server (IMAP_HOSTorPOP3_HOST). The outgoing server is derived from the[pepsi-ingress-listener-*]flaggedSUBMISSION = yesunlessSMTP_HOSToverrides it. Publishing requires anautoconfig.<domain>DNS record and a certificate covering it; see pepsi.conf(5).- GET /.well-known/openpgpkey/hu/HASH
- GET /.well-known/openpgpkey/policy
- GET /.well-known/openpgpkey/DOMAIN/hu/HASH
- GET /.well-known/openpgpkey/DOMAIN/policy
The Web Key Directory (draft-koch-openpgp-webkey-service), in both its direct form (served under the domain itself) and its advanced form (served under
openpgpkey.<domain>, with the domain repeated in the path). HASH is the z-base-32 SHA-1 of the lower-cased local part, which is stored and indexed alongside the domain, so a request costs one indexed lookup.A key is returned only for a domain in
ACCEPTED_DOMAINSand only from an identity that ispublished,activeand OpenPGP. The response is the binary transferable public key — not armor — withContent-Type: application/octet-streamandAccess-Control-Allow-Origin: *as the specification requires. An unknown hash yields404with an empty body.The
policyfile is a200with a zero-length body. It carries no flags, but it must exist: GnuPG treats its absence as “this domain runs no Web Key Directory” and gives up before asking for a key.The
?l=<local-part>parameter is read and ignored. Honouring it would turn the endpoint into a lookup by caller-supplied address rather than by a hash the caller already had to know.The endpoint serves only this deployment’s own published identities (
crypto_identity). It never serves a cached correspondent key (peer_key): that material was never verified by us and is not ours to publish under our own name. There is no option to change this.There is deliberately no rate limiting and no uniform-404 shaping here. A Web Key Directory is a public oracle by construction, the hash covers only the local part (so it answers guesses rather than enumerating), and the addresses are on the outside of every message the domain sends. See pepsi-keys(1) and the manual’s key-management chapter.
For the advanced form,
openpgpkey.<domain>must resolve to this host and the served certificate must cover the name; it is an SNI name on the same HTTPS listener, not a second listener. pepsi-setup(1) adds it to the certbot request and reports any that does not resolve.- GET /addin/manifest.xml
- GET /addin/taskpane.html
The Outlook add-in: a manifest and a one-page task pane that let a user of a mail system behind this gateway ask for a message to be signed or encrypted. The pane sets
X-Pepsi-Sign/X-Pepsi-Encrypton the message being composed; pepsi-stage-encrypt(1) reads them and strips them before the message goes on the wire.Both routes yield
404unless[pepsi-httpd] ADDINis set. Two placeholders are substituted per request: this gateway’s publichttps://origin (fromADDIN_URL, else the request’sHost; the scheme is alwayshttps, because Outlook refuses to load add-in resources over plain HTTP – a configuredhttp://origin is refused at start-up unless its host is loopback, which is allowed for local testing) and a manifest identifier derived from that origin — stable for a deployment and distinct between deployments, so two gateways sideloaded into one Exchange organisation do not collide. AHostcontaining anything other than what a host name and port may contain is refused with400rather than escaped.Distribution is by sideloading: the Exchange administrator points “Integrated apps” at the manifest URL. See the manual’s “Microsoft Exchange as a gateway” chapter.
- GET /metrics
Pipeline statistics in the Prometheus text exposition format. Live gauges (
pepsi_stage_active_messages,pepsi_pause_backlog) are read straight from the queue; counters (pepsi_stage_timeouts_total,pepsi_stage_crashes_total,pepsi_stage_messages_total,pepsi_stage_duration_seconds_totaland the globalpepsi_stages_executed_total/pepsi_messages_processed_total) come from the statistics tables that pepsi-dispatch(1) updates.The endpoint is administrative: queue depths, per-stage crash and timeout counts, lifetime totals and every operator-chosen stage name together describe how much mail this deployment carries and how its pipeline is built. It is therefore served only on a listener flagged
ADMIN = yesthat may actually carry the administrative surface, and answers a plain404— what any unknown path gets — everywhere else. Unlike/api/v1it asks for no credential, because a Prometheus scraper has none to present: the listener flag is the whole access control, so flag a listener only the monitoring system can reach.Point the scraper at the administrative listener, or add
ADMIN = yesto the listener it already scrapes (a plaintext one must be loopback or a UNIX socket — see The administrative surface below). Binding pepsi-httpd itself to a private address is not an alternative: the same process has to answermta-sts.<domain>(RFC 8461) andopenpgpkey.<domain>from the public internet, and there is no per-route listener binding. The first scrape refused because its listener is not flagged is logged with the listener’s name.- POST /resume
Release a paused message back into the pipeline: the matching row is set from
pausedtopendingand the dispatcher is notified so the responsible stage runs again. This is the target of the GNU Talerpepsi-resumepayment webhook (see pepsi-setup(1) and[pepsi-payments]), called when an order is paid.The request must carry
Authorization: Bearer <token>matching the configuredRESUME_AUTHORIZATION_TOKEN(compared in constant time), and a JSON body{"message_id": "<token>"}naming the message’s external token (the merchantorder_id). Responses:200when the message exists (a paused message was resumed, or it was already non-paused — the call is idempotent),404when the token is unknown,401on a failed authorization,400on a malformed body,413on a body over 4 KiB. The endpoint is disabled (404) whenRESUME_AUTHORIZATION_TOKENis not configured.- /api/v1/…
The administrative API — the queue, the health summary, the configuration, the key store, the audit log. Served only on a listener flagged
ADMIN = yes, and only to an authenticated principal. See The administrative surface below and the manual’s “The administrative API” chapter for the endpoint reference, the scope table and the error shape.- /ui, /ui/…
The administration console: a server-rendered browser front end for the queue, the key store, the configuration and the logs. Served on the same
ADMIN = yeslisteners as/api/v1, under the same authentication, the same scope checks and the same audit log — it is a client of the API and adds no capability to it. The pages are/ui(dashboard),/ui/login,/ui/logout,/ui/queue,/ui/queue/ID,/ui/queue/ID/{requeue,bounce,cancel},/ui/identities,/ui/identities/ID,/ui/identities/ID/{publish,unpublish,vks,primary,revoke,delete},/ui/identities/request/{generate,register},/ui/peers,/ui/peers/ID/delete,/ui/ca-trust,/ui/ca-trust/ID/delete,/ui/config,/ui/config/SECTION,/ui/logs,/ui/logs/{mail,tls,dns},/ui/domains,/ui/domains/check,/ui/secure,/ui/secure/TOKEN,/ui/secure/TOKEN/ACTION,/ui/setup,/ui/setup/STEP (GET and POST),/ui/setup/tasks(GET and POST),/ui/setup/tasks/ID and the single stylesheet/ui/static/console.css.Waiting for a setup task.
GET /api/v1/setup/tasks/ID takes?wait=SECONDS (at most 60, combinable with?since=SEQ) and answers as soon as the task has ended or has a progress line after SEQ, or when the time is up; the console’s task page waits the same way for up to 20 seconds per reload. Neither polls the database: the server keeps oneLISTENconnection on thesetup_task_doneandsetup_task_progresschannels, which the applier’s writes notify with the task id, and wakes the requests waiting on that task. The connection is separate from the request pool and reconnects on its own with a growing delay; while it is down a wait returns at once rather than hanging, and the console page falls back to reloading every five seconds.The telemetry switch. The interview’s
SHARE_TELEMETRYquestion can only be answered yes while pepsi-telemetry-client(1) is running (dormant while telemetry is off) and[pepsi] SYSTEM_IDis configured, because this server never generates an identifier and a yes with no daemon to act on it would do nothing. It learns both from the daemon’s liveness row inpepsi.telemetry_client, which it may read and not write, and which carries a boolean for the identifier, never its value. Otherwise the checkbox is rendered disabled with directions (start the unit; add aSYSTEM_ID),GET /api/v1/setup/questionsmarks the question"disabled": truewith adisabled_reason, and a yes throughPUT /api/v1/setup/answersor awrite-configtask is refused with409 telemetry_client_not_ready.GET /api/v1/setup/telemetryreports the same state (sharing,can_enable,reason,client). Switching telemetry off is never refused. The applier’swrite-confignotifies the daemon after writing the file, in either direction.A reverse-proxy path allowlist has to include the
/ui/setup*and/ui/secure*families too: the first is the whole browser setup interview, the second the secure-link administration pages.The console ships no JavaScript and loads nothing from another host; every action is a form, every destructive one is confirmed, and every mutating form carries the session’s CSRF token as a hidden field (a mutating request whose
Originnames another host is refused outright). It performs no service control — there is no restart, shutdown or backup button, deliberately. See the manual’s “The administration console” chapter.- /3.0/…, /3.1/…
The GNU Mailman 3 REST API — upstream version 3.3.10, reimplemented so that software written for GNU Mailman 3 (
mailmanclient, Postorius, HyperKitty) works against Pepsi unchanged. Served only on a listener flaggedLIST_API = yes, under the same binding rule asADMIN(loopback plaintext, a UNIX socket, or TLS), and authenticated by HTTP Basic against the single[pepsi-list]API_USER/API_PASSpair. This is not/api/v1: different audience, different collection envelope, different error shape, no scopes. See the manual’s “The GNU Mailman 3 REST API” chapter.LIST_API_TESTING = yesadditionally armsGET /3.1/reserved/reset, which truncates every mailing-list and archive table. It exists for the compatibility test suites and must not be set on a deployment carrying real mail.- GET /favicon.ico
- GET /favicon-VERSION.svg
The Pepsi badge as the browser icon, served on every listener whatever its flags: an icon says nothing about the deployment. The console, its login page and the mailing-list pages link the SVG, whose name carries the start of its SHA-256; since a new icon is a new URL, it is sent
Cache-Control: public, max-age=31536000, immutable./favicon.ico(16, 32 and 48 px) is what a browser fetches by itself for a page that links no icon; its URL cannot change, so it is cacheable for a week. Both carry anETagand answer a matchingIf-None-Matchwith304. The files are compiled into the binary.- /lists, /lists/…, /archives/list/…, /robots.txt
The public mailing-list web interface: the list index, a list’s information page, the subscribe and unsubscribe forms, the confirmation pages, the RFC 8058 one-click unsubscribe target, the archive (on HyperKitty’s URL shapes, so a migrated deployment’s
Archived-At:links keep working) and its search. Served only on a listener flaggedLISTS = yes, to anybody, with no credential.This is the one flag whose deployment advice is the opposite of the other two.
ADMINis for the operator andLIST_APIcarries one shared password, so both refuse to be served where their credentials would cross a cleartext network; this surface exists to be reached from the open internet, so that restriction is deliberately not applied to it. A plaintextLISTSlistener is the operator’s choice and gets a log line rather than a refusal — but a subscribe form carries somebody’s address, so put it on a TLS listener.These pages ship no JavaScript, and their Content-Security-Policy has no
script-srcat all as a result./robots.txtallows the archive and disallows search, which is the one route that runs a full-text query per request; search is additionally rate-limited on its own budget, because a crawler that ignores the file still has to be survivable.Both budgets are requests per minute per source address (an IPv6 client per
/64), read from the[pepsi-list]section:- WEB_RATE_LIMIT
(integer, optional) Every route of this interface and of the member tier below. Default
600.- WEB_SEARCH_RATE_LIMIT
(integer, optional) The search route, on top of the above. Default
30.
A value below
1is raised to1: neither can be switched off. Behind a reverse proxy every request arrives over the UNIX socket with no client address, so all clients share one budget; raise the limits there and let the proxy limit per client.The same flag also serves the member tier —
/lists/register,/lists/verify/<token>,/lists/sign-in,/lists/sign-out,/lists/resetand/lists/me— and the owner and moderator console at/lists/<list-id>/admin.... Neither is a second flag: an account system nobody can reach is not a deployment choice anybody would make.Those pages use
pepsi.list_sessionand the cookiespepsi_list_session/pepsi_list_csrf, which are not the operator console’spepsi_session/pepsi_csrfand grant nothing on it — nor the reverse. Authority over a list is a roster query, not an account flag: alist_memberrow withrole = ownerormoderatorfor that list, pluslist_user.is_server_owneras the only global authority in the tier. A caller without it gets the ordinary not-found page, because on a public surface a refusal that distinguishes itself has disclosed that the thing exists. See the manual’s “Two account systems” section.
85.1.3.1.5. The administrative surface¶
A listener carrying ADMIN = yes additionally serves /api/v1, the /ui
console and /metrics. On every other listener those routes answer a plain
404, byte-for-byte what any unknown path gets, so publishing the public
listener does not publish administration and a public listener cannot be probed
for whether administration is enabled on this deployment.
/api/v1 and /ui additionally authenticate and authorise every request, as
below. /metrics does not: a scraper has no credential to present, so the
listener flag is all that stands in front of it.
pepsi-httpd refuses to serve them on a flagged listener that would carry
them 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) never qualifies without TLS, whatever its unit
binds — including a ListenStream= on loopback or on a UNIX path — because the
inherited descriptor’s address is not visible from here and treating it as
permitted would let the strictest case through the loosest check. Since the
shipped deployment is socket activated, administration wants a listener of its
own (a UNIX socket, or a TLS one). A listener that
fails the test logs a warning at start-up and serves the public endpoints only;
pepsi-setup(1) reports the same at configuration time.
Three authentication mechanisms resolve to one identity model, tried in this order — an explicitly presented credential always wins, so a deliberately narrow token is never silently widened:
Authorization: Bearer pepsi_<id>.<secret>— a row inpepsi.api_token. Only the digest of the secret half is stored.A session cookie from
POST /api/v1/auth/login— or from the console’s ownPOST /ui/loginform, which mints the same session — with a CSRF token required on every mutating request. Sessions have a 30-minute idle timeout and a 12-hour hard lifetime by default ([pepsi-admin] SESSION_IDLEandSESSION_LIFETIME).SO_PEERCREDon a UNIX-socket listener:root, or a member of[pepsi-admin] ADMIN_GROUP, is an administrator with no credential at all. This is what makes first-run bootstrap work and what the shipped configuration relies on.
PAM is deliberately not supported: it would put a privileged authentication path and system-account semantics inside the mail server’s web tier.
Authorisation is by scope, declared per endpoint (config:read,
config:write, keys:read, keys:write, peers:write,
queue:read, queue:write, logs:read, setup:write,
secure:read, secure:write, plus own:<address> for a principal
confined to one address). GET /api/v1/openapi.json is generated from the
server’s own route table and is the authoritative list for a given build.
secure:read and secure:write are separate because revoking a secure-link
message destroys the only copy of it; an irreversible deletion does not belong
behind a capability named “read”.
Generating or revoking key material is never done here: private material
belongs to the pepsi-crypto database role alone. Generating a server-managed
key (POST /api/v1/identities), registering a user’s own public key
(POST /api/v1/identities/client) and revoking an identity (PATCH
/api/v1/identities/{id} with {"status": "revoked"} and an optional
revocation_reason, which destroys a signing-only key’s private half) are
instead asked for, as generate-identity, register-client-key and
revoke-identity rows in pepsi.setup_task that pepsi-setup apply
re-validates and performs as pepsi-crypto (see below). A revoking
PATCH is therefore asynchronous: it carries status alone and answers
with the queued task; the identity reads revoked once the applier has run. Registering involves no private material, but a key registered as the
user’s own becomes their public face and verifies signatures as theirs, so a
compromised web tier must not be able to plant one. Producing a CSR and
importing an issued certificate are not offered over HTTP; use
pepsi-keys(1).
Self-service on one’s own keys. A principal confined to one address (own:<address>) may reach, for that
address only, the three key writes of the pEp-style self-service:
POST /api/v1/identities ({"address": ..., "vks": true|false}, vks
optional and defaulting to [pepsi-keys] VKS_PUBLISH),
POST /api/v1/identities/client ({"address": ..., "key": "<armoured OpenPGP
public key>"}, at most 64 KiB) and PATCH /api/v1/identities/{id} (whose
vks_wanted field, when true, records a key-server upload request
for an active OpenPGP identity and publishes it over WKD; the retry job
pepsi-keys identity publish --retry makes the upload; and whose
status: revoked asks for a revocation). Each handler re-checks
the address; another address’s identity answers 404. The two POST routes
and a revoking PATCH answer with the queued task ({"task": ..., "kind":
..., "applier_notified": ...}), not with the key, and the principal that asked
may follow that task: GET /api/v1/setup/tasks/ID answers any principal
for a task it queued itself (404 for anybody else’s, as for one that does
not exist; ownership is the task’s requester_key – the token’s selector, the
account’s row number or the peer’s login, none of them ever reused – not the
requested_by display name), and GET /api/v1/setup/tasks lists
only those tasks to a principal without setup:write. The console’s key forms
answer with the task’s page, /ui/setup/tasks/ID, on the same terms. DELETE /api/v1/identities/{id} stays operator-only
(keys:write): a user retires a key, they do not erase its row. The console
offers the same through the Generate a server-managed key and Register my own
key forms on the identities page and the Upload to the key server and
Revoke actions on an identity’s page.
The applier admits these three task kinds with its own authorisation gate: the
requesting principal must have held keys:write, or own:<address> for
exactly the address named in the task’s parameters (setup:write alone is not
enough). It then requires the address to be at a [pepsi-ingress]
ACCEPTED_DOMAINS domain and, for a registration, one OpenPGP certificate whose
User IDs name the address; for a revocation, the identity must belong to that
address. Without the applier all three answer 503
setup_applier_unavailable, like every other applier path; enqueuing also needs
the [pepsi-admin] CONFIG_DB connection (503 setup_write_unavailable
otherwise).
The second factor. Each of the three bodies (and the console’s forms) takes
an optional "otp": "123456", the address owner’s current second-factor code
(see pepsi-keys(1), otp). This server cannot check it – it holds no
grant on pepsi.otp_key at all – so it only passes the code on in the task’s
parameters, where the applier checks it for a task admitted on
own:<address> alone, before acting; a missing, wrong, replayed or locked
code fails the task with the reason. A principal holding keys:write is not
asked. DELETE /api/v1/otp/ADDRESS (keys:write, never reachable
under own:) queues a reset-otp task that removes the address’s second
factor, which is how a locked one is unlocked. The synchronous flag changes of
PATCH /api/v1/identities/{id} (is_primary, published,
vks_wanted) are performed here and so are not second-factor gated.
Two operations this server deliberately refuses: writing the configuration
overlay (503 unless
[pepsi-admin] CONFIG_DB names a connection authenticating as the
pepsi-config role, which the server verifies at start-up), and every
privileged setup request when the applier package is not installed (503, see
The privileged applier, and doing without it below).
The retention policy of the audit log and, when it is enabled, the mail log is
applied by this binary’s prune command, not by the server: every
mail-processing account may append to those tables and not erase from them, and
the pepsi-httpd role is the one service role that holds DELETE on them
(the only other holder is the operator’s pepsi-config role). The shipped
pepsi-log-prune.timer runs it daily as that account, so retention holds
whether or not the web server is running — it used to be a background task of
serve, started only when a listener served the administrative surface, and a
deployment without the console kept every record for ever.
Note
One binary serves the public and the administrative surface. A bug in the shared server process is therefore a bug in both; the isolation is the operator’s to configure — bind the administrative listener to a UNIX socket or to loopback.
- GET /secure/TOKEN
The secure-link fallback portal’s entry page. Without a session it asks for the PIN; with a valid session cookie it renders the message. Answers
404when the token is unknown (or the portal is not configured),410when the message has expired and429while the token is locked out. See the manual’s secure-link chapter and pepsi-stage-secure-link(1).- POST /secure/TOKEN
Verify the PIN. On success it sets the session cookie and redirects (
303) to the page above; on a wrong PIN it re-renders the form with401, and on the attempt that exhaustsMAX_ATTEMPTSit answers429and locks the token for a period that doubles with each further lockout. A submission carrying no PIN is answered401as well, but is not counted againstMAX_ATTEMPTS: it is not a guess, and a client that re-submits the form empty would otherwise spend a legitimate recipient’s attempts for them. The PIN is submitted in the request body and never appears in a URL.- GET /secure/TOKEN/part/N
Download attachment N of the message. Authorised by the session, never by the PIN. Always served
Content-Type: application/octet-streamwithContent-Disposition: attachmentand a sandboxingContent-Security-Policy, whatever media type the message claimed — atext/htmlpart rendered inline from the portal’s own origin is precisely what the design refuses.- POST /secure/TOKEN/reply
Compose a reply, as
multipart/form-data(atextfield and optionalfiles). Authorised by the session. The reply’s recipient and envelope sender are taken from the stored row and are not inputs; the body is alwaystext/plain; uploads are capped byMAX_REPLY_SIZE/MAX_REPLY_FILESwhile the body streams; and the injected message deliberately does not carrystate.local_origin, so it cannot inherit submission privileges. Answers404whenREPLY_STAGEis unset (replying is opt-in per deployment).Every portal response carries, per route rather than per host,
Content-Security-Policy: default-src 'none'(with a per-response nonce for the one inline stylesheet and no script source at all),Referrer-Policy: no-referrer,X-Content-Type-Options: nosniff,X-Frame-Options: DENYandCache-Control: no-store. The session cookie isPath=/secure/-scoped,HttpOnly,SecureandSameSite=Strict, so sharing an origin with the endpoints above is safe by construction rather than by deployment discipline.
85.1.3.1.6. The privileged applier, and doing without it¶
pepsi-httpd never performs a privileged change. It records an intent row in
pepsi.setup_task and connects to a doorbell socket; systemd’s socket
activation turns that connection into a short-lived root pepsi-setup apply,
which validates the row and does the work. The full trust model is in
pepsi-setup(1), The applier’s trust model.
That makes the capability removable, and Debian packages it separately:
pepsiThe mail server proper: the binaries — including pepsi-setup itself — the schema, the templates and every other unit (the separately packaged
pepsi-stage-detect-languageandpepsi-telemetryaside). A deployment administered from a terminal needs nothing else.pepsi-httpd-adminpepsi-setup-apply.socketandpepsi-setup-apply.service, and nothing else. It is what turns an HTTP request into a change under/etc/pepsi.pepsiRecommends it, so a default installation has it;apt remove pepsi-httpd-adminsucceeds without touchingpepsi.
With that package removed there is nothing to drain pepsi.setup_task. The
loop is broken at the mechanism, not at the button: an insert into that table
becomes inert, whatever happens to the web tier. (A source install gets the same
lever from make install INSTALL_ADMIN_UNITS=no.)
Inert, and it stays that way: putting the package back does not act on whatever
accumulated while it was gone. Its postinst runs pepsi-setup apply
--clear, which deletes every queued row without executing one, so arming the
applier is never also the event that runs a request nobody remembers making. See
pepsi-setup(1).
85.1.3.1.6.1. What the console does then¶
Read-only, and it says so. Every setting the setup interview asks about is still rendered, with its staged answer or its default; every control that would change one is disabled, under a banner naming the missing package. The pages are not hidden: an operator who deliberately removed the applier still has to be able to see what the deployment is configured to do.
The API refuses the corresponding mutations with a status and a code of their own rather than a generic failure:
HTTP/1.1 503 Service Unavailable
{"code": "setup_applier_unavailable",
"hint": "the administrative package 'pepsi-httpd-admin' is not installed …",
"detail": {"package": "pepsi-httpd-admin", "unit": "pepsi-setup-apply.socket"}}
It applies to POST /api/v1/setup/tasks, /setup/preflight,
/setup/dns-check, /setup/certificates, PUT /api/v1/setup/answers,
POST /api/v1/identities, POST /api/v1/identities/client, a revoking
PATCH /api/v1/identities/{id} and DELETE /api/v1/otp/{address}, and to
the console’s own form posts —
POST /ui/setup/{step}, /ui/setup/tasks,
/ui/identities/request/{generate,register}, /ui/identities/{id}/revoke
and /ui/domains/check, which is the “check now” button
on the domains page. Every one of them reaches the queue through a single
enqueuing function that asks first, so the refusal is one decision rather than
one per surface; the disabled control on the page is presentation over that
check, not a substitute for it. Answer staging is included deliberately: a
draft answer’s only purpose is to be applied, and a wizard that banks six steps
of them on a host where nothing can apply them is a silent no-op.
DELETE /api/v1/setup/answers is not gated — discarding drafts cannot
cause a privileged change, and a read-only console still has to be able to clear
a half-finished interview.
Note the distinct code. setup_write_unavailable (no CONFIG_DB
connection) and setup_applier_unavailable have different remedies, and the
applier is reported first when both apply: configuring a database role would not
have helped.
What is not affected is /api/v1/config and the console’s configuration
pages. Those write pepsi.config_override, the database overlay every
component reads at run time — not a file in /etc. Their only gate is
[pepsi-admin] CONFIG_DB, because the applier has nothing to do with
them and widening the read-only rule to cover them would remove a capability the
package split was not about. The two layers are described under Configuration
layering in the manual.
85.1.3.1.6.2. How it detects this¶
By looking at two files, which between them answer installed and armed:
pepsi-setup-apply.socketexists as a unit file in a directory systemd searches (/etc/systemd/system,/run/systemd/system,/usr/local/lib/systemd/system,/usr/lib/systemd/system,/lib/systemd/system); and[pepsi-admin] APPLY_SOCKETexists, is a socket, and is writable by this account — connecting to a UNIX socket needs write permission.
Two stats and an access, no privilege — which matters, because the
server has already dropped to the unprivileged pepsi-httpd account and
cannot ask dpkg or systemd anything. It deliberately does not connect:
connecting is the doorbell, and would start a root process on every page
render.
Neither test is redundant. Without the first, a stale socket node reads as a
live doorbell — and stale nodes happen: systemd’s RemoveOnStop= defaults to
off (the shipped unit sets it; a hand-edited one may not), and a package whose
files are deleted rather than stopped leaves the node behind outright. That is
exactly the case this feature exists for, and getting it wrong would fail open.
Without the second, a freshly installed package whose socket has never been
enabled would read as ready and every task would sit pending for ever.
The check fails closed. A path that cannot be examined, a path that is not a socket, a permission error — every uncertainty reads as “not available” and the console goes read-only. The recoverable failure is being told to install something that is already installed; the unrecoverable one is believing a change was made.
Two consequences:
A site that drains the queue some other way —
pepsi-setup apply --oncefrom cron, a hand-written unit, a host without systemd, unit files somewhere systemd does not search — has no doorbell (or no unit file) and reads as unavailable.[pepsi-admin] APPLIER = yesoverrides the probe. It is a promise the operator makes; nothing here can check it.The converse: the socket can be present while the service unit is masked or broken, in which case a task is enqueued and stays
pending. The task page shows that status rather than pretending otherwise. Both units ship in one package, so this is a hand-made state rather than a packaging outcome.
[pepsi-admin] APPLIER = no makes the console read-only by configuration
alone, for a deployment that cannot change its package set.
85.1.3.1.7. Configuration¶
The following are documented in pepsi.conf(5): the [pepsi-httpd]
section’s options (MAX_CONNECTIONS, MAX_CONNECTIONS_PER_IP,
DB_POOL_SIZE, the ADDIN /
ADDIN_URL pair that enables the Outlook add-in, and the optional
RESUME_AUTHORIZATION_TOKEN that guards POST /resume), the
[pepsi-httpd-listener-<name>] and [pepsi-httpd-cert-<name>] socket and
SNI-certificate sections (including the listener flags ADMIN, LIST_API,
LIST_API_TESTING and LISTS), the [pepsi-admin] section that configures the
administrative API, the [pepsi-list] section that holds the Mailman API’s
API_USER/API_PASS credential and API_RATE_LIMIT and the web
interface’s WEB_RATE_LIMIT/WEB_SEARCH_RATE_LIMIT, the
[pepsi-autoconfig] section the autoconfiguration document is built from, and
the [pepsi-secure-link] section that enables the portal.
85.1.3.1.8. Privilege dropping¶
To bind privileged ports (80/443) pepsi-httpd is often started as root.
It does not serve as root: once every configured listener has been bound (and any
TLS key material read), the process drops to the unprivileged service account
pepsi-httpd, dropping all supplementary groups and switching to that
account’s group and user id before accepting a single connection. The account
must therefore exist before the server is started as root; create it (for example
useradd --system --no-create-home --shell /usr/sbin/nologin pepsi-httpd) as
part of installation.
If the drop cannot be completed while running as root — most often because the
pepsi-httpd account does not exist — the server logs an error and exits
without serving, rather than risk running as root. When started by a non-root
user no privilege change is performed (the server simply runs as the invoking
user); it will never run as root.
85.1.3.1.9. TLS key material¶
The certificate and private key of every TLS listener — its own
TLS_CERT/TLS_KEY and each [pepsi-httpd-cert-*] SNI certificate — are
read once, at start-up, before the privilege drop described above. Where
they are read from depends on how the server was started.
Under systemd (the packaged deployment). The unit runs as the unprivileged
pepsi-httpd user from the outset — it never holds root — and so cannot open
certbot’s root-only /etc/letsencrypt/{live,archive}. It does not have to:
pepsi-setup(1) writes a drop-in
/etc/systemd/system/pepsi-httpd.service.d/10-tls-credentials.conf with one
LoadCredential= entry per file. systemd opens those files as root when it
starts the unit and passes private copies to the service under
$CREDENTIALS_DIRECTORY — a per-unit tmpfs directory readable by this one
service and invisible to every other unit — where pepsi-httpd looks each
configured path up before falling back to reading the path itself. The
configuration is untouched by this: TLS_CERT/TLS_KEY still name the real
files, and the credential names are derived from those paths.
Two consequences:
Re-run
pepsi-setup runafter adding, moving or removing a certificate. The drop-in is regenerated from the configuration and deliberately lists only files that exist, because systemd refuses to start a unit whose credential source is missing.A credential is materialised when the unit starts, so a renewed certificate reaches clients only after a restart. The certbot deploy hook pepsi-setup installs performs it (
systemctl try-restart).
Started directly, without systemd. The server is started as root, reads
the files itself and only then drops privileges, so no credential is involved and
the file permissions never matter.
A certificate that cannot be loaded is reported and skipped, not fatal: the SNI host names it would have served stop being available over HTTPS while every other certificate keeps working. Only a TLS listener left without a single usable certificate makes the server exit. One unreadable file therefore cannot take the MTA-STS policy of unrelated domains — or the metrics endpoint — down with it.
85.1.3.1.10. Commands¶
- serve
Run the server until interrupted. Requires that the schema has been installed with pepsi-setup(1).
- prune
Delete audit records older than
[pepsi-admin] EVENT_RETENTION_DAYS, mail-log records older thanMAIL_LOG_RETENTION_DAYSand expired administrative sessions, then exit. Run as root, it first drops to thepepsi-httpdaccount, whose database role holds theDELETEgrant.pepsi-log-prune.timerruns it daily; on a system without systemd, run it from cron.
85.1.3.1.11. Global Options¶
- -c FILE, –config FILE
Read the configuration from FILE instead of searching the default locations.
- -L LOGLEVEL, –log LOGLEVEL
Set the logging verbosity (default
info).- -v, –verbose
Show log messages from all sources.
- -h, –help; -V, –version
Print a usage summary / the version and exit.
85.1.3.1.12. Signals¶
- SIGINT
Initiate shutdown and exit cleanly.
- SIGTERM
Not caught: the default disposition ends the process at once, cutting any request in flight. Nothing is lost by that — the queue is in PostgreSQL and this server keeps no state of its own — and it is how
systemctl stopends the unit.
85.1.3.1.13. Exit Status¶
- 0
Clean shutdown.
- 1
An error occurred (for example a malformed configuration file, an unreadable certificate, or a failed database connection). The reason is written to the log.
85.1.3.1.14. Examples¶
Serve over HTTPS:
pepsi-httpd -c /etc/pepsi/pepsi.conf serve
Fetch a policy for testing (resolving the SNI host to the server):
curl --resolve mta-sts.example.org:443:203.0.113.7 \
https://mta-sts.example.org/.well-known/mta-sts.txt
Check that a user’s key is published, the way a correspondent’s client would:
gpg --locate-external-key alice@example.org
85.1.3.1.15. See Also¶
pepsi-config(1), pepsi.conf(5), pepsi-dispatch(1), pepsi-keys(1), pepsi-setup(1)
85.1.3.1.16. Bugs¶
Report bugs to the Pepsi issue tracker.